package cli

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/redscaresu/infrafactory/internal/feedback"
	"github.com/redscaresu/infrafactory/internal/generator"
	"github.com/redscaresu/infrafactory/internal/harness"
	"github.com/redscaresu/infrafactory/internal/runstore"
	"github.com/redscaresu/infrafactory/internal/scenario"
	"github.com/spf13/cobra"
)

type runIDContextKey struct{}

type runMode string

const (
	runModeClean       runMode = "clean"
	runModeIncremental runMode = "incremental"
)

type detectedRunMode struct {
	Mode          runMode
	Reason        string
	PreviousRunID string
	BaselineState []byte
}

func runRunCommand(cmd *cobra.Command, args []string, runtime *CommandRuntime) error {
	scenarioPath := args[0]

	controls, err := resolveRunControls(cmd, runtime)
	if err != nil {
		return err
	}
	repairIterationsMax := controls.RepairIterationsMax

	sc, err := runtime.LoadScenario(scenarioPath)
	if err != nil {
		return fmt.Errorf("load scenario %q: %w", scenarioPath, err)
	}

	// M81 test-only isolation guard. Under `go test`, both the
	// output dir and run-store root must be absolute so parallel
	// subtests can't collide on shared relative-path defaults
	// (`./output`, `.infrafactory/runs`). The May 2026 CI regression
	// (M71) traced to two parallel subtests racing on `./output`
	// after one's os.RemoveAll wiped the other's project.tf
	// mid-iteration. macOS hid it; Linux didn't. Use isolatedRunOpts
	// in tests to satisfy this guard.
	if testing.Testing() {
		if !filepath.IsAbs(runtime.Config.Paths.Output) {
			return fmt.Errorf("test isolation: cfg.Paths.Output must be absolute under go test, got %q — use isolatedRunOpts(h, ...) which sets it to h.OutputDir()", runtime.Config.Paths.Output)
		}
		if !filepath.IsAbs(runtime.RunStoreRoot()) {
			return fmt.Errorf("test isolation: runstoreRoot must be absolute under go test, got %q — use isolatedRunOpts(h, ...) which sets it to h.RunstoreRoot()", runtime.RunStoreRoot())
		}
	}

	startedAt := time.Now().UTC()
	runID, _ := cmd.Context().Value(runIDContextKey{}).(string)
	if runID == "" {
		runID = startedAt.Format("20060102T150405Z0700")
	}
	store := runstore.NewFilesystemStore(runtime.RunStoreRoot())
	mode, err := detectRunMode(cmd.Context(), runtime, store, sc.Name, runtime.OutputDir(), controls)
	if err != nil {
		return err
	}
	if mode.Mode == runModeIncremental && len(mode.BaselineState) > 0 {
		if err := store.WriteRunArtifact(sc.Name, runID, "baseline_state.json", mode.BaselineState); err != nil {
			return fmt.Errorf("write baseline state artifact: %w", err)
		}
	}
	if mode.Mode == runModeIncremental {
		if err := runtime.Deps.MockState.Snapshot(cmd.Context()); err != nil {
			return fmt.Errorf("snapshot mockway baseline: %w", err)
		}
	}
	// Ticket 7: fan-out /mock/reset to every configured mock before
	// iter 1 of a clean run. Previously each sweep script had to do
	// this externally; bringing it in-tree means CI batch + retry +
	// any sequential caller gets a hermetic per-scenario state without
	// extra plumbing. Skip on incremental runs (resetting would wipe
	// the baseline they're meant to build on).
	if mode.Mode == runModeClean && controls.ResetMocks {
		if err := resetAllConfiguredMocks(cmd.Context(), runtime); err != nil {
			runtime.Logger.Log(LogEntry{
				Level:   logLevelInfo,
				Command: "run",
				Event:   "reset_mocks_failed",
				Status:  "warn",
				RunID:   runID,
				Detail:  err.Error(),
			})
		}
	}
	logPath := filepath.Join(runtime.RunStoreRoot(), sc.Name, runID, "app.log")
	closeRunLogSink, err := runtime.Logger.AddFileSink(logPath)
	if err == nil {
		defer func() {
			_ = closeRunLogSink()
		}()
	} else {
		runtime.Logger.Log(LogEntry{
			Level:   logLevelError,
			Command: "run",
			Event:   "run_log_sink_unavailable",
			Status:  "warn",
			RunID:   runID,
			Detail:  fmt.Sprintf("path=%s error=%v", logPath, err),
		})
	}
	runtime.Logger.Log(LogEntry{
		Level:   logLevelInfo,
		Command: "run",
		Event:   "run_start",
		Status:  "start",
		RunID:   runID,
		Detail:  fmt.Sprintf("repair_iterations_max=%d run_mode=%s no_destroy=%t previous_run_id=%s", repairIterationsMax, mode.Mode, controls.NoDestroy, mode.PreviousRunID),
	})
	if err := store.WriteRunMetadata(runstore.RunMetadata{
		Schema:              runstore.RunMetadataSchemaVersion,
		Scenario:            sc.Name,
		RunID:               runID,
		Status:              "running",
		Incremental:         mode.Mode == runModeIncremental,
		Layer3Enabled:       runtime.Config.Validation.Layers.SandboxDeploy.Enabled,
		PreviousRunID:       mode.PreviousRunID,
		RepairIterationsMax: repairIterationsMax,
		StartedAt:           startedAt,
	}); err != nil {
		return fmt.Errorf("write initial run metadata: %w", err)
	}

	allStages := []StageSummary{
		{Layer: "run", Stage: "mode", Status: StageStatusPass, Detail: fmt.Sprintf("%s (%s)", mode.Mode, mode.Reason)},
	}
	allFailures := make([]FailureSummary, 0)
	var previousFailures []feedback.Failure
	var previousIterationFailures []FailureSummary
	var iterationHistory []feedback.IterationResult
	holdoutBlocked := false
	completed := 0
	terminalReason := ""
	lastIterationFailed := false
	failedIterations := 0
	consecutiveTransportFailures := 0
	captureLLMRaw := llmRawCaptureEnabled()

	for iteration := 1; ; iteration++ {
		runtime.Logger.Log(LogEntry{
			Level:     logLevelInfo,
			Command:   "run",
			Event:     "iteration_start",
			Status:    "start",
			RunID:     runID,
			Iteration: iteration,
		})
		completed = iteration
		stages, failures := runIteration(cmd.Context(), runID, iteration, sc.Name, scenarioPath, runtime, store, captureLLMRaw, previousIterationFailures, mode.Mode, controls.NoDestroy)
		allStages = append(allStages, stages...)

		if err := persistRunIteration(store, sc.Name, runID, iteration, stages, failures); err != nil {
			return fmt.Errorf("persist run iteration %d: %w", iteration, err)
		}

		if len(failures) == 0 {
			// Auto-learn pitfalls: if this is iteration 2+, a self-correction
			// happened. Extract actionable patterns from the previous iteration's
			// failures and persist them for future runs. Mirrors the oscillation
			// path's cross-cloud isolation — a GCP scenario whose detail had no
			// resource name would otherwise pick up a Scaleway-flavoured fallback
			// from ExtractDescriptivePitfall and pollute pitfalls/gcp.yaml.
			if iteration > 1 && len(previousIterationFailures) > 0 {
				cloud := sc.Cloud
				for _, failure := range previousIterationFailures {
					// N3 / T12 classifier: mock-actionable failures
					// (501, plugin-did-not-respond, auth escape,
					// resource-not-found wait loops) belong in
					// docs/mock-gaps.md, NOT pitfalls/<cloud>.yaml.
					// Writing them as pitfalls teaches the LLM to
					// avoid valid resources because the mock is
					// incomplete — the system narrows over time.
					if generator.IsMockServerBug(failure.Detail) {
						gap := generator.MockGap{
							Cloud:     cloud,
							Signal:    generator.FirstMockSignal(failure.Detail),
							Resource:  generator.ExtractResourceFromDetail(failure.Detail),
							Scenario:  sc.Name,
							Detail:    failure.Detail,
							Timestamp: runID,
						}
						if err := generator.AppendMockGap(runtime.Config.Paths.Docs, gap); err != nil {
							runtime.Logger.Log(LogEntry{
								Level:   logLevelError,
								Command: "run",
								Event:   "self_correction_mock_gap_append",
								Status:  "failed",
								RunID:   runID,
								Detail:  err.Error(),
							})
						} else {
							runtime.Logger.Log(LogEntry{
								Level:   logLevelInfo,
								Command: "run",
								Event:   "self_correction_mock_gap_recorded",
								Status:  "success",
								RunID:   runID,
								Detail:  fmt.Sprintf("cloud=%s signal=%s resource=%s", gap.Cloud, gap.Signal, gap.Resource),
							})
						}
						continue
					}
					learned := generator.ExtractDescriptivePitfall(failure.Detail, sc.Name)
					if learned == nil {
						continue
					}
					if !pitfallResourceMatchesCloud(learned.Resource, cloud) {
						runtime.Logger.Log(LogEntry{
							Level:   logLevelInfo,
							Command: "run",
							Event:   "self_correction_pitfall_skipped",
							Status:  "skipped",
							RunID:   runID,
							Detail:  fmt.Sprintf("cross-cloud resource %q for cloud=%s", learned.Resource, cloud),
						})
						continue
					}
					if err := generator.AppendPitfall(runtime.Config.Paths.Pitfalls, cloud, *learned); err != nil {
						runtime.Logger.Log(LogEntry{
							Level:   logLevelError,
							Command: "run",
							Event:   "self_correction_pitfall_append",
							Status:  "failed",
							RunID:   runID,
							Detail:  err.Error(),
						})
					}
				}
			}
			lastIterationFailed = false
			previousFailures = nil
			previousIterationFailures = previousIterationFailures[:0]
			terminalReason = "target_reached"
			// Record the passing iteration in iterationHistory so the
			// N10 diff-extractor sees the failed→passing pair. Without
			// this, len(iterationHistory) stays at the count of failed
			// iters and the simple "1 fail → 1 pass" case skips
			// ExtractFixPitfall entirely (the loop's `len > 1`
			// guard never fires).
			iterationHistory = append(iterationHistory, feedback.IterationResult{
				Iteration: iteration,
				Failures:  nil,
			})
			runtime.Logger.Log(LogEntry{
				Level:     logLevelInfo,
				Command:   "run",
				Event:     "iteration_end",
				Status:    "success",
				RunID:     runID,
				Iteration: iteration,
			})
			break
		}

		lastIterationFailed = true
		failedIterations++
		allFailures = append(allFailures, failures...)
		transportDominated := failuresAreTransportDominated(failures)
		if transportDominated {
			consecutiveTransportFailures++
		} else {
			consecutiveTransportFailures = 0
		}
		runtime.Logger.Log(LogEntry{
			Level:     logLevelError,
			Command:   "run",
			Event:     "iteration_end",
			Status:    "failed",
			RunID:     runID,
			Iteration: iteration,
			Detail:    fmt.Sprintf("%d failure(s)", len(failures)),
		})
		currentFailures := toFeedbackFailures(failures)
		iterationHistory = append(iterationHistory, feedback.IterationResult{
			Iteration: iteration,
			Failures:  currentFailures,
		})
		if consecutiveTransportFailures >= transportFailureRetryBudget {
			terminalReason = "repair_budget_exhausted"
			allFailures = append(allFailures, FailureSummary{
				Layer:   "run",
				Stage:   fmt.Sprintf("iteration_%d", iteration),
				Check:   "transport_runtime_dominated",
				Command: "run loop",
				Detail:  fmt.Sprintf("stopped early after %d consecutive transport-runtime failures; consider adjusting agent timeouts/retries or fixing transport dependencies", consecutiveTransportFailures),
			})
			break
		}
		// Stop early when the failure signature is unchanged/subset-equivalent;
		// further iterations are unlikely to produce new signal.
		if feedback.IsStuck(previousFailures, currentFailures) {
			terminalReason = "stuck"
			allFailures = append(allFailures, FailureSummary{
				Layer:   "run",
				Stage:   fmt.Sprintf("iteration_%d", iteration),
				Check:   "stuck",
				Command: "run loop",
				Detail:  "stopped due to stuck detection",
			})
			break
		}
		previousFailures = currentFailures
		previousIterationFailures = append(previousIterationFailures[:0], failures...)

		if failedIterations >= repairIterationsMax {
			terminalReason = "repair_budget_exhausted"
			allFailures = append(allFailures, FailureSummary{
				Layer:   "run",
				Stage:   fmt.Sprintf("iteration_%d", iteration),
				Check:   "repair_budget_exhausted",
				Command: "run loop",
				Detail:  fmt.Sprintf("reached repair iterations max (%d)", repairIterationsMax),
			})
			break
		}
	}

	if terminalReason == "" && !lastIterationFailed {
		terminalReason = "target_reached"
	}
	runtime.Logger.Log(LogEntry{
		Level:   logLevelInfo,
		Command: "run",
		Event:   "terminal_reason",
		Status:  terminalReason,
		RunID:   runID,
		Detail:  fmt.Sprintf("completed_iterations=%d", completed),
	})

	// Auto-learn from failures when a run exhausts its repair budget or
	// stops on stuck detection. Prior versions only inspected (a) the
	// signatures DetectOscillation surfaced from a 3-iteration toggle
	// window or (b) the last iteration's failures as a fallback. Both
	// miss the most common case observed in the wild: the LLM hits the
	// same mistake in iters 1 and 4 (a 4-apart recurrence outside the
	// toggle window) while iter 5 finally compiles and fails on a
	// downstream check. The recurring mistake is exactly the pitfall
	// material we want, but the prior fallback only saw iter 5's
	// (unrelated) failure.
	//
	// New behavior: scan every iteration's failures, dedupe by
	// (resource, normalized-detail), and run each unique candidate
	// through ExtractDescriptivePitfall. NormalizeDetail collapses
	// cosmetic shifts (line numbers, `web_0` vs `web[*]`, "Did you
	// mean") so the same logical bug isn't recorded twice in one run;
	// AppendPitfall's isDuplicate then handles cross-run dedup.
	if terminalReason == "repair_budget_exhausted" || terminalReason == "stuck" {
		seen := make(map[string]struct{})
		var candidates []feedback.Failure
		for _, ir := range iterationHistory {
			for _, f := range ir.Failures {
				key := f.Resource + "|" + feedback.NormalizeDetail(f.Detail)
				if _, ok := seen[key]; ok {
					continue
				}
				seen[key] = struct{}{}
				candidates = append(candidates, f)
			}
		}
		for _, f := range candidates {
			// N3 / T12 classifier (stuck/budget path). Mirror of the
			// self-correction hook above (~line 194): mock-actionable
			// failures must route to docs/mock-gaps.md, not pollute
			// pitfalls/<cloud>.yaml. Without this guard the
			// 2026-06-01 sweep showed gcp-cloud-run, gcp-cloud-sql,
			// gcp-gke-cluster, gcp-storage all re-learning the same
			// OAuth-escape + plugin-crash rules N2 just pruned.
			if generator.IsMockServerBug(f.Detail) {
				gap := generator.MockGap{
					Cloud:     sc.Cloud,
					Signal:    generator.FirstMockSignal(f.Detail),
					Resource:  generator.ExtractResourceFromDetail(f.Detail),
					Scenario:  sc.Name,
					Detail:    f.Detail,
					Timestamp: runID,
				}
				if err := generator.AppendMockGap(runtime.Config.Paths.Docs, gap); err != nil {
					runtime.Logger.Log(LogEntry{
						Level:   logLevelError,
						Command: "run",
						Event:   "oscillation_mock_gap_append",
						Status:  "failed",
						RunID:   runID,
						Detail:  err.Error(),
					})
				} else {
					runtime.Logger.Log(LogEntry{
						Level:   logLevelInfo,
						Command: "run",
						Event:   "oscillation_mock_gap_recorded",
						Status:  "success",
						RunID:   runID,
						Detail:  fmt.Sprintf("cloud=%s signal=%s resource=%s", gap.Cloud, gap.Signal, gap.Resource),
					})
				}
				continue
			}
			learned := generator.ExtractDescriptivePitfall(f.Detail, sc.Name)
			if learned == nil {
				continue
			}
			// ExtractDescriptivePitfall has Scaleway-flavoured fallbacks
			// (scaleway_redis_cluster, scaleway_k8s_cluster) that fire
			// when the failure detail names no resource. Don't pollute
			// pitfalls/<otherCloud>.yaml with cross-cloud resource
			// types; skip when the learned resource prefix doesn't
			// match the scenario's cloud.
			if !pitfallResourceMatchesCloud(learned.Resource, sc.Cloud) {
				runtime.Logger.Log(LogEntry{
					Level:   logLevelInfo,
					Command: "run",
					Event:   "oscillation_pitfall_skipped",
					Status:  "skipped",
					RunID:   runID,
					Detail:  fmt.Sprintf("cross-cloud resource %q for cloud=%s", learned.Resource, sc.Cloud),
				})
				continue
			}
			if err := generator.AppendPitfall(runtime.Config.Paths.Pitfalls, sc.Cloud, *learned); err != nil {
				runtime.Logger.Log(LogEntry{
					Level:   logLevelError,
					Command: "run",
					Event:   "oscillation_pitfall_append",
					Status:  "failed",
					RunID:   runID,
					Detail:  err.Error(),
				})
				continue
			}
			runtime.Logger.Log(LogEntry{
				Level:   logLevelInfo,
				Command: "run",
				Event:   "oscillation_pitfall_learned",
				Status:  "success",
				RunID:   runID,
				Detail:  fmt.Sprintf("resource=%s rule=%s", learned.Resource, learned.Rule),
			})
		}

		// N9: orphan_check extractor. ExtractDescriptivePitfall produces
		// nothing for orphan_check failures because the failure detail
		// names no resource (only a count). Route through
		// generator.ClassifyOrphans which cross-references the live
		// /mock/state to identify lingering resources and classify
		// each across the 5 sub-shapes (see N9 doc in NEXT_SESSION.md).
		//
		// Fires only when stuck-detection or budget-exhaustion caught
		// us on an orphan_check failure — the live mock state at that
		// point reflects what destroy left behind.
		hasOrphanCheck := false
		for _, f := range candidates {
			if strings.Contains(strings.ToLower(f.Detail), "orphaned resources") {
				hasOrphanCheck = true
				break
			}
		}
		if hasOrphanCheck && runtime.Deps.MockState != nil {
			mockState, stateErr := runtime.Deps.MockState.State(cmd.Context())
			if stateErr != nil {
				runtime.Logger.Log(LogEntry{
					Level:   logLevelInfo,
					Command: "run",
					Event:   "orphan_classify_state_unavailable",
					Status:  "warn",
					RunID:   runID,
					Detail:  stateErr.Error(),
				})
			} else {
				routing := generator.ClassifyOrphans(mockState, sc.Cloud, sc.Name, runID)
				for _, p := range routing.Pitfalls {
					if !pitfallResourceMatchesCloud(p.Resource, sc.Cloud) {
						continue
					}
					if err := generator.AppendPitfall(runtime.Config.Paths.Pitfalls, sc.Cloud, p); err != nil {
						runtime.Logger.Log(LogEntry{
							Level:   logLevelError,
							Command: "run",
							Event:   "orphan_pitfall_append",
							Status:  "failed",
							RunID:   runID,
							Detail:  err.Error(),
						})
						continue
					}
					runtime.Logger.Log(LogEntry{
						Level:   logLevelInfo,
						Command: "run",
						Event:   "orphan_pitfall_learned",
						Status:  "success",
						RunID:   runID,
						Detail:  fmt.Sprintf("subshape=LLMSoftDelete resource=%s", p.Resource),
					})
				}
				for _, g := range routing.MockGaps {
					if err := generator.AppendMockGap(runtime.Config.Paths.Docs, g); err != nil {
						runtime.Logger.Log(LogEntry{
							Level:   logLevelError,
							Command: "run",
							Event:   "orphan_mock_gap_append",
							Status:  "failed",
							RunID:   runID,
							Detail:  err.Error(),
						})
						continue
					}
					runtime.Logger.Log(LogEntry{
						Level:   logLevelInfo,
						Command: "run",
						Event:   "orphan_mock_gap_recorded",
						Status:  "success",
						RunID:   runID,
						Detail:  fmt.Sprintf("cloud=%s resource=%s signal=%s", g.Cloud, g.Resource, g.Signal),
					})
				}
				if len(routing.Unclassified) > 0 {
					runtime.Logger.Log(LogEntry{
						Level:   logLevelInfo,
						Command: "run",
						Event:   "orphan_classify_unclassified",
						Status:  "warn",
						RunID:   runID,
						Detail:  fmt.Sprintf("count=%d — extend the sub-shape table in pitfalls_learn/orphan_classify.go", len(routing.Unclassified)),
					})
				}
			}
		}

		// N8: policy_pitfall_conflict detection. When a candidate
		// failure is a policy violation AND the LLM's HCL contains
		// all the keywords from a same-resource prescriptive pitfall,
		// the conflict is in the policy file (rego), not the LLM. Route
		// to docs/policy-gaps.md instead of polluting pitfalls/<cloud>.yaml.
		//
		// Reads HCL from the latest iteration's generated/ dir.
		// Pitfalls list comes from the current pitfalls/<cloud>.yaml.
		existingPitfalls, _ := generator.LoadPitfallEntries(runtime.Config.Paths.Pitfalls, sc.Cloud)
		hclSnapshot := readLatestGeneratedHCL(runtime, sc.Name, runID, completed)
		for _, f := range candidates {
			gap := generator.DetectPolicyConflict(f.Policy, f.Detail, hclSnapshot, existingPitfalls, sc.Cloud, sc.Name, runID)
			if gap == nil {
				continue
			}
			if err := generator.AppendPolicyGap(runtime.Config.Paths.Docs, *gap); err != nil {
				runtime.Logger.Log(LogEntry{
					Level:   logLevelError,
					Command: "run",
					Event:   "policy_gap_append",
					Status:  "failed",
					RunID:   runID,
					Detail:  err.Error(),
				})
				continue
			}
			runtime.Logger.Log(LogEntry{
				Level:   logLevelInfo,
				Command: "run",
				Event:   "policy_gap_recorded",
				Status:  "success",
				RunID:   runID,
				Detail:  fmt.Sprintf("policy=%s resource=%s", gap.Policy, gap.Resource),
			})
		}
	}

	// N10 — diff-based prescriptive-pitfall extractor (target_reached path).
	// When a run eventually passes after at least one failing iteration,
	// diff the last-failing iter's HCL against the first-passing iter's
	// HCL and extract a prescriptive pitfall for each cleared failure.
	// Closes the symptom-only gap in ExtractDescriptivePitfall: today the
	// system learns WHAT failed; with N10 it also learns the HCL
	// pattern that FIXED it.
	//
	// See `docs/NEXT_SESSION.md` § N10 for the design rationale and
	// `internal/generator/prescriptive_extractor.go` for the diff logic.
	if terminalReason == "target_reached" && len(iterationHistory) > 1 {
		for i := 1; i < len(iterationHistory); i++ {
			prev := iterationHistory[i-1]
			curr := iterationHistory[i]
			if len(prev.Failures) == 0 {
				continue
			}
			// Cleared = failures in prev whose (resource, normalized)
			// signature does NOT appear in curr.
			currKeys := make(map[string]struct{}, len(curr.Failures))
			for _, f := range curr.Failures {
				currKeys[f.Resource+"|"+feedback.NormalizeDetail(f.Detail)] = struct{}{}
			}
			failedDir := iterationGeneratedDir(runtime, sc.Name, runID, prev.Iteration)
			passingDir := iterationGeneratedDir(runtime, sc.Name, runID, curr.Iteration)
			for _, cleared := range prev.Failures {
				key := cleared.Resource + "|" + feedback.NormalizeDetail(cleared.Detail)
				if _, stillFailing := currKeys[key]; stillFailing {
					continue
				}
				entry, err := generator.ExtractFixPitfall(
					failedDir, passingDir,
					cleared.Detail, cleared.Resource,
					sc.Cloud, sc.Name, runID,
				)
				if err != nil {
					runtime.Logger.Log(LogEntry{
						Level: logLevelInfo, Command: "run",
						Event: "prescriptive_extract_error", Status: "warn",
						RunID: runID, Detail: err.Error(),
					})
				} else if entry != nil && pitfallResourceMatchesCloud(entry.Resource, sc.Cloud) {
					if err := generator.AppendPitfall(runtime.Config.Paths.Pitfalls, sc.Cloud, *entry); err != nil {
						runtime.Logger.Log(LogEntry{
							Level: logLevelError, Command: "run",
							Event: "prescriptive_pitfall_append", Status: "failed",
							RunID: runID, Detail: err.Error(),
						})
					} else {
						runtime.Logger.Log(LogEntry{
							Level: logLevelInfo, Command: "run",
							Event: "prescriptive_pitfall_learned", Status: "success",
							RunID: runID,
							Detail: fmt.Sprintf("resource=%s iter_pair=%d->%d source=%s",
								entry.Resource, prev.Iteration, curr.Iteration, entry.Source),
						})
					}
				}
				// N13: deletion-as-fix (the dual of N10's addition-as-fix).
				// Runs on the same cleared-failure cursor — the two
				// extractors capture different patterns and either or
				// both may emit per pair.
				avoidEntry, err := generator.ExtractAvoidPitfall(
					failedDir, passingDir,
					cleared.Detail, cleared.Resource,
					sc.Cloud, sc.Name, runID,
				)
				if err != nil {
					runtime.Logger.Log(LogEntry{
						Level: logLevelInfo, Command: "run",
						Event: "prescriptive_avoid_extract_error", Status: "warn",
						RunID: runID, Detail: err.Error(),
					})
					continue
				}
				if avoidEntry == nil {
					continue
				}
				if !pitfallResourceMatchesCloud(avoidEntry.Resource, sc.Cloud) {
					continue
				}
				if err := generator.AppendPitfall(runtime.Config.Paths.Pitfalls, sc.Cloud, *avoidEntry); err != nil {
					runtime.Logger.Log(LogEntry{
						Level: logLevelError, Command: "run",
						Event: "prescriptive_avoid_append", Status: "failed",
						RunID: runID, Detail: err.Error(),
					})
					continue
				}
				runtime.Logger.Log(LogEntry{
					Level: logLevelInfo, Command: "run",
					Event: "prescriptive_avoid_learned", Status: "success",
					RunID: runID,
					Detail: fmt.Sprintf("resource=%s iter_pair=%d->%d source=%s",
						avoidEntry.Resource, prev.Iteration, curr.Iteration, avoidEntry.Source),
				})
			}
		}
	}

	// Auto-destroy real Scaleway resources on failure to prevent orphaned billing.
	// Contract #14: failed run without --no-destroy must destroy real resources.
	sandboxEnabled := runtime.Config.Validation.Layers.SandboxDeploy.Enabled
	if sandboxEnabled && !controls.NoDestroy && terminalReason != "target_reached" {
		liveStatePath := filepath.Join(runtime.OutputDir(), harness.LiveStateFilename)
		if _, statErr := os.Stat(liveStatePath); statErr == nil {
			sandboxEnv, sandboxEnvErr := sandboxCommandEnv(runtime)
			if sandboxEnvErr != nil {
				runtime.Logger.Log(LogEntry{
					Level:   logLevelError,
					Command: "run",
					Event:   "layer3_auto_destroy_preflight",
					Status:  "failed",
					RunID:   runID,
					Detail:  sandboxEnvErr.Error(),
				})
				allStages = append(allStages, StageSummary{Layer: "sandbox_deploy", Stage: "auto_destroy_preflight", Status: StageStatusFail})
			} else {
				destroyResult, destroyErr := runtime.Deps.SandboxDestroy.Run(cmd.Context(), runtime.OutputDir(), sandboxEnv)
				destroyStages, destroyFailures := appendSandboxDestroyResult(nil, nil, destroyResult, destroyErr)
				allStages = append(allStages, destroyStages...)
				if destroyErr != nil {
					runtime.Logger.Log(LogEntry{
						Level:   logLevelError,
						Command: "run",
						Event:   "layer3_auto_destroy",
						Status:  "failed",
						RunID:   runID,
						Detail:  destroyErr.Error(),
					})
					allFailures = append(allFailures, destroyFailures...)
				} else {
					runtime.Logger.Log(LogEntry{
						Level:   logLevelInfo,
						Command: "run",
						Event:   "layer3_auto_destroy",
						Status:  "success",
						RunID:   runID,
					})
				}
			}
		}
	}

	if terminalReason == "target_reached" && !controls.NoDestroy {
		holdoutStages, holdoutFailures, err := runCriteriaOnlyHoldouts(cmd.Context(), runtime, scenarioPath)
		if err != nil {
			return fmt.Errorf("run holdout checks: %w", err)
		}
		allStages = append(allStages, holdoutStages...)
		allFailures = append(allFailures, holdoutFailures...)
		holdoutBlocked = len(holdoutFailures) > 0
	} else if terminalReason == "target_reached" && controls.NoDestroy {
		allStages = append(allStages, StageSummary{Layer: "holdout", Stage: "skipped", Status: StageStatusSkip, Detail: "skipped by --no-destroy"})
	}

	status := CommandStatusSuccess
	if terminalReason != "target_reached" || holdoutBlocked {
		status = CommandStatusFailed
	}

	if err := store.WriteRunMetadata(runstore.RunMetadata{
		Schema:              runstore.RunMetadataSchemaVersion,
		Scenario:            sc.Name,
		RunID:               runID,
		Status:              string(status),
		TerminalReason:      terminalReason,
		Incremental:         mode.Mode == runModeIncremental,
		Layer3Enabled:       runtime.Config.Validation.Layers.SandboxDeploy.Enabled,
		PreviousRunID:       mode.PreviousRunID,
		RepairIterationsMax: repairIterationsMax,
		StartedAt:           startedAt,
	}); err != nil {
		return fmt.Errorf("write run metadata: %w", err)
	}

	allStages = append(allStages, StageSummary{
		Layer:  "run",
		Stage:  "metadata",
		Status: StageStatusPass,
		Detail: fmt.Sprintf("run_id=%s", runID),
	})
	if terminalReason != "" {
		allStages = append(allStages, StageSummary{
			Layer:  "run",
			Stage:  "terminal_reason",
			Status: StageStatusPass,
			Detail: terminalReason,
		})
	}

	result := OutputResult{
		Command:  "run",
		Scenario: sc.Name,
		Status:   status,
		Stages:   allStages,
		Failures: allFailures,
	}
	if err := writeCommandOutput(cmd, result); err != nil {
		return err
	}

	if terminalReason != "target_reached" || holdoutBlocked {
		errDetail := fmt.Errorf("run stopped with terminal reason %q after %d iteration(s)", terminalReason, completed)
		if holdoutBlocked {
			errDetail = fmt.Errorf("holdout checks failed after training convergence")
		}
		return &CLIError{
			Op:   "run",
			Code: errorCodeCommandFailed,
			Err:  errDetail,
		}
	}

	return nil
}

