//go:build regression

package automation

// Regression tests for the 19 record-triggered AutoLaunchedFlows that were
// already executing correctly against the pre-Phase-2 engine. This file is
// the Phase 3 item 2 deliverable from
// amber-main-design-20260416-230159.md ("Regression tests (CRITICAL)").
// The original baseline was 17 flows; #2043 added Opportunity_Auto_To_Launch_Stage
// and POC_Set_Region after #1817 / #2016 surfaced gaps in coverage.
//
// The design doc: "For each of the 17 existing working flows, capture
// pre-change execution snapshots. After engine changes, replay the same
// inputs and assert identical output. The engine changes (formula wiring,
// `__r` resolution) affect ALL flows — this proves no silent regressions."
//
// Strategy
// --------
// Hermetic snapshot replay. No live DB at test time (fixtures are dumped
// once via scripts/dump-flow-regression-fixtures into testdata/). The test:
//
//  1. Loads each flow's metadata JSON from testdata/regression_fixtures/
//     (verbatim copy of flow.Metadata for the ActiveVersion in the test DB
//     as of the snapshot run).
//  2. Builds a deterministic synthetic trigger record for each flow. __r
//     relationship fields are pre-populated as maps so that the test does
//     not depend on Lane A's __r lazy-load DB query — the behavioural
//     invariant we are asserting is "given identical context, the engine
//     dispatches to the same elements and mutates the same keys".
//  3. Walks the flow via FlowExecutor.executeElement starting at
//     flowMeta.Start.Connector.TargetReference. Elements that would need a
//     DB (RecordCreate/Update/Delete/Lookup, ActionCall, Subflow) are
//     recorded opaquely in the trace as "<type>:<name>" and then the
//     element's default/primary connector is followed — they are NOT
//     actually invoked. This keeps the test hermetic while still
//     exercising every pure-logic element (Decision, Assignment, Loop,
//     CustomError) end-to-end.
//  4. Serialises the trace + final context data as a stable JSON snapshot
//     in testdata/regression_snapshots/<flow>.json. Run with
//     REGRESSION_UPDATE=1 to regenerate snapshots on the baseline branch;
//     run without the env var on the merged branch to diff.
//
// Snapshot override
// -----------------
// Some flows may legitimately diverge after the engine changes (e.g. a
// condition that previously resolved to nil now resolves to a real value
// because Lane A lazy-loads the __r relationship). Such divergences are
// not regressions — they are the intended effect of the fix. To mark a
// flow's divergence as expected, add an entry to
// testdata/regression_snapshots/overrides.json:
//
//   {"Collection_Set_PLG_BD_Pool": "__r lazy-load now resolves Account__r.PLG_BD_Pool__c"}
//
// Overridden flows still run and their trace is captured; they emit a
// t.Log warning and PASS. Flows that diverge without an override FAIL.
//
// Running the test
// ----------------
//   # Regenerate snapshots on the baseline (main) branch:
//   REGRESSION_UPDATE=1 go test -tags=regression \
//     -run TestFlowRegression ./internal/features/automation/...
//
//   # Verify on merged branch:
//   go test -tags=regression -run TestFlowRegression \
//     ./internal/features/automation/...
//
// Run /review before merging snapshot changes — a snapshot diff is a
// behavioural claim and deserves dual-model verification.

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

	"github.com/nexuscrm/shared/pkg/models"
)

