#!/usr/bin/env perl

# test_selectChecksumSample.pl
#
# Tests validateBackup's digest-sampling decision: sampleDecision, openSampleFile,
# closeSampleFile, selectChecksumSample.
#
# validateBackup is a script, not a requireable module (unguarded top-level logic runs on load),
# so the functions under test are copied here verbatim, per this project's "extract a sub into a
# stub harness" testing convention (see test_destroyedSourceDataset.pl). logMsg is stubbed to a
# no-op. No live ZFS or ssh is required - only a File::Temp directory to stand in for a dataset
# pair's tempDir.
#
# IF YOU CHANGE validateBackup's copies of these functions, change them here too.
#
# Author: R. W. Rodolico <rodo@dailydata.net>
# Created: September 2026

use strict;
use warnings;
use File::Temp qw(tempdir);

my ( $passed, $failed ) = ( 0, 0 );

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

sub logMsg { }    # stub: validateBackup's real logMsg comes from ZFS_Utils, not needed here

# ---------------------------------------------------------------------------------------------
# Functions under test, copied verbatim from validateBackup
# ---------------------------------------------------------------------------------------------

sub sampleDecision {
   my ($n) = @_;
   return 0 unless $n;
   return 1 if $n == 1;
   return int( rand($n) ) == 0 ? 1 : 0;
}

sub openSampleFile {
   my ($pair) = @_;
   return $pair->{sampleHandle} if $pair->{sampleHandle};
   my $path = "$pair->{tempDir}/sample.nul";
   unless ( open( my $fh, '>', $path ) ) {
      logMsg("openSampleFile: cannot write '$path': $!");
      return undef;
   }
   else {
      binmode $fh;
      $pair->{sampleHandle} = $fh;
      $pair->{samplePath}   = $path;
   }
   return $pair->{sampleHandle};
}

sub closeSampleFile {
   my ($pair) = @_;
   return unless $pair->{sampleHandle};
   close( $pair->{sampleHandle} );
   delete $pair->{sampleHandle};
   return;
}

sub selectChecksumSample {
   my ( $config, $pair, $record ) = @_;
   my $counters = $pair->{counters};

   if ( $config->{maxSampleFileSize} && $record->{size} > $config->{maxSampleFileSize} ) {
      $counters->{sampleSkippedTooLarge}++;
      return;
   }
   return unless sampleDecision( $config->{randFile} );

   my $fh = openSampleFile($pair);
   return unless $fh;
   print { $fh } $record->{path} . "\0";
   $counters->{filesSampled}++;
   return;
}

# ---------------------------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------------------------

my $tempDir;

## Build a fresh pair HASHREF with its own tempDir and a blank counter set.
sub newPair {
   $tempDir = tempdir( CLEANUP => 1 );
   return {
      tempDir  => $tempDir,
      counters => { filesSampled => 0, sampleSkippedTooLarge => 0 },
   };
}

## Read a NUL-delimited sample file back into a list of paths.
sub readSampleFile {
   my ($pair) = @_;
   my $path = "$pair->{tempDir}/sample.nul";
   return () unless -f $path;
   open( my $fh, '<', $path ) or die "cannot read $path: $!";
   local $/;
   my $content = <$fh>;
   close($fh);
   return () unless defined $content && $content ne '';
   my @paths = split( /\0/, $content );
   return @paths;
}

# ---------------------------------------------------------------------------------------------
print "\n=== sampleDecision: the randFile 0/1 trap ===\n";
# ---------------------------------------------------------------------------------------------
{
   # The trap this guards against: Perl treats rand(0) exactly like rand(1), so a naive
   # 'int(rand($n)) == 0' without the 0-guard running first would digest every file when
   # randFile is meant to disable digesting entirely.
   ok( int( rand(0) ) == 0, "sanity check: Perl's rand(0) behaves like rand(1) (confirms the trap is real)" );
   ok( sampleDecision(0) == 0, "sampleDecision(0) is 0 - digesting disabled" );
   ok( sampleDecision(undef) == 0, "sampleDecision(undef) is 0 - same guard covers an unset value" );
   ok( sampleDecision(1) == 1, "sampleDecision(1) is 1 - full mode digests every eligible file" );
}
{
   # Statistical check at a small N, generous tolerance band to avoid a flaky test while still
   # catching a badly broken sampler (e.g. one that always returns 0 or always returns 1).
   my $n = 50;
   my $trials = 20000;
   my $selected = 0;
   $selected += sampleDecision($n) for 1 .. $trials;
   my $expected = $trials / $n;
   ok( $selected > $expected * 0.5 && $selected < $expected * 1.5,
      "sampleDecision($n) over $trials trials selects roughly 1-in-$n (got $selected, expected ~$expected)" );
}
{
   my @results = map { sampleDecision(1000) } 1 .. 100;
   ok( ( grep { $_ == 0 || $_ == 1 } @results ) == 100, "sampleDecision always returns exactly 0 or 1, never anything else" );
}

