#!/usr/bin/env perl

# Regression tests for sendIsIncremental(), ensureScratchVerifyDataset(), and
# the doSourceReplication call-site logic added in sneakernet v1.10.6 to fix
# VERIFY_FULL always failing for a full/base send (production incident:
# mc-006, dataset iscsi/mc-014, 2026-07-31 - see CHANGELOG.md [1.10.6]).
#
# sneakernet is a script, not a requireable module (unguarded top-level logic
# runs on load), so the functions under test are copied here verbatim from
# sneakernet v1.10.6, per this project's "extract a sub into a stub harness"
# testing convention (see zfs-utils-testing memory / testLibrary/README).
#
# No live ZFS required for the pure unit tests. The integration tests use
# fake zfs/openssl shell stubs on PATH - no real ZFS pool is touched.

use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/..";
use ZFS_Utils qw(logMsg runCmd fatalError);
use File::Temp qw(tempdir);

$ZFS_Utils::displayLogsOnConsole = 0;
$ZFS_Utils::logFileName = '/tmp/test_sendIsIncremental.log';
unlink $ZFS_Utils::logFileName if -f $ZFS_Utils::logFileName;
$ZFS_Utils::verboseLoggingLevel = 3;

my $passed = 0;
my $failed = 0;

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

use constant VERIFY_OFF    => 0;
use constant VERIFY_HEADER => 1;
use constant VERIFY_FULL   => 2;

# --- verbatim copy of sendIsIncremental() from sneakernet v1.10.6 ---
sub sendIsIncremental {
   my ($command) = @_;
   return 0 unless defined $command;
   return $command =~ /(?:^|\s)-I(?:\s|$)/ ? 1 : 0;
}

# --- verbatim copy of shellQuote() from sneakernet v1.10.6 ---
sub shellQuote {
   my ($s) = @_;
   $s = '' unless defined $s;
   $s =~ s/'/'\\''/g;
   return "'$s'";
}

# --- verbatim copy of ensureScratchVerifyDataset() from sneakernet v1.10.6 ---
sub ensureScratchVerifyDataset {
   my ($config, $dataset) = @_;
   my @existing = runCmd("zfs list -H -o name " . shellQuote($dataset));
   if ( $ZFS_Utils::lastRunError == 0 && @existing ) {
      return;
   }
   runCmd("zfs create -p -o mountpoint=none -o canmount=off " . shellQuote($dataset));
   if ( $ZFS_Utils::lastRunError != 0 ) {
      fatalError("ensureScratchVerifyDataset: failed to create scratch verification dataset '$dataset': $ZFS_Utils::lastRunErrorOutput", $config, sub {});
   }
}

# --- verbatim copy of verifyTransportFile()'s mode-dispatch and full-check
# from sneakernet v1.10.6 (header check itself omitted - already exercised
# manually against production log data in this session; these tests focus on
# the receive-target/mode selection this fix changed, i.e. does the full
# receive-based check run at all, and against what target) ---
sub runVerification {
   my ($mode, $encFile, $receiveTarget) = @_;
   return 1 if $mode == VERIFY_HEADER; # sneakernet:818 - the exact short-circuit that must fire for a downgraded full/base send
   my $pipeline = "cat " . shellQuote($encFile) . " | zfs receive -nF " . shellQuote($receiveTarget) . " 2>&1";
   runCmd($pipeline);
   return $ZFS_Utils::lastRunError == 0 ? 1 : 0;
}

# --- doSourceReplication's call-site decision logic from sneakernet v1.10.6,
# extracted so it can be exercised without the surrounding ~150-line function ---
sub decideVerification {
   my ($config, $cmd, $command, $sourceParent, $outfile, $verifyMode, $scratchReadyRef) = @_;
   my $effectiveVerifyMode = $verifyMode;
   my $verifyReceiveTarget = "$sourceParent$cmd";
   if ( $verifyMode == VERIFY_FULL && !sendIsIncremental($command) ) {
      my $scratchParent = $config->{transport}->{verifyFullSendDataset} // '';
      if ( $scratchParent ne '' ) {
         ensureScratchVerifyDataset( $config, $scratchParent ) unless $scratchReadyRef->{$scratchParent}++;
         $verifyReceiveTarget = "$scratchParent/$outfile";
      } else {
         $effectiveVerifyMode = VERIFY_HEADER;
      }
   }
   return ($effectiveVerifyMode, $verifyReceiveTarget);
}

print "=" x 70 . "\n";
print "sendIsIncremental() - pure unit tests\n";
print "=" x 70 . "\n";

# makeReplicateCommands (ZFS_Utils.pm) only ever produces these four shapes.
ok( sendIsIncremental('zfs send storage/backup/iscsi/mc-014@weekly_2026-07-25_02.15.00--3m') == 0,
    "plain full send (the real mc-014 command) is NOT incremental" );
ok( sendIsIncremental('zfs send -R storage/backup/nextcloud@weekly_2026-07-25_02.15.00--3m') == 0,
    "recursive full send is NOT incremental" );
ok( sendIsIncremental('zfs send -I storage/backup/files_share@a storage/backup/files_share@b') == 1,
    "plain incremental send IS incremental" );
ok( sendIsIncremental('zfs send -R -I storage/backup/nextcloud@a storage/backup/nextcloud@b') == 1,
    "recursive incremental send IS incremental" );
ok( sendIsIncremental('zfs send storage/backup/foo@weekly-Import-2026') == 0,
    "a snapshot name merely containing '-I' with no surrounding whitespace is NOT misread as incremental" );
ok( sendIsIncremental(undef) == 0, "undef is treated as full/base (fails safe)" );

