package locationoccurrences

import (
	"context"
	"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/pipeline"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
	locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry"
	"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
)

func TestNormalizeCanonicalizesNamesByIDAndClonesInputs(t *testing.T) {
	doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
	locations := registryLocations("The Tavern", "The Tavern")
	normalizer := newNormalizer(t, registryReferences(t, locations))
	input := dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{
		LocationID: locations.Locations[1].ID, Name: "  a tavern  ", Kind: dnd.LocationOccurrenceKindVisited,
		SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
	}}}
	before := cloneList(input)
	result, err := normalizer.Normalize(context.Background(), normalizeRequest(input, doc, contracts.ReferenceSet{}))
	if err != nil {
		t.Fatal(err)
	}
	occurrence := result.Value.Occurrences[0]
	if occurrence.Name != locations.Locations[1].Name || !reflect.DeepEqual(occurrence.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) {
		t.Fatalf("normalized occurrence = %#v", occurrence)
	}
	if !hasDiagnostic(result.Diagnostics, ReasonCodeNameCanonicalized, contracts.DiagnosticDispositionObservation) || !hasDiagnostic(result.Diagnostics, ReasonCodeSourceRefsNormalized, contracts.DiagnosticDispositionObservation) || !reflect.DeepEqual(input, before) {
		t.Fatalf("diagnostics/input = %#v/%#v", result.Diagnostics, input)
	}
	second, err := normalizer.Normalize(context.Background(), normalizeRequest(result.Value, doc, contracts.ReferenceSet{}))
	if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Diagnostics) != 0 {
		t.Fatalf("second normalization = %#v, %v", second, err)
	}
	result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999
	if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 {
		t.Fatal("normalized source references share input storage")
	}
}

func TestNormalizeKeepsSameNamedIDsAndDistinctEvidence(t *testing.T) {
	doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}}
	locations := registryLocations("The Tavern", "The Tavern")
	first, second := locations.Locations[0], locations.Locations[1]
	ref := func(unit int) source.SourceRef {
		return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
	}
	firstMention := dnd.LocationOccurrence{LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{ref(50)}}
	input := dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{
		{LocationID: second.ID, Name: second.Name, Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{ref(90)}},
		firstMention,
		firstMention,
		{LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: []source.SourceRef{ref(50)}},
		{LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindPlanned, SourceRefs: []source.SourceRef{ref(50)}},
		{LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindRecalled, SourceRefs: []source.SourceRef{ref(50)}},
		{LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{ref(10)}},
		{LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{ref(999)}},
	}}
	result, err := newNormalizer(t, registryReferences(t, locations)).Normalize(context.Background(), normalizeRequest(input, doc, contracts.ReferenceSet{}))
	if err != nil {
		t.Fatal(err)
	}
	got := result.Value.Occurrences
	if len(got) != 7 || got[0].Kind != dnd.LocationOccurrenceKindVisited || got[1].Kind != dnd.LocationOccurrenceKindPlanned || got[2].Kind != dnd.LocationOccurrenceKindRecalled || got[3].Kind != dnd.LocationOccurrenceKindMentioned || got[3].SourceRefs[0].StartUnitID != 50 || got[4].SourceRefs[0].StartUnitID != 10 || got[5].LocationID != second.ID || got[6].SourceRefs[0].StartUnitID != 999 {
		t.Fatalf("canonical occurrences = %#v", got)
	}
	if !hasDiagnostic(result.Diagnostics, ReasonCodeDuplicateCollapsed, contracts.DiagnosticDispositionObservation) || !hasDiagnostic(result.Diagnostics, ReasonCodeOccurrencesReordered, contracts.DiagnosticDispositionObservation) {
		t.Fatalf("diagnostics = %#v", result.Diagnostics)
	}
}

func TestNormalizePreservesUnknownIDsAndMalformedOperationRegistry(t *testing.T) {
	doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}}}
	locations := registryLocations("The Mill")
	unknown := dnd.LocationOccurrence{LocationID: "location:sha256:unknown", Name: "The Mill", Kind: "unexpected", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}
	result, err := newNormalizer(t, registryReferences(t, locations)).Normalize(context.Background(), normalizeRequest(dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{unknown}}, doc, contracts.ReferenceSet{}))
	if err != nil || !reflect.DeepEqual(result.Value.Occurrences[0], unknown) || !hasDiagnostic(result.Diagnostics, ReasonCodeUnknownLocationID, contracts.DiagnosticDispositionAdvisory) {
		t.Fatalf("unknown normalization = %#v, %v", result, err)
	}
	malformed := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{LocationRegistryReferenceSlot: {
		Items: []contracts.ReferenceItem{{SlotName: LocationRegistryReferenceSlot, MediaType: "application/json", Content: []byte(`{"private":"registry evidence"}`)}},
	}}}
	if _, err := newNormalizer(t).Normalize(context.Background(), normalizeRequest(dnd.LocationOccurrenceList{}, doc, malformed)); err == nil || !strings.Contains(err.Error(), "resolve location registry") || strings.Contains(err.Error(), "registry evidence") {
		t.Fatalf("operation registry error = %v", err)
	}
}

