#!/usr/bin/env perl

# Copyright (c) 2025, R. W. Rodolico
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

# t/install_dependencies.t
# Installer behaviour on a host where YAML::Tiny is missing: validateDependencies tries the
# package manager, then cpanm, then cpan, and setup loads YAML::Tiny only after that step. The module is
# hidden with an @INC hook until a stub installer "installs" it (touches a marker file), so no
# real package manager runs and nothing is installed.
#
# Revision History:
# 2026-10-14 Claude v1.0.0 initial version
# 2026-10-17 Claude v1.1.0 cpan fallback after cpanm; failure tests stub cpan so the host's own is never run;
#            LWP for upload_http installed, its failure only warns, upload_http reports it missing (bug 209)

use strict;
use warnings;
use Test::More;
use FindBin;
use File::Basename qw(dirname);
use File::Path qw(make_path);
use File::Temp qw(tempdir);

my $clientDir = "$FindBin::Bin/../unix";
my $perlDir = dirname($^X);

my $tmp = tempdir('install_dependencies_XXXXXX', DIR => '/tmp', CLEANUP => 1);
my $marker = "$tmp/marker";
my $callLog = "$tmp/calls.log";
my $blockDir = "$tmp/block";
make_path($blockDir);

open(my $blockFile, '>', "$blockDir/Block.pm") or die "could not write Block.pm: $!\n";
print {$blockFile} <<'END_BLOCK';
package Block;
# hides YAML::Tiny (unless SHOW_YAML) and LWP::UserAgent (if HIDE_LWP) until $ENV{YT_MARKER}
# exists, as an uninstalled module would be
unshift @INC, sub {
   my ($self, $file) = @_;
   return if -e $ENV{YT_MARKER};
   return unless ($file eq 'YAML/Tiny.pm' && !$ENV{SHOW_YAML}) || ($file eq 'LWP/UserAgent.pm' && $ENV{HIDE_LWP});
   die "Can't locate $file in \@INC (hidden for test)\n";
};
1;
END_BLOCK
close($blockFile);

#######################################################
#
# makeStubDir( $name, %installers )
#
# Creates a directory of stub installers. Each value is the exit code
# of the stub; a stub that exits 0 also creates the marker, which makes
# the hidden YAML::Tiny appear. Every call is appended to the call log.
#
# Parameters:
#   $name       - directory name under the temp dir
#   %installers - command name => exit code
#
# Returns:
#   path of the directory
#
# Side Effects:
#   writes files
#
#######################################################
sub makeStubDir {
   my ($name, %installers) = @_;
   my $dir = "$tmp/$name";
   make_path($dir);
   foreach my $command (sort keys %installers) {
      my $code = $installers{$command};
      open(my $fh, '>', "$dir/$command") or die "could not write stub $command: $!\n";
      print {$fh} "#!/bin/sh\necho \"$command \$*\" >> $callLog\n";
      print {$fh} "touch $marker\n" if $code == 0;
      print {$fh} "exit $code\n";
      close($fh);
      chmod 0755, "$dir/$command";
   }
   return $dir;
}

