#!/usr/bin/env perl

# Unit tests for Harness::Containment - the config containment gate
# (sneakernet/Documentation/TESTING_automation.md section 6.5). Pure YAMLPatch-level testing;
# no ZFS, no dd-nas1.

use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/..";
use lib "$FindBin::Bin/../sneakernet/testing";
use YAMLPatch;
use Harness::Containment;

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 lives_ok {
    my ($code, $desc) = @_;
    my $ok = eval { $code->(); 1 };
    ok($ok, $desc) or print "    error: $@\n";
}

# A minimal but complete config matching the shape check_object expects, built from the
# TESTING.md Step 0.5 canonical text used throughout the YAMLPatch test suite.
my $GOOD_CONFIG = <<'YAML';
---
datasets:
  ds1:
    dataset: ds1
    source: storage/testing/src
    target: storage/testing/dst
debug: '0'
logFile: /storage/testing/code/sneakernet/sneakernet.log
statusFile: /storage/testing/code/sneakernet/sneakernet_target.status
source:
  hostname: test-source
  poolname: storage/testing/src
  historyFile: /storage/testing/code/sneakernet/history.tsv
  cleanUpScriptsDir: /storage/testing/code/sneakernet/cleanupScripts
  oneShotCleanup: /storage/testing/oneshot
  targetSnapshotList: ''
  report:
    targetDrive:
      label: ''
      mountPoint: ''
target:
  hostname: test-target
  poolname: storage/testing/dst
  stateFile: /storage/testing/code/sneakernet/states/targetState
  allowFullOverwrite: 0
  shutdownAfterReplication: 0
  report:
    targetDrive:
      label: ''
      mountPoint: /storage/testing/report
transport:
  label: ''
  mountPoint: /storage/testing/transport
  encryptionKey: deadbeef
YAML

sub check_string {
    my ($text) = @_;
    my $y = YAMLPatch->load_string($text);
    return Harness::Containment::check_object($y, $y->as_text);
}

# ===========================================================================
print "=== a correctly-scoped config passes cleanly ===\n";
# ===========================================================================
{
    lives_ok(sub { check_string($GOOD_CONFIG) }, 'the good fixture config passes the containment gate');
}

# ===========================================================================
print "\n=== dataset-namespace fields must be under storage/testing/ ===\n";
# ===========================================================================
{
    for my $field (qw(source.poolname target.poolname)) {
        my $bad = $GOOD_CONFIG;
        my ($key) = $field =~ /\.(\w+)$/;
        $bad =~ s/^(  $key: ).*/${1}storage\/backup/m;
        dies_ok(sub { check_string($bad) }, "$field pointed at storage/backup is refused", qr/outside storage\/testing/);
    }

    my $badDs = $GOOD_CONFIG;
    $badDs =~ s{source: storage/testing/src}{source: storage/backup};
    dies_ok(sub { check_string($badDs) }, 'datasets.ds1.source pointed at storage/backup is refused', qr/outside storage\/testing/);

    my $badVfsd = $GOOD_CONFIG;
    $badVfsd =~ s{  encryptionKey: deadbeef}{  encryptionKey: deadbeef\n  verifyFullSendDataset: storage/backup/scratch};
    dies_ok(sub { check_string($badVfsd) }, 'transport.verifyFullSendDataset pointed at storage/backup is refused', qr/outside storage\/testing/);
}

# ===========================================================================
print "\n=== filesystem-namespace fields must be under /storage/testing/ or empty ===\n";
# ===========================================================================
{
    my $badLog = $GOOD_CONFIG;
    $badLog =~ s{logFile: /storage/testing/code/sneakernet/sneakernet.log}{logFile: /var/log/sneakernet.log};
    dies_ok(sub { check_string($badLog) }, 'logFile outside /storage/testing/ is refused', qr/outside \/storage\/testing/);

    my $badMount = $GOOD_CONFIG;
    $badMount =~ s{mountPoint: /storage/testing/transport}{mountPoint: /storage/backup};
    dies_ok(sub { check_string($badMount) }, 'transport.mountPoint outside /storage/testing/ is refused', qr/outside \/storage\/testing/);

    lives_ok(sub { check_string($GOOD_CONFIG) }, "empty targetSnapshotList/label/mountPoint values don't false-positive");
}