func persistRunIteration(store *runstore.FilesystemStore, scenario string, runID string, iteration int, stages []StageSummary, failures []FailureSummary) error {
	failureSummary := make([]string, 0, len(failures))
	transportDiagnostics := make([]map[string]string, 0)
	for _, failure := range failures {
		failureSummary = append(failureSummary, fmt.Sprintf("%s/%s check=%s command=%s detail=%s", failure.Layer, failure.Stage, failure.Check, failure.Command, failure.Detail))
		if feedbackFailureClassForSummary(failure) == "transport_runtime" {
			transportDiagnostics = append(transportDiagnostics, map[string]string{
				"stage":   failure.Stage,
				"check":   failure.Check,
				"command": failure.Command,
				"detail":  failure.Detail,
			})
		}
	}

	payload, err := json.MarshalIndent(struct {
		Schema               string              `json:"schema"`
		Iteration            int                 `json:"iteration"`
		Stages               []StageSummary      `json:"stages"`
		Failures             []FailureSummary    `json:"failures"`
		FailureSummary       []string            `json:"failure_summary,omitempty"`
		TransportDiagnostics []map[string]string `json:"transport_diagnostics,omitempty"`
	}{
		Schema:               runstore.RunIterationSchemaVersion,
		Iteration:            iteration,
		Stages:               stages,
		Failures:             failures,
		FailureSummary:       failureSummary,
		TransportDiagnostics: transportDiagnostics,
	}, "", "  ")
	if err != nil {
		return fmt.Errorf("encode iteration artifact: %w", err)
	}

	if err := store.WriteIterationArtifact(scenario, runID, iteration, "iteration.json", payload); err != nil {
		return fmt.Errorf("write iteration artifact: %w", err)
	}

	return nil
}

