package spells

import (
	"context"
	"fmt"
	"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"
	spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
)

func TestModuleContractAndStrictOptions(t *testing.T) {
	if _, err := DecodeOptions(nil); err != nil {
		t.Fatalf("DecodeOptions(nil) error = %v, want nil", err)
	}
	if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
		t.Fatalf("DecodeOptions() error = %v, want unknown option error", err)
	}

	want := pipeline.ModuleSpec{
		Key:            Key,
		Stage:          pipeline.StageNormalize,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"merged"},
		Provides:       []string{"normalized"},
		ArtifactKind:   dnd.SpellListKind,
		ReferenceSlots: []contracts.ReferenceSlot{{
			Name:               spellcatalog.SpellCatalogReferenceSlot,
			Description:        "Optional canonical spell-name catalog used for normalization and duplicate identity.",
			AcceptedMediaTypes: []string{"application/json"},
			MaxBytes:           1048576,
		}},
	}
	if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
		t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
	}

	registry := pipeline.NewNormalizerRegistry()
	if err := Register(registry); err != nil {
		t.Fatalf("Register() error = %v, want nil", err)
	}
	registered, ok := registry.SpecForArtifact(Key, dnd.SpellListKind)
	if !ok || !reflect.DeepEqual(registered, want) {
		t.Fatalf("registered spec = %#v, ok = %t, want %#v", registered, ok, want)
	}
	if err := Register(nil); err == nil || !strings.Contains(err.Error(), "normalizer registry") {
		t.Fatalf("Register(nil) error = %v, want registry error", err)
	}
}

func TestNewBuildsEmbeddedAndOverlayCatalogs(t *testing.T) {
	base, err := New(Options{})
	if err != nil {
		t.Fatalf("New() error = %v, want nil", err)
	}
	if base.effectiveCatalog.BaseID() != spellcatalog.SRD5E2014ID || base.effectiveCatalog.Digest() == "" || len(base.effectiveCatalog.OverlayIDs()) != 0 {
		t.Fatalf("base catalog identity = %#v, want embedded catalog identity", base.effectiveCatalog)
	}
	if canonical, ok := base.effectiveCatalog.Lookup(" cure   wounds "); !ok || canonical != "Cure Wounds" {
		t.Fatalf("base catalog lookup = %q, %t, want Cure Wounds", canonical, ok)
	}
	if canonical, ok := base.effectiveCatalog.Lookup("Arcanist's Magic Aura"); !ok || canonical != "Arcanist’s Magic Aura" {
		t.Fatalf("apostrophe lookup = %q, %t, want canonical curly apostrophe spelling", canonical, ok)
	}

	overlay, err := New(Options{}, overlayReference())
	if err != nil {
		t.Fatalf("New(overlay) error = %v, want nil", err)
	}
	if got := overlay.effectiveCatalog.OverlayIDs(); !reflect.DeepEqual(got, []string{"campaign.example"}) {
		t.Fatalf("overlay IDs = %#v, want campaign.example", got)
	}
	if canonical, ok := overlay.effectiveCatalog.Lookup(" emberfall   aegis "); !ok || canonical != "Aegis of Emberfall" {
		t.Fatalf("overlay alias lookup = %q, %t, want Aegis of Emberfall", canonical, ok)
	}
}

func TestNewRejectsInvalidCatalogReferencesDuringConstruction(t *testing.T) {
	invalid := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
		spellcatalog.SpellCatalogReferenceSlot: {
			Items: []contracts.ReferenceItem{{MediaType: "text/plain", Content: []byte("not a catalog")}},
		},
	}}
	if _, err := New(Options{}, invalid); err == nil || !strings.Contains(err.Error(), "application/json") {
		t.Fatalf("New(invalid reference) error = %v, want media type failure", err)
	}

	tooMany := overlayReference()
	slot := tooMany.Slots[spellcatalog.SpellCatalogReferenceSlot]
	slot.Items = append(slot.Items, slot.Items[0])
	tooMany.Slots[spellcatalog.SpellCatalogReferenceSlot] = slot
	if _, err := New(Options{}, tooMany); err == nil || !strings.Contains(err.Error(), "zero or one item") {
		t.Fatalf("New(duplicated reference) error = %v, want multiplicity failure", err)
	}
}

