package main

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
)

// apiError carries the server's error message plus the HTTP status, so callers
// can distinguish auth failures (401) from other errors without losing the
// server's message (e.g. "invalid credentials" vs "invalid or expired token").
type apiError struct {
	status int
	msg    string
}

func (e *apiError) Error() string { return e.msg }

// isAuthError reports whether err is a 401 from the API.
func isAuthError(err error) bool {
	var ae *apiError
	return errors.As(err, &ae) && ae.status == http.StatusUnauthorized
}

// Client talks to the Tinyforge HTTP API. It has no global timeout so that
// long synchronous deploys and follow streams work; callers pass a context
// with the appropriate deadline.
type Client struct {
	baseURL string
	token   string
	http    *http.Client
}

func newClient(baseURL, token string) *Client {
	return &Client{
		baseURL: strings.TrimRight(baseURL, "/"),
		token:   token,
		http:    &http.Client{},
	}
}

// apiEnvelope mirrors the server's response wrapper. The server's struct is
// unexported, so the CLI defines its own matching shape. Data is deferred so a
// single decode path serves every endpoint.
type apiEnvelope struct {
	Success bool            `json:"success"`
	Data    json.RawMessage `json:"data"`
	Error   string          `json:"error"`
}

// SessionToken is the data payload of POST /api/auth/login.
type SessionToken struct {
	Token     string `json:"token"`
	ExpiresAt string `json:"expires_at"`
}

// User is the data payload of GET /api/auth/me.
type User struct {
	ID       string `json:"id"`
	Username string `json:"username"`
	Email    string `json:"email"`
	Role     string `json:"role"`
}

// Workload is the subset of the workload row the CLI needs. An "app" is a
// workload with a non-empty SourceKind.
type Workload struct {
	ID         string `json:"id"`
	Name       string `json:"name"`
	Kind       string `json:"kind"`
	AppID      string `json:"app_id"`
	SourceKind string `json:"source_kind"`
	CreatedAt  string `json:"created_at"`
}

func (w Workload) isApp() bool { return w.SourceKind != "" }

// Container is the subset of a container row the CLI needs. State is one of
// running|stopped|failed|missing|starting|created|restarting|paused|...
type Container struct {
	ID          string `json:"id"`
	WorkloadID  string `json:"workload_id"`
	Role        string `json:"role"`
	ContainerID string `json:"container_id"`
	ImageRef    string `json:"image_ref"`
	State       string `json:"state"`
	Port        int    `json:"port"`
	Subdomain   string `json:"subdomain"`
	CreatedAt   string `json:"created_at"`
}

// DeployResult is the data payload of POST /api/workloads/{id}/deploy.
type DeployResult struct {
	WorkloadID  string `json:"workload_id"`
	Reference   string `json:"reference"`
	TriggeredBy string `json:"triggered_by"`
}

// doJSON performs a JSON request and unwraps the response envelope. body may be
// nil. out may be nil when the caller does not need the data payload. A 401
// maps to errNotAuthenticated; any other non-success surfaces the server's
// error message.
func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) error {
	var reqBody io.Reader
	if body != nil {
		buf, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("encode request: %w", err)
		}
		reqBody = bytes.NewReader(buf)
	}

	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
	if err != nil {
		return fmt.Errorf("build request: %w", err)
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	c.authorize(req)

	resp, err := c.http.Do(req)
	if err != nil {
		return fmt.Errorf("%s %s: %w", method, path, err)
	}
	defer resp.Body.Close()

	raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
	if err != nil {
		return fmt.Errorf("read response: %w", err)
	}

	var env apiEnvelope
	if jsonErr := json.Unmarshal(raw, &env); jsonErr != nil {
		// Non-JSON body (e.g. a proxy error page). Surface status + a snippet,
		// preserving auth-error typing for 401s with an unparseable body.
		if resp.StatusCode >= 400 {
			return &apiError{status: resp.StatusCode, msg: fmt.Sprintf(
				"%s %s: unexpected response (status %d): %s", method, path, resp.StatusCode, snippet(raw))}
		}
		return fmt.Errorf("%s %s: decode response: %w", method, path, jsonErr)
	}
	if resp.StatusCode >= 400 || !env.Success {
		msg := env.Error
		if msg == "" {
			msg = fmt.Sprintf("%s %s: request failed (status %d)", method, path, resp.StatusCode)
		}
		return &apiError{status: resp.StatusCode, msg: msg}
	}
	if out != nil && len(env.Data) > 0 {
		if err := json.Unmarshal(env.Data, out); err != nil {
			return fmt.Errorf("decode response data: %w", err)
		}
	}
	return nil
}

// authorize attaches the bearer token. Using the Authorization header (rather
// than a ?token= query param) keeps the JWT out of server and proxy logs.
func (c *Client) authorize(req *http.Request) {
	if c.token != "" {
		req.Header.Set("Authorization", "Bearer "+c.token)
	}
}

// streamSSE opens an SSE stream and invokes onData for each `data:` payload.
// Comment lines (heartbeats, beginning with ':') and blanks are skipped. The
// stream ends on EOF, context cancellation, or when onData returns an error.
func (c *Client) streamSSE(ctx context.Context, path string, onData func(payload []byte) error) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
	if err != nil {
		return fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("Accept", "text/event-stream")
	c.authorize(req)

	resp, err := c.http.Do(req)
	if err != nil {
		return fmt.Errorf("GET %s: %w", path, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
		var env apiEnvelope
		msg := fmt.Sprintf("GET %s: stream failed (status %d)", path, resp.StatusCode)
		if json.Unmarshal(raw, &env) == nil && env.Error != "" {
			msg = env.Error
		}
		return &apiError{status: resp.StatusCode, msg: msg}
	}

	scanner := bufio.NewScanner(resp.Body)
	scanner.Buffer(make([]byte, 0, 64<<10), 2<<20) // tolerate long log lines
	for scanner.Scan() {
		line := scanner.Text()
		if line == "" || strings.HasPrefix(line, ":") {
			continue // blank separator or SSE comment/heartbeat
		}
		data, ok := strings.CutPrefix(line, "data:")
		if !ok {
			continue // ignore event:/id: fields — the API uses default events
		}
		if err := onData([]byte(strings.TrimPrefix(data, " "))); err != nil {
			return err
		}
	}
	if err := scanner.Err(); err != nil && !errors.Is(err, context.Canceled) {
		return fmt.Errorf("read stream: %w", err)
	}
	return nil
}

// snippet returns a short, single-line view of an unexpected response body.
func snippet(b []byte) string {
	const max = 200
	s := strings.TrimSpace(string(b))
	s = strings.ReplaceAll(s, "\n", " ")
	if len(s) > max {
		s = s[:max] + "…"
	}
	if s == "" {
		s = "(empty body)"
	}
	return s
}
