#!/usr/bin/env perl

# Unit tests for YAMLPatch.pm - a path-addressed, formatting-preserving YAML editor.
#
# Follows this project's homegrown test convention (see test_destroyedSourceDataset.pl):
# a simple ok()/pass/fail counter, no Test::More. Run directly:
#   perl testLibrary/test_YAMLPatch.pl

use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/..";
use YAMLPatch;
use File::Temp qw(tempfile tempdir);
use File::Basename qw(basename);

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

sub lives_ok {
    my ($code, $desc) = @_;
    my $ok = eval { $code->(); 1 };
    ok($ok, $desc) or print "    error: $@\n";
}

# ---------------------------------------------------------------------------
# Fixture: the actual sneakernet TESTING.md Step 0.5 canonical config, byte
# for byte (transcribed from Documentation/TESTING.md lines 90-164). Using
# the real config, not a toy example, is deliberate - it's the document this
# module was built to safely edit.
# ---------------------------------------------------------------------------
my $SNEAKERNET_CONFIG = <<'YAML';
---
datasets:
  ds1:
    dataset: ds1
    source: storage/testing/src
    target: storage/testing/dst
    maxDelta: 0.9
  ds2:
    dataset: ds2
    source: storage/testing/src
    target: storage/testing/dst
    maxDelta: 0.9
debug: '0'
displayLogsOnConsole: '1'
displayLogsOnTTY: ''
dryrun: '0'
verbosity: '4'
logFile: /storage/testing/code/sneakernet/sneakernet.log
statusFile: /storage/testing/code/sneakernet/sneakernet_target.status
statusFileBackups: 5
source:
  hostname: test-source
  poolname: storage/testing/src
  runningAverageCount: 6
  historyFile: /storage/testing/code/sneakernet/history.tsv
  cleanUpScriptsDir: /storage/testing/code/sneakernet/cleanupScripts
  oneShotCleanup: /storage/testing/oneshot
  targetSnapshotList: ''
  fullSendPolicy: warn
  cleanupScriptSchedule:
    helloWorld:
    - 1
    - 2
    - 3
    - 4
    - 5
    - 6
    - 7
    - 8
    - 9
    - 10
    - 11
    - 12
  report:
    email: ''
    subject: ''
    targetDrive:
      label: ''
      mountPoint: ''
target:
  hostname: test-target
  poolname: storage/testing/dst
  stateFileName: target_state.txt
  stateFile: /storage/testing/code/sneakernet/states/targetState
  cleanUpScriptsDir: cleanUpScripts
  allowFullOverwrite: 0
  shutdownAfterReplication: 0
  report:
    email: ''
    subject: ''
    targetDrive:
      label: ''
      mountPoint: /storage/testing/report
transport:
  label: ''
  mountPoint: /storage/testing/transport
  pathSubstitution: '.'
  datasetDir: datasets
  serialFile: serial.txt
  encryptionKey: PASTE_YOUR_KEY_HERE
  verifyStream: header
  compression:
    method: 'off'
    level: 6
    threads: 0
    nice: 0
YAML

# ===========================================================================
print "=== Round-trip fidelity ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    ok($y->as_text eq $SNEAKERNET_CONFIG, 'unmodified real sneakernet config round-trips byte-identical');

    my $small = "a: 1\nb: 2\n";
    ok(YAMLPatch->load_string($small)->as_text eq $small, 'small doc round-trips byte-identical');

    my $noTrailingNL = "a: 1\nb: 2";
    ok(YAMLPatch->load_string($noTrailingNL)->as_text eq $noTrailingNL,
        'doc without trailing newline round-trips without adding one');

    ok(YAMLPatch->load_string('')->as_text eq '', 'empty document round-trips to empty string');
}

