package registry

import (
	"context"
	"encoding/json"
	"testing"

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

	"github.com/tidbcloud/AgentTeam/internal/extension/spec"
)

// stubExecPtr is an Executor whose method set is on the pointer receiver, so a
// typed-nil *stubExecPtr is non-nil to a plain != nil check but nil in practice.
type stubExecPtr struct{}

func (s *stubExecPtr) Strategy() string { return "stub" }

func (s *stubExecPtr) Invoke(context.Context, BuiltinInvocation) (BuiltinResult, error) {
	return BuiltinResult{}, nil
}

// ---- Invariant: same digest ⇒ byte-identical stored Binding (normalized
// storage). Register External with reordered auth profiles, scopes, success
// statuses, and content types in two separate registries; both share a digest
// (the projection canonicalizes order), and both must return a byte-identical
// stored Binding because RegisterExternal stores the normalized package. ----

func TestRegisterExternal_NormalizedStorage_ByteIdenticalBinding(t *testing.T) {
	// Build one package whose order-independent sets are in one order, and a
	// second with the SAME semantic content but every set reordered. Both derive
	// the same digest (the canonical-digest projection sorts these sets). The
	// default profile and the action auth profile reference are identical across
	// the two packages so the semantic content is the same — only the ORDER of
	// the profile list, scopes, statuses, and content types differs.
	profilesA := []spec.AuthProfile{
		{ID: "alpha", Type: "oauth2_bearer"},
		{ID: "beta", Type: "oauth2_bearer"},
	}
	profilesB := []spec.AuthProfile{
		{ID: "beta", Type: "oauth2_bearer"},
		{ID: "alpha", Type: "oauth2_bearer"},
	}
	pkgA := makeRichPackage(t, "normalize-ext", profilesA,
		[]string{"scope-b", "scope-a"},
		[]int{201, 200},
		[]string{"text/plain", "application/json"})
	pkgB := makeRichPackage(t, "normalize-ext", profilesB,
		[]string{"scope-a", "scope-b"},
		[]int{200, 201},
		[]string{"application/json", "text/plain"})

	require.Equal(t, pkgA.Digest, pkgB.Digest,
		"packages that differ only in the order of unordered sets must share a digest")

	// Register each in its OWN registry (the same digest cannot live under two
	// identities in one registry), then look both up.
	regA := New()
	gotA, err := regA.RegisterExternal(pkgA)
	require.NoError(t, err)
	regB := New()
	gotB, err := regB.RegisterExternal(pkgB)
	require.NoError(t, err)

	// Serialize both stored Bindings to JSON and compare bytes. JSON marshaling
	// emits sorted map keys, so the only way the bytes differ is if a SLICE
	// (profiles, scopes, statuses, content types) is in a different order. The
	// normalize-on-store invariant forces all of those into canonical order, so
	// the two serializations must be byte-identical.
	bytesA, err := json.Marshal(gotA.Binding)
	require.NoError(t, err)
	bytesB, err := json.Marshal(gotB.Binding)
	require.NoError(t, err)
	assert.Equal(t, string(bytesA), string(bytesB),
		"same digest must guarantee byte-identical stored Binding regardless of caller input order")

	// Also prove the canonical order is the SORTED order (not just identical to
	// each other): scopes [scope-a, scope-b], statuses [200, 201], content types
	// [application/json, text/plain], profiles [alpha, beta].
	require.NotEmpty(t, gotA.Binding.Actions)
	binding := gotA.Binding.Actions[0]
	assert.Equal(t, []string{"scope-a", "scope-b"}, binding.Auth.RequiredScopes,
		"stored scopes must be in sorted order")
	assert.Equal(t, []int{200, 201}, binding.Binding.Response.SuccessStatuses,
		"stored success statuses must be in sorted order")
	assert.Equal(t, []string{"application/json", "text/plain"}, binding.Binding.Response.ContentTypes,
		"stored content types must be in sorted order")
	require.Len(t, gotA.Binding.Auth.Profiles, 2)
	assert.Equal(t, "alpha", gotA.Binding.Auth.Profiles[0].ID, "stored profiles must be sorted by ID")
	assert.Equal(t, "beta", gotA.Binding.Auth.Profiles[1].ID)
}

