package npcregistry

import (
	"context"
	"errors"
	"reflect"
	"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/npcs/identity"
)

func TestExtractReturnsCanonicalNPCRegistryFromPrivateResponse(t *testing.T) {
	client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
		{
			Name: "Captain Vale", SourceRefs: responseSourceRefs(3, 2),
		},
		{
			Name: "Mira Thorn",
			SourceRefs: []npcSourceRefResponse{
				{StartUnitID: 2, EndUnitID: 2},
				{StartUnitID: 1, EndUnitID: 2},
				{StartUnitID: 1, EndUnitID: 2},
			},
		},
	}}}

	result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
	if err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	want := dnd.NPCRegistry{NPCs: []dnd.NPC{
		{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}},
		{ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 3}}},
	}}
	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))
	}
	request := client.requests[0]
	if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.ProfileID != "profile-npcs" || request.SessionID != "session-123" {
		t.Fatalf("LLM request identity = %#v", request)
	}
	transcript := request.Inputs["transcript"]
	if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:chunk" || transcript.OriginURI != "file:///session-alpha.json" || string(transcript.Content) != string(extractionRequest().Chunk.Content) {
		t.Fatalf("transcript input = %#v, want chunk-scoped material", transcript)
	}
}

func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) {
	client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
		{
			Name:       "Later NPC",
			SourceRefs: responseSourceRefs(10, 10),
		},
		{
			Name: "Earlier NPC",
			SourceRefs: []npcSourceRefResponse{
				{StartUnitID: 50, EndUnitID: 50},
				{StartUnitID: 100, EndUnitID: 100},
			},
		},
	}}}
	req := extractionRequest()
	req.Source.Units = []source.SourceUnit{
		{ID: 100, Kind: "transcript_segment", Text: "Earlier NPC appears."},
		{ID: 10, Kind: "transcript_segment", Text: "Later NPC appears."},
		{ID: 50, Kind: "transcript_segment", Text: "Earlier NPC appears again."},
	}
	req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
	req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 100, EndUnitID: 50}

	result, err := newExtractor(t, client).Extract(context.Background(), req)
	if err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Earlier NPC" || result.Value.NPCs[1].Name != "Later NPC" {
		t.Fatalf("NPC order = %#v, want source-document order", result.Value.NPCs)
	}
}

func TestExtractUsesDocumentOrderForNPCReferencesAndStableTies(t *testing.T) {
	client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
		{Name: "Later", SourceRefs: responseSourceRefs(10, 10)},
		{Name: "First", SourceRefs: []npcSourceRefResponse{
			{StartUnitID: 10, EndUnitID: 10},
			{StartUnitID: 30, EndUnitID: 30},
			{StartUnitID: 30, EndUnitID: 30},
			{StartUnitID: 999, EndUnitID: 0},
		}},
		{Name: "Second", SourceRefs: responseSourceRefs(30, 30)},
	}}}
	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.NPCs[0].Name, result.Value.NPCs[1].Name, result.Value.NPCs[2].Name}; !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
		t.Fatalf("NPC order = %#v, want document chronology with stable equal-evidence ties", got)
	}
	refs := result.Value.NPCs[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 _, npc := range client.response.NPCs {
		for _, ref := range npc.SourceRefs {
			if ref.StartUnitID == 777 {
				t.Fatal("result source references alias the model response")
			}
		}
	}
}

func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
	client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}
	req := extractionRequest()
	req.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
		"players":  {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Dana: Mira")}}},
		"party":    {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Mira: ranger")}}},
		"glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("Greencloak: local title")}}},
	}}
	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) != "Dana: Mira" || string(inputs["party"].Content) != "Mira: ranger" || string(inputs["glossary"].Content) != "Greencloak: local title" {
		t.Fatalf("reference inputs = %#v", inputs)
	}
	if strings.Contains(string(inputs["transcript"].Content), "local title") {
		t.Fatal("transcript input contains reference content")
	}
}

func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
	client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{
		Name:       "",
		SourceRefs: []npcSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
	}}}}
	result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
	if err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	if len(result.Value.NPCs) != 1 || result.Value.NPCs[0].ID != "" || result.Value.NPCs[0].Name != "" {
		t.Fatalf("malformed candidate = %#v, want invalid values preserved", result.Value)
	}
	if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != "session-alpha" || refs[0].StartUnitID != 99 || refs[0].EndUnitID != 0 {
		t.Fatalf("malformed source refs = %#v, want invalid range preserved after exact deduplication", refs)
	}
}

func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
	client := &fakeNPCsLLMClient{content: []byte(`{"npcs":[{"name":"","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)
	}
	npc := result.Value.NPCs[0]
	if npc.ID != "" || npc.Name != "" {
		t.Fatalf("NPC = %#v, want blank semantic values preserved", npc)
	}
	if refs := npc.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)
	}
}

func TestExtractForwardsCorrectionAndOwnsModelCandidate(t *testing.T) {
	rawResponse := []byte(`{"npcs":[{"name":"Mira Thorn","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
	client := &fakeNPCsLLMClient{content: append([]byte(nil), rawResponse...)}
	correction, err := contracts.NewSemanticCorrection([]byte(`{"npcs":[]}`), "Retain the transcript-grounded NPC.")
	if err != nil {
		t.Fatalf("NewSemanticCorrection() error = %v", err)
	}
	request := extractionRequest()
	request.Correction = correction
	result, err := newExtractor(t, client).Extract(context.Background(), request)
	if err != nil {
		t.Fatalf("Extract() error = %v, want nil", err)
	}
	if len(client.requests) != 1 || client.requests[0].Correction == nil ||
		string(client.requests[0].Correction.AssistantResponse) != `{"npcs":[]}` ||
		client.requests[0].Correction.UserGuidance != "Retain the transcript-grounded NPC." {
		t.Fatalf("structured request correction = %#v, want forwarded correction", client.requests)
	}
	correction.AssistantResponse[0] = '['
	if got := string(client.requests[0].Correction.AssistantResponse); got != `{"npcs":[]}` {
		t.Fatalf("captured correction changed after caller mutation: %q", got)
	}
	if result.ModelCandidate == nil || result.ModelCandidate.Protocol != contracts.CorrectionProtocolSingleResponseV1 ||
		string(result.ModelCandidate.Response) != string(rawResponse) {
		t.Fatalf("model candidate = %#v, want exact validated response", result.ModelCandidate)
	}
	client.content[0] = '['
	if got := string(result.ModelCandidate.Response); got != string(rawResponse) {
		t.Fatalf("model candidate changed after provider buffer mutation: %q", got)
	}
}

func TestExtractRetainsLocalErrorContextAndProviderFailures(t *testing.T) {
	request := extractionRequest()
	extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}})
	var nilExtractor *Extractor
	for _, test := range []struct {
		name      string
		extractor *Extractor
		req       contracts.TypedExtractionRequest
		want      string
	}{
		{name: "nil extractor", extractor: nilExtractor, req: request, want: "extractor"},
		{name: "nil LLM client", extractor: &Extractor{}, req: request, want: "LLM client"},
		{name: "wrapped preflight failure", extractor: extractor, req: mismatchedSourceInputRequest(request), want: "must match chunk"},
	} {
		t.Run(test.name, func(t *testing.T) {
			if _, err := test.extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd npc registry") || !strings.Contains(err.Error(), test.want) {
				t.Fatalf("Extract() error = %v, want contextual local error", err)
			}
		})
	}
	_, err := newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
	if err == nil || !strings.Contains(err.Error(), "dnd npc registry") || !strings.Contains(err.Error(), "provider unavailable") {
		t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
	}
}