func TestNormalizerContractsRequiredRegistryAndFindingBounds(t *testing.T) {
	if _, err := New(Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") {
		t.Fatalf("New() error = %v", err)
	}
	normalizer := newNormalizer(t, registryReferences(t, registryLocations("The Mill")))
	spec := ModuleSpec()
	if spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ExecutionClass != contracts.ExecutionClassDeterministic || spec.ArtifactKind != dnd.LocationOccurrenceListKind {
		t.Fatalf("ModuleSpec() = %#v", spec)
	}
	assertLocationRegistryReferenceSlots(t, "ModuleSpec", spec.ReferenceSlots)
	assertLocationRegistryReferenceSlots(t, "Normalizer", normalizer.ReferenceSlots())
	registry := pipeline.NewNormalizerRegistry()
	if err := Register(registry); err != nil {
		t.Fatal(err)
	}
	registeredSpec, ok := registry.Spec(Key)
	if !ok {
		t.Fatalf("registry missing %q", Key)
	}
	assertLocationRegistryReferenceSlots(t, "registered ModuleSpec", registeredSpec.ReferenceSlots)
	if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
		t.Fatal("DecodeOptions() accepted unknown options")
	}
	if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["location_registry_digest"] == "" || metadata["location_count"] != 1 {
		t.Fatalf("metadata = %#v", metadata)
	}
	if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 2 || fingerprints[1].Name != "location_registry" || fingerprints[1].Value == "" {
		t.Fatalf("fingerprints = %#v", fingerprints)
	}
	if _, err := newNormalizer(t).Normalize(context.Background(), normalizeRequest(dnd.LocationOccurrenceList{}, nil, contracts.ReferenceSet{})); err == nil || !strings.Contains(err.Error(), "required") {
		t.Fatalf("unbound registry error = %v", err)
	}

	count := contracts.MaxDiagnosticSamples + 5
	doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
	input := dnd.LocationOccurrenceList{Occurrences: make([]dnd.LocationOccurrence, count)}
	location := registryLocations("The Mill").Locations[0]
	for index := range doc.Units {
		doc.Units[index].ID = index + 1
		input.Occurrences[index] = dnd.LocationOccurrence{LocationID: location.ID, Name: "not canonical", Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: count - index, EndUnitID: count - index}}}
	}
	bounded, err := newNormalizer(t, registryReferences(t, dnd.LocationRegistry{Locations: []dnd.Location{location}})).Normalize(context.Background(), normalizeRequest(input, doc, contracts.ReferenceSet{}))
	if err != nil || len(bounded.Diagnostics) == 0 {
		t.Fatalf("bounded diagnostics = %#v, %v", bounded.Diagnostics, err)
	}
}

func assertLocationRegistryReferenceSlots(t *testing.T, owner string, slots []contracts.ReferenceSlot) {
	t.Helper()
	if len(slots) != 1 {
		t.Fatalf("%s reference slots = %#v, want one location registry", owner, slots)
	}
	got := slots[0]
	description := got.Description
	got.Description = ""
	want := contracts.ReferenceSlot{
		Name:                  LocationRegistryReferenceSlot,
		Required:              true,
		AcceptedMediaTypes:    []string{"application/json"},
		AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.LocationRegistryKind},
		MaxBytes:              LocationRegistryMaxBytes,
	}
	if !reflect.DeepEqual(got, want) || strings.TrimSpace(description) == "" {
		t.Fatalf("%s location registry slot = %#v, want contract %#v with a nonempty description", owner, slots[0], want)
	}
}

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

func normalizeRequest(value dnd.LocationOccurrenceList, doc *source.SourceDocument, references contracts.ReferenceSet) contracts.TypedNormalizeRequest[dnd.LocationOccurrenceList] {
	return contracts.TypedNormalizeRequest[dnd.LocationOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.LocationOccurrenceList]{Value: value}, References: references}
}

func registryLocations(names ...string) dnd.LocationRegistry {
	locations := make([]dnd.Location, len(names))
	for index, name := range names {
		refs := []source.SourceRef{{SourceID: "registry", StartUnitID: index + 1, EndUnitID: index + 1}}
		locations[index] = dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs}
	}
	return dnd.LocationRegistry{Locations: locations}
}

func registryReferences(t *testing.T, locations dnd.LocationRegistry) contracts.ReferenceSet {
	t.Helper()
	content, err := locationcodec.New().Encode(locations)
	if err != nil {
		t.Fatal(err)
	}
	return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{LocationRegistryReferenceSlot: {
		Items: []contracts.ReferenceItem{{SlotName: LocationRegistryReferenceSlot, MediaType: locationcodec.MediaType, Content: content}},
	}}}
}

func cloneList(input dnd.LocationOccurrenceList) dnd.LocationOccurrenceList {
	output := dnd.LocationOccurrenceList{Occurrences: make([]dnd.LocationOccurrence, len(input.Occurrences))}
	for index, occurrence := range input.Occurrences {
		output.Occurrences[index] = occurrence
		output.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), occurrence.SourceRefs...)
	}
	if input.Occurrences == nil {
		output.Occurrences = nil
	}
	return output
}

func hasDiagnostic(diagnostics []contracts.ProducerDiagnostic, code string, disposition contracts.DiagnosticDisposition) bool {
	for _, diagnostic := range diagnostics {
		if diagnostic.ReasonCode == code && diagnostic.Disposition == disposition {
			return true
		}
	}
	return false
}
