package pipeline

import (
	"context"
	"fmt"
	"reflect"
	"strings"
	"testing"

	"gitea.maximumdirect.net/eric/notarius/internal/core/source"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)

type typedTestExtractor[T any] struct{ key string }

func (e typedTestExtractor[T]) Key() string                             { return e.key }
func (typedTestExtractor[T]) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (typedTestExtractor[T]) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[T], error) {
	return contracts.TypedExtractionResult[T]{}, nil
}

type typedTestMerger[T any] struct{ key string }

func (m typedTestMerger[T]) Key() string { return m.key }
func (typedTestMerger[T]) Merge(context.Context, contracts.TypedMergeRequest[T]) (contracts.TypedMergeResult[T], error) {
	return contracts.TypedMergeResult[T]{}, nil
}

type typedTestNormalizer[T any] struct{ key string }

func (n typedTestNormalizer[T]) Key() string                             { return n.key }
func (typedTestNormalizer[T]) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (typedTestNormalizer[T]) Normalize(context.Context, contracts.TypedNormalizeRequest[T]) (contracts.TypedNormalizeResult[T], error) {
	return contracts.TypedNormalizeResult[T]{}, nil
}

type typedTestValidator[T any] struct{ key string }

func (v typedTestValidator[T]) Name() string { return v.key }
func (typedTestValidator[T]) ExecutionClass() contracts.ExecutionClass {
	return contracts.ExecutionClassDeterministic
}
func (typedTestValidator[T]) Validate(context.Context, contracts.TypedValidationRequest[T]) (contracts.ValidationResult, error) {
	return contracts.ValidationResult{Approved: true}, nil
}

type typedTestChunkValidator struct{ key string }

func (v typedTestChunkValidator) Name() string { return v.key }
func (typedTestChunkValidator) ExecutionClass() contracts.ExecutionClass {
	return contracts.ExecutionClassDeterministic
}
func (typedTestChunkValidator) Validate(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
	return contracts.ValidationResult{Approved: true}, nil
}

type typedTestSerializedValidator struct{ key string }

type typedTestInput struct {
	key string
	doc *source.SourceDocument
}

func (v *typedTestInput) Key() string { return v.key }
func (v *typedTestInput) Parse(context.Context, contracts.ParseRequest) (*source.SourceDocument, error) {
	return v.doc, nil
}

type typedTestChunker struct {
	key  string
	plan source.ChunkPlan
}