func resolveRunStoreRoot() string {
	if root := os.Getenv("INFRAFACTORY_RUNSTORE_ROOT"); root != "" {
		return root
	}
	return runstore.DefaultRoot
}

func runIteration(
	ctx context.Context,
	runID string,
	iteration int,
	scenarioName string,
	scenarioPath string,
	runtime *CommandRuntime,
	store *runstore.FilesystemStore,
	captureLLMRaw bool,
	previousIterationFailures []FailureSummary,
	mode runMode,
	noDestroy bool,
) ([]StageSummary, []FailureSummary) {
	stages := make([]StageSummary, 0, 3)
	failures := make([]FailureSummary, 0)

	steps := []struct {
		name    string
		command string
	}{
		{name: "generate", command: "generate"},
		{name: "validate", command: "validate"},
		{name: "test", command: "test"},
	}

	for _, step := range steps {
		stageName := fmt.Sprintf("iteration_%d_%s", iteration, step.name)
		runtime.Logger.Log(LogEntry{
			Level:     logLevelInfo,
			Command:   "run",
			Event:     "stage_start",
			Status:    "start",
			RunID:     runID,
			Iteration: iteration,
			Stage:     stageName,
		})
		var (
			err        error
			testResult OutputResult
		)
		if step.name == "test" {
			testResult, err = executeTest(ctx, runtime, scenarioPath, testExecutionOptions{
				MockDeployMode: mockDeployModeForRunMode(mode),
				SkipDestroy:    noDestroy,
			})
			if len(testResult.PlanLiveText) > 0 {
				writeErr := store.WriteIterationArtifact(scenarioName, runID, iteration, "plan-live.txt", testResult.PlanLiveText)
				if writeErr != nil && err == nil {
					err = writeErr
				}
			}
		} else {
			switch step.name {
			case "generate":
				var generated *generator.GeneratedCode
				_, generated, err = generateAndWriteFilesWithResult(ctx, runtime, scenarioPath, iteration, previousIterationFailures, generatedFileWriteModeForRunMode(mode))
				if err == nil {
					err = store.WriteGeneratedFiles(scenarioName, runID, generated.Files)
				}
				if err == nil {
					err = store.WriteIterationGeneratedFiles(scenarioName, runID, iteration, generated.Files)
				}
				if err == nil && captureLLMRaw {
					err = persistLLMRawPhaseResponses(store, scenarioName, runID, iteration, generated.Metadata.Phases)
				}
			case "validate":
				var artifacts validateArtifacts
				testResult, artifacts, err = executeValidateWithArtifacts(ctx, runtime, scenarioPath)
				if len(artifacts.PlanText) > 0 {
					writeErr := store.WriteRunArtifact(scenarioName, runID, "plan.txt", artifacts.PlanText)
					if writeErr != nil && err == nil {
						err = writeErr
					}
				}
			default:
				err = fmt.Errorf("unsupported run step %q", step.name)
			}
		}
		if err != nil {
			stages = append(stages, StageSummary{Layer: "run", Stage: stageName, Status: StageStatusFail})
			if (step.name == "test" || step.name == "validate") && len(testResult.Failures) > 0 {
				for _, failure := range testResult.Failures {
					failures = append(failures, FailureSummary{
						Layer:    "run",
						Stage:    stageName,
						Check:    failure.Check,
						Policy:   failure.Policy,
						Command:  step.command,
						Resource: failure.Resource,
						Detail:   failure.Detail,
					})
				}
			} else {
				failures = append(failures, FailureSummary{
					Layer:   "run",
					Stage:   stageName,
					Check:   step.name,
					Command: step.command,
					Detail:  err.Error(),
				})
			}
			runtime.Logger.Log(LogEntry{
				Level:     logLevelError,
				Command:   "run",
				Event:     "stage_end",
				Status:    "failed",
				RunID:     runID,
				Iteration: iteration,
				Stage:     stageName,
				Check:     step.name,
				Detail:    err.Error(),
			})
			break
		}
		stages = append(stages, StageSummary{Layer: "run", Stage: stageName, Status: StageStatusPass})
		runtime.Logger.Log(LogEntry{
			Level:     logLevelInfo,
			Command:   "run",
			Event:     "stage_end",
			Status:    "success",
			RunID:     runID,
			Iteration: iteration,
			Stage:     stageName,
		})
	}

	return stages, failures
}

