package spec

import (
	"encoding/json"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// ---- Invariant: absent output_schema (nil) and an explicit unconstrained
// output_schema ({}) have DISTINCT digests. Both are Agent-visible and
// semantically different (nil = no output contract declared; {} = an explicit
// schema placing no constraints), so the digest projection must not collapse
// them via omitempty. ----

func TestDefinitionDigest_OutputSchemaNilVsEmpty_DistinctDigests(t *testing.T) {
	// pNil: no OutputSchema declared (nil).
	pNil := basePkg()
	// basePkg's single action has no OutputSchema; assert the precondition.
	require.Nil(t, pNil.Actions[0].Catalog.OutputSchema)

	// pEmpty: an explicit unconstrained OutputSchema ({}).
	pEmpty := basePkg()
	pEmpty.Actions[0].Catalog.OutputSchema = map[string]any{}

	dNil, err := DefinitionDigest(pNil)
	require.NoError(t, err)
	dEmpty, err := DefinitionDigest(pEmpty)
	require.NoError(t, err)

	assert.NotEqual(t, dNil, dEmpty,
		"absent output_schema (nil) and explicit unconstrained output_schema ({}) must have distinct digests")
}

// TestDefinitionDigest_OutputSchemaNilVsPopulated proves the non-degenerate case:
// nil and a real schema are also distinct (sanity, not a regression target).
func TestDefinitionDigest_OutputSchemaNilVsPopulated(t *testing.T) {
	pNil := basePkg()
	pSet := basePkg()
	pSet.Actions[0].Catalog.OutputSchema = map[string]any{
		"$schema": "https://json-schema.org/draft/2020-12/schema",
		"type":    "object",
	}
	dNil, err := DefinitionDigest(pNil)
	require.NoError(t, err)
	dSet, err := DefinitionDigest(pSet)
	require.NoError(t, err)
	assert.NotEqual(t, dNil, dSet, "nil vs populated output_schema must differ")
}

// ---- Invariant: the safety destructive flag must agree with the class.
// class=destructive REQUIRES destructive=true; every other class (read,
// mutation) REQUIRES destructive=false. Contradictory combinations are rejected;
// consistent combinations are accepted. ----

func TestValidateSafety_DestructiveFlagMustAgreeWithClass(t *testing.T) {
	cases := []struct {
		name    string
		safety  Safety
		wantErr bool
	}{
		{"read + destructive=false (consistent)", Safety{Class: "read", Destructive: false}, false},
		{"read + destructive=true (contradiction)", Safety{Class: "read", Destructive: true}, true},
		{"mutation + destructive=false (consistent)", Safety{Class: "mutation", Destructive: false}, false},
		{"mutation + destructive=true (contradiction)", Safety{Class: "mutation", Destructive: true}, true},
		{"destructive + destructive=true (consistent)", Safety{Class: "destructive", Destructive: true}, false},
		{"destructive + destructive=false (contradiction)", Safety{Class: "destructive", Destructive: false}, true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := validateSafety("act", tc.safety)
			if tc.wantErr {
				require.Error(t, err, "contradictory safety contract must be rejected")
				assert.ErrorIs(t, err, ErrInvalidSafetyClass)
			} else {
				assert.NoError(t, err, "consistent safety contract must be accepted")
			}
		})
	}
}

// TestValidateCatalog_RejectsMutationDestructive is the integration-level proof
// that the shared catalog validator enforces the agreement through the public
// ValidateCatalog entry point (the path a code-registered Builtin takes).
func TestValidateCatalog_RejectsContradictorySafety(t *testing.T) {
	meta := Metadata{SchemaVersion: 1, Domain: "demo", Version: 1, Name: "Demo"}
	baseAction := Action{
		Name: "do",
		Catalog: Catalog{
			Safety:      Safety{Class: "mutation", Destructive: true},
			InputSchema: map[string]any{"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object"},
		},
	}

	// mutation + destructive=true must be rejected.
	err := ValidateCatalog(meta, []Action{baseAction})
	require.Error(t, err, "mutation+destructive=true must be rejected by ValidateCatalog")
	assert.ErrorIs(t, err, ErrInvalidSafetyClass)

	// destructive + destructive=false must also be rejected.
	dAction := baseAction
	dAction.Catalog.Safety = Safety{Class: "destructive", Destructive: false}
	err = ValidateCatalog(meta, []Action{dAction})
	require.Error(t, err, "destructive+destructive=false must be rejected by ValidateCatalog")
	assert.ErrorIs(t, err, ErrInvalidSafetyClass)

	// And the consistent combinations must pass.
	okAction := baseAction
	okAction.Catalog.Safety = Safety{Class: "destructive", Destructive: true}
	assert.NoError(t, ValidateCatalog(meta, []Action{okAction}), "destructive+destructive=true must pass")

	readAction := baseAction
	readAction.Catalog.Safety = Safety{Class: "read", Destructive: false}
	assert.NoError(t, ValidateCatalog(meta, []Action{readAction}), "read+destructive=false must pass")
}

