// Package spicy is a copyable SpicyAPI reference client for Go 1.21+.
//
// It is repository example code, not a published Go module. It depends on the
// standard library only; copy the file into a server-side package, rename the
// package clause if you like, and delete what you do not call.
//
// What it covers is the asynchronous generation pipeline: find a model and its
// input schema, quote it, create a task, poll that task to a terminal state,
// upload reference media, refresh an expired artefact link, and verify the
// webhook that announces a result. Text models are reached through the
// OpenAI, Anthropic and Gemini compatible layers instead, where the official
// libraries work by changing only the base URL.
//
// Two things decide most of the code below:
//
//   - Every /api/v1 response is the {code,msg,data,request_id} envelope, and a
//     successful HTTP status does not mean the call succeeded. Branch on the
//     business code, never on the message text.
//   - HTTP 202 from task creation means accepted, not finished. Success or
//     failure is reported by the task's state, read back with GetTask.
//
// The contract this file is written against is published at
// https://docs.spicyapi.ai/openapi.yaml.
package spicy

import (
	"bytes"
	"context"
	"crypto/hmac"
	cryptorand "crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/rand"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

// Defaults for a new Client. Every one of them can be overridden in Options.
const (
	// APIBaseURL is the only production API root.
	APIBaseURL = "https://api.spicyapi.ai/api/v1"

	// DefaultRequestTimeout bounds one JSON API attempt, not the whole call.
	// Give the context its own deadline to bound a call including retries.
	DefaultRequestTimeout = 30 * time.Second

	// DefaultUploadTimeout bounds the PUT that streams bytes into object
	// storage. It is deliberately far longer than DefaultRequestTimeout:
	// audio and video may be 90 MiB, and 30 seconds would demand a sustained
	// 25 Mbit/s upstream from the caller. A connection cut halfway through
	// reports a timeout, which reads like a network fault but is this client
	// hanging up on itself.
	DefaultUploadTimeout = 10 * time.Minute

	// DefaultWaitTimeout bounds WaitForTerminal. It is a local safety bound,
	// not a service level: reaching it says nothing about the remote task.
	DefaultWaitTimeout = 10 * time.Minute

	// DefaultPollInterval is the first gap between task polls; the gap grows
	// by half on every pass and stops at MaxPollInterval.
	DefaultPollInterval = 2 * time.Second
	// MaxPollInterval caps the growth of the polling gap.
	MaxPollInterval = 15 * time.Second

	// DefaultMaxRetries is how many extra attempts a replayable request gets.
	DefaultMaxRetries = 3

	// WebhookTolerance is the default clock skew VerifyWebhook accepts.
	WebhookTolerance = 5 * time.Minute

	baseRetryDelay   = 500 * time.Millisecond
	maxRetryDelay    = 8 * time.Second
	maxResponseBytes = 8 << 20
)

// Task states. The two in-flight states are the stable part of this set:
// treat anything else as terminal rather than listing the terminal values,
// so that a state added later ends a wait instead of extending it forever.
const (
	StateQueued    = "queued"
	StateRunning   = "running"
	StateSucceeded = "succeeded"
	StateFailed    = "failed"
	StateCanceled  = "canceled"
	StateExpired   = "expired"
)

// Failure identifiers reported on a terminal task. The set is closed; handle
// any value you do not recognise the way you handle ErrorCodeUpstreamFailed.
const (
	ErrorCodeInvalidRequest         = "invalid_request"
	ErrorCodeUnsupportedCombination = "unsupported_combination"
	ErrorCodeContentRejected        = "content_rejected"
	ErrorCodeRateLimited            = "rate_limited"
	ErrorCodeUpstreamUnavailable    = "upstream_unavailable"
	ErrorCodeGenerationFailed       = "generation_failed"
	ErrorCodeTimeout                = "timeout"
	ErrorCodeInvalidAsset           = "invalid_asset"
	ErrorCodeUpstreamFailed         = "upstream_failed"
)

// Business codes worth naming. They travel in the envelope's code field and
// are the thing to branch on: several of them share one HTTP status.
//
//	40003  the bytes that were uploaded do not match the ticket that was
//	       issued for them (size, media type or signature). Not retryable:
//	       request a fresh ticket with the real content type and size and
//	       upload again.
//	40004  the request is valid but no deployment serves this exact
//	       combination of settings right now. Not retryable: change the
//	       parameter named in the message, or pick another model. Waiting
//	       does not help, and nothing was created or held.
//	40901  the quote expired, or the request or price changed since it was
//	       signed. Quote again, then resubmit.
//	503    a supporting dependency is briefly unavailable. Retryable; honour
//	       Retry-After when the response carries it.
//	50301  the model itself has no usable deployment or effective price.
//	       Retryable, but repetition will not fix an unlisted model.
//	50302  a synchronous generation failed at the model and the hold was
//	       released, so nothing was charged. The same request body is safe to
//	       submit again — under a new idempotency key, because the old key is
//	       already spent on the attempt that failed. This client therefore
//	       does not resend it for you.
//
// 503, 50301 and 50302 share HTTP 503 and mean three different things, which
// is the reason this client reads the business code first and the status
// second.
const (
	CodeSuccess                = 200
	CodeUploadMismatch         = 40003
	CodeUnsupportedCombination = 40004
	CodeInsufficientBalance    = 40201
	CodeSpendCapReached        = 40202
	CodeQuoteRejected          = 40901
	CodeDependencyUnavailable  = 503
	CodeModelUnavailable       = 50301
	CodeGenerationRefunded     = 50302
)

// retryableHTTP holds the statuses worth another attempt when the response
// body is not our envelope at all, for example an edge network error page.
var retryableHTTP = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true}

// retryableCode holds the business codes worth another attempt with the exact
// same bytes. CodeGenerationRefunded is deliberately absent: it needs a new
// idempotency key, so resending it here would be a different request.
var retryableCode = map[int]bool{
	429:                       true,
	500:                       true,
	CodeDependencyUnavailable: true,
	CodeModelUnavailable:      true,
}

// envelope is the shape every /api/v1 endpoint returns. Data stays raw so each
// call site decodes it into whatever it actually expects.
type envelope struct {
	Code      int             `json:"code"`
	Msg       string          `json:"msg"`
	Data      json.RawMessage `json:"data"`
	RequestID string          `json:"request_id"`
}

// APIError is a rejected call: an HTTP status, the business code to branch on,
// and the request ID to quote to support. Recover it with errors.As.
type APIError struct {
	// Status is the HTTP status. It is 0 when no response was parsed.
	Status int
	// Code is the business code from the envelope, 0 when the body was not an
	// envelope. Branch on this, not on Message.
	Code int
	// Message is the server's explanation, in English unless the account or
	// the Accept-Language header selects another language. Never parse it.
	Message string
	// RequestID correlates this call with our logs.
	RequestID string
	// RetryAfter is the server's own backoff request, 0 when absent.
	RetryAfter time.Duration
}

