package exec

import (
	"errors"
	"fmt"
	"strings"

	"github.com/tidbcloud/AgentTeam/desktop/internal/wire/apiclient"
	"github.com/tidbcloud/AgentTeam/internal/workflow/run"
)

const (
	workflowRunCallbackNotReadyCode        = "step_not_ready"
	workflowRunCallbackAlreadyResolvedCode = "step_already_resolved"
)

// requireRuntimeClient is the shared runtime-identity gate for all apiclient
// adapters: a nil Client or missing workspace/agent/token returns a stable
// error instead of panicking. Domain adapters call this before API interaction.
func requireRuntimeClient(c *apiclient.Client) error {
	if c == nil || strings.TrimSpace(c.RouteWorkspaceID) == "" {
		return fmt.Errorf("tools require a workspace-scoped profile")
	}
	if strings.TrimSpace(c.AgentID) == "" || strings.TrimSpace(c.AuthToken) == "" {
		return fmt.Errorf("tools require an agent token")
	}
	if c.HTTPClient == nil {
		return fmt.Errorf("tools require an HTTP client")
	}
	return nil
}

// isAuthoritativeWorkflowRunTerminalError recognizes definitive callback
// outcomes only for local matching-snapshot cleanup. It is never an
// authorization decision for explicit Run/Step selectors. The code is matched
// byte-for-byte: a server code with surrounding whitespace is never trimmed
// into an authoritative terminal code.
func isAuthoritativeWorkflowRunTerminalError(err error) bool {
	var apiErr *apiclient.APIError
	if !errors.As(err, &apiErr) {
		return false
	}
	switch apiErr.Code {
	case "step_not_waiting", workflowRunCallbackAlreadyResolvedCode:
		return true
	default:
		return false
	}
}

// classifyAPIError maps a service error (typically *apiclient.APIError) to a
// typed exec.Error. For a TRUSTED canonical Workflow code the bounded
// server-approved public message (APIError.Message) is projected as Problem; for
// every other failure the generic action text is retained. The raw API error
// text stays in Cause only so API/raw server details never leak to the model.
// When the server returned a structured Workflow error whose code is allowlisted,
// the canonical contract fields (server code, stage, path, hint, retryability,
// diagnostics with field/index/line/column/nodeId/edgeId, and the server's
// declared truncation metadata) are preserved on the typed Error so every
// Workflow action projects the same contract. Unknown/non-JSON/network failures,
// unknown server codes, and oversized (truncated) error bodies fail closed: no
// server details are projected. Non-APIError failures become internal. All
// domain handlers use this so they cannot invent ad-hoc mappings.
func classifyAPIError(err error, problem string) *Error {
	if err == nil {
		return nil
	}
	var apiErr *apiclient.APIError
	if errors.As(err, &apiErr) {
		if apiErr.Truncated {
			return &Error{
				Code:      CodeInternal,
				Problem:   problem,
				Hint:      "the server error response exceeded the size limit and was truncated; retry or narrow the request",
				Truncated: true,
				Cause:     err,
			}
		}
		// The top-level server identity fields (code/stage/path) are validated
		// byte-for-byte against the reviewed allowlist / canonical grammars BEFORE
		// any contract projection. The apiclient preserves the raw server bytes; a
		// malformed/spaced code, stage, or path fails closed to the generic
		// redacted problem and never unlocks the server's problem/hint/stage/
		// path/diagnostics/ServerCode.
		canonicalCode, codeOK := allowlistedWorkflowCode(apiErr.Code)
		if !codeOK {
			return &Error{Code: apiErrorCode(apiErr.StatusCode), Problem: problem, Cause: err}
		}
		// Stage: empty is allowed (many errors carry no stage), but a non-empty
		// stage must be a valid canonical public Workflow stage. Path: empty is
		// allowed, but a non-empty path must be a valid canonical path.
		rawStage := apiErr.RawStage()
		if rawStage != "" {
			if _, ok := publicStage(rawStage); !ok {
				return &Error{Code: apiErrorCode(apiErr.StatusCode), Problem: problem, Cause: err}
			}
		}
		if apiErr.ErrorPath != "" {
			if _, ok := run.PublicFailurePath(apiErr.ErrorPath); !ok {
				return &Error{Code: apiErrorCode(apiErr.StatusCode), Problem: problem, Cause: err}
			}
		}
		return &Error{
			Code:        apiErrorCode(apiErr.StatusCode),
			Problem:     workflowPublicProblem(apiErr, problem),
			Stage:       rawStage,
			Path:        apiErr.ErrorPath,
			Hint:        apiErr.Hint,
			Retryable:   apiErr.Retryable,
			ServerCode:  canonicalCode,
			Diagnostics: projectAPIErrorDiagnostics(apiErr),
			Truncation:  projectServerTruncation(apiErr.Truncation),
			Cause:       err,
		}
	}
	return &Error{Code: CodeInternal, Problem: problem, Cause: err}
}

