package pipeline

import (
	"context"
	"encoding/json"
	"errors"
	"reflect"
	"strings"
	"testing"
	"time"

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

type recordingChunkPlanStore struct {
	record   ChunkPlanRecord
	decision ChunkPlanDecision
	loadErr  error
	saveErr  error
	loads    int
	saves    int
	saved    ChunkPlanRecord
}

func (s *recordingChunkPlanStore) Load(string) (ChunkPlanRecord, ChunkPlanDecision, error) {
	s.loads++
	return s.record, s.decision, s.loadErr
}

func (s *recordingChunkPlanStore) Save(record ChunkPlanRecord) error {
	s.saves++
	s.saved = record
	return s.saveErr
}

type countingChunkValidator struct {
	calls  int
	result contracts.ValidationResult
	err    error
}

type capturingChunkMapOutput struct {
	requests []contracts.OutputRequest
}

func (*capturingChunkMapOutput) Key() string { return "capture/chunk-map" }

func (output *capturingChunkMapOutput) Encode(_ context.Context, request contracts.OutputRequest) (contracts.OutputResult, error) {
	output.requests = append(output.requests, request)
	return contracts.OutputResult{}, nil
}

func (*countingChunkValidator) Name() string { return "test/counting-chunks" }
func (*countingChunkValidator) ExecutionClass() contracts.ExecutionClass {
	return contracts.ExecutionClassDeterministic
}
func (v *countingChunkValidator) Validate(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
	v.calls++
	return v.result, v.err
}

type manifestChunker struct {
	terminalChunker
	metadata map[string]any
}

func (c manifestChunker) ManifestMetadata() map[string]any { return c.metadata }

type llmCountingChunker struct {
	terminalChunker
	llmCalls *int
}

func (c llmCountingChunker) Plan(ctx context.Context, request contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
	(*c.llmCalls)++
	return c.terminalChunker.Plan(ctx, request)
}

type retryingChunker struct {
	key   string
	plan  source.ChunkPlan
	calls int
}

type correctionAwareChunker struct {
	key        string
	plan       source.ChunkPlan
	calls      int
	correction *contracts.SemanticCorrection
}

func (c *correctionAwareChunker) Key() string                             { return c.key }
func (*correctionAwareChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (c *correctionAwareChunker) Plan(_ context.Context, request contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
	c.calls++
	if request.Correction != nil {
		correction, err := contracts.CloneSemanticCorrection(request.Correction)
		if err != nil {
			return contracts.ChunkPlanResult{}, err
		}
		c.correction = correction
	}
	response := "initial chunk response"
	if c.calls > 1 {
		response = "corrected chunk response"
	}
	candidate, err := contracts.NewModelCandidate([]byte(response), contracts.CorrectionProtocolSingleResponseV1)
	if err != nil {
		return contracts.ChunkPlanResult{}, err
	}
	return contracts.ChunkPlanResult{Plan: source.CloneChunkPlan(c.plan), ModelCandidate: candidate}, nil
}

func (c *retryingChunker) Key() string                             { return c.key }
func (*retryingChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (c *retryingChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
	c.calls++
	if c.calls == 1 {
		return contracts.ChunkPlanResult{Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("discarded", "discarded diagnostic")}}, errors.New("retry generation")
	}
	return contracts.ChunkPlanResult{Plan: source.CloneChunkPlan(c.plan), Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("accepted", "accepted diagnostic")}}, nil
}

type dependencyLoader struct {
	CheckpointLoader
	extractDependencies []CheckpointFingerprint
}

func (l *dependencyLoader) Extract(_ string, _ string, dependencies []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) {
	l.extractDependencies = append([]CheckpointFingerprint(nil), dependencies...)
	return ExtractCheckpoint{}, CheckpointDecision{Reason: "not found"}
}

func TestRunnerChunkPlanModeMatrix(t *testing.T) {
	tests := []struct {
		name       string
		mode       ChunkCacheMode
		decision   ChunkPlanDecision
		wantLoads  int
		wantSaves  int
		wantCalls  int
		wantAction string
	}{
		{name: "auto hit", mode: ChunkCacheAuto, decision: ChunkPlanDecision{Status: ChunkPlanHit}, wantLoads: 1, wantAction: "reused"},
		{name: "auto missing", mode: ChunkCacheAuto, decision: ChunkPlanDecision{Status: ChunkPlanMissing}, wantLoads: 1, wantSaves: 1, wantCalls: 1, wantAction: "generated"},
		{name: "auto invalid", mode: ChunkCacheAuto, decision: ChunkPlanDecision{Status: ChunkPlanInvalid}, wantLoads: 1, wantSaves: 1, wantCalls: 1, wantAction: "generated"},
		{name: "bypass", mode: ChunkCacheBypass, wantCalls: 1, wantAction: "bypassed"},
		{name: "empty bypass", wantCalls: 1, wantAction: "bypassed"},
		{name: "refresh", mode: ChunkCacheRefresh, wantSaves: 1, wantCalls: 1, wantAction: "refreshed"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			prepared, plan := preparedTerminalDebugPipeline(t)
			calls := 0
			llmCalls := 0
			prepared.chunker = llmCountingChunker{terminalChunker: terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, calls: &calls}, llmCalls: &llmCalls}
			store := &recordingChunkPlanStore{record: chunkPlanRecord(t, prepared, plan), decision: tc.decision}
			output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: tc.mode, ChunkPlans: store})
			if err != nil {
				t.Fatalf("Run() error = %v", err)
			}
			if store.loads != tc.wantLoads || store.saves != tc.wantSaves || calls != tc.wantCalls {
				t.Fatalf("calls = load %d save %d module %d, want %d %d %d", store.loads, store.saves, calls, tc.wantLoads, tc.wantSaves, tc.wantCalls)
			}
			if output.Manifest.ChunkPlan == nil || output.Manifest.ChunkPlan.Action != tc.wantAction || output.ChunkPlan == nil || output.ChunkPlan.Action != tc.wantAction {
				t.Fatalf("chunk plan manifest = %#v summary = %#v, want action %q", output.Manifest.ChunkPlan, output.ChunkPlan, tc.wantAction)
			}
		})
	}
}

