package pipeline

import (
	"context"
	"errors"
	"reflect"
	"strings"
	"testing"

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

func TestResolvePipelineValidatesModuleAndValidatorOptions(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*PipelineProfile)
		want   []string
	}{
		{
			name: "module",
			mutate: func(profile *PipelineProfile) {
				profile.Input.Options = map[string]any{"surprise": true}
			},
			want: []string{`pipeline "construction" input module "input" options`, `unknown option "surprise"`},
		},
		{
			name: "validator",
			mutate: func(profile *PipelineProfile) {
				profile.Chunk.Validators = ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured", Options: map[string]any{"surprise": true}}}}
			},
			want: []string{`pipeline "construction" chunk module "chunk" validator "configured" options`, `unknown option "surprise"`},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			registries, _ := constructionRegistries(t, nil, nil)
			profile := constructionProfile()
			test.mutate(&profile)
			_, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
			if err == nil {
				t.Fatal("ResolvePipeline() error = nil, want option validation error")
			}
			for _, want := range test.want {
				if !strings.Contains(err.Error(), want) {
					t.Fatalf("ResolvePipeline() error = %q, want substring %q", err, want)
				}
			}
		})
	}
}

func TestPrepareConstructsEverythingInStableOrder(t *testing.T) {
	var built []string
	registries, _ := constructionRegistries(t, &built, nil)
	resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	prepared, err := Prepare(resolved, registries, ModuleDependencies{})
	if err != nil {
		t.Fatalf("Prepare() error = %v, want nil", err)
	}
	want := []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"}
	if !reflect.DeepEqual(built, want) {
		t.Fatalf("construction order = %#v, want %#v", built, want)
	}
	if prepared.Input.Module != "input" || prepared.Chunk.Module != "chunk" || prepared.Output.Module != "output" || len(prepared.Steps) != 1 || len(prepared.Steps[0].ArtifactLanes) != 1 {
		t.Fatalf("PreparedPipeline = %#v, want explicit resolved components", prepared)
	}
}

func TestPrepareBuildsEveryOrderedStepBeforeExecution(t *testing.T) {
	var built []string
	registries, input := constructionRegistries(t, &built, nil)
	profile := constructionProfile()
	lane := profile.Artifacts["artifact"]
	profile.Artifacts = nil
	profile.Steps = []PipelineStepProfile{
		{ID: "first", Artifacts: map[string]ArtifactLaneProfile{"first-artifact": lane}},
		{ID: "second", Artifacts: map[string]ArtifactLaneProfile{"second-artifact": lane}},
	}
	resolved, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	prepared, err := Prepare(resolved, registries, ModuleDependencies{})
	if err != nil {
		t.Fatalf("Prepare() error = %v, want nil", err)
	}
	want := []string{
		"input", "chunk", "validator",
		"extract", "validator", "merge", "validator", "normalize", "validator",
		"extract", "validator", "merge", "validator", "normalize", "validator",
		"output",
	}
	if !reflect.DeepEqual(built, want) {
		t.Fatalf("construction order = %#v, want %#v", built, want)
	}
	if len(prepared.Steps) != 2 || len(prepared.Steps[0].lanes) != 1 || len(prepared.Steps[1].lanes) != 1 {
		t.Fatalf("prepared steps = %#v, want two constructed steps", prepared.Steps)
	}
	if len(input.requests) != 0 {
		t.Fatalf("input Parse calls = %d, want zero during preparation", len(input.requests))
	}
}

func TestPrepareRetainsGeneratedSelectorsWithoutReferenceBytes(t *testing.T) {
	var built []string
	registries, _ := constructionRegistries(t, &built, nil)
	resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings = []ReferenceBinding{{
		Stage:    StageExtract,
		LaneID:   "artifact",
		SlotName: "generated",
		Artifact: &ArtifactReference{Step: "producer", Lane: "artifact"},
	}}
	prepared, err := Prepare(resolved, registries, ModuleDependencies{})
	if err != nil {
		t.Fatalf("Prepare() error = %v, want nil", err)
	}
	if len(prepared.Steps[0].ArtifactLanes[0].Resolved.ExtractReferences.ReferenceSet.Slots) != 0 {
		t.Fatalf("prepared generated reference set = %#v, want no reference bytes", prepared.Steps[0].ArtifactLanes[0].Resolved.ExtractReferences.ReferenceSet)
	}
	if len(built) == 0 {
		t.Fatal("constructed modules = empty, want preparation to construct the selected components")
	}
}

