// Package api is the CLI's HTTP client for the public Warmbly REST API.
//
// It exists so every command speaks to the API the same way: one place that
// knows the /v1 prefix, the bearer header, the idempotency header, the error
// envelope and how to walk a cursor. Nothing here is Warmbly-specific beyond
// those; the typed commands are a table on top of it.
package api

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
)

// Client is one host's API surface.
type Client struct {
	BaseURL   string
	Token     string
	UserAgent string
	HTTP      *http.Client
	// Debug prints the request line to stderr, which is the first thing
	// anyone wants when a call goes somewhere unexpected.
	Debug io.Writer
}

func New(baseURL, token, userAgent string) *Client {
	return &Client{
		BaseURL:   strings.TrimRight(baseURL, "/"),
		Token:     token,
		UserAgent: userAgent,
		HTTP:      &http.Client{Timeout: 120 * time.Second},
	}
}

// Error is a non-2xx response. Every field comes from the API's stable
// envelope, so a script can branch on Code without reading the prose.
type Error struct {
	Status     int
	Code       string
	Message    string
	RequestID  string
	RetryAfter string
	Method     string
	Path       string
	Body       string
}

func (e *Error) Error() string {
	msg := e.Message
	if msg == "" {
		msg = strings.TrimSpace(e.Body)
	}
	if msg == "" {
		msg = http.StatusText(e.Status)
	}
	out := fmt.Sprintf("%s %s failed (HTTP %d", e.Method, e.Path, e.Status)
	if e.Code != "" {
		out += " " + e.Code
	}
	out += "): " + msg
	if e.RequestID != "" {
		out += " (request " + e.RequestID + ")"
	}
	if e.Status == http.StatusTooManyRequests && e.RetryAfter != "" {
		out += ". Rate limited; retry after " + e.RetryAfter + "s."
	}
	return out
}

// IsNotFound and IsUnauthorized are what command code branches on.
func (e *Error) IsNotFound() bool     { return e.Status == http.StatusNotFound }
func (e *Error) IsUnauthorized() bool { return e.Status == http.StatusUnauthorized }

// StatusOf returns the HTTP status of an API error, or 0.
func StatusOf(err error) int {
	var apiErr *Error
	if errors.As(err, &apiErr) {
		return apiErr.Status
	}
	return 0
}

// Request is one call. Path is relative to /v1 unless it already names a
// version, which is what makes `warmbly api get /campaigns` work.
type Request struct {
	Method  string
	Path    string
	Query   url.Values
	Body    []byte
	Headers map[string]string
	// IdempotencyKey rides the documented header, for retryable writes.
	IdempotencyKey string
	// Anonymous skips the bearer header. Only the sign-in handshake uses it:
	// the CLI has no credential yet, which is the whole point of the flow.
	Anonymous bool
}

// Response is a completed call. Body is the raw payload: JSON for every
// documented endpoint, but a few stream files, so it is not parsed here.
type Response struct {
	Status  int
	Header  http.Header
	Body    []byte
	Request *Request
}

// NormalizePath applies the /v1 rule. Exported because `warmbly api` prints
// the path it is about to call.
func NormalizePath(path string) string {
	if !strings.HasPrefix(path, "/") {
		path = "/" + path
	}
	if strings.HasPrefix(path, "/v") && len(path) > 2 && path[2] >= '0' && path[2] <= '9' {
		return path
	}
	return "/v1" + path
}

func (c *Client) Do(ctx context.Context, req Request) (*Response, error) {
	if c.Token == "" && !req.Anonymous {
		return nil, errors.New("no API token. Run `warmbly auth login`, or set WARMBLY_TOKEN.")
	}
	path := NormalizePath(req.Path)
	full := c.BaseURL + path
	if len(req.Query) > 0 {
		full += "?" + req.Query.Encode()
	}

	var reader io.Reader
	if len(req.Body) > 0 {
		reader = bytes.NewReader(req.Body)
	}
	httpReq, err := http.NewRequestWithContext(ctx, strings.ToUpper(req.Method), full, reader)
	if err != nil {
		return nil, err
	}
	if !req.Anonymous {
		httpReq.Header.Set("Authorization", "Bearer "+c.Token)
	}
	httpReq.Header.Set("Accept", "application/json")
	if c.UserAgent != "" {
		httpReq.Header.Set("User-Agent", c.UserAgent)
	}
	if len(req.Body) > 0 && httpReq.Header.Get("Content-Type") == "" {
		httpReq.Header.Set("Content-Type", "application/json")
	}
	if req.IdempotencyKey != "" {
		httpReq.Header.Set("Idempotency-Key", req.IdempotencyKey)
	}
	for k, v := range req.Headers {
		httpReq.Header.Set(k, v)
	}

	if c.Debug != nil {
		fmt.Fprintf(c.Debug, "* %s %s\n", httpReq.Method, full)
	}

	resp, err := c.HTTP.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("could not reach the API at %s: %w\nCheck the host with `warmbly auth status`, or set WARMBLY_API_URL.", c.BaseURL, err)
	}
	defer resp.Body.Close()

	payload, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
	if err != nil {
		return nil, fmt.Errorf("reading the API response: %w", err)
	}
	if c.Debug != nil {
		fmt.Fprintf(c.Debug, "* HTTP %d (%d bytes)\n", resp.StatusCode, len(payload))
	}

	out := &Response{Status: resp.StatusCode, Header: resp.Header, Body: payload, Request: &req}
	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
		return out, nil
	}

	apiErr := &Error{
		Status:     resp.StatusCode,
		Method:     strings.ToUpper(req.Method),
		Path:       path,
		Body:       string(payload),
		RetryAfter: resp.Header.Get("Retry-After"),
	}
	var envelope struct {
		Error     string `json:"error"`
		Message   string `json:"message"`
		Code      string `json:"code"`
		RequestID string `json:"request_id"`
	}
	if json.Unmarshal(payload, &envelope) == nil {
		apiErr.Code = envelope.Code
		apiErr.RequestID = envelope.RequestID
		apiErr.Message = envelope.Message
		if apiErr.Message == "" {
			apiErr.Message = envelope.Error
		}
	}
	return out, apiErr
}