func TestRunnerUsesStatusDerivedChunkPlanSummaryReasons(t *testing.T) {
	for _, tc := range []struct {
		name       string
		status     ChunkPlanStatus
		wantStatus string
		wantReason string
	}{
		{name: "hit", status: ChunkPlanHit, wantStatus: "hit", wantReason: "stored chunk plan is valid"},
		{name: "missing", status: ChunkPlanMissing, wantStatus: "missing", wantReason: "chunk plan not found"},
		{name: "invalid", status: ChunkPlanInvalid, wantStatus: "invalid", wantReason: "stored chunk plan is invalid"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			prepared, plan := preparedTerminalDebugPipeline(t)
			store := &recordingChunkPlanStore{
				record:   chunkPlanRecord(t, prepared, plan),
				decision: ChunkPlanDecision{Status: tc.status, Reason: "SENTINEL_STORE_REASON"},
			}
			output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
			if err != nil {
				t.Fatal(err)
			}
			if output.ChunkPlan.LookupStatus != tc.wantStatus || output.ChunkPlan.LookupReason != tc.wantReason || strings.Contains(output.ChunkPlan.LookupReason, "SENTINEL_STORE_REASON") {
				t.Fatalf("summary = %#v", output.ChunkPlan)
			}
		})
	}

	prepared, _ := preparedTerminalDebugPipeline(t)
	output, err := New().Run(context.Background(), RunInput{
		Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto,
		ChunkPlans: &recordingChunkPlanStore{loadErr: errors.New("read failed")},
	})
	if err == nil || output.ChunkPlan == nil || output.ChunkPlan.LookupStatus != "skipped" || output.ChunkPlan.LookupReason != "chunk plan lookup skipped" {
		t.Fatalf("operational lookup output=%#v error=%v", output.ChunkPlan, err)
	}
}

func TestRunnerChunkPlanHitUsesStoredProducerProvenance(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	prepared.resolved.Chunk.Module = "chunk/requested"
	prepared.resolved.Chunk.LLMProfile = "current-profile"
	record := chunkPlanRecord(t, prepared, plan)
	record.Producer.InputModule = "input/original"
	record.Producer.ChunkModule = "chunk/original"
	record.Producer.LLMProfile = "original-profile"
	record.Producer.Metadata = map[string]any{"prompt": map[string]any{"id": "original"}}
	store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
	if err != nil {
		t.Fatal(err)
	}
	manifest := output.Manifest.ChunkPlan
	if manifest.RequestedModule != "chunk/requested" || manifest.ProducerModule != "chunk/original" || manifest.ProducerLLMProfile != "original-profile" {
		t.Fatalf("chunk plan manifest = %#v", manifest)
	}
	storedNested := record.Producer.Metadata["prompt"].(map[string]any)
	storedNested["id"] = "mutated"
	if got := manifest.ProducerMetadata["prompt"].(map[string]any)["id"]; got != "original" {
		t.Fatalf("producer metadata changed through stored alias: %v", got)
	}
}

