package spells

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

	"gitea.maximumdirect.net/eric/notarius/internal/core/source"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
	spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
)

func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
	client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
		{
			Caster:     " Aria ",
			Spell:      " Cure Wounds ",
			SourceRefs: responseSourceRefs(2, 1),
		},
	}}}
	req := extractionRequest()

	result, err := newExtractor(t, client).Extract(context.Background(), req)
	if err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	want := dnd.SpellList{SpellCasts: []dnd.SpellCast{
		{
			Caster:     " Aria ",
			Spell:      " Cure Wounds ",
			SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
		},
	}}
	if !reflect.DeepEqual(result.Value, want) {
		t.Fatalf("Value = %#v, want %#v", result.Value, want)
	}
	if len(client.requests) != 1 {
		t.Fatalf("LLM calls = %d, want 1", len(client.requests))
	}
	llmReq := client.requests[0]
	if llmReq.StageName != Key || llmReq.PromptID != PromptID || llmReq.PromptVersion != SchemaVersion {
		t.Fatalf("LLM request identity = %#v, want spell prompt", llmReq)
	}
	if llmReq.SessionID != "session-123" || llmReq.ProfileID != "profile-spells" {
		t.Fatalf("session/profile = %q/%q, want session-123/profile-spells", llmReq.SessionID, llmReq.ProfileID)
	}
	transcript := llmReq.Inputs["transcript"]
	if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:chunk" || transcript.OriginURI != "file:///session-alpha.json" {
		t.Fatalf("transcript metadata = %#v", transcript)
	}
	if got := string(transcript.Content); got != string(req.Chunk.Content) {
		t.Fatalf("transcript content = %q, want chunk content %q", got, req.Chunk.Content)
	}
	catalogInput := llmReq.Inputs[spellcatalog.SpellCatalogReferenceSlot]
	if catalogInput.Name != spellcatalog.SpellCatalogReferenceSlot || catalogInput.MediaType != "application/json" || catalogInput.OriginURI != "" || !strings.HasPrefix(catalogInput.Digest, "sha256:") {
		t.Fatalf("catalog prompt input metadata = %#v", catalogInput)
	}
	var catalogPayload struct {
		Spells []spellcatalog.PromptSpell `json:"spells"`
	}
	if err := json.Unmarshal(catalogInput.Content, &catalogPayload); err != nil {
		t.Fatalf("decode catalog prompt input: %v", err)
	}
	base, err := spellcatalog.LoadSRD5E2014()
	if err != nil {
		t.Fatal(err)
	}
	wantNames := make([]string, 0, len(base.Spells()))
	for _, spell := range base.Spells() {
		wantNames = append(wantNames, spell.Name)
	}
	sort.Strings(wantNames)
	gotNames := make([]string, len(catalogPayload.Spells))
	for index, spell := range catalogPayload.Spells {
		gotNames[index] = spell.CanonicalName
		if !sort.StringsAreSorted(spell.Aliases) {
			t.Fatalf("catalog prompt aliases for %q are not sorted: %#v", spell.CanonicalName, spell.Aliases)
		}
	}
	if !reflect.DeepEqual(gotNames, wantNames) || !sort.StringsAreSorted(gotNames) {
		t.Fatalf("catalog prompt names = %d entries, want sorted base catalog", len(gotNames))
	}
}