func runCriteriaOnlyHoldouts(ctx context.Context, runtime *CommandRuntime, trainingScenarioPath string) ([]StageSummary, []FailureSummary, error) {
	holdoutDir := filepath.Join(runtime.Config.Paths.Scenarios, "holdout")
	holdouts, err := scenario.DiscoverCriteriaOnlyHoldouts(holdoutDir, trainingScenarioPath)
	if err != nil {
		if os.IsNotExist(err) {
			return []StageSummary{{Layer: "holdout", Stage: "discovery", Status: StageStatusPass, Detail: "0 holdouts"}}, nil, nil
		}
		return nil, nil, err
	}

	stages := []StageSummary{
		{
			Layer:  "holdout",
			Stage:  "discovery",
			Status: StageStatusPass,
			Detail: fmt.Sprintf("%d holdouts", len(holdouts)),
		},
	}
	failures := make([]FailureSummary, 0)
	for _, holdout := range holdouts {
		sc, err := runtime.scenarioLoader(holdout.Path)
		if err != nil {
			stages = append(stages, StageSummary{Layer: "holdout", Stage: holdout.ScenarioName, Status: StageStatusFail})
			failures = append(failures, FailureSummary{
				Layer:   "holdout",
				Stage:   holdout.ScenarioName,
				Check:   "load",
				Command: "scenario loader",
				Detail:  err.Error(),
			})
			continue
		}

		// Criteria-only holdouts validate against the generated code of the
		// already-converged training scenario, not their own output path.
		result, err := executeTestWithScenario(ctx, runtime, sc, runtime.OutputDir(), testExecutionOptions{MockDeployMode: harness.MockDeployModeClean})
		if err != nil {
			stages = append(stages, StageSummary{Layer: "holdout", Stage: holdout.ScenarioName, Status: StageStatusFail})
			if len(result.Failures) == 0 {
				failures = append(failures, FailureSummary{
					Layer:   "holdout",
					Stage:   holdout.ScenarioName,
					Check:   "test",
					Command: "test",
					Detail:  err.Error(),
				})
				continue
			}
			for _, failure := range result.Failures {
				failures = append(failures, FailureSummary{
					Layer:    "holdout",
					Stage:    holdout.ScenarioName,
					Check:    failure.Check,
					Policy:   failure.Policy,
					Command:  "test",
					Resource: failure.Resource,
					Detail:   failure.Detail,
				})
			}
			continue
		}

		stage := StageSummary{Layer: "holdout", Stage: holdout.ScenarioName, Status: StageStatusPass}
		for _, testStage := range result.Stages {
			if testStage.Layer == "criteria" && testStage.Stage == "support_matrix" && testStage.Detail != "" {
				stage.Detail = testStage.Detail
				break
			}
		}
		stages = append(stages, stage)
	}

	return stages, failures, nil
}