func TestPreparedCheckpointFingerprintsCollectEveryComponentInStableScopeOrder(t *testing.T) {
	provider := func(value string) checkpointFingerprintTestProvider {
		return checkpointFingerprintTestProvider{{Name: "identity", Value: value}}
	}
	prepared := &PreparedPipeline{
		resolved: ResolvedPipeline{
			ID:     "fingerprints",
			Input:  Binding("input"),
			Chunk:  Binding("chunk"),
			Output: Binding("output"),
		},
		input:   provider("input"),
		chunker: provider("chunk"),
		chunkValidators: preparedValidatorChain{validators: []preparedValidator{{
			resolved: ResolvedValidator{Binding: Binding("chunk-validator"), Target: ValidatorTargetChunk},
			chunk:    provider("chunk-validator"),
		}}},
		output: provider("output"),
		Steps:  []PreparedPipelineStep{{ID: "default"}},
	}
	lane := preparedLaneExecutor{
		resolved: ResolvedArtifactLane{
			ID:        "spells",
			Extract:   Binding("extract"),
			Merge:     Binding("merge"),
			Normalize: Binding("normalize"),
		},
		typed: &preparedTypedLane{
			extractor:  provider("extract"),
			merger:     provider("merge"),
			normalizer: provider("normalize"),
		},
		extractValidators:   fingerprintTestValidatorChain("extract-validator", provider("extract-validator")),
		mergeValidators:     fingerprintTestValidatorChain("merge-validator", provider("merge-validator")),
		normalizeValidators: fingerprintTestValidatorChain("normalize-validator", provider("normalize-validator")),
	}
	prepared.Steps[0].lanes = []preparedLaneExecutor{lane}

	first, err := collectPreparedCheckpointFingerprints(prepared)
	if err != nil {
		t.Fatalf("collectPreparedCheckpointFingerprints() error = %v, want nil", err)
	}
	second, err := collectPreparedCheckpointFingerprints(prepared)
	if err != nil {
		t.Fatalf("second collection error = %v, want nil", err)
	}
	if !reflect.DeepEqual(first, second) {
		t.Fatalf("fingerprints are not deterministic: %#v / %#v", first, second)
	}
	if len(first) != 10 {
		t.Fatalf("fingerprints = %#v, want all 10 prepared components", first)
	}
	for index := 1; index < len(first); index++ {
		if first[index-1].Name >= first[index].Name {
			t.Fatalf("fingerprints are not sorted: %#v", first)
		}
	}
	prepared.checkpointFingerprints = first
	copy := prepared.CheckpointFingerprints()
	copy[0].Value = "mutated"
	if reflect.DeepEqual(copy, prepared.CheckpointFingerprints()) {
		t.Fatal("CheckpointFingerprints() exposed mutable backing storage")
	}
}

func TestPreparedCheckpointFingerprintsValidateProviderValues(t *testing.T) {
	tests := []struct {
		name         string
		fingerprints []CheckpointFingerprint
		want         string
	}{
		{name: "empty name", fingerprints: []CheckpointFingerprint{{Value: "value"}}, want: "non-empty name and value"},
		{name: "empty value", fingerprints: []CheckpointFingerprint{{Name: "name"}}, want: "non-empty name and value"},
		{name: "duplicate after trimming", fingerprints: []CheckpointFingerprint{{Name: "same", Value: "one"}, {Name: " same ", Value: "two"}}, want: "duplicated"},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			prepared := &PreparedPipeline{
				resolved: ResolvedPipeline{ID: "fingerprints", Input: Binding("input"), Chunk: Binding("chunk"), Output: Binding("output")},
				input:    checkpointFingerprintTestProvider(test.fingerprints),
			}
			_, err := collectPreparedCheckpointFingerprints(prepared)
			if err == nil || !strings.Contains(err.Error(), test.want) {
				t.Fatalf("error = %v, want substring %q", err, test.want)
			}
		})
	}
}