// regressionFlows is the hardcoded set of 19 record-triggered
// AutoLaunchedFlows that were executing correctly pre-Phase-2. This list is
// derived from the active FlowDefinition rows in the snapshot test DB
// (gateway01.us-east-1.prod.aws.tidbcloud.com/nexuscrm) where
// ProcessType='AutoLaunchedFlow' AND TriggerType IN
// ('RecordAfterSave','RecordBeforeSave') AND ActiveVersionId IS NOT NULL —
// filtered to the four target objects from the design doc
// (Order/Contract/Invoice_Collection__c/Account), then expanded with
// nine additional flows on related objects (PoC__c/Opportunity) — including
// Opportunity_Auto_To_Launch_Stage and POC_Set_Region added per #2043 after
// the #1817 / #2016 audits surfaced gaps. The expansion is explicit because
// the same Phase-2 engine changes (formula wiring, __r resolution, IsBlank,
// Wait) affect every RecordAfterSave/RecordBeforeSave flow — the regression
// surface is not limited to the four in-scope objects.
var regressionFlows = []string{
	// In-scope (4 target objects)
	"contractOpportunityInset",
	"Collection_Set_PLG_BD_Pool",
	"Order_Set_PLG_BD_Pool",
	"Contract_Update_Account_Contract_Valid",
	"Order_Org_Auto_Set_Account_Create",
	"Order_Org_Auto_Set_Account_Update",
	"Collection_Amount_and_Payable_Amount_exc_tax_Update",
	"Collection_Amount_and_Payable_Amount_exc_tax",
	"Cloud_Registration_Source_from_Org_to_Order",
	"Cloud_Registration_Source_from_Org_to_Collections",

	// Adjacent objects (PoC__c, Opportunity) — same engine paths exercised
	"POC_Set_Approval_Date",
	"Add_or_update_Opt_Owner_s_Role",
	"PoC_Complete_Date",
	"AWS_Close_Date",
	"Opportunity_Close_Date_Auto_Set",
	"Technical_Validation_Is_Request_Products",
	"Opportunity_Service_Start_Date_Close_Date",

	// Added per #2043 — surfaced by #1817 runtime audit + #2016 metadata-content review.
	// F8: RecordBeforeSave on Opportunity, EqualTo + IsNull entry filters.
	// F9: RecordAfterSave on PoC__c, IsNull(false) on Region__c — exercises the
	// AfterSave start-element path not previously covered by the 17-flow baseline.
	"Opportunity_Auto_To_Launch_Stage",
	"POC_Set_Region",
}

const (
	regressionFixturesDir  = "testdata/regression_fixtures"
	regressionSnapshotsDir = "testdata/regression_snapshots"
	regressionOverrideFile = "testdata/regression_snapshots/overrides.json"
	maxRegressionSteps     = 200
)

// regressionSnapshot is the per-flow stable fingerprint persisted to disk.
type regressionSnapshot struct {
	Flow       string            `json:"flow"`
	StartEl    string            `json:"startElement"`
	Trace      []string          `json:"trace"`
	FinalCtx   map[string]any    `json:"finalContext"`
	FinalErr   string            `json:"finalError,omitempty"`
	Steps      int               `json:"steps"`
	Terminated string            `json:"terminated"`
	Assertions map[string]string `json:"assertions,omitempty"`
}

// TestFlowRegression_AllFixturesPresent fails if a flow in regressionFlows
// is missing its fixture or has a stale extra fixture. Mirrors the guard
// pattern from flow_e2e_integration_test.go::TestE2EFlowFixtures_AllPresent.
func TestFlowRegression_AllFixturesPresent(t *testing.T) {
	entries, err := os.ReadDir(regressionFixturesDir)
	if err != nil {
		t.Fatalf("cannot read %s: %v", regressionFixturesDir, err)
	}
	present := map[string]bool{}
	for _, e := range entries {
		if !strings.HasSuffix(e.Name(), ".flow.json") {
			continue
		}
		present[strings.TrimSuffix(e.Name(), ".flow.json")] = true
	}
	want := map[string]bool{}
	for _, f := range regressionFlows {
		want[f] = true
		if !present[f] {
			t.Errorf("missing fixture for %s — run scripts/dump-flow-regression-fixtures", f)
		}
	}
	for got := range present {
		if !want[got] {
			t.Errorf("stale fixture %s — not in regressionFlows", got)
		}
	}
}

// TestFlowRegression is the main per-flow snapshot replay. Each flow runs
// as a subtest so one divergence does not mask others.
func TestFlowRegression(t *testing.T) {
	overrides := loadOverrides(t)

	for _, flow := range regressionFlows {
		flow := flow
		t.Run(flow, func(t *testing.T) {
			meta, err := loadRegressionFixture(flow)
			if err != nil {
				var fle *fixtureLoadError
				if errors.As(err, &fle) && fle.fatal {
					t.Fatalf("broken fixture %s: %v", flow, err)
				}
				t.Skipf("cannot load fixture %s: %v", flow, err)
				return
			}

			actual := replayFlow(t, flow, meta)
			snapshotGuardTimeVars(t, flow, actual.FinalCtx)
			actualJSON := marshalStable(t, actual)

			snapshotPath := filepath.Join(regressionSnapshotsDir, flow+".json")

			if os.Getenv("REGRESSION_UPDATE") == "1" {
				if err := os.WriteFile(snapshotPath, actualJSON, 0o644); err != nil {
					t.Fatalf("write snapshot %s: %v", snapshotPath, err)
				}
				t.Logf("wrote snapshot %s (%d bytes)", snapshotPath, len(actualJSON))
				return
			}

			expected, err := os.ReadFile(snapshotPath)
			if err != nil {
				t.Fatalf("missing snapshot %s — run with REGRESSION_UPDATE=1 on baseline branch: %v", snapshotPath, err)
			}

			if string(expected) == string(actualJSON) {
				return
			}

			// Diff detected — check override.
			if reason, ok := overrides[flow]; ok {
				t.Logf("OVERRIDE %s: snapshot diverged — allowed by override: %s", flow, reason)
				return
			}
			t.Errorf("snapshot mismatch for %s\n--- expected\n%s\n--- actual\n%s", flow, string(expected), string(actualJSON))
		})
	}
}

