package npcregistry

import (
	"context"
	"errors"
	"math"
	"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"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)

func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing.T) {
	client := &recordingNPCNormalizerClient{}
	normalizer := newNormalizer(t, client)
	doc := semanticDocument()
	input := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}}}
	result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil || len(client.requests) != 0 || result.Retry != nil {
		t.Fatalf("Normalize() = %#v, %v; calls = %d, want deterministic no-call result", result, err, len(client.requests))
	}
	if result.Value.NPCs[0].Name != "Mira Thorn" {
		t.Fatalf("NPCs = %#v, want deterministic record", result.Value.NPCs)
	}
}

func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) {
	client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`}
	normalizer := newNormalizer(t, client)
	doc := semanticDocument()
	input := dnd.NPCRegistry{NPCs: []dnd.NPC{
		{ID: "npc:sha256:short", Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
		{ID: "npc:sha256:long", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
		{ID: "npc:sha256:captain", Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
	}}
	before := cloneNPCRegistry(input)
	request := normalizeRequestWithSource(input, doc)
	request.LLMProfile = "normalizer-profile"
	request.SessionID = "normalizer-session"
	result, err := normalizer.Normalize(context.Background(), request)
	if err != nil || result.Retry != nil {
		t.Fatalf("Normalize() = %#v, %v; want accepted semantic result", result, err)
	}
	if !reflect.DeepEqual(input, before) {
		t.Fatalf("Normalize() mutated input to %#v", input)
	}
	if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Mira Thorn" || result.Value.NPCs[1].Name != "Captain Vale" {
		t.Fatalf("NPCs = %#v, want canonical record at earliest position", result.Value.NPCs)
	}
	merged := result.Value.NPCs[0]
	if merged.ID != identity.DeriveID("Mira Thorn") || !reflect.DeepEqual(merged.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}) {
		t.Fatalf("merged NPC = %#v, want canonical ID and original evidence union", merged)
	}
	if !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") {
		t.Fatalf("warnings = %#v, want semantic collapse warning", result.Warnings)
	}
	if len(client.requests) != 1 {
		t.Fatalf("completion calls = %d, want one", len(client.requests))
	}
	completion := client.requests[0]
	if completion.StageName != Key || completion.PromptID != PromptID || completion.PromptVersion != entityreconcile.SchemaVersion || completion.ProfileID != request.LLMProfile || completion.SessionID != request.SessionID || len(completion.Inputs) != 2 {
		t.Fatalf("completion request = %#v, want normalize request identity and exactly two inputs", completion)
	}
	encoded := string(completion.Inputs["candidates"].Content) + string(completion.Inputs["transcript"].Content)
	if strings.Contains(encoded, "npc:sha256:") || strings.Contains(encoded, doc.ID) {
		t.Fatalf("completion inputs leaked private identifiers: %s", encoded)
	}
}

func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) {
	client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`}
	normalizer := newNormalizer(t, client)
	doc := semanticDocument()
	input := dnd.NPCRegistry{NPCs: []dnd.NPC{
		{Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
		{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
		{Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
	}}
	result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil || result.Retry == nil {
		t.Fatalf("Normalize() = %#v, %v; want retryable safe fallback", result, err)
	}
	if result.Retry.ReasonCode != ReasonCodeNPCSemanticProposalInvalid || !strings.Contains(result.Retry.Message, "group 1: member_unknown") {
		t.Fatalf("retry = %#v, want bounded invalid-proposal diagnostics", result.Retry)
	}
	if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Mira Thorn" || result.Value.NPCs[1].Name != "Captain Vale" {
		t.Fatalf("fallback NPCs = %#v, want independently safe group applied", result.Value.NPCs)
	}
	if len(result.Retry.FallbackWarnings) != 1 || result.Retry.FallbackWarnings[0].ReasonCode != ReasonCodeNPCSemanticReconciliationExhausted || !strings.Contains(result.Retry.FallbackWarnings[0].Message, "1 proposal group") {
		t.Fatalf("fallback warnings = %#v, want exact omitted-group warning", result.Retry.FallbackWarnings)
	}
}

func TestNormalizeInvalidStructuredOutputAndOperationalErrorsRemainDistinct(t *testing.T) {
	doc := semanticDocument()
	input := dnd.NPCRegistry{NPCs: []dnd.NPC{
		{Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
		{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
	}}
	invalid := newNormalizer(t, &recordingNPCNormalizerClient{err: contracts.ErrInvalidStructuredOutput})
	result, err := invalid.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil || result.Retry == nil || result.Retry.ReasonCode != ReasonCodeNPCSemanticProposalInvalid || len(result.Value.NPCs) != 2 {
		t.Fatalf("invalid structured result = %#v, %v; want deterministic retry fallback", result, err)
	}
	operational := errors.New("provider unavailable")
	if _, err := newNormalizer(t, &recordingNPCNormalizerClient{err: operational}).Normalize(context.Background(), normalizeRequestWithSource(input, doc)); !errors.Is(err, operational) || errors.Is(err, contracts.ErrInvalidStructuredOutput) {
		t.Fatalf("operational completion error = %v, want ordinary error", err)
	}
}

func TestNormalizeRedactsContextMaterialFailures(t *testing.T) {
	client := &recordingNPCNormalizerClient{}
	normalizer := newNormalizer(t, client)
	const (
		metadataKey   = "normalizer-sensitive-metadata-key"
		metadataValue = "normalizer-sensitive-metadata-value"
		sourceID      = "normalizer-sensitive-source-id"
		originPath    = "file:///normalizer-sensitive-origin.json"
		transcript    = "normalizer-sensitive-transcript"
		firstName     = "Normalizer Sensitive"
		secondName    = "Normalizer Sensitive Alias"
	)
	doc := &source.SourceDocument{ID: sourceID, Units: []source.SourceUnit{
		{ID: 10, Kind: "speech", Text: transcript, Metadata: map[string]any{metadataKey: math.NaN(), "value": metadataValue}},
		{ID: 20, Kind: "speech", Text: "other context"},
	}}
	input := dnd.NPCRegistry{NPCs: []dnd.NPC{
		{Name: firstName, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
		{Name: secondName, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
	}}
	request := normalizeRequestWithSource(input, doc)
	request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(transcript), "sha256:test", originPath)
	_, err := normalizer.Normalize(context.Background(), request)
	if err == nil || !strings.Contains(err.Error(), "build entity reconciliation context: invalid source metadata") {
		t.Fatalf("Normalize() error = %v; want content-safe context-material failure", err)
	}
	for _, forbidden := range []string{metadataKey, metadataValue, transcript, firstName, secondName, sourceID, originPath, "float64", "non-finite"} {
		if strings.Contains(err.Error(), forbidden) {
			t.Fatalf("Normalize() error leaked %q: %v", forbidden, err)
		}
	}
	if len(client.requests) != 0 {
		t.Fatalf("completion calls = %d, want context failure before completion", len(client.requests))
	}
}

func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
	client := &recordingNPCNormalizerClient{responses: []string{
		`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`,
		`{"duplicate_groups":[{"members":["candidate-000001","candidate-000003"],"canonical":"candidate-000003"}]}`,
	}}
	normalizer := newNormalizer(t, client)
	doc := semanticDocument()
	input := dnd.NPCRegistry{NPCs: []dnd.NPC{
		{Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
		{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
		{Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
	}}
	first, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil || first.Retry == nil || len(first.Value.NPCs) != 2 {
		t.Fatalf("first Normalize() = %#v, %v; want partial retry fallback", first, err)
	}
	second, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil || second.Retry != nil {
		t.Fatalf("second Normalize() = %#v, %v; want accepted independent retry result", second, err)
	}
	if got := []string{second.Value.NPCs[0].Name, second.Value.NPCs[1].Name}; !reflect.DeepEqual(got, []string{"Captain Vale", "Mira Thorn"}) {
		t.Fatalf("second NPCs = %#v, want proposal applied to original merge output", second.Value.NPCs)
	}
}

func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) {
	client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`}
	normalizer := newNormalizer(t, client)
	doc := semanticDocument()
	input := dnd.NPCRegistry{NPCs: []dnd.NPC{
		{Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
		{Name: "Broken", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}}},
		{Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
	}}
	result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil || result.Retry == nil || len(result.Value.NPCs) != 3 {
		t.Fatalf("Normalize() = %#v, %v; want unchanged retry fallback", result, err)
	}
	if !strings.Contains(result.Retry.Message, "member_ineligible") || result.Value.NPCs[1].Name != "Broken" {
		t.Fatalf("result = %#v, want ineligible record excluded but preserved", result)
	}
}

func TestReconciliationCandidatesKeepEqualDisplayNamesDistinct(t *testing.T) {
	doc := semanticDocument()
	records := []normalizedRecord{
		{npc: dnd.NPC{Name: "The Guard", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}},
		{npc: dnd.NPC{Name: "The Guard", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}},
	}
	materials, ready, err := entityreconcile.BuildContext(doc, reconciliationCandidates(records), semanticContextRadius)
	if err != nil || !ready {
		t.Fatalf("BuildContext() = %#v, %t, %v; want ready keyed candidates", materials, ready, err)
	}
	keys := materials.CandidateKeys()
	if !reflect.DeepEqual(keys, []string{"candidate-000001", "candidate-000002"}) || strings.Count(string(materials.Candidates.Content), `"The Guard"`) != 2 {
		t.Fatalf("candidate keys and inputs = %#v, %s; want distinct equal-display candidates", keys, materials.Candidates.Content)
	}
	assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{
		Members: keys, Canonical: keys[1],
	}}})
	groups := reconciliationGroups(assessment, keys)
	if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 {
		t.Fatalf("reconciliation = %#v, %#v; want distinct keyed group", assessment, groups)
	}
}

func TestRetryWarningLimitReservesExhaustionWarningPosition(t *testing.T) {
	warnings := make([]contracts.Warning, 0, 25)
	for index := 0; index < 25; index++ {
		warnings = append(warnings, contracts.Warning{Scope: "npcs", ReasonCode: "test", Message: "warning"})
	}
	bounded := limitWarningsForRetry(warnings)
	if len(bounded) != 19 || bounded[len(bounded)-1].ReasonCode != ReasonCodeNPCNormalizationWarningsOmitted || !strings.Contains(bounded[len(bounded)-1].Message, "7 additional") {
		t.Fatalf("retry warnings = %#v, want 18 warnings plus accurate omission summary", bounded)
	}
}

func semanticDocument() *source.SourceDocument {
	return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
		{ID: 10, Kind: "speech", Text: "Mira speaks"},
		{ID: 15, Kind: "narration", Text: "surrounding context"},
		{ID: 20, Kind: "speech", Text: "Mira Thorn replies"},
		{ID: 30, Kind: "speech", Text: "Captain Vale watches"},
	}}
}

func cloneNPCRegistry(input dnd.NPCRegistry) dnd.NPCRegistry {
	output := dnd.NPCRegistry{NPCs: make([]dnd.NPC, len(input.NPCs))}
	for index, npc := range input.NPCs {
		output.NPCs[index] = cloneNPC(npc)
	}
	return output
}
