package publicerr

import (
	"errors"
	"fmt"
	"net/http"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

// testDomainError is a minimal DomainError used to exercise the centralized
// classifier without importing a workflow management domain package.
type testDomainError struct {
	kind    error
	code    string
	message string
	details any
}

func (err testDomainError) Error() string { return err.message }
func (err testDomainError) Unwrap() error { return err.kind }
func (err testDomainError) WorkflowPublicError() (string, any) {
	return err.code, err.details
}

func TestClassifyPreservesCodeStageAndBoundedProblem(t *testing.T) {
	options := Options{Stage: StageRun, DefaultCode: "run_request_failed"}
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrConflict, code: "run_terminal_conflict", message: "completed Workflow Run cannot be canceled",
		details: map[string]any{"status": "succeeded"},
	}, options)
	require.True(t, ok)
	require.Equal(t, http.StatusConflict, classified.Status)
	require.Equal(t, "run_terminal_conflict", classified.Error.Code)
	require.Equal(t, StageRun, classified.Error.Stage)
	require.Equal(t, "completed Workflow Run cannot be canceled", classified.Error.Problem)
	require.False(t, classified.Error.Retryable)
	require.Equal(t, map[string]any{"status": "succeeded"}, classified.Details)
}

func TestClassifyMapsStoreKindsToStatus(t *testing.T) {
	for _, test := range []struct {
		name   string
		kind   error
		status int
	}{
		{name: "bad_request", kind: storeerr.ErrBadRequest, status: http.StatusBadRequest},
		{name: "forbidden", kind: storeerr.ErrForbidden, status: http.StatusForbidden},
		{name: "unauthorized", kind: storeerr.ErrUnauthorized, status: http.StatusForbidden},
		{name: "not_found", kind: storeerr.ErrNotFound, status: http.StatusNotFound},
		{name: "conflict", kind: storeerr.ErrConflict, status: http.StatusConflict},
		{name: "unprocessable", kind: storeerr.ErrUnprocessable, status: http.StatusUnprocessableEntity},
	} {
		t.Run(test.name, func(t *testing.T) {
			classified, ok := Classify(testDomainError{kind: test.kind, code: "run_test", message: "failed"}, Options{Stage: StageRun, DefaultCode: "run_request_failed"})
			require.True(t, ok)
			require.Equal(t, test.status, classified.Status)
			require.Equal(t, "run_test", classified.Error.Code)
		})
	}
}

func TestClassifyUnknownFailureFailsClosed(t *testing.T) {
	classified, ok := Classify(fmt.Errorf("raw repository: dial tcp: SELECT secret FROM runs"), Options{Stage: StageRun, DefaultCode: "run_request_failed"})
	require.True(t, ok)
	require.Equal(t, http.StatusInternalServerError, classified.Status)
	require.Equal(t, "run_request_failed", classified.Error.Code)
	require.Equal(t, "Workflow operation failed", classified.Error.Problem)
	require.Nil(t, classified.Details)
	require.False(t, classified.Error.Retryable)
}

func TestClassifyRawStoreErrorIsNonEnumerating(t *testing.T) {
	// A raw not-found store error and a raw forbidden store error must project
	// an identical generic body: a caller cannot use code/problem/details to
	// probe which resources exist. (HTTP status may still differ so callers
	// can distinguish the transport semantics; the body never reveals the
	// resource.)
	options := Options{Stage: StageBinding, DefaultCode: "binding_request_failed"}
	notFound, ok := Classify(storeerr.NotFound("binding pln_secret does not exist"), options)
	require.True(t, ok)
	forbidden, ok := Classify(storeerr.Forbidden("binding pln_secret is forbidden"), options)
	require.True(t, ok)

	require.Equal(t, notFound.Error.Code, forbidden.Error.Code)
	require.Equal(t, notFound.Error.Problem, forbidden.Error.Problem)
	require.Equal(t, "binding_request_failed", notFound.Error.Code)
	require.NotContains(t, notFound.Error.Problem, "pln_secret")
	require.NotContains(t, forbidden.Error.Problem, "pln_secret")
	require.Nil(t, notFound.Details)
	require.Nil(t, forbidden.Details)
}

