package apiclient

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"mime"
	"mime/multipart"
	"net/http"
	"net/textproto"
	"net/url"
	"os"
	"path/filepath"
	"reflect"
	"strings"
)

// ---------------------------------------------------------------------------
// AgentTeam HTTP API client (for MCP bridge proxy calls)
// ---------------------------------------------------------------------------

const (
	maxAPIErrorBodyBytes       = 64 * 1024
	maxResponseDrainBytes      = 64 * 1024
	maxAttachmentDownloadBytes = MaxUploadBytes
	routeWorkspaceHeader       = "X-AgentTeam-Route-Workspace-Id"
)

// ErrResponseTooLarge identifies a response rejected before JSON decoding.
// Callers classify it without exposing wrapped transport details.
var ErrResponseTooLarge = errors.New("response exceeds configured byte budget")

// MaxUploadBytes is the desktop-side payload ceiling for AgentTeam file and
// attachment transfers. Keeping it with the HTTP API client makes the boundary
// explicit: callers can preflight local files using the same limit that the
// API client enforces for uploads and downloads.
const MaxUploadBytes = 10 * 1024 * 1024

// MaxUploadSizeLabel is the human-readable form of MaxUploadBytes for MCP tool
// descriptions and preflight errors.
const MaxUploadSizeLabel = "10 MiB"

// SplitMessageText splits outbound message text on UTF-8 boundaries,
// preferring the latest newline within each byte budget.
func SplitMessageText(text string, maxBytes int) []string {
	if maxBytes <= 0 || len(text) <= maxBytes {
		return []string{text}
	}

	runes := []rune(text)
	chunks := make([]string, 0, (len(text)/maxBytes)+1)

	for start := 0; start < len(runes); {
		currentBytes := 0
		end := start
		for end < len(runes) {
			nextBytes := len(string(runes[end]))
			if currentBytes > 0 && currentBytes+nextBytes > maxBytes {
				break
			}
			currentBytes += nextBytes
			end++
		}
		if end >= len(runes) {
			chunks = append(chunks, string(runes[start:]))
			break
		}

		splitAt := end
		for i := end; i > start; i-- {
			if runes[i-1] == '\n' {
				splitAt = i
				break
			}
		}
		if splitAt <= start {
			splitAt = end
		}

		chunks = append(chunks, string(runes[start:splitAt]))
		start = splitAt
	}

	return chunks
}

// Client provides authenticated access to AgentTeam server HTTP APIs.
type Client struct {
	ServerURL        string
	AgentID          string
	LocalAgentID     string
	AuthToken        string
	RouteWorkspaceID string
	// MachineID is the machine identity this channel runs on, populated from
	// profile.AgentTeamServer.MachineID at Protocol.New() time. Tools that need a
	// machine identity (e.g., project_create, review_add) read this field
	// instead of asking the model to supply one. Empty when the channel has
	// not been bound to a machine yet.
	MachineID  string
	HTTPClient *http.Client
	// StreamingHTTPClient is reserved for request bodies whose upload may
	// legitimately outlive the control-plane client's total timeout.
	StreamingHTTPClient *http.Client
	RecordAPIRetry      func()
	TargetResolver      *TargetResolver
	// retry contains package-private timing hooks used by deterministic tests.
	// Production clients leave it nil and use the default retry policy.
	retry *retryConfig
	// RefreshAuthToken is called after an authenticated request receives HTTP
	// 401. The callback receives the token used for the failed request and
	// returns a fresh token. When present, the client updates AuthToken and
	// retries the failed request once.
	RefreshAuthToken func(context.Context, string) (string, error)
}

// AttachmentDownload is one attachment payload plus response metadata needed by
// runtime bridges to preserve filename and MIME hints.
type AttachmentDownload struct {
	Data        []byte
	ContentType string
	Filename    string
}

// isRetryable reports whether an HTTP status code represents a transient
// failure that a replay-safe request may retry.
func isRetryable(statusCode int) bool {
	switch statusCode {
	case http.StatusRequestTimeout,
		http.StatusTooManyRequests,
		http.StatusInternalServerError,
		http.StatusBadGateway,
		http.StatusServiceUnavailable,
		http.StatusGatewayTimeout:
		return true
	default:
		return false
	}
}

func closeResponseBody(body io.ReadCloser) {
	if body == nil {
		return
	}
	if _, err := io.CopyN(io.Discard, body, maxResponseDrainBytes); err != nil && !errors.Is(err, io.EOF) {
		slog.Debug("agentteam api: failed to drain response body", "error", err)
	}
	if err := body.Close(); err != nil {
		slog.Debug("agentteam api: failed to close response body", "error", err)
	}
}