type runControls struct {
	RepairIterationsMax int
	Clean               bool
	NoDestroy           bool
	ResetMocks          bool
}

const transportFailureRetryBudget = 2

func resolveRunControls(cmd *cobra.Command, runtime *CommandRuntime) (runControls, error) {
	repairMax, err := cmd.Flags().GetInt("repair-iterations-max")
	if err != nil {
		return runControls{}, &CLIError{Op: "run", Code: errorCodeUsage, Err: fmt.Errorf("read --repair-iterations-max flag: %w", err)}
	}
	clean, err := cmd.Flags().GetBool("clean")
	if err != nil {
		return runControls{}, &CLIError{Op: "run", Code: errorCodeUsage, Err: fmt.Errorf("read --clean flag: %w", err)}
	}
	noDestroy, err := cmd.Flags().GetBool("no-destroy")
	if err != nil {
		return runControls{}, &CLIError{Op: "run", Code: errorCodeUsage, Err: fmt.Errorf("read --no-destroy flag: %w", err)}
	}
	resetMocks, err := cmd.Flags().GetBool("reset-mocks")
	if err != nil {
		return runControls{}, &CLIError{Op: "run", Code: errorCodeUsage, Err: fmt.Errorf("read --reset-mocks flag: %w", err)}
	}
	if clean && noDestroy {
		return runControls{}, &CLIError{Op: "run", Code: errorCodeUsage, Err: fmt.Errorf("clean and no-destroy are mutually exclusive")}
	}

	if repairMax == 0 {
		repairMax = runtime.Config.Agent.RepairIterationsMax
	}

	if repairMax < 1 {
		return runControls{}, &CLIError{Op: "run", Code: errorCodeUsage, Err: fmt.Errorf("repair iterations max must be >= 1")}
	}

	return runControls{
		RepairIterationsMax: repairMax,
		Clean:               clean,
		NoDestroy:           noDestroy,
		ResetMocks:          resetMocks,
	}, nil
}