func TestIdentityAndMetadataAreDefensive(t *testing.T) {
	normalizer, err := New(Options{}, overlayReference())
	if err != nil {
		t.Fatal(err)
	}
	if NormalizationPolicy != "dnd.spells.normalize.v2" {
		t.Fatalf("NormalizationPolicy = %q, want v2 policy", NormalizationPolicy)
	}

	fingerprints := normalizer.CheckpointFingerprints()
	if len(fingerprints) != 2 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() || fingerprints[1] != (pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy}) {
		t.Fatalf("fingerprints = %#v, want catalog and normalization policy fingerprints", fingerprints)
	}
	fingerprints[0].Name = "changed"
	fingerprints[0].Value = "changed"
	if got := normalizer.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "effective_catalog" || got[0].Value != normalizer.effectiveCatalog.Digest() || got[1] != (pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy}) {
		t.Fatalf("fingerprints were not defensive: %#v", got)
	}

	metadata := normalizer.ManifestMetadata()
	if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || metadata["catalog_digest"] != normalizer.effectiveCatalog.Digest() || metadata["normalization_policy"] != normalizationPolicy {
		t.Fatalf("metadata = %#v, want catalog identity", metadata)
	}
	metadata["catalog_overlay_ids"].([]string)[0] = "changed"
	if got := normalizer.ManifestMetadata()["catalog_overlay_ids"].([]string); !reflect.DeepEqual(got, []string{"campaign.example"}) {
		t.Fatalf("metadata overlay IDs were not defensive: %#v", got)
	}
}

func TestNormalizeCanonicalizesNamesAndReportsUnresolvedNames(t *testing.T) {
	normalizer := newNormalizer(t)
	input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
		{Spell: " cure   wounds "},
		{Spell: "Arcanist's Magic Aura"},
		{Spell: "Emberfall   Aegis"},
		{Spell: "Mystery\nSpell\tName"},
		{Spell: "Cure Wounds"},
	}}
	result, err := normalizer.Normalize(context.Background(), normalizeRequest(input))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	wantNames := []string{"Cure Wounds", "Arcanist’s Magic Aura", "Aegis of Emberfall", "Mystery\nSpell\tName", "Cure Wounds"}
	for index, want := range wantNames {
		if result.Value.SpellCasts[index].Spell != want {
			t.Fatalf("spell[%d] = %q, want %q", index, result.Value.SpellCasts[index].Spell, want)
		}
	}
	canonicalized := diagnosticByReason(result.Diagnostics, ReasonCodeSpellNameCanonicalized)
	unresolved := diagnosticByReason(result.Diagnostics, ReasonCodeSpellNameUnresolved)
	if canonicalized == nil || canonicalized.Disposition != contracts.DiagnosticDispositionObservation || canonicalized.OccurrenceCount != 3 || unresolved == nil || unresolved.Disposition != contracts.DiagnosticDispositionAdvisory || unresolved.OccurrenceCount != 1 || unresolved.Samples[0].Scope != "spell_casts[3]" || strings.Contains(unresolved.Samples[0].Message, "Mystery\nSpell") || !strings.Contains(unresolved.Samples[0].Message, `Mystery\nSpell\tName`) {
		t.Fatalf("diagnostics = %#v, want grouped canonicalization and unresolved-quality diagnostics", result.Diagnostics)
	}
}

func TestNormalizeLimitsFindingsWithoutChangingSpellValues(t *testing.T) {
	input := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, contracts.MaxDiagnosticSamples+1)}
	for index := range input.SpellCasts {
		input.SpellCasts[index].Spell = fmt.Sprintf("Unknown Spell %d", index)
	}
	result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequest(input))
	if err != nil {
		t.Fatalf("Normalize() error = %v", err)
	}
	if !reflect.DeepEqual(result.Value, input) {
		t.Fatalf("normalized value = %#v, want unresolved spell values preserved", result.Value)
	}
	if len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCodeSpellNameUnresolved || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionAdvisory || result.Diagnostics[0].OccurrenceCount != len(input.SpellCasts) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples {
		t.Fatalf("diagnostics = %#v", result.Diagnostics)
	}
}