type agentteamRequestContext struct {
	AuthToken        string
	RouteWorkspaceID string
}

func (a *Client) requestPath(path string) string {
	return canonicalRuntimePath(path, a.RouteWorkspaceID)
}

// AgentRuntimePath returns the canonical server path for agent runtime calls.
func (a *Client) AgentRuntimePath(suffix string) string {
	return agentRuntimePath(a.RouteWorkspaceID, a.AgentID, suffix)
}

func agentRuntimePath(workspaceID string, agentID string, suffix string) string {
	if !strings.HasPrefix(suffix, "/") {
		suffix = "/" + suffix
	}
	escapedAgentID := url.PathEscape(strings.TrimSpace(agentID))
	return "/api/v1/workspaces/" + url.PathEscape(strings.TrimSpace(workspaceID)) + "/agents/" + escapedAgentID + "/runtime" + suffix
}

func canonicalRuntimePath(path string, workspaceID string) string {
	workspaceID = strings.TrimSpace(workspaceID)
	if workspaceID == "" {
		return path
	}
	if rewritten, ok := canonicalAppRuntimePath(path, workspaceID); ok {
		return rewritten
	}
	return path
}

func canonicalAppRuntimePath(path string, workspaceID string) (string, bool) {
	const prefix = "/internal/app/"
	if !strings.HasPrefix(path, prefix) {
		return "", false
	}
	rest := strings.TrimPrefix(path, prefix)
	installationID, suffix, ok := strings.Cut(rest, "/")
	if strings.TrimSpace(installationID) == "" {
		return "", false
	}
	if ok {
		suffix = "/" + suffix
	}
	switch {
	case strings.HasPrefix(suffix, "/message_history_read"):
		suffix = "/history" + strings.TrimPrefix(suffix, "/message_history_read")
	case strings.HasPrefix(suffix, "/message_send"):
		suffix = "/messages" + strings.TrimPrefix(suffix, "/message_send")
	}
	return "/api/v1/workspaces/" + url.PathEscape(workspaceID) + "/app-installations/" + url.PathEscape(installationID) + "/runtime" + suffix, true
}

func (a *Client) setRequestHeaders(req *http.Request, path string) {
	a.agentteamRequestContext().apply(req, path)
}

func (a *Client) agentteamRequestContext() agentteamRequestContext {
	return agentteamRequestContext{
		AuthToken:        a.AuthToken,
		RouteWorkspaceID: a.RouteWorkspaceID,
	}
}

func (requestContext agentteamRequestContext) apply(req *http.Request, path string) {
	if req == nil {
		return
	}
	setAgentTeamAuthHeader(req, requestContext.AuthToken)
	setRouteWorkspaceHeader(req, path, requestContext.RouteWorkspaceID)
}

func setAgentTeamAuthHeader(req *http.Request, authToken string) {
	req.Header.Set("Authorization", "Bearer "+authToken)
}

func setRouteWorkspaceHeader(req *http.Request, path string, workspaceID string) {
	if req == nil || !shouldRouteByWorkspace(path) {
		return
	}
	if workspaceID = strings.TrimSpace(workspaceID); workspaceID != "" {
		req.Header.Set(routeWorkspaceHeader, workspaceID)
	}
}

func shouldRouteByWorkspace(path string) bool {
	return strings.HasPrefix(path, "/internal/app/")
}

// ---------------------------------------------------------------------------
// AgentTeamAPIClient methods
// ---------------------------------------------------------------------------

// Get sends an authenticated GET request to the AgentTeam server. GET is
// replay-safe, so transient HTTP and transport failures use bounded backoff.
func (a *Client) Get(ctx context.Context, path string, result any) error {
	return a.execute(ctx, "GET "+path, attemptReplaySafe, func() error {
		return a.doGet(ctx, path, result)
	})
}

// doGet performs a single GET request without retry.
func (a *Client) doGet(ctx context.Context, path string, result any) error {
	requestPath := a.requestPath(path)
	url := a.ServerURL + requestPath
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
	if err != nil {
		return fmt.Errorf("agentteam api get: %w", err)
	}
	a.setRequestHeaders(req, requestPath)

	resp, err := a.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("agentteam api get: %w", &transportError{err: err})
	}
	defer closeResponseBody(resp.Body)

	if resp.StatusCode >= 400 {
		return apiErrorFromResponse(resp, http.MethodGet, requestPath)
	}
	if result != nil {
		target, commit, err := temporaryJSONDecodeTarget(result)
		if err != nil {
			return fmt.Errorf("agentteam api get: decode response: %w", err)
		}
		if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
			return fmt.Errorf("agentteam api get: decode response: %w", err)
		}
		commit()
	}
	return nil
}

