#!/usr/bin/perl

use strict;
use warnings FATAL => 'all';
use v5.34.0; # The Perl version on Slackware 15.0 (sbozyp's min supported version)

package Sbozyp;

our $VERSION = '0.6.2';

use File::Basename qw(basename dirname);
use File::Temp qw();
use File::Path qw(make_path remove_tree);
use Getopt::Long qw(GetOptionsFromArray :config no_ignore_case no_bundling);
use Pod::Usage qw(pod2usage);

$SIG{INT} = $SIG{TERM} = sub { die "\nsbozyp: got a signal to exit ... going down!\n" };

our %CONFIG = (
    # defaults
    TMPDIR => '/tmp',
    REPO_ROOT => '/var/lib/sbozyp/SBo',
    #REPO_NAME => REPO_PRIMARY
);

 # 'unless caller' allows us to load this file from test code without executing main()
unless (caller) { main(@ARGV) ; exit 0 }

sub main {
    my @argv = @_;
    # process global options
    Getopt::Long::Configure('pass_through'); # pass_through to ignore the command options
    sbozyp_getopts(
        \@argv,
        'C'   => \my $opt_clone,
        'F=s' => \my $opt_configfile,
        'R=s' => \my $opt_reponame,
        'S'   => \my $opt_sync,
        'T'   => \my $opt_noinit
    );
    Getopt::Long::Configure('nopass_through');
    # determine the command main function
    my $cmd = shift(@argv) or die command_usage('main');
    my $cmd_main;
    if    ($cmd =~ /^(?:--help|-h)$/)    { print command_help_msg('main'); return }
    elsif ($cmd =~ /^(?:--version|-V)$/) { print $VERSION, "\n" ; return          }
    elsif ($cmd =~ /^(?:build|bu)$/)     { $cmd_main = \&build_command_main       }
    elsif ($cmd =~ /^(?:install|in)$/)   { $cmd_main = \&install_command_main     }
    elsif ($cmd =~ /^(?:null|nu)$/)      { $cmd_main = \&null_command_main        }
    elsif ($cmd =~ /^(?:query|qr)$/)     { $cmd_main = \&query_command_main       }
    elsif ($cmd =~ /^(?:remove|rm)$/)    { $cmd_main = \&remove_command_main      }
    elsif ($cmd =~ /^(?:search|se)$/)    { $cmd_main = \&search_command_main      }
    else                                 { sbozyp_die("invalid command '$cmd'")   }
    # set the configuration
    parse_config_file($opt_configfile); # mutates the global %CONFIG
    set_repo_name_or_die($opt_reponame // $CONFIG{REPO_PRIMARY});
    # initialize the environment
    return if ($opt_noinit and !repo_is_cloned());
    sbozyp_mkdir("$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}", $CONFIG{TMPDIR});
    if ($opt_clone or !repo_is_cloned()) {
        i_am_root_or_die('need root to clone repo');
        clone_repo();
    }
    if ($opt_sync) {
        i_am_root_or_die('need root to sync repo');
        sync_repo();
    }
    # run the command
    $cmd_main->(@argv);
}

            ####################################################
            #                     COMMANDS                     #
            ####################################################

sub build_command_main {
    sbozyp_getopts(
        \@_,
        'h|help' => \my $opt_help,
        'f'      => \my $opt_force,
        'i'      => \my $opt_noninteractive
    );
    if ($opt_help) { print command_help_msg('build'); return }
    @_ >= 1 or die command_usage('build');
    i_am_root_or_die('the build command requires root');
    my @pkgs = pkgs_uniq(map { $_ = pkg($_) } @_);
    if (not $opt_noninteractive) {
        sbozyp_print('are you sure you want to build these packages:', "\n");
        return unless pkgs_confirm_with_user(@pkgs);
    }
    for my $pkg (@pkgs) {
        if (my $slackware_pkg = built_slackware_pkg($pkg)) {
            unless ($opt_force) {
                sbozyp_print("existing package for '$pkg->{PKGNAME}' found at '$slackware_pkg'\n");
                next;
            }
        }
        build_slackware_pkg($pkg);
    }
}

sub install_command_main {
    sbozyp_getopts(
        \@_,
        'h|help' => \my $opt_help,
        'f'      => \my $opt_force,
        'i'      => \my $opt_noninteractive,
        'k'      => \my $opt_keeppackage,
        'n'      => \my $opt_nodeps
    );
    if ($opt_help) { print command_help_msg('install'); return }
    @_ >= 1 or die command_usage('install');
    i_am_root_or_die('the install command requires root');
    my @pkgs = pkgs_uniq(map { $_ = pkg($_) } @_);
    my @queue; for my $pkg (@pkgs) {
        my @pkg_queue = $opt_nodeps ? ($pkg) : pkg_queue($pkg);
        unless ($opt_force) {
            @pkg_queue = grep { !pkg_installed_and_up_to_date($_) } @pkg_queue;
        }
        @queue = pkgs_merged(@queue, @pkg_queue);
    }
    if (@queue) {
        if (not $opt_noninteractive) {
            sbozyp_print('are you sure you want to install these packages:', "\n");
            return unless pkgs_confirm_with_user(@queue)
        }
        for my $pkg (@queue) {
            my $slackware_pkg = built_slackware_pkg($pkg) // build_slackware_pkg($pkg);
            install_slackware_pkg($slackware_pkg);
            sbozyp_unlink($slackware_pkg) unless $opt_keeppackage;
        }
    } else {
        sbozyp_print("all packages (and their deps) requested for installation are up to date, invoke with -f option to force installation\n");
    }
}

sub null_command_main {
    sbozyp_getopts(
        \@_,
        'h|help' => \my $opt_help,
    );
    if ($opt_help) { print command_help_msg('null'); return }
    @_ == 0 or die command_usage('null');
}

sub query_command_main {
    sbozyp_getopts(
        \@_,
        'h|help' => \my $opt_help,
        'a'     => \my $opt_listinstalled,
        'b'     => \my $opt_printpackagedir,
        'd'     => \my $opt_slackdesc,
        'i'     => \my $opt_info,
        'm'     => \my $opt_pkgsnodependents,
        'n'     => \my $opt_recdependents,
        'o'     => \my $opt_directdependents,
        'p'     => \my $opt_pkginstalled,
        'q'     => \my $opt_printqueue,
        'r'     => \my $opt_readme,
        's'     => \my $opt_slackbuild,
        'u'     => \my $opt_listneedupgrade
    );
    if ($opt_help) { print command_help_msg('query'); return }
    if (@_ > 1) { die command_usage('query') }
    my $num_opts_set = 0; for ($opt_listinstalled,$opt_printpackagedir,$opt_slackdesc,$opt_info,$opt_pkgsnodependents,$opt_recdependents,$opt_directdependents,$opt_pkginstalled,$opt_printqueue,$opt_readme,$opt_slackbuild,$opt_listneedupgrade) { $num_opts_set++ if defined }
    if    ($num_opts_set != 1)  { sbozyp_die("must set exactly 1 query option but $num_opts_set were set") }
    my $opt = $opt_listinstalled ? '-a' : $opt_printpackagedir ? '-b' : $opt_slackdesc ? '-d' : $opt_info ? '-i' : $opt_pkgsnodependents ? '-m' : $opt_recdependents ? '-n' : $opt_directdependents ? '-o' : $opt_pkginstalled ? '-p' : $opt_printqueue ? '-q' : $opt_readme ? '-r' : $opt_slackbuild ? '-s' : $opt_listneedupgrade ? '-u' : die;
    my $pkg; if ($opt_printpackagedir || $opt_slackdesc || $opt_info || $opt_recdependents || $opt_directdependents || $opt_pkginstalled || $opt_printqueue || $opt_readme || $opt_slackbuild) {
        @_ == 1 or sbozyp_die("query option '$opt' requires single PKGNAME argument");
        $pkg = pkg($_[0]);
    } else {
        @_ == 0 or sbozyp_die("query option '$opt' does not take PKGNAME argument");
    }
    # option implementations
    if ($opt_listinstalled) {
        my %installed_sbo_pkgs = installed_sbo_pkgs();
        for my $pkgname (sort keys %installed_sbo_pkgs) {
            print $pkgname, "\n";
        }
    } elsif ($opt_printpackagedir) {
        print $pkg->{PKGDIR}, "\n";
    } elsif ($opt_slackdesc) {
        sbozyp_print_file("$pkg->{PKGDIR}/slack-desc");
    } elsif ($opt_info) {
        sbozyp_print_file("$pkg->{PKGDIR}/$pkg->{PRGNAM}.info");
    } elsif ($opt_pkgsnodependents) {
        print "$_->{PKGNAME}\n" for pkgs_no_dependents();
    } elsif ($opt_recdependents) {
        print "$_->{PKGNAME}\n" for pkg_dependents_recursive($pkg);
    } elsif ($opt_directdependents) {
        print "$_->{PKGNAME}\n" for pkg_dependents_direct($pkg);
    } elsif ($opt_pkginstalled) {
        if (defined(my $version = pkg_installed($pkg))) {
            print "$version\n";
        }
    } elsif ($opt_printqueue) {
        print "$_->{PKGNAME}\n" for pkg_queue($pkg);
    } elsif ($opt_readme) {
        sbozyp_print_file("$pkg->{PKGDIR}/README");
    } elsif ($opt_slackbuild) {
        sbozyp_print_file("$pkg->{PKGDIR}/$pkg->{PRGNAM}.SlackBuild");
    } elsif ($opt_listneedupgrade) {
        my %installed_sbo_pkgs = installed_sbo_pkgs();
        for my $pkgname (sort keys %installed_sbo_pkgs) {
            my $installed_version = $installed_sbo_pkgs{$pkgname};
            my $available_version = pkg($pkgname)->{VERSION};
            if (version_gt($available_version, $installed_version)) {
                print "$pkgname $installed_version -> $available_version\n"
            }
        }
    }
}

sub remove_command_main {
    sbozyp_getopts(
        \@_,
        'h|help' => \my $opt_help,
        'f'      => \my $opt_nodepsafetycheck,
        'i'      => \my $opt_noninteractive,
        'r'      => \my $opt_removedeps
    );
    if ($opt_help) { print command_help_msg('remove'); return }
    @_ >= 1 or die command_usage('remove');
    i_am_root_or_die('the remove command requires root');
    my @pkgs = pkgs_uniq(map { $_ = pkg($_) } @_);
    unless ($opt_nodepsafetycheck) {
        my @errors; for my $pkg (@pkgs) {
            my @dependents = pkg_array_minus([pkg_dependents_direct($pkg)], [@pkgs]);
            if (@dependents) {
                my $error = sbozyp_error_prefix()."package '$pkg->{PKGNAME}' is depended on by:\n";
                $error .= "    $_->{PKGNAME}\n" for @dependents;
                push @errors, $error;
            }
        }
        die @errors if @errors;
    }
    @pkgs = (@pkgs, pkgs_removable_dependencies(@pkgs)) if $opt_removedeps;
    for my $pkg (@pkgs) {
        if (!defined pkg_installed($pkg)) {
            sbozyp_die("the package '$pkg->{PKGNAME}' is not installed");
        }
    }
    if (not $opt_noninteractive) {
        sbozyp_print('are you sure you want to remove these packages:', "\n");
        return unless pkgs_confirm_with_user(@pkgs);
    }
    remove_slackware_pkg($_->{PRGNAM}) for @pkgs;
}

sub search_command_main {
    sbozyp_getopts(
        \@_,
        'h|help' => \my $opt_help,
        'c'      => \my $opt_casesensitive,
        'n'      => \my $opt_matchcategory,
        'p'      => \my $opt_prgnam
    );
    if ($opt_help) { print command_help_msg('search'); return }
    @_ == 1 or die command_usage('search');
    my $regex_arg = $_[0];
    my $regex = $opt_casesensitive ? qr/$regex_arg/ : qr/$regex_arg/i;
    my @matches = grep {
        $opt_matchcategory ? $_ =~ $regex : basename($_) =~ $regex;
    } all_pkgnames();
    if (@matches) {
        if ($opt_prgnam) {
            @matches = sort map { $_ = basename($_) } @matches;
        }
        print $_, "\n" for @matches
    } else {
        sbozyp_die("no packages match the regex '$regex_arg'");
    }
}

            ####################################################
            #            IMPLEMENTATION SUBROUTINES            #
            ####################################################

sub pkg {
    my ($prgnam) = @_;
    my $pkgname = prgnam_to_pkgname($prgnam) // sbozyp_die("could not find a package named '$prgnam'");
    state %pkg_cache; if (my $pkg = $pkg_cache{$pkgname}) { return $pkg }
    my $info_file = "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}/$pkgname/@{[basename($pkgname)]}.info";
    my %info = parse_info_file($info_file);
    my $pkg = {
        PKGNAME         => $pkgname,
        PKGDIR          => "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}/$pkgname",
        INFO_FILE       => $info_file,
        SLACKBUILD_FILE => "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}/$pkgname/".basename($pkgname).'.SlackBuild',
        DESC_FILE       => "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}/$pkgname/slack-desc",
        README_FILE     => "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}/$pkgname/README",
        PRGNAM          => $info{PRGNAM},
        VERSION         => $info{VERSION},
        HOMEPAGE        => $info{HOMEPAGE},
        MAINTAINER      => $info{MAINTAINER},
        EMAIL           => $info{EMAIL},
        DOWNLOAD        => [split ' ', $info{DOWNLOAD}],
        MD5SUM          => [split ' ', $info{MD5SUM}],
        DOWNLOAD_x86_64 => [split ' ', $info{DOWNLOAD_x86_64}],
        MD5SUM_x86_64   => [split ' ', $info{MD5SUM_x86_64}],
        REQUIRES        => [grep { prgnam_to_pkgname($_) } split(' ', $info{REQUIRES})], # removes %README% specifier and non-existent packages
        HAS_EXTRA_DEPS  => scalar(grep { $_ eq '%README%' } split(' ', $info{REQUIRES})),
        ARCH_UNSUPPORTED  => do {
            my @urls = split(' ', arch() eq 'x86_64' ? $info{DOWNLOAD_x86_64} : $info{DOWNLOAD});
            if    (grep { $_ eq 'UNSUPPORTED' } @urls) { 'unsupported' }
            elsif (grep { $_ eq 'UNTESTED'    } @urls) { 'untested'    }
            else                                       { 0             }
        }
    };
    $pkg_cache{$pkgname} = $pkg;
    return $pkg
}

sub pkgs_confirm_with_user {
    my @pkgs = @_;
    print '    ', $_->{PKGNAME}, "\n" for @pkgs;
    print '  (y/n) -> ';
    my $user_input = <STDIN>;
    $user_input =~ s/^\s+|\s+$//g;
    return $user_input =~ /^y(?:es)?$/ ? 1 : 0;
}

sub pkgs_uniq {
    my @pkgs = @_;
    my %seen; my @pkgs_uniq;
    for my $pkg (@pkgs) {
        next if $seen{$pkg->{PKGNAME}};
        $seen{$pkg->{PKGNAME}} = 1;
        push @pkgs_uniq, $pkg;
    }
    return @pkgs_uniq;
}

sub pkgs_sorted {
    my @pkgs = @_;
    my @pkgs_uniq = pkgs_uniq(@pkgs);
    return sort { $a->{PKGNAME} cmp $b->{PKGNAME} } @pkgs_uniq;
}

sub pkgs_merged {
    my @pkgs = @_;
    my @pkgs_merged = pkgs_uniq(@pkgs);
    return @pkgs_merged;
}

sub pkg_array_minus {
    my ($pkg_aref1, $pkg_aref2) = @_;
    my @pkgs_minus = grep {
        my $pkg = $_; !grep { $pkg->{PKGNAME} eq $_->{PKGNAME} } @$pkg_aref2
    } @$pkg_aref1;
    return @pkgs_minus;
}

sub pkg_installed {
    my ($pkg) = @_;
    my %installed_sbo_pkgs = installed_sbo_pkgs(); # hash from PKGNAME to version
    my $version = $installed_sbo_pkgs{$pkg->{PKGNAME}};
    return $version;
}

sub pkg_installed_and_up_to_date {
    my ($pkg) = @_;
    my $installed_version = pkg_installed($pkg);
    if (!defined $installed_version or version_gt($pkg->{VERSION}, $installed_version)) {
        return 0;
    } else {
        return 1;
    }
}

sub all_categories {
    state @all_categories = sort map {
        basename($_);
    } sbozyp_qx("find '$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}' -mindepth 1 -maxdepth 1 -type d -not -path '*/.*'");
    return @all_categories
}

sub all_pkgnames {
    state @all_pkgnames = sort map {
        my ($pkgname) = $_ =~ m,/([^/]+/[^/]+)$,;
    } sbozyp_qx("find '$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}' -mindepth 2 -maxdepth 2 -type d -not -path '*/.*'");
    return @all_pkgnames;
}

sub prgnam_to_pkgname { # if $prgnam is already a pkgname just return it back
    my ($prgnam) = @_;
    $prgnam or return;
    return $prgnam if $prgnam =~ m,^[^/]+/[^/]+$, && -d "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}/$prgnam";
    my $pkgname;
    for my $category (all_categories()) {
        $pkgname = "$category/$prgnam" if -d "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}/$category/$prgnam";
    }
    return $pkgname;
}

sub parse_info_file {
    my ($info_file) = @_;
    my $fh = sbozyp_open('<', $info_file);
    my $info_file_content = do { local $/; <$fh> }; # slurp the info file
    my %info = $info_file_content =~ /^(\w+)="([^"]*)"/mg;
    # Multiline values are broken up with newline escapes. Lets squish them into single spaces.
    $info{$_} =~ s/\\\n\s+//g for keys %info;
    return %info;
}

