package pipeline

import (
	"encoding/json"
	"errors"
	"io"
	"math"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"

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

type countingReader struct {
	content  []byte
	consumed int
}

func (reader *countingReader) Read(buffer []byte) (int, error) {
	if len(reader.content) == 0 {
		return 0, io.EOF
	}
	read := copy(buffer, reader.content)
	reader.content = reader.content[read:]
	reader.consumed += read
	return read, nil
}

func TestReadReferenceContentBoundsPositiveMaximum(t *testing.T) {
	tests := []struct {
		name          string
		content       string
		maxBytes      int64
		wantContent   string
		wantConsumed  int
		wantLimitErr  bool
		wantReadError bool
	}{
		{name: "exact maximum", content: "abc", maxBytes: 3, wantContent: "abc", wantConsumed: 3},
		{name: "oversized", content: "abcdef", maxBytes: 3, wantConsumed: 4, wantLimitErr: true},
		{name: "unbounded", content: "abcdef", maxBytes: 0, wantContent: "abcdef", wantConsumed: 6},
		{name: "negative maximum remains unbounded", content: "abcdef", maxBytes: -1, wantContent: "abcdef", wantConsumed: 6},
		{name: "overflowing maximum", content: "abcdef", maxBytes: math.MaxInt64, wantConsumed: 0, wantReadError: true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			reader := &countingReader{content: []byte(tt.content)}
			content, err := readReferenceContent(reader, tt.maxBytes)
			if reader.consumed != tt.wantConsumed {
				t.Fatalf("reader consumed %d bytes, want %d", reader.consumed, tt.wantConsumed)
			}
			if got := string(content); got != tt.wantContent {
				t.Fatalf("content = %q, want %q", got, tt.wantContent)
			}
			var sizeErr *referenceSizeLimitError
			if errors.As(err, &sizeErr) != tt.wantLimitErr {
				t.Fatalf("error = %v, want limit error=%t", err, tt.wantLimitErr)
			}
			if (err != nil) != (tt.wantLimitErr || tt.wantReadError) {
				t.Fatalf("error = %v, want error=%t", err, tt.wantLimitErr || tt.wantReadError)
			}
			if sizeErr != nil && (sizeErr.SizeBytes != 4 || sizeErr.MaxBytes != 3) {
				t.Fatalf("size error = %#v, want observed size 4 and limit 3", sizeErr)
			}
		})
	}
}

