#!/usr/bin/env perl

# test_readChecksumOutput.pl
#
# Tests validateBackup's digest-output parsing and path-based correlation: digestFormatFor,
# readChecksumOutput, compareChecksums.
#
# 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). recordFinding is
# replaced with a local stub that collects into an array instead of writing a findings file. No
# live ZFS, ssh or digest command is required - the fixtures are plain text files.
#
# 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 FindBin;
use lib "$FindBin::Bin/..";
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++; }
}

my $tempDir = tempdir( CLEANUP => 1 );

## Write a digest-output fixture and return its path.
sub writeSums {
   my ( $name, @lines ) = @_;
   my $path = "$tempDir/$name";
   open( my $fh, '>', $path ) or die "cannot write $path: $!";
   print { $fh } "$_\n" foreach @lines;
   close($fh);
   return $path;
}

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

my %DIGEST_FORMATS = (
   sha256 => { flag => '-r', parse => qr/^([0-9a-fA-F]+)\s+(.+)$/ },
   md5    => { flag => '-r', parse => qr/^([0-9a-fA-F]+)\s+(.+)$/ },
   cksum  => { flag => '',   parse => qr/^(\d+)\s+\d+\s+(.+)$/ },
);

sub digestFormatFor {
   my ($digestCommand) = @_;
   my ($name) = $digestCommand =~ m{([^/]+)$};
   $name = $digestCommand unless defined $name;
   return $DIGEST_FORMATS{$name} // $DIGEST_FORMATS{sha256};
}

sub readChecksumOutput {
   my ( $sumsFile, $format ) = @_;
   my %digests;
   my $unparsable = 0;
   if ( open( my $fh, '<', $sumsFile ) ) {
      while ( my $line = <$fh> ) {
         chomp $line;
         next if $line eq '';
         if ( $line =~ $format->{parse} ) {
            $digests{$2} = $1;
         }
         else {
            $unparsable++;
         }
      }
      close($fh);
   }
   return ( \%digests, $unparsable );
}

sub compareChecksums {
   my ( $config, $runState, $pair, $activeDigests, $backupDigests ) = @_;
   my $counters = $pair->{counters};

   foreach my $path ( sort keys %$activeDigests ) {
      my $backupHash = $backupDigests->{$path};
      unless ( defined $backupHash ) {
         recordFinding( $config, $runState, $pair, 'checksumUnavailable', $path, undef, undef,
            'no digest produced on the backup side (file unreadable there?)' );
         next;
      }
      if ( lc( $activeDigests->{$path} ) eq lc($backupHash) ) {
         $counters->{filesValidated}++;
      }
      else {
         recordFinding( $config, $runState, $pair, 'checksumDiff', $path, undef, undef,
            "active $activeDigests->{$path} vs backup $backupHash" );
      }
   }
   foreach my $path ( sort keys %$backupDigests ) {
      next if exists $activeDigests->{$path};
      recordFinding( $config, $runState, $pair, 'checksumUnavailable', $path, undef, undef,
         'no digest produced on the active side (file unreadable there?)' );
   }
   return;
}

# ---------------------------------------------------------------------------------------------
# Stub: collects findings in memory instead of writing the findings file
# ---------------------------------------------------------------------------------------------

my @recordedFindings;

sub recordFinding {
   my ( $config, $runState, $pair, $type, $path, $activeRec, $backupRec, $detail ) = @_;
   $pair->{counters}->{errorCounts}->{$type}++;
   push @recordedFindings, { type => $type, path => $path, detail => $detail };
   return;
}

sub newPair {
   return {
      counters => {
         filesValidated => 0,
         errorCounts    => { checksumDiff => 0, checksumUnavailable => 0 },
      },
   };
}

# ---------------------------------------------------------------------------------------------
print "\n=== readChecksumOutput: parsing per digest format ===\n";
# ---------------------------------------------------------------------------------------------
{
   my $path = writeSums( 'sha256.out',
      'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ./a.txt',
      '1111111111111111111111111111111111111111111111111111111111112  ./with space.txt',   # double space
   );
   my ( $digests, $unparsable ) = readChecksumOutput( $path, $DIGEST_FORMATS{sha256} );
   ok( $unparsable == 0, "sha256 -r style output (single space) parses cleanly" );
   ok( $digests->{'./a.txt'} eq 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
      "sha256 digest correctly keyed by path" );
   ok( $digests->{'./with space.txt'} eq '1111111111111111111111111111111111111111111111111111111111112',
      "double-space-separated output (common GNU coreutils shape) also parses - \\s+ absorbs it" );
}
{
   my $path = writeSums( 'md5.out', 'd41d8cd98f00b204e9800998ecf8427e ./empty.txt' );
   my ( $digests, $unparsable ) = readChecksumOutput( $path, $DIGEST_FORMATS{md5} );
   ok( $unparsable == 0 && $digests->{'./empty.txt'} eq 'd41d8cd98f00b204e9800998ecf8427e',
      "md5 -r style output (same shape as sha256 -r) parses correctly" );
}
{
   my $path = writeSums( 'cksum.out', '1234567890 42 ./a.txt', '999 0 ./empty.txt' );
   my ( $digests, $unparsable ) = readChecksumOutput( $path, $DIGEST_FORMATS{cksum} );
   ok( $unparsable == 0, "cksum's 3-field format parses cleanly" );
   ok( $digests->{'./a.txt'} eq '1234567890', "cksum's CRC (not the size field) is captured as the digest" );
   ok( $digests->{'./empty.txt'} eq '999', "cksum output for a zero-byte file still parses" );
}
{
   my $path = writeSums( 'mixed.out',
      'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ./good.txt',
      '',                                    # blank line
      'sha256: /some/path: Permission denied',   # a tool's own error line, not a digest
   );
   my ( $digests, $unparsable ) = readChecksumOutput( $path, $DIGEST_FORMATS{sha256} );
   ok( $unparsable == 1, "an unparsable non-blank line is counted" );
   ok( scalar( keys %$digests ) == 1, "the blank line contributes neither a digest nor an unparsable count" );
}