# ===========================================================================
print "\n=== get() ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    ok($y->get('transport.compression.method')->{value} eq 'off', "get transport.compression.method == 'off'");
    ok($y->get('transport.compression.method')->{style} eq 'single', 'style correctly recorded as single');
    ok($y->get('datasets.ds1.maxDelta')->{value} eq '0.9', 'get datasets.ds1.maxDelta == 0.9');
    ok($y->get('datasets.ds1.maxDelta')->{style} eq 'bare', 'maxDelta style is bare');
    ok(!defined $y->get('does.not.exist'), 'get on missing path returns undef');
    ok($y->get('source.cleanupScriptSchedule.helloWorld[0]')->{value} eq '1', 'sequence item [0] == 1');
    ok($y->get('source.cleanupScriptSchedule.helloWorld[11]')->{value} eq '12', 'sequence item [11] == 12 (last)');

    # the duplicate-key-NAME cases: mountPoint x3, label x2, email/subject/poolname x2 each -
    # every one must be independently addressable by full path.
    ok($y->get('transport.mountPoint')->{value} eq '/storage/testing/transport', 'transport.mountPoint');
    ok($y->get('target.report.targetDrive.mountPoint')->{value} eq '/storage/testing/report', 'target.report.targetDrive.mountPoint');
    ok($y->get('source.report.targetDrive.mountPoint')->{value} eq '', 'source.report.targetDrive.mountPoint (empty)');
    ok($y->get('transport.label')->{value} eq '', 'transport.label');
    ok($y->get('target.report.targetDrive.label')->{value} eq '', 'target.report.targetDrive.label');
    ok($y->get('source.poolname')->{value} eq 'storage/testing/src', 'source.poolname');
    ok($y->get('target.poolname')->{value} eq 'storage/testing/dst', 'target.poolname');
}

# ===========================================================================
print "\n=== set() changes exactly one line ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y->set('transport.compression.method', 'xz', style => 'single');
    my $diff = $y->diff($SNEAKERNET_CONFIG);
    my @diffLines = split /\n/, $diff;
    ok(scalar(@diffLines) == 2, 'set() on one key produces exactly one changed line (one - and one +)')
        or print "    diff was:\n$diff\n";
    ok($y->get('transport.compression.method')->{value} eq 'xz', 'value actually changed');
    ok($y->get('transport.compression.level')->{value} eq '6', 'sibling key (level) untouched');

    # setting the SAME value should be a true no-op (no line changes at all)
    my $y2 = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y2->set('debug', '0', style => 'single');
    ok($y2->as_text eq $SNEAKERNET_CONFIG, 'set() to the identical value is a byte-identical no-op');
}

# ===========================================================================
print "\n=== set() rejects duplicate-name confusion (path-only addressing) ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y->set('target.report.targetDrive.mountPoint', '/mnt/other');
    ok($y->get('target.report.targetDrive.mountPoint')->{value} eq '/mnt/other', 'target mountPoint changed');
    ok($y->get('transport.mountPoint')->{value} eq '/storage/testing/transport', 'transport.mountPoint (same bare key name) untouched');
    ok($y->get('source.report.targetDrive.mountPoint')->{value} eq '', 'source.report.targetDrive.mountPoint (same bare key name) untouched');
}

# ===========================================================================
print "\n=== quoting safety ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    dies_ok(sub { $y->set('transport.compression.method', 'off', style => 'bare') },
        'set(..., style=>bare) refuses a YAML 1.1 boolean word ("off")', qr/bare/);
    lives_ok(sub { $y->set('transport.compression.method', 'off', style => 'single') },
        "set(..., style=>single) accepts 'off' since it is safely quoted");
    ok($y->as_text =~ /method: 'off'/, "'off' was emitted quoted, not bare");

    dies_ok(sub { YAMLPatch::_format_value("has'quote", 'single', 'x') },
        'internal: single-quote style refuses embedded single quote');
}

# ===========================================================================
print "\n=== set-on-commented-path dies with actionable message ===\n";
# ===========================================================================
{
    my $drifted = $SNEAKERNET_CONFIG;
    $drifted =~ s/^(    maxDelta: 0\.9)$/    #maxDelta: 0.9/m;   # comment out ds1's only, like the real drift
    my $y = YAMLPatch->load_string($drifted);
    ok(!$y->exists_path('datasets.ds1.maxDelta'), 'commented maxDelta is not a live path');
    ok($y->exists_commented('datasets.ds1.maxDelta'), 'commented maxDelta is visible as commented');
    dies_ok(sub { $y->set('datasets.ds1.maxDelta', '0.01') },
        'set() on a commented-only path dies rather than silently duplicating',
        qr/commented-out/);
    ok($y->get('datasets.ds2.maxDelta')->{value} eq '0.9', 'ds2 (the untouched sibling) is unaffected');
}