func TestMaterializeReferencesResolvesPathsAndDigestsContent(t *testing.T) {
	configDir := t.TempDir()
	workingDir := t.TempDir()
	configReference := filepath.Join(configDir, "config-reference.txt")
	cliReference := filepath.Join(workingDir, "cli-reference.txt")
	writeReferenceFile(t, configReference, []byte("config text"))
	writeReferenceFile(t, cliReference, []byte("cli text"))

	pipeline := baselineProfile()
	pipeline.References = ExternalReferenceMap(map[string]string{"roster": "config-reference.txt"})
	lane := pipeline.Artifacts["events"]
	lane.References = ExternalReferenceMap(map[string]string{"glossary": "cli-reference.txt"})
	pipeline.Artifacts["events"] = lane
	catalog := referenceCatalog(t, []contracts.ReferenceSlot{
		{Name: "roster"},
		{Name: "glossary"},
	})
	resolved, err := ResolvePipeline(pipeline, ResolveOptions{
		ReferenceOverrides: []ReferenceBinding{
			{LaneID: "events", SlotName: "glossary", Source: "cli-reference.txt", BindingSource: contracts.ReferenceBindingSourceCLI},
		},
	}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	first, warnings, err := MaterializeReferences(resolved, catalog, ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
		WorkingDir: workingDir,
	})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	if len(warnings) != 0 {
		t.Fatalf("warnings = %#v, want none", warnings)
	}
	second, _, err := MaterializeReferences(resolved, catalog, ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
		WorkingDir: workingDir,
	})
	if err != nil {
		t.Fatalf("MaterializeReferences(second) error = %v, want nil", err)
	}

	referenceSet := first.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet
	roster := referenceSet.Slots["roster"].Items[0]
	if string(roster.Content) != "config text" {
		t.Fatalf("roster content = %q, want config text", roster.Content)
	}
	if roster.Digest != referenceDigest([]byte("config text")) || roster.Digest != second.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0].Digest {
		t.Fatalf("roster digest = %q, want stable digest", roster.Digest)
	}
	if roster.BindingSource != contracts.ReferenceBindingSourceConfig {
		t.Fatalf("roster binding source = %q, want config", roster.BindingSource)
	}
	if roster.MediaType != referenceMediaType || roster.Origin.Type != referenceOriginFile || roster.SizeBytes != int64(len("config text")) {
		t.Fatalf("roster metadata = %#v, want text file metadata", roster)
	}
	if !strings.Contains(roster.Origin.URI, "config-reference.txt") {
		t.Fatalf("roster origin URI = %q, want config reference path", roster.Origin.URI)
	}

	glossary := referenceSet.Slots["glossary"].Items[0]
	if string(glossary.Content) != "cli text" {
		t.Fatalf("glossary content = %q, want cli text", glossary.Content)
	}
	if glossary.BindingSource != contracts.ReferenceBindingSourceCLI {
		t.Fatalf("glossary binding source = %q, want cli", glossary.BindingSource)
	}
	if !strings.Contains(glossary.Origin.URI, "cli-reference.txt") {
		t.Fatalf("glossary origin URI = %q, want cli reference path", glossary.Origin.URI)
	}

	provenance := ReferenceProvenance(first)
	if len(provenance) != 2 {
		t.Fatalf("ReferenceProvenance() = %#v, want two entries", provenance)
	}
	if provenance[0].Stage != string(StageExtract) || provenance[0].LaneID != "events" || provenance[0].SlotName != "glossary" || provenance[0].Digest != glossary.Digest {
		t.Fatalf("ReferenceProvenance()[0] = %#v, want sorted glossary provenance", provenance[0])
	}
	if provenance[1].Stage != string(StageExtract) || provenance[1].LaneID != "events" || provenance[1].SlotName != "roster" || provenance[1].Digest != roster.Digest {
		t.Fatalf("ReferenceProvenance()[1] = %#v, want roster provenance", provenance[1])
	}

	encoded, err := json.Marshal(first)
	if err != nil {
		t.Fatalf("json.Marshal(materialized) error = %v, want nil", err)
	}
	if strings.Contains(string(encoded), "config text") || strings.Contains(string(encoded), "cli text") {
		t.Fatalf("materialized pipeline JSON contains reference content: %s", encoded)
	}
}