func (e *APIError) Error() string {
	parts := []string{fmt.Sprintf("spicy: %s", e.Message)}
	if e.Code != 0 {
		parts = append(parts, fmt.Sprintf("code %d", e.Code))
	}
	if e.Status != 0 {
		parts = append(parts, fmt.Sprintf("HTTP %d", e.Status))
	}
	if e.RequestID != "" {
		parts = append(parts, fmt.Sprintf("request_id %s", e.RequestID))
	}
	return strings.Join(parts, ", ")
}

// Retryable reports whether sending the identical request again is worth it.
func (e *APIError) Retryable() bool {
	if e.Code != 0 {
		return retryableCode[e.Code]
	}
	return retryableHTTP[e.Status]
}

// Refunded reports code 50302: generation failed at the model and the hold was
// released, so this cost nothing. Submit the same input again under a new
// idempotency key.
func (e *APIError) Refunded() bool { return e.Code == CodeGenerationRefunded }

// WaitTimeoutError means a local polling deadline elapsed. It is not evidence
// that the task failed: the generation is still running somewhere, so store
// TaskID and reconcile it later instead of submitting the work twice.
type WaitTimeoutError struct {
	TaskID    string
	Timeout   time.Duration
	LastState string
}

func (e *WaitTimeoutError) Error() string {
	return fmt.Sprintf(
		"spicy: task %s did not finish within the local %s deadline (last state %q); its remote state is unknown",
		e.TaskID, e.Timeout, e.LastState,
	)
}

// Unwrap lets errors.Is(err, context.DeadlineExceeded) recognise this.
func (e *WaitTimeoutError) Unwrap() error { return context.DeadlineExceeded }

// Webhook rejection reasons. Every one of them wraps ErrWebhookRejected, so a
// handler that only wants to answer 401 can test for that alone.
var (
	ErrWebhookRejected  = errors.New("spicy: webhook rejected")
	ErrWebhookStale     = fmt.Errorf("%w: timestamp outside the tolerance window", ErrWebhookRejected)
	ErrWebhookSignature = fmt.Errorf("%w: signature mismatch", ErrWebhookRejected)
)

// ModelPrice is one price tier of a model.
type ModelPrice struct {
	// Variant identifies the tier, for example "720p" or
	// "duration=5;resolution=720p". Empty when the model has a single price.
	Variant string `json:"variant"`
	// Unit is per_image, per_second, per_request or per_1k_tokens.
	Unit string `json:"unit"`
	// Price is a decimal USD string. Keep it a string end to end; binary
	// floating point cannot hold these amounts exactly.
	Price string `json:"price"`
	// RegularPrice is the undiscounted price, present only while an offer runs.
	RegularPrice string     `json:"regularPrice,omitempty"`
	OfferLabel   string     `json:"offerLabel,omitempty"`
	OfferPercent string     `json:"offerPercent,omitempty"`
	OfferEndsAt  *time.Time `json:"offerEndsAt,omitempty"`
	Currency     string     `json:"currency"`
}

// ModelExample is an input that validates against the model's current schema.
type ModelExample struct {
	ID         string         `json:"id"`
	Input      map[string]any `json:"input"`
	SortWeight int            `json:"sortWeight"`
}

// Model is one callable endpoint from the authenticated catalogue. The typed
// fields are the ones an integration usually reads; the response carries more.
type Model struct {
	// Model is the exact identifier to pass as CreateTaskInput.Model.
	Model  string `json:"model"`
	Family string `json:"family"`
	// DisplayName is for your UI, not for calls.
	DisplayName string `json:"displayName"`
	// Provider is the model's creator, the lab that built it.
	Provider string `json:"provider"`
	// Modality is image, video, audio or text.
	Modality string `json:"modality"`
	// Tasks holds exactly one internal classification and is not always the
	// suffix of Model: editing endpoints publish as .../edit and classify as
	// image-to-image. Call with Model; group with Tasks.
	Tasks []string `json:"tasks"`
	Async bool     `json:"async"`
	// Mature and PolicyTier are informational metadata about the model. They
	// take no part in authorization, availability or rejection.
	Mature     bool   `json:"mature"`
	PolicyTier string `json:"policyTier"`
	// TaskTimeoutSeconds is the platform execution deadline, not the duration
	// of the media that comes out.
	TaskTimeoutSeconds int `json:"taskTimeoutSeconds"`
	// Enabled and Available both have to be true before a model can be called.
	Enabled   bool `json:"enabled"`
	Available bool `json:"available"`
	// QuantityField names the input field that multiplies the price.
	QuantityField string       `json:"quantityField"`
	Pricing       []ModelPrice `json:"pricing"`
	StartingPrice *ModelPrice  `json:"startingPrice,omitempty"`
	// InputSchema is JSON Schema 2020-12 and stays raw on purpose: it is what
	// validates your input and what a form generator renders, and both jobs
	// want the document itself. Request it with ListModelsOptions.IncludeSchema
	// or read it from GetModel, which always includes it.
	InputSchema json.RawMessage `json:"inputSchema,omitempty"`
	// Version changes whenever the schema or metadata changes; cache on it.
	Version       string         `json:"version"`
	Availability  string         `json:"availability"`
	Badges        []string       `json:"badges"`
	RelatedModels []string       `json:"relatedModels"`
	Examples      []ModelExample `json:"examples,omitempty"`
	UpdatedAt     time.Time      `json:"updatedAt"`
}

// ModelList is the ListModels response.
type ModelList struct {
	Total int     `json:"total"`
	Items []Model `json:"items"`
}

// Quote is a signed price for one exact request. It reserves nothing and
// guarantees no capacity; it only fixes the price for five minutes.
type Quote struct {
	// QuoteID goes back to CreateTask together with ExpectedCost.
	QuoteID string `json:"quoteId"`
	Model   string `json:"model"`
	// EstimatedCost is what the request is expected to cost, MaxCharge the
	// ceiling. Both are decimal USD strings.
	EstimatedCost string `json:"estimatedCost"`
	MaxCharge     string `json:"maxCharge"`
	Currency      string `json:"currency"`
	// Quantity and Unit explain how the amount was reached.
	Quantity  string    `json:"quantity"`
	Unit      string    `json:"unit"`
	ExpiresAt time.Time `json:"expiresAt"`
}

// CreateTaskInput is the body shared by Quote and CreateTask.
type CreateTaskInput struct {
	// Model is an exact identifier from the catalogue.
	Model string `json:"model"`
	// Input is validated against that model's input schema. Media fields take
	// a public HTTPS URL, a committed spicy:// URI, or a small Base64 data URI.
	Input map[string]any `json:"input"`
	// CallBackURL is an optional public HTTPS endpoint for terminal delivery.
	// Verify every delivery with VerifyWebhook before acting on it.
	CallBackURL string `json:"callBackUrl,omitempty"`
	// QuoteID and ExpectedCost together confirm a price: if the price moved
	// since the quote was signed, the task is rejected with code 40901 instead
	// of being accepted at the new amount.
	QuoteID      string `json:"quoteId,omitempty"`
	ExpectedCost string `json:"expectedCost,omitempty"`
}