sub is_multilib_system {
    my $is_multilib_system = -f '/etc/profile.d/32dev.sh';
    return $is_multilib_system;
}

sub arch {
    state $arch = sbozyp_qx('uname -m');
    return $arch;
}

sub sbozyp_getopts {
    my $err_prefix = (caller(1))[3] =~ /([^:_]+)_command_main$/ ? "$1: " : '';
    my $getopt_err;
    local $SIG{__WARN__} = sub { chomp($getopt_err = lcfirst $_[0]) };
    GetOptionsFromArray(@_) or sbozyp_die($err_prefix.$getopt_err);
}

sub sbozyp_msg_prefix {
    return 'sbozyp: ';
}

sub sbozyp_print {
    print sbozyp_msg_prefix(), @_;
}

sub sbozyp_error_prefix {
    return 'sbozyp: error: ';
}

sub sbozyp_die {
    die sbozyp_error_prefix(), @_, "\n";
}

sub sbozyp_system {
    my @cmd = @_;
    my $exit_status = system(@cmd) >> 8;
    unless (0 == $exit_status) {
        sbozyp_die("the following system command exited with status $exit_status: @cmd");
    }
    return $exit_status;
}

sub clear_terminal {
    print "\033[2J";    # clear the screen
    # print "\033[3J";    # clear the scrollback
    print "\033[0;0H";  # jump to 0,0
}