// ---- Invariant: NormalizeDefinition is digest-preserving and normalizes
// unordered sets. Two packages that differ only in set ordering normalize to
// the same value and share a digest. ----

func TestNormalizeDefinition_DigestPreserving_AndNormalizesSets(t *testing.T) {
	// One package with sets in sorted order, one with sets reversed. Both
	// share a digest (the projection sorts), and NormalizeDefinition must produce
	// sorted order from both.
	pkgSorted := basePkg()
	pkgSorted.Metadata.Auth = Auth{
		Default: "alpha",
		Profiles: []AuthProfile{
			{ID: "alpha", Type: "oauth2_bearer"},
			{ID: "beta", Type: "api_key", In: "header", Name: "X-Beta"},
		},
	}
	pkgSorted.Actions[0].Auth = &ActionAuthRequirement{Profile: "alpha", RequiredScopes: []string{"a", "b"}}
	pkgSorted.Actions[0].Binding.Response.SuccessStatuses = []int{200, 201}
	pkgSorted.Actions[0].Binding.Response.ContentTypes = []string{"application/json", "text/plain"}

	pkgReordered := basePkg()
	pkgReordered.Metadata.Auth = Auth{
		Default: "alpha",
		Profiles: []AuthProfile{
			{ID: "beta", Type: "api_key", In: "header", Name: "X-Beta"},
			{ID: "alpha", Type: "oauth2_bearer"},
		},
	}
	pkgReordered.Actions[0].Auth = &ActionAuthRequirement{Profile: "alpha", RequiredScopes: []string{"b", "a"}}
	pkgReordered.Actions[0].Binding.Response.SuccessStatuses = []int{201, 200}
	pkgReordered.Actions[0].Binding.Response.ContentTypes = []string{"text/plain", "application/json"}

	dSorted, err := DefinitionDigest(pkgSorted)
	require.NoError(t, err)
	dReordered, err := DefinitionDigest(pkgReordered)
	require.NoError(t, err)
	require.Equal(t, dSorted, dReordered, "precondition: reordered sets must share a digest")

	// NormalizeDefinition is digest-preserving.
	normSorted := NormalizeDefinition(pkgSorted)
	normReordered := NormalizeDefinition(pkgReordered)
	dNormSorted, err := DefinitionDigest(normSorted)
	require.NoError(t, err)
	dNormReordered, err := DefinitionDigest(normReordered)
	require.NoError(t, err)
	assert.Equal(t, dSorted, dNormSorted, "NormalizeDefinition must preserve the digest")
	assert.Equal(t, dReordered, dNormReordered, "NormalizeDefinition must preserve the digest")

	// NormalizeDefinition puts both into the SAME canonical order.
	assert.Equal(t, []string{"alpha", "beta"},
		[]string{normSorted.Metadata.Auth.Profiles[0].ID, normSorted.Metadata.Auth.Profiles[1].ID})
	assert.Equal(t, []string{"alpha", "beta"},
		[]string{normReordered.Metadata.Auth.Profiles[0].ID, normReordered.Metadata.Auth.Profiles[1].ID})
	assert.Equal(t, []string{"a", "b"}, normReordered.Actions[0].Auth.RequiredScopes)
	assert.Equal(t, []int{200, 201}, normReordered.Actions[0].Binding.Response.SuccessStatuses)
	assert.Equal(t, []string{"application/json", "text/plain"}, normReordered.Actions[0].Binding.Response.ContentTypes)
}

// ---- Blocker #2 (adversarial): NormalizeDefinition is digest-preserving
// (SORT ONLY, no element removal). Sorting is order-invariant, so it never
// changes the digest — even for unvalidated input that carries duplicates,
// which a dedup-ing normalizer would silently remove (changing the digest of
// input it never validated). ----

