#!/usr/bin/env perl

# Unit tests for Harness::Runner against a small FIXTURE catalogue - no real ZFS, no
# dd-nas1. zfs calls go through $Harness::Safe::EXEC_OVERRIDE; the sandbox root is
# retargeted at a tempdir so YAMLPatch/Harness::Safe path() checks accept fixture paths.

use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/..";
use lib "$FindBin::Bin/../sneakernet/testing";
use Harness::Catalogue;
use Harness::Runner;
use Harness::Safe;
use YAMLPatch;
use File::Temp qw(tempdir);
use File::Path qw(make_path);
use JSON::PP qw(encode_json);

$| = 1;
my $passed = 0;
my $failed = 0;

sub ok {
    my ($cond, $desc) = @_;
    if ($cond) { print "  PASS: $desc\n"; $passed++; return 1; }
    else       { print "  FAIL: $desc\n"; $failed++; return 0; }
}

sub dies_ok {
    my ($code, $desc, $like) = @_;
    my $died = 0; my $msg = '';
    eval { $code->(); 1 } or do { $died = 1; $msg = $@; };
    if ($died && defined $like && $msg !~ $like) {
        print "  FAIL: $desc (died, but message didn't match $like: $msg)\n"; $failed++; return;
    }
    ok($died, $desc);
}

sub write_json { my ($path, $data) = @_; open my $fh, '>', $path or die $!; print {$fh} encode_json($data); close $fh; }

my $CONFIG_TEXT = <<'YAML';
---
datasets:
  ds1:
    dataset: ds1
    source: storage/testing/src
    target: storage/testing/dst
debug: '0'
statusFile: /storage/testing/code/sneakernet_target.status
source:
  poolname: storage/testing/src
  targetSnapshotList: ''
target:
  poolname: storage/testing/dst
  shutdownAfterReplication: 0
  report:
    targetDrive:
      label: ''
transport:
  label: ''
  mountPoint: /storage/testing/transport
YAML

# Makes a fresh sandbox tempdir (with a config file already in it) and returns its path,
# BEFORE the caller builds a part/step structure that references paths under it - so
# "$sandbox/..." can appear in the fixture JSON without a chicken-and-egg ordering problem.
sub make_sandbox {
    my $sandbox = tempdir(CLEANUP => 1);
    make_path("$sandbox/transport");
    my $configPath = "$sandbox/sneakernet.conf.yaml";
    open my $fh, '>', $configPath or die $!;
    print {$fh} $CONFIG_TEXT;
    close $fh;
    return ($sandbox, $configPath);
}

# Wires a given $sandbox + part-hash into a Runner. Called AFTER make_sandbox(), so $sandbox
# is already a real value when the caller constructs the part hash.
sub make_runner {
    my ($sandbox, $configPath, $part, %opts) = @_;
    my $catDir = tempdir(CLEANUP => 1);
    write_json("$catDir/reset_profiles.json", {
        standard => [ { do => 'empty_dir', path => "$sandbox/transport" } ],
        none     => [],
    });
    write_json("$catDir/part01.json", $part);
    my $cat = Harness::Catalogue->load_dir($catDir);
    return Harness::Runner->new(catalogue => $cat, config_path => $configPath, include_optional => $opts{include_optional} // 0);
}

# ===========================================================================
print "=== a passing step with a config mutation and an assertion ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'set debug',
              config => { mutations => [ { op => 'set', path => 'debug', value => '9', style => 'single' } ], restore => 'none' },
              actions => [ { do => 'write_file', path => "$sandbox/marker.txt", content => 'ran' } ],
              assertions => [ { type => 'file_nonempty', path => "$sandbox/marker.txt", message => 'marker written' } ] },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my @results = $runner->results;
    ok($results[0]{status} eq 'PASS', "step with a mutation + passing assertion reports PASS")
        or print "    error: " . ($results[0]{error} // '') . "\n";

    my $y = YAMLPatch->load_file($configPath);
    ok($y->get('debug')->{value} eq '9', 'the config mutation actually landed on disk');
}