func TestNormalizeBoundsUnicodeNamesAndQuotesCanonicalReplacement(t *testing.T) {
	longName := strings.Repeat("火", 140)
	reference := spellCatalogReference(fmt.Sprintf(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.long","ruleset":"dnd-5e-2014","source":{"title":"Private campaign source"},"spells":[{"name":%q,"aliases":["long alias"]}]}]}`, longName))
	normalizer, err := New(Options{}, reference)
	if err != nil {
		t.Fatalf("New() error = %v, want nil", err)
	}
	result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "long alias"}}}))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	diagnostic := diagnosticByReason(result.Diagnostics, ReasonCodeSpellNameCanonicalized)
	if diagnostic == nil || !utf8.ValidString(diagnostic.Samples[0].Message) || !strings.Contains(diagnostic.Samples[0].Message, "…") || strings.Contains(diagnostic.Samples[0].Message, longName) {
		t.Fatalf("diagnostic = %#v, want valid bounded Unicode diagnostic", diagnostic)
	}
	if got := result.Value.SpellCasts[0].Spell; got != longName {
		t.Fatalf("canonical value = %q, want full catalog name", got)
	}
}

func TestNormalizeSortsAndDeduplicatesExactSourceReferences(t *testing.T) {
	normalizer := newNormalizer(t)
	inputRefs := []source.SourceRef{
		{SourceID: "source-b", StartUnitID: 4, EndUnitID: 5},
		{SourceID: "source-a", StartUnitID: 3, EndUnitID: 4},
		{SourceID: "source-a", StartUnitID: 3, EndUnitID: 4},
		{SourceID: "source-a", StartUnitID: 1, EndUnitID: 2},
		{SourceID: "source-a", StartUnitID: 2, EndUnitID: 3},
		{SourceID: "source-a", StartUnitID: 1, EndUnitID: 4},
	}
	input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "Cure Wounds", SourceRefs: inputRefs}}}
	result, err := normalizer.Normalize(context.Background(), normalizeRequest(input))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	wantRefs := []source.SourceRef{
		{SourceID: "source-a", StartUnitID: 1, EndUnitID: 2},
		{SourceID: "source-a", StartUnitID: 1, EndUnitID: 4},
		{SourceID: "source-a", StartUnitID: 2, EndUnitID: 3},
		{SourceID: "source-a", StartUnitID: 3, EndUnitID: 4},
		{SourceID: "source-b", StartUnitID: 4, EndUnitID: 5},
	}
	if !reflect.DeepEqual(result.Value.SpellCasts[0].SourceRefs, wantRefs) {
		t.Fatalf("source refs = %#v, want %#v", result.Value.SpellCasts[0].SourceRefs, wantRefs)
	}
	diagnostic := diagnosticByReason(result.Diagnostics, ReasonCodeSourceReferencesNormalized)
	if diagnostic == nil || !strings.Contains(diagnostic.Samples[0].Message, "original count 6") || !strings.Contains(diagnostic.Samples[0].Message, "final count 5") || !strings.Contains(diagnostic.Samples[0].Message, "order changed true") || !strings.Contains(diagnostic.Samples[0].Message, "duplicates removed 1") {
		t.Fatalf("diagnostics = %#v, want source normalization observation", result.Diagnostics)
	}
}

func TestNormalizeReportsDuplicateRemovalWithoutOrderChange(t *testing.T) {
	ref1 := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}
	ref2 := source.SourceRef{SourceID: "source", StartUnitID: 2, EndUnitID: 2}
	input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
		Spell:      "Cure Wounds",
		SourceRefs: []source.SourceRef{ref1, ref1, ref2},
	}}}

	result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequest(input))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	diagnostic := diagnosticByReason(result.Diagnostics, ReasonCodeSourceReferencesNormalized)
	if diagnostic == nil {
		t.Fatalf("diagnostics = %#v, want one source normalization observation", result.Diagnostics)
	}
	message := diagnostic.Samples[0].Message
	if !strings.Contains(message, "order changed false") || !strings.Contains(message, "duplicates removed 1") {
		t.Fatalf("finding = %q, want duplicate-only repair without order change", message)
	}
}