func TestRunnerReusesChunkPlanDiagnosticsWithCurrentValidatorDiagnostics(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	record := chunkPlanRecord(t, prepared, plan)
	record.Diagnostics = []contracts.ProducerDiagnostic{{
		Disposition:     contracts.DiagnosticDispositionWarning,
		Category:        contracts.DiagnosticCategoryConfiguration,
		ReasonCode:      "stored_chunk_diagnostic",
		OccurrenceCount: 1,
		Samples:         []contracts.DiagnosticSample{{Scope: "reference", Message: "Stored configuration signal."}},
	}}
	validator := &countingChunkValidator{result: contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{{
		Disposition:     contracts.DiagnosticDispositionAdvisory,
		Category:        contracts.DiagnosticCategoryDataQuality,
		ReasonCode:      "current_validator_diagnostic",
		OccurrenceCount: 1,
		Samples:         []contracts.DiagnosticSample{{Scope: "chunk", Message: "Current validator finding."}},
	}}}}
	prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}}

	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}})
	if err != nil {
		t.Fatal(err)
	}
	if validator.calls != 1 || len(output.Diagnostics.Groups) != 2 {
		t.Fatalf("validator calls = %d diagnostics = %#v", validator.calls, output.Diagnostics)
	}
	if output.Diagnostics.Groups[0].ReasonCode != "stored_chunk_diagnostic" || output.Diagnostics.Groups[1].ReasonCode != "current_validator_diagnostic" || output.Diagnostics.Groups[1].Origin.ValidatorKey != validator.Name() {
		t.Fatalf("diagnostic groups = %#v", output.Diagnostics.Groups)
	}
}

func TestRunnerProvidesAcceptedChunkMapToOutput(t *testing.T) {
	for _, test := range []struct {
		name      string
		configure func(*PreparedPipeline, source.ChunkPlan) (RunInput, chunkmap.Producer)
	}{
		{
			name: "bypassed plan",
			configure: func(prepared *PreparedPipeline, _ source.ChunkPlan) (RunInput, chunkmap.Producer) {
				return RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheBypass}, chunkmap.Producer{
					InputModule: prepared.input.Key(), ChunkModule: prepared.chunker.Key(),
				}
			},
		},
		{
			name: "cached plan",
			configure: func(prepared *PreparedPipeline, plan source.ChunkPlan) (RunInput, chunkmap.Producer) {
				prepared.resolved.Chunk.Module = "chunk/requested"
				record := chunkPlanRecord(t, prepared, plan)
				record.Producer.InputModule = "input/original"
				record.Producer.ChunkModule = "chunk/original"
				record.Producer.LLMProfile = "original-profile"
				return RunInput{
					Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto,
					ChunkPlans: &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}},
				}, chunkmap.Producer{InputModule: "input/original", ChunkModule: "chunk/original", LLMProfile: "original-profile"}
			},
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			prepared, plan := preparedTerminalDebugPipeline(t)
			encoder := &capturingChunkMapOutput{}
			prepared.output = encoder
			input, wantProducer := test.configure(prepared, plan)
			if _, err := New().Run(context.Background(), input); err != nil {
				t.Fatalf("Run() error = %v", err)
			}
			if len(encoder.requests) != 1 || encoder.requests[0].ChunkMap == nil {
				t.Fatalf("output requests = %#v, want one accepted chunk map", encoder.requests)
			}
			artifact := encoder.requests[0].ChunkMap
			if artifact.Kind != chunkmap.ArtifactKind || artifact.Schema.ID != chunkmap.SchemaID || artifact.MediaType != chunkmap.MediaType {
				t.Fatalf("chunk map artifact = %#v, want fixed serialized identity", artifact)
			}
			value, err := chunkmap.New().Decode(artifact.Content)
			if err != nil {
				t.Fatalf("decode output chunk map: %v", err)
			}
			if value.RequestedChunker != prepared.resolved.Chunk.Module || value.Producer != wantProducer {
				t.Fatalf("chunk map = %#v, want current request and producer %#v", value, wantProducer)
			}
		})
	}
}

func TestRunnerOmitsChunkMapForRejectedPlan(t *testing.T) {
	prepared, _ := preparedTerminalDebugPipeline(t)
	encoder := &capturingChunkMapOutput{}
	prepared.output = encoder
	prepared.chunkValidators.validators = []preparedValidator{{
		resolved: ResolvedValidator{Binding: Binding("reject/chunk"), Target: ValidatorTargetChunk},
		chunk:    &countingChunkValidator{result: contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "not accepted", CorrectionGuidance: "return an acceptable chunk plan"}},
	}}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if len(output.Rejected) != 1 || len(encoder.requests) != 1 || encoder.requests[0].ChunkMap != nil {
		t.Fatalf("output = %#v requests = %#v, want rejected plan without chunk map", output.Rejected, encoder.requests)
	}
}