# ===========================================================================
print "\n=== restore => 'auto' (the default) restores config after the step ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'temp change', config => { mutations => [ { op => 'set', path => 'debug', value => '5', style => 'single' } ] } },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my $y = YAMLPatch->load_file($configPath);
    ok($y->get('debug')->{value} eq '0', "restore=>'auto' put debug back to its original value after the step");
}

# ===========================================================================
print "\n=== restore => 'auto' also restores under --confirm-destructive, not just --run (regression) ===\n";
# ===========================================================================
{
    # Regression: found running the real harness against dd-nas1 - _apply_config_mutations
    # persists to disk under BOTH 'run' and 'confirm_destructive' (only explain/dry_run skip
    # the save), but the restore-after-step logic checked only MODE eq 'run', so every mutation
    # made during a --confirm-destructive run (a deliberately SAFER first-pass mode, since it
    # prompts before each destructive primitive) was silently left in place forever. On dd-nas1
    # this left transport.mountPoint set to '' (Step 2.3's deliberate failure-mode test value)
    # for every step after it, cascading into unrelated failures for the rest of the run.
    my ($sandbox, $configPath) = make_sandbox();
    # reset => 'none': the point of this test is the mutation/restore behavior on step 1.1
    # itself, not the reset profile - and confirm_destructive mode would otherwise prompt on
    # the standard profile's empty_dir (a destructive-class primitive), which reads STDIN.
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'none',
        steps => [
            { id => '1.1', title => 'temp change', config => { mutations => [ { op => 'set', path => 'debug', value => '5', style => 'single' } ] } },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('confirm_destructive');
    $runner->run_steps('1.1');
    my $y = YAMLPatch->load_file($configPath);
    ok($y->get('debug')->{value} eq '0', "restore=>'auto' put debug back to its original value under --confirm-destructive too");
    Harness::Safe::set_mode('run');
}

# ===========================================================================
print "\n=== restore => 'none' leaves the mutation in place for the next step ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'persist', config => { mutations => [ { op => 'set', path => 'debug', value => '5', style => 'single' } ], restore => 'none' } },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my $y = YAMLPatch->load_file($configPath);
    ok($y->get('debug')->{value} eq '5', "restore=>'none' leaves the mutated value in place");
}

# ===========================================================================
print "\n=== a step with no config.mutations at all is never auto-restored (regression) ===\n";
# ===========================================================================
{
    # Regression: found running Part 0 for real against dd-nas1. A step with no "config" key
    # at all (e.g. Step 0.5, which installs the baseline config via a plain write_file action,
    # not config.mutations) still fell through to $step->{config}{restore} being undef - which
    # is "ne 'none'" and so was treated the same as an explicit restore=>'auto'. That reverted
    # the step's own write_file to whatever the config held immediately before the step ran,
    # silently undoing the very config the step existed to install.
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'install baseline config (no config key at all)',
              actions => [ { do => 'write_file', path => $configPath, content => "debug: '9'\n" } ] },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my $y = YAMLPatch->load_file($configPath);
    ok($y->get('debug')->{value} eq '9', "a step with no config.mutations keeps its own write_file's content");
}

# ===========================================================================
print "\n=== the containment hook refuses a mutation pointed at production ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'evil', config => { mutations => [ { op => 'set', path => 'target.poolname', value => 'storage/backup' } ] } },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my @results = $runner->results;
    ok($results[0]{status} eq 'FAIL', 'a step whose mutation points at production reports FAIL, not a crash');
    ok($results[0]{error} =~ /outside storage\/testing|containment/i, 'the failure reason names the containment refusal')
        or print "    error was: $results[0]{error}\n";
    my $y = YAMLPatch->load_file($configPath);
    ok($y->get('target.poolname')->{value} eq 'storage/testing/dst', 'the on-disk config was never actually changed');
}