func TestClassifyRawStoreNotFoundDropsResourceDetails(t *testing.T) {
	classified, ok := Classify(storeerr.NotFound("Workflow Plan pln_secret is missing"), Options{Stage: StagePlan, DefaultCode: "plan_request_failed"})
	require.True(t, ok)
	require.Equal(t, http.StatusNotFound, classified.Status)
	require.Equal(t, "plan_request_failed", classified.Error.Code)
	require.NotContains(t, classified.Error.Problem, "pln_secret")
	require.Nil(t, classified.Details)
}

func TestClassifyStatusForCodeOverride(t *testing.T) {
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrConflict, code: "plan_revision_conflict", message: "draft changed",
		details: map[string]any{"resource": "planDraft", "expectedRevision": 1, "currentRevision": 2},
	}, Options{
		Stage: StagePlan, DefaultCode: "plan_request_failed",
		StatusForCode: func(code string, status int) int {
			if code == "plan_revision_conflict" || code == "plan_draft_revision_conflict" {
				return http.StatusPreconditionFailed
			}
			return status
		},
	})
	require.True(t, ok)
	require.Equal(t, http.StatusPreconditionFailed, classified.Status)
	require.Equal(t, "plan_revision_conflict", classified.Error.Code)
	require.Equal(t, map[string]any{"resource": "planDraft", "expectedRevision": 1, "currentRevision": 2}, classified.Details)
}

func TestClassifyProjectDiagnosticsAndPath(t *testing.T) {
	diagnostics := []Diagnostic{{
		Severity: SeverityError, Code: "input.unknown_field", Path: "$.input.unknown", Message: "unknown input field",
	}}
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrUnprocessable, code: "invocation_input_invalid", message: "unknown input field",
		details: map[string]any{"diagnostics": diagnostics},
	}, Options{Stage: StageInvocation, DefaultCode: "invocation_request_failed"})
	require.True(t, ok)
	require.Equal(t, http.StatusUnprocessableEntity, classified.Status)
	require.Equal(t, "$.input.unknown", classified.Error.Path)
	require.Len(t, classified.Error.Diagnostics, 1)
	require.Equal(t, "input.unknown_field", classified.Error.Diagnostics[0].Code)
	require.Equal(t, "$.input.unknown", classified.Error.Diagnostics[0].Path)
}

func TestClassifyDiagnosticsFromMapShape(t *testing.T) {
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrUnprocessable, code: "invocation_input_invalid", message: "unknown input field",
		details: map[string]any{"diagnostics": []any{map[string]any{
			"severity": "error", "code": "input.unknown_field", "path": "$.input.unknown", "message": "unknown input field",
		}}},
	}, Options{Stage: StageInvocation, DefaultCode: "invocation_request_failed"})
	require.True(t, ok)
	require.Len(t, classified.Error.Diagnostics, 1)
	require.Equal(t, "input.unknown_field", classified.Error.Diagnostics[0].Code)
	require.Equal(t, "$.input.unknown", classified.Error.Diagnostics[0].Path)
}

// domainDiagnostic mirrors a typed workflow domain diagnostic struct with
// exported fields and no JSON tags, as used by the binding manage package.
type domainDiagnostic struct {
	Severity string
	Code     string
	Path     string
	Message  string
}

// planDiagnostic mirrors planlang.Diagnostic which carries node/edge identity.
type planDiagnostic struct {
	Severity string
	Code     string
	Path     string
	Message  string
	Line     int
	Column   int
	NodeID   string
	EdgeID   string
}

func TestClassifyProjectsTypedDomainDiagnostics(t *testing.T) {
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrUnprocessable, code: "plan_compile_failed", message: "Plan draft did not compile",
		details: map[string]any{"diagnostics": []planDiagnostic{{
			Severity: "error", Code: "plan.node_unreachable", Path: "$.nodes.entry", Message: "entry is unreachable",
			Line: 12, Column: 3, NodeID: "entry", EdgeID: "edge_1",
		}}},
	}, Options{Stage: StagePlan, DefaultCode: "plan_request_failed"})
	require.True(t, ok)
	require.Len(t, classified.Error.Diagnostics, 1)
	require.Equal(t, "plan.node_unreachable", classified.Error.Diagnostics[0].Code)
	require.Equal(t, "$.nodes.entry", classified.Error.Diagnostics[0].Path)
	require.Equal(t, SeverityError, classified.Error.Diagnostics[0].Severity)
	require.Equal(t, 12, classified.Error.Diagnostics[0].Line)
	require.Equal(t, 3, classified.Error.Diagnostics[0].Column)
	require.Equal(t, "entry", classified.Error.Diagnostics[0].NodeID)
	require.Equal(t, "edge_1", classified.Error.Diagnostics[0].EdgeID)
	require.Equal(t, "$.nodes.entry", classified.Error.Path)
}