func TestMaterializeReferencesStoresSetsAndProvenanceForAllTargets(t *testing.T) {
	configDir := t.TempDir()
	writeReferenceFile(t, filepath.Join(configDir, "chunk.txt"), []byte("chunk text"))
	writeReferenceFile(t, filepath.Join(configDir, "extract.txt"), []byte("extract text"))
	writeReferenceFile(t, filepath.Join(configDir, "merge.txt"), []byte("merge text"))
	writeReferenceFile(t, filepath.Join(configDir, "normalize.txt"), []byte("normalize text"))

	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{
		"scene_guide":         "chunk.txt",
		"roster":              "extract.txt",
		"merge_notes":         "merge.txt",
		"normalization_notes": "normalize.txt",
	})
	catalog := referenceCatalogForTargets(t,
		[]contracts.ReferenceSlot{{Name: "scene_guide"}},
		[]contracts.ReferenceSlot{{Name: "roster"}},
		[]contracts.ReferenceSlot{{Name: "merge_notes"}},
		[]contracts.ReferenceSlot{{Name: "normalization_notes"}},
	)
	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	materialized, warnings, err := MaterializeReferences(resolved, catalog, ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	if len(warnings) != 0 {
		t.Fatalf("warnings = %#v, want none", warnings)
	}

	chunkItem := materialized.ChunkReferences.ReferenceSet.Slots["scene_guide"].Items[0]
	if string(chunkItem.Content) != "chunk text" {
		t.Fatalf("chunk content = %q, want chunk text", chunkItem.Content)
	}
	extractItem := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0]
	if string(extractItem.Content) != "extract text" {
		t.Fatalf("extract content = %q, want extract text", extractItem.Content)
	}
	mergeItem := materialized.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet.Slots["merge_notes"].Items[0]
	if string(mergeItem.Content) != "merge text" {
		t.Fatalf("merge content = %q, want merge text", mergeItem.Content)
	}
	normalizeItem := materialized.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["normalization_notes"].Items[0]
	if string(normalizeItem.Content) != "normalize text" {
		t.Fatalf("normalize content = %q, want normalize text", normalizeItem.Content)
	}

	provenance := ReferenceProvenance(materialized)
	if len(provenance) != 4 {
		t.Fatalf("ReferenceProvenance() = %#v, want four entries", provenance)
	}
	if provenance[0].Stage != string(StageChunk) || provenance[0].LaneID != "" || provenance[0].SlotName != "scene_guide" || provenance[0].Digest != chunkItem.Digest {
		t.Fatalf("ReferenceProvenance()[0] = %#v, want chunk scene guide provenance", provenance[0])
	}
	if provenance[1].Stage != string(StageExtract) || provenance[1].LaneID != "events" || provenance[1].SlotName != "roster" || provenance[1].Digest != extractItem.Digest {
		t.Fatalf("ReferenceProvenance()[1] = %#v, want extract roster provenance", provenance[1])
	}
	if provenance[2].Stage != string(StageMerge) || provenance[2].LaneID != "events" || provenance[2].SlotName != "merge_notes" || provenance[2].Digest != mergeItem.Digest {
		t.Fatalf("ReferenceProvenance()[2] = %#v, want merge notes provenance", provenance[2])
	}
	if provenance[3].Stage != string(StageNormalize) || provenance[3].LaneID != "events" || provenance[3].SlotName != "normalization_notes" || provenance[3].Digest != normalizeItem.Digest {
		t.Fatalf("ReferenceProvenance()[2] = %#v, want normalize notes provenance", provenance[2])
	}
}