# ---------------------------------------------------------------------------------------------
print "\n=== selectChecksumSample: randFile gating ===\n";
# ---------------------------------------------------------------------------------------------
{
   my $config = { randFile => 0, maxSampleFileSize => 0 };
   my $pair   = newPair();
   selectChecksumSample( $config, $pair, { path => './a.txt', size => 10 } ) for 1 .. 50;
   ok( $pair->{counters}->{filesSampled} == 0, "randFile => 0 samples nothing over 50 calls" );
   ok( !-f "$pair->{tempDir}/sample.nul" || -z "$pair->{tempDir}/sample.nul",
      "randFile => 0 never even creates a non-empty sample file" );
}
{
   my $config = { randFile => 1, maxSampleFileSize => 0 };
   my $pair   = newPair();
   selectChecksumSample( $config, $pair, { path => "./file$_.txt", size => 10 } ) for 1 .. 20;
   closeSampleFile($pair);
   ok( $pair->{counters}->{filesSampled} == 20, "randFile => 1 samples every eligible file (20 of 20)" );
   my @paths = readSampleFile($pair);
   ok( scalar(@paths) == 20, "sample file contains exactly 20 NUL-delimited entries" );
   ok( ( join( ',', sort @paths ) eq join( ',', sort map { "./file$_.txt" } 1 .. 20 ) ),
      "sample file's paths exactly match what was selected, in full" );
}

# ---------------------------------------------------------------------------------------------
print "\n=== selectChecksumSample: maxSampleFileSize ===\n";
# ---------------------------------------------------------------------------------------------
{
   my $config = { randFile => 1, maxSampleFileSize => 100 };
   my $pair   = newPair();
   selectChecksumSample( $config, $pair, { path => './small.txt', size => 50 } );
   selectChecksumSample( $config, $pair, { path => './exact.txt', size => 100 } );
   selectChecksumSample( $config, $pair, { path => './big.txt',   size => 101 } );
   closeSampleFile($pair);
   ok( $pair->{counters}->{filesSampled} == 2, "two files at/under the size limit are sampled" );
   ok( $pair->{counters}->{sampleSkippedTooLarge} == 1, "the one oversized file is counted, not silently dropped" );
   my @paths = readSampleFile($pair);
   ok( ( join( ',', sort @paths ) eq './exact.txt,./small.txt' ),
      "the oversized file's path never reaches the sample file" );
   ok( ( grep { $_ eq './exact.txt' } @paths ),
      "a file exactly AT maxSampleFileSize is still sampled (boundary is '>', not '>=')" );
}
{
   my $config = { randFile => 0, maxSampleFileSize => 100 };
   my $pair   = newPair();
   selectChecksumSample( $config, $pair, { path => './big.txt', size => 999 } );
   ok( $pair->{counters}->{sampleSkippedTooLarge} == 1,
      "maxSampleFileSize exclusion is counted even when randFile => 0 would have excluded it anyway" );
   ok( $pair->{counters}->{filesSampled} == 0, "and it is not also counted as sampled" );
}

# ---------------------------------------------------------------------------------------------
print "\n=== openSampleFile / closeSampleFile ===\n";
# ---------------------------------------------------------------------------------------------
{
   my $pair = newPair();
   my $fh1  = openSampleFile($pair);
   my $fh2  = openSampleFile($pair);
   ok( defined $fh1, "openSampleFile returns a filehandle" );
   ok( $fh1 == $fh2, "a second call to openSampleFile returns the SAME handle (lazy-open, not reopened/truncated)" );
   print {$fh1} "marker\0";
   closeSampleFile($pair);
   ok( !exists $pair->{sampleHandle}, "closeSampleFile removes the handle from the pair" );
   my @paths = readSampleFile($pair);
   ok( ( @paths == 1 && $paths[0] eq 'marker' ), "content written before close is flushed and readable" );
}
{
   my $pair = newPair();
   closeSampleFile($pair);    # never opened
   ok( 1, "closeSampleFile on a pair that never opened a sample file does not die" );
}

print "\nTest Summary: $passed passed, $failed failed\n";
exit( $failed == 0 ? 0 : 1 );