func TestClassifyProjectsDiagnosticMapLocations(t *testing.T) {
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrUnprocessable, code: "plan_compile_failed", message: "compile failed",
		details: map[string]any{"diagnostics": []any{map[string]any{
			"severity": "error", "code": "plan.unknown_field", "path": "$.nodes.a", "message": "unknown field",
			"line": 7, "column": 2, "nodeId": "a", "edgeId": "",
		}}},
	}, Options{Stage: StagePlan, DefaultCode: "plan_request_failed"})
	require.True(t, ok)
	require.Len(t, classified.Error.Diagnostics, 1)
	require.Equal(t, 7, classified.Error.Diagnostics[0].Line)
	require.Equal(t, 2, classified.Error.Diagnostics[0].Column)
	require.Equal(t, "a", classified.Error.Diagnostics[0].NodeID)
}

func TestClassifyPublicWorkflowErrorStatusOverride(t *testing.T) {
	classified, ok := Classify(WorkflowError{
		Kind: storeerr.ErrBadRequest, Status: http.StatusPreconditionRequired, Code: "precondition_required",
		Problem: "If-Match is required",
	}, Options{Stage: StagePlan, DefaultCode: "plan_request_failed"})
	require.True(t, ok)
	require.Equal(t, http.StatusPreconditionRequired, classified.Status)
	require.Equal(t, "precondition_required", classified.Error.Code)
	require.Equal(t, StagePlan, classified.Error.Stage)
	require.Equal(t, "If-Match is required", classified.Error.Problem)
	require.False(t, classified.Error.Retryable)
}

func TestClassifyPublicWorkflowErrorCarriesHintPathAndDiagnostics(t *testing.T) {
	diagnostic := Diagnostic{
		Severity: SeverityError, Code: "input.unknown_field", Path: "$.input.assignee",
		Field: "assignee", Message: "unknown input field",
	}
	classified, ok := Classify(WorkflowError{
		Kind: storeerr.ErrUnprocessable, Code: "invocation_input_invalid",
		Problem: "invocation input is invalid", Hint: "check the declared input fields",
		Path: "$.input.assignee", Diagnostics: []Diagnostic{diagnostic},
	}, Options{Stage: StageInvocation, DefaultCode: "invocation_request_failed"})
	require.True(t, ok)
	require.Equal(t, http.StatusUnprocessableEntity, classified.Status)
	require.Equal(t, "check the declared input fields", classified.Error.Hint)
	require.Equal(t, "$.input.assignee", classified.Error.Path)
	require.Len(t, classified.Error.Diagnostics, 1)
	require.Equal(t, "input.unknown_field", classified.Error.Diagnostics[0].Code)
	require.Equal(t, "assignee", classified.Error.Diagnostics[0].Field)
	// The details envelope never carries a duplicate of the public top-level
	// diagnostics projection.
	require.Nil(t, classified.Details)
}

func TestClassifyPublicWorkflowErrorRetryable(t *testing.T) {
	classified, ok := Classify(WorkflowError{
		Kind: storeerr.ErrBadRequest, Code: "run_retry_later", Problem: "temporarily busy", Retryable: true,
	}, Options{Stage: StageRun, DefaultCode: "run_request_failed"})
	require.True(t, ok)
	require.Equal(t, http.StatusBadRequest, classified.Status)
	require.True(t, classified.Error.Retryable)
}

func TestClassifyHintAndRetryable(t *testing.T) {
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrConflict, code: "run_terminal_conflict", message: "completed",
	}, Options{
		Stage: StageRun, DefaultCode: "run_request_failed",
		HintFor: func(code string) string {
			if code == "run_terminal_conflict" {
				return "start a new Run instead"
			}
			return ""
		},
	})
	require.True(t, ok)
	require.Equal(t, "start a new Run instead", classified.Error.Hint)
}

func TestClassifyNilError(t *testing.T) {
	_, ok := Classify(nil, Options{Stage: StageRun, DefaultCode: "run_request_failed"})
	require.False(t, ok)
}

func TestClassifyBoundedProblemCapsLength(t *testing.T) {
	long := strings.Repeat("x", 2000)
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrConflict, code: "run_conflict", message: long,
	}, Options{Stage: StageRun, DefaultCode: "run_request_failed"})
	require.True(t, ok)
	require.True(t, len(classified.Error.Problem) <= 512)
}