func TestMaterializeReferencesUsesLaneArtifactVariant(t *testing.T) {
	configDir := t.TempDir()
	writeReferenceFile(t, filepath.Join(configDir, "merge.txt"), []byte("merge alpha"))
	writeReferenceFile(t, filepath.Join(configDir, "normalize.txt"), []byte("normalize alpha"))
	mergers := NewMergerRegistry()
	normalizers := NewNormalizerRegistry()
	for _, item := range []struct {
		kind          contracts.ArtifactKind
		mergeSlot     string
		normalizeSlot string
	}{
		{kind: "test/beta", mergeSlot: "beta_merge", normalizeSlot: "beta_normalize"},
		{kind: "test/alpha", mergeSlot: "alpha_merge", normalizeSlot: "alpha_normalize"},
	} {
		if err := RegisterMerger(mergers, ModuleSpec{Key: "shared/merge", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: item.kind, ReferenceSlots: []contracts.ReferenceSlot{{Name: item.mergeSlot}}}, func() (contracts.Merger[codecNotes], error) {
			return typedTestMerger[codecNotes]{key: "shared/merge"}, nil
		}); err != nil {
			t.Fatalf("RegisterMerger(%s): %v", item.kind, err)
		}
		if err := RegisterNormalizer(normalizers, ModuleSpec{Key: "shared/normalize", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: item.kind, ReferenceSlots: []contracts.ReferenceSlot{{Name: item.normalizeSlot}}}, func() (contracts.Normalizer[codecNotes], error) {
			return typedTestNormalizer[codecNotes]{key: "shared/normalize"}, nil
		}); err != nil {
			t.Fatalf("RegisterNormalizer(%s): %v", item.kind, err)
		}
	}
	resolved := ResolvedPipeline{
		ID: "variants",
		Steps: []ResolvedPipelineStep{{
			ID: "default",
			ArtifactLanes: []ResolvedArtifactLane{{
				ID:           "alpha",
				ArtifactKind: "test/alpha",
				MergeReferences: referenceTarget(StageMerge, "alpha", "shared/merge", []ReferenceBinding{{
					Stage: StageMerge, LaneID: "alpha", SlotName: "alpha_merge", Source: "merge.txt", BindingSource: contracts.ReferenceBindingSourceConfig,
				}}),
				NormalizeReferences: referenceTarget(StageNormalize, "alpha", "shared/normalize", []ReferenceBinding{{
					Stage: StageNormalize, LaneID: "alpha", SlotName: "alpha_normalize", Source: "normalize.txt", BindingSource: contracts.ReferenceBindingSourceConfig,
				}}),
			}},
		}},
	}
	materialized, _, err := MaterializeReferences(resolved, ModuleCatalog{Mergers: mergers, Normalizers: normalizers}, ReferenceMaterializationOptions{ConfigPath: filepath.Join(configDir, "notarius.yml")})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	lane := materialized.Steps[0].ArtifactLanes[0]
	if got := string(lane.MergeReferences.ReferenceSet.Slots["alpha_merge"].Items[0].Content); got != "merge alpha" {
		t.Fatalf("merge reference = %q", got)
	}
	if got := string(lane.NormalizeReferences.ReferenceSet.Slots["alpha_normalize"].Items[0].Content); got != "normalize alpha" {
		t.Fatalf("normalize reference = %q", got)
	}
}

func TestMaterializeReferencesRejectsNonUTF8Content(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "bad.txt")
	writeReferenceFile(t, path, []byte{0xff, 0xfe})

	resolved := resolvedPipelineWithReference(t, "roster", "bad.txt", contracts.ReferenceBindingSourceConfig, contracts.ReferenceSlot{Name: "roster"})
	_, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{{Name: "roster"}}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err == nil || !strings.Contains(err.Error(), "UTF-8") || !strings.Contains(err.Error(), "roster") || !strings.Contains(err.Error(), path) {
		t.Fatalf("error = %v, want UTF-8 path error", err)
	}
}

func TestMaterializeReferencesRejectsNonUTF8ContentForChunkTarget(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "bad.txt")
	writeReferenceFile(t, path, []byte{0xff, 0xfe})

	resolved := resolvedPipelineWithTargetReference(t, StageChunk, "", "scene_guide", "bad.txt", contracts.ReferenceBindingSourceConfig, contracts.ReferenceSlot{Name: "scene_guide"})
	_, _, err := MaterializeReferences(resolved, referenceCatalogForTargets(t, []contracts.ReferenceSlot{{Name: "scene_guide"}}, nil, nil, nil), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err == nil || !strings.Contains(err.Error(), "chunk") || !strings.Contains(err.Error(), "UTF-8") || !strings.Contains(err.Error(), "scene_guide") || !strings.Contains(err.Error(), path) {
		t.Fatalf("error = %v, want chunk UTF-8 path error", err)
	}
}

func TestMaterializeReferencesAllowsAnyMediaTypeWhenSlotDoesNotRestrictIt(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "roster.reference")
	writeReferenceFile(t, path, []byte("plain text"))

	resolved := resolvedPipelineWithReference(t, "roster", "roster.reference", contracts.ReferenceBindingSourceConfig, contracts.ReferenceSlot{Name: "roster"})
	materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{{Name: "roster"}}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	item := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0]
	if item.MediaType != unknownMediaType {
		t.Fatalf("MediaType = %q, want %q", item.MediaType, unknownMediaType)
	}
}