func detectRunMode(ctx context.Context, runtime *CommandRuntime, store *runstore.FilesystemStore, scenarioName, outputDir string, controls runControls) (detectedRunMode, error) {
	if controls.Clean {
		statePayload, err := runtime.Deps.MockState.State(ctx)
		if err != nil {
			return detectedRunMode{}, fmt.Errorf("capture baseline state for clean run: %w", err)
		}
		return detectedRunMode{Mode: runModeClean, Reason: "forced by --clean", BaselineState: statePayload}, nil
	}

	statePayload, err := runtime.Deps.MockState.State(ctx)
	if err != nil {
		return detectedRunMode{}, fmt.Errorf("detect run mode from mockway state: %w", err)
	}
	hasMockResources, err := mockStateHasResources(statePayload)
	if err != nil {
		return detectedRunMode{}, fmt.Errorf("decode mockway state for run mode detection: %w", err)
	}
	hasTFState := tfStateExists(outputDir)
	previousRunID, err := store.LatestSuccessfulRunID(scenarioName)
	if err != nil {
		return detectedRunMode{}, fmt.Errorf("detect previous successful run: %w", err)
	}

	missing := make([]string, 0, 3)
	if !hasMockResources {
		missing = append(missing, "mockway state")
	}
	if !hasTFState {
		missing = append(missing, "terraform.tfstate")
	}
	if previousRunID == "" {
		missing = append(missing, "previous successful run")
	}
	if len(missing) == 0 {
		return detectedRunMode{
			Mode:          runModeIncremental,
			Reason:        "auto-detected from mockway state, terraform.tfstate, and previous successful run",
			PreviousRunID: previousRunID,
			BaselineState: statePayload,
		}, nil
	}

	return detectedRunMode{
		Mode:          runModeClean,
		Reason:        "missing " + strings.Join(missing, ", "),
		BaselineState: statePayload,
	}, nil
}