sub sbozyp_qx {
    my ($cmd) = @_;
    wantarray ? chomp(my @output = qx($cmd)) : chomp(my $output = qx($cmd));
    unless (0 == $?) {
        my $exit_status = $? >> 8;
        sbozyp_die("the following system command exited with status $exit_status: $cmd");
    }
    return wantarray ? @output : $output;
}

sub sbozyp_tee {
    my ($cmd) = @_;
    my $tmp = File::Temp->new(DIR=>$CONFIG{TMPDIR}, TEMPLATE=>'sbozyp_tee_XXXXXX');
    $cmd = "set -o pipefail && ( $cmd ) | tee '$tmp'";
    sbozyp_system('bash', '-c', $cmd);
    seek $tmp, 0, 0;
    my $stdout = do { local $/; <$tmp> };
    return $stdout;
}

sub sbozyp_print_file {
    my ($file) = @_;
    my $fh = sbozyp_open('<', $file);
    print while <$fh>;
}

sub sbozyp_open {
    my ($mode, $path) = @_;
    open(my $fh, $mode, $path) or sbozyp_die("could not open file '$path': $!");
    return $fh;
}

sub sbozyp_unlink {
    my ($file) = @_;
    unlink $file or sbozyp_die("could not unlink file '$file': $!");
}