func (v *typedTestChunker) Key() string                               { return v.key }
func (v *typedTestChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (v *typedTestChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
	return contracts.ChunkPlanResult{Plan: source.CloneChunkPlan(v.plan)}, nil
}

type typedTestOutput struct{ key string }

func (v *typedTestOutput) Key() string { return v.key }
func (v *typedTestOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
	return contracts.OutputResult{}, nil
}
func typedTestDocument() *source.SourceDocument {
	return typedTestDocumentWithUnits(1)
}

func typedTestDocumentWithUnits(count int) *source.SourceDocument {
	doc := &source.SourceDocument{ID: "source", Kind: "document", Format: "text/plain"}
	for id := 1; id <= count; id++ {
		ref := source.SourceRef{SourceID: doc.ID, StartUnitID: id, EndUnitID: id}
		doc.Units = append(doc.Units, source.SourceUnit{ID: id, Kind: "line", Text: fmt.Sprintf("text-%d", id), Ref: ref})
	}
	doc.Digest, _ = source.DigestDocument(doc)
	return doc
}

func typedTestPlan(doc *source.SourceDocument) source.ChunkPlan {
	plan := source.ChunkPlan{SourceDigest: doc.Digest, Ranges: make([]source.ChunkRange, len(doc.Units))}
	for i, unit := range doc.Units {
		plan.Ranges[i] = source.ChunkRange{StartUnitID: unit.ID, EndUnitID: unit.ID}
	}
	return plan
}

func (v typedTestSerializedValidator) Name() string { return v.key }
func (typedTestSerializedValidator) ExecutionClass() contracts.ExecutionClass {
	return contracts.ExecutionClassDeterministic
}
func (typedTestSerializedValidator) Validate(context.Context, contracts.SerializedValidationRequest) (contracts.ValidationResult, error) {
	return contracts.ValidationResult{Approved: true}, nil
}

type typedCatalogOptions struct {
	registerNotesCodec      bool
	registerScoreCodec      bool
	registerNotesMerger     bool
	registerScoreMerger     bool
	registerNotesNormalizer bool
	registerScoreNormalizer bool
	registerScoreValidator  bool
	scoreExtractorUsesNotes bool
	notesCodec              testArtifactCodec[codecNotes]
	scoreCodec              testArtifactCodec[codecScore]
}

func completeTypedCatalogOptions() typedCatalogOptions {
	return typedCatalogOptions{
		registerNotesCodec:      true,
		registerScoreCodec:      true,
		registerNotesMerger:     true,
		registerScoreMerger:     true,
		registerNotesNormalizer: true,
		registerScoreNormalizer: true,
		registerScoreValidator:  true,
		notesCodec:              notesCodec(),
		scoreCodec:              scoreCodec(),
	}
}

func TestResolveTypedHeterogeneousLanes(t *testing.T) {
	catalog := typedResolutionCatalog(t, completeTypedCatalogOptions())
	resolved, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	if got, want := resolvedLaneIDs(resolved.Steps[0].ArtifactLanes), []string{"notes", "score"}; !reflect.DeepEqual(got, want) {
		t.Fatalf("lane order = %#v, want %#v", got, want)
	}
	assertResolvedArtifactIdentity(t, resolved.Steps[0].ArtifactLanes[0], "test/notes", "notes.v1")
	assertResolvedArtifactIdentity(t, resolved.Steps[0].ArtifactLanes[1], "test/score", "score.v1")

	chunkChain := resolved.ValidatorChains[0]
	if got := resolvedValidatorTargets(chunkChain.Validators); !reflect.DeepEqual(got, []ValidatorTarget{ValidatorTargetChunk, ValidatorTargetSerialized}) {
		t.Fatalf("chunk validator targets = %#v, want chunk then serialized", got)
	}
	notesExtract := resolved.ValidatorChains[1]
	if got := resolvedValidatorTargets(notesExtract.Validators); !reflect.DeepEqual(got, []ValidatorTarget{ValidatorTargetTyped, ValidatorTargetSerialized}) {
		t.Fatalf("notes extract validator targets = %#v, want typed then serialized", got)
	}
	if notesExtract.Validators[0].ArtifactKind != "test/notes" || resolved.ValidatorChains[4].Validators[0].ArtifactKind != "test/score" {
		t.Fatalf("resolved validator kinds = %#v, want lane kinds", resolved.ValidatorChains)
	}
}

func TestPrepareConstructsHeterogeneousTypedLanes(t *testing.T) {
	catalog := typedResolutionCatalog(t, completeTypedCatalogOptions())
	resolved, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	prepared, err := Prepare(resolved, registriesFromModuleCatalog(catalog), ModuleDependencies{})
	if err != nil {
		t.Fatalf("Prepare() error = %v, want nil", err)
	}
	if len(prepared.Steps[0].ArtifactLanes) != 2 || prepared.Steps[0].lanes[0].typed == nil || prepared.Steps[0].lanes[1].typed == nil {
		t.Fatalf("PreparedPipeline lanes = %#v, want two typed executors", prepared.Steps[0].ArtifactLanes)
	}
}

func TestRunExecutesHeterogeneousTypedLanesWithCheckpointsDisabled(t *testing.T) {
	catalog := typedResolutionCatalog(t, completeTypedCatalogOptions())
	resolved, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v", err)
	}
	prepared, err := Prepare(resolved, registriesFromModuleCatalog(catalog), ModuleDependencies{})
	if err != nil {
		t.Fatalf("Prepare() error = %v", err)
	}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte(`{"source":true}`), RunID: "typed-run"})
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if len(output.NormalizeOutputs) != 2 {
		t.Fatalf("normalize outputs = %#v, want two typed lane results", output.NormalizeOutputs)
	}
	if len(output.CheckpointEvents) != 0 {
		t.Fatalf("checkpoint events = %#v, want none with checkpoint loading disabled", output.CheckpointEvents)
	}
}

