package catalog

import (
	"encoding/json"
	"reflect"
	"sort"
	"strings"
	"testing"

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

func TestResolveEffectiveCatalogBaseAndImmutability(t *testing.T) {
	effective, err := ResolveEffectiveCatalog(contracts.ReferenceSet{})
	if err != nil {
		t.Fatal(err)
	}
	if effective.BaseID() != SRD5E2014ID || effective.Ruleset() != SRD5E2014Ruleset {
		t.Fatalf("identity = %q/%q", effective.BaseID(), effective.Ruleset())
	}
	if got := effective.OverlayIDs(); len(got) != 0 {
		t.Fatalf("overlay IDs = %#v", got)
	}
	names := effective.CanonicalNames()
	if len(names) != 319 || !sort.StringsAreSorted(names) {
		t.Fatalf("canonical names count/order = %d/%t", len(names), sort.StringsAreSorted(names))
	}
	if got, ok := effective.Lookup("  HUNTER'S   MARK "); !ok || got != "Hunter’s Mark" {
		t.Fatalf("Hunter's Mark lookup = %q, present=%t", got, ok)
	}
	if got, ok := effective.Lookup("Definitely Not A Spell"); ok || got != "" {
		t.Fatalf("unknown lookup = %q, present=%t", got, ok)
	}
	if !strings.HasPrefix(effective.Digest(), "sha256:") {
		t.Fatalf("digest = %q", effective.Digest())
	}

	names[0] = "changed"
	ids := effective.OverlayIDs()
	ids = append(ids, "changed")
	again, err := ResolveEffectiveCatalog(contracts.ReferenceSet{})
	if err != nil {
		t.Fatal(err)
	}
	if reflect.DeepEqual(names, again.CanonicalNames()) || len(again.OverlayIDs()) != 0 {
		t.Fatal("effective catalog exposed mutable result storage")
	}
}

func TestResolveEffectiveCatalogAddsAndAugmentsSpells(t *testing.T) {
	overlay := testOverlayJSON(t, testOverlayCatalog(
		"campaign.example",
		testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"),
		testOverlaySpell("Cure Wounds", "Healing Touch"),
	))
	effective, err := ResolveEffectiveCatalog(overlayReference([]byte(overlay), "application/json"))
	if err != nil {
		t.Fatal(err)
	}
	if got := effective.OverlayIDs(); !reflect.DeepEqual(got, []string{"campaign.example"}) {
		t.Fatalf("overlay IDs = %#v", got)
	}
	if len(effective.CanonicalNames()) != 320 {
		t.Fatalf("canonical name count = %d, want 320", len(effective.CanonicalNames()))
	}
	if got, ok := effective.Lookup("aegis of emberfall"); !ok || got != "Aegis of Emberfall" {
		t.Fatalf("new spell lookup = %q, present=%t", got, ok)
	}
	if got, ok := effective.Lookup("Emberfall Aegis"); !ok || got != "Aegis of Emberfall" {
		t.Fatalf("new alias lookup = %q, present=%t", got, ok)
	}
	if got, ok := effective.Lookup("Healing Touch"); !ok || got != "Cure Wounds" {
		t.Fatalf("augmentation alias lookup = %q, present=%t", got, ok)
	}
	for _, name := range effective.CanonicalNames() {
		if name == "Emberfall Aegis" || name == "Healing Touch" {
			t.Fatalf("alias %q was exposed as a canonical name", name)
		}
	}
}

func TestEffectiveCatalogPromptSpellsIncludeSortedAliasesWithoutProvenance(t *testing.T) {
	overlay := testOverlayJSON(t, testOverlayCatalog(
		"campaign.example",
		testOverlaySpell("Aegis of Emberfall", "Z Emberfall", "Emberfall Aegis", "emberfall aegis"),
		testOverlaySpell("Cure Wounds", "Healing Touch"),
	))
	effective, err := ResolveEffectiveCatalog(overlayReference([]byte(overlay), "application/json"))
	if err != nil {
		t.Fatal(err)
	}

	spells := effective.PromptSpells()
	if len(spells) != len(effective.CanonicalNames()) {
		t.Fatalf("prompt spell count = %d, want %d", len(spells), len(effective.CanonicalNames()))
	}
	for index := 1; index < len(spells); index++ {
		if spells[index-1].CanonicalName > spells[index].CanonicalName {
			t.Fatalf("prompt spells are not sorted: %q before %q", spells[index-1].CanonicalName, spells[index].CanonicalName)
		}
	}

	aliases := make(map[string][]string, len(spells))
	for _, spell := range spells {
		aliases[spell.CanonicalName] = spell.Aliases
		if !sort.StringsAreSorted(spell.Aliases) {
			t.Fatalf("aliases for %q are not sorted: %#v", spell.CanonicalName, spell.Aliases)
		}
	}
	if got := aliases["Aegis of Emberfall"]; !reflect.DeepEqual(got, []string{"Emberfall Aegis", "Z Emberfall"}) {
		t.Fatalf("Aegis aliases = %#v, want normalized aliases once", got)
	}
	if got := aliases["Cure Wounds"]; !reflect.DeepEqual(got, []string{"Healing Touch"}) {
		t.Fatalf("Cure Wounds aliases = %#v", got)
	}

	encoded, err := json.Marshal(spells)
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(encoded), `"aliases":null`) {
		t.Fatalf("prompt projection encoded missing aliases as null: %s", encoded)
	}
	for _, forbidden := range []string{"campaign.example", "spells", "source", "license", "ruleset", "provenance"} {
		if strings.Contains(string(encoded), forbidden) {
			t.Fatalf("prompt projection leaked %q: %s", forbidden, encoded)
		}
	}

	for index := range spells {
		if spells[index].CanonicalName == "Aegis of Emberfall" {
			spells[index].Aliases[0] = "changed"
			break
		}
	}
	for _, spell := range effective.PromptSpells() {
		if spell.CanonicalName == "Aegis of Emberfall" && spell.Aliases[0] == "changed" {
			t.Fatal("effective catalog exposed mutable prompt projection storage")
		}
	}
}