func TestExtractPromptProjectsCanonicalNamesAndAliasesWithoutMetadata(t *testing.T) {
	client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
	if _, err := newExtractor(t, client, overlaySpellCatalogReference()).Extract(context.Background(), extractionRequest()); err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	input := client.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
	content := string(input.Content)
	var payload struct {
		Spells []spellcatalog.PromptSpell `json:"spells"`
	}
	if err := json.Unmarshal(input.Content, &payload); err != nil {
		t.Fatalf("decode catalog prompt input: %v", err)
	}
	var aegis *spellcatalog.PromptSpell
	for index := range payload.Spells {
		if payload.Spells[index].CanonicalName == "Aegis of Emberfall" {
			aegis = &payload.Spells[index]
			break
		}
	}
	if aegis == nil || !reflect.DeepEqual(aegis.Aliases, []string{"Emberfall Aegis"}) {
		t.Fatalf("Aegis prompt projection = %#v, want canonical name and alias", aegis)
	}
	for _, forbidden := range []string{"Private campaign source", "file:///private-source.json", "private", "license", "ruleset", "provenance"} {
		if strings.Contains(content, forbidden) {
			t.Fatalf("catalog prompt input leaked %q: %s", forbidden, content)
		}
	}

	metadata := newExtractor(t, &fakeSpellsLLMClient{}, overlaySpellCatalogReference()).ManifestMetadata()
	if metadata["mapping_policy"] != mappingPolicy {
		t.Fatalf("mapping policy metadata = %#v, want %q", metadata["mapping_policy"], mappingPolicy)
	}
	if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID {
		t.Fatalf("catalog base metadata = %#v", metadata["catalog_base_id"])
	}
	if digest, ok := metadata["catalog_digest"].(string); !ok || !strings.HasPrefix(digest, "sha256:") {
		t.Fatalf("catalog digest metadata = %#v", metadata["catalog_digest"])
	}
	if got, ok := metadata["catalog_overlay_ids"].([]string); !ok || !reflect.DeepEqual(got, []string{"campaign.example"}) {
		t.Fatalf("catalog overlay metadata = %#v", metadata["catalog_overlay_ids"])
	}
	fingerprints := newExtractor(t, &fakeSpellsLLMClient{}, overlaySpellCatalogReference()).CheckpointFingerprints()
	wantFingerprints := map[string]any{
		"effective_catalog": metadata["catalog_digest"],
		"mapping_policy":    mappingPolicy,
		"prompt":            metadata["prompt_sha256"],
		"response_schema":   metadata["response_schema_sha256"],
		"npc_registry":      checkpointFingerprintMap(newExtractor(t, &fakeSpellsLLMClient{}).CheckpointFingerprints())["npc_registry"],
	}
	if len(fingerprints) != len(wantFingerprints) {
		t.Fatalf("checkpoint fingerprints = %#v, want prompt, response schema, catalog, and NPC projection identities", fingerprints)
	}
	for _, fingerprint := range fingerprints {
		if want, ok := wantFingerprints[fingerprint.Name]; !ok || fingerprint.Value != want {
			t.Fatalf("checkpoint fingerprint %q = %q, want %#v", fingerprint.Name, fingerprint.Value, want)
		}
	}
	encoded, err := json.Marshal(metadata)
	if err != nil {
		t.Fatal(err)
	}
	for _, forbidden := range []string{"Aegis of Emberfall", "Emberfall Aegis", "Private campaign source", "file:///private-source.json"} {
		if strings.Contains(string(encoded), forbidden) {
			t.Fatalf("manifest metadata leaked %q: %s", forbidden, encoded)
		}
	}
}

func TestExtractUsesAliasRecognitionToRequestCanonicalSpellNames(t *testing.T) {
	client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
		Caster:     "Aria",
		Spell:      "Aegis of Emberfall",
		SourceRefs: responseSourceRefs(1, 1),
	}}}}
	request := extractionRequest()
	request.Chunk.Content = []byte(`{"segments":[{"id":1,"text":"Aria invokes Emberfall Aegis."}]}`)
	request.SourceInput = spellChunkInput(request.Chunk)

	result, err := newExtractor(t, client, overlaySpellCatalogReference()).Extract(context.Background(), request)
	if err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	if got := result.Value.SpellCasts; len(got) != 1 || got[0].Spell != "Aegis of Emberfall" {
		t.Fatalf("spell casts = %#v, want canonical spell name", got)
	}
	input := client.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
	if !strings.Contains(string(input.Content), `"aliases":["Emberfall Aegis"]`) {
		t.Fatalf("catalog prompt input = %s, want transcript alias recognition", input.Content)
	}
}