// Post sends an authenticated POST request with a JSON body to the AgentTeam server.
// Mutating requests are not retried automatically: a lost response after the
// server commits the side effect would otherwise replay the mutation.
func (a *Client) Post(ctx context.Context, path string, body any, result any) error {
	return a.execute(ctx, "POST "+path, attemptAuthRefreshOnly, func() error {
		return a.doPost(ctx, path, body, result)
	})
}

// PostWithHeaders sends an authenticated POST request with additional headers.
// Like Post, it avoids transparent retries for non-idempotent mutations.
func (a *Client) PostWithHeaders(ctx context.Context, path string, body any, result any, headers map[string]string) error {
	return a.execute(ctx, "POST "+path, attemptAuthRefreshOnly, func() error {
		return a.doPostWithHeaders(ctx, path, body, result, headers, 0)
	})
}

// PostIdempotentWithHeaders sends a receipt-backed POST request with bounded
// backoff for transient transport or server failures. The JSON body and
// caller-provided headers are copied exactly once and reused for every attempt,
// so the server can resolve a lost response through its durable idempotency
// receipt. Callers must not use this method for mutations without a durable
// receipt.
func (a *Client) PostIdempotentWithHeaders(ctx context.Context, path string, body any, result any, headers map[string]string) error {
	data, err := json.Marshal(body)
	if err != nil {
		return fmt.Errorf("agentteam api post: %w", err)
	}
	stableHeaders, err := canonicalReplayHeaders(headers)
	if err != nil {
		return fmt.Errorf("agentteam api post: %w", err)
	}
	return a.execute(ctx, "POST "+path, attemptReplaySafe, func() error {
		return a.doPostJSONWithHeaders(ctx, path, data, result, stableHeaders, 0)
	})
}

func canonicalReplayHeaders(headers map[string]string) (map[string]string, error) {
	stable := make(map[string]string, len(headers))
	for rawKey, value := range headers {
		key := strings.TrimSpace(rawKey)
		if key == "" || strings.TrimSpace(value) == "" {
			continue
		}
		// Lowercasing before HTTP canonicalization makes the output independent
		// of map iteration even for non-standard keys.
		key = http.CanonicalHeaderKey(strings.ToLower(key))
		if existing, ok := stable[key]; ok {
			if existing != value {
				return nil, fmt.Errorf("conflicting values for header %q", key)
			}
			continue
		}
		stable[key] = value
	}
	return stable, nil
}

// PostWithHeadersResponseLimit is PostWithHeaders with a success response body
// cap applied before JSON decoding.
func (a *Client) PostWithHeadersResponseLimit(ctx context.Context, path string, body any, result any, headers map[string]string, maxResponseBytes int64) error {
	return a.execute(ctx, "POST "+path, attemptAuthRefreshOnly, func() error {
		return a.doPostWithHeaders(ctx, path, body, result, headers, maxResponseBytes)
	})
}

// Put sends an authenticated PUT request with a JSON body to the AgentTeam server.
// It is treated as a mutation and is not retried automatically.
func (a *Client) Put(ctx context.Context, path string, body any, result any) error {
	return a.execute(ctx, "PUT "+path, attemptAuthRefreshOnly, func() error {
		return a.doPut(ctx, path, body, result)
	})
}

// Patch sends an authenticated PATCH request with a JSON body to the AgentTeam server.
// It is treated as a mutation and is not retried automatically.
func (a *Client) Patch(ctx context.Context, path string, body any, result any) error {
	return a.execute(ctx, "PATCH "+path, attemptAuthRefreshOnly, func() error {
		return a.doPatch(ctx, path, body, result)
	})
}

// PatchIdempotentWithHeaders sends a receipt-backed PATCH with bounded replay.
// Callers must provide a server-supported stable selector in headers.
func (a *Client) PatchIdempotentWithHeaders(ctx context.Context, path string, body any, result any, headers map[string]string) error {
	data, err := json.Marshal(body)
	if err != nil {
		return fmt.Errorf("agentteam api patch: %w", err)
	}
	stableHeaders, err := canonicalReplayHeaders(headers)
	if err != nil {
		return fmt.Errorf("agentteam api patch: %w", err)
	}
	return a.execute(ctx, "PATCH "+path, attemptReplaySafe, func() error {
		return a.doPatchJSONWithHeaders(ctx, path, data, result, stableHeaders)
	})
}