func TestResolveEffectiveCatalogTreatsRepeatedAliasesAsIdempotent(t *testing.T) {
	single := testOverlayJSON(t, testOverlayCatalog(
		"campaign.example",
		testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"),
	))
	repeated := testOverlayJSON(t, testOverlayCatalog(
		"campaign.example",
		testOverlaySpell("Aegis of Emberfall", "Emberfall Aegis"),
		testOverlaySpell("Aegis of Emberfall", "emberfall aegis"),
	))
	first, err := ResolveEffectiveCatalog(overlayReference([]byte(single), "application/json"))
	if err != nil {
		t.Fatal(err)
	}
	second, err := ResolveEffectiveCatalog(overlayReference([]byte(repeated), "application/json"))
	if err != nil {
		t.Fatal(err)
	}
	if first.Digest() != second.Digest() || !reflect.DeepEqual(first.CanonicalNames(), second.CanonicalNames()) {
		t.Fatalf("repeated alias changed effective result: digest=%q/%q", first.Digest(), second.Digest())
	}
}

func TestResolveEffectiveCatalogDigestIgnoresOrderingAndFormatting(t *testing.T) {
	first := `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.z","ruleset":"dnd-5e-2014","source":{"title":"Z spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Z Alias","Another Alias"]}]},{"id":"campaign.a","ruleset":"dnd-5e-2014","source":{"title":"A spells","version":"1","url":"","license":""},"spells":[{"name":"Cure Wounds","aliases":["Healing Touch","Cure Mend"]}]}]}`
	second := `{
  "catalogs": [
    {"source":{"license":"","url":"","version":"1","title":"A spells"},"spells":[{"aliases":["Cure Mend","Healing Touch"],"name":"Cure Wounds"}],"ruleset":"dnd-5e-2014","id":"campaign.a"},
    {"spells":[{"aliases":["Another Alias","Z Alias"],"name":"Aegis of Emberfall"}],"source":{"title":"Z spells","version":"1","url":"","license":""},"ruleset":"dnd-5e-2014","id":"campaign.z"}
  ],
  "schema_version":"notarius.dnd.spell-catalog-overlay.v1"
}`
	firstCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(first), "application/json"))
	if err != nil {
		t.Fatal(err)
	}
	secondCatalog, err := ResolveEffectiveCatalog(overlayReference([]byte(second), "application/json"))
	if err != nil {
		t.Fatal(err)
	}
	if !reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()) || firstCatalog.Digest() != secondCatalog.Digest() {
		t.Fatalf("reordered overlays changed effective result: names=%t digest=%q/%q", reflect.DeepEqual(firstCatalog.CanonicalNames(), secondCatalog.CanonicalNames()), firstCatalog.Digest(), secondCatalog.Digest())
	}
}

func TestResolveEffectiveCatalogRejectsInvalidOverlays(t *testing.T) {
	valid := validOverlayJSON()
	tests := []struct {
		name    string
		content string
	}{
		{name: "wrong schema version", content: strings.Replace(valid, "notarius.dnd.spell-catalog-overlay.v1", "notarius.dnd.spell-catalog-overlay.v2", 1)},
		{name: "empty catalogs", content: `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[]}`},
		{name: "duplicate IDs", content: testOverlayJSON(t, testOverlayCatalog("campaign.example", testOverlaySpell("Aegis of Emberfall")), testOverlayCatalog("campaign.example", testOverlaySpell("Another Spell")))},
		{name: "wrong ruleset", content: strings.Replace(valid, `"ruleset":"dnd-5e-2014"`, `"ruleset":"dnd-5e-other"`, 1)},
		{name: "empty ID", content: strings.Replace(valid, `"id":"campaign.example"`, `"id":""`, 1)},
		{name: "empty source title", content: strings.Replace(valid, `"title":"Example campaign spells"`, `"title":""`, 1)},
		{name: "empty spells", content: testOverlayJSON(t, testOverlayCatalog("campaign.example"))},
		{name: "empty spell name", content: strings.Replace(valid, `"name":"Aegis of Emberfall"`, `"name":""`, 1)},
		{name: "null aliases", content: strings.Replace(valid, `"aliases":["Emberfall Aegis"]`, `"aliases":null`, 1)},
		{name: "unknown bundle field", content: strings.Replace(valid, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs"`, `"schema_version":"notarius.dnd.spell-catalog-overlay.v1","unexpected":true,"catalogs"`, 1)},
		{name: "unknown catalog field", content: strings.Replace(valid, `"id":"campaign.example","ruleset"`, `"id":"campaign.example","unexpected":true,"ruleset"`, 1)},
		{name: "unknown source field", content: strings.Replace(valid, `"title":"Example campaign spells","version"`, `"title":"Example campaign spells","unexpected":true,"version"`, 1)},
		{name: "unknown spell field", content: strings.Replace(valid, `"name":"Aegis of Emberfall","aliases"`, `"name":"Aegis of Emberfall","unexpected":true,"aliases"`, 1)},
		{name: "trailing JSON", content: valid + `{}`},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			if _, err := ResolveEffectiveCatalog(overlayReference([]byte(test.content), "application/json")); err == nil {
				t.Fatal("invalid overlay was accepted")
			}
		})
	}
}