// CreateTaskResult is the acceptance record, not a result.
type CreateTaskResult struct {
	TaskID string `json:"taskId"`
	// State is queued for a new submission. An idempotent replay reports the
	// original task's current state instead, which may already be terminal.
	State string `json:"state"`
	// EstimatedCost is the amount held, and the ceiling on the final charge.
	// Unused funds are released; nothing above the hold is ever collected.
	EstimatedCost string `json:"estimatedCost"`
	// DeadlineAt is the server's execution deadline, fixed at acceptance. It
	// is not a local wait bound and not an artefact expiry.
	DeadlineAt time.Time `json:"deadlineAt"`
}

// RetryTaskResult additionally names the task this one was created from.
type RetryTaskResult struct {
	CreateTaskResult
	SourceTaskID string `json:"sourceTaskId"`
}

// Asset is one generated artefact.
type Asset struct {
	// Key identifies this output for CreateDownloadURL.
	Key string `json:"key,omitempty"`
	// URL is a signed link that works on its own. Never attach your API key to
	// it: it is already a bearer grant, and sending the key leaks it to storage.
	// It is absent while Pending or Unavailable is true.
	URL string `json:"url,omitempty"`
	// ExpiresAt is this link's expiry, normally about twenty minutes out. It is
	// not the retention deadline of the result; poll again for a fresh link.
	ExpiresAt       *time.Time `json:"expiresAt,omitempty"`
	MIME            string     `json:"mime,omitempty"`
	Width           int        `json:"width,omitempty"`
	Height          int        `json:"height,omitempty"`
	DurationSeconds float64    `json:"durationSeconds,omitempty"`
	Bytes           int64      `json:"bytes,omitempty"`
	Role            string     `json:"role,omitempty"`
	NSFW            bool       `json:"nsfw,omitempty"`
	// Pending means the artefact is still being copied into our own storage.
	Pending bool `json:"pending,omitempty"`
	// Unavailable means it will not arrive; do not keep waiting for it.
	Unavailable bool `json:"unavailable,omitempty"`
}

// TaskOutput is the result payload of a finished task.
//
// Not every endpoint produces files. Transcription and other text endpoints
// answer in Text and return no assets at all, so check what you actually got
// before reaching for Assets[0].
type TaskOutput struct {
	Text   string  `json:"text,omitempty"`
	Assets []Asset `json:"assets,omitempty"`
}

// TaskRetention says when this task's content disappears and which layer
// decided it: the per-request header, the account setting, or the platform
// maximum.
type TaskRetention struct {
	OutputsExpireAt time.Time `json:"outputsExpireAt"`
	PromptsExpireAt time.Time `json:"promptsExpireAt"`
	Source          string    `json:"source"`
}

// Task is the full record returned by GetTask. Terminal and non-terminal
// states share this shape.
type Task struct {
	TaskID string `json:"taskId"`
	// SourceTaskID is set when this task came from RetryTask.
	SourceTaskID string `json:"sourceTaskId,omitempty"`
	Model        string `json:"model"`
	State        string `json:"state"`
	// Input is the normalized input, omitted once retention has redacted it.
	Input  map[string]any `json:"input,omitempty"`
	Output *TaskOutput    `json:"output,omitempty"`
	// ErrorCode is one of the nine ErrorCode constants. Branch on it.
	ErrorCode string `json:"errorCode,omitempty"`
	// ErrorMessage explains the failure in English. It may carry the model
	// service's own wording, with identifiers removed. Do not parse it.
	ErrorMessage string `json:"errorMessage,omitempty"`
	// Cost is a decimal USD string: the final charge once Settled is true, and
	// the held estimate before that.
	Cost    string `json:"cost"`
	Settled bool   `json:"settled"`

	CreatedAt        time.Time      `json:"createdAt"`
	DeadlineAt       *time.Time     `json:"deadlineAt,omitempty"`
	CompletedAt      *time.Time     `json:"completedAt,omitempty"`
	ContentState     string         `json:"contentState,omitempty"`
	ContentRemovedBy string         `json:"contentRemovedBy,omitempty"`
	PurgedAt         *time.Time     `json:"purgedAt,omitempty"`
	Retention        *TaskRetention `json:"retention,omitempty"`
}

// InFlight reports whether the task is still queued or running. Everything
// else is terminal, including a state this client has never heard of.
func (t *Task) InFlight() bool {
	return t.State == StateQueued || t.State == StateRunning
}

// ReadyAssets returns the artefacts that have a usable URL right now.
func (t *Task) ReadyAssets() []Asset {
	if t == nil || t.Output == nil {
		return nil
	}
	ready := make([]Asset, 0, len(t.Output.Assets))
	for _, asset := range t.Output.Assets {
		if asset.URL != "" && !asset.Unavailable {
			ready = append(ready, asset)
		}
	}
	return ready
}

// HasPendingAssets reports whether an artefact is still being copied. A task
// can be succeeded with its files not yet linkable.
func (t *Task) HasPendingAssets() bool {
	if t == nil || t.Output == nil {
		return false
	}
	for _, asset := range t.Output.Assets {
		if asset.Pending && !asset.Unavailable {
			return true
		}
	}
	return false
}

// PurgeResult reports what PurgeTask destroyed.
type PurgeResult struct {
	TaskID string `json:"taskId"`
	// ContentState is purged after a successful destroy and on every repeat.
	ContentState string `json:"contentState"`
	// PurgedAt is the time of the first destroy, not of this call.
	PurgedAt         *time.Time `json:"purgedAt,omitempty"`
	ContentRemovedBy string     `json:"contentRemovedBy,omitempty"`
	// BillingRetained is always true. Destroying content never touches the
	// ledger, the charged amount or anything else the spend record is made of.
	BillingRetained bool `json:"billingRetained"`
	// MediaDeletionPending is true while the background sweep is still
	// removing the stored objects, which takes about a minute.
	MediaDeletionPending bool `json:"mediaDeletionPending"`
}

// UploadTicket is a presigned slot for exactly one file of a declared size.
type UploadTicket struct {
	FileID string `json:"fileId"`
	// Key is a compatibility alias of the final URI and is unusable until the
	// commit succeeds. Put UploadedFile.URI in task input, never this.
	Key       string `json:"key"`
	UploadURL string `json:"uploadUrl"`
	Method    string `json:"method"`
	// Headers must be forwarded to the PUT exactly as given.
	Headers map[string]string `json:"headers"`
	// ExpiresAt is about twenty minutes out and covers the PUT and the commit
	// together. Ask for a ticket right before uploading, not in advance.
	ExpiresAt time.Time `json:"expiresAt"`
	// MaxBytes is the authoritative ceiling for this upload.
	MaxBytes int64 `json:"maxBytes"`
}