// ---- Invariant: a typed-nil Executor is rejected. Go's typed-nil
// ((*stubExecPtr)(nil)) wrapped in the Executor interface is non-nil to a plain
// != nil check but nil in practice; RegisterBuiltin must reject it as missing. ----

func TestRegisterBuiltin_TypedNilExecutor_Rejected(t *testing.T) {
	r := New()
	def := makeBuiltinDefinition(t, "typed-nil", 1, "do", "s")

	var nilExec *stubExecPtr // typed-nil: non-nil interface, nil pointer
	// Sanity check the precondition: the typed-nil is non-nil to == but nil to
	// reflect.
	require.False(t, nilExecutorInterface(nilExec) == nil,
		"precondition: a typed-nil Executor must be non-nil to a plain == nil check")

	_, err := r.RegisterBuiltin(def, nilExec, "rev-1")
	require.Error(t, err, "a typed-nil executor must be rejected")
	assert.ErrorIs(t, err, ErrBuiltinMissingExecutor)
	assert.Equal(t, 0, r.Len(), "a rejected registration must not mutate the registry")
}

// nilExecutorInterface wraps v in the Executor interface the same way
// RegisterBuiltin's call site does, so the == nil check behaves identically.
func nilExecutorInterface(v Executor) Executor { return v }

// makeRichPackage builds a self-consistent, digest-verified External Package
// with the given auth profiles (in their supplied order) and one action whose
// order-independent sets (required_scopes, success_statuses, content_types) are
// in the supplied order. The default profile and the action auth profile
// reference are both "alpha", which must be present in profiles, so two packages
// that differ only in the ORDER of the unordered sets share a digest.
func makeRichPackage(t *testing.T, domain string, profiles []spec.AuthProfile, scopes []string, statuses []int, contentTypes []string) spec.Package {
	t.Helper()
	pkg := spec.Package{
		Metadata: spec.Metadata{
			SchemaVersion: 1,
			Domain:        domain,
			Version:       1,
			Name:          "Rich " + domain,
			Endpoint:      spec.Endpoint{BaseURL: "https://api.rich.example.com"},
			Auth: spec.Auth{
				Default:  "alpha",
				Profiles: profiles,
			},
		},
		Actions: []spec.Action{{
			Name: "fetch-thing",
			File: "actions/fetch-thing.yaml",
			Auth: &spec.ActionAuthRequirement{
				Profile:        "alpha",
				RequiredScopes: scopes,
			},
			Catalog: spec.Catalog{
				Summary: "fetch",
				Safety:  spec.Safety{Class: "read", Idempotent: true},
				InputSchema: map[string]any{
					"$schema":              "https://json-schema.org/draft/2020-12/schema",
					"type":                 "object",
					"additionalProperties": false,
					"required":             []any{"id"},
					"properties": map[string]any{
						"id": map[string]any{"type": "string", "minLength": float64(1)},
					},
				},
				OutputSchema: map[string]any{
					"$schema": "https://json-schema.org/draft/2020-12/schema",
					"type":    "object",
					"properties": map[string]any{
						"id": map[string]any{"type": "string"},
					},
				},
				Example: map[string]any{"id": "abc"},
			},
			Binding: spec.Binding{
				Method: "GET",
				Path:   "/things/{id}",
				Parameters: []spec.Parameter{{
					From: "/id", In: "path", Name: "id",
				}},
				Response: spec.Response{
					Mode:            "passthrough",
					SuccessStatuses: statuses,
					ContentTypes:    contentTypes,
				},
			},
		}},
	}
	digest, err := spec.DefinitionDigest(pkg)
	require.NoError(t, err)
	pkg.Metadata.Digest = digest
	pkg.Digest = digest
	return pkg
}