func TestMaterializeReferencesAcceptsDeclaredMarkdownMediaType(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "glossary.md")
	writeReferenceFile(t, path, []byte("# Terms\n"))

	slot := contracts.ReferenceSlot{Name: "glossary", AcceptedMediaTypes: []string{"text/markdown"}}
	resolved := resolvedPipelineWithReference(t, "glossary", "glossary.md", contracts.ReferenceBindingSourceConfig, slot)
	materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	item := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["glossary"].Items[0]
	if item.MediaType != "text/markdown" {
		t.Fatalf("MediaType = %q, want text/markdown", item.MediaType)
	}
}

func TestMaterializeReferencesAcceptsDeclaredJSONMediaType(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "roster.json")
	writeReferenceFile(t, path, []byte(`{"aria":"cleric"}`))

	slot := contracts.ReferenceSlot{Name: "roster", AcceptedMediaTypes: []string{"application/json"}}
	resolved := resolvedPipelineWithReference(t, "roster", "roster.json", contracts.ReferenceBindingSourceConfig, slot)
	materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	item := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0]
	if item.MediaType != "application/json" {
		t.Fatalf("MediaType = %q, want application/json", item.MediaType)
	}
}

func TestMaterializeReferencesAcceptsDeclaredYAMLMediaType(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "roster.yaml")
	writeReferenceFile(t, path, []byte("aria: cleric\n"))

	slot := contracts.ReferenceSlot{Name: "roster", AcceptedMediaTypes: []string{"application/yaml"}}
	resolved := resolvedPipelineWithReference(t, "roster", "roster.yaml", contracts.ReferenceBindingSourceConfig, slot)
	materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	item := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0]
	if item.MediaType != "application/yaml" {
		t.Fatalf("MediaType = %q, want application/yaml", item.MediaType)
	}
}

func TestMaterializeReferencesRejectsUnacceptedMediaType(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "roster.json")
	writeReferenceFile(t, path, []byte(`{"aria":"cleric"}`))

	slot := contracts.ReferenceSlot{Name: "roster", AcceptedMediaTypes: []string{"text/markdown"}}
	resolved := resolvedPipelineWithReference(t, "roster", "roster.json", contracts.ReferenceBindingSourceConfig, slot)
	_, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err == nil || !strings.Contains(err.Error(), "media type") || !strings.Contains(err.Error(), "application/json") || !strings.Contains(err.Error(), "roster") {
		t.Fatalf("error = %v, want media type rejection", err)
	}
}

func TestMaterializeReferencesMatchesAcceptedMediaTypesIgnoringParameters(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "roster.txt")
	writeReferenceFile(t, path, []byte("Aria\n"))

	slot := contracts.ReferenceSlot{Name: "roster", AcceptedMediaTypes: []string{"text/plain; charset=utf-8"}}
	resolved := resolvedPipelineWithReference(t, "roster", "roster.txt", contracts.ReferenceBindingSourceConfig, slot)
	materialized, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	item := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0]
	if item.MediaType != referenceMediaType {
		t.Fatalf("MediaType = %q, want %q", item.MediaType, referenceMediaType)
	}
}

func TestMaterializeReferencesRejectsUnacceptedMediaTypeForNormalizeTarget(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "notes.json")
	writeReferenceFile(t, path, []byte(`{"notes":true}`))

	slot := contracts.ReferenceSlot{Name: "normalization_notes", AcceptedMediaTypes: []string{"text/markdown"}}
	resolved := resolvedPipelineWithTargetReference(t, StageNormalize, "events", "normalization_notes", "notes.json", contracts.ReferenceBindingSourceConfig, slot)
	_, _, err := MaterializeReferences(resolved, referenceCatalogForTargets(t, nil, nil, nil, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err == nil || !strings.Contains(err.Error(), "normalize") || !strings.Contains(err.Error(), "media type") || !strings.Contains(err.Error(), "application/json") || !strings.Contains(err.Error(), "normalization_notes") {
		t.Fatalf("error = %v, want normalize media type rejection", err)
	}
}

func TestMaterializeReferencesWarnsForEmptyFiles(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "empty.txt")
	writeReferenceFile(t, path, nil)

	resolved := resolvedPipelineWithReference(t, "roster", "empty.txt", contracts.ReferenceBindingSourceConfig, contracts.ReferenceSlot{Name: "roster"})
	materialized, diagnostics, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{{Name: "roster"}}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	if len(diagnostics) != 1 || diagnostics[0].Disposition != contracts.DiagnosticDispositionWarning || diagnostics[0].Category != contracts.DiagnosticCategoryConfiguration || diagnostics[0].ReasonCode != "empty_reference" || diagnostics[0].OccurrenceCount != 1 || len(diagnostics[0].Samples) != 1 {
		t.Fatalf("diagnostics = %#v, want structured empty-reference signal", diagnostics)
	}
	item := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0]
	if item.SizeBytes != 0 || item.Digest != referenceDigest(nil) {
		t.Fatalf("empty item = %#v, want zero size and empty digest", item)
	}
}