func TestRunnerCorrectsRejectedGeneratedChunkPlan(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	prepared.resolved.ChunkExecutionClass = contracts.ExecutionClassLLMBacked
	prepared.resolved.Chunk.Retries = 1
	chunker := &correctionAwareChunker{key: prepared.resolved.Chunk.Module, plan: plan}
	prepared.chunker = chunker
	validatorCalls := 0
	prepared.chunkValidators.validators = []preparedValidator{{
		resolved: ResolvedValidator{Binding: Binding("reject-then-approve"), Target: ValidatorTargetChunk},
		chunk: chunkValidationFunc{name: "reject-then-approve", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
			validatorCalls++
			if validatorCalls == 1 {
				return contracts.ValidationResult{ReasonCode: "chunk_scope", Message: "scope needs correction", CorrectionGuidance: "correct the chunk scope"}, nil
			}
			return contracts.ValidationResult{Approved: true}, nil
		}},
	}}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if len(output.Rejected) != 0 || chunker.calls != 2 || validatorCalls != 2 {
		t.Fatalf("output = %#v chunker calls = %d validator calls = %d", output.Rejected, chunker.calls, validatorCalls)
	}
	if got := string(chunker.correction.AssistantResponse); got != "initial chunk response" {
		t.Fatalf("correction response = %q", got)
	}
}

func TestRunnerDoesNotPublishValidationIncompleteChunkPlan(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	prepared.resolved.ChunkValidationPolicy.ValidatorFailure = ValidatorFailureWarnContinue
	prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan}
	prepared.chunkValidators.validators = []preparedValidator{{
		resolved: ResolvedValidator{Binding: Binding("unavailable"), Target: ValidatorTargetChunk},
		chunk:    &countingChunkValidator{err: errors.New("validator unavailable")},
	}}
	store := &recordingChunkPlanStore{decision: ChunkPlanDecision{Status: ChunkPlanMissing}}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if output.ChunkPlan == nil || output.ChunkPlan.ValidationStatus != "incomplete" || output.ChunkPlan.PublicationStatus != "not_published" || store.saves != 0 {
		t.Fatalf("chunk summary = %#v saves = %d", output.ChunkPlan, store.saves)
	}
}

func TestRunnerRegeneratesValidationIncompleteChunkPlanHit(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	prepared.resolved.ChunkValidationPolicy.ValidatorFailure = ValidatorFailureWarnContinue
	calls := 0
	prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, calls: &calls}
	validatorCalls := 0
	prepared.chunkValidators.validators = []preparedValidator{{
		resolved: ResolvedValidator{Binding: Binding("cache-then-generated"), Target: ValidatorTargetChunk},
		chunk: chunkValidationFunc{name: "cache-then-generated", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
			validatorCalls++
			if validatorCalls == 1 {
				return contracts.ValidationResult{}, errors.New("cached candidate could not be validated")
			}
			return contracts.ValidationResult{Approved: true}, nil
		}},
	}}
	record := chunkPlanRecord(t, prepared, plan)
	record.Diagnostics = []contracts.ProducerDiagnostic{producerDiagnostic("stored", "discarded stored diagnostic")}
	store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
	debug := newCapturedDebugRecorder()

	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store, Debug: debug})
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if calls != 1 || validatorCalls != 2 || store.saves != 1 {
		t.Fatalf("calls = producer %d validator %d saves %d, want 1 2 1", calls, validatorCalls, store.saves)
	}
	if output.ChunkPlan == nil || output.ChunkPlan.Action != "generated" || output.ChunkPlan.LookupStatus != "invalid" || output.ChunkPlan.ValidationStatus != "approved" {
		t.Fatalf("chunk plan summary = %#v", output.ChunkPlan)
	}
	if len(output.Diagnostics.Groups) != 0 {
		t.Fatalf("diagnostics = %#v, want discarded cache diagnostics omitted", output.Diagnostics)
	}
	assertAttemptEnvelopeSequence(t, debug, "chunk", 1)
}

func TestRunnerKeepsStoredPlanWhenCacheAndGeneratedValidationAreIncomplete(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	prepared.resolved.ChunkValidationPolicy.ValidatorFailure = ValidatorFailureWarnContinue
	calls := 0
	prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, calls: &calls}
	validator := &countingChunkValidator{err: errors.New("validator unavailable")}
	prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}}
	record := chunkPlanRecord(t, prepared, plan)
	record.Diagnostics = []contracts.ProducerDiagnostic{producerDiagnostic("stored", "discarded stored diagnostic")}
	store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}

	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if calls != 1 || validator.calls != 2 || store.saves != 0 {
		t.Fatalf("calls = producer %d validator %d saves %d, want 1 2 0", calls, validator.calls, store.saves)
	}
	if output.ChunkPlan == nil || output.ChunkPlan.Action != "generated" || output.ChunkPlan.LookupStatus != "invalid" || output.ChunkPlan.ValidationStatus != "incomplete" {
		t.Fatalf("chunk plan summary = %#v", output.ChunkPlan)
	}
	if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].ReasonCode != "validator_execution_incomplete" {
		t.Fatalf("diagnostics = %#v, want only generated incomplete diagnostic", output.Diagnostics)
	}
	if !reflect.DeepEqual(store.record, record) {
		t.Fatal("discarded incomplete candidates mutated the stored record")
	}
}

