package spec

import (
	"bytes"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"sort"
)

// semanticDigestDomain prefixes the hashed bytes. It is the versioned Package
// Spec digest identifier; changing the projection algorithm requires a new domain
// (and therefore a new digest space) rather than silently changing V1 output.
// The trailing NUL prevents a prefix collision with a future, longer domain.
const semanticDigestDomain = "agentteam.extension.definition/v1\x00"

// semanticDefinition is the canonical projection of a Package. Per spec §4.6
// the digest covers the FULL Agent-visible catalog and every runtime-relevant
// field. The ONLY exclusions are pure display provenance (source location/sha,
// fetch time), the stored digest itself, source_operation_id, and YAML
// comments/formatting. Metadata name/description and catalog
// summary/description/example ARE part of the catalog the Agent sees, so they
// contribute to the digest: changing them yields a new immutable Definition.
type semanticDefinition struct {
	SchemaVersion int              `json:"schemaVersion"`
	Domain        string           `json:"domain"`
	Version       int              `json:"version"`
	Name          string           `json:"name"`
	Description   string           `json:"description,omitempty"`
	Endpoint      Endpoint         `json:"endpoint"`
	Auth          Auth             `json:"auth"`
	Actions       []semanticAction `json:"actions"`
}

// semanticAction carries the complete Agent-visible catalog plus the server-only
// HTTP binding. Nothing the Agent can observe is omitted from the digest.
type semanticAction struct {
	Name    string                 `json:"name"`
	Auth    *ActionAuthRequirement `json:"auth,omitempty"`
	Catalog semanticCatalog        `json:"catalog"`
	Binding Binding                `json:"binding"`
}

// semanticCatalog mirrors Catalog for the projection. Example is included so a
// changed example (Agent-visible documentation) is a semantic change.
//
// OutputSchema deliberately drops omitempty so that an absent output_schema
// (nil) and an explicit unconstrained output_schema ({}) have DISTINCT digest
// contributions: both are Agent-visible and semantically different (nil means
// "no output contract declared"; {} means "an explicit schema that places no
// constraints"). With omitempty both would be dropped and collide. InputSchema
// keeps omitempty because validation requires it to be non-nil, so the
// nil-vs-{} ambiguity never arises for a valid package.
type semanticCatalog struct {
	Summary      string         `json:"summary,omitempty"`
	Description  string         `json:"description,omitempty"`
	Safety       Safety         `json:"safety"`
	InputSchema  map[string]any `json:"inputSchema,omitempty"`
	OutputSchema map[string]any `json:"outputSchema"`
	Example      any            `json:"example,omitempty"`
}

// semanticProjection builds the deterministic projection used by the digest.
// Actions are sorted by name. Order-independent SET fields
// (auth required_scopes, response success statuses, response content types) are
// canonical-sorted so reordering never changes the digest. All copies are
// detached from the caller's Package.
func semanticProjection(pkg Package) (semanticDefinition, error) {
	actions := make([]semanticAction, 0, len(pkg.Actions))
	for _, a := range pkg.Actions {
		var in, out map[string]any
		if a.Catalog.InputSchema != nil {
			in = copySchema(a.Catalog.InputSchema)
		}
		if a.Catalog.OutputSchema != nil {
			out = copySchema(a.Catalog.OutputSchema)
		}
		actions = append(actions, semanticAction{
			Name: a.Name,
			Auth: sortedActionAuthForDefinitionDigest(a.Auth),
			Catalog: semanticCatalog{
				Summary:      a.Catalog.Summary,
				Description:  a.Catalog.Description,
				Safety:       a.Catalog.Safety,
				InputSchema:  in,
				OutputSchema: out,
				Example:      detachedJSONValue(a.Catalog.Example),
			},
			Binding: bindingForDefinitionDigest(a.Binding),
		})
	}
	sort.Slice(actions, func(i, j int) bool { return actions[i].Name < actions[j].Name })

	return semanticDefinition{
		SchemaVersion: pkg.Metadata.SchemaVersion,
		Domain:        pkg.Metadata.Domain,
		Version:       pkg.Metadata.Version,
		Name:          pkg.Metadata.Name,
		Description:   pkg.Metadata.Description,
		Endpoint:      pkg.Metadata.Endpoint,
		Auth:          sortedAuthForDefinitionDigest(pkg.Metadata.Auth),
		Actions:       actions,
	}, nil
}

// bindingForDefinitionDigest returns a detached copy of b with its order-independent
// response sets sorted, so reordering statuses/content types does not change
// the digest.
func bindingForDefinitionDigest(b Binding) Binding {
	cb := b
	cb.Response = sortedResponseForDefinitionDigest(b.Response)
	if cb.Body != nil {
		body := *cb.Body
		cb.Body = &body
	}
	cb.Parameters = append([]Parameter(nil), b.Parameters...)
	cb.StaticHeaders = copyStringMap(b.StaticHeaders)
	return cb
}

// sortedResponseForDefinitionDigest sorts success statuses (numeric) and content types
// (lexicographic) on a detached copy.
func sortedResponseForDefinitionDigest(r Response) Response {
	cr := r
	if len(cr.SuccessStatuses) > 0 {
		s := append([]int(nil), cr.SuccessStatuses...)
		sort.Ints(s)
		cr.SuccessStatuses = s
	}
	if len(cr.ContentTypes) > 0 {
		s := append([]string(nil), cr.ContentTypes...)
		sort.Strings(s)
		cr.ContentTypes = s
	}
	return cr
}