sub sbozyp_fork {
    my $pid = fork();
    defined $pid or sbozyp_die("fork failed: $!");
    return $pid;
}

sub sbozyp_copy {
    my ($file, $dest) = @_;
    sbozyp_system('cp', '-a', -d $file ? "$file/." : $file, $dest);
}

sub sbozyp_readdir {
    my ($dir) = @_;
    opendir(my $dh, $dir) or sbozyp_die("could not opendir '$dir': $!");
    my @files = sort map { "$dir/$_" } grep { !/^\.\.?$/ } readdir($dh);
    return @files;
}

sub sbozyp_find_files_recursive {
    my ($dir) = @_;
    my @files;
    my $find_files_recursive = sub {
        for my $f (@_) {
            if (-f $f) {
                push @files, $f;
            } else {
                __SUB__->(sbozyp_readdir($f));
            }
        }
    };
    $find_files_recursive->(sbozyp_readdir($dir));
    return sort(@files);
}

sub sbozyp_chdir {
    my ($dir) = @_;
    chdir $dir or sbozyp_die("could not chdir to '$dir': $!");
}

sub sbozyp_mkdir {
    my @dirs = @_;
    for my $dir (@dirs) {
        unless (-d $dir) {
            make_path($dir, {error => \my $err});
            if ($err) {
                for my $diag (@$err) {
                    my (undef, $err_msg) = %$diag;
                    sbozyp_die("could not mkdir '$dir': $err_msg");
                }
            }
        }
    }
    return @dirs;
}

sub sbozyp_rmdir {
    my ($dir) = @_;
    if (-d $dir) {
        rmdir $dir or sbozyp_die("could not rmdir '$dir': $!");
    }
}

