// Copyright 2026 printing-press-golden and contributors. Licensed under Apache-2.0. See LICENSE.
// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.

package client

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"printing-press-oauth2-pp-cli/internal/cliutil"
	"printing-press-oauth2-pp-cli/internal/config"
	"sort"
	"strings"
	"sync"
	"time"
)

const BinaryResponseHeader = "X-Printing-Press-Binary-Response"

var ErrPlaceholderCredential = errors.New("auth placeholder credential")

type Client struct {
	BaseURL    string
	Config     *config.Config
	HTTPClient *http.Client
	DryRun     bool
	NoCache    bool
	cacheDir   string
	limiter    *cliutil.AdaptiveLimiter
	// ccMu serializes OAuth2 client_credentials token mints so concurrent
	// API calls inside the 60s pre-expiry window don't all dial the token
	// endpoint and race on Config field writes / file persistence.
	ccMu *sync.Mutex
}

// RequestBaseURL returns the base URL used for requests.
// Novel commands that build request URLs by hand should use this instead of
// concatenating c.BaseURL directly.
func (c *Client) RequestBaseURL() string {
	return c.BaseURL
}

// APIError carries HTTP status information for structured exit codes.
type APIError struct {
	Method     string
	Path       string
	StatusCode int
	Body       string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("%s %s returned HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
}

func rejectUnresolvedPathParams(path string, allowedTemplateVars map[string]string) error {
	for {
		start := strings.IndexByte(path, '{')
		if start < 0 {
			return nil
		}
		rest := path[start+1:]
		end := strings.IndexByte(rest, '}')
		if end < 0 {
			return nil
		}
		name := rest[:end]
		if _, ok := allowedTemplateVars[name]; ok {
			path = rest[end+1:]
			continue
		}
		if isPathPlaceholderName(name) {
			return fmt.Errorf("unresolved path parameter {%s}", name)
		}
		path = rest[end+1:]
	}
}

func isPathPlaceholderName(name string) bool {
	if name == "" {
		return false
	}
	for i, r := range name {
		if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9' {
			continue
		}
		return false
	}
	return true
}

func newHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client {
	return &http.Client{Timeout: timeout, Jar: jar}
}

func New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {
	cacheDir := ""
	if dir, err := cliutil.CacheDir(); err == nil {
		cacheDir = filepath.Join(dir, "http")
	}
	httpClient := newHTTPClient(timeout, nil)
	c := &Client{
		BaseURL:    strings.TrimRight(cfg.BaseURL, "/"),
		Config:     cfg,
		HTTPClient: httpClient,
		cacheDir:   cacheDir,
		limiter:    cliutil.NewAdaptiveLimiter(rateLimit),
		ccMu:       &sync.Mutex{},
	}
	// CheckRedirect re-derives auth on each hop. Go's default replays the
	// original Authorization header verbatim, which breaks nonce-bound
	// schemes (OAuth 1.0a PLAINTEXT, SigV4, Hawk): the duplicate nonce
	// trips the server's replay detector with a 401. c.authHeader()
	// returns a fresh value for those schemes and the same static value
	// for Bearer/api_key, so post-redirect headers are byte-identical for
	// static auth and freshly-signed for nonce-bound auth.
	httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		if len(via) >= 10 {
			// Match Go's defaultCheckRedirect: a plain error so Client.Do
			// returns it through do()'s err != nil branch. ErrUseLastResponse
			// would cause Do to return the 3xx with nil error, which do()
			// would then classify as a successful response and hand the HTML
			// "Moved Permanently" body back to the caller.
			return errors.New("stopped after 10 redirects")
		}
		// Same-host gate mirrors Go's shouldCopyHeaderOnRedirect: a
		// cross-domain 3xx (open redirect or partner handoff) must not
		// receive the auth credential, even though we are inside
		// CheckRedirect where Go's automatic stripping has already run.
		if req.URL.Host == via[0].URL.Host {
			if h, err := c.authHeader(req.Context()); err == nil && h != "" {
				req.Header.Set("Authorization", h)
			}
		} else {
			// Cross-host hop: Go strips standard auth headers (Authorization,
			// Cookie) but not custom ones, so a custom API-key header would be
			// forwarded verbatim to the redirect target. Delete it explicitly.
			req.Header.Del("Authorization")
		}
		return nil
	}
	return c
}

// RateLimit returns the current effective rate limit in req/s. Returns 0 if disabled.
func (c *Client) RateLimit() float64 {
	return c.limiter.Rate()
}

func (c *Client) Get(ctx context.Context, path string, params map[string]string) (json.RawMessage, error) {
	return c.GetWithHeaders(ctx, path, params, nil)
}

func (c *Client) GetWithHeaders(ctx context.Context, path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
	if err := c.validateCachedRequestAuth(ctx); err != nil {
		return nil, err
	}
	binaryResponse := c.wantsBinaryResponse(headers)
	cacheEnabled := c.responseCacheEnabled(binaryResponse)
	// Check cache for GET requests
	if cacheEnabled {
		if cached, ok := c.readCache(path, params); ok {
			return cached, nil
		}
	}
	result, _, err := c.do(ctx, "GET", path, params, nil, headers)
	if err == nil && cacheEnabled {
		c.writeCache(path, params, result)
	}
	return result, err
}