func TestResolveEffectiveCatalogRejectsReferenceMultiplicityAndMediaType(t *testing.T) {
	valid := []byte(validOverlayJSON())
	tests := []struct {
		name string
		refs contracts.ReferenceSet
	}{
		{
			name: "multiple items",
			refs: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
				SpellCatalogReferenceSlot: {Items: []contracts.ReferenceItem{
					{MediaType: "application/json", Content: valid},
					{MediaType: "application/json", Content: valid},
				}},
			}},
		},
		{
			name: "non JSON item",
			refs: overlayReference(valid, "text/plain"),
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			if _, err := ResolveEffectiveCatalog(test.refs); err == nil {
				t.Fatal("invalid reference set was accepted")
			}
		})
	}

	for _, refs := range []contracts.ReferenceSet{
		{}, {Slots: map[string]contracts.ResolvedReferenceSlot{SpellCatalogReferenceSlot: {}}},
	} {
		if effective, err := ResolveEffectiveCatalog(refs); err != nil || len(effective.CanonicalNames()) != 319 {
			t.Fatalf("missing overlay should use base catalog: effective=%#v err=%v", effective, err)
		}
	}
}

func TestResolveEffectiveCatalogRejectsCrossSpellCollisions(t *testing.T) {
	tests := []struct {
		name    string
		spells  []overlaySpell
		content string
	}{
		{
			name:   "same key display conflict with embedded canonical",
			spells: []overlaySpell{testOverlaySpell("cure  wounds")},
		},
		{
			name:   "same key display conflict between canonical names",
			spells: []overlaySpell{testOverlaySpell("Moon Beam"), testOverlaySpell("moon  beam")},
		},
		{
			name:   "canonical versus alias",
			spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Beta Spell"), testOverlaySpell("Beta Spell")},
		},
		{
			name:   "alias versus canonical",
			spells: []overlaySpell{testOverlaySpell("Alpha Spell"), testOverlaySpell("Beta Spell", "Alpha Spell")},
		},
		{
			name:   "alias versus alias",
			spells: []overlaySpell{testOverlaySpell("Alpha Spell", "Shared Name"), testOverlaySpell("Beta Spell", "Shared Name")},
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			content := test.content
			if content == "" {
				content = testOverlayJSON(t, testOverlayCatalog("campaign.example", test.spells...))
			}
			if _, err := ResolveEffectiveCatalog(overlayReference([]byte(content), "application/json")); err == nil {
				t.Fatal("cross-spell collision was accepted")
			}
		})
	}
}

func overlayReference(content []byte, mediaType string) contracts.ReferenceSet {
	return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
		SpellCatalogReferenceSlot: {
			Items: []contracts.ReferenceItem{{
				SlotName:  SpellCatalogReferenceSlot,
				MediaType: mediaType,
				Content:   append([]byte(nil), content...),
			}},
		},
	}}
}

func testOverlaySpell(name string, aliases ...string) overlaySpell {
	content, err := json.Marshal(aliases)
	if err != nil {
		panic(err)
	}
	return overlaySpell{Name: name, Aliases: content}
}

func testOverlayCatalog(id string, spells ...overlaySpell) overlayCatalog {
	return overlayCatalog{
		ID:      id,
		Ruleset: SRD5E2014Ruleset,
		Source: overlaySource{
			Title:   id + " spells",
			Version: json.RawMessage(`"1"`),
			URL:     json.RawMessage(`""`),
			License: json.RawMessage(`""`),
		},
		Spells: spells,
	}
}

func testOverlayJSON(t *testing.T, catalogs ...overlayCatalog) string {
	t.Helper()
	content, err := json.Marshal(overlayBundle{SchemaVersion: overlaySchemaVersion, Catalogs: catalogs})
	if err != nil {
		t.Fatal(err)
	}
	return string(content)
}

func validOverlayJSON() string {
	return `{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Example campaign spells","version":"1","url":"","license":""},"spells":[{"name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]}]}`
}