// UploadedFile is a committed file, ready to be referenced by a task.
type UploadedFile struct {
	FileID string `json:"fileId"`
	Status string `json:"status"`
	Bytes  int64  `json:"bytes"`
	// ContentType is what the server verified, which may differ from what you
	// declared; if it does, the commit fails with code 40003 instead.
	ContentType string `json:"contentType"`
	SHA256      string `json:"sha256"`
	// URI is the spicy://f/fil_... value to put in task input. Use this one,
	// not the upload URL, not a storage key, not the bare file ID.
	URI       string    `json:"uri"`
	ExpiresAt time.Time `json:"expiresAt"`
	// DurationSeconds is a decimal string, present for audio and video.
	DurationSeconds string `json:"durationSeconds,omitempty"`
	Width           int    `json:"width,omitempty"`
	Height          int    `json:"height,omitempty"`
}

// DownloadTicket is a fresh signed link to one output of a finished task.
type DownloadTicket struct {
	Key       string    `json:"key"`
	URL       string    `json:"url"`
	ExpiresAt time.Time `json:"expiresAt"`
}

// HTTPDoer is the slice of *http.Client this package needs, so that tests can
// substitute a transport without a network.
type HTTPDoer interface {
	Do(req *http.Request) (*http.Response, error)
}

// Options configures New. Every zero value falls back to a documented default.
type Options struct {
	// APIKey defaults to the SPICY_API_KEY environment variable. Keep it
	// server-side; it is a bearer credential for your balance.
	APIKey string
	// BaseURL defaults to APIBaseURL.
	BaseURL string
	// HTTPClient defaults to a client with no timeout of its own, because
	// every request here already carries a context deadline.
	HTTPClient HTTPDoer

	RequestTimeout time.Duration
	UploadTimeout  time.Duration
	WaitTimeout    time.Duration
	// MaxRetries is the number of extra attempts a replayable request gets.
	// Set it to -1 to disable retries entirely.
	MaxRetries int

	// Now and Rand exist for deterministic tests.
	Now  func() time.Time
	Rand func() float64
	// Sleep must return the context error if the context ends first.
	Sleep func(ctx context.Context, d time.Duration) error
}

// Client calls the SpicyAPI task surface. It is safe for concurrent use.
type Client struct {
	apiKey         string
	baseURL        string
	httpClient     HTTPDoer
	requestTimeout time.Duration
	uploadTimeout  time.Duration
	waitTimeout    time.Duration
	maxRetries     int
	now            func() time.Time
	rand           func() float64
	sleep          func(ctx context.Context, d time.Duration) error
}

// New builds a client. It fails when no API key is available, because every
// endpoint here needs one and discovering that at the first call is worse.
func New(opts Options) (*Client, error) {
	key := strings.TrimSpace(opts.APIKey)
	if key == "" {
		key = strings.TrimSpace(os.Getenv("SPICY_API_KEY"))
	}
	if key == "" {
		return nil, errors.New("spicy: SPICY_API_KEY is required")
	}

	base := opts.BaseURL
	if base == "" {
		base = APIBaseURL
	}
	parsed, err := url.Parse(base)
	if err != nil || parsed.Scheme == "" || parsed.Host == "" {
		return nil, fmt.Errorf("spicy: BaseURL must be an absolute URL, got %q", base)
	}
	// The API key is a bearer credential: sending it over plain HTTP hands it
	// out in clear text. Loopback is the one exception, because local
	// development traffic never crosses a network.
	if parsed.Scheme != "https" && !(parsed.Scheme == "http" && isLoopback(parsed.Hostname())) {
		return nil, fmt.Errorf("spicy: BaseURL must use HTTPS; HTTP is allowed only for loopback, got %q", base)
	}

	client := &Client{
		apiKey:         key,
		baseURL:        strings.TrimRight(base, "/"),
		httpClient:     opts.HTTPClient,
		requestTimeout: opts.RequestTimeout,
		uploadTimeout:  opts.UploadTimeout,
		waitTimeout:    opts.WaitTimeout,
		maxRetries:     opts.MaxRetries,
		now:            opts.Now,
		rand:           opts.Rand,
		sleep:          opts.Sleep,
	}
	if client.httpClient == nil {
		client.httpClient = &http.Client{}
	}
	if client.requestTimeout <= 0 {
		client.requestTimeout = DefaultRequestTimeout
	}
	if client.uploadTimeout <= 0 {
		client.uploadTimeout = DefaultUploadTimeout
	}
	if client.waitTimeout <= 0 {
		client.waitTimeout = DefaultWaitTimeout
	}
	if client.maxRetries == 0 {
		client.maxRetries = DefaultMaxRetries
	}
	if client.maxRetries < 0 {
		client.maxRetries = 0
	}
	if client.now == nil {
		client.now = time.Now
	}
	if client.rand == nil {
		// Only used to add jitter to backoff; not a security use.
		client.rand = rand.Float64
	}
	if client.sleep == nil {
		client.sleep = sleepWithContext
	}
	return client, nil
}

// ListModelsOptions filters the catalogue. A zero field means "do not filter".
type ListModelsOptions struct {
	// Modality is image, video, audio or text.
	Modality string
	// Provider is the model's creator.
	Provider string
	// Task is an exact task, for example text-to-image. Image editing accepts
	// either spelling: edit and image-to-image select the same endpoints.
	Task string
	// Search matches names and descriptions.
	Search string
	// IncludeSchema adds each model's input JSON Schema, which is what you
	// need to build or validate an input. It makes the response much larger.
	IncludeSchema bool
	// IncludeExamples adds inputs that already validate against that schema —
	// the fastest way to get a first call working.
	IncludeExamples bool
}

// ListModels returns every model this API key may call, with the prices that
// apply to this account. Enabled and Available must both be true before a
// model is callable.
func (c *Client) ListModels(ctx context.Context, opts ListModelsOptions) (*ModelList, error) {
	query := url.Values{}
	for name, value := range map[string]string{
		"modality": opts.Modality,
		"provider": opts.Provider,
		"task":     opts.Task,
		"search":   opts.Search,
	} {
		if value != "" {
			query.Set(name, value)
		}
	}
	if opts.IncludeSchema {
		query.Set("includeSchema", "1")
	}
	if opts.IncludeExamples {
		query.Set("includeExamples", "1")
	}

	path := "/models"
	if encoded := query.Encode(); encoded != "" {
		path += "?" + encoded
	}
	out := &ModelList{}
	if err := c.do(ctx, request{method: http.MethodGet, path: path, replayable: true, out: out}); err != nil {
		return nil, err
	}
	return out, nil
}