func (c *Client) GetWithHeadersValues(ctx context.Context, path string, params url.Values, headers map[string]string) (json.RawMessage, error) {
	return c.GetWithHeaders(ctx, pathWithQueryValues(path, params), nil, headers)
}

// GetNoCache issues a GET that bypasses the cache read for this call only,
// then refreshes the cache with the fresh response on success. Use for
// polling-until-terminal patterns where every call must reflect current
// server state; the same (path, params) pair returning a stale
// "in-progress" snapshot from cache would lock the poll loop on the
// initial response. Writing-back on success means subsequent c.Get calls
// (e.g. a follow-up `... get <id>` after WaitForJob returns) see the
// terminal value, not the stale non-terminal snapshot left behind by the
// first poll.
func (c *Client) GetNoCache(ctx context.Context, path string, params map[string]string) (json.RawMessage, error) {
	return c.GetWithHeadersNoCache(ctx, path, params, nil)
}

// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but still
// writes cacheable fresh responses on success. See GetNoCache for when to
// prefer this over Get/GetWithHeaders.
func (c *Client) GetWithHeadersNoCache(ctx context.Context, path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
	binaryResponse := c.wantsBinaryResponse(headers)
	result, _, err := c.do(ctx, "GET", path, params, nil, headers)
	if err == nil && c.responseCacheEnabled(binaryResponse) {
		c.writeCache(path, params, result)
	}
	return result, err
}

func (c *Client) GetWithHeadersNoCacheValues(ctx context.Context, path string, params url.Values, headers map[string]string) (json.RawMessage, error) {
	return c.GetWithHeadersNoCache(ctx, pathWithQueryValues(path, params), nil, headers)
}

func (c *Client) responseCacheEnabled(binaryResponse bool) bool {
	return !binaryResponse && !c.NoCache && !c.DryRun && c.cacheDir != ""
}

// The response cache stores text/JSON bodies as <hash>.json. Binary callers
// opt out so opaque blobs do not land under a misleading extension.
func (c *Client) wantsBinaryResponse(headers map[string]string) bool {
	binaryResponse := false
	if c != nil && c.Config != nil {
		if value, ok := binaryResponseHeaderValue(c.Config.Headers); ok {
			binaryResponse = value
		}
	}
	if value, ok := binaryResponseHeaderValue(headers); ok {
		binaryResponse = value
	}
	return binaryResponse
}

func binaryResponseHeaderValue(headers map[string]string) (bool, bool) {
	found := false
	for k, v := range headers {
		if strings.EqualFold(k, BinaryResponseHeader) {
			found = true
			if strings.EqualFold(v, "true") {
				return true, true
			}
		}
	}
	return false, found
}

func (c *Client) validateCachedRequestAuth(ctx context.Context) error {
	if c == nil || c.Config == nil {
		return nil
	}
	clientID, clientSecret := resolveClientCredentials(c.Config)
	if authHeaderLooksLikePlaceholderCredential(clientID) || authHeaderLooksLikePlaceholderCredential(clientSecret) {
		return authPlaceholderCredentialError(c.Config)
	}
	if authHeaderLooksLikePlaceholderCredential(c.Config.AuthHeader()) {
		return authPlaceholderCredentialError(c.Config)
	}
	return nil
}

func (c *Client) ProbeGet(ctx context.Context, path string) (int, error) {
	_, status, err := c.do(ctx, "GET", path, nil, nil, nil)
	return status, err
}

func (c *Client) cacheKey(path string, params map[string]string) string {
	key := path
	key += "|base_url=" + c.BaseURL
	if c.Config != nil {
		key += "|auth_source=" + c.Config.AuthSource
		if authHeader := c.Config.AuthHeader(); authHeader != "" {
			authHash := sha256.Sum256([]byte(authHeader))
			key += "|auth=" + hex.EncodeToString(authHash[:8])
		}
		if c.Config.Path != "" {
			key += "|config_path=" + c.Config.Path
		}
	}
	if len(params) > 0 {
		query := url.Values{}
		for k, v := range params {
			query.Set(k, v)
		}
		key += "|query=" + query.Encode()
	}
	h := sha256.Sum256([]byte(key))
	return hex.EncodeToString(h[:8])
}

func pathWithQueryValues(path string, params url.Values) string {
	if len(params) == 0 {
		return path
	}
	encoded := params.Encode()
	if encoded == "" {
		return path
	}
	separator := "?"
	if strings.Contains(path, "?") {
		separator = "&"
	}
	return path + separator + encoded
}

func (c *Client) readCache(path string, params map[string]string) (json.RawMessage, bool) {
	cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
	info, err := os.Stat(cacheFile)
	if err != nil || time.Since(info.ModTime()) > 5*time.Minute {
		return nil, false
	}
	data, err := os.ReadFile(cacheFile)
	if err != nil {
		return nil, false
	}
	return json.RawMessage(data), true
}

func (c *Client) writeCache(path string, params map[string]string, data json.RawMessage) {
	os.MkdirAll(c.cacheDir, 0o700)
	cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
	os.WriteFile(cacheFile, []byte(data), 0o600)
}