# ===========================================================================
print "\n=== the run_sneakernet pre-flight containment check skips a deliberately-invalid config, but only for the step that declares it (regression) ===\n";
# ===========================================================================
{
    # Regression: found running Part 15.3 for real - it deliberately appends invalid YAML to the
    # config via append_raw (only permitted on a step with config.expects_invalid_config, see
    # _apply_config_mutations), to prove sneakernet itself refuses to run against a broken config
    # safely. Harness::Safe::run_sneakernet's own pre-flight gate (CONFIG_CONTAINMENT_CHECK,
    # registered by Runner->new) does a full strict-subset YAMLPatch parse before every
    # invocation - it crashed on the deliberately-broken file before sneakernet ever got a chance
    # to run, testing the harness's own gate instead of the product. Runner->new now skips that
    # pre-flight parse specifically when the step currently executing declares
    # expects_invalid_config - checked here directly against the registered check closure,
    # since run_sneakernet itself needs a real sneakernet script at a hardcoded absolute path
    # this test sandbox can't provide.
    my ($sandbox, $configPath) = make_sandbox();
    open my $fh, '>>', $configPath or die $!;
    print {$fh} "not valid yaml: [[[\n";
    close $fh;
    my $runner = make_runner($sandbox, $configPath, { part => 1, title => 'x', reset => 'none', steps => [] });

    $runner->{current_step} = { id => '15.3', config => { expects_invalid_config => 1 } };
    my $ok = eval { $Harness::Safe::CONFIG_CONTAINMENT_CHECK->(); 1 };
    ok($ok, 'a step declaring expects_invalid_config skips the pre-flight parse entirely')
        or print "    error: $@\n";

    $runner->{current_step} = { id => '1.1', config => { expects_invalid_config => 0 } };
    my $ok2 = eval { $Harness::Safe::CONFIG_CONTAINMENT_CHECK->(); 1 };
    ok(!$ok2, 'an ordinary step against the SAME broken file still gets refused - the skip is scoped to the declaring step, not global')
        or print "    (unexpectedly succeeded)\n";
}