func TestResolveTypedLaneRejectsIncompatibleComposition(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*typedCatalogOptions)
		want   string
	}{
		{name: "missing codec", mutate: func(options *typedCatalogOptions) { options.registerScoreCodec = false }, want: `artifact codec "test/score" is not registered`},
		{name: "missing merger variant", mutate: func(options *typedCatalogOptions) { options.registerScoreMerger = false }, want: `merger "typed/merge" has no typed variant for artifact kind "test/score"`},
		{name: "missing normalizer variant", mutate: func(options *typedCatalogOptions) { options.registerScoreNormalizer = false }, want: `normalizer "typed/normalize" has no typed variant for artifact kind "test/score"`},
		{name: "extractor Go type mismatch", mutate: func(options *typedCatalogOptions) { options.scoreExtractorUsesNotes = true }, want: `artifact kind "test/score" requires Go type pipeline.codecScore, got pipeline.codecNotes`},
		{name: "wrong validator kind", mutate: func(options *typedCatalogOptions) { options.registerScoreValidator = false }, want: `validator "typed/check" has no typed variant for artifact kind "test/score"; registered kinds: test/notes`},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			options := completeTypedCatalogOptions()
			test.mutate(&options)
			_, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, typedResolutionCatalog(t, options))
			if err == nil || !strings.Contains(err.Error(), test.want) {
				t.Fatalf("ResolvePipeline() error = %v, want %q", err, test.want)
			}
		})
	}
}

