package catalog

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"mime"
	"sort"
	"strings"

	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)

const (
	SpellCatalogReferenceSlot = "spell_catalog"
	overlaySchemaVersion      = "notarius.dnd.spell-catalog-overlay.v1"
)

// EffectiveCatalog is the immutable spell-name recognition catalog assembled
// from the embedded SRD catalog and an optional set of campaign overlays.
type EffectiveCatalog struct {
	baseID         string
	ruleset        string
	overlayIDs     []string
	canonicalNames []string
	promptSpells   []PromptSpell
	lookup         map[string]string
	digest         string
}

// PromptSpell is the recognition-only catalog entry supplied to extraction
// prompts. It intentionally contains no catalog provenance or source data.
type PromptSpell struct {
	CanonicalName string   `json:"canonical_name"`
	Aliases       []string `json:"aliases"`
}

func (c EffectiveCatalog) BaseID() string       { return c.baseID }
func (c EffectiveCatalog) Ruleset() string      { return c.ruleset }
func (c EffectiveCatalog) Digest() string       { return c.digest }
func (c EffectiveCatalog) OverlayIDs() []string { return append([]string(nil), c.overlayIDs...) }

// CanonicalNames returns the globally sorted canonical spell names without
// exposing the catalog's internal storage.
func (c EffectiveCatalog) CanonicalNames() []string {
	return append([]string(nil), c.canonicalNames...)
}

// PromptSpells returns canonical names and their recognized aliases in
// deterministic canonical-name order. The result is safe for callers to
// modify.
func (c EffectiveCatalog) PromptSpells() []PromptSpell {
	out := make([]PromptSpell, len(c.promptSpells))
	for index, spell := range c.promptSpells {
		aliases := make([]string, len(spell.Aliases))
		copy(aliases, spell.Aliases)
		out[index] = PromptSpell{
			CanonicalName: spell.CanonicalName,
			Aliases:       aliases,
		}
	}
	return out
}

// Lookup matches canonical names and aliases after applying the same
// normalization used by the embedded catalog. The returned string is the
// established canonical display name.
func (c EffectiveCatalog) Lookup(name string) (string, bool) {
	canonical, ok := c.lookup[lookupKey(name)]
	return canonical, ok
}

// ResolveEffectiveCatalog loads the embedded SRD catalog and applies the
// optional spell-catalog overlay found in its spell_catalog reference slot. It
// does not resolve paths or perform filesystem access.
func ResolveEffectiveCatalog(references contracts.ReferenceSet) (EffectiveCatalog, error) {
	base, err := LoadSRD5E2014()
	if err != nil {
		return EffectiveCatalog{}, err
	}

	slot, ok := references.Slots[SpellCatalogReferenceSlot]
	if !ok || len(slot.Items) == 0 {
		return composeEffectiveCatalog(base, nil)
	}
	if len(slot.Items) != 1 {
		return EffectiveCatalog{}, fmt.Errorf("reference slot %q must contain zero or one item", SpellCatalogReferenceSlot)
	}

	item := slot.Items[0]
	mediaType, _, err := mime.ParseMediaType(item.MediaType)
	if err != nil {
		return EffectiveCatalog{}, fmt.Errorf("reference slot %q item media type %q is invalid: %w", SpellCatalogReferenceSlot, item.MediaType, err)
	}
	if !strings.EqualFold(mediaType, "application/json") {
		return EffectiveCatalog{}, fmt.Errorf("reference slot %q item media type %q must be application/json", SpellCatalogReferenceSlot, item.MediaType)
	}
	overlays, err := decodeOverlayBundle(item.Content)
	if err != nil {
		return EffectiveCatalog{}, err
	}
	return composeEffectiveCatalog(base, overlays)
}

type overlayBundle struct {
	SchemaVersion string           `json:"schema_version"`
	Catalogs      []overlayCatalog `json:"catalogs"`
}

type overlayCatalog struct {
	ID      string         `json:"id"`
	Ruleset string         `json:"ruleset"`
	Source  overlaySource  `json:"source"`
	Spells  []overlaySpell `json:"spells"`
}