# ===========================================================================
print "\n=== variable substitution and the empty-capture defence ===\n";
# ===========================================================================
{
    local $Harness::Safe::EXEC_OVERRIDE = sub {
        my ($argv) = @_;
        return ("storage/testing/src/ds1\@S1_x\nstorage/testing/src/ds1\@S2_y\n", 0) if grep { /zfs/ } @$argv;
        return ('', 0);
    };
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'capture then use',
              actions => [
                  { do => 'zfs_list_snapshots', dataset => 'storage/testing/src/ds1',
                    capture => { name => 'S2NAME', select => '\@(S2_[a-z]+)$', expect_count => 1 } },
                  { do => 'write_file', path => "$sandbox/\$S2NAME.txt", content => 'x' },
              ] },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my @results = $runner->results;
    ok($results[0]{status} eq 'PASS', 'a capture that finds exactly expect_count matches proceeds normally')
        or print "    error: " . ($results[0]{error} // '') . "\n";
    ok(-e "$sandbox/S2_y.txt", 'the captured variable was correctly substituted into a later action path');
}
{
    local $Harness::Safe::EXEC_OVERRIDE = sub { return ('', 0); }; # no snapshots at all -> zero matches
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'capture nothing',
              actions => [
                  { do => 'zfs_list_snapshots', dataset => 'storage/testing/src/ds1',
                    capture => { name => 'S2NAME', select => '\@(S2_[a-z]+)$', expect_count => 1 } },
                  { do => 'zfs_destroy', dataset => "storage/testing/src/ds1\@\$S2NAME" },
              ] },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my @results = $runner->results;
    ok($results[0]{status} eq 'FAIL', 'a capture finding ZERO matches fails the step');
    ok($results[0]{error} =~ /expected 1 match/, 'the failure explains it was an expect_count mismatch, not a mystery crash')
        or print "    error was: $results[0]{error}\n";
}
{
    # A capture with no expect_count that legitimately resolves to an empty string (e.g. reduce
    # 'lines' over zero matches) must still be refused before it reaches a later action's
    # substitution - UNDER --run. Under --explain the same catalogue must not crash: nothing
    # actually executes, so a readonly primitive's capture is only ever a placeholder.
    local $Harness::Safe::EXEC_OVERRIDE = sub { return ('', 0); }; # no snapshots -> empty capture
    my $catalogue = {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'capture empty, then substitute it elsewhere',
              actions => [
                  { do => 'zfs_list_snapshots', dataset => 'storage/testing/src/ds1',
                    capture => { name => 'INVENTORY', reduce => 'lines' } },
                  { do => 'write_file', path => 'PLACEHOLDER', content => '$INVENTORY' },
              ] },
        ],
    };
    {
        my ($sandbox, $configPath) = make_sandbox();
        $catalogue->{steps}[0]{actions}[1]{path} = "$sandbox/inventory.txt";
        my $runner = make_runner($sandbox, $configPath, $catalogue);
        local $Harness::Safe::SANDBOX_PATH = $sandbox;
        Harness::Safe::set_mode('run');
        $runner->run_steps('1.1');
        my @results = $runner->results;
        ok($results[0]{status} eq 'FAIL', 'under --run, substituting a genuinely empty captured value still fails the step');
        ok($results[0]{error} =~ /is defined but empty/, 'the failure names the empty-substitution guard')
            or print "    error was: " . ($results[0]{error} // '') . "\n";
    }
    {
        my ($sandbox, $configPath) = make_sandbox();
        $catalogue->{steps}[0]{actions}[1]{path} = "$sandbox/inventory.txt";
        my $runner = make_runner($sandbox, $configPath, $catalogue);
        local $Harness::Safe::SANDBOX_PATH = $sandbox;
        Harness::Safe::set_mode('explain');
        $runner->run_steps('1.1');
        my @results = $runner->results;
        ok($results[0]{status} eq 'EXPLAINED', 'the same catalogue under --explain does not crash on the empty placeholder capture')
            or print "    error was: " . ($results[0]{error} // '') . "\n";
    }
    Harness::Safe::set_mode('run');
}

# ===========================================================================
print "\n=== capture works against STRING output (read_file), not just list output ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    open my $vfh, '>', "$sandbox/version.txt" or die $!;
    print {$vfh} "some header\nour \$VERSION = '1.10.11';\nmore text\n";
    close $vfh;
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'capture version from a read_file',
              actions => [
                  { do => 'read_file', path => "$sandbox/version.txt",
                    capture => { name => 'VER', select => q{VERSION = '([\d.]+)'}, expect_count => 1 } },
              ],
              assertions => [ { type => 'regex_present', var => 'VER', pattern => '^1\.10\.11$', message => 'version captured from file text' } ] },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my @results = $runner->results;
    ok($results[0]{status} eq 'PASS', 'capturing a regex group out of read_file (string) output works')
        or print "    error: " . ($results[0]{error} // '') . "\n";
}

# ===========================================================================
print "\n=== capture with no 'select' defaults to the FULL text (not just the first line) ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    open my $vfh, '>', "$sandbox/multiline.txt" or die $!;
    print {$vfh} "first line\nsecond line has the needle\nthird line\n";
    close $vfh;
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'capture whole file, no select',
              actions => [
                  { do => 'read_file', path => "$sandbox/multiline.txt",
                    capture => { name => 'WHOLE' } },
              ],
              assertions => [ { type => 'regex_present', var => 'WHOLE', pattern => 'needle', message => 'a later line is visible, not just the first' } ] },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my @results = $runner->results;
    ok($results[0]{status} eq 'PASS', "capture without 'select' joins every line, not just matches[0] (regression: this used to silently capture only the first line)")
        or print "    error: " . ($results[0]{error} // '') . "\n";
}