// JSON runs a request and decodes the body into v.
func (c *Client) JSON(ctx context.Context, req Request, v any) error {
	resp, err := c.Do(ctx, req)
	if err != nil {
		return err
	}
	if v == nil || len(bytes.TrimSpace(resp.Body)) == 0 {
		return nil
	}
	if err := json.Unmarshal(resp.Body, v); err != nil {
		return fmt.Errorf("the API returned something that is not JSON: %w", err)
	}
	return nil
}

// paginateRetries is how many rate-limited pages one walk will wait out
// before giving up. Three covers a walk that crosses a minute boundary or two;
// beyond that the budget is the problem, not the timing.
const paginateRetries = 3

// listEnvelope is the documented list shape: data plus pagination.
type listEnvelope struct {
	Data       json.RawMessage `json:"data"`
	Pagination struct {
		NextCursor *string `json:"next_cursor"`
		HasMore    bool    `json:"has_more"`
	} `json:"pagination"`
}

// Paginate walks every page of a list endpoint and returns one merged
// envelope: data holds every row, pagination reports no more pages. Endpoints
// that do not use the cursor envelope come back unchanged after one call.
func (c *Client) Paginate(ctx context.Context, req Request, maxPages int) ([]byte, error) {
	if maxPages <= 0 {
		maxPages = 100
	}
	var merged []json.RawMessage
	// The cursor the walk stopped on, empty when it reached the end. It is
	// what tells the caller a --max-pages cut the list short rather than the
	// data running out.
	nextCursor := ""
	query := url.Values{}
	for k, v := range req.Query {
		query[k] = v
	}

	// Retries are budgeted across the whole walk, not per page: a limit that
	// never clears has to end as an error rather than looping until maxPages.
	retriesLeft := paginateRetries

	for page := 0; page < maxPages; page++ {
		req.Query = query
		resp, err := c.Do(ctx, req)
		if err != nil {
			// A long walk will meet the per-key minute budget. The response
			// says how long to wait, so waiting is strictly better than
			// handing back a partial list the caller cannot tell from a
			// complete one.
			wait, ok := retryAfter(err)
			if !ok || retriesLeft == 0 {
				return nil, err
			}
			retriesLeft--
			select {
			case <-ctx.Done():
				return nil, ctx.Err()
			case <-time.After(wait):
			}
			page--
			continue
		}
		var env listEnvelope
		if jerr := json.Unmarshal(resp.Body, &env); jerr != nil || env.Data == nil {
			// Not a cursor list. One page is the whole answer.
			if page == 0 {
				return resp.Body, nil
			}
			break
		}
		var rows []json.RawMessage
		if err := json.Unmarshal(env.Data, &rows); err != nil {
			if page == 0 {
				return resp.Body, nil
			}
			break
		}
		merged = append(merged, rows...)
		if !env.Pagination.HasMore || env.Pagination.NextCursor == nil || *env.Pagination.NextCursor == "" {
			nextCursor = ""
			break
		}
		nextCursor = *env.Pagination.NextCursor
		query = cloneValues(query)
		query.Set("cursor", *env.Pagination.NextCursor)
	}

	envelope := struct {
		Data       []json.RawMessage `json:"data"`
		Pagination struct {
			Total      int     `json:"total"`
			NextCursor *string `json:"next_cursor"`
			HasMore    bool    `json:"has_more"`
		} `json:"pagination"`
	}{Data: merged}
	if envelope.Data == nil {
		envelope.Data = []json.RawMessage{}
	}
	envelope.Pagination.Total = len(merged)
	if nextCursor != "" {
		envelope.Pagination.HasMore = true
		envelope.Pagination.NextCursor = &nextCursor
	}
	return json.Marshal(envelope)
}

// retryAfter reports how long a rate-limited response asked the caller to
// wait. The wait is capped so a hostile or misconfigured Retry-After cannot
// park the CLI for an hour.
func retryAfter(err error) (time.Duration, bool) {
	var apiErr *Error
	if !errors.As(err, &apiErr) || apiErr.Status != http.StatusTooManyRequests {
		return 0, false
	}
	seconds, perr := strconv.Atoi(strings.TrimSpace(apiErr.RetryAfter))
	if perr != nil || seconds <= 0 {
		seconds = 5
	}
	if seconds > 120 {
		seconds = 120
	}
	return time.Duration(seconds) * time.Second, true
}

func cloneValues(in url.Values) url.Values {
	out := url.Values{}
	for k, v := range in {
		out[k] = append([]string(nil), v...)
	}
	return out
}