type overlaySource struct {
	Title   string          `json:"title"`
	Version json.RawMessage `json:"version"`
	URL     json.RawMessage `json:"url"`
	License json.RawMessage `json:"license"`
}

type overlaySpell struct {
	Name    string          `json:"name"`
	Aliases json.RawMessage `json:"aliases,omitempty"`
}

func decodeOverlayBundle(content []byte) ([]overlayCatalog, error) {
	decoder := json.NewDecoder(bytes.NewReader(content))
	decoder.DisallowUnknownFields()
	var bundle overlayBundle
	if err := decoder.Decode(&bundle); err != nil {
		return nil, fmt.Errorf("decode spell catalog overlay: %w", err)
	}
	var trailing any
	if err := decoder.Decode(&trailing); err != io.EOF {
		return nil, fmt.Errorf("decode spell catalog overlay: multiple JSON values")
	}
	if bundle.SchemaVersion != overlaySchemaVersion {
		return nil, fmt.Errorf("spell catalog overlay schema_version %q does not match %q", bundle.SchemaVersion, overlaySchemaVersion)
	}
	if len(bundle.Catalogs) == 0 {
		return nil, fmt.Errorf("spell catalog overlay catalogs must not be empty")
	}

	seenIDs := make(map[string]struct{}, len(bundle.Catalogs))
	for catalogIndex := range bundle.Catalogs {
		catalog := &bundle.Catalogs[catalogIndex]
		if catalog.ID != strings.TrimSpace(catalog.ID) || catalog.ID == "" {
			return nil, fmt.Errorf("catalog[%d] id must be non-empty and trimmed", catalogIndex)
		}
		if _, exists := seenIDs[catalog.ID]; exists {
			return nil, fmt.Errorf("catalog id %q is duplicated", catalog.ID)
		}
		seenIDs[catalog.ID] = struct{}{}
		if catalog.Ruleset != SRD5E2014Ruleset {
			return nil, fmt.Errorf("catalog %q ruleset %q does not match %q", catalog.ID, catalog.Ruleset, SRD5E2014Ruleset)
		}
		if catalog.Source.Title != strings.TrimSpace(catalog.Source.Title) || catalog.Source.Title == "" {
			return nil, fmt.Errorf("catalog %q source title must be non-empty and trimmed", catalog.ID)
		}
		for field, raw := range map[string]json.RawMessage{
			"version": catalog.Source.Version,
			"url":     catalog.Source.URL,
			"license": catalog.Source.License,
		} {
			if _, err := decodeOptionalString(raw); err != nil {
				return nil, fmt.Errorf("catalog %q source %s: %w", catalog.ID, field, err)
			}
		}
		if len(catalog.Spells) == 0 {
			return nil, fmt.Errorf("catalog %q spells must not be empty", catalog.ID)
		}
		for spellIndex := range catalog.Spells {
			spell := &catalog.Spells[spellIndex]
			if spell.Name != strings.TrimSpace(spell.Name) || spell.Name == "" {
				return nil, fmt.Errorf("catalog %q spell[%d] name must be non-empty and trimmed", catalog.ID, spellIndex)
			}
			_, err := decodeAliases(spell.Aliases)
			if err != nil {
				return nil, fmt.Errorf("catalog %q spell %q aliases: %w", catalog.ID, spell.Name, err)
			}
		}
	}

	return bundle.Catalogs, nil
}

func decodeOptionalString(raw json.RawMessage) (string, error) {
	if len(raw) == 0 {
		return "", nil
	}
	if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
		return "", fmt.Errorf("must be a string when present")
	}
	var value string
	if err := json.Unmarshal(raw, &value); err != nil {
		return "", fmt.Errorf("must be a string when present: %w", err)
	}
	return value, nil
}

func decodeAliases(raw json.RawMessage) ([]string, error) {
	if len(raw) == 0 {
		return nil, nil
	}
	if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
		return nil, fmt.Errorf("must be an array when present")
	}
	var aliases []string
	if err := json.Unmarshal(raw, &aliases); err != nil {
		return nil, fmt.Errorf("must be an array of strings: %w", err)
	}
	for index, alias := range aliases {
		if alias != strings.TrimSpace(alias) || alias == "" {
			return nil, fmt.Errorf("value at index %d must be non-empty and trimmed", index)
		}
	}
	return aliases, nil
}