// Delete sends an authenticated DELETE request to the AgentTeam server.
// It is treated as a mutation and is not retried automatically.
func (a *Client) Delete(ctx context.Context, path string, result any) error {
	return a.execute(ctx, "DELETE "+path, attemptAuthRefreshOnly, func() error {
		return a.doDelete(ctx, path, result)
	})
}

// doPost performs a single POST request without retry.
func (a *Client) doPost(ctx context.Context, path string, body any, result any) error {
	return a.doPostWithHeaders(ctx, path, body, result, nil, 0)
}

func (a *Client) doPostWithHeaders(ctx context.Context, path string, body any, result any, headers map[string]string, maxResponseBytes int64) error {
	data, err := json.Marshal(body)
	if err != nil {
		return fmt.Errorf("agentteam api post: %w", err)
	}
	return a.doPostJSONWithHeaders(ctx, path, data, result, headers, maxResponseBytes)
}

func (a *Client) doPostJSONWithHeaders(ctx context.Context, path string, data []byte, result any, headers map[string]string, maxResponseBytes int64) error {
	requestPath := a.requestPath(path)
	url := a.ServerURL + requestPath
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
	if err != nil {
		return fmt.Errorf("agentteam api post: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	a.setRequestHeaders(req, requestPath)
	for key, value := range headers {
		if strings.TrimSpace(key) != "" && strings.TrimSpace(value) != "" {
			req.Header.Set(key, value)
		}
	}

	resp, err := a.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("agentteam api post: %w", &transportError{err: err})
	}
	defer closeResponseBody(resp.Body)

	if resp.StatusCode >= 400 {
		return apiErrorFromResponse(resp, http.MethodPost, requestPath)
	}
	if result != nil {
		if maxResponseBytes > 0 {
			data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
			if err != nil {
				return fmt.Errorf("agentteam api post: read response: %w", err)
			}
			if int64(len(data)) > maxResponseBytes {
				return fmt.Errorf("agentteam api post: response exceeds %d bytes: %w", maxResponseBytes, ErrResponseTooLarge)
			}
			if err := json.Unmarshal(data, result); err != nil {
				return fmt.Errorf("agentteam api post: decode response: %w", err)
			}
			return nil
		}
		decoder := json.NewDecoder(resp.Body)
		target, commit, err := temporaryJSONDecodeTarget(result)
		if err != nil {
			return fmt.Errorf("agentteam api post: decode response: %w", err)
		}
		if err := decoder.Decode(target); err != nil {
			return err
		}
		if err := decoder.Decode(new(struct{})); err != io.EOF {
			return fmt.Errorf("agentteam api post: decode response: unexpected trailing JSON")
		}
		commit()
		return nil
	}
	return nil
}

func temporaryJSONDecodeTarget(result any) (any, func(), error) {
	value := reflect.ValueOf(result)
	if !value.IsValid() || value.Kind() != reflect.Pointer || value.IsNil() {
		return nil, nil, fmt.Errorf("result must be a non-nil pointer")
	}
	if value.Elem().Kind() == reflect.Interface && !value.Elem().IsNil() &&
		value.Elem().Elem().Kind() == reflect.Pointer {
		concrete := value.Elem().Elem()
		temporary := reflect.New(concrete.Type().Elem())
		return temporary.Interface(), func() {
			value.Elem().Set(temporary)
		}, nil
	}
	temporary := reflect.New(value.Elem().Type())
	return temporary.Interface(), func() {
		value.Elem().Set(temporary.Elem())
	}, nil
}

// doPut performs a single PUT request without retry.
func (a *Client) doPut(ctx context.Context, path string, body any, result any) error {
	requestPath := a.requestPath(path)
	url := a.ServerURL + requestPath
	data, err := json.Marshal(body)
	if err != nil {
		return fmt.Errorf("agentteam api put: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(data))
	if err != nil {
		return fmt.Errorf("agentteam api put: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	a.setRequestHeaders(req, requestPath)

	resp, err := a.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("agentteam api put: %w", err)
	}
	defer closeResponseBody(resp.Body)

	if resp.StatusCode >= 400 {
		return apiErrorFromResponse(resp, http.MethodPut, requestPath)
	}
	if result != nil {
		return json.NewDecoder(resp.Body).Decode(result)
	}
	return nil
}

// doPatch performs a single PATCH request without retry.
func (a *Client) doPatch(ctx context.Context, path string, body any, result any) error {
	data, err := json.Marshal(body)
	if err != nil {
		return fmt.Errorf("agentteam api patch: %w", err)
	}
	return a.doPatchJSONWithHeaders(ctx, path, data, result, nil)
}

func (a *Client) doPatchJSONWithHeaders(ctx context.Context, path string, data []byte, result any, headers map[string]string) error {
	requestPath := a.requestPath(path)
	url := a.ServerURL + requestPath
	req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(data))
	if err != nil {
		return fmt.Errorf("agentteam api patch: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	a.setRequestHeaders(req, requestPath)
	for key, value := range headers {
		if strings.TrimSpace(key) != "" && strings.TrimSpace(value) != "" {
			req.Header.Set(key, value)
		}
	}

	resp, err := a.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("agentteam api patch: %w", err)
	}
	defer closeResponseBody(resp.Body)

	if resp.StatusCode >= 400 {
		return apiErrorFromResponse(resp, http.MethodPatch, requestPath)
	}
	if result != nil {
		return json.NewDecoder(resp.Body).Decode(result)
	}
	return nil
}

// doDelete performs a single DELETE request without retry.
func (a *Client) doDelete(ctx context.Context, path string, result any) error {
	requestPath := a.requestPath(path)
	url := a.ServerURL + requestPath
	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, http.NoBody)
	if err != nil {
		return fmt.Errorf("agentteam api delete: %w", err)
	}
	a.setRequestHeaders(req, requestPath)

	resp, err := a.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("agentteam api delete: %w", err)
	}
	defer closeResponseBody(resp.Body)

	if resp.StatusCode >= 400 {
		return apiErrorFromResponse(resp, http.MethodDelete, requestPath)
	}
	if result != nil {
		return json.NewDecoder(resp.Body).Decode(result)
	}
	return nil
}

// APIError is a structured HTTP error that records the status code,
// allowing retry logic to inspect whether the error is retryable (5xx). It also
// carries the allowlisted canonical public Workflow contract fields (server
// code, stage, business path, hint, retryability, diagnostics) so Desktop Exec
// can project them for every Workflow action while unknown/non-JSON/network
// errors stay fail-closed.
type APIError struct {
	StatusCode int
	Method     string
	Path       string
	Message    string
	Code       string
	Details    any

	// Allowlisted canonical Workflow contract fields parsed from the error
	// body. Path here is the request path above; the server's business path is
	// ErrorPath.
	StageName   string
	ErrorPath   string
	Hint        string
	Retryable   bool
	Diagnostics []Diagnostic
	// Truncation reports the server's deterministic diagnostic budget cuts when
	// the error body declared them. It is nil when the server did not truncate.
	Truncation *Truncation
	// Truncated reports that the error body exceeded maxAPIErrorBodyBytes and
	// was not fully read, so its structured contract fields are not trustworthy.
	// The desktop fails closed on such bodies instead of parsing partial JSON.
	Truncated bool

	// retryAfter preserves the server's raw Retry-After value for the
	// replay-safe retry executor. It is intentionally not part of the public
	// workflow error projection.
	retryAfter string
}

// Truncation mirrors the canonical server diagnostic budget truncation metadata
// so the desktop can surface exactly which cuts the server applied.
//
// SerializedBytes is the server-declared byte length of the final serialized
// JSON error envelope as actually written (a serialization fixed point computed
// by the server: it includes the truncation metadata object itself and any
// encoder escaping). It is informational here; the desktop never recomputes it.
type Truncation struct {
	DiagnosticsTruncated bool     `json:"diagnosticsTruncated,omitempty"`
	EnvelopeTruncated    bool     `json:"envelopeTruncated,omitempty"`
	OmittedCount         int      `json:"omittedCount,omitempty"`
	TruncatedFields      []string `json:"truncatedFields,omitempty"`
	SerializedBytes      int      `json:"serializedBytes,omitempty"`
}

// Diagnostic mirrors the canonical public Workflow diagnostic contract so the
// desktop can preserve typed field/index diagnostics and the deterministic
// compiler/validator source location and graph identity (line, column, nodeId,
// edgeId) from the server.
type Diagnostic struct {
	Severity string `json:"severity"`
	Code     string `json:"code"`
	Path     string `json:"path,omitempty"`
	Field    string `json:"field,omitempty"`
	Index    *int   `json:"index,omitempty"`
	Line     int    `json:"line,omitempty"`
	Column   int    `json:"column,omitempty"`
	NodeID   string `json:"nodeId,omitempty"`
	EdgeID   string `json:"edgeId,omitempty"`
	Message  string `json:"message"`
}

// Error implements the error interface.
func (e *APIError) Error() string {
	classifications := make([]string, 0, 2)
	if stage := e.Stage(); stage != "" {
		classifications = append(classifications, "stage="+stage)
	}
	if code := strings.TrimSpace(e.Code); code != "" {
		classifications = append(classifications, "code="+code)
	}
	classification := ""
	if len(classifications) > 0 {
		classification = " [" + strings.Join(classifications, " ") + "]"
	}
	if message := strings.TrimSpace(e.Message); message != "" {
		return fmt.Sprintf("agentteam api %s: HTTP %d (%s)%s: %s", strings.ToLower(e.Method), e.StatusCode, e.Path, classification, message)
	}
	return fmt.Sprintf("agentteam api %s: HTTP %d (%s)%s", strings.ToLower(e.Method), e.StatusCode, e.Path, classification)
}

func (e *APIError) Stage() string {
	if e == nil {
		return ""
	}
	if stage := strings.TrimSpace(e.StageName); stage != "" {
		return stage
	}
	if details, ok := e.Details.(map[string]any); ok {
		if stage, ok := details["stage"].(string); ok {
			return strings.TrimSpace(stage)
		}
	}
	return ""
}

func apiErrorFromResponse(resp *http.Response, method string, path string) error {
	if resp == nil {
		return &APIError{Method: method, Path: path}
	}
	message, code, details, contract, truncated := readAPIErrorContract(resp.Body)
	return &APIError{
		StatusCode:  resp.StatusCode,
		Method:      method,
		Path:        path,
		Message:     message,
		Code:        code,
		Details:     details,
		StageName:   contract.stage,
		ErrorPath:   contract.path,
		Hint:        contract.hint,
		Retryable:   contract.retryable,
		Diagnostics: contract.diagnostics,
		Truncation:  contract.truncation,
		Truncated:   truncated,
		retryAfter:  resp.Header.Get("Retry-After"),
	}
}

func readAPIErrorMessage(body io.Reader) string {
	message, _, _, _, _ := readAPIErrorContract(body)
	return message
}

// apiErrorContract is the allowlisted canonical Workflow error contract fields
// carried on APIError.
type apiErrorContract struct {
	stage       string
	path        string
	hint        string
	retryable   bool
	diagnostics []Diagnostic
	truncation  *Truncation
}

// readAPIErrorContract reads the error response body with limit+1 truncation
// detection: it reads up to maxAPIErrorBodyBytes+1 bytes and, when the body is
// longer than the limit, reports truncation instead of attempting to parse a
// partial JSON body. A truncated body never yields partial structured fields:
// callers fail closed on the truncated flag.
func readAPIErrorContract(body io.Reader) (string, string, any, apiErrorContract, bool) {
	if body == nil {
		return "", "", nil, apiErrorContract{}, false
	}
	payload, err := io.ReadAll(io.LimitReader(body, maxAPIErrorBodyBytes+1))
	if err != nil {
		return "", "", nil, apiErrorContract{}, false
	}
	if int64(len(payload)) > maxAPIErrorBodyBytes {
		return "server error response exceeded the size limit and was truncated", "", nil, apiErrorContract{}, true
	}
	message, code, details, contract := parseAPIErrorContract(payload)
	return message, code, details, contract, false
}

func parseAPIErrorMessage(payload []byte) string {
	message, _, _, _ := parseAPIErrorContract(payload)
	return message
}

func parseAPIErrorResponse(payload []byte) (string, string, any) {
	message, code, details, _ := parseAPIErrorContract(payload)
	return message, code, details
}

func parseAPIErrorContract(payload []byte) (string, string, any, apiErrorContract) {
	trimmedPayload := strings.TrimSpace(string(payload))
	if trimmedPayload == "" {
		return "", "", nil, apiErrorContract{}
	}

	var structured struct {
		Error       string       `json:"error"`
		Message     string       `json:"message"`
		Code        string       `json:"code"`
		Details     any          `json:"details"`
		Stage       string       `json:"stage"`
		Path        string       `json:"path"`
		Hint        string       `json:"hint"`
		Retryable   bool         `json:"retryable"`
		Diagnostics []Diagnostic `json:"diagnostics"`
		Truncation  *Truncation  `json:"truncation"`
	}
	if err := json.Unmarshal(payload, &structured); err == nil {
		message := ""
		if errorText := strings.TrimSpace(structured.Error); errorText != "" {
			message = errorText
		} else if messageText := strings.TrimSpace(structured.Message); messageText != "" {
			message = messageText
		}
		// Preserve the RAW server code/stage/path bytes exactly (identity fields
		// the Exec allowlist validates byte-for-byte later); only the free-text
		// message/hint may be trimmed.
		return message, structured.Code, structured.Details, apiErrorContract{
			stage:       structured.Stage,
			path:        structured.Path,
			hint:        strings.TrimSpace(structured.Hint),
			retryable:   structured.Retryable,
			diagnostics: append([]Diagnostic(nil), structured.Diagnostics...),
			truncation:  structured.Truncation,
		}
	}
	return trimmedPayload, "", nil, apiErrorContract{}
}

// shouldRetry reports whether err represents a transient HTTP response or a
// failure returned directly by http.Client.Do. Local encode/decode/read errors
// remain terminal.
func (a *Client) shouldRetry(err error) bool {
	if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false
	}
	var ae *APIError
	if errors.As(err, &ae) {
		return isRetryable(ae.StatusCode)
	}
	var transportErr *transportError
	return errors.As(err, &transportErr)
}

func (a *Client) refreshAuthTokenAfterUnauthorized(ctx context.Context, err error) (bool, error) {
	if err == nil || a.RefreshAuthToken == nil {
		return false, nil
	}
	var ae *APIError
	if !errors.As(err, &ae) || ae.StatusCode != http.StatusUnauthorized {
		return false, nil
	}
	staleToken := a.AuthToken
	freshToken, refreshErr := a.RefreshAuthToken(ctx, staleToken)
	if refreshErr != nil {
		return false, fmt.Errorf("agentteam api refresh auth token after HTTP 401: %w", refreshErr)
	}
	freshToken = strings.TrimSpace(freshToken)
	if freshToken == "" {
		return false, fmt.Errorf("agentteam api refresh auth token after HTTP 401: empty auth token")
	}
	a.AuthToken = freshToken
	return true, nil
}

// GetAttachment downloads an attachment file from the AgentTeam server.
func (a *Client) GetAttachment(ctx context.Context, attachmentID string) (AttachmentDownload, error) {
	var download AttachmentDownload
	err := a.execute(ctx, "GET attachment "+attachmentID, attemptReplaySafe, func() error {
		candidate, attemptErr := a.doGetAttachment(ctx, attachmentID)
		if attemptErr == nil {
			download = candidate
		}
		return attemptErr
	})
	return download, err
}

func (a *Client) doGetAttachment(ctx context.Context, attachmentID string) (AttachmentDownload, error) {
	workspaceID := strings.TrimSpace(a.RouteWorkspaceID)
	if workspaceID == "" {
		return AttachmentDownload{}, fmt.Errorf("agentteam api get attachment: workspace id is required")
	}
	path := "/api/v1/workspaces/" + url.PathEscape(workspaceID) + "/attachments/" + url.PathEscape(attachmentID)
	requestPath := path
	url := a.ServerURL + requestPath
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
	if err != nil {
		return AttachmentDownload{}, fmt.Errorf("agentteam api get attachment: %w", err)
	}
	a.setRequestHeaders(req, requestPath)

	resp, err := a.HTTPClient.Do(req)
	if err != nil {
		return AttachmentDownload{}, fmt.Errorf("agentteam api get attachment: %w", &transportError{err: err})
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return AttachmentDownload{}, apiErrorFromResponse(resp, http.MethodGet, requestPath)
	}
	if resp.ContentLength > maxAttachmentDownloadBytes {
		return AttachmentDownload{}, fmt.Errorf("agentteam api get attachment: attachment too large (%d bytes, max %d)", resp.ContentLength, maxAttachmentDownloadBytes)
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, maxAttachmentDownloadBytes+1))
	if err != nil {
		return AttachmentDownload{}, fmt.Errorf("agentteam api read attachment: %w", err)
	}
	if len(data) > maxAttachmentDownloadBytes {
		return AttachmentDownload{}, fmt.Errorf("agentteam api get attachment: attachment too large (max %d bytes)", maxAttachmentDownloadBytes)
	}
	return AttachmentDownload{
		Data:        data,
		ContentType: resp.Header.Get("Content-Type"),
		Filename:    attachmentFilenameFromContentDisposition(resp.Header.Get("Content-Disposition")),
	}, nil
}