// GetModel reads one model, always including its input schema.
//
// A model identifier contains slashes, so it is escaped into a single path
// segment here; sending them raw would address a different resource.
func (c *Client) GetModel(ctx context.Context, model string) (*Model, error) {
	if strings.TrimSpace(model) == "" {
		return nil, errors.New("spicy: model is required")
	}
	out := &Model{}
	path := "/models/" + url.PathEscape(model)
	if err := c.do(ctx, request{method: http.MethodGet, path: path, replayable: true, out: out}); err != nil {
		return nil, err
	}
	return out, nil
}

// Quote prices one exact request for five minutes without creating a task,
// holding funds or starting anything. It runs the same validation and
// admission rules as CreateTask, so it is also the cheapest way to find out
// that an input is wrong.
//
// Pass the returned QuoteID and EstimatedCost to CreateTask to refuse a price
// that moved in the meantime.
func (c *Client) Quote(ctx context.Context, input CreateTaskInput) (*Quote, error) {
	if strings.TrimSpace(input.Model) == "" {
		return nil, errors.New("spicy: input.Model is required")
	}
	if len(input.Input) == 0 {
		return nil, errors.New("spicy: input.Input is required")
	}
	// Send only the three fields that are priced. Echoing a previous quoteId /
	// expectedCost would ask "quote me at that old price", when the whole point
	// of a quote is to find out whether the price has changed.
	body := CreateTaskInput{Model: input.Model, Input: input.Input, CallBackURL: input.CallBackURL}
	out := &Quote{}
	// A quote creates no task and holds no funds, so resending it is safe.
	if err := c.do(ctx, request{method: http.MethodPost, path: "/jobs/quote", body: body, replayable: true, out: out}); err != nil {
		return nil, err
	}
	return out, nil
}

// CreateTaskOptions carries the optional header that accompanies a submission.
type CreateTaskOptions struct {
	// RetentionSeconds, when non-nil, shortens how long this task's outputs
	// and its stored request text are kept. It can only shorten: the effective
	// value is the smallest of this, the account setting and the platform
	// maximum, and the result is reported back in Task.Retention.
	//
	// It is a pointer because 0 is a meaningful value — it means the outputs
	// go as soon as the task reaches a terminal state — so it must not be what
	// an empty options struct sends.
	RetentionSeconds *int
}

// NewIdempotencyKey mints a key with enough entropy that two submissions never
// collide. Store it next to your own record of the job before you send it: the
// point of the key is to be available again after the crash that lost the
// response.
func NewIdempotencyKey() (string, error) {
	buf := make([]byte, 16)
	if _, err := cryptorand.Read(buf); err != nil {
		return "", fmt.Errorf("spicy: generating an idempotency key: %w", err)
	}
	return "spicy-" + hex.EncodeToString(buf), nil
}

// CreateTask submits a generation and returns as soon as it is accepted.
//
// Acceptance is not completion. The returned state is queued; read the outcome
// back with GetTask or WaitForTerminal. EstimatedCost is held, not charged,
// and it is also the ceiling: the settled charge can only be lower.
//
// idempotencyKey is required by this client even though the contract allows it
// to be omitted, because the automatic resend on a network fault is only sound
// when it exists. Without a key, a request that times out leaves you unable to
// tell an accepted task from a lost one, and the obvious reaction — send it
// again — is a second generation and a second charge. Reuse the same value for
// every attempt at the same logical submission: for 24 hours, and for this API
// key, a repeat returns the original task instead of creating another.
func (c *Client) CreateTask(
	ctx context.Context,
	input CreateTaskInput,
	idempotencyKey string,
	opts CreateTaskOptions,
) (*CreateTaskResult, error) {
	if strings.TrimSpace(input.Model) == "" {
		return nil, errors.New("spicy: input.Model is required")
	}
	if len(input.Input) == 0 {
		return nil, errors.New("spicy: input.Input is required")
	}
	key := strings.TrimSpace(idempotencyKey)
	if key == "" {
		return nil, errors.New("spicy: an Idempotency-Key is required; see NewIdempotencyKey")
	}

	headers := map[string]string{"Idempotency-Key": key}
	if opts.RetentionSeconds != nil {
		headers["X-Spicy-Retention"] = strconv.Itoa(*opts.RetentionSeconds)
	}

	out := &CreateTaskResult{}
	if err := c.do(ctx, request{
		method:  http.MethodPost,
		path:    "/jobs/createTask",
		body:    input,
		headers: headers,
		// Automatic resends are safe only because of the idempotency key: the
		// server folds a repeated key back into the original task, so a network
		// blip never turns into a second generation or a second charge.
		replayable: true,
		out:        out,
	}); err != nil {
		return nil, err
	}
	return out, nil
}

// GetTask reads one task created by this API key. An unknown task, another
// account's task and a sibling key's task are all a 404; the three cases are
// deliberately indistinguishable.
func (c *Client) GetTask(ctx context.Context, taskID string) (*Task, error) {
	if strings.TrimSpace(taskID) == "" {
		return nil, errors.New("spicy: taskID is required")
	}
	out := &Task{}
	path := "/jobs/recordInfo?" + url.Values{"taskId": []string{taskID}}.Encode()
	if err := c.do(ctx, request{method: http.MethodGet, path: path, replayable: true, out: out}); err != nil {
		return nil, err
	}
	return out, nil
}

// WaitOptions tunes WaitForTerminal. Every zero field takes its default.
type WaitOptions struct {
	// Timeout is the local bound on the whole wait, DefaultWaitTimeout by
	// default. It is not a service level.
	Timeout time.Duration
	// Interval is the first gap between polls, DefaultPollInterval by default.
	Interval time.Duration
	// MaxInterval caps how far that gap grows, MaxPollInterval by default.
	MaxInterval time.Duration
	// AcceptPendingAssets returns a terminal task even when its artefacts are
	// still being copied into our storage, so they have no URL yet. Leave it
	// false to get back a record you can actually download from.
	AcceptPendingAssets bool
}

// WaitForTerminal polls a task until it stops being queued or running, backing
// off between reads.
//
// Terminal is defined as "neither of the two in-flight states" rather than as
// a list of the four known endings. A client that enumerates the endings will
// either loop forever or reject a real answer the day a new one is added, and
// that failure arrives silently. The four endings today are "succeeded",
// "failed", "canceled" and "expired"; only "succeeded" carries a result.
//
// A local timeout is not a verdict: it returns *WaitTimeoutError, and the
// generation is still running on our side. Keep the task ID and reconcile it
// later, or use a callback URL instead of waiting.
func (c *Client) WaitForTerminal(ctx context.Context, taskID string, opts WaitOptions) (*Task, error) {
	if strings.TrimSpace(taskID) == "" {
		return nil, errors.New("spicy: taskID is required")
	}
	timeout := opts.Timeout
	if timeout <= 0 {
		timeout = c.waitTimeout
	}
	interval := opts.Interval
	if interval <= 0 {
		interval = DefaultPollInterval
	}
	maxInterval := opts.MaxInterval
	if maxInterval <= 0 {
		maxInterval = MaxPollInterval
	}

	waitCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	lastState := ""
	for {
		task, err := c.GetTask(waitCtx, taskID)
		if err != nil {
			return nil, c.waitError(ctx, waitCtx, taskID, timeout, lastState, err)
		}
		lastState = task.State
		if !task.InFlight() && (opts.AcceptPendingAssets || !task.HasPendingAssets()) {
			return task, nil
		}
		if err := c.sleep(waitCtx, c.jitter(interval)); err != nil {
			return nil, c.waitError(ctx, waitCtx, taskID, timeout, lastState, err)
		}
		if interval = interval * 3 / 2; interval > maxInterval {
			interval = maxInterval
		}
	}
}