// workflowPublicProblem projects the bounded server-approved public message as
// the Exec Problem for a TRUSTED canonical Workflow code. The server already
// bounds this message (publicerr.BoundedProblem caps at 512 runes and every
// workflow ErrorResponse message is a fixed, display-safe constant); the desktop
// re-bounds it against the Exec envelope budget. When the server did not supply
// a message, the generic action text is retained so a trusted code never
// degrades to an empty Problem.
func workflowPublicProblem(apiErr *apiclient.APIError, fallback string) string {
	if apiErr == nil {
		return fallback
	}
	message := strings.TrimSpace(apiErr.Message)
	if message == "" {
		return fallback
	}
	return capField(message, maxProblemRunes)
}

// workflowTrustedErrorContractVersion is the reviewed version of the explicit
// server Workflow error code set the Desktop is allowed to project. Bump it
// (and extend workflowTrustedServerErrorCodes) only after a new production code
// has been reviewed; the set is the audit boundary for registry drift.
const workflowTrustedErrorContractVersion = 4

// workflowTrustedServerErrorCodes is the EXPLICIT, reviewed set of server
// Workflow error codes whose canonical contract fields (server code, stage,
// path, hint, retryability, typed diagnostics) the Desktop may project into the
// model envelope. It replaces the former prefix allowlist (plan_/binding_/
// invocation_/run_/step_) so an un-reviewed future code — including one that
// merely shares a workflow prefix — fails closed instead of leaking structured
// details. Every production code the workflow domains emit today is enumerated:
//   - plan/binding/invocation/run/step management domains (publicerr stages),
//     including the agent-edit validation dynamic invalid_<field> family and the
//     binding request-helpers/preconditions codes;
//   - the team_workflow_* and workflow_agent_* API endpoints;
//   - the stable generic workflow codes (idempotency, request, run, preconditions).
//
// The set is intentionally closed: add a code only after the corresponding
// server projection has been reviewed for bounded public-safety. The exhaustive
// emitter-versus-registry drift check (workflow_emitter_drift_test.go) scans
// every server emitter site so an un-reviewed new emitter fails the build.
var workflowTrustedServerErrorCodes = map[string]struct{}{
	// plan domain (publicerr StagePlan).
	"plan_archived": {}, "plan_compile_failed": {}, "plan_conflict": {},
	"plan_draft_revision_conflict": {}, "plan_id_required": {}, "plan_idempotency_conflict": {},
	"plan_invalid_action": {}, "plan_invalid_json": {}, "plan_invalid_request": {},
	"plan_no_changes": {}, "plan_not_found": {}, "plan_request_failed": {},
	"plan_request_too_large": {}, "plan_revision_conflict": {},
	// plan agent-edit validation (dynamic invalid_<field>; the only production
	// field is beforeSeq).
	"invalid_beforeSeq": {}, "invalid_limit": {}, "invalid_plan_version": {},

	// binding domain (publicerr StageBinding).
	"binding_action_not_found": {}, "binding_archived": {},
	"binding_conflict": {}, "binding_forbidden": {}, "binding_id_required": {},
	"binding_idempotency_conflict": {}, "binding_idempotency_key_required": {},
	"binding_internal_error": {}, "binding_invalid_idempotency_key": {},
	"binding_invalid_if_match": {}, "binding_invalid_json": {}, "binding_invalid_limit": {},
	"binding_invalid_request": {}, "binding_invalid_revision": {}, "binding_invalid_state": {},
	"binding_not_found": {}, "binding_plan_archived": {}, "binding_plan_not_found": {},
	"binding_plan_version_invalid": {}, "binding_request_failed": {}, "binding_request_too_large": {},
	"binding_revision_conflict": {}, "binding_validation_failed": {},

	// invocation domain (publicerr StageInvocation).
	"invocation_conflict": {}, "invocation_forbidden": {}, "invocation_idempotency_conflict": {},
	"invocation_idempotency_key_required": {}, "invocation_input_invalid": {},
	"invocation_invalid_idempotency_key": {}, "invocation_invalid_json": {},
	"invocation_invalid_request": {}, "invocation_not_found": {},
	"invocation_plan_version_unavailable": {}, "invocation_principal_unavailable": {},
	"invocation_request_failed": {}, "invocation_request_too_large": {},

	// run domain.
	"run_command_not_found": {}, "run_conflict": {}, "run_forbidden": {},
	"run_invalid_action": {}, "run_invalid_json": {}, "run_invalid_request": {},
	"run_not_found": {}, "run_request_failed": {}, "run_request_too_large": {},
	"run_artifact_conflict": {}, "run_artifact_forbidden": {}, "run_artifact_invalid": {},
	"run_artifact_unavailable": {}, "run_start_failed": {}, "run_terminal_conflict": {}, "run_unavailable": {},

	// step / callback domain (Workflow Run callback validation).
	"step_already_resolved": {}, "step_artifact_conflict": {}, "step_artifact_forbidden": {},
	"step_forbidden": {}, "step_not_found": {}, "step_not_ready": {}, "step_not_waiting": {},
	"step_payload_conflict": {}, "step_output_budget_exceeded": {},
	"step_output_contract_invalid": {}, "step_output_invalid": {},
	"step_work_item_reference_invalid": {}, "step_work_items_invalid": {},
	"step_work_scope_invalid": {}, "step_work_scope_unauthorized": {},

	// team_workflow API endpoint.
	"authoring_failed": {}, "team_workflow_invalid_if_match": {}, "team_workflow_invalid_limit": {},
	"team_workflow_invalid_request": {},
	"team_workflow_invalid_state":   {}, "team_workflow_not_found": {},
	"team_workflow_deleted": {}, "team_workflow_request_failed": {}, "team_workflow_unavailable": {},

	// workflow_agent API endpoint.
	"workflow_agent_edit_revision_mismatch": {}, "workflow_agent_edit_unavailable": {},
	"workflow_agent_wake_active": {},

	// stable generic workflow codes.
	"idempotency_conflict": {}, "internal_error": {}, "invalid_if_match": {},
	"invalid_request_body": {}, "precondition_required": {}, "workflow_request_failed": {},
	"workflow_run_unavailable": {},
}