func TestNormalizeOrdersReferencesBySourceDocumentPosition(t *testing.T) {
	doc := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
	input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
		Spell: "Cure Wounds",
		SourceRefs: []source.SourceRef{
			{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
			{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
			{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
		},
	}}}
	result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	got := result.Value.SpellCasts[0].SourceRefs
	if got[0].StartUnitID != 30 || got[1].StartUnitID != 10 || got[2].StartUnitID != 999 {
		t.Fatalf("normalized refs = %#v, want source-document order followed by invalid reference", got)
	}
}

func TestNormalizePreservesNilEmptyAndAdjacentOrOverlappingReferences(t *testing.T) {
	normalizer := newNormalizer(t)
	input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
		{Spell: "Cure Wounds", SourceRefs: nil},
		{Spell: "Cure Wounds", SourceRefs: []source.SourceRef{}},
		{Spell: "Cure Wounds", SourceRefs: []source.SourceRef{
			{SourceID: "source", StartUnitID: 3, EndUnitID: 4},
			{SourceID: "source", StartUnitID: 1, EndUnitID: 2},
			{SourceID: "source", StartUnitID: 2, EndUnitID: 5},
		}},
	}}
	before := input
	before.SpellCasts = append([]dnd.SpellCast(nil), input.SpellCasts...)
	before.SpellCasts[2].SourceRefs = append([]source.SourceRef(nil), input.SpellCasts[2].SourceRefs...)
	result, err := normalizer.Normalize(context.Background(), normalizeRequest(input))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	if result.Value.SpellCasts[0].SourceRefs != nil || result.Value.SpellCasts[1].SourceRefs == nil {
		t.Fatalf("nil/empty source refs were not preserved: %#v", result.Value.SpellCasts)
	}
	if len(result.Value.SpellCasts[2].SourceRefs) != 3 {
		t.Fatalf("adjacent/overlapping references = %#v, want all three retained", result.Value.SpellCasts[2].SourceRefs)
	}
	if !reflect.DeepEqual(input, before) {
		t.Fatalf("Normalize() mutated input: got %#v, before %#v", input, before)
	}
	result.Value.SpellCasts[2].SourceRefs[0].SourceID = "changed"
	if input.SpellCasts[2].SourceRefs[0].SourceID == "changed" {
		t.Fatal("normalized references share input storage")
	}
}

func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
	request := normalizeRequest(dnd.SpellList{})
	var normalizer *Normalizer
	if _, err := normalizer.Normalize(context.Background(), request); err == nil || !strings.Contains(err.Error(), "normalizer must not be nil") {
		t.Fatalf("nil receiver error = %v, want nil receiver error", err)
	}
	normalizer = newNormalizer(t)
	if _, err := normalizer.Normalize(nil, request); err == nil || !strings.Contains(err.Error(), "context must not be nil") {
		t.Fatalf("nil context error = %v, want nil context error", err)
	}
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	if _, err := normalizer.Normalize(ctx, request); err == nil || !strings.Contains(err.Error(), "context error before normalize") {
		t.Fatalf("canceled context error = %v, want canceled context error", err)
	}
}

func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) {
	normalizer := newNormalizer(t)
	doc := sourceDocument(6)
	firstEvidence := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 2}
	secondEvidence := source.SourceRef{SourceID: "source", StartUnitID: 3, EndUnitID: 4}
	input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
		{Caster: "  Aria \t", Spell: " cure   wounds ", SourceRefs: []source.SourceRef{secondEvidence, firstEvidence, firstEvidence}},
		{Caster: "Borin", Spell: "Healing Word", SourceRefs: []source.SourceRef{firstEvidence}},
		{Caster: " Kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
		{Caster: "aria", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence, secondEvidence}},
		{Caster: "KYLE", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence}},
		{Caster: " kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
	}}

	result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	if len(result.Value.SpellCasts) != 3 {
		t.Fatalf("normalized casts = %#v, want first occurrences plus distinct cast", result.Value.SpellCasts)
	}
	if got := result.Value.SpellCasts[0]; got.Caster != "  Aria \t" {
		t.Fatalf("retained first cast = %#v, want first occurrence caster unchanged", got)
	}
	if got := result.Value.SpellCasts[0].SourceRefs; !reflect.DeepEqual(got, []source.SourceRef{firstEvidence, secondEvidence}) {
		t.Fatalf("retained first evidence = %#v, want canonical first evidence only", got)
	}
	if got := result.Value.SpellCasts[2].Caster; got != " Kyle " {
		t.Fatalf("Unicode caster output = %q, want first occurrence text unchanged", got)
	}

	canonicalized := diagnosticByReason(result.Diagnostics, ReasonCodeSpellNameCanonicalized)
	refsNormalized := diagnosticByReason(result.Diagnostics, ReasonCodeSourceReferencesNormalized)
	collapsed := diagnosticByReason(result.Diagnostics, ReasonCodeDuplicateSpellCastCollapsed)
	if canonicalized == nil || canonicalized.OccurrenceCount != 3 || refsNormalized == nil || refsNormalized.OccurrenceCount != 1 || collapsed == nil || collapsed.OccurrenceCount != 2 || !strings.Contains(collapsed.Samples[0].Message, "retained input index 0") {
		t.Fatalf("diagnostics = %#v, want grouped normalization observations", result.Diagnostics)
	}
}