// waitError separates our own polling deadline from the caller cancelling.
// Only the first one is a WaitTimeoutError; the second is the caller's own.
func (c *Client) waitError(
	ctx context.Context,
	waitCtx context.Context,
	taskID string,
	timeout time.Duration,
	lastState string,
	err error,
) error {
	if ctx.Err() == nil && errors.Is(waitCtx.Err(), context.DeadlineExceeded) {
		return &WaitTimeoutError{TaskID: taskID, Timeout: timeout, LastState: lastState}
	}
	return err
}

// RetryTask creates a new task from a failed or expired one. The source task
// is never modified, and the current schema, price, permissions and balance
// are all evaluated again — a retry can cost a different amount, or be refused
// outright.
//
// The source must be terminal and settled; retrying too early returns 409, and
// retrying a succeeded task returns 404 with no way to tell it from a task
// that was never there. Retention is inherited from the source, so one family
// of tasks never ends up with two different expiry times.
func (c *Client) RetryTask(ctx context.Context, taskID, idempotencyKey string) (*RetryTaskResult, error) {
	if strings.TrimSpace(taskID) == "" {
		return nil, errors.New("spicy: taskID is required")
	}
	key := strings.TrimSpace(idempotencyKey)
	if key == "" {
		return nil, errors.New("spicy: an Idempotency-Key is required; see NewIdempotencyKey")
	}
	out := &RetryTaskResult{}
	if err := c.do(ctx, request{
		method:     http.MethodPost,
		path:       "/jobs/retry",
		body:       map[string]string{"taskId": taskID},
		headers:    map[string]string{"Idempotency-Key": key},
		replayable: true,
		out:        out,
	}); err != nil {
		return nil, err
	}
	return out, nil
}

// PurgeTask destroys a finished task's stored content: the generated objects,
// the result payload and the request text including the prompt.
//
// It destroys content, not the spend record. The ledger, the charged amount,
// the model, the state, the timestamps and the request ID all remain, which is
// what BillingRetained says out loud on the way back.
//
// Unlike the other write endpoints this one reads no Idempotency-Key header:
// the task ID is the idempotency key, so a repeat after a timeout is safe and
// returns the original purgedAt. A queued or running task returns 400 — an
// accepted generation cannot be stopped, so wait for it to finish first.
func (c *Client) PurgeTask(ctx context.Context, taskID string) (*PurgeResult, error) {
	if strings.TrimSpace(taskID) == "" {
		return nil, errors.New("spicy: taskID is required")
	}
	out := &PurgeResult{}
	if err := c.do(ctx, request{
		method: http.MethodPost,
		path:   "/jobs/purge",
		body:   map[string]string{"taskId": taskID},
		// The server is idempotent on taskId, so a transport-level resend gets
		// the same record back.
		replayable: true,
		out:        out,
	}); err != nil {
		return nil, err
	}
	return out, nil
}

// uploadContentTypes maps an extension to one of the accepted media types.
var uploadContentTypes = map[string]string{
	".gif":  "image/gif",
	".jpeg": "image/jpeg",
	".jpg":  "image/jpeg",
	".png":  "image/png",
	".webp": "image/webp",
	".mp4":  "video/mp4",
	".webm": "video/webm",
	".mp3":  "audio/mpeg",
	".wav":  "audio/wav",
}

// CreateUploadURL asks for a presigned slot for exactly one file of exactly
// the declared size. Images are capped at 10 MiB, audio and video at 90 MiB
// and 600 seconds; the ticket's MaxBytes is the authoritative ceiling.
//
// The ticket lives about twenty minutes and that window covers both the PUT
// and the commit, so ask for it right before uploading rather than in advance.
func (c *Client) CreateUploadURL(ctx context.Context, contentType string, size int64) (*UploadTicket, error) {
	if !isAcceptedUploadType(contentType) {
		return nil, fmt.Errorf(
			"spicy: contentType %q is not accepted; use one of %s",
			contentType, strings.Join(acceptedUploadTypes, ", "),
		)
	}
	if size <= 0 {
		return nil, errors.New("spicy: size must be the exact number of bytes you are about to upload")
	}
	out := &UploadTicket{}
	if err := c.do(ctx, request{
		method: http.MethodPost,
		path:   "/common/upload-url",
		body:   map[string]any{"contentType": contentType, "bytes": size},
		// Never resent automatically: every call allocates a new fileId and a new
		// ticket, so a resend would only leave behind tickets nobody commits.
		replayable: false,
		out:        out,
	}); err != nil {
		return nil, err
	}
	return out, nil
}

// CommitUpload verifies the bytes against the ticket — size, media type,
// signature — copies them to an immutable private object and returns the
// spicy:// URI to put in task input. Repeating a successful commit is
// idempotent.
//
// Code 40003 here means the stored file does not match what the ticket was
// issued for. Do not retry it: ask for a new ticket with the real content type
// and size and upload again.
func (c *Client) CommitUpload(ctx context.Context, fileID string) (*UploadedFile, error) {
	if strings.TrimSpace(fileID) == "" {
		return nil, errors.New("spicy: fileID is required")
	}
	out := &UploadedFile{}
	path := "/files/" + url.PathEscape(fileID) + "/commit"
	if err := c.do(ctx, request{method: http.MethodPost, path: path, replayable: true, out: out}); err != nil {
		return nil, err
	}
	return out, nil
}

// Upload runs all three steps: take a ticket, PUT the bytes straight into
// object storage, then commit. Only the URI on the returned file belongs in
// task input — not the upload URL, not a storage key, not the bare file ID.
//
// Uploading is free; you pay for the task that consumes the file. A public
// HTTPS URL can be passed in the input field directly and skips all of this.
func (c *Client) Upload(ctx context.Context, data []byte, contentType string) (*UploadedFile, error) {
	if len(data) == 0 {
		return nil, errors.New("spicy: refusing to upload an empty file")
	}
	ticket, err := c.CreateUploadURL(ctx, contentType, int64(len(data)))
	if err != nil {
		return nil, err
	}
	if int64(len(data)) > ticket.MaxBytes {
		return nil, fmt.Errorf(
			"spicy: file is %d bytes, above this ticket's ceiling of %d",
			len(data), ticket.MaxBytes,
		)
	}
	if err := c.putUpload(ctx, ticket, data); err != nil {
		return nil, err
	}
	return c.CommitUpload(ctx, ticket.FileID)
}