func TestTypedVariantRegistrationRejectsDuplicates(t *testing.T) {
	registry := NewMergerRegistry()
	spec := ModuleSpec{Key: "typed/merge", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes"}
	constructor := func() (contracts.Merger[codecNotes], error) {
		return typedTestMerger[codecNotes]{key: "typed/merge"}, nil
	}
	if err := RegisterMerger(registry, spec, constructor); err != nil {
		t.Fatalf("RegisterMerger() error = %v, want nil", err)
	}
	if err := RegisterMerger(registry, spec, constructor); err == nil || !strings.Contains(err.Error(), "already registered") {
		t.Fatalf("duplicate RegisterMerger() error = %v, want duplicate variant error", err)
	}
}

func TestConstructorRegistrationsRejectUnconfiguredOptions(t *testing.T) {
	extractors := NewExtractorRegistry()
	if err := RegisterExtractor(extractors, ModuleSpec{Key: "typed/extract", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes"}, func() (contracts.Extractor[codecNotes], error) {
		return typedTestExtractor[codecNotes]{key: "typed/extract"}, nil
	}); err != nil {
		t.Fatalf("RegisterExtractor() error = %v", err)
	}
	if err := extractors.validateOptions("typed/extract", map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
		t.Fatalf("extractor option validation error = %v, want unknown option", err)
	}

	mergers := NewMergerRegistry()
	if err := RegisterMerger(mergers, ModuleSpec{Key: "typed/merge", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes"}, func() (contracts.Merger[codecNotes], error) {
		return typedTestMerger[codecNotes]{key: "typed/merge"}, nil
	}); err != nil {
		t.Fatalf("RegisterMerger() error = %v", err)
	}
	if err := mergers.validateOptions("typed/merge", "test/notes", map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
		t.Fatalf("merger option validation error = %v, want unknown option", err)
	}

	normalizers := NewNormalizerRegistry()
	if err := RegisterNormalizer(normalizers, ModuleSpec{Key: "typed/normalize", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes"}, func() (contracts.Normalizer[codecNotes], error) {
		return typedTestNormalizer[codecNotes]{key: "typed/normalize"}, nil
	}); err != nil {
		t.Fatalf("RegisterNormalizer() error = %v", err)
	}
	if err := normalizers.validateOptions("typed/normalize", "test/notes", map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
		t.Fatalf("normalizer option validation error = %v, want unknown option", err)
	}

	validators := NewValidatorRegistry()
	if err := RegisterTypedValidator(validators, "test/notes", ValidatorSpec{Key: "typed/check", ExecutionClass: contracts.ExecutionClassDeterministic}, func() (contracts.TypedValidator[codecNotes], error) {
		return typedTestValidator[codecNotes]{key: "typed/check"}, nil
	}); err != nil {
		t.Fatalf("RegisterTypedValidator() error = %v", err)
	}
	if err := validators.validateOptions(ResolvedValidator{Binding: ModuleBinding{Module: "typed/check", Options: map[string]any{"unexpected": true}}, Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}); err == nil || !strings.Contains(err.Error(), "unknown option") {
		t.Fatalf("validator option validation error = %v, want unknown option", err)
	}
}

func TestTypedRegistrySpecLookupUsesArtifactKindAndStableCatalogOrder(t *testing.T) {
	type registration struct {
		kind contracts.ArtifactKind
		slot string
	}
	orders := [][]registration{
		{{kind: "test/score", slot: "score_notes"}, {kind: "test/notes", slot: "note_notes"}},
		{{kind: "test/notes", slot: "note_notes"}, {kind: "test/score", slot: "score_notes"}},
	}

	for orderIndex, order := range orders {
		t.Run(fmt.Sprintf("registration order %d", orderIndex+1), func(t *testing.T) {
			mergers := NewMergerRegistry()
			normalizers := NewNormalizerRegistry()
			for _, item := range order {
				base := ModuleSpec{Key: "typed/shared", ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: item.kind, ReferenceSlots: []contracts.ReferenceSlot{{Name: item.slot}}}
				mergeSpec := base
				mergeSpec.Stage = StageMerge
				normalizeSpec := base
				normalizeSpec.Stage = StageNormalize
				switch item.kind {
				case "test/notes":
					if err := RegisterMerger(mergers, mergeSpec, func() (contracts.Merger[codecNotes], error) {
						return typedTestMerger[codecNotes]{key: "typed/shared"}, nil
					}); err != nil {
						t.Fatalf("RegisterMerger(notes): %v", err)
					}
					if err := RegisterNormalizer(normalizers, normalizeSpec, func() (contracts.Normalizer[codecNotes], error) {
						return typedTestNormalizer[codecNotes]{key: "typed/shared"}, nil
					}); err != nil {
						t.Fatalf("RegisterNormalizer(notes): %v", err)
					}
				case "test/score":
					if err := RegisterMerger(mergers, mergeSpec, func() (contracts.Merger[codecScore], error) {
						return typedTestMerger[codecScore]{key: "typed/shared"}, nil
					}); err != nil {
						t.Fatalf("RegisterMerger(score): %v", err)
					}
					if err := RegisterNormalizer(normalizers, normalizeSpec, func() (contracts.Normalizer[codecScore], error) {
						return typedTestNormalizer[codecScore]{key: "typed/shared"}, nil
					}); err != nil {
						t.Fatalf("RegisterNormalizer(score): %v", err)
					}
				}
			}

			for name, lookup := range map[string]func(contracts.ArtifactKind) (ModuleSpec, bool){
				"merger": func(kind contracts.ArtifactKind) (ModuleSpec, bool) {
					return mergers.SpecForArtifact(" typed/shared ", kind)
				},
				"normalizer": func(kind contracts.ArtifactKind) (ModuleSpec, bool) {
					return normalizers.SpecForArtifact(" typed/shared ", kind)
				},
			} {
				t.Run(name, func(t *testing.T) {
					notes, ok := lookup(" test/notes ")
					if !ok || len(notes.ReferenceSlots) != 1 || notes.ReferenceSlots[0].Name != "note_notes" {
						t.Fatalf("notes spec = %#v, ok = %v", notes, ok)
					}
					score, ok := lookup("test/score")
					if !ok || len(score.ReferenceSlots) != 1 || score.ReferenceSlots[0].Name != "score_notes" {
						t.Fatalf("score spec = %#v, ok = %v", score, ok)
					}
					notes.ReferenceSlots[0].Name = "mutated"
					again, _ := lookup("test/notes")
					if again.ReferenceSlots[0].Name != "note_notes" {
						t.Fatalf("registry spec changed after caller mutation: %#v", again)
					}
				})
			}

			for name, lookup := range map[string]func() (ModuleSpec, bool){
				"merger":     func() (ModuleSpec, bool) { return mergers.Spec("typed/shared") },
				"normalizer": func() (ModuleSpec, bool) { return normalizers.Spec("typed/shared") },
			} {
				t.Run(name+" catalog", func(t *testing.T) {
					for attempt := 0; attempt < 10; attempt++ {
						spec, ok := lookup()
						if !ok || spec.ArtifactKind != "test/notes" || spec.ReferenceSlots[0].Name != "note_notes" {
							t.Fatalf("Spec() = %#v, ok = %v; want lexicographically first artifact kind", spec, ok)
						}
					}
				})
			}

			if got := mergers.RegisteredArtifactKinds("typed/shared"); !reflect.DeepEqual(got, []contracts.ArtifactKind{"test/notes", "test/score"}) {
				t.Fatalf("merger kinds = %#v", got)
			}
			if got := normalizers.RegisteredArtifactKinds("typed/shared"); !reflect.DeepEqual(got, []contracts.ArtifactKind{"test/notes", "test/score"}) {
				t.Fatalf("normalizer kinds = %#v", got)
			}
		})
	}
}

func TestResolvedPipelineDigestIncludesArtifactSchemaIdentity(t *testing.T) {
	baseOptions := completeTypedCatalogOptions()
	base, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, typedResolutionCatalog(t, baseOptions))
	if err != nil {
		t.Fatalf("ResolvePipeline(base) error = %v, want nil", err)
	}

	identityOptions := completeTypedCatalogOptions()
	identityOptions.notesCodec.schema.ID = "notes-renamed.v1"
	identity, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, typedResolutionCatalog(t, identityOptions))
	if err != nil {
		t.Fatalf("ResolvePipeline(identity) error = %v, want nil", err)
	}
	if base.Digest == identity.Digest {
		t.Fatalf("pipeline digest = %q after schema identity change, want different digest", identity.Digest)
	}

	digestOptions := completeTypedCatalogOptions()
	digestOptions.notesCodec.schema.JSONSchema = []byte(`{"additionalProperties":false,"description":"changed","properties":{"items":{"items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"}`)
	changedSchema, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, typedResolutionCatalog(t, digestOptions))
	if err != nil {
		t.Fatalf("ResolvePipeline(schema bytes) error = %v, want nil", err)
	}
	if base.Digest == changedSchema.Digest {
		t.Fatalf("pipeline digest = %q after schema digest change, want different digest", changedSchema.Digest)
	}
}

