// Package catalog provides immutable D&D spell reference catalogs.
package catalog

import (
	"bytes"
	"embed"
	"encoding/json"
	"fmt"
	"io"
	"sort"
	"strings"
	"sync"
)

const (
	SRD5E2014ID      = "dnd-5e-2014-srd-spells"
	SRD5E2014Ruleset = "dnd-5e-2014"
)

//go:embed assets/dnd_5e_2014_srd_spells.json
var catalogAssets embed.FS

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

type Spell struct {
	Name    string   `json:"name"`
	Level   int      `json:"level"`
	Classes []string `json:"classes"`
	Aliases []string `json:"aliases"`
}

type document struct {
	ID      string  `json:"id"`
	Ruleset string  `json:"ruleset"`
	Source  Source  `json:"source"`
	Spells  []Spell `json:"spells"`
}

type Catalog struct {
	id      string
	ruleset string
	source  Source
	spells  []Spell
	lookup  map[string]int
}

var (
	loadSRD5E2014Once sync.Once
	loadedSRD5E2014   Catalog
	loadSRD5E2014Err  error
)

// LoadSRD5E2014 returns the embedded SRD 5.1 spell catalog. Returned values do
// not expose mutable catalog storage.
func LoadSRD5E2014() (Catalog, error) {
	loadSRD5E2014Once.Do(func() {
		loadedSRD5E2014, loadSRD5E2014Err = load("assets/dnd_5e_2014_srd_spells.json")
	})
	return loadedSRD5E2014, loadSRD5E2014Err
}

func (c Catalog) ID() string      { return c.id }
func (c Catalog) Ruleset() string { return c.ruleset }
func (c Catalog) Source() Source  { return c.source }

func (c Catalog) Spells() []Spell {
	return cloneSpells(c.spells)
}

// Lookup matches canonical names and declared aliases after case, whitespace,
// and common apostrophe variants are normalized.
func (c Catalog) Lookup(name string) (Spell, bool) {
	index, ok := c.lookup[lookupKey(name)]
	if !ok {
		return Spell{}, false
	}
	return cloneSpell(c.spells[index]), true
}

func load(path string) (Catalog, error) {
	raw, err := catalogAssets.ReadFile(path)
	if err != nil {
		return Catalog{}, fmt.Errorf("read spell catalog: %w", err)
	}
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.DisallowUnknownFields()
	var doc document
	if err := decoder.Decode(&doc); err != nil {
		return Catalog{}, fmt.Errorf("decode spell catalog: %w", err)
	}
	var trailing any
	if err := decoder.Decode(&trailing); err != io.EOF {
		return Catalog{}, fmt.Errorf("decode spell catalog: multiple JSON values")
	}
	return newCatalog(doc)
}

func newCatalog(doc document) (Catalog, error) {
	if doc.ID != SRD5E2014ID {
		return Catalog{}, fmt.Errorf("spell catalog id %q does not match %q", doc.ID, SRD5E2014ID)
	}
	if doc.Ruleset != SRD5E2014Ruleset {
		return Catalog{}, fmt.Errorf("spell catalog ruleset %q does not match %q", doc.Ruleset, SRD5E2014Ruleset)
	}
	if strings.TrimSpace(doc.Source.Title) == "" || strings.TrimSpace(doc.Source.Version) == "" ||
		strings.TrimSpace(doc.Source.URL) == "" || strings.TrimSpace(doc.Source.License) == "" {
		return Catalog{}, fmt.Errorf("spell catalog source metadata must be complete")
	}
	if len(doc.Spells) == 0 {
		return Catalog{}, fmt.Errorf("spell catalog spells must not be empty")
	}

	allowedClasses := map[string]struct{}{
		"bard": {}, "cleric": {}, "druid": {}, "paladin": {},
		"ranger": {}, "sorcerer": {}, "warlock": {}, "wizard": {},
	}
	lookup := make(map[string]int, len(doc.Spells))
	for index := range doc.Spells {
		spell := &doc.Spells[index]
		if spell.Name != strings.TrimSpace(spell.Name) || spell.Name == "" {
			return Catalog{}, fmt.Errorf("spell[%d] name must be non-empty and trimmed", index)
		}
		if index > 0 && doc.Spells[index-1].Name >= spell.Name {
			return Catalog{}, fmt.Errorf("spell catalog names must be unique and sorted")
		}
		if spell.Level < 0 || spell.Level > 9 {
			return Catalog{}, fmt.Errorf("spell %q level must be between 0 and 9", spell.Name)
		}
		if len(spell.Classes) == 0 {
			return Catalog{}, fmt.Errorf("spell %q classes must not be empty", spell.Name)
		}
		if err := validateSortedStrings(spell.Classes, func(value string) bool {
			_, ok := allowedClasses[value]
			return ok
		}); err != nil {
			return Catalog{}, fmt.Errorf("spell %q classes: %w", spell.Name, err)
		}
		if spell.Aliases == nil {
			return Catalog{}, fmt.Errorf("spell %q aliases must be present", spell.Name)
		}
		if err := validateSortedStrings(spell.Aliases, func(value string) bool {
			return value == strings.TrimSpace(value) && value != ""
		}); err != nil {
			return Catalog{}, fmt.Errorf("spell %q aliases: %w", spell.Name, err)
		}
		for _, candidate := range append([]string{spell.Name}, spell.Aliases...) {
			key := lookupKey(candidate)
			if key == "" {
				return Catalog{}, fmt.Errorf("spell %q has an empty lookup key", spell.Name)
			}
			if previous, exists := lookup[key]; exists {
				return Catalog{}, fmt.Errorf("spell %q lookup key conflicts with %q", spell.Name, doc.Spells[previous].Name)
			}
			lookup[key] = index
		}
	}

	return Catalog{
		id:      doc.ID,
		ruleset: doc.Ruleset,
		source:  doc.Source,
		spells:  cloneSpells(doc.Spells),
		lookup:  lookup,
	}, nil
}

func validateSortedStrings(values []string, valid func(string) bool) error {
	if !sort.StringsAreSorted(values) {
		return fmt.Errorf("values must be sorted")
	}
	for index, value := range values {
		if !valid(value) {
			return fmt.Errorf("value %q is not supported", value)
		}
		if index > 0 && values[index-1] == value {
			return fmt.Errorf("value %q is duplicated", value)
		}
	}
	return nil
}

var apostropheVariants = strings.NewReplacer(
	"‘", "'",
	"’", "'",
	"ʼ", "'",
	"＇", "'",
)

func lookupKey(value string) string {
	value = apostropheVariants.Replace(value)
	return strings.ToLower(strings.Join(strings.Fields(value), " "))
}

func cloneSpells(values []Spell) []Spell {
	if values == nil {
		return nil
	}
	out := make([]Spell, len(values))
	for index, value := range values {
		out[index] = cloneSpell(value)
	}
	return out
}

func cloneSpell(value Spell) Spell {
	value.Classes = append([]string(nil), value.Classes...)
	value.Aliases = append([]string(nil), value.Aliases...)
	return value
}