// invalidateCache wholesale-removes the cache directory so the next read
// after a mutation cannot return a stale snapshot. Selective per-resource
// invalidation rejected: cache keys are opaque sha256 hashes.
func (c *Client) invalidateCache() {
	if c.cacheDir == "" {
		return
	}
	_ = os.RemoveAll(c.cacheDir)
}

func (c *Client) Post(ctx context.Context, path string, body any) (json.RawMessage, int, error) {
	return c.do(ctx, "POST", path, nil, body, nil)
}

func (c *Client) PostWithParams(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
	return c.do(ctx, "POST", path, params, body, nil)
}

func (c *Client) PostWithHeaders(ctx context.Context, path string, body any, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "POST", path, nil, body, headers)
}

func (c *Client) PostWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "POST", path, params, body, headers)
}

// PostQueryWithParams is a POST that does not mutate remote state — used
// by read-only operations that ride a mutating verb on the wire (GraphQL
// queries, JSON-RPC reads, POST-based search endpoints). The verify-mode
// short-circuit does not fire for these calls; the request reaches the
// real transport even under PRINTING_PRESS_VERIFY=1 without LIVE_HTTP=1.
func (c *Client) PostQueryWithParams(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
	return c.doRead(ctx, "POST", path, params, body, nil)
}

// PostQueryWithParamsAndHeaders is the headers-aware counterpart to
// PostQueryWithParams. See PostQueryWithParams for the verify-mode rationale.
func (c *Client) PostQueryWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
	return c.doRead(ctx, "POST", path, params, body, headers)
}

func (c *Client) Delete(ctx context.Context, path string) (json.RawMessage, int, error) {
	return c.do(ctx, "DELETE", path, nil, nil, nil)
}

func (c *Client) DeleteWithParams(ctx context.Context, path string, params map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "DELETE", path, params, nil, nil)
}

func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) (json.RawMessage, int, error) {
	return c.do(ctx, "DELETE", path, nil, body, nil)
}

func (c *Client) DeleteWithParamsAndBody(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
	return c.do(ctx, "DELETE", path, params, body, nil)
}

func (c *Client) DeleteWithHeaders(ctx context.Context, path string, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "DELETE", path, nil, nil, headers)
}

func (c *Client) DeleteWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "DELETE", path, params, nil, headers)
}

func (c *Client) DeleteWithBodyAndHeaders(ctx context.Context, path string, body any, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "DELETE", path, nil, body, headers)
}

func (c *Client) DeleteWithParamsAndBodyAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "DELETE", path, params, body, headers)
}

func (c *Client) Put(ctx context.Context, path string, body any) (json.RawMessage, int, error) {
	return c.do(ctx, "PUT", path, nil, body, nil)
}

func (c *Client) PutWithParams(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
	return c.do(ctx, "PUT", path, params, body, nil)
}

func (c *Client) PutWithHeaders(ctx context.Context, path string, body any, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "PUT", path, nil, body, headers)
}

func (c *Client) PutWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "PUT", path, params, body, headers)
}

func (c *Client) Patch(ctx context.Context, path string, body any) (json.RawMessage, int, error) {
	return c.do(ctx, "PATCH", path, nil, body, nil)
}

func (c *Client) PatchWithParams(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
	return c.do(ctx, "PATCH", path, params, body, nil)
}

func (c *Client) PatchWithHeaders(ctx context.Context, path string, body any, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "PATCH", path, nil, body, headers)
}

func (c *Client) PatchWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
	return c.do(ctx, "PATCH", path, params, body, headers)
}

// isMutatingVerb reports whether the HTTP method writes server state.
// Used by do()'s verify-mode short-circuit to gate dial-out: under
// PRINTING_PRESS_VERIFY=1 (without LIVE_HTTP=1 opt-in), generated
// commands must not actually issue mutating requests, even if a
// handler-level dry-run check was missed.
func isMutatingVerb(method string) bool {
	switch method {
	case "DELETE", "POST", "PUT", "PATCH":
		return true
	}
	return false
}

// verifyShortCircuitEnvelope returns the synthetic JSON body that
// stands in for a real mutating response when do() short-circuits in
// verify mode. The __pp_verify_synthetic__ sentinel is namespace-
// reserved (no real API uses __pp_*) so downstream consumers
// (validate-narrative, agent inspections) can key on one obvious field
// instead of trying to disambiguate common literals like status:"noop".
// method and path are echoed back as diagnostic prose for human/agent
// inspection.
func verifyShortCircuitEnvelope(method, path string) json.RawMessage {
	body, _ := json.Marshal(map[string]any{
		"__pp_verify_synthetic__": true,
		"status":                  "noop",
		"reason":                  "verify_short_circuit",
		"method":                  method,
		"path":                    path,
	})
	return json.RawMessage(body)
}