func typedResolutionCatalog(t *testing.T, options typedCatalogOptions) ModuleCatalog {
	t.Helper()
	catalog := ModuleCatalog{
		Inputs:          NewInputAdapterRegistry(),
		Chunkers:        NewChunkerRegistry(),
		ArtifactCodecs:  NewArtifactCodecRegistry(),
		Extractors:      NewExtractorRegistry(),
		Mergers:         NewMergerRegistry(),
		Normalizers:     NewNormalizerRegistry(),
		Validators:      NewValidatorRegistry(),
		ValidatorChains: NewValidatorChainRegistry(),
		Outputs:         NewOutputEncoderRegistry(),
	}
	mustRegisterTypedTestBase(t, catalog)
	if options.registerNotesCodec {
		mustRegisterArtifactCodec(t, catalog.ArtifactCodecs, options.notesCodec)
	}
	if options.registerScoreCodec {
		mustRegisterArtifactCodec(t, catalog.ArtifactCodecs, options.scoreCodec)
	}
	mustRegisterTypedExtractor(t, catalog.Extractors, "typed/extract-notes", "test/notes", typedTestExtractor[codecNotes]{key: "typed/extract-notes"})
	if options.scoreExtractorUsesNotes {
		mustRegisterTypedExtractor(t, catalog.Extractors, "typed/extract-score", "test/score", typedTestExtractor[codecNotes]{key: "typed/extract-score"})
	} else {
		mustRegisterTypedExtractor(t, catalog.Extractors, "typed/extract-score", "test/score", typedTestExtractor[codecScore]{key: "typed/extract-score"})
	}
	if options.registerNotesMerger {
		mustRegisterTypedMerger(t, catalog.Mergers, "test/notes", typedTestMerger[codecNotes]{key: "typed/merge"})
	}
	if options.registerScoreMerger {
		mustRegisterTypedMerger(t, catalog.Mergers, "test/score", typedTestMerger[codecScore]{key: "typed/merge"})
	}
	if options.registerNotesNormalizer {
		mustRegisterTypedNormalizer(t, catalog.Normalizers, "test/notes", typedTestNormalizer[codecNotes]{key: "typed/normalize"})
	}
	if options.registerScoreNormalizer {
		mustRegisterTypedNormalizer(t, catalog.Normalizers, "test/score", typedTestNormalizer[codecScore]{key: "typed/normalize"})
	}
	mustRegisterTypedValidator(t, catalog.Validators, "test/notes", typedTestValidator[codecNotes]{key: "typed/check"})
	if options.registerScoreValidator {
		mustRegisterTypedValidator(t, catalog.Validators, "test/score", typedTestValidator[codecScore]{key: "typed/check"})
	}
	if err := RegisterChunkValidator(catalog.Validators, ValidatorSpec{Key: "chunk/check", ExecutionClass: contracts.ExecutionClassDeterministic}, func() (contracts.ChunkValidator, error) {
		return typedTestChunkValidator{key: "chunk/check"}, nil
	}); err != nil {
		t.Fatalf("RegisterChunkValidator() error = %v", err)
	}
	if err := RegisterSerializedValidator(catalog.Validators, SerializedValidatorSpec{
		ValidatorSpec:     ValidatorSpec{Key: "serialized/check", ExecutionClass: contracts.ExecutionClassDeterministic},
		SupportsArtifacts: true,
	}, func() (contracts.SerializedValidator, error) {
		return typedTestSerializedValidator{key: "serialized/check"}, nil
	}); err != nil {
		t.Fatalf("RegisterSerializedValidator() error = %v", err)
	}
	if err := RegisterSerializedValidator(catalog.Validators, SerializedValidatorSpec{
		ValidatorSpec:  ValidatorSpec{Key: "serialized/chunks", ExecutionClass: contracts.ExecutionClassDeterministic},
		SupportsChunks: true,
	}, func() (contracts.SerializedValidator, error) {
		return typedTestSerializedValidator{key: "serialized/chunks"}, nil
	}); err != nil {
		t.Fatalf("RegisterSerializedValidator(chunks) error = %v", err)
	}
	return catalog
}