// loadOverrides reads overrides.json if present; returns empty map if not.
// overrides.json is a simple {flowName: reasonString} mapping. A missing
// file is not an error — most runs will have no overrides.
func loadOverrides(t *testing.T) map[string]string {
	t.Helper()
	raw, err := os.ReadFile(regressionOverrideFile)
	if err != nil {
		if os.IsNotExist(err) {
			return map[string]string{}
		}
		t.Fatalf("read overrides: %v", err)
	}
	m := map[string]string{}
	if err := json.Unmarshal(raw, &m); err != nil {
		t.Fatalf("parse overrides: %v", err)
	}
	return m
}

// fixtureLoadError distinguishes "file does not exist" (a legitimate reason
// to skip a subtest, e.g. a new flow in the list that has not been dumped
// yet) from "file exists but is empty or malformed" (a broken fixture that
// should fail loudly rather than silently skip).
type fixtureLoadError struct {
	path  string
	err   error
	fatal bool
}

func (e *fixtureLoadError) Error() string { return fmt.Sprintf("%s: %v", e.path, e.err) }

func loadRegressionFixture(flow string) (*models.FlowMetadata, error) {
	path := filepath.Join(regressionFixturesDir, flow+".flow.json")
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, &fixtureLoadError{path: path, err: err, fatal: false}
	}
	if len(raw) == 0 {
		return nil, &fixtureLoadError{path: path, err: fmt.Errorf("empty fixture"), fatal: true}
	}
	var meta models.FlowMetadata
	if err := json.Unmarshal(raw, &meta); err != nil {
		return nil, &fixtureLoadError{path: path, err: fmt.Errorf("unmarshal: %w", err), fatal: true}
	}
	return &meta, nil
}

// replayFlow walks the flow graph starting at Start.Connector.TargetReference,
// recording each visited element and the post-execution context. Non-pure
// elements (record ops, actions, subflows) are recorded opaquely in the
// trace and their primary connector is followed. This keeps the replay
// deterministic and DB-free.
func replayFlow(t *testing.T, flow string, meta *models.FlowMetadata) *regressionSnapshot {
	t.Helper()

	snap := &regressionSnapshot{
		Flow:       flow,
		Trace:      []string{},
		Assertions: map[string]string{},
	}

	if meta.Start == nil {
		snap.Terminated = "no-start-element"
		return snap
	}
	// Record-triggered flows use Start.Connector; scheduled flows embed the
	// entry point under Start.ScheduledPaths[0].Connector. Pick whichever is
	// populated so a Scheduled-start flow is not recorded as a no-op.
	snap.StartEl = resolveStartTarget(meta.Start)
	if snap.StartEl == "" {
		snap.Terminated = "no-start-connector"
		return snap
	}

	executor := newRegressionExecutor()
	// initializeFlowContext seeds defaults for Variables and merges inputs.
	initialContext := initializeFlowContext(meta, syntheticInputs(meta))
	interview := &models.FlowInterview{
		Id:              "test-" + flow,
		FlowVersionId:   "test-version",
		InterviewStatus: models.FlowInterviewStatusRunning,
		ContextData:     initialContext,
	}

	// Entry conditions gate: if false, the production executor returns a
	// {_skipped: true} result and never walks the graph (see
	// flow_executor.go::executeFlowVersion). Mirror that here so the
	// snapshot matches the observable behaviour. Record the outcome either
	// way — a regression that flips the gate result is itself a regression.
	entryPass := executor.checkEntryConditions(meta, interview.ContextData)
	snap.Assertions["entryConditionsPass"] = fmt.Sprintf("%v", entryPass)
	if !entryPass {
		snap.Terminated = "entry-skipped"
		snap.FinalCtx = sanitizeContext(interview.ContextData)
		return snap
	}

	currentElement := snap.StartEl
	steps := 0
	ctx := context.Background()
	user := &models.UserSession{ID: "regression-user", Name: "Regression"}

	for steps < maxRegressionSteps && currentElement != "" {
		interview.CurrentElement = currentElement

		elementType, pure := classifyElement(meta, currentElement)
		snap.Trace = append(snap.Trace, fmt.Sprintf("%s:%s", elementType, currentElement))

		if !pure {
			// Record the opaque step and follow its first/primary connector
			// without invoking the element. This keeps the test hermetic.
			next := firstConnectorTarget(meta, currentElement, elementType)
			currentElement = next
			steps++
			continue
		}

		next, err := executor.executeElement(ctx, interview, meta, currentElement, user)
		if err != nil {
			snap.FinalErr = err.Error()
			snap.Terminated = "element-error"
			break
		}
		currentElement = next
		steps++
	}
	if steps >= maxRegressionSteps {
		snap.Terminated = "max-steps"
	} else if snap.Terminated == "" {
		if currentElement == "" {
			snap.Terminated = "completed"
		}
	}
	snap.Steps = steps

	snap.FinalCtx = sanitizeContext(interview.ContextData)
	return snap
}