# ---------------------------------------------------------------------------------------------
print "\n=== digestFormatFor ===\n";
# ---------------------------------------------------------------------------------------------
{
   ok( digestFormatFor('sha256') == $DIGEST_FORMATS{sha256}, "digestFormatFor('sha256') returns the sha256 entry" );
   ok( digestFormatFor('cksum') == $DIGEST_FORMATS{cksum}, "digestFormatFor('cksum') returns the cksum entry" );
   ok( digestFormatFor('/usr/local/bin/sha256') == $DIGEST_FORMATS{sha256},
      "a full path is reduced to its basename before lookup" );
   ok( digestFormatFor('/sbin/md5') == $DIGEST_FORMATS{md5}, "basename extraction also works for md5" );
   ok( digestFormatFor('some-unknown-tool') == $DIGEST_FORMATS{sha256},
      "an unrecognized command name falls back to the sha256/md5 shape rather than dying or returning undef" );
}

# ---------------------------------------------------------------------------------------------
print "\n=== compareChecksums: correlation is BY PATH, immune to output order ===\n";
# ---------------------------------------------------------------------------------------------
{
   # The trap: a naive implementation that zips the two sides' lines together by position would
   # misattribute every digest after the first divergence in ordering. Build both sides with
   # DELIBERATELY different internal ordering (Perl hash iteration order is unspecified anyway,
   # so this also happens for free, but make the intent explicit) and confirm every path still
   # gets the right verdict.
   @recordedFindings = ();
   my $pair = newPair();
   my $activeDigests = { './z.txt' => 'aaa', './a.txt' => 'bbb', './m.txt' => 'ccc' };
   my $backupDigests = { './m.txt' => 'ccc', './a.txt' => 'bbb', './z.txt' => 'aaa' };
   compareChecksums( {}, {}, $pair, $activeDigests, $backupDigests );
   ok( $pair->{counters}->{filesValidated} == 3, "all three matching files validate despite unrelated hash key ordering" );
   ok( scalar(@recordedFindings) == 0, "no false findings from ordering alone" );
}
{
   @recordedFindings = ();
   my $pair = newPair();
   my $activeDigests = { './a.txt' => 'same111', './b.txt' => 'AAAA', './c.txt' => 'different1' };
   my $backupDigests = { './a.txt' => 'same111', './b.txt' => 'aaaa', './c.txt' => 'different2' };
   compareChecksums( {}, {}, $pair, $activeDigests, $backupDigests );
   ok( $pair->{counters}->{filesValidated} == 2, "identical digests validate, including a case-only difference (b.txt)" );
   ok( $pair->{counters}->{errorCounts}->{checksumDiff} == 1, "a genuinely different digest is the only checksumDiff" );
   ok( ( $recordedFindings[0]->{type} eq 'checksumDiff' && $recordedFindings[0]->{path} eq './c.txt' ),
      "the checksumDiff finding is attributed to the CORRECT path (./c.txt), not a neighboring one" );
}
{
   # The scenario the trap is really about: a file unreadable on one side must become
   # checksumUnavailable for THAT PATH specifically, and must not disturb any other path's
   # verdict - which a positional/line-based correlation would fail at.
   @recordedFindings = ();
   my $pair = newPair();
   my $activeDigests = { './before.txt' => 'x1', './unreadable.txt' => 'x2', './after.txt' => 'x3' };
   my $backupDigests = { './before.txt' => 'x1', './after.txt' => 'x3' };    # unreadable.txt missing
   compareChecksums( {}, {}, $pair, $activeDigests, $backupDigests );
   ok( $pair->{counters}->{filesValidated} == 2, "the two files on both sides still validate correctly" );
   ok( $pair->{counters}->{errorCounts}->{checksumUnavailable} == 1, "exactly one checksumUnavailable, not a cascade" );
   ok( $recordedFindings[0]->{path} eq './unreadable.txt', "checksumUnavailable is attributed to the actually-missing path" );
   ok( $recordedFindings[0]->{detail} =~ /backup side/, "detail correctly says which side lacks the digest" );
}
{
   @recordedFindings = ();
   my $pair = newPair();
   my $activeDigests = { './a.txt' => 'x1' };
   my $backupDigests = { './a.txt' => 'x1', './backup-only.txt' => 'x9' };
   compareChecksums( {}, {}, $pair, $activeDigests, $backupDigests );
   ok( $pair->{counters}->{errorCounts}->{checksumUnavailable} == 1,
      "a path present only in the backup digest output is also checksumUnavailable" );
   ok( $recordedFindings[0]->{detail} =~ /active side/, "and correctly attributes the gap to the active side this time" );
}
{
   @recordedFindings = ();
   my $pair = newPair();
   compareChecksums( {}, {}, $pair, {}, {} );
   ok( $pair->{counters}->{filesValidated} == 0 && !@recordedFindings, "two empty digest sets produce nothing" );
}

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