func TestRunnerFailsOnValidationIncompleteChunkPlanHitUnderFailRun(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	calls := 0
	prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, calls: &calls}
	validator := &countingChunkValidator{err: errors.New("validator unavailable")}
	prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}}
	store := &recordingChunkPlanStore{record: chunkPlanRecord(t, prepared, plan), decision: ChunkPlanDecision{Status: ChunkPlanHit}}

	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
	if err == nil || !strings.Contains(err.Error(), "validator unavailable") {
		t.Fatalf("Run() error = %v, want cached validation failure", err)
	}
	if calls != 0 || validator.calls != 1 || store.saves != 0 {
		t.Fatalf("calls = producer %d validator %d saves %d, want 0 1 0", calls, validator.calls, store.saves)
	}
	if output.ChunkPlan == nil || output.ChunkPlan.ValidationStatus != "error" {
		t.Fatalf("chunk plan summary = %#v", output.ChunkPlan)
	}
}

func TestRunnerRetainsChunkMapAfterLaneRejection(t *testing.T) {
	prepared, _ := preparedTerminalDebugPipeline(t)
	encoder := &capturingChunkMapOutput{}
	prepared.output = encoder
	prepared.Steps[0].lanes[0].mergeValidators.validators = []preparedValidator{{
		resolved: ResolvedValidator{Binding: Binding("reject/lane"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
		typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
			return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "not accepted", CorrectionGuidance: "return an acceptable candidate"}, nil
		},
	}}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if len(output.Rejected) != 1 || len(encoder.requests) != 1 || encoder.requests[0].ChunkMap == nil {
		t.Fatalf("output = %#v requests = %#v, want lane rejection with accepted chunk map", output.Rejected, encoder.requests)
	}
}