// sortedAuthForDefinitionDigest returns a detached copy of a with profiles canonical-sorted by
// ID so reordering profiles never changes the digest. Profiles carry no scopes
// (scopes are per-Action on ActionAuthRequirement), so no scope sorting is
// needed here.
func sortedAuthForDefinitionDigest(a Auth) Auth {
	ca := a
	if len(a.Profiles) > 0 {
		profiles := make([]AuthProfile, len(a.Profiles))
		copy(profiles, a.Profiles)
		sort.Slice(profiles, func(i, j int) bool { return profiles[i].ID < profiles[j].ID })
		ca.Profiles = profiles
	}
	return ca
}

// sortedActionAuthForDefinitionDigest returns a detached copy of the per-Action auth requirement
// with required_scopes canonical-sorted, so reordering scopes does not change
// the digest. A nil requirement (explicit no-auth) stays nil.
func sortedActionAuthForDefinitionDigest(a *ActionAuthRequirement) *ActionAuthRequirement {
	if a == nil {
		return nil
	}
	cp := *a
	if len(cp.RequiredScopes) > 0 {
		s := append([]string(nil), cp.RequiredScopes...)
		sort.Strings(s)
		cp.RequiredScopes = s
	}
	return &cp
}

func copyStringMap(in map[string]string) map[string]string {
	if in == nil {
		return nil
	}
	out := make(map[string]string, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

// detachedJSONValue normalizes an arbitrary value into the deterministic JSON tree
// (map[string]any, []any, and scalar leaves) by round-tripping through
// encoding/json. This FULLY detaches the value from the caller's input —
// including typed Go containers a programmatic (Builtin) registration may
// supply, such as map[string]string, []string, or []int, which a type-switch
// over map[string]any/[]any would leave aliased to the caller's slice. Because
// the marshaled bytes depend only on the value (not on its Go container type),
// the JSON round-trip is digest-stable: a value and its canonical form always
// serialize to identical bytes. A nil value stays nil; a value that is not
// JSON-encodable (which cannot occur in a valid catalog) is returned unchanged.
//
// The decode uses json.Decoder.UseNumber so JSON numbers unmarshal as
// json.Number (a string-backed type) instead of float64. float64 silently
// rounds integers beyond 2^53, which would COLLIDE the digest of distinct
// Agent-visible values (e.g. 9007199254740992 vs 9007199254740993 both round
// to the same float64) and would make a post-normalize VerifyDigest fail for a
// valid schema carrying a large-integer constraint (e.g. maxLength). json.Number
// preserves the exact numeric text losslessly; it is a leaf in the canonical
// tree (marshaled verbatim), so for integers within float64's exact range the
// canonical bytes — and therefore every existing digest — are unchanged.
func detachedJSONValue(v any) any {
	if v == nil {
		return nil
	}
	b, err := json.Marshal(v)
	if err != nil {
		return v
	}
	dec := json.NewDecoder(bytes.NewReader(b))
	dec.UseNumber()
	var out any
	if err := dec.Decode(&out); err != nil {
		return v
	}
	return out
}

// copySchema returns a detached shallow copy of a JSON schema map. Nested
// maps/sequences are shared, which is safe because the projection is encoded
// immediately and never mutated.
func copySchema(in map[string]any) map[string]any {
	if in == nil {
		return nil
	}
	out := make(map[string]any, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

// encodeDeterministicJSON marshals with lexicographically-sorted map keys and HTML escaping
// disabled so the byte output depends only on the value, not on its history.
func encodeDeterministicJSON(v any) ([]byte, error) {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		return nil, err
	}
	// json.Encoder appends a trailing newline; drop it for a stable digest.
	return bytes.TrimRight(buf.Bytes(), "\n"), nil
}

// DefinitionDigest returns "sha256:<64 lowercase hex>" computed over the
// semantic projection. The same semantic package and Package Spec digest version
// always produce the same digest; one digest identifies exactly one immutable
// Definition version.
func DefinitionDigest(pkg Package) (string, error) {
	proj, err := semanticProjection(pkg)
	if err != nil {
		return "", err
	}
	encoded, err := encodeDeterministicJSON(proj)
	if err != nil {
		return "", fmt.Errorf("encode semantic projection: %w", err)
	}
	h := sha256.New()
	h.Write([]byte(semanticDigestDomain))
	h.Write(encoded)
	return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}

// VerifyDigest reports whether the digest recorded in Metadata matches a fresh
// deterministic computation. It is the single runtime check that a package is the
// immutable Definition its digest claims.
func VerifyDigest(pkg Package) error {
	stored := pkg.Metadata.Digest
	if stored == "" {
		return fmt.Errorf("definition %q has no recorded digest", pkg.Metadata.Domain)
	}
	want, err := DefinitionDigest(pkg)
	if err != nil {
		return fmt.Errorf("recompute digest: %w", err)
	}
	if subtle.ConstantTimeCompare([]byte(stored), []byte(want)) != 1 {
		return fmt.Errorf("digest mismatch for definition %q: stored %s, recomputed %s", pkg.Metadata.Domain, stored, want)
	}
	return nil
}