type effectiveSpell struct {
	name         string
	canonicalKey string
	aliases      map[string]string
}

type effectiveBuilder struct {
	spells         map[string]*effectiveSpell
	canonicalByKey map[string]string
	lookup         map[string]string
}

func composeEffectiveCatalog(base Catalog, overlays []overlayCatalog) (EffectiveCatalog, error) {
	builder := effectiveBuilder{
		spells:         make(map[string]*effectiveSpell, len(base.spells)),
		canonicalByKey: make(map[string]string, len(base.lookup)),
		lookup:         make(map[string]string, len(base.lookup)),
	}
	for _, spell := range base.Spells() {
		if err := builder.addCanonical(spell.Name); err != nil {
			return EffectiveCatalog{}, fmt.Errorf("base catalog: %w", err)
		}
		for _, alias := range spell.Aliases {
			if err := builder.addAlias(spell.Name, alias); err != nil {
				return EffectiveCatalog{}, fmt.Errorf("base catalog: %w", err)
			}
		}
	}

	sort.Slice(overlays, func(i, j int) bool { return overlays[i].ID < overlays[j].ID })
	overlayIDs := make([]string, len(overlays))
	for i, overlay := range overlays {
		overlayIDs[i] = overlay.ID
		for _, spell := range overlay.Spells {
			canonical, err := builder.ensureCanonical(spell.Name)
			if err != nil {
				return EffectiveCatalog{}, fmt.Errorf("catalog %q: %w", overlay.ID, err)
			}
			aliases, err := decodeAliases(spell.Aliases)
			if err != nil {
				return EffectiveCatalog{}, fmt.Errorf("catalog %q spell %q aliases: %w", overlay.ID, spell.Name, err)
			}
			for _, alias := range aliases {
				if err := builder.addAlias(canonical, alias); err != nil {
					return EffectiveCatalog{}, fmt.Errorf("catalog %q spell %q: %w", overlay.ID, spell.Name, err)
				}
			}
		}
	}

	canonicalNames := make([]string, 0, len(builder.spells))
	for _, spell := range builder.spells {
		canonicalNames = append(canonicalNames, spell.name)
	}
	sort.Strings(canonicalNames)
	promptSpells := make([]PromptSpell, len(canonicalNames))
	for index, name := range canonicalNames {
		spell := builder.spells[name]
		aliases := make([]string, 0, len(spell.aliases))
		for _, alias := range spell.aliases {
			aliases = append(aliases, alias)
		}
		sort.Strings(aliases)
		promptSpells[index] = PromptSpell{CanonicalName: spell.name, Aliases: aliases}
	}

	digest, err := effectiveDigest(base, overlays, builder, canonicalNames)
	if err != nil {
		return EffectiveCatalog{}, err
	}
	return EffectiveCatalog{
		baseID:         base.ID(),
		ruleset:        base.Ruleset(),
		overlayIDs:     overlayIDs,
		canonicalNames: canonicalNames,
		promptSpells:   promptSpells,
		lookup:         cloneStringMap(builder.lookup),
		digest:         digest,
	}, nil
}

func (b *effectiveBuilder) ensureCanonical(name string) (string, error) {
	key := lookupKey(name)
	if key == "" {
		return "", fmt.Errorf("spell %q has an empty lookup key", name)
	}
	if canonical, exists := b.canonicalByKey[key]; exists {
		if canonical != name {
			return "", fmt.Errorf("canonical lookup key %q has conflicting display names %q and %q", key, canonical, name)
		}
		return canonical, nil
	}
	if canonical, exists := b.lookup[key]; exists {
		return "", fmt.Errorf("canonical lookup key %q collides with alias of spell %q", key, canonical)
	}
	spell := &effectiveSpell{name: name, canonicalKey: key, aliases: make(map[string]string)}
	b.spells[name] = spell
	b.canonicalByKey[key] = name
	b.lookup[key] = name
	return name, nil
}

func (b *effectiveBuilder) addCanonical(name string) error {
	_, err := b.ensureCanonical(name)
	return err
}