# ===========================================================================
print "\n=== physical-drive triggers must be empty ===\n";
# ===========================================================================
{
    for my $field (qw(transport.label)) {
        my $bad = $GOOD_CONFIG;
        $bad =~ s{^(transport:\n)(  label: )''}{$1${2}sneakernet}m;
        ok($bad ne $GOOD_CONFIG, "sanity: the fixture substitution for $field actually changed something")
            or print "    (test bug, not a module bug - fix the regex)\n";
        dies_ok(sub { check_string($bad) }, "$field non-empty (physical drive) is refused", qr/PHYSICAL drive/);
    }

    my $badTargetLabel = $GOOD_CONFIG;
    $badTargetLabel =~ s{(target:.*?report:.*?targetDrive:\n *)label: ''}{${1}label: sneakernet}s;
    dies_ok(sub { check_string($badTargetLabel) }, 'target.report.targetDrive.label non-empty is refused', qr/PHYSICAL drive/);
}

# ===========================================================================
print "\n=== out-of-scope subsystems ===\n";
# ===========================================================================
{
    my $withGeli = $GOOD_CONFIG;
    $withGeli =~ s{(target:\n)}{$1  geli:\n    poolname: backup\n};
    dies_ok(sub { check_string($withGeli) }, 'a target.geli block is refused', qr/GELI/);

    my $shutdown = $GOOD_CONFIG;
    $shutdown =~ s{shutdownAfterReplication: 0}{shutdownAfterReplication: 1};
    dies_ok(sub { check_string($shutdown) }, 'shutdownAfterReplication: 1 is refused', qr/POWER OFF/);

    my $shutdownStr = $GOOD_CONFIG;
    $shutdownStr =~ s{shutdownAfterReplication: 0}{shutdownAfterReplication: '1'};
    dies_ok(sub { check_string($shutdownStr) }, "shutdownAfterReplication: '1' (string form) is also refused", qr/POWER OFF/);
}

# ===========================================================================
print "\n=== the loadConfig silent-rewrite trigger ===\n";
# ===========================================================================
{
    my $y = YAMLPatch->load_string($GOOD_CONFIG);
    dies_ok(sub { Harness::Containment::check_object($y, $y->as_text . "# Rodo <rodo\@dailydata.net>\n") },
        "raw text containing '<'/'>' (e.g. an email display name) is refused", qr/silently rewrite/);
}

# ===========================================================================
print "\n=== as_yamlpatch_hook() integrates with YAMLPatch::save() ===\n";
# ===========================================================================
{
    use File::Temp qw(tempdir);
    my $dir = tempdir(CLEANUP => 1);
    my $path = "$dir/sneakernet.conf.yaml";
    open my $fh, '>', $path or die $!; print {$fh} $GOOD_CONFIG; close $fh;

    my $y = YAMLPatch->load_file($path);
    $y->set('target.poolname', 'storage/backup');   # the exact scenario section 6.5 exists to stop
    dies_ok(sub { $y->save($path, hooks => [ Harness::Containment::as_yamlpatch_hook() ]) },
        'YAMLPatch::save() refuses via the containment hook when a mutation points at production',
        qr/hook failed|hook refused|outside storage\/testing/);

    my $afterBytes = do { local (@ARGV, $/) = $path; <> };
    ok($afterBytes eq $GOOD_CONFIG, 'the on-disk file is untouched after the hook refusal');

    my $y2 = YAMLPatch->load_file($path);
    $y2->set('debug', '9', style => 'single');
    lives_ok(sub { $y2->save($path, hooks => [ Harness::Containment::as_yamlpatch_hook() ]) },
        'a legitimate, in-sandbox mutation passes the same hook and commits');
}

# ===========================================================================
print "\n=== check_file() loads and checks a real file on disk ===\n";
# ===========================================================================
{
    use File::Temp qw(tempdir);
    my $dir = tempdir(CLEANUP => 1);
    my $path = "$dir/good.yaml";
    open my $fh, '>', $path or die $!; print {$fh} $GOOD_CONFIG; close $fh;
    lives_ok(sub { Harness::Containment::check_file($path) }, 'check_file() on a good config passes');

    my $badPath = "$dir/bad.yaml";
    (my $bad = $GOOD_CONFIG) =~ s{target/poolname}{x}; # noop-safe copy
    $bad =~ s{poolname: storage/testing/dst}{poolname: storage/backup};
    open my $fh2, '>', $badPath or die $!; print {$fh2} $bad; close $fh2;
    dies_ok(sub { Harness::Containment::check_file($badPath) }, 'check_file() on a bad config refuses', qr/outside storage\/testing/);
}

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