package combatturns

import (
	"context"
	"fmt"
	"math"
	"reflect"
	"strings"
	"testing"
	"unicode/utf8"

	"gitea.maximumdirect.net/eric/notarius/internal/core/source"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
	npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)

func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
	doc := testDocument()
	references := npcReferences(t)
	normalizer, err := New(Options{}, references)
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
		Actor:      "  aria  ",
		TurnKind:   dnd.CombatTurnKindTurn,
		SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
	}}}
	original := cloneCombatTurn(input.CombatTurns[0])

	result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
		Source:      doc,
		MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input},
	})
	if err != nil {
		t.Fatalf("Normalize() error = %v", err)
	}
	if got := result.Value.CombatTurns[0]; got.Actor != "Aria" {
		t.Fatalf("normalized turn = %#v, want canonical actor", got)
	}
	wantRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}
	if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) {
		t.Fatalf("normalized refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs)
	}
	for _, reason := range []string{ReasonCodeActorCanonicalized, ReasonCodeSourceRefsNormalized} {
		if !hasDiagnosticReason(result.Diagnostics, reason) {
			t.Fatalf("diagnostics = %#v, missing reason %q", result.Diagnostics, reason)
		}
	}
	if !reflect.DeepEqual(input.CombatTurns[0], original) {
		t.Fatalf("Normalize() mutated input: got %#v, want %#v", input.CombatTurns[0], original)
	}
	result.Value.CombatTurns[0].SourceRefs[0].StartUnitID = 999
	if input.CombatTurns[0].SourceRefs[0].StartUnitID == 999 {
		t.Fatal("normalized source refs share input storage")
	}
}

func TestNormalizeLimitsFindingsWithoutChangingCombatTurnValues(t *testing.T) {
	units := make([]source.SourceUnit, contracts.MaxDiagnosticSamples+1)
	turns := make([]dnd.CombatTurn, len(units))
	for index := range units {
		unitID := index + 1
		units[index] = source.SourceUnit{ID: unitID}
		turns[index] = validTurn(" Aria ", source.SourceRef{SourceID: "session", StartUnitID: unitID, EndUnitID: unitID})
	}
	normalizer, err := New(Options{})
	if err != nil {
		t.Fatal(err)
	}
	result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
		Source:      &source.SourceDocument{ID: "session", Units: units},
		MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: dnd.CombatTurnList{CombatTurns: turns}},
	})
	if err != nil {
		t.Fatalf("Normalize() error = %v", err)
	}
	if len(result.Value.CombatTurns) != len(turns) || result.Value.CombatTurns[0].Actor != "Aria" {
		t.Fatalf("normalized turns = %#v, want canonicalized values", result.Value.CombatTurns)
	}
	if len(result.Diagnostics) == 0 || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionObservation || result.Diagnostics[0].OccurrenceCount != len(turns) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples {
		t.Fatalf("diagnostics = %#v", result.Diagnostics)
	}
}

func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testing.T) {
	doc := testDocument()
	normalizer, err := New(Options{})
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
		Actor:      "aria",
		TurnKind:   dnd.CombatTurnKindTurn,
		SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}},
	}}}
	result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
		Source:      doc,
		MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input},
		References:  npcReferences(t),
	})
	if err != nil {
		t.Fatalf("Normalize() error = %v", err)
	}
	turn := result.Value.CombatTurns[0]
	if turn.Actor != "Aria" {
		t.Fatalf("operation-normalized turn = %#v, want Aria", turn)
	}
	if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
		t.Fatalf("singleton metadata = %#v, want no operation-varying NPC identity", metadata)
	}
}