func TestExtractAliasOnlyCatalogChangesPromptMaterialAndCheckpointFingerprint(t *testing.T) {
	aliasReference := spellCatalogReference(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Private campaign source","version":"1","url":"file:///private-source.json","license":"private"},"spells":[{"name":"Cure Wounds","aliases":["Campaign Woundweave"]}]}]}`)
	baseClient := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
	aliasClient := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
	baseExtractor := newExtractor(t, baseClient)
	aliasExtractor := newExtractor(t, aliasClient, aliasReference)
	if _, err := baseExtractor.Extract(context.Background(), extractionRequest()); err != nil {
		t.Fatalf("base Extract() error = %v", err)
	}
	if _, err := aliasExtractor.Extract(context.Background(), extractionRequest()); err != nil {
		t.Fatalf("alias Extract() error = %v", err)
	}

	baseInput := baseClient.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
	aliasInput := aliasClient.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
	if baseInput.Digest == aliasInput.Digest || string(baseInput.Content) == string(aliasInput.Content) {
		t.Fatalf("alias-only catalog did not change prompt material: %q / %q", baseInput.Digest, aliasInput.Digest)
	}
	if !strings.Contains(string(aliasInput.Content), "Campaign Woundweave") {
		t.Fatalf("alias prompt input = %s, want alias recognition", aliasInput.Content)
	}
	baseFingerprints := checkpointFingerprintMap(baseExtractor.CheckpointFingerprints())
	aliasFingerprints := checkpointFingerprintMap(aliasExtractor.CheckpointFingerprints())
	if baseFingerprints["effective_catalog"] == aliasFingerprints["effective_catalog"] {
		t.Fatalf("effective catalog fingerprint did not change: %#v", aliasFingerprints)
	}
	if baseExtractor.ManifestMetadata()["prompt_id"] != aliasExtractor.ManifestMetadata()["prompt_id"] || baseExtractor.ManifestMetadata()["prompt_version"] != aliasExtractor.ManifestMetadata()["prompt_version"] {
		t.Fatal("catalog-only change altered prompt identity")
	}
}

func TestNewRejectsMalformedCatalogBeforeLLMCall(t *testing.T) {
	client := &fakeSpellsLLMClient{}
	_, err := New(client, Options{}, spellCatalogReference(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v2","catalogs":[]}`))
	if err == nil || !strings.Contains(err.Error(), "resolve effective spell catalog") {
		t.Fatalf("New() error = %v, want effective catalog error", err)
	}
	if len(client.requests) != 0 {
		t.Fatalf("LLM calls = %d, want none during failed construction", len(client.requests))
	}
}

func TestExtractorManifestMetadataIncludesLLMSchemaProvenance(t *testing.T) {
	metadata := newExtractor(t, &fakeSpellsLLMClient{}).ManifestMetadata()
	tests := map[string]string{
		"prompt_id": PromptID, "prompt_version": SchemaVersion,
		"response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
		"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion,
	}
	for key, want := range tests {
		if metadata[key] != want {
			t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
		}
	}
	for _, key := range []string{"prompt_sha256", "response_schema_sha256"} {
		value, ok := metadata[key].(string)
		if !ok || !strings.HasPrefix(value, "sha256:") {
			t.Fatalf("metadata[%q] = %#v, want sha256 value", key, metadata[key])
		}
	}
}

func TestNilExtractorManifestMetadata(t *testing.T) {
	var extractor *Extractor
	if metadata := extractor.ManifestMetadata(); metadata != nil {
		t.Fatalf("nil extractor metadata = %#v, want nil", metadata)
	}
}

func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
	client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
	req := extractionRequest()
	req.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
		"players":  {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Alice: Aria Brightmantle")}}},
		"party":    {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Aria Brightmantle: party cleric")}}},
		"glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("Brightmantle: local temple name")}}},
	}}

	if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	inputs := client.requests[0].Inputs
	if string(inputs["players"].Content) != "Alice: Aria Brightmantle" || string(inputs["party"].Content) != "Aria Brightmantle: party cleric" || string(inputs["glossary"].Content) != "Brightmantle: local temple name" {
		t.Fatalf("reference inputs = %#v, want configured content", inputs)
	}
	if strings.Contains(string(inputs["transcript"].Content), "party cleric") {
		t.Fatal("transcript input contains reference content")
	}
}

func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
	inputs := shared.PromptInputs(spellSourceInput(), contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
		"roster": {Slot: contracts.ReferenceSlot{Name: "roster"}, Items: []contracts.ReferenceItem{{SlotName: "roster", Content: []byte("Legacy roster text")}}},
	}})
	if got := string(inputs["party"].Content); got != "Legacy roster text" {
		t.Fatalf("party input = %q, want legacy roster content", got)
	}
	if _, ok := inputs["roster"]; ok {
		t.Fatal("roster prompt input was present; want only party input")
	}
}

func TestExtractPreservesEmptyAndMalformedValuesForTypedValidators(t *testing.T) {
	tests := []struct {
		name     string
		response extractionResponse
		wantNil  bool
	}{
		{name: "empty", response: extractionResponse{SpellCasts: []spellCastResponse{}}},
		{name: "missing", response: extractionResponse{}, wantNil: true},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			result, err := newExtractor(t, &fakeSpellsLLMClient{response: test.response}).Extract(context.Background(), extractionRequest())
			if err != nil {
				t.Fatalf("Extract() error = %v, want nil", err)
			}
			if (result.Value.SpellCasts == nil) != test.wantNil || len(result.Value.SpellCasts) != 0 {
				t.Fatalf("SpellCasts = %#v, want empty with nil=%t", result.Value.SpellCasts, test.wantNil)
			}
		})
	}
}

func TestExtractWrapsLLMClientError(t *testing.T) {
	_, err := newExtractor(t, &fakeSpellsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), extractionRequest())
	if err == nil || !strings.Contains(err.Error(), "dnd spells") || !strings.Contains(err.Error(), "provider unavailable") {
		t.Fatalf("Extract() error = %v, want wrapped provider error", err)
	}
}