// UploadFile reads a local file and uploads it. An empty contentType is
// inferred from the extension, which only works for the accepted media types.
func (c *Client) UploadFile(ctx context.Context, path, contentType string) (*UploadedFile, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("spicy: reading %s: %w", path, err)
	}
	if contentType == "" {
		inferred, ok := uploadContentTypes[strings.ToLower(filepath.Ext(path))]
		if !ok {
			return nil, fmt.Errorf(
				"spicy: cannot infer a content type from %q; pass one of %s",
				filepath.Ext(path), strings.Join(acceptedUploadTypes, ", "),
			)
		}
		contentType = inferred
	}
	return c.Upload(ctx, data, contentType)
}

// putUpload sends the bytes to object storage.
func (c *Client) putUpload(ctx context.Context, ticket *UploadTicket, data []byte) error {
	// Byte transfers get their own upload timeout. Thirty seconds is fine for a
	// JSON call, but pushing a 90 MiB video in 30 seconds needs 25 Mbit/s of
	// upstream bandwidth: on a real network the client would cut its own upload
	// off halfway, and the error would look like the server's fault.
	ctx, cancel := context.WithTimeout(ctx, c.uploadTimeout)
	defer cancel()

	method := ticket.Method
	if method == "" {
		method = http.MethodPut
	}
	req, err := http.NewRequestWithContext(ctx, method, ticket.UploadURL, bytes.NewReader(data))
	if err != nil {
		return fmt.Errorf("spicy: building the upload request: %w", err)
	}

	// Every header on the ticket must be forwarded verbatim, but net/http does
	// not read two of them from Header, so a plain loop would silently drop them.
	for name, value := range ticket.Headers {
		switch http.CanonicalHeaderKey(name) {
		case "Content-Length":
			// net/http derives this value from the body and ignores whatever
			// Header says. So instead of forwarding it we use it as a cross-check:
			// a mismatch means the ticket was issued for a different file, and the
			// upload would only fail with 40003 at the commit step.
			declared, parseErr := strconv.ParseInt(value, 10, 64)
			if parseErr == nil && declared != int64(len(data)) {
				return fmt.Errorf(
					"spicy: this ticket was issued for %d bytes but the file is %d",
					declared, len(data),
				)
			}
		case "Host":
			req.Host = value
		default:
			req.Header.Set(name, value)
		}
	}

	// No Authorization header on this hop: the presigned URL is the credential.
	// An extra auth header makes some object stores reject the request outright,
	// and it would hand your API key to a third party.
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("spicy: uploading to object storage: %w", err)
	}
	defer func() {
		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes))
		_ = resp.Body.Close()
	}()
	if resp.StatusCode/100 != 2 {
		return &APIError{
			Status:  resp.StatusCode,
			Message: "presigned upload was rejected by object storage",
		}
	}
	return nil
}

// CreateDownloadURL issues a fresh signed link to one output of a finished
// task. A finished task already carries usable links, so this is only needed
// once those have expired — polling the task again does the same job.
//
// Leave key empty to select the first output. Do not attach your API key when
// fetching the link: it is a bearer grant on its own.
func (c *Client) CreateDownloadURL(ctx context.Context, taskID, key string) (*DownloadTicket, error) {
	if strings.TrimSpace(taskID) == "" {
		return nil, errors.New("spicy: taskID is required")
	}
	body := map[string]string{"taskId": taskID}
	if key != "" {
		body["key"] = key
	}
	out := &DownloadTicket{}
	if err := c.do(ctx, request{
		method: http.MethodPost, path: "/common/download-url", body: body, replayable: true, out: out,
	}); err != nil {
		return nil, err
	}
	return out, nil
}

// webhookEvent is the smallest shape that locates the task ID in either
// payload version.
type webhookEvent struct {
	TaskID string `json:"task_id"`
	Data   struct {
		TaskID string `json:"taskId"`
	} `json:"data"`
}

// VerifyWebhook authenticates one callback delivery and returns the task ID
// the signature was computed over. Use that returned ID, not one you read out
// of the body yourself.
//
// The signature is
//
//	base64( HMAC-SHA256( secret, "<taskID>.<timestamp>.<sha256_hex(rawBody)>" ) )
//
// where the task ID comes from top-level task_id in payload version 1 and from
// data.taskId in version 2. Read X-Webhook-Payload-Version to choose; never
// infer the version from the shape of the JSON, because an old delivery can be
// retried long after the platform moved on.
//
// rawBody must be the exact bytes that arrived. Decoding and re-encoding the
// JSON first changes key order, spacing and escaping, and the digest then
// fails every single time for a reason that looks like a wrong secret.
//
// The digest is part of the message on purpose. Signing only the task ID and
// the timestamp would let anyone who intercepted one real delivery pair those
// two values with a body of their own — and it would verify.
//
// tolerance bounds the accepted clock skew; pass 0 for WebhookTolerance.
func VerifyWebhook(secret string, header http.Header, rawBody []byte, tolerance time.Duration) (string, error) {
	if secret == "" {
		return "", fmt.Errorf("%w: no signing secret configured", ErrWebhookRejected)
	}
	version := header.Get("X-Webhook-Payload-Version")
	timestamp := header.Get("X-Webhook-Timestamp")
	signature := header.Get("X-Webhook-Signature")
	if version != "1" && version != "2" {
		return "", fmt.Errorf("%w: unsupported payload version %q", ErrWebhookRejected, version)
	}
	if timestamp == "" || signature == "" {
		return "", fmt.Errorf("%w: missing timestamp or signature header", ErrWebhookRejected)
	}

	seconds, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return "", fmt.Errorf("%w: timestamp %q is not unix seconds", ErrWebhookRejected, timestamp)
	}
	if tolerance <= 0 {
		tolerance = WebhookTolerance
	}
	if skew := time.Since(time.Unix(seconds, 0)); skew > tolerance || skew < -tolerance {
		return "", fmt.Errorf("%w: %s off", ErrWebhookStale, skew.Round(time.Second))
	}

	// The taskId has to be read from the still-unverified body, but only to
	// recompute the signature: a forged ID yields a signature that does not
	// match, so the value is substituted into the check, never trusted.
	var parsed webhookEvent
	if err := json.Unmarshal(rawBody, &parsed); err != nil {
		return "", fmt.Errorf("%w: body is not JSON", ErrWebhookRejected)
	}
	taskID := parsed.Data.TaskID
	if version == "1" {
		taskID = parsed.TaskID
	}
	if taskID == "" {
		return "", fmt.Errorf("%w: body carries no task ID for version %s", ErrWebhookRejected, version)
	}

	digest := sha256.Sum256(rawBody)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(taskID + "." + timestamp + "." + hex.EncodeToString(digest[:])))
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	// Constant-time comparison: a plain == returns at the first differing byte,
	// and that timing difference is enough to recover the signature byte by byte.
	if !hmac.Equal([]byte(expected), []byte(signature)) {
		return "", ErrWebhookSignature
	}
	return taskID, nil
}