func TestRunnerChunkMapRequestDoesNotAliasStoredPlan(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	encoder := &capturingChunkMapOutput{}
	prepared.output = encoder
	record := chunkPlanRecord(t, prepared, plan)
	record.Plan.Annotations = source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"title":"Opening"}`)}
	store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
	if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	store.record.Plan.Annotations["dnd/scenes"][0] = '['
	value, err := chunkmap.New().Decode(encoder.requests[0].ChunkMap.Content)
	if err != nil {
		t.Fatalf("decode captured chunk map after store mutation: %v", err)
	}
	if string(value.PlanAnnotations["dnd/scenes"]) != `{"title":"Opening"}` {
		t.Fatalf("captured chunk map aliases stored plan: %s", value.PlanAnnotations["dnd/scenes"])
	}
}

func TestRunnerChunkPlanManifestRetainsCandidateOnRejection(t *testing.T) {
	prepared, _ := preparedTerminalDebugPipeline(t)
	validator := &countingChunkValidator{result: contracts.ValidationResult{Approved: false, ReasonCode: "policy", Message: "no", CorrectionGuidance: "return a policy-compliant chunk plan"}}
	prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheRefresh, ChunkPlans: &recordingChunkPlanStore{}})
	if err != nil {
		t.Fatal(err)
	}
	if output.Manifest.ChunkPlan.Action != "" || output.Manifest.ChunkPlan.PlanDigest != "" || output.ChunkPlan.ValidationStatus != "rejected" || output.ChunkPlan.PublicationStatus != "not_published" {
		t.Fatalf("manifest = %#v summary = %#v; rejected output must not retain a usable chunk candidate", output.Manifest.ChunkPlan, output.ChunkPlan)
	}
}

func TestRunnerValidatesChunkPlanPolicyInputs(t *testing.T) {
	for _, tc := range []struct {
		name string
		mode ChunkCacheMode
		want string
	}{
		{name: "auto requires store", mode: ChunkCacheAuto, want: "store is required"},
		{name: "refresh requires store", mode: ChunkCacheRefresh, want: "store is required"},
		{name: "invalid mode", mode: "sometimes", want: "not supported"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			prepared, _ := preparedTerminalDebugPipeline(t)
			_, err := New().Run(context.Background(), RunInput{Prepared: prepared, ChunkCacheMode: tc.mode})
			if err == nil || !strings.Contains(err.Error(), tc.want) {
				t.Fatalf("Run() error = %v, want %q", err, tc.want)
			}
		})
	}
}

func TestRunnerChunkPlanStoreErrorsAreFrameworkErrors(t *testing.T) {
	for _, tc := range []struct {
		name      string
		mode      ChunkCacheMode
		decision  ChunkPlanDecision
		loadErr   error
		saveErr   error
		wantError string
		wantCalls int
	}{
		{name: "load", mode: ChunkCacheAuto, loadErr: errors.New("read failed"), wantError: "load chunk plan", wantCalls: 0},
		{name: "save", mode: ChunkCacheRefresh, saveErr: errors.New("write failed"), wantError: "save chunk plan", wantCalls: 1},
	} {
		t.Run(tc.name, func(t *testing.T) {
			prepared, plan := preparedTerminalDebugPipeline(t)
			calls := 0
			llmCalls := 0
			prepared.chunker = llmCountingChunker{terminalChunker: terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, calls: &calls}, llmCalls: &llmCalls}
			store := &recordingChunkPlanStore{decision: tc.decision, loadErr: tc.loadErr, saveErr: tc.saveErr}
			_, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: tc.mode, ChunkPlans: store})
			if err == nil || !strings.Contains(err.Error(), tc.wantError) || calls != tc.wantCalls {
				t.Fatalf("Run() error = %v, module calls = %d, want %q and %d", err, calls, tc.wantError, tc.wantCalls)
			}
		})
	}
}

func TestRunnerRegeneratesStructurallyInvalidHit(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	calls := 0
	prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, calls: &calls}
	invalid := chunkPlanRecord(t, prepared, plan)
	invalid.Plan.Ranges[0].StartUnitID = 999
	store := &recordingChunkPlanStore{record: invalid, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
	if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store}); err != nil {
		t.Fatal(err)
	}
	if calls != 1 || store.loads != 1 || store.saves != 1 {
		t.Fatalf("calls = module %d load %d save %d, want 1 1 1", calls, store.loads, store.saves)
	}
}

func TestRunnerReusesAnnotationsFromDifferentChunkerAsOptionalData(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	plan.Annotations = source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"label":"fixture"}`)}
	plan.Ranges[0].Annotations = source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"category":"sample"}`)}
	record := chunkPlanRecord(t, prepared, plan)
	record.Producer.ChunkModule = "chunk/producer"
	store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
	if err != nil {
		t.Fatal(err)
	}
	if output.Manifest.ChunkPlan.ProducerModule != "chunk/producer" || output.Manifest.ChunkPlan.Action != "reused" {
		t.Fatalf("different-chunker annotation plan was not reused: %#v", output.Manifest.ChunkPlan)
	}
}

func TestRunnerRetriesBeforePublishingAcceptedPlan(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	prepared.resolved.Chunk.Retries = 1
	chunker := &retryingChunker{key: prepared.resolved.Chunk.Module, plan: plan}
	prepared.chunker = chunker
	store := &recordingChunkPlanStore{decision: ChunkPlanDecision{Status: ChunkPlanMissing}}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
	if err != nil {
		t.Fatal(err)
	}
	if chunker.calls != 2 || store.saves != 1 {
		t.Fatalf("module calls = %d saves = %d, want 2 and 1", chunker.calls, store.saves)
	}
	if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].Samples[0].Scope != "accepted" || len(store.saved.Diagnostics) != 1 || store.saved.Diagnostics[0].Samples[0].Scope != "accepted" {
		t.Fatalf("output diagnostics = %#v stored diagnostics = %#v", output.Diagnostics, store.saved.Diagnostics)
	}
}

func TestRunnerAutoHitValidatesOnceWithoutRegenerationOrMutation(t *testing.T) {
	tests := []struct {
		name         string
		result       contracts.ValidationResult
		validatorErr error
		wantError    string
		wantReject   bool
	}{
		{name: "diagnostic", result: contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("current", "current diagnostic")}}},
		{name: "rejection", result: contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "rejected hit", CorrectionGuidance: "return an acceptable chunk plan"}, wantReject: true},
		{name: "error", validatorErr: errors.New("validator failed"), wantError: "validator failed"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			prepared, plan := preparedTerminalDebugPipeline(t)
			calls := 0
			llmCalls := 0
			prepared.chunker = llmCountingChunker{terminalChunker: terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, calls: &calls}, llmCalls: &llmCalls}
			validator := &countingChunkValidator{result: tc.result, err: tc.validatorErr}
			prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}}
			record := chunkPlanRecord(t, prepared, plan)
			record.Diagnostics = []contracts.ProducerDiagnostic{producerDiagnostic("stored", "stored diagnostic")}
			store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
			debug := newCapturedDebugRecorder()
			output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store, Debug: debug})
			if tc.wantError != "" {
				if err == nil || !strings.Contains(err.Error(), tc.wantError) {
					t.Fatalf("Run() error = %v, want %q", err, tc.wantError)
				}
			} else if err != nil {
				t.Fatalf("Run() error = %v", err)
			}
			wantProducerCalls := 0
			if tc.wantReject {
				wantProducerCalls = 1
			}
			if validator.calls != 1+wantProducerCalls || calls != wantProducerCalls || llmCalls != wantProducerCalls || store.loads != 1 || store.saves != 0 {
				t.Fatalf("calls = validator %d module %d llm %d load %d save %d", validator.calls, calls, llmCalls, store.loads, store.saves)
			}
			if tc.wantReject {
				assertAttemptEnvelopeSequence(t, debug, "chunk", 1)
			} else {
				assertAttemptEnvelopeSequence(t, debug, "chunk")
			}
			if tc.wantError == "" {
				encoded := string(debug.json["chunk/output.json"])
				if tc.wantReject {
					if !strings.Contains(encoded, `"status":"invalid"`) || strings.Contains(encoded, `"plan":`) {
						t.Fatalf("rejected cache debug = %s", encoded)
					}
				} else if !strings.Contains(encoded, `"status":"hit"`) || !strings.Contains(encoded, `"plan":`) || !strings.Contains(encoded, `"materialized_chunks":`) {
					t.Fatalf("chunk hit debug = %s", encoded)
				}
			}
			if tc.wantReject && (len(output.Rejected) != 1 || output.Rejected[0].ReasonCode != "rejected") {
				t.Fatalf("rejected = %#v", output.Rejected)
			}
			if tc.wantError == "" && !tc.wantReject && len(output.Diagnostics.Groups) != 1+len(tc.result.Diagnostics) {
				t.Fatalf("diagnostics = %#v, want stored diagnostic once plus current diagnostics", output.Diagnostics)
			}
		})
	}
}

func TestRunnerPublishesOnlyAcceptedGeneratedPlans(t *testing.T) {
	for _, tc := range []struct {
		name       string
		moduleErr  error
		validator  contracts.ValidationResult
		cancel     bool
		wantCalls  int
		wantReject bool
	}{
		{name: "module error", moduleErr: errors.New("generation failed"), wantCalls: 2},
		{name: "validator rejection", validator: contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "rejected generated plan", CorrectionGuidance: "return an acceptable chunk plan"}, wantCalls: 1, wantReject: true},
		{name: "cancellation", cancel: true},
	} {
		t.Run(tc.name, func(t *testing.T) {
			prepared, plan := preparedTerminalDebugPipeline(t)
			prepared.resolved.Chunk.Retries = 1
			calls := 0
			prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, calls: &calls, err: tc.moduleErr}
			if tc.wantReject {
				validator := &countingChunkValidator{result: tc.validator}
				prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}}
			}
			ctx := context.Background()
			if tc.cancel {
				cancelled, cancel := context.WithCancel(ctx)
				cancel()
				ctx = cancelled
			}
			store := &recordingChunkPlanStore{decision: ChunkPlanDecision{Status: ChunkPlanInvalid}}
			output, err := New().Run(ctx, RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
			if tc.wantReject {
				if err != nil || len(output.Rejected) != 1 {
					t.Fatalf("Run() error = %v rejected = %#v", err, output.Rejected)
				}
			} else if err == nil {
				t.Fatal("Run() error = nil")
			}
			if calls != tc.wantCalls || store.saves != 0 {
				t.Fatalf("module calls = %d, saves = %d; want %d, 0", calls, store.saves, tc.wantCalls)
			}
		})
	}
}

func TestRunnerStoresProducerProvenanceAndDiagnostics(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	prepared.resolved.Chunk.LLMProfile = "chunk-profile"
	prepared.resolved.ChunkExecutionClass = contracts.ExecutionClassLLMBacked
	prepared.resolved.ChunkReferences = ResolvedReferenceTarget{
		Stage: StageChunk,
		ReferenceSet: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
			"guide": {Items: []contracts.ReferenceItem{{SlotName: "guide", Digest: "sha256:guide", Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///guide.txt"}, Content: []byte("sensitive")}}},
		}},
	}
	prepared.chunker = manifestChunker{terminalChunker: terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("producer", "producer diagnostic")}}, metadata: map[string]any{"prompt_id": "chunk/prompt"}}
	validator := &countingChunkValidator{result: contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("validator", "validator diagnostic")}}}
	prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}}
	store := &recordingChunkPlanStore{}
	if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheRefresh, ChunkPlans: store}); err != nil {
		t.Fatal(err)
	}
	producer := store.saved.Producer
	if producer.InputModule != prepared.input.Key() || producer.ChunkModule != prepared.chunker.Key() || producer.LLMProfile != "chunk-profile" || producer.Metadata["prompt_id"] != "chunk/prompt" {
		t.Fatalf("producer = %#v", producer)
	}
	if len(producer.References) != 1 || producer.References[0].Digest != "sha256:guide" {
		t.Fatalf("producer references = %#v", producer.References)
	}
	if len(store.saved.Diagnostics) != 1 || store.saved.Diagnostics[0].Samples[0].Scope != "producer" {
		t.Fatalf("stored diagnostics = %#v, want only producer diagnostic", store.saved.Diagnostics)
	}
}

func TestRunnerRejectsUncloneableModuleManifestMetadata(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	cyclic := make(map[string]any)
	cyclic["self"] = cyclic
	prepared.chunker = manifestChunker{
		terminalChunker: terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan},
		metadata:        map[string]any{"cyclic": cyclic},
	}
	_, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
	if err == nil || !strings.Contains(err.Error(), `clone manifest metadata for module "chunker": metadata.cyclic.self contains a cycle`) {
		t.Fatalf("Run() error = %v, want contextual module metadata clone failure", err)
	}
}

func TestRunnerOmitsProducerProfileForDeterministicChunker(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	prepared.resolved.Chunk.LLMProfile = "configured-but-unused"
	prepared.resolved.ChunkExecutionClass = contracts.ExecutionClassDeterministic
	prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan}
	store := &recordingChunkPlanStore{}
	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheRefresh, ChunkPlans: store})
	if err != nil {
		t.Fatal(err)
	}
	if store.saved.Producer.LLMProfile != "" || output.Manifest.ChunkPlan.ProducerLLMProfile != "" {
		t.Fatalf("deterministic producer profile = stored %q manifest %q", store.saved.Producer.LLMProfile, output.Manifest.ChunkPlan.ProducerLLMProfile)
	}
}

func TestRunnerDoesNotInventProducerProfileForLLMChunker(t *testing.T) {
	prepared, plan := preparedTerminalDebugPipeline(t)
	prepared.resolved.ChunkExecutionClass = contracts.ExecutionClassLLMBacked
	prepared.resolved.Chunk.LLMProfile = ""
	prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan}
	store := &recordingChunkPlanStore{}

	output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheRefresh, ChunkPlans: store})
	if err != nil {
		t.Fatal(err)
	}
	if store.saved.Producer.LLMProfile != "" || output.Manifest.ChunkPlan.ProducerLLMProfile != "" {
		t.Fatalf("LLM producer profile = stored %q manifest %q, want empty", store.saved.Producer.LLMProfile, output.Manifest.ChunkPlan.ProducerLLMProfile)
	}
}

func TestRunnerRefreshChangesDownstreamChunkFingerprint(t *testing.T) {
	doc := typedTestDocumentWithUnits(2)
	prepared := preparedConcurrentPipeline(t, 1)
	prepared.Steps[0].lanes = prepared.Steps[0].lanes[:1]
	prepared.resolved.Steps[0].ArtifactLanes = prepared.resolved.Steps[0].ArtifactLanes[:1]
	prepared.Steps[0].ArtifactLanes = prepared.Steps[0].ArtifactLanes[:1]
	prepared.input = &typedTestInput{key: prepared.resolved.Input.Module, doc: doc}

	run := func(plan source.ChunkPlan) []CheckpointFingerprint {
		prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan}
		loader := &dependencyLoader{CheckpointLoader: NoopCheckpointLoader()}
		if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, ChunkCacheMode: ChunkCacheRefresh, ChunkPlans: &recordingChunkPlanStore{}, Checkpoint: loader}); err != nil {
			t.Fatal(err)
		}
		return loader.extractDependencies
	}
	separate := typedTestPlan(doc)
	combined := source.ChunkPlan{SourceDigest: doc.Digest, Ranges: []source.ChunkRange{{StartUnitID: 1, EndUnitID: 2}}}
	if first, second := run(separate), run(combined); reflect.DeepEqual(first, second) {
		t.Fatalf("downstream fingerprints did not change: %#v", first)
	}
}

func chunkPlanRecord(t *testing.T, prepared *PreparedPipeline, plan source.ChunkPlan) ChunkPlanRecord {
	t.Helper()
	digest, err := source.DigestChunkPlan(plan)
	if err != nil {
		t.Fatal(err)
	}
	return ChunkPlanRecord{
		SchemaVersion: ChunkPlanSchemaVersion,
		SourceDigest:  plan.SourceDigest,
		PlanDigest:    digest,
		Plan:          source.CloneChunkPlan(plan),
		Producer:      ChunkPlanProducer{InputModule: prepared.input.Key(), ChunkModule: prepared.chunker.Key()},
		CreatedAt:     time.Now().UTC(),
	}
}