// resolveStartTarget returns the flow's entry element name. Record-triggered
// flows carry it on Start.Connector; scheduled flows embed it under
// Start.ScheduledPaths[0].Connector. Returns "" when the flow has no entry,
// which replayFlow records as "no-start-connector".
func resolveStartTarget(start *models.FlowStart) string {
	if start == nil {
		return ""
	}
	if start.Connector != nil && start.Connector.TargetReference != "" {
		return start.Connector.TargetReference
	}
	for _, sp := range start.ScheduledPaths {
		if sp.Connector != nil && sp.Connector.TargetReference != "" {
			return sp.Connector.TargetReference
		}
	}
	return ""
}

// classifyElement returns the element's type name and whether it is pure
// (safe to execute without a DB or external service). Non-pure elements
// are recorded opaquely; the test follows their primary connector instead
// of invoking them, which keeps the replay hermetic.
//
// Scope note: Screens and Waits are not enumerated here. Screens are an
// interactive-flow concept the engine does not run. Waits are a Phase-2
// addition — by definition the 17 pre-Phase-2 flows in regressionFlows
// never reference one. If the regression set grows to include Wait-using
// flows, add a "Wait" case that returns non-pure and reads from
// meta.Waits on the merged branch. Until then, an element not in any
// enumerated list falls through to "Unknown" and will be executed via
// executeElement, which surfaces a NotFoundError — that loud failure is
// the intended signal that the regression surface has expanded past what
// this classifier covers.
func classifyElement(meta *models.FlowMetadata, name string) (string, bool) {
	for _, d := range meta.Decisions {
		if d.Name == name {
			return "Decision", true
		}
	}
	for _, a := range meta.Assignments {
		if a.Name == name {
			return "Assignment", true
		}
	}
	for _, l := range meta.Loops {
		if l.Name == name {
			return "Loop", true
		}
	}
	for _, c := range meta.CustomErrors {
		if c.Name == name {
			return "CustomError", true
		}
	}
	for _, r := range meta.RecordCreates {
		if r.Name == name {
			return "RecordCreate", false
		}
	}
	for _, r := range meta.RecordUpdates {
		if r.Name == name {
			return "RecordUpdate", false
		}
	}
	for _, r := range meta.RecordDeletes {
		if r.Name == name {
			return "RecordDelete", false
		}
	}
	for _, r := range meta.RecordLookups {
		if r.Name == name {
			return "RecordLookup", false
		}
	}
	for _, a := range meta.ActionCalls {
		if a.Name == name {
			return "ActionCall", false
		}
	}
	for _, s := range meta.Subflows {
		if s.Name == name {
			return "Subflow", false
		}
	}
	return "Unknown", true
}