# ===========================================================================
print "\n=== assertion vocabulary ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'assertions',
              actions => [
                  { do => 'random_hex', bytes => 4, store_as => 'HEXVAL' },
              ],
              assertions => [
                  { type => 'regex_present', var => 'HEXVAL', pattern => '^[0-9a-f]{8}$', message => 'hex looks right' },
                  { type => 'regex_absent',  var => 'HEXVAL', pattern => 'zzz', message => 'no zzz in hex' },
              ] },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    ok(($runner->results)[0]{status} eq 'PASS', 'regex_present/regex_absent both pass on a real value');
}
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'equal',
              actions => [ { do => 'random_hex', bytes => 1, store_as => 'A' } ],
              assertions => [ { type => 'values_equal', a => 'A', b => 'A', message => 'a var equals itself' } ] },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    ok(($runner->results)[0]{status} eq 'PASS', 'values_equal comparing a variable to itself passes');
}
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [ { id => '1.1', title => 'files_match',
              actions => [
                  { do => 'write_file', path => "$sandbox/a.txt", content => 'same' },
                  { do => 'write_file', path => "$sandbox/b.txt", content => 'same' },
              ],
              assertions => [ { type => 'files_match', a => "$sandbox/a.txt", b => "$sandbox/b.txt", message => 'identical content' } ] } ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    ok(($runner->results)[0]{status} eq 'PASS', 'files_match passes for identical content');
}
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [ { id => '1.1', title => 'files differ',
              actions => [
                  { do => 'write_file', path => "$sandbox/a.txt", content => 'one' },
                  { do => 'write_file', path => "$sandbox/b.txt", content => 'two' },
              ],
              assertions => [ { type => 'files_match', a => "$sandbox/a.txt", b => "$sandbox/b.txt", message => 'should match but content differs' } ] } ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    ok(($runner->results)[0]{status} eq 'FAIL', 'files_match correctly fails when content differs');
}

# ===========================================================================
print "\n=== raw_file_absent (Step 13.2's key-must-not-appear check) ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [ { id => '1.1', title => 'no secret',
              actions => [ { do => 'write_file', path => "$sandbox/log.txt", content => "line1\nline2\n" } ],
              assertions => [ { type => 'raw_file_absent', path => "$sandbox/log.txt", pattern => 'SECRETKEY', message => 'key absent from log' } ] } ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    ok(($runner->results)[0]{status} eq 'PASS', 'raw_file_absent passes when the secret truly is absent');
}
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [ { id => '1.1', title => 'leaked secret',
              actions => [ { do => 'write_file', path => "$sandbox/log.txt", content => "line1\n-K SECRETKEY123\n" } ],
              assertions => [ { type => 'raw_file_absent', path => "$sandbox/log.txt", pattern => 'SECRETKEY123', message => 'key must not leak' } ] } ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    my @results = $runner->results;
    ok($results[0]{status} eq 'FAIL', 'raw_file_absent fails when the secret IS present');
    my ($a) = grep { !$_->{ok} } @{ $results[0]{assertions} };
    ok($a->{detail} !~ /SECRETKEY123/, 'the failure detail names the LINE NUMBER but never echoes the leaked secret itself')
        or print "    detail was: $a->{detail}\n";
    ok($a->{detail} =~ /:2/, 'the failure detail correctly identifies line 2');
}

# ===========================================================================
print "\n=== classification handling ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
            part => 1, title => 'x', reset => 'standard',
            steps => [
                { id => '1.1', title => 'optional', classification => 'optional',
                  actions => [ { do => 'write_file', path => "$sandbox/should_not_exist.txt", content => 'x' } ] },
                { id => '1.2', title => 'manual', classification => 'manual', manual_note => 'do this by hand' },
                { id => '1.3', title => 'record only', classification => 'record_only',
                  assertions => [ { type => 'true', var => 'NEVER_SET', message => 'deliberately false' } ] },
            ],
        }, include_optional => 0);
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1', '1.2', '1.3');
    my @results = $runner->results;
    ok($results[0]{status} eq 'SKIP', 'an optional step is SKIPped when --include-optional is not set');
    ok(!-e "$sandbox/should_not_exist.txt", 'a skipped optional step never runs its actions');
    ok($results[1]{status} eq 'N/A', 'a manual step reports N/A and is never executed');
    ok($results[1]{note} eq 'do this by hand', "the manual step's note is preserved for the report");
    ok($results[2]{status} eq 'RECORDED', 'a record_only step with a failing assertion is RECORDED, not FAIL');
}
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
            part => 1, title => 'x', reset => 'standard',
            steps => [ { id => '1.1', title => 'optional', classification => 'optional',
                         actions => [ { do => 'write_file', path => "$sandbox/should_exist.txt", content => 'x' } ] } ],
        }, include_optional => 1);
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1');
    ok(-e "$sandbox/should_exist.txt", '--include-optional lets an optional step actually run');
}