func TestPreparedCheckpointFingerprintsRemainEmptyWithoutProviders(t *testing.T) {
	registries, _ := constructionRegistries(t, nil, nil)
	resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
	if err != nil {
		t.Fatal(err)
	}
	prepared, err := Prepare(resolved, registries, ModuleDependencies{})
	if err != nil {
		t.Fatal(err)
	}
	if got := prepared.CheckpointFingerprints(); len(got) != 0 {
		t.Fatalf("CheckpointFingerprints() = %#v, want empty", got)
	}
}

func TestPreparedCheckpointFingerprintsScopeRepeatedValidatorsByPosition(t *testing.T) {
	provider := checkpointFingerprintTestProvider{{Name: "identity", Value: "same"}}
	validator := func() preparedValidator {
		return preparedValidator{resolved: ResolvedValidator{Binding: Binding("configured"), Target: ValidatorTargetChunk}, chunk: provider}
	}
	prepared := &PreparedPipeline{
		resolved:        ResolvedPipeline{ID: "fingerprints", Input: Binding("input"), Chunk: Binding("chunk"), Output: Binding("output")},
		chunkValidators: preparedValidatorChain{validators: []preparedValidator{validator(), validator()}},
	}
	got, err := collectPreparedCheckpointFingerprints(prepared)
	if err != nil {
		t.Fatalf("collect repeated validators: %v", err)
	}
	if len(got) != 2 || got[0].Name == got[1].Name {
		t.Fatalf("fingerprints = %#v, want independently scoped repeated validators", got)
	}
}

type checkpointFingerprintTestProvider []CheckpointFingerprint

func (p checkpointFingerprintTestProvider) CheckpointFingerprints() []CheckpointFingerprint {
	return append([]CheckpointFingerprint(nil), p...)
}
func (checkpointFingerprintTestProvider) Key() string                               { return "fingerprint-test" }
func (checkpointFingerprintTestProvider) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (checkpointFingerprintTestProvider) Parse(context.Context, contracts.ParseRequest) (*source.SourceDocument, error) {
	return typedTestDocument(), nil
}
func (checkpointFingerprintTestProvider) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
	return contracts.ChunkPlanResult{}, nil
}
func (checkpointFingerprintTestProvider) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
	return contracts.OutputResult{}, nil
}
func (checkpointFingerprintTestProvider) Name() string { return "fingerprint-test" }
func (checkpointFingerprintTestProvider) ExecutionClass() contracts.ExecutionClass {
	return contracts.ExecutionClassDeterministic
}
func (checkpointFingerprintTestProvider) Validate(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
	return contracts.ValidationResult{Approved: true}, nil
}

type checkpointFingerprintOutput struct {
	contracts.OutputEncoder
	fingerprints []CheckpointFingerprint
}

func (o checkpointFingerprintOutput) CheckpointFingerprints() []CheckpointFingerprint {
	return append([]CheckpointFingerprint(nil), o.fingerprints...)
}

func fingerprintTestValidatorChain(key string, provider checkpointFingerprintTestProvider) preparedValidatorChain {
	return preparedValidatorChain{validators: []preparedValidator{{
		resolved: ResolvedValidator{Binding: Binding(key), Target: ValidatorTargetTyped},
		typed:    provider,
	}}}
}