# ===========================================================================
print "\n=== comment_out() / uncomment() round trip ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y->comment_out('datasets.ds1.maxDelta');
    ok(!$y->exists_path('datasets.ds1.maxDelta'), 'maxDelta gone from live paths after comment_out');
    ok($y->exists_commented('datasets.ds1.maxDelta'), 'maxDelta visible as commented after comment_out');
    ok($y->as_text =~ /^ {4}#maxDelta: 0\.9$/m, 'comment_out emits the exact "    #maxDelta: 0.9" form seen in production drift');

    $y->uncomment('datasets.ds1.maxDelta');
    ok($y->as_text eq $SNEAKERNET_CONFIG, 'comment_out() followed by uncomment() restores the original text exactly');
}

# ===========================================================================
print "\n=== comment_out() on a multi-line block (Step 5.2: remove ds2) ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y->comment_out('datasets.ds2');
    for my $p (qw(datasets.ds2.dataset datasets.ds2.source datasets.ds2.target datasets.ds2.maxDelta)) {
        ok(!$y->exists_path($p), "$p no longer live after commenting out ds2");
    }
    ok($y->get('datasets.ds1.dataset')->{value} eq 'ds1', 'ds1 (preceding sibling) still live and correct');
    ok($y->as_text =~ /^ {2}#ds2:$/m, 'the ds2: line itself is commented');
    ok($y->as_text =~ /^ {4}#maxDelta: 0\.9\n^debug:/m, 'ds2 subtree comments extend through to (and no further than) the next real sibling');

    $y->uncomment('datasets.ds2');
    ok($y->as_text eq $SNEAKERNET_CONFIG, 'uncommenting the whole ds2 block restores the original text exactly');
}

# ===========================================================================
print "\n=== delete() with subtree ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y->delete('datasets.ds2');
    for my $p (qw(datasets.ds2 datasets.ds2.dataset datasets.ds2.source datasets.ds2.target datasets.ds2.maxDelta)) {
        ok(!$y->exists_path($p), "$p removed after delete(datasets.ds2)");
    }
    ok($y->get('datasets.ds1.dataset')->{value} eq 'ds1', 'ds1 untouched by deleting ds2');
    ok($y->get('debug')->{value} eq '0', 'top-level debug (the following sibling) untouched');
    ok($y->as_text !~ /ds2/, 'no trace of ds2 remains in the text');
}

# ===========================================================================
print "\n=== delete() of a single scalar (18.3: delete datasets.ds1.maxDelta) ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y->delete('datasets.ds1.maxDelta');
    ok(!$y->exists_path('datasets.ds1.maxDelta'), 'ds1.maxDelta removed');
    ok($y->get('datasets.ds2.maxDelta')->{value} eq '0.9',
        'ds2.maxDelta (the naive-sed trap: same bare key name) is untouched');
    ok($y->get('datasets.ds1.source')->{value} eq 'storage/testing/src', 'ds1.source untouched');
}

# ===========================================================================
print "\n=== insert_kv() anchored between siblings ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y->insert_kv(parent => 'transport', key => 'verifyFullSendDataset',
                  value => 'storage/testing/verify', style => 'bare', after => 'verifyStream');
    ok($y->get('transport.verifyFullSendDataset')->{value} eq 'storage/testing/verify', 'new key readable');
    my @lines = split /\n/, $y->as_text;
    my ($afterIdx)  = grep { $lines[$_] =~ /^\s*verifyStream:/ } 0 .. $#lines;
    my ($newIdx)    = grep { $lines[$_] =~ /^\s*verifyFullSendDataset:/ } 0 .. $#lines;
    my ($compIdx)   = grep { $lines[$_] =~ /^\s*compression:/ } 0 .. $#lines;
    ok($newIdx == $afterIdx + 1, 'inserted immediately after verifyStream, not appended to end of transport:');
    ok($newIdx < $compIdx, 'inserted before compression: (i.e. did NOT land inside the nested compression block)');
}

# ===========================================================================
print "\n=== insert_block() (Steps 4.3/4.4: add ds3/ds4) ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y->insert_block(parent => 'datasets', key => 'ds3', after => 'ds2', text => <<'BLOCK');
ds3:
  dataset: ds3
  source: storage/testing/src
  target: storage/testing/dst
  maxDelta: 0.9
