package exec

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"testing"
	"unicode/utf8"
)

// sliceErr is a non-comparable custom error type (slice value) used to verify
// classify never panics on it and degrades to internal.
type sliceErr []error

func (s sliceErr) Error() string   { return "slice err" }
func (s sliceErr) Unwrap() []error { return []error(s) }

func TestErrorCodeValid(t *testing.T) {
	for _, c := range []ErrorCode{
		CodeValidation, CodeNotFound, CodePermissionDenied, CodeConflict,
		CodeAgentExtensionCatalogStale, CodeProviderAuthUnavailable,
		CodeRateLimited, CodeUpstreamFailure, CodeInternal, CodeResultBudgetExceeded,
	} {
		if !c.valid() {
			t.Errorf("%q should be valid", c)
		}
	}
	for _, c := range []ErrorCode{"", "wat", "Validation"} {
		if c.valid() {
			t.Errorf("%q should be invalid", c)
		}
	}
}

func TestErrorCodeMapping(t *testing.T) {
	for _, tc := range []struct {
		code ErrorCode
		http int
	}{
		{CodeValidation, 400},
		{CodeNotFound, 404},
		{CodePermissionDenied, 403},
		{CodeConflict, 409},
		{CodeAgentExtensionCatalogStale, 409},
		{CodeProviderAuthUnavailable, 503},
		{CodeRateLimited, 429},
		{CodeUpstreamFailure, 502},
		{CodeResultBudgetExceeded, 413},
		{CodeInternal, 500},
		{"wat", 500}, // invalid -> internal
	} {
		if got := tc.code.httpStatus(); got != tc.http {
			t.Errorf("%q HTTPStatus = %d, want %d", tc.code, got, tc.http)
		}
	}
}

func TestNewErrorCoercesInvalidCode(t *testing.T) {
	if e := NewError("wat", "p"); e.Code != CodeInternal {
		t.Fatalf("invalid code not coerced: %v", e.Code)
	}
	if e := NewError(CodeNotFound, "p"); e.Code != CodeNotFound {
		t.Fatalf("valid code not preserved: %v", e.Code)
	}
}

func TestErrorEnvelopeBoundsAndRedaction(t *testing.T) {
	t.Run("rune cap with ellipsis inside budget", func(t *testing.T) {
		long := strings.Repeat("世", 5000) // multi-byte
		env := (&Error{Code: CodeInternal, Problem: long, Hint: long, Next: long}).envelope()
		if pr := len([]rune(env.Problem)); pr != maxProblemRunes {
			t.Errorf("Problem = %d runes, want %d (ellipsis inside cap)", pr, maxProblemRunes)
		}
		if !strings.HasSuffix(env.Problem, "…") {
			t.Error("Problem missing truncation marker")
		}
	})
	t.Run("invalid UTF-8 replaced", func(t *testing.T) {
		env := (&Error{Code: CodeInternal, Problem: "bad\xff\xfeend"}).envelope()
		if !utf8.ValidString(env.Problem) {
			t.Errorf("envelope Problem is not valid UTF-8: %q", env.Problem)
		}
	})
	t.Run("trimmed", func(t *testing.T) {
		env := (&Error{Code: CodeInternal, Problem: "  hello  ", Hint: "  h  "}).envelope()
		if env.Problem != "hello" || env.Hint != "h" {
			t.Errorf("not trimmed: %+v", env)
		}
	})
	t.Run("cause never serialized", func(t *testing.T) {
		cause := errors.New("db connection 10.0.0.1:5432")
		b, err := json.Marshal((&Error{Code: CodeNotFound, Problem: "missing", Cause: cause}).envelope())
		if err != nil {
			t.Fatal(err)
		}
		if bytes.Contains(b, []byte("10.0.0.1")) || bytes.Contains(b, []byte("Cause")) {
			t.Fatalf("envelope leaked cause: %s", b)
		}
	})
}

func TestClassify(t *testing.T) {
	if got := classify(nil); got != nil {
		t.Fatal("nil must classify to nil")
	}

	// typed-nil is a non-nil error interface -> internal, never nil.
	var typedNil *Error
	if got := classify(error(typedNil)); got == nil || got.Code != CodeInternal {
		t.Fatalf("typed-nil must be internal, got %+v", got)
	}

	// raw error -> internal.
	raw := errors.New("boom")
	if got := classify(raw); got.Code != CodeInternal || got.Cause != raw {
		t.Fatalf("raw error not internal-wrapped: %+v", got)
	}

	// wrapped single *Error preserves code/Problem/Hint AND the outer chain as
	// Cause so errors.Is/As stay usable for logging.
	inner := &Error{Code: CodeConflict, Problem: "v", Hint: "h"}
	outer := fmt.Errorf("handler: %w", inner)
	got := classify(outer)
	if got.Code != CodeConflict || got.Problem != "v" || got.Hint != "h" {
		t.Fatalf("wrapped *Error lost fields: %+v", got)
	}
	if !errors.Is(got, outer) {
		t.Error("classified error lost the outer chain (errors.Is)")
	}

	// direct *Error returns a copy, keeping its own Cause; not the caller's ptr.
	e := &Error{Code: CodeNotFound, Problem: "p", Cause: errors.New("c")}
	got = classify(e)
	if got == e {
		t.Fatal("classify must return a copy, not the caller's pointer")
	}
	if got.Cause == nil {
		t.Error("direct *Error lost its Cause")
	}

	// invalid code coerced to internal WITHOUT mutating the caller.
	bad := &Error{Code: "wat", Problem: "p"}
	if got := classify(bad); got.Code != CodeInternal {
		t.Fatalf("invalid code not coerced: %+v", got)
	}
	if bad.Code != "wat" {
		t.Fatal("classify mutated the caller's *Error")
	}

	// Join with multiple distinct typed errors is ambiguous -> internal, both orders.
	a := &Error{Code: CodeInternal, Problem: "a"}
	b := &Error{Code: CodePermissionDenied, Problem: "b"}
	if g1, g2 := classify(errors.Join(a, b)), classify(errors.Join(b, a)); g1.Code != CodeInternal || g2.Code != CodeInternal {
		t.Fatalf("join conflict must be internal both orders: %v / %v", g1.Code, g2.Code)
	}

	// non-comparable custom error -> internal, no panic.
	if got := classify(sliceErr{&Error{Code: CodeConflict, Problem: "x"}}); got == nil || got.Code != CodeInternal {
		t.Fatalf("non-comparable must be internal (no panic), got %+v", got)
	}

	// standard Join with a typed-nil plus a valid typed -> internal: the typed-nil
	// is a failure and must not be masked by the valid sibling.
	if got := classify(errors.Join(typedNil, &Error{Code: CodeNotFound, Problem: "v"})); got == nil || got.Code != CodeInternal {
		t.Fatalf("join(typed-nil, valid) must be internal, got %+v", got)
	}
}

func TestErrorErrorUnwrap(t *testing.T) {
	cause := errors.New("root")
	e := &Error{Code: CodeValidation, Problem: "bad", Cause: cause}
	if e.Error() != "validation: bad" {
		t.Errorf("Error() = %q", e.Error())
	}
	if !errors.Is(e, cause) {
		t.Error("Unwrap did not expose cause for errors.Is")
	}
	var target *Error
	if !errors.As(e, &target) {
		t.Error("errors.As(*Error) failed")
	}
}
