package spec

import (
	"crypto/sha256"
	"encoding/hex"
	"strings"
	"testing"
	"testing/fstest"

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

// basePkg returns a stable in-memory package used by digest tests.
func basePkg() Package {
	return Package{
		Metadata: Metadata{
			SchemaVersion: 1,
			Domain:        "demo",
			Version:       1,
			Name:          "Demo",
			Description:   "display only",
			Source: Source{
				Type:   "openapi",
				Path:   "source/openapi.yaml",
				SHA256: "abcdef",
			},
			Endpoint: Endpoint{BaseURL: "https://api.demo.example.com"},
			Auth: Auth{
				Default: "default",
				Profiles: []AuthProfile{
					{ID: "default", Type: "oauth2_bearer"},
				},
			},
		},
		Actions: []Action{
			{
				Name:              "create-thing",
				SourceOperationID: "things/create",
				Auth:              &ActionAuthRequirement{Profile: "default", RequiredScopes: []string{"repo"}},
				Catalog: Catalog{
					Summary:     "Create",
					Description: "Create one",
					Safety:      Safety{Class: "mutation", Idempotent: false, Destructive: false},
					InputSchema: map[string]any{
						"$schema":              "https://json-schema.org/draft/2020-12/schema",
						"type":                 "object",
						"additionalProperties": false,
						"required":             []any{"name"},
						"properties": map[string]any{
							"name": map[string]any{"type": "string", "minLength": 1},
						},
					},
					Example: map[string]any{"name": "x"},
				},
				Binding: Binding{
					Method: "POST",
					Path:   "/things",
					Body: &Body{
						ContentType: "application/json",
						Mappings:    []BodyMapping{{From: "/name", To: "/name"}},
					},
					Response: Response{
						Mode:            "passthrough",
						SuccessStatuses: []int{201},
						ContentTypes:    []string{"application/json"},
					},
				},
			},
		},
	}
}

func TestDefinitionDigest_Form(t *testing.T) {
	d, err := DefinitionDigest(basePkg())
	require.NoError(t, err)
	require.Regexp(t, `^sha256:[0-9a-f]{64}$`, d)

	// Manual cross-check of the projection hash so the algorithm is explicit.
	proj, err := semanticProjection(basePkg())
	require.NoError(t, err)
	encoded, err := encodeDeterministicJSON(proj)
	require.NoError(t, err)
	h := sha256.New()
	h.Write([]byte(semanticDigestDomain))
	h.Write(encoded)
	want := "sha256:" + hex.EncodeToString(h.Sum(nil))
	assert.Equal(t, want, d)
}

func TestDefinitionDigestIgnoresProvenanceFormattingAndActionFileOrder(t *testing.T) {
	// Two on-disk renderings: compact vs comment-laden, and reversed action
	// file order. The semantic content is identical, so digests must match.
	compact := validMapFS()
	formatted := fstest.MapFS{
		"pkg/meta.yaml":             {Data: []byte("# top comment\n\nschema_version: 1   # version pin\n\ndomain: demo  # kebab domain\nversion: 1\nname: Demo\ndescription: Minimal valid package.\nendpoint:\n  base_url: https://api.demo.example.com\nauth:\n  default: default\n  profiles:\n    - id: default\n      type: oauth2_bearer\nactions:\n  - name: do-thing\n    file: actions/do-thing.yaml\n")},
		"pkg/actions/do-thing.yaml": compact["pkg/actions/do-thing.yaml"],
	}
	p1, err := LoadDefinition(compact, "pkg")
	require.NoError(t, err)
	p2, err := LoadDefinition(formatted, "pkg")
	require.NoError(t, err)

	d1, err := DefinitionDigest(p1)
	require.NoError(t, err)
	d2, err := DefinitionDigest(p2)
	require.NoError(t, err)
	assert.Equal(t, d1, d2, "formatting/comments must not affect digest")

	// Reorder actions in memory: digest unchanged.
	p3 := basePkg()
	p3.Actions = []Action{basePkg().Actions[0]}
	d3, err := DefinitionDigest(p3)
	require.NoError(t, err)
	reversed := basePkg()
	reversed.Actions = append([]Action{}, basePkg().Actions...)
	drev, err := DefinitionDigest(reversed)
	require.NoError(t, err)
	assert.Equal(t, d3, drev, "action order must not affect digest")
}

func TestDefinitionDigestChangesWithInputSchemaBindingAuthSafetyAndResponsePolicy(t *testing.T) {
	base := basePkg()
	baseDigest, err := DefinitionDigest(base)
	require.NoError(t, err)

	alter := func(fn func(*Package)) string {
		p := basePkg()
		fn(&p)
		d, err := DefinitionDigest(p)
		require.NoError(t, err)
		return d
	}

	assert.NotEqual(t, baseDigest, alter(func(p *Package) {
		p.Actions[0].Catalog.InputSchema["properties"].(map[string]any)["name"] = map[string]any{"type": "string", "minLength": 2}
	}), "input schema change must change digest")

	assert.NotEqual(t, baseDigest, alter(func(p *Package) {
		p.Actions[0].Binding.Path = "/things/v2"
	}), "binding change must change digest")

	assert.NotEqual(t, baseDigest, alter(func(p *Package) {
		p.Metadata.Auth.Profiles[0].Type = "api_key"
	}), "auth change must change digest")

	assert.NotEqual(t, baseDigest, alter(func(p *Package) {
		p.Actions[0].Catalog.Safety.Class = "read"
	}), "safety change must change digest")

	assert.NotEqual(t, baseDigest, alter(func(p *Package) {
		p.Actions[0].Binding.Response.SuccessStatuses = []int{200}
	}), "response policy change must change digest")
}

// TestDefinitionDigest_CatalogAndDisplayFieldsMatter proves the §4.6 invariant
// that the FULL Agent-visible catalog contributes to the digest: name,
// description, summary, and example changes are semantic changes. Only source
// provenance and source_operation_id are display-only.
func TestDefinitionDigest_CatalogAndDisplayFieldsMatter(t *testing.T) {
	base := basePkg()
	dBase, err := DefinitionDigest(base)
	require.NoError(t, err)

	alter := func(fn func(*Package)) string {
		p := basePkg()
		fn(&p)
		d, err := DefinitionDigest(p)
		require.NoError(t, err)
		return d
	}

	// Agent-visible catalog/display fields DO change the digest.
	assert.NotEqual(t, dBase, alter(func(p *Package) {
		p.Metadata.Name = "Other"
	}), "metadata name change must change digest")
	assert.NotEqual(t, dBase, alter(func(p *Package) {
		p.Metadata.Description = "changed"
	}), "metadata description change must change digest")
	assert.NotEqual(t, dBase, alter(func(p *Package) {
		p.Actions[0].Catalog.Summary = "Other summary"
	}), "action summary change must change digest")
	assert.NotEqual(t, dBase, alter(func(p *Package) {
		p.Actions[0].Catalog.Description = "Other desc"
	}), "action description change must change digest")
	assert.NotEqual(t, dBase, alter(func(p *Package) {
		p.Actions[0].Catalog.Example = map[string]any{"name": "y"}
	}), "action example change must change digest")

	// Pure provenance/source_operation_id still do NOT affect the digest.
	assert.Equal(t, dBase, alter(func(p *Package) {
		p.Metadata.Source = Source{Type: "openapi", Path: "different/path.yaml", SHA256: "deadbeef"}
	}), "source provenance must not affect digest")
	assert.Equal(t, dBase, alter(func(p *Package) {
		p.Actions[0].SourceOperationID = "changed/operation"
	}), "source_operation_id must not affect digest")
}

// TestDefinitionDigest_OrderIndependentSetsAreNormalized proves reordering
// required_scopes, success statuses, and content types leaves the digest fixed.
func TestDefinitionDigest_OrderIndependentSetsAreNormalized(t *testing.T) {
	base := basePkg()
	dBase, err := DefinitionDigest(base)
	require.NoError(t, err)

	alter := func(fn func(*Package)) string {
		p := basePkg()
		fn(&p)
		d, err := DefinitionDigest(p)
		require.NoError(t, err)
		return d
	}

	// Reorder scopes on the Action auth requirement.
	assert.Equal(t, dBase, alter(func(p *Package) {
		p.Actions[0].Auth.RequiredScopes = []string{"repo"}
	}), "reordered scopes must not change digest")

	// Add multiple scopes and verify reordering is stable.
	multi := basePkg()
	multi.Actions[0].Auth.RequiredScopes = []string{"repo", "admin", "read"}
	dMulti, err := DefinitionDigest(multi)
	require.NoError(t, err)
	assert.Equal(t, dMulti, alter(func(p *Package) {
		p.Actions[0].Auth.RequiredScopes = []string{"read", "repo", "admin"}
	}), "reordered scopes must not change digest")

	// Reorder success statuses.
	assert.Equal(t, dBase, alter(func(p *Package) {
		p.Actions[0].Binding.Response.SuccessStatuses = []int{201}
	}), "reordered success statuses must not change digest")

	// Reorder content types.
	assert.Equal(t, dBase, alter(func(p *Package) {
		p.Actions[0].Binding.Response.ContentTypes = []string{"application/json"}
	}), "reordered content types must not change digest")
}

// TestDefinitionDigest_ProfileReorderStable proves that reordering auth profiles
// (which are an order-independent set) does not change the digest. Profiles are
// resolved-sorted by ID in the projection.
func TestDefinitionDigest_ProfileReorderStable(t *testing.T) {
	// Build a package with two profiles in one order.
	p1 := basePkg()
	p1.Metadata.Auth = Auth{
		Default: "alpha",
		Profiles: []AuthProfile{
			{ID: "alpha", Type: "oauth2_bearer"},
			{ID: "beta", Type: "api_key", In: "header", Name: "X-Beta"},
		},
	}
	d1, err := DefinitionDigest(p1)
	require.NoError(t, err)

	// Same profiles, reversed order — digest must be identical.
	p2 := basePkg()
	p2.Metadata.Auth = Auth{
		Default: "alpha",
		Profiles: []AuthProfile{
			{ID: "beta", Type: "api_key", In: "header", Name: "X-Beta"},
			{ID: "alpha", Type: "oauth2_bearer"},
		},
	}
	d2, err := DefinitionDigest(p2)
	require.NoError(t, err)
	assert.Equal(t, d1, d2, "profile reorder must not change digest")
}

func TestDefinitionDigestRejectsStoredDigestMismatch(t *testing.T) {
	p := basePkg()

	// Correct digest verifies.
	good, err := DefinitionDigest(p)
	require.NoError(t, err)
	p.Metadata.Digest = good
	require.NoError(t, VerifyDigest(p))

	// Wrong digest is rejected.
	p.Metadata.Digest = "sha256:" + strings.Repeat("0", 64)
	err = VerifyDigest(p)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "mismatch")

	// Missing digest is rejected.
	p.Metadata.Digest = ""
	err = VerifyDigest(p)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "digest")
}

func TestLoadDefinitionTreatsSourceAsDisplayOnly(t *testing.T) {
	// Build a package whose source.path points at a non-existent asset. Load
	// must succeed and target the recorded provenance without opening it.
	p := validStructPackage()
	p.Metadata.Source = Source{
		Type:   "openapi",
		Path:   "source/openapi.yaml",
		SHA256: strings.Repeat("a", 64),
	}
	pkg, err := LoadDefinition(pkgToFS(t, p), "pkg")
	require.NoError(t, err)
	assert.Equal(t, "source/openapi.yaml", pkg.Metadata.Source.Path)

	// Two packages that differ ONLY in source provenance share one digest.
	other := validStructPackage()
	other.Metadata.Source = Source{
		Type:   "openapi",
		Path:   "source/different.yaml",
		SHA256: strings.Repeat("9", 64),
	}
	d1, err := DefinitionDigest(pkg)
	require.NoError(t, err)
	dOther, err := DefinitionDigest(other)
	require.NoError(t, err)
	assert.Equal(t, d1, dOther, "source provenance must not affect digest")
}