func mockStateHasResources(payload []byte) (bool, error) {
	var state map[string]any
	if err := json.Unmarshal(payload, &state); err != nil {
		return false, err
	}
	for _, rootNode := range state {
		rootMap, ok := rootNode.(map[string]any)
		if !ok {
			continue
		}
		for _, value := range rootMap {
			items, ok := value.([]any)
			if ok && len(items) > 0 {
				return true, nil
			}
		}
	}
	return false, nil
}

func tfStateExists(outputDir string) bool {
	_, err := os.Stat(filepath.Join(outputDir, "terraform.tfstate"))
	return err == nil
}

func mockDeployModeForRunMode(mode runMode) harness.MockDeployMode {
	if mode == runModeIncremental {
		return harness.MockDeployModeIncremental
	}
	return harness.MockDeployModeClean
}

func generatedFileWriteModeForRunMode(mode runMode) generatedFileWriteMode {
	if mode == runModeIncremental {
		return generatedFileWriteModeIncremental
	}
	return generatedFileWriteModeClean
}

// pitfallResourceMatchesCloud reports whether a learned pitfall's
// resource name fits the scenario's cloud. Empty cloud and empty
// resource accept anything (no signal to mismatch). Otherwise the
// resource must be prefixed with the cloud's canonical Terraform
// provider stem (scaleway_*, google_*, aws_*).
func pitfallResourceMatchesCloud(resource, cloud string) bool {
	if cloud == "" || resource == "" {
		return true
	}
	switch cloud {
	case "scaleway":
		return strings.HasPrefix(resource, "scaleway_")
	case "gcp":
		return strings.HasPrefix(resource, "google_")
	case "aws":
		return strings.HasPrefix(resource, "aws_")
	case "genesys":
		// S118: gate genesys auto-learning by the genesyscloud_ prefix.
		// Without this case, the default-true branch lets cross-cloud
		// resources (like Scaleway-flavoured fallbacks from
		// ExtractDescriptivePitfall) pollute pitfalls/genesys.yaml.
		return strings.HasPrefix(resource, "genesyscloud_")
	default:
		return true
	}
}