func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
	var built []string
	var observations []constructionBuildObservation
	registries, input := constructionRegistriesWithHooks(t, &built, nil,
		func(name string, request BuildRequest) {
			observations = append(observations, constructionBuildObservation{Name: name, Request: request})
		},
		func(name string, request *BuildRequest) {
			if name != "extract" {
				return
			}
			request.Options["nested"].(map[string]any)["value"] = "mutated by extractor builder"
			slot := request.References.Slots["extract"]
			slot.Items[0].Content = []byte("mutated by extractor builder")
			request.References.Slots["extract"] = slot
		},
	)
	resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	resolved.ChunkReferences.ReferenceSet = constructionReferenceSet("chunk", "chunk reference")
	resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = constructionReferenceSet("extract", "extract reference")
	resolved.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet = constructionReferenceSet("merge", "merge reference")
	resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet = constructionReferenceSet("normalize", "normalize reference")
	resolved.Steps[0].ArtifactLanes[0].Extract.Options = constructionBuildRequest().Options

	prepared, err := Prepare(resolved, registries, ModuleDependencies{})
	if err != nil {
		t.Fatalf("Prepare() error = %v, want nil", err)
	}
	wantNames := []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"}
	if !reflect.DeepEqual(built, wantNames) {
		t.Fatalf("construction order = %#v, want %#v", built, wantNames)
	}
	wantContents := []string{"", "chunk reference", "chunk reference", "extract reference", "extract reference", "merge reference", "merge reference", "normalize reference", "normalize reference", ""}
	if len(observations) != len(wantContents) {
		t.Fatalf("observed %d build requests, want %d", len(observations), len(wantContents))
	}
	for i, want := range wantContents {
		if got := constructionReferenceContent(observations[i].Request.References); got != want {
			t.Errorf("build request %d (%s) reference content = %q, want %q", i, observations[i].Name, got, want)
		}
	}
	if got := constructionReferenceContent(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
		t.Fatalf("resolved extract references = %q, want original content", got)
	}
	if got := constructionReferenceContent(prepared.resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
		t.Fatalf("prepared extract references = %q, want original content", got)
	}
	if got := resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
		t.Fatalf("resolved extract options = %#v, want original nested value", got)
	}
	if got := prepared.resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
		t.Fatalf("prepared extract options = %#v, want original nested value", got)
	}

	_, err = prepared.Steps[0].lanes[0].typed.extract(context.Background(), prepared.Steps[0].lanes[0].typed.extractor, contracts.TypedExtractionRequest{
		References: CloneReferenceSet(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet),
	})
	if err != nil {
		t.Fatalf("prepared extractor operation error = %v, want nil", err)
	}
	if len(input.extractRequests) != 1 {
		t.Fatalf("runtime extraction requests = %d, want one", len(input.extractRequests))
	}
	if got := constructionReferenceContent(input.extractRequests[0].References); got != "extract reference" {
		t.Fatalf("runtime extraction references = %q, want original content", got)
	}
}