#######################################################
#
# runHidden( $stubDir, @command )
#
# Runs a command with YAML::Tiny hidden and a PATH of the stub directory
# plus the system directories. Resets the marker and the call log first.
#
# Parameters:
#   $stubDir - directory of stub installers placed first on PATH
#   @command - command and arguments (perl script, options)
#
# Returns:
#   list of (combined output, exit status)
#
# Side Effects:
#   runs a subprocess
#
#######################################################
sub runHidden {
   my ($stubDir, @command) = @_;
   unlink($marker, $callLog);
   local $ENV{'YT_MARKER'} = $marker;
   local $ENV{'PERL5OPT'} = "-I$blockDir -MBlock";
   local $ENV{'PATH'} = "$stubDir:$perlDir:/usr/bin:/bin";
   my $output = '';
   my $pid = open(my $fh, '-|');
   die "fork failed: $!\n" unless defined $pid;
   if ($pid == 0) {
      open(STDERR, '>&', \*STDOUT);
      exec($^X, @command) or exit 127;
   }
   $output = do { local $/; <$fh> };
   close($fh);
   return ($output // '', $? >> 8);
}

sub callsMade {
   return () unless -f $callLog;
   open(my $fh, '<', $callLog) or return ();
   my @lines = <$fh>;
   close($fh);
   chomp @lines;
   return @lines;
}

my $validate = "$clientDir/installer/validateDependencies";
my $setup = "$clientDir/installer/setup";

# ---- validateDependencies ----

{
   my $stubs = makeStubDir('apt_fails_cpanm_works', 'apt-get' => 1, 'cpanm' => 0);
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'debian', '--quiet');
   is($rc, 0, 'package manager fails, cpanm works: exit 0');
   my @calls = callsMade();
   is(scalar(@calls), 2, 'both installers were tried');
   like($calls[0] // '', qr/^apt-get install -y libyaml-tiny-perl$/, 'package manager is tried first');
   like($calls[1] // '', qr/^cpanm --notest YAML::Tiny$/, 'cpanm is tried second');
}

{
   # OPNsense: no package for the module and no cpanm, only cpan (bug 206)
   my $stubs = makeStubDir('pkg_fails_cpan_works', 'pkg' => 1, 'cpan' => 0);
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'opnsense', '--quiet');
   is($rc, 0, 'package fails, no cpanm, cpan works: exit 0');
   my @calls = callsMade();
   is(scalar(@calls), 2, 'package manager then cpan');
   like($calls[1] // '', qr/^cpan -T YAML::Tiny$/, 'cpan is the last resort');
}

{
   my $stubs = makeStubDir('apt_works', 'apt-get' => 0, 'cpanm' => 0);
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'debian', '--quiet');
   is($rc, 0, 'package manager works: exit 0');
   my @calls = callsMade();
   is(scalar(@calls), 1, 'cpanm not tried when the package manager worked');
   like($calls[0] // '', qr/^apt-get /, 'only the package manager ran');
}

{
   my $stubs = makeStubDir('freebsd_pkg', 'pkg' => 0);
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'freebsd', '--quiet');
   is($rc, 0, 'FreeBSD uses pkg');
   like((callsMade())[0] // '', qr/^pkg install -y p5-YAML-Tiny$/, 'FreeBSD package name');
}

{
   my $stubs = makeStubDir('cpanm_only', 'cpanm' => 0);
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'freebsd', '--quiet');
   is($rc, 0, 'no package manager on the path: cpanm is used');
   like((callsMade())[0] // '', qr/^cpanm --notest YAML::Tiny$/, 'only cpanm was run');
}

SKIP: {
   # the helper keeps the system directories on PATH, so this only works if they hold neither
   my $hostHasInstaller = grep { -x "$_/pkg" || -x "$_/cpanm" || -x "$_/cpan" } qw(/usr/bin /bin /usr/local/bin);
   skip('this host has pkg, cpanm or cpan in a system directory', 2) if $hostHasInstaller;
   my $stubs = makeStubDir('nothing');
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'freebsd', '--quiet');
   is($rc, 2, 'no installer at all: exit 2');
   like($out, qr/none of a package manager, cpanm or cpan is available/, 'clear message when nothing can install it');
}

{
   my $stubs = makeStubDir('both_fail', 'apt-get' => 1, 'cpanm' => 1, 'cpan' => 1);
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'debian', '--quiet');
   is($rc, 2, 'both installers fail: exit 2');
   like($out, qr/could not install required YAML::Tiny/, 'failure is reported');
}

{
   my $stubs = makeStubDir('dryrun', 'apt-get' => 0);
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'debian', '--quiet', '--dryrun');
   is($rc, 0, 'dry run with the module missing: exit 0');
   my @dryCalls = callsMade();
   is(scalar(@dryCalls), 0, 'dry run runs no installer');
}

# ---- LWP for upload_http (bug 209) ----

{
   local $ENV{'SHOW_YAML'} = 1;
   local $ENV{'HIDE_LWP'} = 1;
   my $stubs = makeStubDir('lwp_pkg', 'pkg' => 0);
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'opnsense', '--quiet');
   is($rc, 0, 'LWP missing, pkg works: exit 0');
   ok((grep { $_ eq 'pkg install -y p5-libwww' } callsMade()), 'LWP installed with pkg install p5-libwww');
}

{
   local $ENV{'SHOW_YAML'} = 1;
   local $ENV{'HIDE_LWP'} = 1;
   my $stubs = makeStubDir('lwp_fails', 'apt-get' => 1, 'cpanm' => 1, 'cpan' => 1);
   my ($out, $rc) = runHidden($stubs, $validate, '--os', 'debian', '--quiet');
   is($rc, 0, 'LWP cannot be installed: still exit 0 (only upload_http needs it)');
   like($out, qr/warning: could not install LWP::UserAgent; the upload_http transport will not work/, 'warning names the transport');
}