// firstConnectorTarget returns the target of the element's primary connector
// by element type. For RecordCreate/Update/Delete/Lookup/ActionCall/Subflow
// this is the Connector field. Returns "" when no connector is declared.
func firstConnectorTarget(meta *models.FlowMetadata, name string, elementType string) string {
	switch elementType {
	case "RecordCreate":
		for _, r := range meta.RecordCreates {
			if r.Name == name && r.Connector != nil {
				return r.Connector.TargetReference
			}
		}
	case "RecordUpdate":
		for _, r := range meta.RecordUpdates {
			if r.Name == name && r.Connector != nil {
				return r.Connector.TargetReference
			}
		}
	case "RecordDelete":
		for _, r := range meta.RecordDeletes {
			if r.Name == name && r.Connector != nil {
				return r.Connector.TargetReference
			}
		}
	case "RecordLookup":
		for _, r := range meta.RecordLookups {
			if r.Name == name && r.Connector != nil {
				return r.Connector.TargetReference
			}
		}
	case "ActionCall":
		for _, a := range meta.ActionCalls {
			if a.Name == name && a.Connector != nil {
				return a.Connector.TargetReference
			}
		}
	case "Subflow":
		for _, s := range meta.Subflows {
			if s.Name == name && s.Connector != nil {
				return s.Connector.TargetReference
			}
		}
	}
	return ""
}

// newRegressionExecutor builds a zero-DB FlowExecutor. Pure-logic
// elements never read persistence/metadata/actions, so nil dependencies
// are sufficient. The logger is left nil — pkg/logging.Logger is
// documented as nil-safe so callers that only wire a few dependencies
// (this test) don't panic on a logging call.
func newRegressionExecutor() *FlowExecutor {
	return &FlowExecutor{}
}

// syntheticInputs constructs a deterministic $Record and $Record__Old for
// the flow, pre-populating __r relationship fields with stable maps so
// that resolveNestedReference traverses them identically on main and
// merged branches. The objective is not to simulate real CRM data but to
// drive the engine through a repeatable code path.
//
// After building a generic base record, the function inspects
// meta.Start.Filters and populates per-flow field values so that entry
// conditions pass. This gets flows past the entry gate and into graph
// traversal, improving regression coverage.
func syntheticInputs(meta *models.FlowMetadata) map[string]interface{} {
	record := models.SObject{
		"Id":           "001regression000000",
		"Name":         "Regression Test Record",
		"OwnerId":      "005regression000000",
		"CreatedById":  "005regression000000",
		"CreatedDate":  "2026-01-01T00:00:00Z",
		"LastModifiedDate": "2026-01-01T00:00:00Z",
		// Common FK shapes. Fields that flows reference as X__r.Y will be
		// looked up via resolveNestedReference — providing a populated
		// map here avoids divergence from Lane A's lazy-load path.
		"AccountId":   "001regression000001",
		"Account__c":  "001regression000001",
		"Order__c":    "801regression000001",
		"Account__r": map[string]interface{}{
			"Id":                                 "001regression000001",
			"Name":                               "Regression Account",
			"PLG_BD_Pool__c":                     "pool-A",
			"Contracting_Entity__c":              "Entity-A",
			"Marketplace_Type__c":                "AWS",
			"Owner":                              map[string]interface{}{"Id": "005regression000000", "Name": "Owner"},
		},
		"Account": map[string]interface{}{
			"Id":          "001regression000001",
			"Name":        "Regression Account",
			"OwnerId":     "005regression000000",
			"PLG_BD_Pool__c": "pool-A",
		},
		"Org__r": map[string]interface{}{
			"Id":         "a00regression000000",
			"Account__c": "001regression000001",
			"Cloud_Registration_Source__c": "aws",
		},
		"Order__r": map[string]interface{}{
			"Id":   "801regression000001",
			"Name": "Regression Order",
			"Overall_Start_Date__c": "2026-01-01",
		},
		"Owner": map[string]interface{}{
			"Id":   "005regression000000",
			"Name": "Owner User",
			"UserRole": map[string]interface{}{"Name": "Sales Engineer"},
		},
	}
	old := models.SObject{
		"Id":   "001regression000000",
		"Name": "Regression Test Record (old)",
	}

	// Per-flow overrides: inspect entry filters and populate fields so the
	// entry gate passes. For IsNull-false we ensure the field is non-nil;
	// for EqualTo we set the exact expected value; for IsChanged we set
	// the field in $Record (old record lacks it, so it counts as changed).
	applyEntryFilterOverrides(meta, record, old)

	return map[string]interface{}{
		"$Record":      record,
		"$Record__Old": old,
	}
}