func sleepContext(ctx context.Context, wait time.Duration) error {
	timer := time.NewTimer(wait)
	defer timer.Stop()
	select {
	case <-timer.C:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

// do executes an HTTP request. headerOverrides, when non-nil, override global
// RequiredHeaders for this specific request (used for per-endpoint API versioning).
func (c *Client) do(ctx context.Context, method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
	return c.doInternal(ctx, method, path, params, body, headerOverrides, false)
}

// doRead is do() minus the verify-mode mutating-verb gate. Used by the
// PostQuery* family for read-only operations that ride a mutating verb on
// the wire (GraphQL queries, JSON-RPC reads, POST-based search endpoints).
// The wire verb is still POST/PUT/PATCH so the server sees a real request,
// but the verify-mode short-circuit does not fire because the operation
// does not mutate remote state.
func (c *Client) doRead(ctx context.Context, method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
	return c.doInternal(ctx, method, path, params, body, headerOverrides, true)
}

// doInternal is the shared implementation behind do() and doRead(). The
// readOnlyIntent flag is set by doRead() callers (read-only POST/PUT/PATCH
// operations like GraphQL queries) to skip the mutating-verb verify-mode
// gate. Plain do() callers leave it false and get the usual short-circuit.
func (c *Client) doInternal(ctx context.Context, method, path string, params map[string]string, body any, headerOverrides map[string]string, readOnlyIntent bool) (json.RawMessage, int, error) {
	// Verify-mode transport-layer gate. When the verifier (or any consumer
	// that sets PRINTING_PRESS_VERIFY=1) drives a mutating verb without
	// the LIVE_HTTP=1 opt-in, return a synthetic envelope without dialing,
	// minting auth, or touching the cache. The verify pipeline itself
	// sets both env vars in mock mode so its httptest server still sees
	// real requests; every other consumer gets a safe no-op.
	//
	// readOnlyIntent suppresses the gate for read-only operations that
	// happen to ride a mutating verb on the wire (GraphQL queries, JSON-RPC
	// reads, POST-based search endpoints). The handler-level annotation
	// `mcp:read-only: true` drives the codegen choice of doRead() vs do().
	//
	// Placement note: this fires BEFORE URL building, auth header
	// minting, and the success-branch invalidateCache() call below — so
	// no cache invalidation runs (no remote state changed) and no
	// client_credentials mint happens unnecessarily.
	if !readOnlyIntent && isMutatingVerb(method) && cliutil.IsVerifyEnv() && !cliutil.IsVerifyLiveHTTPEnv() {
		return verifyShortCircuitEnvelope(method, path), http.StatusOK, nil
	}
	if err := rejectUnresolvedPathParams(path, nil); err != nil {
		return nil, 0, err
	}
	targetURL := c.BaseURL + path

	var bodyBytes []byte
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return nil, 0, fmt.Errorf("marshaling body: %w", err)
		}
		bodyBytes = b
	}

	// Resolve auth material before the dry-run branch so --dry-run can preview
	// exactly what would be sent. Uses only cached credentials; a token that
	// requires a network refresh will be re-fetched on the live request path,
	// not during dry-run.
	authHeader, err := c.authHeader(ctx)
	if err != nil {
		return nil, 0, err
	}

	// Build the request for dry-run display or actual execution
	if c.DryRun {
		return c.dryRun(method, targetURL, path, params, bodyBytes, headerOverrides, authHeader)
	}

	maxRetries := clientMaxRetries()
	var lastErr error
	refreshedAfterUnauthorized := false

	for attempt := 0; attempt <= maxRetries; attempt++ {
		// Proactive rate limiting — wait before sending
		c.limiter.Wait()
		var bodyReader io.Reader
		if bodyBytes != nil {
			bodyReader = strings.NewReader(string(bodyBytes))
		}

		req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
		if err != nil {
			return nil, 0, fmt.Errorf("creating request: %w", err)
		}
		if bodyBytes != nil {
			req.Header.Set("Content-Type", "application/json")
		}

		if params != nil {
			q := req.URL.Query()
			for k, v := range params {
				if v != "" {
					q.Set(k, v)
				}
			}
			req.URL.RawQuery = q.Encode()
		}

		if authHeader != "" {
			req.Header.Set("Authorization", authHeader)
		}
		if c.Config != nil {
			for k, v := range c.Config.Headers {
				req.Header.Set(k, v)
			}
		}
		// Per-endpoint header overrides (e.g., different API version per resource)
		for k, v := range headerOverrides {
			req.Header.Set(k, v)
		}
		binaryResponse := strings.EqualFold(req.Header.Get(BinaryResponseHeader), "true")
		if binaryResponse {
			req.Header.Del(BinaryResponseHeader)
		}
		if req.Header.Get("User-Agent") == "" {
			req.Header.Set("User-Agent", "printing-press-oauth2-pp-cli/1.0.0")
		}
		// Go's net/http omits Accept by default; browsers, curl, and other
		// stdlibs always send it. Fingerprint-checking WAFs (Imperva, Akamai,
		// Cloudflare bot-mode, DataDome) flag the absence as a bot signal
		// and answer with empty-body 5xx, 403, or a challenge redirect
		// depending on vendor and rule tier. The value is application/json
		// rather than */* because strict-JSON APIs (Zendesk, Atlassian REST,
		// Salesforce) return 415 on anything that isn't literally
		// application/json; specs that need a different content type
		// (vendor mediatypes, XML, HTML) declare it via RequiredHeaders or
		// per-endpoint headerOverrides, both of which run before this
		// if-empty default.
		if req.Header.Get("Accept") == "" {
			if binaryResponse {
				req.Header.Set("Accept", "*/*")
			} else {
				req.Header.Set("Accept", "application/json")
			}
		}

		resp, err := c.HTTPClient.Do(req)
		if err != nil {
			if ctxErr := ctx.Err(); ctxErr != nil {
				return nil, 0, ctxErr
			}
			lastErr = fmt.Errorf("%s %s: %w", method, c.displayURL(path, authHeader), c.maskError(err, authHeader))
			continue
		}

		respBody, err := io.ReadAll(resp.Body)
		resp.Body.Close()
		if err != nil {
			return nil, 0, fmt.Errorf("reading response: %w", err)
		}

		// Success
		if resp.StatusCode < 400 {
			c.limiter.OnSuccess()
			if method != http.MethodGet && !c.DryRun {
				c.invalidateCache()
			}
			// Non-textual bodies (PDF, zip, image, octet-stream) must not be
			// run through the JSON sanitizer or returned as raw json.RawMessage
			// — return a self-describing base64 envelope instead. Textual and
			// JSON responses fall through to the unchanged path.
			if isBinaryResponseContentType(resp.Header.Get("Content-Type")) {
				env, encErr := wrapBinaryResponse(resp.Header.Get("Content-Type"), respBody)
				if encErr != nil {
					return nil, 0, encErr
				}
				return env, resp.StatusCode, nil
			}
			return json.RawMessage(sanitizeJSONResponse(respBody)), resp.StatusCode, nil
		}

		if !binaryResponse {
			respBody = sanitizeJSONResponse(respBody)
		}

		apiErr := &APIError{
			Method:     method,
			Path:       c.displayURL(path, authHeader),
			StatusCode: resp.StatusCode,
			Body:       c.maskCredentialText(truncateBody(respBody), authHeader),
		}
		// OAuth providers can expire tokens early, omit expires_in, or disagree
		// with the local clock. Retry one unauthorized response after refreshing.
		if resp.StatusCode == http.StatusUnauthorized && !refreshedAfterUnauthorized && attempt < maxRetries && c.Config != nil && c.Config.RefreshToken != "" && !(cliutil.IsVerifyEnv() && !cliutil.IsVerifyLiveHTTPEnv()) {
			if authHeaderLooksLikePlaceholderCredential(c.Config.AccessToken) || authHeaderLooksLikePlaceholderCredential(c.Config.RefreshToken) || authHeaderLooksLikePlaceholderCredential(c.Config.ClientID) || authHeaderLooksLikePlaceholderCredential(c.Config.ClientSecret) {
				return nil, resp.StatusCode, authPlaceholderCredentialError(c.Config)
			}
			if err := c.refreshAccessToken(ctx); err != nil {
				return nil, resp.StatusCode, fmt.Errorf("refreshing access token after 401: %w", err)
			}
			authHeader = c.Config.AuthHeader()
			if authHeaderLooksLikePlaceholderCredential(authHeader) {
				return nil, resp.StatusCode, authPlaceholderCredentialError(c.Config)
			}
			refreshedAfterUnauthorized = true
			lastErr = apiErr
			continue
		}

		// Rate limited - adjust adaptive limiter and retry
		if resp.StatusCode == 429 && attempt < maxRetries {
			c.limiter.OnRateLimit()
			wait := cliutil.RetryAfter(resp)
			fmt.Fprintf(os.Stderr, "rate limited, waiting %s (attempt %d/%d, rate adjusted to %.1f req/s)\n", wait, attempt+1, maxRetries, c.limiter.Rate())
			if err := sleepContext(ctx, wait); err != nil {
				return nil, 0, err
			}
			lastErr = apiErr
			continue
		}

		// Server error - retry with backoff
		if resp.StatusCode >= 500 && attempt < maxRetries {
			wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second
			fmt.Fprintf(os.Stderr, "server error %d, retrying in %s (attempt %d/%d)\n", resp.StatusCode, wait, attempt+1, maxRetries)
			if err := sleepContext(ctx, wait); err != nil {
				return nil, 0, err
			}
			lastErr = apiErr
			continue
		}

		// Client error or retries exhausted - return the error
		return nil, resp.StatusCode, apiErr
	}

	return nil, 0, lastErr
}