func TestMaterializeReferencesWarningScopesIncludeTargetContext(t *testing.T) {
	configDir := t.TempDir()
	writeReferenceFile(t, filepath.Join(configDir, "chunk.txt"), nil)
	writeReferenceFile(t, filepath.Join(configDir, "extract.txt"), nil)
	writeReferenceFile(t, filepath.Join(configDir, "normalize.txt"), nil)

	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{
		"scene_guide":         "chunk.txt",
		"roster":              "extract.txt",
		"normalization_notes": "normalize.txt",
	})
	catalog := referenceCatalogForTargets(t,
		[]contracts.ReferenceSlot{{Name: "scene_guide"}},
		[]contracts.ReferenceSlot{{Name: "roster"}},
		nil,
		[]contracts.ReferenceSlot{{Name: "normalization_notes"}},
	)
	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	_, diagnostics, err := MaterializeReferences(resolved, catalog, ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err != nil {
		t.Fatalf("MaterializeReferences() error = %v, want nil", err)
	}
	got := diagnosticScopes(diagnostics)
	want := []string{
		"pipeline.baseline.chunk.reference.scene_guide",
		"pipeline.baseline.lane.events.extract.reference.roster",
		"pipeline.baseline.lane.events.normalize.reference.normalization_notes",
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("warning scopes = %#v, want %#v", got, want)
	}
}

func TestMaterializeReferencesEnforcesMaxBytes(t *testing.T) {
	configDir := t.TempDir()
	path := filepath.Join(configDir, "large.txt")
	writeReferenceFile(t, path, []byte("too large"))

	slot := contracts.ReferenceSlot{Name: "roster", MaxBytes: 3}
	resolved := resolvedPipelineWithReference(t, "roster", "large.txt", contracts.ReferenceBindingSourceConfig, slot)
	_, _, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{slot}), ReferenceMaterializationOptions{
		ConfigPath: filepath.Join(configDir, "config.yml"),
	})
	if err == nil || !strings.Contains(err.Error(), "9 bytes") || !strings.Contains(err.Error(), "limit 3") || !strings.Contains(err.Error(), "roster") {
		t.Fatalf("error = %v, want max bytes error", err)
	}
}

func resolvedPipelineWithReference(t *testing.T, slotName, source, bindingSource string, slot contracts.ReferenceSlot) ResolvedPipeline {
	t.Helper()
	return resolvedPipelineWithTargetReference(t, StageExtract, "events", slotName, source, bindingSource, slot)
}