// applyEntryFilterOverrides inspects meta.Start.Filters and ensures the
// synthetic $Record (and $Record__Old where needed) carry field values that
// satisfy each filter. This is intentionally conservative — it only handles
// the operator families used by the 17 in-scope regression flows.
func applyEntryFilterOverrides(meta *models.FlowMetadata, record, old models.SObject) {
	if meta == nil || meta.Start == nil || len(meta.Start.Filters) == 0 {
		return
	}
	for _, f := range meta.Start.Filters {
		field := f.Field
		filterVal, _ := extractTypedValue(f.Value)
		op := normalizeFlowOperator(f.Operator)

		switch op {
		case "IsNull":
			// IsNull with booleanValue=false → field must be non-nil
			if bv, ok := filterVal.(bool); ok && !bv {
				if record[field] == nil || record[field] == "" {
					record[field] = "synth-" + field
				}
			}
			// IsNull with booleanValue=true → field must be nil
			if bv, ok := filterVal.(bool); ok && bv {
				delete(record, field)
			}

		case "EqualTo":
			// Set the field to the exact expected value.
			if filterVal != nil {
				record[field] = fmt.Sprintf("%v", filterVal)
			}

		case "IsChanged":
			// Ensure the field exists in $Record. The old record should
			// either lack the field or have a different value. Since old
			// only has Id/Name by default, just setting the field in
			// $Record is enough. If old happens to already have it with
			// the same value, give old a different one.
			if record[field] == nil || record[field] == "" {
				record[field] = "synth-" + field
			}
			if oldVal, exists := old[field]; exists && fmt.Sprintf("%v", oldVal) == fmt.Sprintf("%v", record[field]) {
				old[field] = "old-synth-" + field
			}
		}
	}
}

// sanitizeContext drops volatile keys (step counters, transient synthetic
// inputs) and produces a stable map for JSON marshalling. _completedSteps
// is written by executeFlowMetadata; we capture the total step count
// separately in snap.Steps so this transient key should not leak into the
// snapshot.
//
// Time-dependent values: resolveFlowSystemVariable reads time.Now() when
// a flow references $Flow.CurrentDate / $Flow.CurrentDateTime /
// $Flow.InterviewStartTime. If the pure path of any regression flow
// writes such a value into context, the snapshot becomes day-dependent.
// None of the 17 in-scope flows currently hits this path — verified by
// the present snapshot set — but if that changes, snapshotGuardTimeVars
// below will panic loudly rather than silently drift.
func sanitizeContext(in map[string]interface{}) map[string]interface{} {
	out := make(map[string]interface{}, len(in))
	for k, v := range in {
		if k == "_completedSteps" {
			continue
		}
		out[k] = v
	}
	return out
}

// snapshotGuardTimeVars fails the test if any context value looks like a
// time-varying marker emitted by resolveFlowSystemVariable — a
// yyyy-mm-dd string close to time.Now(). This turns a latent determinism
// bug into an explicit test failure that demands an override entry or a
// harness-level time freeze.
func snapshotGuardTimeVars(t *testing.T, flow string, ctx map[string]interface{}) {
	t.Helper()
	today := time.Now().UTC().Format("2006-01-02")
	var walk func(any)
	walk = func(v any) {
		switch val := v.(type) {
		case string:
			if val == today {
				t.Fatalf("%s: snapshot captured today-dependent value %q — add a freeze or strip from sanitizeContext", flow, val)
			}
		case map[string]interface{}:
			for _, inner := range val {
				walk(inner)
			}
		case []interface{}:
			for _, inner := range val {
				walk(inner)
			}
		}
	}
	for _, v := range ctx {
		walk(v)
	}
}

// marshalStable emits snap as JSON with sorted map keys (encoding/json does
// this by default) and a trailing newline, so diffs are stable and
// human-readable. Trace slice order is the execution order and must not be
// sorted — it is the primary signal of "same element dispatch path".
func marshalStable(t *testing.T, snap *regressionSnapshot) []byte {
	t.Helper()
	buf, err := json.MarshalIndent(snap, "", "  ")
	if err != nil {
		t.Fatalf("marshal snapshot: %v", err)
	}
	buf = append(buf, '\n')
	return buf
}