func mustRegisterTypedTestBase(t *testing.T, catalog ModuleCatalog) {
	t.Helper()
	if err := catalog.Inputs.RegisterWithSpec(ModuleSpec{Key: "typed/input", Stage: StageInput, ExecutionClass: contracts.ExecutionClassDeterministic}, func() (contracts.InputAdapter, error) {
		return &typedTestInput{key: "typed/input", doc: typedTestDocument()}, nil
	}); err != nil {
		t.Fatalf("register input: %v", err)
	}
	if err := catalog.Chunkers.RegisterWithSpec(ModuleSpec{Key: "typed/chunk", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassDeterministic}, func() (contracts.Chunker, error) {
		doc := typedTestDocument()
		return &typedTestChunker{key: "typed/chunk", plan: typedTestPlan(doc)}, nil
	}); err != nil {
		t.Fatalf("register chunker: %v", err)
	}
	if err := catalog.Outputs.RegisterWithSpec(ModuleSpec{Key: "typed/output", Stage: StageOutput, ExecutionClass: contracts.ExecutionClassDeterministic}, func() (contracts.OutputEncoder, error) {
		return &typedTestOutput{key: "typed/output"}, nil
	}); err != nil {
		t.Fatalf("register output: %v", err)
	}
}

func registriesFromModuleCatalog(catalog ModuleCatalog) Registries {
	return Registries{
		Inputs: catalog.Inputs, Chunkers: catalog.Chunkers, ArtifactCodecs: catalog.ArtifactCodecs,
		Extractors: catalog.Extractors, Mergers: catalog.Mergers, Normalizers: catalog.Normalizers,
		Validators: catalog.Validators, ValidatorChains: catalog.ValidatorChains, Outputs: catalog.Outputs,
	}
}

func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.CandidateArtifactCodec[T]) {
	t.Helper()
	if err := RegisterArtifactCodec(registry, codec); err != nil {
		t.Fatalf("RegisterArtifactCodec() error = %v", err)
	}
}

func mustRegisterTypedExtractor[T any](t *testing.T, registry *ExtractorRegistry, key string, kind contracts.ArtifactKind, extractor contracts.Extractor[T]) {
	t.Helper()
	if err := RegisterExtractor(registry, ModuleSpec{Key: key, Stage: StageExtract, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: kind}, func() (contracts.Extractor[T], error) { return extractor, nil }); err != nil {
		t.Fatalf("RegisterExtractor() error = %v", err)
	}
}