func TestNormalizeKeepsDistinctAndIneligibleCastsSeparate(t *testing.T) {
	doc := sourceDocument(6)
	ref := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 2}
	otherRef := source.SourceRef{SourceID: "source", StartUnitID: 3, EndUnitID: 4}
	tests := []struct {
		name    string
		variant dnd.SpellCast
	}{
		{name: "distinct canonical spell", variant: dnd.SpellCast{Spell: "Healing Word", Caster: "Aria", SourceRefs: []source.SourceRef{ref}}},
		{name: "distinct caster", variant: dnd.SpellCast{Spell: "Cure Wounds", Caster: "Borin", SourceRefs: []source.SourceRef{ref}}},
		{name: "distinct evidence", variant: dnd.SpellCast{Spell: "Cure Wounds", Caster: "Aria", SourceRefs: []source.SourceRef{otherRef}}},
		{name: "unknown spell", variant: dnd.SpellCast{Spell: "Unknown Spell", Caster: "Aria", SourceRefs: []source.SourceRef{ref}}},
		{name: "empty evidence", variant: dnd.SpellCast{Spell: "Cure Wounds", Caster: "Aria"}},
		{name: "invalid evidence", variant: dnd.SpellCast{Spell: "Cure Wounds", Caster: "Aria", SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 2}}}},
	}
	base := dnd.SpellCast{Spell: "Cure Wounds", Caster: "Aria", SourceRefs: []source.SourceRef{ref}}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequestWithSource(dnd.SpellList{SpellCasts: []dnd.SpellCast{base, test.variant}}, doc))
			if err != nil {
				t.Fatalf("Normalize() error = %v, want nil", err)
			}
			if len(result.Value.SpellCasts) != 2 {
				t.Fatalf("normalized casts = %#v, want both casts retained", result.Value.SpellCasts)
			}
			if diagnosticByReason(result.Diagnostics, ReasonCodeDuplicateSpellCastCollapsed) != nil {
				t.Fatalf("diagnostics = %#v, want no duplicate collapse", result.Diagnostics)
			}
		})
	}
}

func TestNormalizeDoesNotCollapseAdjacentOrOverlappingEvidence(t *testing.T) {
	doc := sourceDocument(8)
	input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
		{Spell: "Cure Wounds", Caster: "Aria", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}}},
		{Spell: "Cure Wounds", Caster: "aria", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 3, EndUnitID: 4}}},
		{Spell: "Cure Wounds", Caster: "Aria", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 3}}},
		{Spell: "Cure Wounds", Caster: "aria", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 2, EndUnitID: 4}}},
	}}
	result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	if len(result.Value.SpellCasts) != len(input.SpellCasts) {
		t.Fatalf("normalized casts = %#v, want adjacent and overlapping evidence retained", result.Value.SpellCasts)
	}
	if diagnosticByReason(result.Diagnostics, ReasonCodeDuplicateSpellCastCollapsed) != nil {
		t.Fatalf("diagnostics = %#v, want no duplicate collapse", result.Diagnostics)
	}
}