func (b *effectiveBuilder) addAlias(canonical string, alias string) error {
	key := lookupKey(alias)
	if key == "" {
		return fmt.Errorf("spell %q has an empty alias lookup key", canonical)
	}
	if existing, exists := b.lookup[key]; exists && existing != canonical {
		return fmt.Errorf("lookup key %q maps to spells %q and %q", key, existing, canonical)
	}
	spell, exists := b.spells[canonical]
	if !exists {
		return fmt.Errorf("spell %q is not present", canonical)
	}
	if key == spell.canonicalKey {
		return nil
	}
	b.lookup[key] = canonical
	if previous, exists := spell.aliases[key]; !exists || alias < previous {
		spell.aliases[key] = alias
	}
	return nil
}

type effectiveDigestDocument struct {
	BaseID      string                   `json:"base_id"`
	BaseRuleset string                   `json:"base_ruleset"`
	BaseSource  Source                   `json:"base_source"`
	Overlays    []effectiveDigestOverlay `json:"overlays"`
	Spells      []effectiveDigestSpell   `json:"spells"`
}

type effectiveDigestOverlay struct {
	ID      string `json:"id"`
	Ruleset string `json:"ruleset"`
	Source  struct {
		Title   string `json:"title"`
		Version string `json:"version"`
		URL     string `json:"url"`
		License string `json:"license"`
	} `json:"source"`
}

type effectiveDigestSpell struct {
	Key     string                 `json:"key"`
	Name    string                 `json:"name"`
	Aliases []effectiveDigestAlias `json:"aliases,omitempty"`
}

type effectiveDigestAlias struct {
	Key     string `json:"key"`
	Display string `json:"display"`
}

func effectiveDigest(base Catalog, overlays []overlayCatalog, builder effectiveBuilder, canonicalNames []string) (string, error) {
	document := effectiveDigestDocument{
		BaseID:      base.ID(),
		BaseRuleset: base.Ruleset(),
		BaseSource:  base.Source(),
		Overlays:    make([]effectiveDigestOverlay, len(overlays)),
		Spells:      make([]effectiveDigestSpell, 0, len(canonicalNames)),
	}
	for index, overlay := range overlays {
		document.Overlays[index].ID = overlay.ID
		document.Overlays[index].Ruleset = overlay.Ruleset
		version, err := decodeOptionalString(overlay.Source.Version)
		if err != nil {
			return "", fmt.Errorf("catalog %q source version: %w", overlay.ID, err)
		}
		url, err := decodeOptionalString(overlay.Source.URL)
		if err != nil {
			return "", fmt.Errorf("catalog %q source url: %w", overlay.ID, err)
		}
		license, err := decodeOptionalString(overlay.Source.License)
		if err != nil {
			return "", fmt.Errorf("catalog %q source license: %w", overlay.ID, err)
		}
		document.Overlays[index].Source.Title = overlay.Source.Title
		document.Overlays[index].Source.Version = version
		document.Overlays[index].Source.URL = url
		document.Overlays[index].Source.License = license
	}
	for _, name := range canonicalNames {
		spell := builder.spells[name]
		digestSpell := effectiveDigestSpell{Key: spell.canonicalKey, Name: spell.name}
		keys := make([]string, 0, len(spell.aliases))
		for key := range spell.aliases {
			keys = append(keys, key)
		}
		sort.Strings(keys)
		for _, key := range keys {
			digestSpell.Aliases = append(digestSpell.Aliases, effectiveDigestAlias{Key: key, Display: spell.aliases[key]})
		}
		document.Spells = append(document.Spells, digestSpell)
	}
	raw, err := json.Marshal(document)
	if err != nil {
		return "", fmt.Errorf("encode effective spell catalog digest: %w", err)
	}
	sum := sha256.Sum256(raw)
	return "sha256:" + hex.EncodeToString(sum[:]), nil
}

func cloneStringMap(values map[string]string) map[string]string {
	if len(values) == 0 {
		return nil
	}
	out := make(map[string]string, len(values))
	for key, value := range values {
		out[key] = value
	}
	return out
}