BLOCK
    for my $kv (['dataset', 'ds3'], ['source', 'storage/testing/src'], ['target', 'storage/testing/dst'], ['maxDelta', '0.9']) {
        ok($y->get("datasets.ds3.$kv->[0]")->{value} eq $kv->[1], "datasets.ds3.$kv->[0] == $kv->[1]");
    }
    ok($y->get('datasets.ds2.dataset')->{value} eq 'ds2', 'ds2 untouched by inserting ds3 after it');
    ok(!$y->exists_path('datasets.ds4'), 'ds4 correctly absent (not part of this insert)');

    $y->delete('datasets.ds3');
    ok($y->as_text eq $SNEAKERNET_CONFIG, 'insert_block() followed by delete() restores the original text exactly');
}

# ===========================================================================
print "\n=== insert_block()'s declared intent survives save()'s gate 3 (regression) ===\n";
# ===========================================================================
{
    # Regression: found running the real harness for the first time against dd-nas1 (Step 4.3) -
    # the block above only ever checked get()/as_text(), which read the ACTUAL parsed structure
    # and so stayed correct even while insert_block()'s internal intent-tracking silently
    # computed the wrong path ('datasets.ds3.ds3.dataset' instead of 'datasets.ds3.dataset',
    # since the standalone re-parse of a wrapped "$key:\n  ..." block already has '$key' as the
    # leading component of its own paths). That bug was invisible until something actually
    # compared the declared intent against reality - i.e. save()'s gate 3 - which every prior
    # test of insert_block() skipped calling.
    my $dir = tempdir(CLEANUP => 1);
    my $path = "$dir/conf.yaml";
    open my $fh, '>', $path or die $!;
    print {$fh} $SNEAKERNET_CONFIG;
    close $fh;
    my $y = YAMLPatch->load_file($path);
    $y->insert_block(parent => 'datasets', key => 'ds3', after => 'ds2', text => <<'BLOCK');
ds3:
  dataset: ds3
  source: storage/testing/src
  target: storage/testing/dst
BLOCK
    my $ok = eval { $y->save($path); 1 };
    ok($ok, 'save() after a wrapped-block insert_block() passes gate 3')
        or print "    error: $@\n";
    my $y2 = YAMLPatch->load_file($path);
    ok($y2->get('datasets.ds3.dataset')->{value} eq 'ds3', 'the saved file actually has datasets.ds3.dataset');
    ok(!$y2->exists_path('datasets.ds3.ds3'), 'no doubled-up datasets.ds3.ds3 path was created');
}