func attachmentFilenameFromContentDisposition(contentDisposition string) string {
	_, params, err := mime.ParseMediaType(strings.TrimSpace(contentDisposition))
	if err != nil {
		return ""
	}
	return strings.TrimSpace(params["filename"])
}

// PostMultipartRaw sends a multipart form POST where the caller provides the
// pre-built body and content type. The body is immutable bytes so an
// unauthorized first attempt can refresh the Agent token and replay the exact
// upload once.
func (a *Client) PostMultipartRaw(ctx context.Context, path string, body []byte, contentType string, result any) error {
	return a.execute(ctx, "POST multipart "+path, attemptAuthRefreshOnly, func() error {
		return a.doPostMultipartRaw(ctx, path, body, contentType, result)
	})
}

func (a *Client) doPostMultipartRaw(ctx context.Context, path string, body []byte, contentType string, result any) error {
	requestPath := a.requestPath(path)
	url := a.ServerURL + requestPath
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
	if err != nil {
		return fmt.Errorf("create multipart request: %w", err)
	}
	req.Header.Set("Content-Type", contentType)
	a.setRequestHeaders(req, requestPath)

	resp, err := a.HTTPClient.Do(req)
	if err != nil {
		return fmt.Errorf("multipart request: %w", err)
	}
	defer closeResponseBody(resp.Body)

	if resp.StatusCode >= 400 {
		return apiErrorFromResponse(resp, http.MethodPost, requestPath)
	}
	if result != nil {
		return json.NewDecoder(resp.Body).Decode(result)
	}
	return nil
}