func TestNormalizeBoundsDuplicateFindingIndices(t *testing.T) {
	doc := sourceDocument(2)
	ref := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}
	casts := make([]dnd.SpellCast, 22)
	for index := range casts {
		casts[index] = dnd.SpellCast{Spell: "Cure Wounds", Caster: "Aria", SourceRefs: []source.SourceRef{ref}}
	}
	result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequestWithSource(dnd.SpellList{SpellCasts: casts}, doc))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	diagnostic := diagnosticByReason(result.Diagnostics, ReasonCodeDuplicateSpellCastCollapsed)
	if len(result.Value.SpellCasts) != 1 || diagnostic == nil || diagnostic.OccurrenceCount != 1 {
		t.Fatalf("result = %#v, diagnostics = %#v, want one retained cast and one grouped observation", result.Value, result.Diagnostics)
	}
	message := diagnostic.Samples[0].Message
	if !strings.Contains(message, "removed input indices [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]") || strings.Contains(message, ", 21]") || !strings.Contains(message, "1 additional removed input indices omitted") {
		t.Fatalf("finding message = %q, want 20 displayed indices and exact omitted count", message)
	}
}

func TestNormalizeIsIdempotentForAlreadyNormalizedInput(t *testing.T) {
	doc := sourceDocument(3)
	input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
		Spell:      "Cure Wounds",
		Caster:     "Aria",
		SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}},
	}}}
	normalizer := newNormalizer(t)
	first, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil {
		t.Fatalf("first Normalize() error = %v, want nil", err)
	}
	second, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(first.Value, doc))
	if err != nil {
		t.Fatalf("second Normalize() error = %v, want nil", err)
	}
	if !reflect.DeepEqual(second.Value, first.Value) {
		t.Fatalf("first = %#v, second = %#v, want identical artifacts", first.Value, second.Value)
	}
}

func TestNormalizeDuplicateOutputDoesNotShareInputSlices(t *testing.T) {
	doc := sourceDocument(3)
	ref := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 2}
	input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
		{Spell: "Cure Wounds", Caster: "Aria", SourceRefs: []source.SourceRef{ref}},
		{Spell: "Cure Wounds", Caster: "aria", SourceRefs: []source.SourceRef{ref}},
	}}
	result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
	if err != nil {
		t.Fatalf("Normalize() error = %v, want nil", err)
	}
	result.Value.SpellCasts[0].SourceRefs[0].SourceID = "changed"
	if input.SpellCasts[0].SourceRefs[0].SourceID != "source" || input.SpellCasts[1].SourceRefs[0].SourceID != "source" {
		t.Fatalf("normalized output shares input references: input = %#v", input)
	}
}

func newNormalizer(t *testing.T) *Normalizer {
	t.Helper()
	normalizer, err := New(Options{}, overlayReference())
	if err != nil {
		t.Fatalf("New() error = %v, want nil", err)
	}
	return normalizer
}

func normalizeRequest(value dnd.SpellList) contracts.TypedNormalizeRequest[dnd.SpellList] {
	return contracts.TypedNormalizeRequest[dnd.SpellList]{
		LaneID: "spells",
		MergeOutput: contracts.MergeArtifact[dnd.SpellList]{
			LaneID:    "spells",
			MergerKey: "appendorder",
			SourceID:  "source",
			Value:     value,
		},
	}
}

func normalizeRequestWithSource(value dnd.SpellList, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.SpellList] {
	request := normalizeRequest(value)
	request.Source = doc
	return request
}

func sourceDocument(unitCount int) *source.SourceDocument {
	units := make([]source.SourceUnit, unitCount)
	for index := range units {
		units[index] = source.SourceUnit{ID: index + 1}
	}
	return &source.SourceDocument{ID: "source", Units: units}
}

func diagnosticByReason(diagnostics []contracts.ProducerDiagnostic, reason string) *contracts.ProducerDiagnostic {
	for index := range diagnostics {
		if diagnostics[index].ReasonCode == reason {
			return &diagnostics[index]
		}
	}
	return nil
}

func overlayReference() contracts.ReferenceSet {
	return 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":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]}]}`)
}

func spellCatalogReference(content string) contracts.ReferenceSet {
	return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
		spellcatalog.SpellCatalogReferenceSlot: {
			Items: []contracts.ReferenceItem{{
				SlotName:  spellcatalog.SpellCatalogReferenceSlot,
				MediaType: "application/json",
				Content:   []byte(content),
			}},
		},
	}}
}