func toFeedbackFailures(failures []FailureSummary) []feedback.Failure {
	out := make([]feedback.Failure, 0, len(failures))
	for _, failure := range failures {
		out = append(out, feedback.Failure{
			Check:    failure.Check,
			Resource: failure.Resource,
			Detail:   failure.Detail,
		})
	}
	return out
}

func failuresAreTransportDominated(failures []FailureSummary) bool {
	if len(failures) == 0 {
		return false
	}
	for _, failure := range failures {
		if feedbackFailureClassForSummary(failure) != "transport_runtime" {
			return false
		}
	}
	return true
}

// resetAllConfiguredMocks fans /mock/reset out to every mock URL the
// runtime config names — mockway, fakegcp, fakeaws. Unconfigured
// (URL == "") clouds are skipped. Errors are accumulated but never
// fatal: a missing mock shouldn't block a Scaleway-only scenario,
// and the caller logs the warning. Equivalent to the per-mock curl
// fan-out the sweep scripts used to do externally.
// readLatestGeneratedHCL concatenates all .tf files from the most
// recent iteration's generated/ dir so N8's DetectPolicyConflict can
// substring-match against the LLM's full output. Returns "" on any
// error — the detector handles empty input cleanly (no keyword can
// match an empty corpus, so it falls through to the existing
// pitfall path).
//
// completedIter is the index of the last iteration that ran (caller
// passes from the loop's `completed` variable).
// iterationGeneratedDir returns the path to a specific iteration's
// generated/ directory. Used by N10's prescriptive extractor to diff
// adjacent iterations' HCL.
func iterationGeneratedDir(runtime *CommandRuntime, scenarioName, runID string, iter int) string {
	if iter < 1 {
		return ""
	}
	return filepath.Join(runtime.RunStoreRoot(), scenarioName, runID, "iterations",
		fmt.Sprintf("%d", iter), "generated")
}

func readLatestGeneratedHCL(runtime *CommandRuntime, scenarioName, runID string, completedIter int) string {
	if completedIter < 1 {
		return ""
	}
	dir := filepath.Join(runtime.RunStoreRoot(), scenarioName, runID, "iterations",
		fmt.Sprintf("%d", completedIter), "generated")
	entries, err := os.ReadDir(dir)
	if err != nil {
		return ""
	}
	var sb strings.Builder
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".tf") {
			continue
		}
		body, err := os.ReadFile(filepath.Join(dir, e.Name()))
		if err != nil {
			continue
		}
		sb.Write(body)
		sb.WriteByte('\n')
	}
	return sb.String()
}

func resetAllConfiguredMocks(ctx context.Context, runtime *CommandRuntime) error {
	urls := []struct {
		name, url string
	}{
		{"mockway", runtime.Config.Mockway.URL},
		{"fakegcp", runtime.Config.Fakegcp.URL},
		{"fakeaws", runtime.Config.Fakeaws.URL},
		{"fakegenesys", runtime.Config.Fakegenesys.URL},
	}
	var errs []string
	for _, m := range urls {
		if strings.TrimSpace(m.url) == "" {
			continue
		}
		if err := postMockReset(ctx, m.url); err != nil {
			errs = append(errs, fmt.Sprintf("%s: %v", m.name, err))
		}
	}
	if len(errs) == 0 {
		return nil
	}
	return fmt.Errorf("mock reset partial: %s", strings.Join(errs, "; "))
}

// postMockReset issues a single POST {url}/mock/reset with a short
// timeout. Used by resetAllConfiguredMocks.
func postMockReset(ctx context.Context, baseURL string) error {
	u := strings.TrimRight(baseURL, "/") + "/mock/reset"
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil)
	if err != nil {
		return err
	}
	resp, err := mockResetClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("status %d", resp.StatusCode)
	}
	return nil
}

var mockResetClient = &http.Client{Timeout: 10 * time.Second}