// dryRun prints the outgoing request exactly as the live path would send it,
// using the auth material already resolved in `do()`. Never triggers a network
// call — the caller is responsible for passing cached auth material only.
func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte, headerOverrides map[string]string, authHeader string) (json.RawMessage, int, error) {
	fmt.Fprintf(os.Stderr, "%s %s\n", method, c.displayURL(targetURL, authHeader))
	queryPrinted := false
	if params != nil {
		keys := make([]string, 0, len(params))
		for k := range params {
			if params[k] != "" {
				keys = append(keys, k)
			}
		}
		sort.Strings(keys)
		for _, k := range keys {
			sep := "?"
			if queryPrinted {
				sep = "&"
			}
			fmt.Fprintf(os.Stderr, "  %s%s=%s\n", sep, k, c.maskCredentialText(params[k], authHeader))
			queryPrinted = true
		}
	}
	_ = queryPrinted
	if body != nil {
		var pretty json.RawMessage
		if json.Unmarshal(body, &pretty) == nil {
			enc := json.NewEncoder(os.Stderr)
			enc.SetIndent("  ", "  ")
			fmt.Fprintf(os.Stderr, "  Body:\n")
			enc.Encode(pretty)
		}
	}
	if authHeader != "" {
		fmt.Fprintf(os.Stderr, "  %s: %s\n", "Authorization", maskToken(authHeader))
	}
	fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
	return json.RawMessage(`{"dry_run": true}`), 0, nil
}