func mustRegisterTypedMerger[T any](t *testing.T, registry *MergerRegistry, kind contracts.ArtifactKind, merger contracts.Merger[T]) {
	t.Helper()
	if err := RegisterMerger(registry, ModuleSpec{Key: "typed/merge", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: kind}, func() (contracts.Merger[T], error) { return merger, nil }); err != nil {
		t.Fatalf("RegisterMerger() error = %v", err)
	}
}

func mustRegisterTypedNormalizer[T any](t *testing.T, registry *NormalizerRegistry, kind contracts.ArtifactKind, normalizer contracts.Normalizer[T]) {
	t.Helper()
	if err := RegisterNormalizer(registry, ModuleSpec{Key: "typed/normalize", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: kind}, func() (contracts.Normalizer[T], error) { return normalizer, nil }); err != nil {
		t.Fatalf("RegisterNormalizer() error = %v", err)
	}
}

func mustRegisterTypedValidator[T any](t *testing.T, registry *ValidatorRegistry, kind contracts.ArtifactKind, validator contracts.TypedValidator[T]) {
	t.Helper()
	if err := RegisterTypedValidator(registry, kind, ValidatorSpec{Key: "typed/check", ExecutionClass: contracts.ExecutionClassDeterministic}, func() (contracts.TypedValidator[T], error) { return validator, nil }); err != nil {
		t.Fatalf("RegisterTypedValidator() error = %v", err)
	}
}

func typedResolutionProfile() PipelineProfile {
	validatorOverride := ValidatorOverride{Set: true, Validators: []ModuleBinding{Binding("typed/check"), Binding("serialized/check")}}
	return PipelineProfile{
		ID:    "typed-pipeline",
		Input: Binding("typed/input"),
		Chunk: ModuleBinding{Module: "typed/chunk", Validators: ValidatorOverride{Set: true, Validators: []ModuleBinding{Binding("chunk/check"), Binding("serialized/chunks")}}},
		Artifacts: map[string]ArtifactLaneProfile{
			"score": typedLaneProfile("typed/extract-score", validatorOverride),
			"notes": typedLaneProfile("typed/extract-notes", validatorOverride),
		},
		Output: Binding("typed/output"),
	}
}

func typedLaneProfile(extractor string, validators ValidatorOverride) ArtifactLaneProfile {
	return ArtifactLaneProfile{
		Extract:   ModuleBinding{Module: extractor, Validators: validators},
		Merge:     Binding("typed/merge"),
		Normalize: Binding("typed/normalize"),
	}
}

func assertResolvedArtifactIdentity(t *testing.T, lane ResolvedArtifactLane, kind contracts.ArtifactKind, schemaID string) {
	t.Helper()
	if lane.ArtifactKind != kind || lane.ArtifactSchemaID != schemaID || lane.ArtifactSchemaName == "" || lane.ArtifactSchemaVersion == "" || lane.ArtifactSchemaDigest == "" {
		t.Fatalf("resolved lane identity = %#v, want kind %q schema %q with complete metadata", lane, kind, schemaID)
	}
}

func resolvedLaneIDs(lanes []ResolvedArtifactLane) []string {
	ids := make([]string, len(lanes))
	for i, lane := range lanes {
		ids[i] = lane.ID
	}
	return ids
}

func resolvedValidatorTargets(validators []ResolvedValidator) []ValidatorTarget {
	targets := make([]ValidatorTarget, len(validators))
	for i, validator := range validators {
		targets[i] = validator.Target
	}
	return targets
}

var _ contracts.Extractor[codecNotes] = typedTestExtractor[codecNotes]{}
var _ contracts.Merger[codecNotes] = typedTestMerger[codecNotes]{}
var _ contracts.Normalizer[codecNotes] = typedTestNormalizer[codecNotes]{}
var _ contracts.TypedValidator[codecNotes] = typedTestValidator[codecNotes]{}
var _ contracts.ChunkValidator = typedTestChunkValidator{}
var _ contracts.SerializedValidator = typedTestSerializedValidator{}