// allowlistedWorkflowServerCode reports whether a server error code belongs to
// the explicit reviewed/versioned canonical Workflow domain set the desktop is
// allowed to project. The code is matched byte-for-byte: a server code with
// surrounding whitespace, malformed UTF-8, or any other variant of an
// allowlisted code is NOT accepted, so an untrusted server code can never be
// trimmed into an allowlisted identity that unlocks public problem/hint/
// diagnostics.
func allowlistedWorkflowServerCode(code string) bool {
	_, ok := workflowTrustedServerErrorCodes[code]
	return ok
}

// allowlistedWorkflowCode validates a server error code byte-for-byte
// against the explicit reviewed allowlist ONLY. There is no open-ended
// acceptance of syntactically canonical dynamic codes: every projected code
// must be the exact reviewed server code, including the concrete dynamic
// `invalid_beforeSeq` (the single production `invalid_<field>` value from the
// plan agent-edit validator), which is enumerated explicitly in
// workflowTrustedServerErrorCodes. A malformed, spaced, secret-bearing, or
// unreviewed code fails closed and is never trimmed or pattern-accepted into an
// identity that unlocks public problem/hint/stage/path/diagnostics/ServerCode.
func allowlistedWorkflowCode(code string) (string, bool) {
	if allowlistedWorkflowServerCode(code) {
		return code, true
	}
	return "", false
}