func (c *Client) ConfiguredTimeout() time.Duration {
	if c.HTTPClient != nil && c.HTTPClient.Timeout > 0 {
		return c.HTTPClient.Timeout
	}
	return 60 * time.Second
}

func (c *Client) authHeader(ctx context.Context) (string, error) {
	if c.Config == nil {
		return "", nil
	}
	if c.Config.AccessToken == "" && cliutil.IsVerifyEnv() {
		c.Config.AccessToken = "mock-token-for-testing"
		return c.Config.AuthHeader(), nil
	}
	// 60s window avoids in-flight requests racing the expiry boundary.
	// Double-checked lock so only one goroutine mints under contention.
	if needsClientCredentialsMint(c.Config) {
		if c.ccMu == nil {
			c.ccMu = &sync.Mutex{}
		}
		c.ccMu.Lock()
		if needsClientCredentialsMint(c.Config) {
			clientID, clientSecret := resolveClientCredentials(c.Config)
			if clientID != "" && clientSecret != "" {
				if authHeaderLooksLikePlaceholderCredential(clientID) || authHeaderLooksLikePlaceholderCredential(clientSecret) {
					c.ccMu.Unlock()
					return "", authPlaceholderCredentialError(c.Config)
				}
				if err := c.mintClientCredentials(ctx, clientID, clientSecret); err != nil {
					c.ccMu.Unlock()
					return "", err
				}
			}
		}
		c.ccMu.Unlock()
	}
	authHeader := c.Config.AuthHeader()
	if authHeaderLooksLikePlaceholderCredential(authHeader) {
		return "", authPlaceholderCredentialError(c.Config)
	}
	return authHeader, nil
}

func authHeaderLooksLikePlaceholderCredential(header string) bool {
	if scheme, encoded, ok := strings.Cut(strings.TrimSpace(header), " "); ok && strings.EqualFold(scheme, "Basic") {
		encoded = strings.TrimSpace(encoded)
		decoded, err := base64.StdEncoding.DecodeString(encoded)
		if err == nil && authHeaderLooksLikePlaceholderCredential(string(decoded)) {
			return true
		}
	}
	if !strings.Contains(header, "<") && !strings.Contains(header, "YOUR_TOKEN_HERE") && !strings.Contains(header, "your-token") && !strings.Contains(header, "your-key") {
		return false
	}
	for _, field := range strings.Fields(header) {
		field = strings.Trim(field, `"'`)
		if idx := strings.LastIndex(field, "="); idx >= 0 {
			field = field[idx+1:]
		}
		if idx := strings.Index(field, ":"); idx >= 0 {
			if looksLikeCredentialPlaceholder(field[:idx]) || looksLikeCredentialPlaceholder(field[idx+1:]) {
				return true
			}
		}
		if looksLikeCredentialPlaceholder(field) {
			return true
		}
	}
	return looksLikeCredentialPlaceholder(header)
}

func looksLikeCredentialPlaceholder(value string) bool {
	value = strings.Trim(strings.TrimSpace(value), `"'`)
	switch value {
	case "<your-token>", "<your-key>", "<paste-your-key>", "YOUR_TOKEN_HERE", "your-token-here":
		return true
	}
	if len(value) < 3 || value[0] != '<' || value[len(value)-1] != '>' {
		return false
	}
	for _, r := range value[1 : len(value)-1] {
		if r != '_' && (r < 'A' || r > 'Z') {
			return false
		}
	}
	return true
}

func authPlaceholderCredentialError(cfg *config.Config) error {
	return authPlaceholderCredentialErrorWithSetup(cfg, "printing-press-oauth2-pp-cli auth login or printing-press-oauth2-pp-cli auth set-token <token>")
}

func authPlaceholderCredentialErrorWithSetup(cfg *config.Config, setup string) error {
	location := "config file"
	if cfg != nil && cfg.Path != "" {
		location = cfg.Path
	}
	return fmt.Errorf("%w configured in %s; set a real token with: %s", ErrPlaceholderCredential, location, setup)
}

func needsClientCredentialsMint(cfg *config.Config) bool {
	if cfg.AccessToken == "" {
		return true
	}
	if cfg.TokenExpiry.IsZero() {
		return false
	}
	return time.Until(cfg.TokenExpiry) < 60*time.Second
}

// resolveClientCredentials prefers what was passed to `auth login` (cached
// in config) and falls back to environment variables so first-time users
// can ship without a separate login step.
func resolveClientCredentials(cfg *config.Config) (string, string) {
	id := cfg.ClientID
	secret := cfg.ClientSecret
	if id == "" {
		id = os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_ID")
	}
	if secret == "" {
		secret = os.Getenv("PRINTING_PRESS_OAUTH2_CLIENT_SECRET")
	}
	return id, secret
}