# ===========================================================================
print "\n=== append_raw() requires explicit opt-in (Step 15.3 corruption) ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    dies_ok(sub { $y->append_raw("not valid yaml: [[[\n") },
        'append_raw refuses without allow_unsafe', qr/allow_unsafe/);
    lives_ok(sub { $y->append_raw("not valid yaml: [[[\n", allow_unsafe => 1) },
        'append_raw succeeds with allow_unsafe => 1');
    ok($y->as_text =~ /not valid yaml: \[\[\[\n\z/, 'garbage text actually appended verbatim');
}

# ===========================================================================
print "\n=== out-of-subset refusals, and the matching allow_* escape hatch ===\n";
# ===========================================================================
{
    dies_ok(sub { YAMLPatch->load_string("a:\n\tb: 1\n") },
        'tab used as indentation refused', qr/[Tt]ab/);

    dies_ok(sub { YAMLPatch->load_string(" a: 1\n") },
        'indentation not a multiple of 2 refused', qr/multiple of 2/);

    dies_ok(sub { YAMLPatch->load_string("a: [1, 2, 3]\n") },
        'flow-style sequence refused by default', qr/flow-style/);
    lives_ok(sub { YAMLPatch->load_string("a: [1, 2, 3]\n", allow_flow => 1) },
        'flow-style sequence accepted with allow_flow => 1');
    {
        my $y = YAMLPatch->load_string("a: [1, 2, 3]\n", allow_flow => 1);
        ok($y->get('a')->{value} eq '[1, 2, 3]', 'flow value readable as opaque raw text under allow_flow');
        ok($y->as_text eq "a: [1, 2, 3]\n", 'flow value round-trips byte-identical under allow_flow');
    }

    dies_ok(sub { YAMLPatch->load_string("a: |\n  line one\n  line two\n") },
        'block scalar refused by default', qr/block scalar/);
    lives_ok(sub { YAMLPatch->load_string("a: |\n  line one\n  line two\n", allow_block_scalar => 1) },
        'block scalar accepted with allow_block_scalar => 1');
    {
        my $y = YAMLPatch->load_string("a: |\n  line one\n  line two\nb: 2\n", allow_block_scalar => 1);
        ok($y->get('a')->{value} eq "  line one\n  line two", 'block scalar content readable as opaque text');
        ok($y->get('b')->{value} eq '2', 'key following a block scalar parses normally');
    }

    dies_ok(sub { YAMLPatch->load_string("a: &anchor foo\n") },
        'anchor refused by default', qr/[Aa]nchor/);
    lives_ok(sub { YAMLPatch->load_string("a: &anchor foo\n", allow_anchors => 1) },
        'anchor accepted with allow_anchors => 1');
    dies_ok(sub { YAMLPatch->load_string("a: *alias\n") },
        'alias refused by default', qr/[Aa]nchor/);
    dies_ok(sub { YAMLPatch->load_string("a: !!str foo\n") },
        'tag refused by default', qr/[Aa]nchor/);

    # Regression: a real value from sneakernet/problem/mc-011.sneakernet.conf.yaml that is
    # NOT an alias but starts with '*' - a redaction placeholder. An overly broad "starts
    # with * or & or !" check misdetected this as YAML alias syntax and refused to parse a
    # file that has never used aliases at all.
    lives_ok(sub { YAMLPatch->load_string("a: **redacted**\n") },
        "a bare value starting with '*' but not shaped like an alias (**redacted**) parses fine by default");
    {
        my $y = YAMLPatch->load_string("a: **redacted**\n");
        ok($y->get('a')->{value} eq '**redacted**', "**redacted** read back as an ordinary bare string");
        ok($y->get('a')->{style} eq 'bare', "**redacted** classified as bare, not anchor_opaque");
    }

    dies_ok(sub { YAMLPatch->load_string("a: 1\n---\nb: 2\n") },
        'second document separator refused by default', qr/document separator/);
    {
        my $y = YAMLPatch->load_string("a: 1\n---\nb: 2\nc: 3\n", allow_multidoc => 1);
        ok($y->get('a')->{value} eq '1', 'first document still addressable under allow_multidoc');
        ok(!$y->exists_path('b'), 'second document is opaque, not addressable, under allow_multidoc');
        ok($y->as_text eq "a: 1\n---\nb: 2\nc: 3\n", 'multidoc tail preserved verbatim byte-for-byte');
    }

    dies_ok(sub { YAMLPatch->load_string("a: 1\na: 2\n") },
        'duplicate sibling key refused (no allow_* override exists - always ambiguous)', qr/duplicate/);

    dies_ok(sub { YAMLPatch->load_string("a:\n-\n") },
        'sequence item with no inline scalar refused', qr/inline scalar/);
    dies_ok(sub { YAMLPatch->load_string("a:\n- one\n  two\n") },
        'sequence item followed by a more-indented continuation (nested structure) refused - some subset violation');
}

# ===========================================================================
print "\n=== caller hooks (gates 6/7): a refused hook leaves the file untouched ===\n";
# ===========================================================================
{
    my $dir = tempdir(CLEANUP => 1);
    my $path = "$dir/sneakernet.conf.yaml";
    open my $fh, '>', $path or die $!;
    print {$fh} $SNEAKERNET_CONFIG;
    close $fh;
    my $originalBytes = do { local (@ARGV, $/) = $path; <> };

    my $y = YAMLPatch->load_file($path);
    $y->set('target.poolname', 'storage/backup');   # the exact "reaches into production" bug

    my $hookCalled = 0;
    dies_ok(sub {
        $y->save($path, hooks => [ sub {
            my ($self, $text) = @_;
            $hookCalled = 1;
            return $text !~ /storage\/backup/;   # this is what a containment gate would assert
        } ]);
    }, 'save() dies when a hook refuses the commit', qr/hook refused/);
    ok($hookCalled, 'the hook was actually invoked');

    my $afterBytes = do { local (@ARGV, $/) = $path; <> };
    ok($afterBytes eq $originalBytes, 'on-disk file is byte-identical after a refused hook - nothing was written');

    # and the inverse: a passing hook allows the commit through
    my $y2 = YAMLPatch->load_file($path);
    $y2->set('target.poolname', 'storage/testing/dst2');
    lives_ok(sub {
        $y2->save($path, hooks => [ sub { my ($self, $text) = @_; return $text !~ /storage\/backup/; } ]);
    }, 'save() succeeds when all hooks pass');
    my $finalBytes = do { local (@ARGV, $/) = $path; <> };
    ok($finalBytes =~ /storage\/testing\/dst2/, 'the accepted mutation actually landed on disk');
}

# ===========================================================================
print "\n=== gate 3: delta must equal declared intent ===\n";
# ===========================================================================
{
    # This is exercised indirectly by every set/delete/insert test above (each already
    # goes through save()'s gate 3 successfully). Here we directly attack the internal
    # assertion to prove it actually catches a mismatch, using the private helper.
    my %added = (foo => 1);
    my %removed = ();
    my %changed = ();
    my $intent = { added => {}, removed => {}, changed => {} };  # declares NOTHING
    dies_ok(sub { YAMLPatch::_assert_delta_equals(\%added, \%removed, \%changed, $intent) },
        'an undeclared addition is caught by gate 3', qr/was not declared/);

    my $intent2 = { added => { foo => 1, bar => 1 }, removed => {}, changed => {} }; # over-declares
    dies_ok(sub { YAMLPatch::_assert_delta_equals(\%added, \%removed, \%changed, $intent2) },
        'a declared-but-not-happened addition is caught by gate 3', qr/did not happen/);
}

# ===========================================================================
print "\n=== gate 4: bounded line-delta ===\n";
# ===========================================================================
{
    my $dir = tempdir(CLEANUP => 1);
    my $path = "$dir/x.yaml";
    open my $fh, '>', $path or die $!; print {$fh} $SNEAKERNET_CONFIG; close $fh;
    my $y = YAMLPatch->load_file($path);
    $y->set('debug', '5', style => 'single');
    dies_ok(sub { $y->save($path, max_line_delta => 0) },
        'save() with too tight a max_line_delta refuses', qr/gate 4/);
    lives_ok(sub { $y->save($path, max_line_delta => 1) },
        'save() with a sufficient max_line_delta succeeds');
}

# ===========================================================================
print "\n=== diff()/line-delta use real alignment, not positional comparison ===\n";
# ===========================================================================
{
    # Regression: a naive positional diff (old[i] vs new[i]) makes a single insertion
    # look like it changed every subsequent line, because they all shifted position by
    # one. Caught via the CLI's --dry-run output during manual smoke testing (a 1-line
    # insert_kv produced a ~130-line "diff").
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y->insert_kv(parent => 'transport', key => 'verifyFullSendDataset',
                  value => 'storage/testing/verify', style => 'bare', after => 'verifyStream');
    my @diffLines = split /\n/, $y->diff($SNEAKERNET_CONFIG);
    ok(scalar(@diffLines) == 1, 'inserting one key produces a one-line diff, not a cascade')
        or print "    diff was:\n" . join("\n", @diffLines) . "\n";
    ok($y->_line_delta_count == 1, 'line-delta count for a one-line insertion is 1, not the tail length');

    my $y2 = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y2->set('debug', '9', style => 'single');
    ok($y2->_line_delta_count == 1, 'line-delta count for a one-line set() (delete+add) is 1, not 2');

    my $y3 = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    $y3->delete('datasets.ds2');
    ok($y3->_line_delta_count > 1, 'deleting a multi-line block counts more than 1 (sanity: not collapsed to 1 always)');
}

# ===========================================================================
print "\n=== gate 2: independent-parser cross-check (YAML::Tiny) ===\n";
# ===========================================================================
{
    my $hasYamlTiny = eval { require YAML::Tiny; 1 };
    my $dir = tempdir(CLEANUP => 1);
    my $path = "$dir/y.yaml";
    open my $fh, '>', $path or die $!; print {$fh} $SNEAKERNET_CONFIG; close $fh;

    my $y = YAMLPatch->load_file($path);
    $y->set('debug', '5', style => 'single');
    lives_ok(sub { $y->save($path) }, 'save() succeeds with gate 2 enforced (YAML::Tiny present: ' . ($hasYamlTiny ? 'yes' : 'no') . ')');

    my $y2 = YAMLPatch->load_file($path);
    $y2->set('debug', '6', style => 'single');
    lives_ok(sub { $y2->save($path, skip_yaml_tiny_check => 1) },
        'save() also succeeds with gate 2 explicitly skipped (simulates YAML::Tiny absent)');
}

# ===========================================================================
print "\n=== atomic write: save() never leaves a torn/temp file behind ===\n";
# ===========================================================================
{
    my $dir = tempdir(CLEANUP => 1);
    my $path = "$dir/z.yaml";
    open my $fh, '>', $path or die $!; print {$fh} $SNEAKERNET_CONFIG; close $fh;
    my $y = YAMLPatch->load_file($path);
    $y->set('debug', '9', style => 'single');
    $y->save($path);
    my @leftover = grep { /\.yamlpatch\.tmp\./ } glob("$dir/*");
    ok(scalar(@leftover) == 0, 'no .yamlpatch.tmp.* file left behind after a successful save()');
    ok(-e $path, 'target file exists after save()');
}

# ===========================================================================
print "\n=== round-trip fidelity across every real YAML file in this repo ===\n";
# ===========================================================================
{
    # Not synthetic fixtures - the actual config files this project ships and edits by hand.
    # Files known to use flow-style ('diskList: []') are loaded with allow_flow => 1 and are
    # expected to round-trip too; that construct is refused by DEFAULT, not unsupported.
    my @corpus = (
        [ 'replicate/replicate.conf.yaml', {} ],
        [ 'replication/sync.sample.yaml', {} ],
        [ 'sneakernet/sneakernet.conf.yaml', {} ],
        [ 'sneakernet/problem/sneakernet.conf.yaml', { allow_flow => 1 } ],
        [ 'sneakernet/problem/mc-011.sneakernet.conf.yaml', { allow_flow => 1 } ],
        [ 'sneakernet/problem/master.sneakernet.conf.yaml', { allow_flow => 1 } ],
        [ 'sneakernet/problem/20260731/sneakernet.conf.yaml', { allow_flow => 1 } ],
    );
    my $repoRoot = "$FindBin::Bin/..";
    for my $entry (@corpus) {
        my ($rel, $opts) = @$entry;
        my $full = "$repoRoot/$rel";
        SKIP: {
            if (!-e $full) { ok(1, "$rel (skipped - not present on this checkout)"); last SKIP; }
            my $orig = do { local (@ARGV, $/) = $full; <> };
            my $y = eval { YAMLPatch->load_file($full, %$opts) };
            if (!$y) { ok(0, "$rel parses"); print "    error: $@\n"; last SKIP; }
            ok($y->as_text eq $orig, "$rel round-trips byte-identical");
        }
    }

    # And refuse-by-default is proven against the SAME real file, not just a synthetic one.
    dies_ok(sub { YAMLPatch->load_file("$repoRoot/sneakernet/problem/sneakernet.conf.yaml") },
        'the real diskList: [] file is refused without allow_flow', qr/flow-style/);
}

# ===========================================================================
print "\n=== path_list() / exists_path() ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($SNEAKERNET_CONFIG);
    my @paths = $y->path_list;
    ok(scalar(@paths) > 30, 'path_list returns a substantial number of real paths (' . scalar(@paths) . ')');
    ok((grep { $_ eq 'transport.compression.method' } @paths) == 1, 'transport.compression.method appears exactly once in path_list');
    ok($y->exists_path('datasets.ds1.maxDelta'), 'exists_path true for a real path');
    ok(!$y->exists_path('nonexistent.path'), 'exists_path false for a missing path');
}

# ===========================================================================
print "\n=== full simulated Part 18.3 sequence (delete then restore maxDelta) ===\n";
# ===========================================================================
{
    my $dir = tempdir(CLEANUP => 1);
    my $path = "$dir/full.yaml";
    open my $fh, '>', $path or die $!; print {$fh} $SNEAKERNET_CONFIG; close $fh;

    my $y = YAMLPatch->load_file($path);
    $y->delete('datasets.ds1.maxDelta');
    $y->save($path);
    my $y2 = YAMLPatch->load_file($path);
    ok(!$y2->exists_path('datasets.ds1.maxDelta'), 'maxDelta gone after save+reload');
    ok($y2->get('datasets.ds2.maxDelta')->{value} eq '0.9', 'ds2.maxDelta survived untouched after save+reload');

    $y2->insert_kv(parent => 'datasets.ds1', key => 'maxDelta', value => '0.9', style => 'bare', after => 'target');
    $y2->save($path);
    my $y3 = YAMLPatch->load_file($path);
    ok($y3->get('datasets.ds1.maxDelta')->{value} eq '0.9', 'maxDelta restored after delete+save+reload+insert+save+reload');
    ok($y3->as_text eq $SNEAKERNET_CONFIG, 'full delete/restore round-trip is byte-identical to the original');
}

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