// projectAPIErrorDiagnostics converts the server's allowlisted typed
// diagnostics into the exec envelope shape, preserving field/index/line/column/
// nodeId/edgeId. Bounding (count and field caps) is intentionally deferred to
// the envelope/budget boundaries so ApplyErrorBudget can report every
// deterministic cut.
func projectAPIErrorDiagnostics(apiErr *apiclient.APIError) []Diagnostic {
	if apiErr == nil || len(apiErr.Diagnostics) == 0 {
		return nil
	}
	diagnostics := make([]Diagnostic, 0, len(apiErr.Diagnostics))
	for _, diagnostic := range apiErr.Diagnostics {
		// A diagnostic without a canonical code or message identity is not
		// trustworthy: fail closed by dropping the whole element. The identity
		// fields (code/path/field) are validated byte-for-byte — a server code
		// with surrounding whitespace or malformed UTF-8 is never trimmed into an
		// accepted identity that unlocks public diagnostics.
		if diagnostic.Code == "" && diagnostic.Message == "" {
			continue
		}
		if _, ok := run.PublicFailureCode(diagnostic.Code); !ok {
			continue
		}
		if diagnostic.Path != "" {
			if _, ok := run.PublicFailurePath(diagnostic.Path); !ok {
				continue
			}
		}
		if diagnostic.Field != "" {
			if _, ok := run.PublicDiagnosticField(diagnostic.Field); !ok {
				continue
			}
		}
		diagnostics = append(diagnostics, Diagnostic{
			Severity: diagnostic.Severity,
			Code:     diagnostic.Code,
			Path:     diagnostic.Path,
			Field:    diagnostic.Field,
			Index:    diagnostic.Index,
			Line:     diagnostic.Line,
			Column:   diagnostic.Column,
			NodeID:   diagnostic.NodeID,
			EdgeID:   diagnostic.EdgeID,
			Message:  diagnostic.Message,
		})
	}
	return diagnostics
}

// projectServerTruncation converts the server's declared truncation metadata
// into the exec envelope shape. It is nil when the server did not truncate.
func projectServerTruncation(truncation *apiclient.Truncation) *Truncation {
	if truncation == nil {
		return nil
	}
	return &Truncation{
		DiagnosticsTruncated: truncation.DiagnosticsTruncated,
		EnvelopeTruncated:    truncation.EnvelopeTruncated,
		OmittedCount:         truncation.OmittedCount,
		TruncatedFields:      append([]string(nil), truncation.TruncatedFields...),
		SerializedBytes:      truncation.SerializedBytes,
	}
}

// isWorkflowRunCallbackNotReadyError recognizes only the callback delivery
// race reported by the server. Other 409s (including a committed replay with a
// different result) are business errors and must never be retried here. The
// code is matched byte-for-byte: a server code with surrounding whitespace is
// never trimmed into the retryable callback code.
func isWorkflowRunCallbackNotReadyError(err error) bool {
	var apiErr *apiclient.APIError
	return errors.As(err, &apiErr) && apiErr.Code == workflowRunCallbackNotReadyCode
}

// apiErrorCode maps an HTTP status to the stable exec category. The 4xx statuses
// the server emits for Workflow requests are all typed explicitly so a client
// error never degrades to internal: 413 (payload too large) is a validation
// failure, 428 (precondition required) is a stale/conflict precondition, and 429
// (rate limited) is the rate-limited category.
func apiErrorCode(status int) ErrorCode {
	switch {
	case status == 400 || status == 413 || status == 422:
		return CodeValidation
	case status == 401 || status == 403:
		return CodePermissionDenied
	case status == 404:
		return CodeNotFound
	case status == 409 || status == 412 || status == 428:
		return CodeConflict
	case status == 429:
		return CodeRateLimited
	case status >= 500:
		return CodeInternal
	default:
		return CodeInternal
	}
}

// publicWorkflowStages is the closed reviewed set of top-level public Workflow
// error stages the server emits for the allowlisted Workflow error codes. It is
// disjoint from the kernel failure stage vocabulary: these are the publicerr
// management domains (plan/binding/invocation/run/team_workflow) that the Exec
// boundary projects as the canonical contract Stage.
var publicWorkflowStages = map[string]struct{}{
	"plan": {}, "binding": {}, "invocation": {}, "run": {}, "team_workflow": {},
}

// publicStage validates a top-level public Workflow error stage
// byte-for-byte against the closed reviewed set. A stage with surrounding
// whitespace, malformed UTF-8, or an unknown value fails closed; it is never
// trimmed or sanitized into an accepted stage.
func publicStage(stage string) (string, bool) {
	if _, ok := publicWorkflowStages[stage]; ok {
		return stage, true
	}
	return "", false
}