func resolveClientCredentialsScope() string {
	if scope := os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH_SCOPE"); scope != "" {
		return scope
	}
	return "read write"
}

func resolveClientCredentialsUserAgent() string {
	if ua := strings.TrimSpace(os.Getenv("PRINTING_PRESS_OAUTH2_USER_AGENT")); ua != "" {
		return ua
	}
	return "printing-press-oauth2-pp-cli/1.0.0"
}

func (c *Client) mintClientCredentials(ctx context.Context, clientID, clientSecret string) error {
	tokenURL := ""
	if c.Config != nil {
		tokenURL = c.Config.TokenURL
	}
	if tokenURL == "" {
		tokenURL = "https://api.cc.example/oauth/token"
	}
	if tokenURL == "" {
		return nil
	}
	form := url.Values{
		"grant_type": {"client_credentials"},
	}
	if scope := resolveClientCredentialsScope(); scope != "" {
		form.Set("scope", scope)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
	if err != nil {
		return fmt.Errorf("building token request: %w", err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("User-Agent", resolveClientCredentialsUserAgent())
	req.SetBasicAuth(clientID, clientSecret)
	resp, err := c.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("calling token endpoint: %w", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode >= 400 {
		return fmt.Errorf("OAuth client_credentials mint: HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(string(body)))
	}
	var tokenResp struct {
		AccessToken string `json:"access_token"`
		ExpiresIn   int    `json:"expires_in"`
	}
	if err := json.Unmarshal(body, &tokenResp); err != nil {
		return fmt.Errorf("parsing token response: %w", err)
	}
	if tokenResp.AccessToken == "" {
		return fmt.Errorf("OAuth client_credentials mint: response missing access_token")
	}
	// Guard against non-conformant servers that return expires_in: 0.
	// A zero-duration expiry resolves to time.Now(), which authHeader()
	// then treats as expired, causing every request to re-mint in a loop.
	expiry := time.Time{}
	if tokenResp.ExpiresIn > 0 {
		expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
	}
	c.Config.AuthHeaderVal = "" // force AuthHeader() to use the new AccessToken path
	if err := c.Config.SaveTokens(clientID, clientSecret, tokenResp.AccessToken, "", expiry); err != nil {
		return fmt.Errorf("saving minted token: %w", err)
	}
	return nil
}

func (c *Client) refreshAccessToken(ctx context.Context) error {
	if c.Config == nil {
		return nil
	}
	if c.Config.RefreshToken == "" {
		return nil
	}
	if strings.TrimSpace(c.Config.ClientID) == "" {
		return fmt.Errorf("refreshing access token: OAuth2 client ID is required when a refresh token is configured; set client_id in config or the client ID environment variable")
	}

	tokenURL := c.Config.TokenURL
	if tokenURL == "" {
		tokenURL = "https://api.cc.example/oauth/token"
	}

	params := url.Values{
		"grant_type":    {"refresh_token"},
		"refresh_token": {c.Config.RefreshToken},
		"client_id":     {c.Config.ClientID},
	}
	if c.Config.ClientSecret != "" {
		params.Set("client_secret", c.Config.ClientSecret)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(params.Encode()))
	if err != nil {
		return fmt.Errorf("building refresh request: %w", err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := c.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("refreshing access token: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		body, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("refreshing access token: HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(truncateBody(body)))
	}

	var tokenResp struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
		ExpiresIn    int    `json:"expires_in"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
		return fmt.Errorf("parsing refresh response: %w", err)
	}
	if tokenResp.AccessToken == "" {
		return fmt.Errorf("refreshing access token: no access token in response")
	}

	refreshToken := c.Config.RefreshToken
	if tokenResp.RefreshToken != "" {
		refreshToken = tokenResp.RefreshToken
	}

	expiry := time.Time{}
	if tokenResp.ExpiresIn > 0 {
		expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
	}

	c.Config.AuthHeaderVal = "" // force AuthHeader() to use the refreshed AccessToken path
	if err := c.Config.SaveTokens(c.Config.ClientID, c.Config.ClientSecret, tokenResp.AccessToken, refreshToken, expiry); err != nil {
		return fmt.Errorf("saving refreshed token: %w", err)
	}

	return nil
}

// binaryResponseEnvelope wraps a non-textual success body so it survives the
// json.RawMessage contract every consumer (CLI output, --json, MCP tools)
// depends on. Without it, raw bytes (PDF, zip, image) are corrupted by
// sanitizeJSONResponse and emitted as invalid JSON. The _pp_binary
// discriminator lets callers and agents detect and base64-decode the payload.
type binaryResponseEnvelope struct {
	PPBinary    bool   `json:"_pp_binary"`
	ContentType string `json:"content_type"`
	Encoding    string `json:"encoding"`
	Bytes       int    `json:"bytes"`
	Data        string `json:"data"`
}

// isBinaryResponseContentType reports whether a successful response with this
// Content-Type must be base64-wrapped instead of treated as text/JSON. It is
// deliberately narrow: JSON, */*, XML, and every text/* type (including
// text/html, so response_format:html CLIs are untouched) pass through
// unchanged. Only genuinely binary payloads are wrapped.
func isBinaryResponseContentType(ct string) bool {
	mt := strings.ToLower(strings.TrimSpace(ct))
	if i := strings.IndexByte(mt, ';'); i >= 0 {
		mt = strings.TrimSpace(mt[:i])
	}
	if mt == "" {
		return false
	}
	switch {
	case mt == "application/json", mt == "text/json", mt == "*/*":
		return false
	case strings.HasPrefix(mt, "text/"):
		return false
	case strings.HasSuffix(mt, "+json"), strings.HasSuffix(mt, "+xml"):
		return false
	case mt == "application/xml", mt == "application/xhtml+xml":
		return false
	case mt == "application/javascript", mt == "application/ecmascript",
		mt == "application/x-www-form-urlencoded", mt == "application/graphql":
		return false
	}
	return true
}

// wrapBinaryResponse marshals body into a self-describing base64 envelope.
func wrapBinaryResponse(ct string, body []byte) (json.RawMessage, error) {
	out, err := json.Marshal(binaryResponseEnvelope{
		PPBinary:    true,
		ContentType: ct,
		Encoding:    "base64",
		Bytes:       len(body),
		Data:        base64.StdEncoding.EncodeToString(body),
	})
	if err != nil {
		return nil, fmt.Errorf("encoding binary response: %w", err)
	}
	return json.RawMessage(out), nil
}

// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
// response bodies so that downstream JSON parsing succeeds. For clean JSON
// responses these checks are no-ops.
func sanitizeJSONResponse(body []byte) []byte {
	// UTF-8 BOM
	body = bytes.TrimPrefix(body, []byte("\xEF\xBB\xBF"))

	// JSONP/XSSI prefixes, ordered longest-first where prefixes overlap
	prefixes := [][]byte{
		[]byte(")]}'\n"),
		[]byte(")]}'"),
		[]byte("{}&&"),
		[]byte("for(;;);"),
		[]byte("while(1);"),
	}
	for _, p := range prefixes {
		if bytes.HasPrefix(body, p) {
			body = bytes.TrimPrefix(body, p)
			body = bytes.TrimLeft(body, " \t\r\n")
			break
		}
	}
	return body
}

// maskToken redacts all but the last 4 characters of a token for safe display.
func maskToken(token string) string {
	if token == "" {
		return ""
	}
	if len(token) <= 4 {
		return "****"
	}
	return "****" + token[len(token)-4:]
}

type maskedError struct {
	msg string
}

func (e maskedError) Error() string {
	return e.msg
}

func (c *Client) displayURL(rawURL string, extraCredentials ...string) string {
	return c.maskCredentialText(rawURL, extraCredentials...)
}

func (c *Client) maskError(err error, extraCredentials ...string) error {
	if err == nil {
		return nil
	}
	raw := err.Error()
	msg := c.maskCredentialText(raw, extraCredentials...)
	if msg == raw {
		return err
	}
	return maskedError{msg: msg}
}

func (c *Client) maskCredentialText(text string, extraCredentials ...string) string {
	if text == "" {
		return text
	}
	type credentialMask struct {
		needle      string
		replacement string
	}
	var masks []credentialMask
	seen := map[string]struct{}{}
	addValue := func(value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		replacement := maskToken(value)
		addMask := func(needle string) {
			if needle == "" {
				return
			}
			if _, ok := seen[needle]; ok {
				return
			}
			seen[needle] = struct{}{}
			masks = append(masks, credentialMask{needle: needle, replacement: replacement})
		}
		addMask(value)
		if escaped := url.QueryEscape(value); escaped != value {
			addMask(escaped)
		}
		if escaped := url.PathEscape(value); escaped != value {
			addMask(escaped)
		}
	}
	addCredential := func(value string) {
		value = strings.TrimSpace(value)
		addValue(value)
		if _, token, ok := strings.Cut(value, " "); ok {
			addValue(token)
		}
	}
	for _, value := range extraCredentials {
		addCredential(value)
	}
	if c != nil && c.Config != nil {
		addCredential(c.Config.AuthHeaderVal)
		addCredential(c.Config.AuthHeader())
		addCredential(c.Config.AccessToken)
		addCredential(c.Config.RefreshToken)
		addCredential(c.Config.ClientSecret)
		addCredential(c.Config.PrintingPressOauth2ClientSecret)
	}
	sort.SliceStable(masks, func(i, j int) bool {
		return len(masks[i].needle) > len(masks[j].needle)
	})
	masked := text
	for _, mask := range masks {
		masked = strings.ReplaceAll(masked, mask.needle, mask.replacement)
	}
	return masked
}

func truncateBody(b []byte) string {
	const maxBytes = 4096
	if len(b) <= maxBytes {
		return string(b)
	}
	return strings.ToValidUTF8(string(b[:maxBytes]), "") + "..."
}

func clientMaxRetries() int {
	if cliutil.IsVerifyEnv() || cliutil.IsDogfoodEnv() {
		return 0
	}
	return 3
}