// PostMultipart sends a multipart form POST with a file upload and extra fields.
func (a *Client) PostMultipart(ctx context.Context, path, filePath, fieldName string, extraFields map[string]string, result any) (string, error) {
	var attachmentID string
	err := a.execute(ctx, "POST multipart "+path, attemptAuthRefreshOnly, func() error {
		candidate, attemptErr := a.doPostMultipart(ctx, path, filePath, fieldName, extraFields, result)
		if attemptErr == nil {
			attachmentID = candidate
		}
		return attemptErr
	})
	return attachmentID, err
}

func (a *Client) doPostMultipart(ctx context.Context, path, filePath, fieldName string, extraFields map[string]string, result any) (string, error) {
	stat, err := os.Stat(filePath)
	if err != nil {
		if os.IsNotExist(err) {
			return "", fmt.Errorf("file not found: %s", filePath)
		}
		return "", fmt.Errorf("stat file: %w", err)
	}
	if stat.Size() > MaxUploadBytes {
		return "", fmt.Errorf("file too large (%.1f MiB), max %s", float64(stat.Size())/1024/1024, MaxUploadSizeLabel)
	}

	f, err := os.Open(filePath)
	if err != nil {
		return "", fmt.Errorf("open file: %w", err)
	}
	defer f.Close()

	var buf bytes.Buffer
	writer := multipart.NewWriter(&buf)
	part, err := writer.CreatePart(textproto.MIMEHeader{
		"Content-Disposition": {mime.FormatMediaType("form-data", map[string]string{"name": fieldName, "filename": filepath.Base(filePath)})},
		"Content-Type":        {uploadContentTypeForPath(filePath)},
	})
	if err != nil {
		return "", fmt.Errorf("create form file: %w", err)
	}
	if _, err := io.Copy(part, f); err != nil {
		return "", fmt.Errorf("copy file: %w", err)
	}
	for k, v := range extraFields {
		_ = writer.WriteField(k, v)
	}
	_ = writer.Close()

	requestPath := a.requestPath(path)
	url := a.ServerURL + requestPath
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
	if err != nil {
		return "", fmt.Errorf("create multipart request: %w", err)
	}
	req.Header.Set("Content-Type", writer.FormDataContentType())
	a.setRequestHeaders(req, requestPath)

	resp, err := a.HTTPClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("multipart request: %w", err)
	}
	defer closeResponseBody(resp.Body)

	if resp.StatusCode >= 400 {
		return "", apiErrorFromResponse(resp, http.MethodPost, requestPath)
	}

	var payload struct {
		ID    string `json:"id"`
		Error string `json:"error"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return "", fmt.Errorf("decode multipart response: %w", err)
	}
	if payload.Error != "" {
		return "", fmt.Errorf("upload error: %s", payload.Error)
	}
	return payload.ID, nil
}