func resolvedPipelineWithTargetReference(t *testing.T, stage ModuleStage, laneID string, slotName, source, bindingSource string, slot contracts.ReferenceSlot) ResolvedPipeline {
	t.Helper()
	profile := baselineProfile()
	switch stage {
	case StageChunk:
		profile.Chunk.References = ExternalReferenceMap(map[string]string{slotName: source})
	case StageExtract:
		lane := profile.Artifacts[laneID]
		lane.References = ExternalReferenceMap(map[string]string{slotName: source})
		profile.Artifacts[laneID] = lane
	case StageMerge:
		lane := profile.Artifacts[laneID]
		lane.Merge.References = ExternalReferenceMap(map[string]string{slotName: source})
		profile.Artifacts[laneID] = lane
	case StageNormalize:
		lane := profile.Artifacts[laneID]
		lane.Normalize.References = ExternalReferenceMap(map[string]string{slotName: source})
		profile.Artifacts[laneID] = lane
	default:
		t.Fatalf("unsupported reference target stage %q", stage)
	}
	catalog := referenceCatalogForStage(t, stage, []contracts.ReferenceSlot{slot})
	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	if bindingSource != contracts.ReferenceBindingSourceConfig {
		switch stage {
		case StageChunk:
			resolved.ChunkReferences.Bindings[0].BindingSource = bindingSource
		case StageExtract:
			resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings[0].BindingSource = bindingSource
		case StageMerge:
			resolved.Steps[0].ArtifactLanes[0].MergeReferences.Bindings[0].BindingSource = bindingSource
		case StageNormalize:
			resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings[0].BindingSource = bindingSource
		}
	}
	return resolved
}

func referenceCatalog(t *testing.T, slots []contracts.ReferenceSlot) ModuleCatalog {
	t.Helper()
	return referenceCatalogForStage(t, StageExtract, slots)
}

func referenceCatalogForStage(t *testing.T, stage ModuleStage, slots []contracts.ReferenceSlot) ModuleCatalog {
	t.Helper()
	switch stage {
	case StageChunk:
		return referenceCatalogForTargets(t, slots, nil, nil, nil)
	case StageExtract:
		return referenceCatalogForTargets(t, nil, slots, nil, nil)
	case StageMerge:
		return referenceCatalogForTargets(t, nil, nil, slots, nil)
	case StageNormalize:
		return referenceCatalogForTargets(t, nil, nil, nil, slots)
	default:
		t.Fatalf("unsupported reference target stage %q", stage)
		return ModuleCatalog{}
	}
}

func referenceCatalogForTargets(t *testing.T, chunkSlots, extractSlots, mergeSlots, normalizeSlots []contracts.ReferenceSlot) ModuleCatalog {
	t.Helper()
	return newProfileCatalogWithOverrides(t,
		ModuleSpec{
			Key:            "generic",
			Stage:          StageChunk,
			ExecutionClass: contracts.ExecutionClassDeterministic,
			Requires:       []string{"source"},
			Provides:       []string{"chunk"},
			ReferenceSlots: chunkSlots,
		},
		ModuleSpec{
			Key:            "event-extractor",
			Stage:          StageExtract,
			ExecutionClass: contracts.ExecutionClassDeterministic,
			Requires:       []string{"chunk"},
			Provides:       []string{"candidate"},
			ReferenceSlots: extractSlots,
		},
		ModuleSpec{
			Key:            "appendorder",
			Stage:          StageMerge,
			ExecutionClass: contracts.ExecutionClassDeterministic,
			Requires:       []string{"candidate"},
			Provides:       []string{"merged"},
			ReferenceSlots: mergeSlots,
		},
		ModuleSpec{
			Key:            "noop",
			Stage:          StageNormalize,
			ExecutionClass: contracts.ExecutionClassDeterministic,
			Requires:       []string{"merged"},
			Provides:       []string{"normalized"},
			ReferenceSlots: normalizeSlots,
		},
	)
}

func diagnosticScopes(diagnostics []contracts.ProducerDiagnostic) []string {
	scopes := make([]string, 0, len(diagnostics))
	for _, diagnostic := range diagnostics {
		scopes = append(scopes, diagnostic.Samples[0].Scope)
	}
	sort.Strings(scopes)
	return scopes
}

func writeReferenceFile(t *testing.T, path string, content []byte) {
	t.Helper()
	if err := os.WriteFile(path, content, 0o644); err != nil {
		t.Fatalf("write reference %q: %v", path, err)
	}
}
