package npcregistry

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"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/framework/semanticreconcile"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)

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.ModelCandidate != nil {
		t.Fatalf("model candidate = %#v, want nil for no-call result", result.ModelCandidate)
	}
	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":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`}
	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"
	correction, err := contracts.NewSemanticCorrection([]byte(`{"duplicate_groups":[]}`), "keep the distinct captain")
	if err != nil {
		t.Fatal(err)
	}
	request.Correction = correction
	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 !hasDiagnostic(result.Diagnostics, ReasonCodeDuplicateNPCCollapsed, "npcs[0]", contracts.DiagnosticDispositionObservation) {
		t.Fatalf("diagnostics = %#v, want semantic collapse observation", result.Diagnostics)
	}
	if len(client.requests) != 1 {
		t.Fatalf("completion calls = %d, want one", len(client.requests))
	}
	completion := client.requests[0]
	if !reflect.DeepEqual(completion.Correction, correction) {
		t.Fatalf("completion correction = %#v, want %#v", completion.Correction, correction)
	}
	if result.ModelCandidate == nil || result.ModelCandidate.Protocol != contracts.CorrectionProtocolSingleResponseV1 || string(result.ModelCandidate.Response) != client.response {
		t.Fatalf("model candidate = %#v, want exact semantic response", result.ModelCandidate)
	}
	if completion.StageName != Key || completion.PromptID != PromptID || completion.PromptVersion != PromptVersion || 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)
	}
	var visible struct {
		Candidates []struct {
			CandidateNumber int `json:"candidate_number"`
		} `json:"candidates"`
	}
	if err := json.Unmarshal(completion.Inputs["candidates"].Content, &visible); err != nil {
		t.Fatalf("decode candidates: %v", err)
	}
	if got := []int{visible.Candidates[0].CandidateNumber, visible.Candidates[1].CandidateNumber, visible.Candidates[2].CandidateNumber}; !reflect.DeepEqual(got, []int{1, 2, 3}) {
		t.Fatalf("candidate numbers = %v, want contiguous request-local handles", got)
	}
}

func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) {
	client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,99],"canonical_candidate_number":3}]}`}
	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 !strings.Contains(result.Retry.CorrectionGuidance, "Duplicate group 2") || !strings.Contains(result.Retry.CorrectionGuidance, "candidate-number range 1 through 3") || !strings.Contains(result.Retry.CorrectionGuidance, "source positions") || strings.Contains(result.Retry.CorrectionGuidance, "member_unknown") || strings.Contains(result.Retry.CorrectionGuidance, ReasonCodeNPCSemanticProposalInvalid) {
		t.Fatalf("correction guidance = %q, want contextual prose without internal labels", result.Retry.CorrectionGuidance)
	}
	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.FallbackDiagnostics) != 1 || result.Retry.FallbackDiagnostics[0].ReasonCode != ReasonCodeNPCSemanticReconciliationExhausted || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "1 proposal group") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "preserved separately") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "candidate-number range 1 through 3") {
		t.Fatalf("fallback diagnostics = %#v, want exact omitted-group warning", result.Retry.FallbackDiagnostics)
	}
}

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)
	}
	if result.Retry.CorrectionGuidance != "" || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "structured-output contract") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "preserved separately") {
		t.Fatalf("invalid structured retry = %#v, want feedback-free retry and informative fallback", result.Retry)
	}
	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 transcript material: 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":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,99],"canonical_candidate_number":3}]}`,
		`{"duplicate_groups":[{"candidate_numbers":[1,3],"canonical_candidate_number":3}]}`,
	}}
	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 TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownHandle(t *testing.T) {
	client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}`}
	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_unknown") || result.Value.NPCs[1].Name != "Broken" {
		t.Fatalf("result = %#v, want unknown descriptor rejected without dropping a record", result)
	}
}

func TestReconciliationCandidatesKeepEqualContextualDescriptorsDistinct(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}}}},
	}
	candidates, _, err := reconciliationInputs(records)
	if err != nil {
		t.Fatalf("reconciliationInputs() error = %v", err)
	}
	preparation, err := semanticreconcile.Prepare(doc, candidates, semanticreconcile.DefaultLimits())
	if err != nil || preparation.Disposition() != semanticreconcile.Ready {
		t.Fatalf("Prepare() = %#v, %v; want ready candidates", preparation, err)
	}
	var candidateInput struct {
		Candidates []struct {
			CandidateNumber int    `json:"candidate_number"`
			Label           string `json:"label"`
		} `json:"candidates"`
	}
	if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
		t.Fatal(err)
	}
	if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateNumber != 1 || candidateInput.Candidates[1].CandidateNumber != 2 || candidateInput.Candidates[0].Label != "The Guard" || candidateInput.Candidates[1].Label != "The Guard" {
		t.Fatalf("candidate input = %#v, want distinct integer handles for equal descriptors", candidateInput)
	}
}

func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning(t *testing.T) {
	client := &recordingNPCNormalizerClient{}
	normalizer := newNormalizer(t, client)
	doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Kind: "speech", Text: "A crowded hall"}}}
	limit := semanticreconcile.DefaultLimits().MaximumCandidates
	input := dnd.NPCRegistry{NPCs: make([]dnd.NPC, limit+1)}
	for index := range input.NPCs {
		input.NPCs[index] = dnd.NPC{Name: fmt.Sprintf("Person %d", index), SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}
	}
	result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil || result.Retry != nil {
		t.Fatalf("Normalize() = %#v, %v; want deterministic limit fallback", result, err)
	}
	if len(client.requests) != 0 || len(result.Value.NPCs) != limit+1 {
		t.Fatalf("completion calls = %d, NPCs = %d; want no call and all records", len(client.requests), len(result.Value.NPCs))
	}
	if !hasDiagnostic(result.Diagnostics, ReasonCodeNPCSemanticReconciliationExhausted, "npcs", contracts.DiagnosticDispositionWarning) {
		t.Fatalf("diagnostics = %#v, want reconciliation fallback warning", result.Diagnostics)
	}
}

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
}