func TestNormalizeDefinition_IsDigestPreserving(t *testing.T) {
	// Rich unordered-set content in non-canonical order.
	pkg := basePkg()
	pkg.Metadata.Auth = Auth{
		Default: "alpha",
		Profiles: []AuthProfile{
			{ID: "beta", Type: "api_key", In: "header", Name: "X-Beta"},
			{ID: "alpha", Type: "oauth2_bearer"},
		},
	}
	pkg.Actions[0].Auth = &ActionAuthRequirement{Profile: "alpha", RequiredScopes: []string{"z", "a", "m"}}
	pkg.Actions[0].Binding.Response.SuccessStatuses = []int{204, 200, 201}
	pkg.Actions[0].Binding.Response.ContentTypes = []string{"text/plain", "application/xml", "application/json"}

	before, err := DefinitionDigest(pkg)
	require.NoError(t, err)
	normalized := NormalizeDefinition(pkg)
	after, err := DefinitionDigest(normalized)
	require.NoError(t, err)
	assert.Equal(t, before, after,
		"NormalizeDefinition must preserve DefinitionDigest (sort-only, no element removal)")

	// Adversarial: input WITH duplicates. Sort-only normalization keeps every
	// element, so the digest is unchanged. A dedup-ing normalizer would REMOVE
	// the duplicate and change the digest of input it never validated.
	dupPkg := basePkg()
	dupPkg.Actions[0].Binding.Response.SuccessStatuses = []int{200, 200, 201} // duplicate 200
	dupBefore, err := DefinitionDigest(dupPkg)
	require.NoError(t, err)
	dupNorm := NormalizeDefinition(dupPkg)
	dupAfter, err := DefinitionDigest(dupNorm)
	require.NoError(t, err)
	assert.Equal(t, dupBefore, dupAfter,
		"sort-only (no dedup) must keep duplicates so the digest of unvalidated input is unchanged")
	assert.Equal(t, []int{200, 200, 201}, dupNorm.Actions[0].Binding.Response.SuccessStatuses,
		"normalizer must not remove duplicates — dedup is the validator's job")
}

// ---- Blocker #2 (adversarial): NormalizeDefinition fully detaches typed Go
// containers (map[string]string, []string, []int, ...) via a JSON round-trip, so
// programmatic (Builtin) registration that supplies typed containers cannot
// alias the caller's input. And dedup is the VALIDATOR's job: ValidateDefinition
// rejects duplicates BEFORE any normalization runs. ----

func TestNormalizeDefinition_FullDetach_TypedContainers(t *testing.T) {
	pkg := basePkg()
	// Inject TYPED Go containers a programmatic registration may supply. The old
	// detachedJSONValue handled only map[string]any/[]any, leaving these aliased to
	// the caller's input.
	pkg.Actions[0].Catalog.Example = map[string]any{
		"labels": map[string]string{"b": "2", "a": "1"}, // typed map
		"tags":   []string{"x", "y"},                    // typed string slice
		"counts": []int{1, 2},                           // typed int slice
	}
	// A typed slice nested inside a schema value (e.g. an enum).
	pkg.Actions[0].Catalog.InputSchema["enum"] = []string{"foo", "bar"}

	normalized := NormalizeDefinition(pkg)

	// (a) Normalized containers are the deterministic JSON tree types, not the typed
	// aliases.
	normEx, ok := normalized.Actions[0].Catalog.Example.(map[string]any)
	require.True(t, ok, "example must be map[string]any after normalization")
	normLabels := normEx["labels"]
	_, isCanonMap := normLabels.(map[string]any)
	require.True(t, isCanonMap, "typed map[string]string must be detached to map[string]any")
	normTags := normEx["tags"]
	_, isCanonSlice := normTags.([]any)
	require.True(t, isCanonSlice, "typed []string must be detached to []any")
	normCounts := normEx["counts"]
	_, isCanonIntSlice := normCounts.([]any)
	require.True(t, isCanonIntSlice, "typed []int must be detached to []any")

	// (b) No aliasing: mutating the INPUT's typed containers must NOT affect the
	// normalized output.
	pkg.Actions[0].Catalog.Example.(map[string]any)["labels"].(map[string]string)["a"] = "MUTATED"
	pkg.Actions[0].Catalog.Example.(map[string]any)["tags"].([]string)[0] = "MUTATED"
	assert.NotEqual(t, "MUTATED", normLabels.(map[string]any)["a"], "normalized map must not alias the input")
	assert.NotEqual(t, "MUTATED", normTags.([]any)[0], "normalized slice must not alias the input")

	// Schema enum typed slice also detached to []any.
	normEnum := normalized.Actions[0].Catalog.InputSchema["enum"]
	_, isCanonEnum := normEnum.([]any)
	require.True(t, isCanonEnum, "schema []string enum must be detached to []any")
	pkg.Actions[0].Catalog.InputSchema["enum"].([]string)[0] = "MUTATED"
	assert.NotEqual(t, "MUTATED", normEnum.([]any)[0], "normalized schema enum must not alias the input")

	// (c) Dedup is the VALIDATOR's job, not the normalizer's. The validator
	// rejects duplicates BEFORE normalization, so a normalizer that removed
	// elements would be acting on input it never validated.
	dupScope := basePkg()
	dupScope.Actions[0].Auth = &ActionAuthRequirement{Profile: "default", RequiredScopes: []string{"repo", "repo"}}
	require.ErrorContains(t, ValidateDefinition(dupScope), "duplicate required_scope",
		"validator must reject duplicate scopes")

	dupStatus := basePkg()
	dupStatus.Actions[0].Binding.Response.SuccessStatuses = []int{200, 200}
	require.ErrorContains(t, ValidateDefinition(dupStatus), "duplicate success status",
		"validator must reject duplicate success statuses")

	dupCT := basePkg()
	dupCT.Actions[0].Binding.Response.ContentTypes = []string{"application/json", "application/json"}
	require.ErrorContains(t, ValidateDefinition(dupCT), "duplicate content type",
		"validator must reject duplicate content types")
}