func TestRegisteredBuildersReceiveIndependentBuildRequests(t *testing.T) {
	request := constructionBuildRequest()
	probe := buildRequestMutationProbe{t: t, want: cloneBuildRequest(request)}

	inputs := NewInputAdapterRegistry()
	if err := inputs.RegisterBuilderWithSpec(testModuleSpec("input", StageInput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.InputAdapter, error) {
		probe.observe(request)
		return &constructionInput{key: "input"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	chunkers := NewChunkerRegistry()
	if err := chunkers.RegisterBuilderWithSpec(testModuleSpec("chunk", StageChunk), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Chunker, error) {
		probe.observe(request)
		return &typedTestChunker{key: "chunk"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	outputs := NewOutputEncoderRegistry()
	if err := outputs.RegisterBuilderWithSpec(testModuleSpec("output", StageOutput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.OutputEncoder, error) {
		probe.observe(request)
		return &typedTestOutput{key: "output"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	extractors := NewExtractorRegistry()
	extractSpec := testModuleSpec("extract", StageExtract)
	extractSpec.ArtifactKind = "test/notes"
	if err := RegisterExtractorBuilder(extractors, extractSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Extractor[codecNotes], error) {
		probe.observe(request)
		return typedTestExtractor[codecNotes]{key: "extract"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	mergers := NewMergerRegistry()
	mergeSpec := testModuleSpec("merge", StageMerge)
	mergeSpec.ArtifactKind = "test/notes"
	if err := RegisterMergerBuilder(mergers, mergeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Merger[codecNotes], error) {
		probe.observe(request)
		return typedTestMerger[codecNotes]{key: "merge"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	normalizers := NewNormalizerRegistry()
	normalizeSpec := testModuleSpec("normalize", StageNormalize)
	normalizeSpec.ArtifactKind = "test/notes"
	if err := RegisterNormalizerBuilder(normalizers, normalizeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Normalizer[codecNotes], error) {
		probe.observe(request)
		return typedTestNormalizer[codecNotes]{key: "normalize"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	validators := NewValidatorRegistry()
	if err := RegisterChunkValidatorBuilder(validators, ValidatorSpec{Key: "chunk-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.ChunkValidator, error) {
		probe.observe(request)
		return typedTestChunkValidator{key: "chunk-validator"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	if err := RegisterTypedValidatorBuilder(validators, "test/notes", ValidatorSpec{Key: "typed-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.TypedValidator[codecNotes], error) {
		probe.observe(request)
		return typedTestValidator[codecNotes]{key: "typed-validator"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	if err := RegisterSerializedValidatorBuilder(validators, SerializedValidatorSpec{ValidatorSpec: ValidatorSpec{Key: "serialized-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, SupportsArtifacts: true}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.SerializedValidator, error) {
		probe.observe(request)
		return typedTestSerializedValidator{key: "serialized-validator"}, nil
	}); err != nil {
		t.Fatal(err)
	}

	builders := []struct {
		name string
		call func() error
	}{
		{name: "input", call: func() error { _, err := inputs.BuildWithRequest("input", request); return err }},
		{name: "chunk", call: func() error { _, err := chunkers.BuildWithRequest("chunk", request); return err }},
		{name: "output", call: func() error { _, err := outputs.BuildWithRequest("output", request); return err }},
		{name: "extract", call: func() error {
			entry, _ := extractors.typedEntry("extract")
			_, err := entry.builder(request)
			return err
		}},
		{name: "merge", call: func() error {
			entry, _ := mergers.typedEntry("merge", "test/notes")
			_, err := entry.builder(request)
			return err
		}},
		{name: "normalize", call: func() error {
			entry, _ := normalizers.typedEntry("normalize", "test/notes")
			_, err := entry.builder(request)
			return err
		}},
		{name: "chunk validator", call: func() error {
			entry, _ := validators.chunkEntry("chunk-validator")
			_, err := entry.builder(request)
			return err
		}},
		{name: "typed validator", call: func() error {
			entry, _ := validators.typedEntry("typed-validator", "test/notes")
			_, err := entry.builder(request)
			return err
		}},
		{name: "serialized validator", call: func() error {
			entry, _ := validators.serializedEntry("serialized-validator")
			_, err := entry.builder(request)
			return err
		}},
	}
	for _, builder := range builders {
		t.Run(builder.name, func(t *testing.T) {
			if err := builder.call(); err != nil {
				t.Fatal(err)
			}
		})
	}
	if !reflect.DeepEqual(request, probe.want) {
		t.Fatalf("build request = %#v, want unchanged %#v", request, probe.want)
	}
}

type buildRequestMutationProbe struct {
	t    *testing.T
	want BuildRequest
}

func (probe buildRequestMutationProbe) observe(request BuildRequest) {
	probe.t.Helper()
	if !reflect.DeepEqual(request, probe.want) {
		probe.t.Fatalf("builder request = %#v, want independently owned %#v", request, probe.want)
	}
	options := request.Options["nested"].(map[string]any)
	options["value"] = "mutated"
	request.Options["items"].([]any)[0].(map[string]any)["value"] = "mutated"
	request.Options["bytes"].([]byte)[0] = 'x'
	slot := request.References.Slots["reference"]
	slot.Items[0].Content[0] = 'x'
	slot.Items = append(slot.Items, contracts.ReferenceItem{SlotName: "reference", Content: []byte("extra")})
	request.References.Slots["reference"] = slot
	delete(request.References.Slots, "unused")
}

func constructionBuildRequest() BuildRequest {
	return BuildRequest{
		Options: map[string]any{
			"nested": map[string]any{"value": "original"},
			"items":  []any{map[string]any{"value": "original"}},
			"bytes":  []byte("original"),
		},
		References: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
			"reference": {Slot: contracts.ReferenceSlot{Name: "reference"}, Items: []contracts.ReferenceItem{{SlotName: "reference", Content: []byte("original")}}},
			"unused":    {Slot: contracts.ReferenceSlot{Name: "unused"}},
		}},
	}
}

func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
	tests := []struct {
		name      string
		deps      ModuleDependencies
		configure func(*constructionFailure)
		want      string
		wantBuilt []string
	}{
		{
			name: "missing required llm dependency",
			configure: func(failure *constructionFailure) {
				failure.requireExtractorLLM = true
			},
			want:      `lane "artifact" extract module "extract"`,
			wantBuilt: []string{"input", "chunk", "validator", "extract"},
		},
		{
			name: "late output construction",
			configure: func(failure *constructionFailure) {
				failure.output = errors.New("output unavailable")
			},
			want:      `output module "output"`,
			wantBuilt: []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			failure := &constructionFailure{}
			test.configure(failure)
			var built []string
			registries, input := constructionRegistries(t, &built, failure)
			resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
			if err != nil {
				t.Fatalf("ResolvePipeline() error = %v, want nil", err)
			}

			_, err = Prepare(resolved, registries, test.deps)
			if err == nil || !strings.Contains(err.Error(), test.want) {
				t.Fatalf("Prepare() error = %v, want substring %q", err, test.want)
			}
			if len(input.requests) != 0 {
				t.Fatalf("input Parse calls = %d, want zero", len(input.requests))
			}
			if !reflect.DeepEqual(built, test.wantBuilt) {
				t.Fatalf("construction order = %#v, want %#v", built, test.wantBuilt)
			}
		})
	}
}

func TestPrepareRejectsInvalidComponentCheckpointFingerprint(t *testing.T) {
	failure := &constructionFailure{outputFingerprints: []CheckpointFingerprint{{Name: "identity"}}}
	registries, input := constructionRegistries(t, nil, failure)
	resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
	if err != nil {
		t.Fatal(err)
	}
	_, err = Prepare(resolved, registries, ModuleDependencies{})
	if err == nil || !strings.Contains(err.Error(), "checkpoint fingerprint") || !strings.Contains(err.Error(), "output:output") {
		t.Fatalf("Prepare() error = %v, want scoped checkpoint fingerprint error", err)
	}
	if len(input.requests) != 0 {
		t.Fatalf("input Parse calls = %d, want zero", len(input.requests))
	}
}

type constructionFailure struct {
	requireExtractorLLM bool
	output              error
	outputFingerprints  []CheckpointFingerprint
}

func constructionProfile() PipelineProfile {
	validators := ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured"}}}
	return PipelineProfile{
		ID:    "construction",
		Input: Binding("input"),
		Chunk: ModuleBinding{Module: "chunk", Validators: validators},
		Artifacts: map[string]ArtifactLaneProfile{
			"artifact": {
				Extract:   ModuleBinding{Module: "extract", Validators: validators},
				Merge:     ModuleBinding{Module: "merge", Validators: validators},
				Normalize: ModuleBinding{Module: "normalize", Validators: validators},
			},
		},
		Output: Binding("output"),
	}
}

func constructionRegistries(t *testing.T, built *[]string, failure *constructionFailure) (Registries, *constructionInput) {
	return constructionRegistriesWithHooks(t, built, failure, nil, nil)
}

type constructionBuildObservation struct {
	Name    string
	Request BuildRequest
}

func constructionRegistriesWithHooks(t *testing.T, built *[]string, failure *constructionFailure, observe func(string, BuildRequest), mutate func(string, *BuildRequest)) (Registries, *constructionInput) {
	t.Helper()
	if built == nil {
		built = &[]string{}
	}
	if failure == nil {
		failure = &constructionFailure{}
	}
	record := func(name string, request *BuildRequest) {
		*built = append(*built, name)
		if observe != nil {
			observe(name, cloneBuildRequest(*request))
		}
		if mutate != nil {
			mutate(name, request)
		}
	}
	strict := func(options map[string]any) error { return RejectUnknownOptions(options, "known") }
	input := &constructionInput{key: "input"}
	registries := Registries{
		Inputs: NewInputAdapterRegistry(), Chunkers: NewChunkerRegistry(), ArtifactCodecs: NewArtifactCodecRegistry(),
		Extractors: NewExtractorRegistry(), Mergers: NewMergerRegistry(), Normalizers: NewNormalizerRegistry(),
		Validators: NewValidatorRegistry(), ValidatorChains: NewValidatorChainRegistry(), Outputs: NewOutputEncoderRegistry(),
	}
	if err := RegisterArtifactCodec(registries.ArtifactCodecs, notesCodec()); err != nil {
		t.Fatal(err)
	}
	if err := registries.Inputs.RegisterBuilderWithSpec(testModuleSpec("input", StageInput), strict, func(request BuildRequest) (contracts.InputAdapter, error) {
		record("input", &request)
		return input, nil
	}); err != nil {
		t.Fatal(err)
	}
	if err := registries.Chunkers.RegisterBuilderWithSpec(testModuleSpec("chunk", StageChunk), strict, func(request BuildRequest) (contracts.Chunker, error) {
		record("chunk", &request)
		return &typedTestChunker{key: "chunk"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	extractSpec := testModuleSpec("extract", StageExtract)
	extractSpec.ArtifactKind = "test/notes"
	if err := RegisterExtractorBuilder(registries.Extractors, extractSpec, strict, func(request BuildRequest) (contracts.Extractor[codecNotes], error) {
		record("extract", &request)
		if failure.requireExtractorLLM && request.Dependencies.LLM == nil {
			return nil, errors.New("structured LLM client is required")
		}
		return &constructionExtractor{key: "extract", requests: &input.extractRequests}, nil
	}); err != nil {
		t.Fatal(err)
	}
	mergeSpec := testModuleSpec("merge", StageMerge)
	mergeSpec.ArtifactKind = "test/notes"
	if err := RegisterMergerBuilder(registries.Mergers, mergeSpec, strict, func(request BuildRequest) (contracts.Merger[codecNotes], error) {
		record("merge", &request)
		return typedTestMerger[codecNotes]{key: "merge"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	normalizeSpec := testModuleSpec("normalize", StageNormalize)
	normalizeSpec.ArtifactKind = "test/notes"
	if err := RegisterNormalizerBuilder(registries.Normalizers, normalizeSpec, strict, func(request BuildRequest) (contracts.Normalizer[codecNotes], error) {
		record("normalize", &request)
		return typedTestNormalizer[codecNotes]{key: "normalize"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	validatorSpec := ValidatorSpec{Key: "configured", ExecutionClass: contracts.ExecutionClassDeterministic}
	if err := RegisterChunkValidatorBuilder(registries.Validators, validatorSpec, strict, func(request BuildRequest) (contracts.ChunkValidator, error) {
		record("validator", &request)
		return typedTestChunkValidator{key: "configured"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	if err := RegisterTypedValidatorBuilder(registries.Validators, "test/notes", validatorSpec, strict, func(request BuildRequest) (contracts.TypedValidator[codecNotes], error) {
		record("validator", &request)
		return typedTestValidator[codecNotes]{key: "configured"}, nil
	}); err != nil {
		t.Fatal(err)
	}
	if err := registries.Outputs.RegisterBuilderWithSpec(testModuleSpec("output", StageOutput), strict, func(request BuildRequest) (contracts.OutputEncoder, error) {
		record("output", &request)
		if failure.output != nil {
			return nil, failure.output
		}
		output := contracts.OutputEncoder(&typedTestOutput{key: "output"})
		if failure.outputFingerprints != nil {
			output = checkpointFingerprintOutput{OutputEncoder: output, fingerprints: failure.outputFingerprints}
		}
		return output, nil
	}); err != nil {
		t.Fatal(err)
	}
	return registries, input
}

type constructionInput struct {
	key             string
	requests        []contracts.ParseRequest
	extractRequests []contracts.TypedExtractionRequest
}

type constructionExtractor struct {
	key      string
	requests *[]contracts.TypedExtractionRequest
}

func (extractor *constructionExtractor) Key() string                     { return extractor.key }
func (*constructionExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (extractor *constructionExtractor) Extract(_ context.Context, request contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[codecNotes], error) {
	if extractor.requests != nil {
		*extractor.requests = append(*extractor.requests, request)
	}
	return contracts.TypedExtractionResult[codecNotes]{}, nil
}

func constructionReferenceSet(slotName, content string) contracts.ReferenceSet {
	return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
		slotName: {
			Slot:  contracts.ReferenceSlot{Name: slotName},
			Items: []contracts.ReferenceItem{{SlotName: slotName, Content: []byte(content)}},
		},
	}}
}

func constructionReferenceContent(references contracts.ReferenceSet) string {
	for _, slot := range references.Slots {
		if len(slot.Items) > 0 {
			return string(slot.Items[0].Content)
		}
	}
	return ""
}

func (input *constructionInput) Key() string { return input.key }
func (input *constructionInput) Parse(_ context.Context, request contracts.ParseRequest) (*source.SourceDocument, error) {
	input.requests = append(input.requests, request)
	return typedTestDocument(), nil
}