func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T) {
	doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}}
	first := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50})
	second := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90})
	duplicate := cloneCombatTurn(first)
	invalid := validTurn("Unknown", source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})
	input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{second, first, duplicate, invalid}}

	normalizer, err := New(Options{})
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
		Source:      doc,
		MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input},
	})
	if err != nil {
		t.Fatalf("Normalize() error = %v", err)
	}
	if len(result.Value.CombatTurns) != 3 {
		t.Fatalf("normalized turn count = %d, want 3", len(result.Value.CombatTurns))
	}
	if result.Value.CombatTurns[0].SourceRefs[0].StartUnitID != 50 || result.Value.CombatTurns[1].SourceRefs[0].StartUnitID != 90 || result.Value.CombatTurns[2].Actor != "Unknown" {
		t.Fatalf("normalized order/value = %#v, want chronology then invalid evidence", result.Value.CombatTurns)
	}
	if !hasDiagnosticReason(result.Diagnostics, ReasonCodeTurnsReordered) || !hasDiagnosticReason(result.Diagnostics, ReasonCodeDuplicateCollapsed) {
		t.Fatalf("diagnostics = %#v, want reorder and duplicate observations", result.Diagnostics)
	}
	for _, diagnostic := range result.Diagnostics {
		if diagnostic.ReasonCode == ReasonCodeDuplicateCollapsed && diagnostic.Samples[0].Scope != "combat_turns[1]" {
			t.Fatalf("duplicate diagnostic = %#v, want retained input scope combat_turns[1]", diagnostic)
		}
	}
}

func TestNormalizeUsesDocumentOrderForReferencesAndChronology(t *testing.T) {
	doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
	input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
		validTurn("Borin", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}),
		validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}),
		{Actor: "Goblin", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{
			{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
			{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
			{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
		}},
	}}
	normalizer, err := New(Options{})
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}})
	if err != nil {
		t.Fatalf("Normalize() error = %v", err)
	}
	if got := result.Value.CombatTurns; got[0].Actor != "Aria" || got[1].Actor != "Goblin" || got[2].Actor != "Borin" {
		t.Fatalf("turn order = %#v, want source chronology", got)
	}
	refs := result.Value.CombatTurns[1].SourceRefs
	if refs[0].StartUnitID != 30 || refs[1].StartUnitID != 10 || refs[2].StartUnitID != 999 {
		t.Fatalf("turn source refs = %#v, want source-document order and invalid fallback", refs)
	}
}

func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
	doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}}}
	input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
		validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}),
		validTurn("Borin", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}),
	}}
	normalizer, err := New(Options{})
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}})
	if err != nil {
		t.Fatalf("Normalize() error = %v", err)
	}
	if got := []string{result.Value.CombatTurns[0].Actor, result.Value.CombatTurns[1].Actor}; !reflect.DeepEqual(got, []string{"Aria", "Borin"}) {
		t.Fatalf("equal-position order = %#v, want stable input order", got)
	}
	if hasDiagnosticReason(result.Diagnostics, ReasonCodeTurnsReordered) {
		t.Fatalf("diagnostics = %#v, equal-position stable sort should not report reordering", result.Diagnostics)
	}
}

func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) {
	doc := testDocument()
	base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})
	tests := []struct {
		name  string
		other dnd.CombatTurn
	}{
		{name: "different actor", other: withActor(base, "Borin")},
		{name: "different turn kind", other: withKind(base, dnd.CombatTurnKindReaction)},
		{name: "different evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20})},
		{name: "invalid evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			normalizer, err := New(Options{})
			if err != nil {
				t.Fatalf("New() error = %v", err)
			}
			result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
				Source:      doc,
				MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{base, test.other}}},
			})
			if err != nil {
				t.Fatalf("Normalize() error = %v", err)
			}
			if len(result.Value.CombatTurns) != 2 {
				t.Fatalf("normalized turn count = %d, want distinct records", len(result.Value.CombatTurns))
			}
		})
	}
}

func TestNormalizePreservesNilAndPresentEmptyStorage(t *testing.T) {
	normalizer, err := New(Options{})
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	for _, input := range []dnd.CombatTurnList{
		{},
		{CombatTurns: []dnd.CombatTurn{}},
	} {
		result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}})
		if err != nil {
			t.Fatalf("Normalize() error = %v", err)
		}
		if (result.Value.CombatTurns == nil) != (input.CombatTurns == nil) {
			t.Fatalf("nil/present-empty distinction lost: input %#v output %#v", input.CombatTurns, result.Value.CombatTurns)
		}
	}
}