// ---- Stage-2 round-6 blocker (adversarial): the detachedJSONValue JSON round-trip
// must use json.Decoder.UseNumber so large integers (>2^53) survive the
// detachment losslessly. The float64 coercion used by a plain json.Unmarshal
// silently rounds 2^53+1 to 2^53, which both COLLIDES the digest of distinct
// Agent-visible values and would make a post-normalize VerifyDigest fail for a
// schema carrying a large-integer constraint. json.Number (string-backed)
// preserves exact precision and is a leaf in the canonical tree. ----

func TestNormalizeDefinition_LargeInteger_FullDetach(t *testing.T) {
	// 2^53 and 2^53+1: distinct integers that a float64 round-trip collapses.
	const exact = 9007199254740993 // 2^53 + 1, NOT exactly representable as float64

	pkg := basePkg()
	// Top-level large-integer example AND a large integer nested in a map. Both
	// flow through detachedJSONValue (JSON round-trip) during normalization.
	pkg.Actions[0].Catalog.Example = map[string]any{
		"count":  exact, // an int on a 64-bit host — exact, no float64 rounding yet
		"nested": map[string]any{"value": exact},
	}

	before, err := DefinitionDigest(pkg)
	require.NoError(t, err)
	normalized := NormalizeDefinition(pkg)
	after, err := DefinitionDigest(normalized)
	require.NoError(t, err)
	assert.Equal(t, before, after,
		"NormalizeDefinition must preserve DefinitionDigest for large-integer examples (lossless round-trip)")

	// (a) The normalized large integers are preserved EXACTLY as json.Number — no
	// float64 rounding. json.Number is the string-backed lossless leaf produced by
	// decoder.UseNumber.
	normEx, ok := normalized.Actions[0].Catalog.Example.(map[string]any)
	require.True(t, ok, "example must be map[string]any after normalization")
	assert.Equal(t, json.Number("9007199254740993"), normEx["count"],
		"large-integer example must be preserved losslessly as json.Number, not rounded to float64")
	nested, ok := normEx["nested"].(map[string]any)
	require.True(t, ok, "nested example map must be map[string]any")
	assert.Equal(t, json.Number("9007199254740993"), nested["value"],
		"nested large-integer example must be preserved losslessly as json.Number")

	// Re-marshaling the canonical leaf reproduces the exact integer bytes (this is
	// what the digest encoder does), proving no precision was lost.
	reMarshaled, err := json.Marshal(normEx["count"])
	require.NoError(t, err)
	assert.Equal(t, "9007199254740993", string(reMarshaled),
		"json.Number must re-marshal to the exact integer bytes")

	// (b) Full detach: mutating the INPUT's example after normalization must NOT
	// affect the normalized output (detachedJSONValue produces an independent tree).
	inputEx := pkg.Actions[0].Catalog.Example.(map[string]any)
	inputEx["count"] = 0
	inputEx["nested"].(map[string]any)["value"] = 0
	assert.Equal(t, json.Number("9007199254740993"), normEx["count"],
		"normalized output must not alias the caller's input map")
	assert.Equal(t, json.Number("9007199254740993"), normEx["nested"].(map[string]any)["value"],
		"normalized nested output must not alias the caller's input map")
}