func TestStatusForError(t *testing.T) {
	require.Equal(t, http.StatusBadRequest, StatusForError(storeerr.BadRequest("bad")))
	require.Equal(t, http.StatusForbidden, StatusForError(storeerr.Forbidden("forbidden")))
	require.Equal(t, http.StatusNotFound, StatusForError(storeerr.NotFound("missing")))
	require.Equal(t, http.StatusConflict, StatusForError(storeerr.Conflict("conflict")))
	require.Equal(t, http.StatusUnprocessableEntity, StatusForError(storeerr.New(storeerr.ErrUnprocessable, "unprocessable")))
	require.Equal(t, 0, StatusForError(errors.New("unknown")))
}

// TestStatusForErrorDeclaredKindAuthoritativeAcrossMixedSentinels proves a
// declared store kind stays authoritative for the HTTP status even when the
// preserved internal cause wraps a DIFFERENT store sentinel. A conflict whose
// cause wraps a bad-request or not-found must classify as 409, never 400/404;
// a forbidden wrapped selector with a not-found cause stays 403 so protected
// non-enumeration still applies.
func TestStatusForErrorDeclaredKindAuthoritativeAcrossMixedSentinels(t *testing.T) {
	tests := []struct {
		name string
		err  error
		want int
	}{
		{name: "conflict wraps bad request", err: storeerr.Wrapped(storeerr.ErrConflict, "conflict", storeerr.BadRequest("internal bad request")), want: http.StatusConflict},
		{name: "conflict wraps not found", err: storeerr.Wrapped(storeerr.ErrConflict, "conflict", storeerr.NotFound("internal not found")), want: http.StatusConflict},
		{name: "not found wraps bad request", err: storeerr.Wrapped(storeerr.ErrNotFound, "not found", storeerr.BadRequest("internal bad request")), want: http.StatusNotFound},
		{name: "bad request wraps conflict", err: storeerr.Wrapped(storeerr.ErrBadRequest, "bad request", storeerr.Conflict("internal conflict")), want: http.StatusBadRequest},
		{name: "forbidden wraps not found", err: storeerr.Wrapped(storeerr.ErrForbidden, "forbidden", storeerr.NotFound("internal not found")), want: http.StatusForbidden},
		{name: "unauthorized wraps conflict", err: storeerr.Wrapped(storeerr.ErrUnauthorized, "unauthorized", storeerr.Conflict("internal conflict")), want: http.StatusForbidden},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			require.Equal(t, test.want, StatusForError(test.err))
		})
	}
}

// TestClassifyMixedSentinelsProtected403StaysCollapsible proves a forbidden
// protected selector whose internal cause wraps a not-found still resolves to
// the protected not-found collapse path (403 base status, protected mapping)
// rather than being pulled to 404 by the cause sentinel.
func TestClassifyMixedSentinelsProtected403StaysCollapsible(t *testing.T) {
	options := Options{
		Stage:       StageBinding,
		DefaultCode: "binding_request_failed",
		ProtectedSelectors: map[string]string{
			"binding_not_found":      "binding_not_found",
			"binding_forbidden":      "binding_not_found",
			"binding_plan_not_found": "binding_not_found",
		},
		ProtectedNotFoundCode: "binding_not_found",
	}
	classified, ok := Classify(storeerr.Wrapped(storeerr.ErrForbidden, "binding forbidden", storeerr.NotFound("internal repository not found")), options)
	require.True(t, ok)
	require.Equal(t, http.StatusNotFound, classified.Status)
	require.Equal(t, "binding_not_found", classified.Error.Code)
	require.True(t, classified.Collapsed)
	require.Equal(t, "store.forbidden", classified.InternalCode)
}

func TestBoundDiagnosticsPreservesLocationFieldsAndUTF8(t *testing.T) {
	diagnostics := []Diagnostic{{
		Severity: SeverityError,
		Code:     "plan.node_unreachable",
		Path:     "$.nodes.entry",
		Field:    "entry",
		Line:     12,
		Column:   3,
		NodeID:   "entry",
		EdgeID:   "edge_1",
		Message:  "节点不可达",
	}}
	bounded, truncation := BoundDiagnostics(diagnostics)
	require.Len(t, bounded, 1)
	require.Nil(t, truncation, "within-budget diagnostics must not report truncation")
	require.Equal(t, 12, bounded[0].Line)
	require.Equal(t, 3, bounded[0].Column)
	require.Equal(t, "entry", bounded[0].NodeID)
	require.Equal(t, "edge_1", bounded[0].EdgeID)
	require.Equal(t, "节点不可达", bounded[0].Message)
}