func TestExtractRejectsInvalidRequests(t *testing.T) {
	validReq := extractionRequest()
	validExtractor := newExtractor(t, &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}})
	var nilExtractor *Extractor
	tests := []struct {
		name      string
		extractor *Extractor
		ctx       context.Context
		req       contracts.TypedExtractionRequest
		want      string
	}{
		{name: "nil extractor", extractor: nilExtractor, ctx: context.Background(), req: validReq, want: "extractor"},
		{name: "nil LLM client", extractor: &Extractor{}, ctx: context.Background(), req: validReq, want: "LLM client"},
		{name: "wrapped preflight failure", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			_, err := test.extractor.Extract(test.ctx, test.req)
			if err == nil || !strings.Contains(err.Error(), "dnd spells") || !strings.Contains(err.Error(), test.want) {
				t.Fatalf("Extract() error = %v, want %q context", err, test.want)
			}
		})
	}
}

func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
	client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
		{Caster: "Borin", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(2, 2)},
		{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []spellSourceRefResponse{{StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}}},
		{Caster: "Narrator", Spell: "Unknown"},
	}}}
	result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
	if err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	if got := []string{result.Value.SpellCasts[0].Spell, result.Value.SpellCasts[1].Spell, result.Value.SpellCasts[2].Spell}; !reflect.DeepEqual(got, []string{"Cure Wounds", "Fire Bolt", "Unknown"}) {
		t.Fatalf("spell order = %#v, want evidence order", got)
	}
	if refs := result.Value.SpellCasts[0].SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}) {
		t.Fatalf("source refs = %#v, want one canonical ref", refs)
	}
}

func TestExtractUsesDocumentOrderForReferencesAndSpellCasts(t *testing.T) {
	client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
		{Caster: "Later", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(10, 10)},
		{Caster: "Earlier", Spell: "Cure Wounds", SourceRefs: []spellSourceRefResponse{
			{StartUnitID: 10, EndUnitID: 10},
			{StartUnitID: 30, EndUnitID: 30},
			{StartUnitID: 30, EndUnitID: 30},
			{StartUnitID: 999, EndUnitID: 0},
		}},
		{Caster: "Unavailable", Spell: "Healing Word", SourceRefs: []spellSourceRefResponse{{StartUnitID: 999, EndUnitID: 0}}},
	}}}
	req := extractionRequest()
	req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
	req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
	req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}

	result, err := newExtractor(t, client).Extract(context.Background(), req)
	if err != nil {
		t.Fatalf("Extract() error = %v", err)
	}
	if got := []string{result.Value.SpellCasts[0].Spell, result.Value.SpellCasts[1].Spell, result.Value.SpellCasts[2].Spell}; !reflect.DeepEqual(got, []string{"Cure Wounds", "Fire Bolt", "Healing Word"}) {
		t.Fatalf("spell order = %#v, want document chronology followed by invalid evidence", got)
	}
	refs := result.Value.SpellCasts[0].SourceRefs
	if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
		t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
	}
	refs[0].StartUnitID = 777
	for _, spell := range client.response.SpellCasts {
		for _, ref := range spell.SourceRefs {
			if ref.StartUnitID == 777 {
				t.Fatal("result source references alias the model response")
			}
		}
	}
}

func TestExtractPreservesStableSpellOrderForEqualEvidence(t *testing.T) {
	client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
		{Caster: "First", Spell: "Cure Wounds", SourceRefs: responseSourceRefs(2, 2)},
		{Caster: "Second", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(2, 2)},
	}}}
	result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
	if err != nil {
		t.Fatalf("Extract() error = %v", err)
	}
	if got := []string{result.Value.SpellCasts[0].Caster, result.Value.SpellCasts[1].Caster}; !reflect.DeepEqual(got, []string{"First", "Second"}) {
		t.Fatalf("equal-evidence order = %#v, want stable response order", got)
	}
}

func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
	client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
		Caster: "Aria", Spell: "Cure Wounds",
		SourceRefs: []spellSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
	}}}}
	result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
	if err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	ref := result.Value.SpellCasts[0].SourceRefs[0]
	if ref != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) {
		t.Fatalf("source ref = %#v, want canonical source with invalid range preserved", ref)
	}
}

func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
	client := &fakeSpellsLLMClient{content: []byte(`{"spell_casts":[{"caster":"","spell":"Cure Wounds","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)}
	result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
	if err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	spell := result.Value.SpellCasts[0]
	if spell.Caster != "" {
		t.Fatalf("spell = %#v, want blank semantic values preserved", spell)
	}
	if refs := spell.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) {
		t.Fatalf("source refs = %#v, want raw nonpositive candidates preserved", refs)
	}
}