print "\n" . "=" x 70 . "\n";
print "Verification-path integration tests (fake zfs/openssl on PATH)\n";
print "=" x 70 . "\n";

{
   my $bindir = tempdir( CLEANUP => 1 );
   my $receiveLog = "$bindir/receive.log";

   open my $fh, '>', "$bindir/openssl" or die $!;
   print $fh "#!/bin/sh\ncat\n";
   close $fh;
   chmod 0755, "$bindir/openssl";

   # Fake zfs: 'list' says the scratch parent doesn't exist yet (so
   # ensureScratchVerifyDataset creates it); 'create' always succeeds;
   # 'receive -nF <target>' succeeds only for an incremental-shaped target or
   # a scratch child, and fails with the real production error text for the
   # full/base send's own source dataset - reproducing the mc-014 incident
   # exactly so a caller that (incorrectly) skipped the downgrade/scratch
   # logic and used the raw source dataset would be caught failing here.
   open $fh, '>', "$bindir/zfs" or die $!;
   print $fh <<'EOF';
#!/bin/sh
echo "$@" >> RECEIVE_LOG_PLACEHOLDER
if [ "$1 $2" = "list -H" ]; then
   exit 1
elif [ "$1" = "create" ]; then
   exit 0
elif [ "$1 $2" = "receive -nF" ]; then
   target="$3"
   cat >/dev/null
   case "$target" in
      storage/backup/files_share) exit 0 ;;
      storage/backup/iscsi/mc-014)
         echo "cannot receive new filesystem stream: destination has snapshots (eg. storage/backup/iscsi/mc-014@weekly_2026-06-27_02.15.00--3m)" >&2
         exit 1
         ;;
      storage/.sneakernet_verify/iscsi.mc-014) exit 0 ;;
      *) echo "unexpected target [$target]" >&2; exit 1 ;;
   esac
fi
EOF
   close $fh;
   my $content = do { local (@ARGV, $/) = "$bindir/zfs"; <> };
   $content =~ s/RECEIVE_LOG_PLACEHOLDER/$receiveLog/;
   open $fh, '>', "$bindir/zfs" or die $!;
   print $fh $content;
   close $fh;
   chmod 0755, "$bindir/zfs";

   local $ENV{PATH} = "$bindir:$ENV{PATH}";

   my $incrementalCmd = 'zfs send -I storage/backup/files_share@a storage/backup/files_share@b';
   my $fullCmd        = 'zfs send storage/backup/iscsi/mc-014@weekly_2026-07-25_02.15.00--3m';

   # --- Scenario A: incremental send - unaffected by this fix, must still fully verify ---
   {
      my %scratchReady;
      my $config = { transport => {} };
      my ($mode, $target) = decideVerification(
         $config, 'files_share', $incrementalCmd, 'storage/backup/', 'files_share', VERIFY_FULL, \%scratchReady
      );
      ok( $mode == VERIFY_FULL, "incremental: mode stays VERIFY_FULL" );
      ok( $target eq 'storage/backup/files_share', "incremental: receive target is the real source dataset" );
      ok( runVerification($mode, '/dev/null', $target) == 1, "incremental: full-stream check against the real target passes" );
   }

   # --- Scenario B: full/base send, verifyFullSendDataset unset (the default) ---
   {
      my %scratchReady;
      my $config = { transport => {} };
      my ($mode, $target) = decideVerification(
         $config, 'iscsi/mc-014', $fullCmd, 'storage/backup/', 'iscsi.mc-014', VERIFY_FULL, \%scratchReady
      );
      ok( $mode == VERIFY_HEADER, "full/base send, no scratch configured: downgrades to VERIFY_HEADER" );
      truncate $receiveLog, 0 if -f $receiveLog;
      ok( runVerification($mode, '/dev/null', $target) == 1,
          "full/base send, no scratch configured: verification still reports success (via the header short-circuit, not a receive-based pass)" );
      ok( !-s $receiveLog,
          "full/base send, no scratch configured: 'zfs receive' is never invoked - this is the check that would have caught the mc-014 regression (the pre-fix code passed the real source dataset here and always failed)" );
   }

   # --- Scenario C: full/base send, verifyFullSendDataset set (the new opt-in) ---
   {
      my %scratchReady;
      my $config = { transport => { verifyFullSendDataset => 'storage/.sneakernet_verify' } };
      my ($mode, $target) = decideVerification(
         $config, 'iscsi/mc-014', $fullCmd, 'storage/backup/', 'iscsi.mc-014', VERIFY_FULL, \%scratchReady
      );
      ok( $mode == VERIFY_FULL, "full/base send, scratch configured: mode stays VERIFY_FULL (genuinely verified, not downgraded)" );
      ok( $target eq 'storage/.sneakernet_verify/iscsi.mc-014', "full/base send, scratch configured: receive target is the scratch child, not the source's own dataset" );
      truncate $receiveLog, 0 if -f $receiveLog;
      ok( runVerification($mode, '/dev/null', $target) == 1, "full/base send, scratch configured: full-stream check against the scratch child passes" );
      ok( -s $receiveLog, "full/base send, scratch configured: 'zfs receive' WAS invoked (genuine verification happened, unlike Scenario B)" );
      ok( $scratchReady{'storage/.sneakernet_verify'}, "scratch parent is marked ready after first use (won't re-check/re-create on a second full send this run)" );
   }
}

print "\n" . "=" x 70 . "\n";
print "Test Summary: $passed passed, $failed failed\n";
print "=" x 70 . "\n";

exit( $failed == 0 ? 0 : 1 );