func TestBoundDiagnosticsCapsCount(t *testing.T) {
	diagnostics := make([]Diagnostic, 0, MaxServerDiagnostics+5)
	for i := 0; i < MaxServerDiagnostics+5; i++ {
		diagnostics = append(diagnostics, Diagnostic{
			Severity: SeverityError, Code: "plan.test", Path: "$.nodes.n", Message: "m",
		})
	}
	bounded, truncation := BoundDiagnostics(diagnostics)
	require.Len(t, bounded, MaxServerDiagnostics)
	require.NotNil(t, truncation)
	require.True(t, truncation.DiagnosticsTruncated)
	require.Equal(t, 5, truncation.OmittedCount)
}

func TestBoundDiagnosticsCapsFieldsAndSerializedSize(t *testing.T) {
	longMessage := strings.Repeat("m", maxServerDiagnosticMessageRunes+10)
	longField := strings.Repeat("f", maxServerDiagnosticFieldRunes+10)
	bounded, truncation := BoundDiagnostics([]Diagnostic{{
		Severity: SeverityError, Code: "plan.test", Path: "$.n", Field: longField, Message: longMessage,
	}})
	require.Len(t, bounded, 1)
	require.NotNil(t, truncation)
	require.LessOrEqual(t, len([]rune(bounded[0].Message)), maxServerDiagnosticMessageRunes)
	require.LessOrEqual(t, len([]rune(bounded[0].Field)), maxServerDiagnosticFieldRunes)
	require.Contains(t, truncation.TruncatedFields, "message")
	require.Contains(t, truncation.TruncatedFields, "field")

	// Serialized-size cap drops tail diagnostics until the array fits.
	heavy := make([]Diagnostic, 0, MaxServerDiagnostics)
	for i := 0; i < MaxServerDiagnostics; i++ {
		heavy = append(heavy, Diagnostic{
			Severity: SeverityError, Code: "plan.test", Path: "$.n",
			Message: strings.Repeat("m", maxServerDiagnosticMessageRunes),
		})
	}
	bounded, truncation = BoundDiagnostics(heavy)
	require.NotEmpty(t, bounded)
	require.NotNil(t, truncation)
	require.True(t, truncation.DiagnosticsTruncated)
	require.Greater(t, truncation.OmittedCount, 0)
}

func TestClassifyAppliesDiagnosticBoundsAndTruncationMetadata(t *testing.T) {
	index := 0
	diagnostics := make([]Diagnostic, 0, MaxServerDiagnostics+3)
	for i := 0; i < MaxServerDiagnostics+3; i++ {
		diagnostics = append(diagnostics, Diagnostic{
			Severity: SeverityError, Code: "plan.test", Path: "$.nodes.n",
			Field: "n", Index: &index, Message: "m",
		})
	}
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrUnprocessable, code: "plan_compile_failed", message: "Plan draft did not compile",
		details: map[string]any{"diagnostics": diagnostics},
	}, Options{Stage: StagePlan, DefaultCode: "plan_request_failed"})
	require.True(t, ok)
	require.Len(t, classified.Error.Diagnostics, MaxServerDiagnostics)
	require.NotNil(t, classified.Error.Truncation)
	require.True(t, classified.Error.Truncation.DiagnosticsTruncated)
	require.Equal(t, 3, classified.Error.Truncation.OmittedCount)
	// The details envelope must not carry the duplicate array at all: the
	// top-level diagnostics field is the single public projection.
	require.Nil(t, classified.Details)
}

func TestClassifySmallDiagnosticsHaveNoTruncationMetadata(t *testing.T) {
	diagnostics := []Diagnostic{{
		Severity: SeverityError, Code: "plan.test", Path: "$.n", Field: "n", Message: "m",
	}}
	classified, ok := Classify(testDomainError{
		kind: storeerr.ErrUnprocessable, code: "plan_compile_failed", message: "compile failed",
		details: map[string]any{"diagnostics": diagnostics},
	}, Options{Stage: StagePlan, DefaultCode: "plan_request_failed"})
	require.True(t, ok)
	require.Len(t, classified.Error.Diagnostics, 1)
	require.Nil(t, classified.Error.Truncation, "within-budget diagnostics must not add truncation metadata")
}