// request describes one call to the API surface.
type request struct {
	method  string
	path    string
	body    any
	headers map[string]string
	// replayable says whether sending these exact bytes again is safe. It
	// gates every automatic retry: a transport error does not prove the server
	// never saw the request, so anything that would create or charge twice is
	// only resent when an idempotency key makes the duplicate harmless.
	replayable bool
	out        any
}

// do performs a request, decodes the envelope and retries what is worth
// retrying.
func (c *Client) do(ctx context.Context, spec request) error {
	for attempt := 0; ; attempt++ {
		status, header, body, err := c.attempt(ctx, spec)
		if err != nil {
			if ctx.Err() != nil || !spec.replayable || attempt >= c.maxRetries {
				return fmt.Errorf("spicy: %s %s: %w", spec.method, spec.path, err)
			}
			if waitErr := c.sleep(ctx, c.backoff(attempt, 0)); waitErr != nil {
				return waitErr
			}
			continue
		}

		retryAfter := parseRetryAfter(header.Get("Retry-After"), c.now())
		var env envelope
		// code is never zero, so it doubles as the test for "is this our envelope".
		parsed := json.Unmarshal(body, &env) == nil && env.Code != 0

		if !parsed {
			// Not an envelope: usually an HTML error page from the edge network.
			// Only the HTTP status can be relied on, because there is no business
			// code at all.
			if spec.replayable && attempt < c.maxRetries && retryableHTTP[status] {
				if waitErr := c.sleep(ctx, c.backoff(attempt, retryAfter)); waitErr != nil {
					return waitErr
				}
				continue
			}
			return &APIError{
				Status:     status,
				Message:    fmt.Sprintf("response was not the SpicyAPI envelope: %s", snippet(body)),
				RetryAfter: retryAfter,
			}
		}

		if status/100 != 2 || env.Code != CodeSuccess {
			if spec.replayable && attempt < c.maxRetries && retryableCode[env.Code] {
				if waitErr := c.sleep(ctx, c.backoff(attempt, retryAfter)); waitErr != nil {
					return waitErr
				}
				continue
			}
			return &APIError{
				Status:     status,
				Code:       env.Code,
				Message:    env.Msg,
				RequestID:  env.RequestID,
				RetryAfter: retryAfter,
			}
		}

		if spec.out == nil {
			return nil
		}
		if len(env.Data) == 0 {
			return &APIError{
				Status:    status,
				Code:      env.Code,
				Message:   "successful envelope carried no data",
				RequestID: env.RequestID,
			}
		}
		if err := json.Unmarshal(env.Data, spec.out); err != nil {
			return fmt.Errorf("spicy: decoding the %s response: %w", spec.path, err)
		}
		return nil
	}
}

// attempt performs exactly one HTTP round trip under its own deadline.
func (c *Client) attempt(ctx context.Context, spec request) (int, http.Header, []byte, error) {
	ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
	defer cancel()

	var payload io.Reader
	if spec.body != nil {
		raw, err := json.Marshal(spec.body)
		if err != nil {
			return 0, nil, nil, fmt.Errorf("encoding the request body: %w", err)
		}
		payload = bytes.NewReader(raw)
	}

	req, err := http.NewRequestWithContext(ctx, spec.method, c.baseURL+spec.path, payload)
	if err != nil {
		return 0, nil, nil, err
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	if spec.body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	for name, value := range spec.headers {
		req.Header.Set(name, value)
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return 0, nil, nil, err
	}
	defer func() { _ = resp.Body.Close() }()

	// Cap the read: a broken intermediary can stream bytes forever, and without
	// a limit it would exhaust the process's memory.
	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
	if err != nil {
		return resp.StatusCode, resp.Header, nil, err
	}
	if len(body) > maxResponseBytes {
		return resp.StatusCode, resp.Header, nil, fmt.Errorf("response exceeded %d bytes", maxResponseBytes)
	}
	return resp.StatusCode, resp.Header, body, nil
}

// backoff waits as long as the server asked, and otherwise doubles with jitter
// so that a batch of failures does not come back as one thundering herd.
func (c *Client) backoff(attempt int, retryAfter time.Duration) time.Duration {
	if retryAfter > 0 {
		if retryAfter > maxRetryDelay {
			return maxRetryDelay
		}
		return retryAfter
	}
	delay := time.Duration(1<<uint(attempt)) * baseRetryDelay
	if delay <= 0 || delay > maxRetryDelay {
		delay = maxRetryDelay
	}
	return c.jitter(delay)
}

// jitter spreads a delay over ±20 percent.
func (c *Client) jitter(d time.Duration) time.Duration {
	return time.Duration(float64(d) * (0.8 + 0.4*c.rand()))
}

// parseRetryAfter reads the header in both of its documented forms: a count of
// seconds, or an HTTP date.
func parseRetryAfter(value string, now time.Time) time.Duration {
	value = strings.TrimSpace(value)
	if value == "" {
		return 0
	}
	if seconds, err := strconv.Atoi(value); err == nil {
		if seconds <= 0 {
			return 0
		}
		return time.Duration(seconds) * time.Second
	}
	if when, err := http.ParseTime(value); err == nil {
		if delay := when.Sub(now); delay > 0 {
			return delay
		}
	}
	return 0
}

// sleepWithContext waits, or gives up early when the context ends.
func sleepWithContext(ctx context.Context, d time.Duration) error {
	if d <= 0 {
		return ctx.Err()
	}
	timer := time.NewTimer(d)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}

// snippet renders the start of an unexpected body on one line, so that an edge
// network's HTML page is readable in a log without flooding it.
func snippet(body []byte) string {
	const limit = 200
	if len(body) > 4*limit {
		body = body[:4*limit]
	}
	runes := []rune(strings.Join(strings.Fields(string(body)), " "))
	if len(runes) == 0 {
		return "(empty body)"
	}
	if len(runes) > limit {
		// Cut on code points; cutting on bytes could split a multi-byte character.
		return string(runes[:limit]) + "…"
	}
	return string(runes)
}

// isLoopback reports whether a host is this machine.
func isLoopback(host string) bool {
	host = strings.ToLower(strings.Trim(host, "[]"))
	return host == "localhost" || host == "::1" || strings.HasPrefix(host, "127.")
}

// acceptedUploadTypes is the closed set the upload ticket endpoint takes.
var acceptedUploadTypes = []string{
	"image/jpeg", "image/png", "image/webp", "image/gif",
	"video/mp4", "video/webm", "audio/mpeg", "audio/wav",
}

func isAcceptedUploadType(contentType string) bool {
	for _, accepted := range acceptedUploadTypes {
		if accepted == contentType {
			return true
		}
	}
	return false
}