func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T) {
	unbound, err := New(Options{})
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy {
		t.Fatalf("unbound metadata = %#v", metadata)
	}
	if got := unbound.CheckpointFingerprints(); len(got) != 3 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" || got[2].Name != "npc_registry" || got[2].Value == "" {
		t.Fatalf("unbound fingerprints = %#v", got)
	}

	bound, err := New(Options{}, npcReferences(t))
	if err != nil {
		t.Fatalf("bound New() error = %v", err)
	}
	metadata := bound.ManifestMetadata()
	if metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 {
		t.Fatalf("bound metadata = %#v", metadata)
	}
	if got := bound.CheckpointFingerprints(); len(got) != 3 || got[2].Name != "npc_registry" || got[2].Value == "" {
		t.Fatalf("bound fingerprints = %#v", got)
	}
	if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) || len(spec.ReferenceSlots) != 1 || spec.ReferenceSlots[0].Name != NPCRegistryReferenceSlot {
		t.Fatalf("ModuleSpec() = %#v", spec)
	}
	registry := pipeline.NewNormalizerRegistry()
	if err := Register(registry); err != nil {
		t.Fatalf("Register() error = %v", err)
	}
	if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
		t.Fatal("DecodeOptions() accepted unknown option")
	}
	if _, err := New(Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil {
		t.Fatal("New() accepted multiple reference sets")
	}
}

func TestDuplicateFindingBoundsDisplayedIndexesAndReportsAllOmissions(t *testing.T) {
	removed := make([]int, 25)
	for index := range removed {
		removed[index] = math.MaxInt - index
	}
	finding := duplicateFinding(7, removed)
	if finding.Scope != "combat_turns[7]" || finding.ReasonCode != ReasonCodeDuplicateCollapsed {
		t.Fatalf("duplicate finding = %#v, want retained-record scope and reason", finding)
	}
	if !strings.Contains(finding.Message, "retained input index 7") || !strings.Contains(finding.Message, fmt.Sprintf("removed input index %d", removed[0])) {
		t.Fatalf("duplicate finding = %q, want retained and displayed removed indexes", finding.Message)
	}
	if !strings.Contains(finding.Message, "5 additional issue(s) omitted") {
		t.Fatalf("duplicate finding = %q, want exact omitted count", finding.Message)
	}
	if !utf8.ValidString(finding.Message) || len([]byte(finding.Message)) > 4096 {
		t.Fatalf("duplicate finding length/encoding = %d/%t", len([]byte(finding.Message)), utf8.ValidString(finding.Message))
	}
}

func TestNormalizerRejectsNilAndCanceledCalls(t *testing.T) {
	normalizer, err := New(Options{})
	if err != nil {
		t.Fatalf("New() error = %v", err)
	}
	if _, err := normalizer.Normalize(nil, contracts.TypedNormalizeRequest[dnd.CombatTurnList]{}); err == nil {
		t.Fatal("Normalize() accepted nil context")
	}
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	if _, err := normalizer.Normalize(ctx, contracts.TypedNormalizeRequest[dnd.CombatTurnList]{}); err == nil {
		t.Fatal("Normalize() accepted canceled context")
	}
}

func validTurn(actor string, ref source.SourceRef) dnd.CombatTurn {
	return dnd.CombatTurn{
		Actor:      actor,
		TurnKind:   dnd.CombatTurnKindTurn,
		SourceRefs: []source.SourceRef{ref},
	}
}

func testDocument() *source.SourceDocument {
	return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10, Text: "Aria attacks the goblin."}, {ID: 20, Text: "The goblin is hit."}, {ID: 30, Text: "The goblin falls."}}}
}

func npcReferences(t *testing.T) contracts.ReferenceSet {
	t.Helper()
	npcs := dnd.NPCRegistry{NPCs: []dnd.NPC{
		{ID: identity.DeriveID("Aria"), Name: "Aria", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}},
		{ID: identity.DeriveID("Goblin"), Name: "Goblin", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}},
	}}
	content, err := npccodec.New().Encode(npcs)
	if err != nil {
		t.Fatalf("encode NPC references: %v", err)
	}
	return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
		NPCRegistryReferenceSlot: {
			Slot:  contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, AcceptedMediaTypes: []string{npccodec.MediaType}, MaxBytes: NPCRegistryMaxBytes},
			Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
		},
	}}
}

func hasDiagnosticReason(diagnostics []contracts.ProducerDiagnostic, reason string) bool {
	for _, diagnostic := range diagnostics {
		if diagnostic.ReasonCode == reason && diagnostic.Disposition == contracts.DiagnosticDispositionObservation {
			return true
		}
	}
	return false
}

func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn {
	turn.Actor = actor
	return turn
}

func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn {
	turn.TurnKind = kind
	return turn
}

func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
	turn.SourceRefs = []source.SourceRef{ref}
	return turn
}