sub sbozyp_rmdir_rec {
    my ($dir) = @_;
    if (-d $dir) {
        remove_tree($dir, {error => \my $err});
        if ($err) {
            for my $diag (@$err) {
                my (undef, $err_msg) = %$diag;
                sbozyp_die("could not recursively delete directory '$dir': $err_msg");
            }
        }
    }
}

sub i_am_root {
    return 0 == $> ? 1 : 0;
}

sub i_am_root_or_die {
    my ($msg) = @_;
    sbozyp_die($msg // 'must be root') unless i_am_root();
}

sub parse_config_file {
    my ($config_file) = @_;
    if (!defined $config_file) {
        $config_file = -f "$ENV{HOME}/.sbozyp.conf" ? "$ENV{HOME}/.sbozyp.conf" : '/etc/sbozyp/sbozyp.conf';
    }
    my $fh = sbozyp_open('<', $config_file);
    while (<$fh>) {
        chomp;
        my $line_copy = $_; # save $_ so we can create a nice error message if things go wrong
        s/#.*//;            # no comments
        s/^\s+//;           # no leading whitespace
        s/\s+$//;           # no trailing whitespace
        s/\/+$//;           # no trailing /'s
        next unless length; # is there anything left?
        my ($k, $v) = split /\s*=\s*/, $_, 2;
        $k !~ /^\s*$/ && $v !~ /^\s*$/ or sbozyp_die("could not parse line $. '$line_copy': '$config_file'");
        $CONFIG{$k} = $v;
    }
}

sub path_to_pkgname {
    my ($path) = @_;
    my $pkgname = basename(dirname($path)) . '/' . basename($path);
    return $pkgname;
}

sub set_repo_name_or_die {
    my ($repo_name) = @_;
    my $repo_num = repo_name_repo_num($repo_name);
    if (defined $repo_num) {
        $CONFIG{REPO_NAME} = $repo_name;
    } else {
        sbozyp_die("no repo named '$repo_name'");
    }
}

sub repo_name_repo_num {
    my ($repo_name) = @_;
    my $repo_num;
    for my $k (grep /^REPO_.+_NAME/, sort keys %CONFIG) {
        my $v = $CONFIG{$k};
        if ($v eq $repo_name) {
            ($repo_num) = $k =~ /^REPO_(\d+)_NAME/;
        }
    }
    return $repo_num;
}

sub repo_num_git_branch {
    my ($repo_num) = @_;
    for my $k (sort keys %CONFIG) {
        return $CONFIG{$&} if $k =~ /^REPO_\Q$repo_num\E_GIT_BRANCH$/;
    }
}

sub repo_num_git_url {
    my ($repo_num) = @_;
    for my $k (sort keys %CONFIG) {
        return $CONFIG{$&} if $k =~ /^REPO_\Q$repo_num\E_GIT_URL$/;
    }
}

sub repo_git_branch {
    my $repo_num = repo_name_repo_num($CONFIG{REPO_NAME});
    my $repo_git_branch = repo_num_git_branch($repo_num);
    return $repo_git_branch;
}

sub repo_git_url {
    my $repo_num = repo_name_repo_num($CONFIG{REPO_NAME});
    my $repo_git_url = repo_num_git_url($repo_num);
    return $repo_git_url;
}

sub repo_is_cloned {
    my $local_git_repo = "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}";
    return -d "$local_git_repo/.git" ? 1 : 0;
}

sub clone_repo {
    my $local_git_repo = "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}";
    if (repo_is_cloned()) {
        sbozyp_rmdir_rec($local_git_repo);
        sbozyp_mkdir($local_git_repo);
    }
    my $repo_git_branch = repo_git_branch();
    my $repo_git_url = repo_git_url();
    sbozyp_system('git', 'clone', '--branch', $repo_git_branch, '--single-branch', $repo_git_url, $local_git_repo);
}

sub sync_repo {
    my $local_git_repo = "$CONFIG{REPO_ROOT}/$CONFIG{REPO_NAME}";
    if (repo_is_cloned()) {
        my $repo_git_branch = repo_git_branch();
        sbozyp_system("git -C '$local_git_repo' fetch 1>&2");
        sbozyp_system("git -C '$local_git_repo' reset --hard 'origin/$repo_git_branch' 1>&2");
    } else {
        sbozyp_die("cannot sync non-existent git repository at '$local_git_repo'");
    }
}

sub pkg_dependencies_direct {
    my ($pkg) = @_;
    my @deps = map { pkg($_) } @{$pkg->{REQUIRES}};
    return @deps;
}

sub pkg_dependencies_recursive {
    my ($pkg) = @_;
    my @deps;
    my $resolve_deps = sub {
        my ($pkg) = @_;
        for my $dep (pkg_dependencies_direct($pkg)) {
            @deps = grep { $dep->{PKGNAME} ne $_->{PKGNAME} } @deps;
            unshift @deps, $dep;
            __SUB__->($dep);
        }
    };
    $resolve_deps->($pkg);
    return @deps;
}

sub pkg_dependents_direct {
    my ($pkg) = @_;
    my @dependents;
    my @installed_sbo_pkgs = keys %{{ installed_sbo_pkgs() }};
    for my $pkgname (@installed_sbo_pkgs) {
        my $pkg_ = pkg($pkgname);
        my @deps = pkg_dependencies_direct($pkg_);
        push @dependents, $pkg_ if grep { $pkg->{PKGNAME} eq $_->{PKGNAME} } @deps;
    }
    @dependents = pkgs_sorted(@dependents);
    return @dependents;
}

sub pkg_dependents_recursive {
    my ($pkg) = @_;
    my @dependents;
    my %seen;
    my $resolve_dependents = sub {
        my @pkgs = @_;
        for my $pkg (@pkgs) {
            next if $seen{$pkg->{PKGNAME}};
            $seen{$pkg->{PKGNAME}} = 1;
            push @dependents, $pkg;
            __SUB__->(pkg_dependents_direct($pkg));
        }
    };
    $resolve_dependents->(pkg_dependents_direct($pkg));
    @dependents = pkgs_sorted(@dependents);
    return @dependents;
}

sub pkgs_no_dependents {
    my @pkgs_no_dependents;
    my @installed_sbo_pkgs = keys %{{ installed_sbo_pkgs() }};
    for my $pkgname (@installed_sbo_pkgs) {
        my $pkg = pkg($pkgname);
        push @pkgs_no_dependents, $pkg if 0 == pkg_dependents_direct($pkg);
    }
    @pkgs_no_dependents = pkgs_sorted(@pkgs_no_dependents);
    return @pkgs_no_dependents;
}

sub pkgs_removable_dependencies {
    my @pkgs = @_; # we assume all pkgs in @pkgs are actually installed
    my %pkgs; $pkgs{$_->{PKGNAME}} = $_ for @pkgs;
    my %deps; for my $pkg (values %pkgs) {
        for my $dep (pkg_dependencies_direct($pkg)) {
            next if exists $pkgs{$dep->{PKGNAME}};
            if (defined pkg_installed($dep)) {
                $deps{$dep->{PKGNAME}} = $dep;
                for my $dep (pkg_dependencies_recursive($dep)) {
                    next if exists $pkgs{$dep->{PKGNAME}};
                    $deps{$dep->{PKGNAME}} = $dep if defined pkg_installed($dep);
                }
            }
        }
    }
    for my $installed_sbo_pkg (map { pkg($_) } keys %{{ installed_sbo_pkgs() }}) {
        next if exists $pkgs{$installed_sbo_pkg->{PKGNAME}};
        next if exists $deps{$installed_sbo_pkg->{PKGNAME}};
        for my $dep (pkg_dependencies_direct($installed_sbo_pkg)) {
            if ($deps{$dep->{PKGNAME}}) {
                $deps{$dep->{PKGNAME}} = 0;
                for my $dep (pkg_dependencies_recursive($dep)) {
                    $deps{$dep->{PKGNAME}} = 0 if exists $deps{$dep->{PKGNAME}};
                }
            }
        }
    }
    my @removable_deps; for my $pkgname (keys %deps) {
        if (my $dep = $deps{$pkgname}) {
            push @removable_deps, $dep;
        }
    }
    @removable_deps = pkgs_sorted(@removable_deps);
    return @removable_deps;
}

sub pkg_queue {
    my ($pkg) = @_;
    my @deps = pkg_dependencies_recursive($pkg);
    my @queue = (@deps, $pkg);
    return @queue;
}

sub parse_slackware_pkgname {
    my ($slackware_pkgname) = @_;
    my ($prgnam, $version) = $slackware_pkgname =~ /^([\w-]+)-([^-]*)-[^-]*-\d+_SBo$/;
    my $pkgname = prgnam_to_pkgname($prgnam);
    return ($pkgname => $version);
}

sub sbozyp_pod2usage {
    my ($sections) = @_;
    my $fh = sbozyp_open('>', \my $pod);
    pod2usage(
        -input    => __FILE__,
        -output   => $fh,
        -exitval  => 'NOEXIT',
        -verbose  => 99,
        -sections => $sections
    );
    return $pod;
}

sub command_usage {
    my ($cmd) = @_;
    my $pod = sbozyp_pod2usage($cmd eq 'main' ? 'OVERVIEW' : 'COMMANDS/'.uc($cmd));
    my $usage = ($pod =~ /(Usage:[^\n]+)/s)[0];
    return "$usage\n";
}

sub command_help_msg {
    my ($cmd) = @_;
    my $pod = sbozyp_pod2usage($cmd eq 'main' ? 'OVERVIEW' : 'COMMANDS/'.uc($cmd));
    my @pod = split "\n", $pod; @pod = @pod[1..$#pod];
    $pod[0] =~ s/^ //;
    $_ =~ s/^.{4}// for @pod;
    $pod = join("\n", @pod) . "\n";
    return $pod;
}

sub installed_sbo_pkgs {
    my $root = $ENV{ROOT} // '/';
    my %installed_sbo_pkgs;
    if (-d "$root/var/lib/pkgtools/packages") {
        %installed_sbo_pkgs = map {
            my ($pkgname, $version) = parse_slackware_pkgname(basename($_));
            # If $pkgname is undef then this repo doesnt have the package. We only manage packages in the current repo.
            defined $pkgname ? ($pkgname, $version) : ();
        } grep /_SBo$/, sbozyp_readdir("$root/var/lib/pkgtools/packages");
    }
    return %installed_sbo_pkgs;
}

sub pkg_prepare_for_build {
    my ($pkg) = @_;
    my $arch = arch();
    if (my $arch_problem = $pkg->{ARCH_UNSUPPORTED}) {
        sbozyp_die("'$pkg->{PKGNAME}' is $arch_problem on $arch")
    }
    my %url_md5;
    if ($arch eq 'x86_64' and my @urls = @{$pkg->{DOWNLOAD_x86_64}}) {
        @url_md5{@urls} = @{$pkg->{MD5SUM_x86_64}};
    } else {
        my @urls = @{$pkg->{DOWNLOAD}};
        @url_md5{@urls} = @{$pkg->{MD5SUM}};
    }
    my $staging_dir = File::Temp->newdir(DIR => $CONFIG{TMPDIR}, TEMPLATE => 'sbozyp_XXXXXX');
    sbozyp_copy($pkg->{PKGDIR}, $staging_dir);
    for my $url (sort keys %url_md5) {
        my $md5 = $url_md5{$url};
        sbozyp_system('wget', '-P', $staging_dir, $url);
        my $file = basename($url);
        my $got_md5 = sbozyp_qx("md5sum '$staging_dir/$file' | cut -d' ' -f1");
        if ($md5 ne $got_md5) {
            sbozyp_die("md5sum mismatch for '$url': expected '$md5': got '$got_md5'");
        }
    }
    return $staging_dir;
}

sub install_slackware_pkg {
    my ($slackware_pkg) = @_;
    sbozyp_system("upgradepkg --reinstall --install-new '$slackware_pkg'");
}

sub remove_slackware_pkg {
    my ($slackware_pkg) = @_;
    sbozyp_system("removepkg '$slackware_pkg'");
}

sub build_slackware_pkg {
    my ($pkg) = @_;
    local $ENV{OUTPUT} = $CONFIG{TMPDIR}; # all SlackBuilds use the $OUTPUT env var to determine output pkg location
    my $staging_dir = pkg_prepare_for_build($pkg);
    my $slackbuild = $pkg->{PRGNAM} . '.SlackBuild';
    my $cmd = sbozyp_open('-|', "cd '$staging_dir' && chmod +x ./$slackbuild && ./$slackbuild");
    my $slackware_pkg;
    while (my $line = <$cmd>) {
        $slackware_pkg = $1 if $line =~ /^Slackware package (.+) created\.$/;
        print $line; # magically knows to print to stdout or stderr
    }
    close $cmd;
    sbozyp_die("failed to build '$pkg->{PKGNAME}'") if $? != 0;
    sbozyp_die("successfully built '$pkg->{PKGNAME}' but couldn't determine the path of the created Slackware package") if !defined $slackware_pkg;
    return $slackware_pkg;
}

sub built_slackware_pkg {
    my ($pkg) = @_;
    my $output = $CONFIG{TMPDIR};
    return [ glob "$output/$pkg->{PRGNAM}*$pkg->{VERSION}*_SBo*" ]->[0];
}

# versioncmp() is copy and pasted directly from the Sort::Versions CPAN module.
# We copy and paste this here as we don't wish for sbozyp to have any deps.
# Note that sbotools also uses Sort::Versions for version comparisons.
sub versioncmp ($$) {
    my @A = ($_[0] =~ /([-.]|\d+|[^-.\d]+)/g);
    my @B = ($_[1] =~ /([-.]|\d+|[^-.\d]+)/g);

    my ($A, $B);
    while (@A and @B) {
        $A = shift @A;
        $B = shift @B;
        if ($A eq '-' and $B eq '-') {
            next;
        } elsif ( $A eq '-' ) {
            return -1;
        } elsif ( $B eq '-') {
            return 1;
        } elsif ($A eq '.' and $B eq '.') {
            next;
        } elsif ( $A eq '.' ) {
            return -1;
        } elsif ( $B eq '.' ) {
            return 1;
        } elsif ($A =~ /^\d+$/ and $B =~ /^\d+$/) {
            if ($A =~ /^0/ || $B =~ /^0/) {
                return $A cmp $B if $A cmp $B;
            } else {
                return $A <=> $B if $A <=> $B;
            }
        } else {
            $A = uc $A;
            $B = uc $B;
            return $A cmp $B if $A cmp $B;
        }
    }
    @A <=> @B;
}

sub version_gt {
    my ($v1, $v2) = @_;
    my $cmp = versioncmp($v1, $v2);
    return $cmp == 1;
}

1;

__END__

            ####################################################
            #                      MANUAL                      #
            ####################################################

=pod

=head1 NAME

sbozyp - A package manager for Slackware's SlackBuilds.org

=head1 DESCRIPTION

Sbozyp is a command-line package manager for the SlackBuilds.org package
repository. SlackBuilds.org is a collection of third-party SlackBuild scripts
used to build Slackware packages. Sbozyp assumes an understanding of SlackBuilds
and the SlackBuilds.org repository.

=head1 OVERVIEW

 Usage: sbozyp [global_opts] <command> [command_opts] <command_args>

Every command has its own options, these are just the global ones:

 -C            Re-clone SBo repository before running the command
 -F FILE       Use FILE as the configuration file
 -R REPO_NAME  Use SBo repository REPO_NAME instead of REPO_PRIMARY
 -S            Sync SBo repository before running the command
 -T            Exit if the SBo repository hasn't been cloned yet

Commands:

 install|in    Install or upgrade packages
 build|bu      Build but don't install packages
 remove|rm     Remove packages
 query|qr      Query for information about a package
 search|se     Search for a package using a Perl regex
 null|nu       Do nothing, useful in conjunction with -C or -S opts

Examples:

 sbozyp --help
 sbozyp --version
 sbozyp install --help
 sbozyp install -S -R $REPO -f xclip system/password-store
 sbozyp -T build -f mu
 sbozyp remove xclip password-store
 sbozyp query -q password-store
 sbozyp search -n system/.+
 sbozyp -R $REPO -C null

=head1 CONFIGURATION

Sbozyp is configured via the C</etc/sbozyp/sbozyp.conf> file unless
C<~/.sbozyp.conf> is present. An alternate configuration file can be used with
the C<-F> option.

=head2 REPOSITORY DEFINITIONS

You can define as many repositories as you wish in the configuration file. A
repository definition requires these 3 variables to be set ($N is any
non-negative integer):

 REPO_$N_NAME
 REPO_$N_GIT_URL
 REPO_$N_GIT_BRANCH

Example:

 REPO_7_NAME=fifteenpoint0
 REPO_7_GIT_URL=git://git.slackbuilds.org/slackbuilds.git
 REPO_7_GIT_BRANCH=15.0

This defines a repository that will be downloaded with git with a command like:
C<git clone --branch $REPO_7_GIT_BRANCH $REPO_7_GIT_URL>.

You can use this repository with sbozyp by specifying its name (fifteenpoint0)
with the C<-R> option. You can also make this repository the default (used when
C<-R> is omitted) by setting C<REPO_PRIMARY=fifteenpoint0> in your configuration
file.

=head2 OTHER CONFIGURATION VARIABLES

=head3 REPO_PRIMARY

The repo to use by default when not specifying one with the C<-R> flag. There
is no default value for this variable.

=head3 REPO_ROOT

The directory to store local copies of SBo.

Defaults to C<REPO_ROOT=/var/lib/sbozyp/SBo>.

=head3 TMPDIR

The directory used for placing working files.

Defaults to C<TMPDIR=/tmp>.

=head1 COMMANDS

=head2 INSTALL

 Usage: sbozyp <install|in> [-h] [-f] [-i] [-k] [-n] <pkgname...>

Install or upgrade packages.

Options are:

 -h|--help     Print help message
 -f            Force installation even if package is already up to date
 -i            No interactive prompt
 -k            Keep the built package (resides in TMPDIR)
 -n            Do not install package dependencies

Examples:

 sbozyp install --help
 sbozyp in password-store
 sbozyp in xclip mu password-store
 sbozyp in -k system/password-store
 sbozyp in -f -i -n password-store
 sbozyp -S -R $REPO in -f password-store
 sbozyp in $(sbozyp -S qr -u | cut -d' ' -f1) ### upgrade all packages

=head2 BUILD

 Usage: sbozyp <build|bu> [-h] [-f] [-i] <pkgname...>

Build but don't install packages.

Options are:

 -h|--help     Print help message
 -f            Force rebuilding the package even if it's already built
 -i            No confirmation prompt

Examples:

 sbozyp build --help
 sbozyp bu password-store
 sbozyp bu -f password-store
 sbozyp bu -f -i system/password-store
 sbozyp -S -R $REPO bu password-store
 sbozyp bu -i system/password-store xclip mu

=head2 REMOVE

 Usage: sbozyp <remove|rm> [-h] [-f] [-i] [-r] <pkgname...>

Remove packages.

Options are:

 -h|--help     Print help message
 -f            Disable removal safety check (DANGEROUS)
 -i            No confirmation prompt
 -r            Recursively remove package dependencies that are safe to remove

Examples:

 sbozyp remove --help
 sbozyp rm xclip mu system/password-store
 sbozyp rm -i -r password-store
 sbozyp -S -R $REPO rm password-store

=head2 QUERY

 Usage: sbozyp <query|qr> [-h] [-a] [-b] [-d] [-i] [-m] [-n] [-o] [-p] [-q] [-r] [-s] [-u] PKGNAME?

Query for package related information.

Exactly one option must be used in a single query command.

Options are:

 -h|--help     Print help message
 -a            Print all installed SBo packages
 -b            Print the path to PKGNAME's local package directory
 -d            Print PKGNAME's slack-desc file
 -i            Print PKGNAME's info file
 -m            Print all installed SBo packages with no dependents
 -n            Print PKGNAME's recursive dependents
 -o            Print PKGNAME'S direct dependents
 -p            If PKGNAME is installed print the installed version number
 -q            Print PKGNAME's installation queue (finds PKGNAMES dependencies)
 -r            Print PKGNAME's README file
 -s            Print PKGNAME's .SlackBuild file
 -u            Print all packages that have upgrades available

Examples:

 sbozyp query --help
 sbozyp qr -q password-store
 sbozyp qr -m
 sbozyp -S qr -u
 cd $(sbozyp qr -b password-store)
 sbozyp -S -R $REPO qr password-store

=head2 SEARCH

 Usage: sbozyp <search|se> [-h] [-c] [-n] [-p] <regex>

Search for a package using a Perl regex.

Options are:

 -h|--help     Print help message
 -c            Match case sensitive
 -n            Match against CATEGORY/PRGNAM instead of just PRGNAM
 -p            Print just the PRGNAM of matched packages

Examples:

 sbozyp search --help
 sbozyp se password-store
 sbozyp se -p password.+
 sbozyp se -c -n system/.+
 sbozyp -S -R $REPO se password-store

=head2 NULL

 Usage: sbozyp <null|nu> [-h]

Do nothing. Useful if you just want to re-clone (with global -C option) or
sync (with global -S option) a repository.

Options are:

 -h|--help     Print help message

Examples:

 sbozyp null --help
 sbozyp nu
 sbozyp -R $REPO -S nu
 sbozyp -S nu

=head1 AUTHOR

Nicholas Hubbard (nicholashubbard@posteo.net)

=head1 CONTRIBUTORS

=over 4

=item * Kat Nguyen

=item * pghvlaans

=back

=head1 COPYRIGHT

Copyright (c) 2023-2025 by Nicholas Hubbard (nicholashubbard@posteo.net)

=head1 LICENSE

This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with
sbozyp. If not, see http://www.gnu.org/licenses/.

=cut