{
   local $ENV{'SHOW_YAML'} = 1;
   local $ENV{'HIDE_LWP'} = 1;
   my $stubs = makeStubDir('lwp_upload');
   my $code = 'defined(do $ARGV[0]) or die "load failed: $@\n"; print "rc=" . main::doit({ URL => "http://example.invalid/" }, "report") . "\n"';
   my ($out, $rc) = runHidden($stubs, '-e', $code, "$clientDir/transports/upload_http");
   unlike($out, qr/load failed/, 'upload_http loads without LWP');
   like($out, qr/^rc=10$/m, 'doit returns 10 when LWP is missing');
   like($out, qr/LWP::UserAgent is not installed/, 'missing LWP is reported on STDERR');
}

# ---- setup ----

my $seed = "$tmp/seed.yaml";
open(my $seedFile, '>', $seed) or die "could not write seed: $!\n";
print {$seedFile} "clientName: Acme\n";
close($seedFile);

{
   my $root = "$tmp/root_dry";
   make_path($root);
   my $stubs = makeStubDir('setup_dry', 'apt-get' => 1);
   my ($out, $rc) = runHidden($stubs, $setup, '--quiet', '--dryrun', '--root', $root, '--os', 'debian', '--seed', $seed);
   is($rc, 0, 'setup dry run with YAML::Tiny missing: exit 0');
   like($out, qr/^RESULT: ok \S+ dry run: YAML::Tiny is missing/m, 'RESULT line says it would be installed');
   unlike($out, qr/Compilation failed|BEGIN failed/, 'no Perl compile error shown');
   opendir(my $dh, $root);
   my @entries = grep { $_ ne '.' && $_ ne '..' } readdir($dh);
   closedir($dh);
   is(scalar(@entries), 0, 'dry run created nothing');
}

{
   my $root = "$tmp/root_real";
   make_path($root);
   my $stubs = makeStubDir('setup_real', 'apt-get' => 1, 'cpanm' => 0);
   my ($out, $rc) = runHidden($stubs, $setup, '--quiet', '--root', $root, '--os', 'debian', '--seed', $seed);
   is($rc, 0, 'setup installs YAML::Tiny with cpanm and carries on: exit 0');
   like($out, qr/^RESULT: ok \S+ installed$/m, 'RESULT ok');
   unlike($out, qr/Compilation failed|BEGIN failed/, 'no Perl compile error shown');
   ok(-f "$root/etc/camp/sysinfo_client/sysinfo_client.yaml", 'config file created after the install');
   my @calls = callsMade();
   is(scalar(@calls), 2, 'apt-get then cpanm');
}

{
   my $root = "$tmp/root_none";
   make_path($root);
   my $stubs = makeStubDir('setup_none', 'apt-get' => 1, 'cpanm' => 1, 'cpan' => 1);
   my ($out, $rc) = runHidden($stubs, $setup, '--quiet', '--root', $root, '--os', 'debian', '--seed', $seed);
   is($rc, 2, 'setup fails cleanly when YAML::Tiny cannot be installed');
   like($out, qr/^RESULT: fail \S+ dependency validation failed$/m, 'RESULT fail line');
}

# ---- runCommandList: quiet when a command is missing ----

{
   use lib "$FindBin::Bin/../unix/installer";
   require installer_shared;
   my @warnings;
   local $SIG{__WARN__} = sub { push @warnings, @_ };
   my $rc = installer_shared::runCommandList(['no-such-command-for-sysinfo-test', '--x'], 0);
   is($rc, 127, 'a missing command returns 127');
   is(scalar(@warnings), 0, 'a missing command prints no Perl "Can\'t exec" warning');
   is(installer_shared::runCommandList(['/no/such/dir/command'], 0), 127, 'a missing absolute path returns 127');
   is(installer_shared::runCommandList(['true'], 0), 0, 'a command that exists still runs');
   is(installer_shared::runCommandList(['false'], 0), 1, 'its exit code is returned');
   is(installer_shared::runCommandList(['sh', '-c', 'echo out; echo err >&2; exit 3'], 0, 1), 3, 'quiet flag keeps the exit code');
}

done_testing();