# ===========================================================================
print "\n=== reset profile applies once per Part, not once per Step ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'a', actions => [ { do => 'write_file', path => "$sandbox/transport/f1.txt", content => 'x' } ] },
            { id => '1.2', title => 'b', actions => [ { do => 'write_file', path => "$sandbox/transport/f2.txt", content => 'x' } ] },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('run');
    $runner->run_steps('1.1', '1.2');
    ok((-e "$sandbox/transport/f1.txt" && -e "$sandbox/transport/f2.txt"),
        "both steps' files survive - the second step's Part-boundary reset didn't fire and wipe the first (reset is per-Part, not per-Step)");
}

# ===========================================================================
print "\n=== --explain never reads the config file either (it may not exist yet in a preview) ===\n";
# ===========================================================================
{
    # A sandbox with NO config file at all - a from-scratch preview should still render
    # cleanly, since --explain "executes nothing, connects to nothing".
    my $sandbox = tempdir(CLEANUP => 1);
    make_path("$sandbox/transport");
    my $configPath = "$sandbox/does_not_exist.yaml"; # deliberately never created
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [
            { id => '1.1', title => 'config_get against a nonexistent file',
              actions => [ { do => 'config_get', path => 'debug', store_as => 'D' } ] },
            { id => '1.2', title => 'a mutation against a nonexistent file',
              config => { mutations => [ { op => 'set', path => 'debug', value => '9', style => 'single' } ] } },
        ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    Harness::Safe::set_mode('explain');
    $runner->run_steps('1.1', '1.2');
    my @results = $runner->results;
    ok($results[0]{status} eq 'EXPLAINED', 'config_get against a nonexistent file does not crash under --explain')
        or print "    error: " . ($results[0]{error} // '') . "\n";
    ok($results[1]{status} eq 'EXPLAINED', 'a config mutation against a nonexistent file does not crash under --explain')
        or print "    error: " . ($results[1]{error} // '') . "\n";
    ok(!-e $configPath, '--explain never created the config file either');
}

# ===========================================================================
print "\n=== explain/dry_run modes never touch the config file on disk ===\n";
# ===========================================================================
{
    my ($sandbox, $configPath) = make_sandbox();
    my $runner = make_runner($sandbox, $configPath, {
        part => 1, title => 'x', reset => 'standard',
        steps => [ { id => '1.1', title => 'x', config => { mutations => [ { op => 'set', path => 'debug', value => '9', style => 'single' } ] } } ],
    });
    local $Harness::Safe::SANDBOX_PATH = $sandbox;
    my $before = do { local (@ARGV, $/) = $configPath; <> };
    Harness::Safe::set_mode('explain');
    $runner->run_steps('1.1');
    my $afterExplain = do { local (@ARGV, $/) = $configPath; <> };
    ok($before eq $afterExplain, '--explain never writes the config file');

    Harness::Safe::set_mode('dry_run');
    $runner->run_steps('1.1');
    my $afterDry = do { local (@ARGV, $/) = $configPath; <> };
    ok($before eq $afterDry, '--dry-run never writes the config file either');
}

print "\n=== Summary ===\n";
print "Passed: $passed\n";
print "Failed: $failed\n";
exit($failed > 0 ? 1 : 0);
