#!/usr/bin/env perl
#
# upd - download the matching binary from the latest Gitea/GitHub release.
#
#   ./upd https://git.fhi.mpg.de/mike/mgsh
#   ./upd https://github.com/sxyazi/yazi --install ~/bin --name yazi,ya
#   ./upd --all                       # everything listed in the config file
#   ./upd --all --check               # exit 10 if any update is available
#
# Needs only Perl core modules plus curl (or wget) for HTTPS.

use strict;
use warnings;
use Getopt::Long qw(GetOptions);
use File::Basename qw(basename dirname);
use File::Spec;
use File::Temp qw(tempdir);
use POSIX qw(uname strftime);
use Digest::SHA qw(sha256_hex);
use JSON::PP ();

my $UA = 'upd/2.0 (perl)';

# Exit codes: 0 ok, 1 usage, 2 error, 10 update available (--check only).
use constant { EX_OK => 0, EX_USAGE => 1, EX_ERROR => 2, EX_OUTDATED => 10 };

# Options that belong to a single repository. Anything else is global and
# applies to every entry of an --all run.
my @SPEC_KEYS = qw(install name tag asset pattern os arch forge token version-flag pre);

my %opt = ('version-flag' => '--version', timeout => 30);

# Leftovers from an interrupted install must not linger next to the binary.
my @TMPFILES;
END { unlink @TMPFILES if @TMPFILES }

# Run only when executed directly, so the subs below can be require'd and
# exercised on their own.
main() unless caller;

sub main {
    GetOptions(
        \%opt,
        'repo=s', 'tag=s', 'install|dest|i=s', 'name=s', 'asset=s', 'pattern=s',
        'os=s', 'arch=s', 'token=s', 'forge=s', 'version-flag=s', 'timeout=i',
        'config=s', 'all', 'check', 'pre', 'force', 'list', 'dry-run',
        'quiet', 'verbose', 'help',
    ) or usage(EX_USAGE);
    usage(EX_OK) if $opt{help};
    $| = 1;

    for my $sig (qw(INT TERM HUP)) {
        $SIG{$sig} = sub { unlink @TMPFILES; @TMPFILES = (); exit 130 };
    }

    my @specs;
    eval { @specs = build_specs(); 1 } or do {
        warn trim($@) . "\n";
        exit EX_ERROR;
    };

    my @results;
    for my $spec (@specs) {
        my $res = eval { update_one($spec) };
        $res = { status => 'error', label => label_of($spec), msg => trim($@ || 'failed') }
          unless defined $res;
        push @results, $res;
        warn "$res->{label}: $res->{msg}\n" if $res->{status} eq 'error';
    }

    summary(\@results) if @specs > 1 && !$opt{list};
    exit worst_code(\@results);
}

sub build_specs {
    if ($opt{all}) {
        my $path = $opt{config} // config_path();
        my @s = read_config($path);
        die "No entries in $path.\n" unless @s;
        return @s;
    }
    my $url = $opt{repo} // shift(@ARGV) // $ENV{UPD_REPO};
    unless (defined $url && length $url) {
        warn "Error: missing repository URL.\n\n";
        usage(EX_USAGE);
    }
    my %s = (repo => $url);
    $s{$_} = $opt{$_} for grep { defined $opt{$_} } @SPEC_KEYS;
    return (\%s);
}

# ==============================================================================
# One repository, end to end
# ==============================================================================

sub update_one {
    my $spec = shift;

    my ($base, $owner, $repo) = parse_repo($spec->{repo});
    my $forge = detect_forge($base, $spec->{forge});
    my %ctx   = (
        base  => $base,
        owner => $owner,
        repo  => $repo,
        forge => $forge,
        token => resolve_token($forge, $spec->{token}),
        api   => api_base($base, $forge) . '/repos/' . uri_esc($owner) . '/' . uri_esc($repo),
    );

    my @names = parse_names($spec->{name} // $repo, $repo);
    my ($os, $arch) = detect_platform($spec);

    info("Repo:     $base/$owner/$repo [$forge]");
    info("Platform: $os/$arch");

    my $rel = fetch_release(\%ctx, $spec);
    my $tag = $rel->{tag_name};

    if ($opt{list}) {
        list_releases($rel->{_list} || [$rel]);
        return { status => 'listed', label => $names[0]{out}, msg => '' };
    }

    info("Release:  $tag ($rel->{published_at})" . ($rel->{prerelease} ? ' [prerelease]' : ''));

    my @assets = @{ $rel->{assets} || [] };
    die "Release $tag has no assets.\n" unless @assets;

    my $asset = pick_asset(\@assets, $names[0]{src}, $os, $arch, $spec);
    unless ($asset) {
        my $have = join "\n  ", map { $_->{name} } @assets;
        die "No asset for $os/$arch in release $tag.\nAvailable:\n  $have\n"
          . "Select one explicitly with --asset <name> or --pattern <regex>.\n";
    }
    info("Asset:    $asset->{name} (" . human_size($asset->{size}) . ")");

    my @targets = resolve_targets($spec, \@names, $os);
    info("Target:   " . join(', ', map { $_->{dest} } @targets));

    my @stale = grep { !target_current($_, $tag, $asset, $spec) } @targets;
    unless (@stale || $opt{force}) {
        info("Already up to date ($tag) - nothing to do.");
        return { status => 'current', label => $names[0]{out}, msg => $tag };
    }
    @stale = @targets if $opt{force};

    if ($opt{check}) {
        my $have = join ', ', map { installed_tag($_) } @stale;
        info("Update available: $tag (installed: $have)");
        return { status => 'outdated', label => $names[0]{out}, msg => "$tag (have: $have)" };
    }

    my ($dl_url) = asset_url(\%ctx, $asset);
    if ($opt{'dry-run'}) {
        print "[dry-run] would download: $dl_url\n";
        print "[dry-run] would install to: $_->{dest}\n" for @stale;
        return { status => 'dry', label => $names[0]{out}, msg => $tag };
    }

    # --- download -------------------------------------------------------------

    my $tmpdir = tempdir(CLEANUP => 1);
    my $dl     = File::Spec->catfile($tmpdir, $asset->{name});

    info("Downloading $dl_url ...");
    my (undef, $dl_hdr) = asset_url(\%ctx, $asset);
    my $res = http_request(url => $dl_url, headers => { %{ auth_headers(\%ctx) }, %$dl_hdr },
                           file => $dl, progress => 1);
    die "Download failed (HTTP $res->{status}): $dl_url\n" unless $res->{status} == 200;

    my $got = -s $dl // 0;
    die "Incomplete download: $got of $asset->{size} bytes.\n"
      if $asset->{size} && $got < $asset->{size};

    verify_download(\%ctx, $dl, $asset, \@assets, $tmpdir);

    # --- extract and install --------------------------------------------------

    my $root = extract_if_archive($dl, $tmpdir);
    for my $t (@stale) {
        my $src = defined $root ? find_in_tree($root, $t->{name})
                                : $dl;
        die "Binary '$t->{name}' not found inside $asset->{name}.\n" unless defined $src;
        warn "Warning: $t->{name} does not look like an executable.\n"
          unless looks_executable($src);

        install_atomic($src, $t->{dest});
        strip_quarantine($t->{dest}) if $os eq 'darwin';
        write_state($t, \%ctx, $spec, $tag, $asset);
        info("Installed: $t->{dest} ($tag)");
        my $v = installed_version($t->{dest}, $spec);
        info("Version:   $v") if length $v;
    }

    my %dirs = map { dirname($_->{dest}) => 1 } @stale;
    warn "Note: $_ is not in \$PATH.\n" for grep { !in_path($_) } sort keys %dirs;

    return { status => 'updated', label => $names[0]{out}, msg => $tag };
}

# ==============================================================================
# Release selection
# ==============================================================================

sub fetch_release {
    my ($ctx, $spec) = @_;

    # A specific tag: both forges have a direct endpoint. Fall back to the list
    # so "26.5.6" also finds a tag named "v26.5.6".
    if (defined $spec->{tag}) {
        my $t   = $spec->{tag};
        my $hit = api_get($ctx, "$ctx->{api}/releases/tags/" . uri_esc($t), soft => 1);
        return $hit if $hit && $hit->{tag_name};
        my $list = api_get($ctx, releases_url($ctx));
        ($hit) = grep { $_->{tag_name} eq $t } @$list;
        ($hit) = grep { norm_ver($_->{tag_name}) eq norm_ver($t) } @$list unless $hit;
        die "Release '$t' not found (--list shows all).\n" unless $hit;
        return $hit;
    }

    # The plain case is one request: the forge already knows its latest
    # non-draft, non-prerelease release.
    unless ($spec->{pre} || $opt{list}) {
        my $latest = api_get($ctx, "$ctx->{api}/releases/latest", soft => 1);
        return $latest if $latest && $latest->{tag_name};
        verbose('no /releases/latest, falling back to the release list');
    }

    my $list = api_get($ctx, releases_url($ctx));
    die "No releases found in $ctx->{owner}/$ctx->{repo}.\n"
      unless ref $list eq 'ARRAY' && @$list;

    if ($opt{list}) {
        return { tag_name => '', published_at => '', assets => [], _list => $list };
    }
    my ($rel) = grep { !$_->{draft} && ($spec->{pre} || !$_->{prerelease}) } @$list;
    die "No suitable release found (try --pre).\n" unless $rel;
    return $rel;
}

sub releases_url {
    my $ctx = shift;
    return $ctx->{forge} eq 'github' ? "$ctx->{api}/releases?per_page=50"
                                     : "$ctx->{api}/releases?limit=50&draft=false";
}

# ==============================================================================
# Asset selection
# ==============================================================================

# Aliases commonly used in release asset names, including Rust target triples.
sub os_alias {
    my $o = shift;
    return $o eq 'darwin'  ? [qw(darwin macos osx mac apple)]
         : $o eq 'windows' ? [qw(windows win)]
         :                   [$o];
}

# --- Asset name matching ------------------------------------------------------

# Can this machine turn the asset into a binary? Bare names and tarballs
# always qualify; single-file compression needs its decompressor present.
sub unpackable {
    my $n = lc shift;
    return 1 if $n =~ /\.(tar\.gz|tgz|tar\.bz2|tbz|tar\.xz|txz|tar\.zst|tzst|tar)$/;
    return which('unzip') ? 1 : 0 if $n =~ /\.zip$/;
    return (which('7zz') || which('7z') || which('7za')) ? 1 : 0 if $n =~ /\.7z$/;
    return decompressor($1) ? 1 : 0 if $n =~ /\.(gz|bz2|xz|zst|lz4|lzma)$/;
    return 0 if $n =~ /\.(rar|dmg|pkg|msi|deb|rpm)$/;
    return 1;    # bare binary or an unversioned name
}

# Compound tokens that name OS and architecture in one word. Rewritten before
# matching so the normal token rules apply. "win32" is Node-speak for Windows
# in general (pnpm-win32-x64.zip), so it contributes no architecture.
sub normalize_tokens {
    my $n = shift;
    $n =~ s/(?<![a-z0-9])win64(?![a-z0-9])/windows-amd64/g;
    $n =~ s/(?<![a-z0-9])win32(?![a-z0-9])/windows/g;
    return $n;
}

sub arch_alias {
    my $a = shift;
    return $a eq 'amd64'   ? [qw(amd64 x86_64 x64 64bit)]
         : $a eq 'arm64'   ? [qw(arm64 aarch64)]
         : $a eq '386'     ? [qw(386 i386 i686 x86 32bit)]
         : $a eq 'arm'     ? [qw(arm armv7 armv6 armhf)]
         : $a eq 'riscv64' ? [qw(riscv64 riscv64gc)]
         :                   [$a];
}

# Glibc vs musl builds: pick what this system actually runs.
my $MUSL;
sub prefers_musl {
    return $MUSL if defined $MUSL;
    return $MUSL = 1 if -f '/etc/alpine-release';
    my $ldd = which('ldd');
    my $out = $ldd ? capture(5, $ldd, '--version') : '';
    return $MUSL = $out =~ /musl/i ? 1 : 0;
}

# NB: no lexical $a/$b in here - they would shadow sort's globals below.
sub pick_asset {
    my ($assets, $bin, $want_os, $want_arch, $spec) = @_;

    if (defined $spec->{asset}) {
        my ($hit) = grep { $_->{name} eq $spec->{asset} } @$assets;
        die "Asset '$spec->{asset}' is not part of the release.\n" unless $hit;
        return $hit;
    }
    if (defined $spec->{pattern}) {
        my $re = qr/$spec->{pattern}/;
        my ($hit) = grep { $_->{name} =~ $re } @$assets;
        return $hit;
    }

    my $os_re   = join '|', @{ os_alias($want_os) };
    my @arches  = @{ arch_alias($want_arch) };
    # Universal macOS builds serve both architectures.
    push @arches, qw(universal universal2) if $want_os eq 'darwin';
    my $arch_re = join '|', @arches;

    my (@cand, @rejected);
    for my $as (@$assets) {
        my $n = lc $as->{name};
        my $m = normalize_tokens($n);

        # Checksums, signatures and OS packages are not plain binaries.
        if ($n =~ /\.(sha\d*|sha256sum|md5|asc|sig|pem|sbom|json|txt|deb|rpm|apk|dmg|pkg|msi|snap|flatpak|appimage)$/) {
            push @rejected, "$n (not a binary)";
            next;
        }
        # Cross-compile targets whose triples contain a host OS token:
        # aarch64-linux-android is Android, not Linux.
        if ($m =~ /(?:^|[^a-z0-9])(?:android\w*|ios|wasi|wasm\w*|emscripten)(?:[^a-z0-9]|$)/) {
            push @rejected, "$n (foreign target)";
            next;
        }
        unless ($m =~ /(?:^|[^a-z0-9])(?:$os_re)(?:[^a-z0-9]|$)/) {
            push @rejected, "$n (os)";
            next;
        }
        unless ($m =~ /(?:^|[^a-z0-9])(?:$arch_re)(?:[^a-z0-9]|$)/) {
            push @rejected, "$n (arch)";
            next;
        }
        # Keep 32-bit "arm" off "arm64" and "386" off "x86_64".
        if (   ($want_arch eq 'arm'   && $m =~ /arm64|aarch64/)
            || ($want_arch eq '386'   && $m =~ /x86[_-]?64|amd64/)
            || ($want_arch eq 'amd64' && $m =~ /arm64|aarch64/)) {
            push @rejected, "$n (wider arch)";
            next;
        }

        my $score = 0;
        $score += 10 if index($n, lc $bin) == 0;    # named after the binary
        $score += 3  if $n =~ /\.(tar\.gz|tgz|zip|tar\.xz|tar\.bz2|tar\.zst|tzst)$/ || $n !~ /\./;
        $score -= 5  if $n =~ /debug|symbols|static-pie|profile/;
        # Only avoid formats this machine has no tool for - restic, for one,
        # ships nothing but .bz2, and bunzip2 handles that fine.
        $score -= 8  if !unpackable($n);

        # Toolchain preference is a tie-break, not a penalty: a project that
        # ships musl only (or mingw only) must not be downranked for it.
        my $pref = 0;
        if ($want_os eq 'linux' && $n =~ /musl|gnu/) {
            my $libc = prefers_musl() ? 'musl' : 'gnu';
            $pref = $n =~ /\Q$libc\E/ ? 0 : 1;
        }
        elsif ($want_os eq 'windows' && $n =~ /msvc|gnu/) {
            $pref = $n =~ /msvc/ ? 0 : 1;    # msvc is the normal Windows build
        }
        push @cand, [ $score, $pref, length($n), $as ];
    }

    # Best score, then preferred toolchain, then the shortest name (which
    # avoids special variants like -baseline or -static).
    @cand = sort { $b->[0] <=> $a->[0] || $a->[1] <=> $b->[1] || $a->[2] <=> $b->[2] } @cand;
    if ($opt{verbose}) {
        verbose(sprintf 'candidate score=%-3d pref=%d %s', @{$_}[0, 1], $_->[3]{name}) for @cand;
        verbose("rejected: $_") for @rejected;
    }
    return @cand ? $cand[0][3] : undef;
}

# ==============================================================================
# Install targets and state
# ==============================================================================

sub resolve_targets {
    my ($spec, $names, $os) = @_;
    my $want = $spec->{install} // $ENV{UPD_INSTALL};
    $want =~ s{^~(?=/|$)}{$ENV{HOME} // '~'}e if defined $want;
    my $ext = $os eq 'windows' ? '.exe' : '';

    # --install is a directory unless it clearly points at a single binary:
    # an existing file, or a last segment that is one of the binary names.
    # Everything else is a directory (and gets created) - otherwise a config
    # line like "install=~/bin" would create a *file* called bin.
    my ($dir, $single);
    if (defined $want && length $want) {
        my $leaf   = basename($want);
        my $is_bin = grep { $leaf eq $_->{out} || $leaf eq $_->{out} . $ext } @$names;
        if (!-d $want && ($is_bin || -f $want)) {
            die "--install points at the file '$want' but " . scalar(@$names)
              . " binaries were requested; give a directory instead.\n" if @$names > 1;
            $single = $want;
            $dir    = dirname($want);
        }
        else {
            ($dir = $want) =~ s{(?<=.)/+$}{};
        }
    }
    else {
        $dir = default_dir($names->[0]{out});
    }
    make_dir($dir);

    my @t;
    for my $n (@$names) {
        my $dest = $single // File::Spec->catfile($dir, $n->{out} . $ext);
        # Replacing a symlink would silently break the link, so follow it.
        if (-l $dest) {
            require Cwd;
            if (my $real = Cwd::abs_path($dest)) {
                info("Note:     $dest is a symlink, installing to $real");
                $dest = $real;
            }
        }
        push @t, { name => $n->{src}, out => $n->{out}, dest => $dest };
    }
    return @t;
}

# Without --install: replace the binary already on PATH, else ~/.local/bin.
sub default_dir {
    my $bin = shift;
    for my $dir (split /:/, ($ENV{PATH} // '')) {
        next unless length $dir;
        my $p = File::Spec->catfile($dir, $bin);
        return $dir if -x $p && -w $dir;
    }
    return File::Spec->catdir($ENV{HOME} // '.', '.local', 'bin');
}

sub make_dir {
    my $dir = shift;
    return if -d $dir || $opt{'dry-run'} || $opt{list} || $opt{check};
    require File::Path;
    File::Path::make_path($dir)
      or die "Cannot create target directory $dir: $!\n";
}

sub state_dir {
    my $base = $ENV{XDG_STATE_HOME}
      || File::Spec->catdir($ENV{HOME} // '.', '.local', 'state');
    return File::Spec->catdir($base, 'upd');
}

sub cache_dir {
    my $base = $ENV{XDG_CACHE_HOME} || File::Spec->catdir($ENV{HOME} // '.', '.cache');
    return File::Spec->catdir($base, 'upd');
}

# One state file per installed path; readable name plus a hash for uniqueness.
sub state_file {
    my $dest = shift;
    (my $key = $dest) =~ s{[^A-Za-z0-9._-]+}{_}g;
    $key =~ s{^_+|_+$}{}g;
    $key = substr($key, -70) if length $key > 70;
    return File::Spec->catfile(state_dir(), $key . '.' . substr(sha256_hex($dest), 0, 8) . '.json');
}

# Identity of a release asset: the digest if the forge publishes one (GitHub
# does), otherwise its upload time - this is what makes rolling tags such as
# "nightly" detectable.
sub asset_stamp {
    my $a = shift;
    return $a->{digest}                if $a->{digest};
    return "t:$a->{updated_at}"        if $a->{updated_at};
    return "t:$a->{created_at}"        if $a->{created_at};
    return 's:' . ($a->{size} // 0);
}

sub target_current {
    my ($t, $tag, $asset, $spec) = @_;
    return 0 unless -f $t->{dest};

    my $st = read_json(state_file($t->{dest}));
    if ($st && defined $st->{tag}) {
        return 0 unless $st->{tag} eq $tag;
        return 0 unless ($st->{asset} // '') eq $asset->{name};
        return 0 unless ($st->{stamp} // '') eq asset_stamp($asset);
        # A locally replaced binary counts as out of date.
        my $same = ($st->{binary_sha256} // '') eq file_sha256($t->{dest});
        verbose("$t->{dest}: state matches but binary differs") unless $same;
        return $same;
    }

    # No state yet (first run after an install by other means): ask the binary.
    my $v = installed_version($t->{dest}, $spec);
    verbose("$t->{dest}: no state file, binary reports '" . ($v // '') . "'");
    return length($v) && $v eq norm_ver($tag) ? 1 : 0;
}

sub installed_tag {
    my $t  = shift;
    my $st = read_json(state_file($t->{dest}));
    return $st->{tag} if $st && defined $st->{tag};
    return '-' unless -f $t->{dest};
    my $v = installed_version($t->{dest}, {});
    return length($v) ? $v : 'unknown';
}

sub write_state {
    my ($t, $ctx, $spec, $tag, $asset) = @_;
    make_dir_always(state_dir());
    write_json(state_file($t->{dest}), {
        dest          => $t->{dest},
        binary        => $t->{out},
        repo          => "$ctx->{base}/$ctx->{owner}/$ctx->{repo}",
        forge         => $ctx->{forge},
        tag           => $tag,
        asset         => $asset->{name},
        asset_size    => $asset->{size},
        stamp         => asset_stamp($asset),
        binary_sha256 => file_sha256($t->{dest}),
        installed_at  => strftime('%Y-%m-%dT%H:%M:%SZ', gmtime),
    });
}

sub make_dir_always {
    my $dir = shift;
    return if -d $dir;
    require File::Path;
    File::Path::make_path($dir) or die "Cannot create $dir: $!\n";
}

# ==============================================================================
# Config file (--all)
# ==============================================================================

sub config_path {
    my $base = $ENV{XDG_CONFIG_HOME} || File::Spec->catdir($ENV{HOME} // '.', '.config');
    return File::Spec->catfile($base, 'upd', 'tools');
}

# One entry per line: <repo-url> [key=value ...] [pre]
sub read_config {
    my $path = shift;
    open my $fh, '<', $path
      or die "Cannot read config $path: $!\n"
           . "Format: one line per tool, e.g.\n"
           . "  https://github.com/sxyazi/yazi  install=~/bin  name=yazi,ya\n";

    my (@out, $ln);
    while (my $line = <$fh>) {
        $ln++;
        $line =~ s/^\s+|\s+$//g;
        next if !length $line || $line =~ /^#/;

        my @tok = map { unquote($_) } $line =~ /("(?:\\.|[^"])*"|\S+)/g;
        my %s = (repo => shift @tok);
        for my $t (@tok) {
            my ($k, $v) = split /=/, $t, 2;
            $k = lc $k;
            die "$path:$ln: unknown key '$k' (allowed: " . join(', ', @SPEC_KEYS) . ")\n"
              unless grep { $_ eq $k } @SPEC_KEYS;
            $s{$k} = defined $v ? $v : 1;
        }
        # Global flags still win, so --force/--pre on the command line apply.
        $s{$_} = $opt{$_} for grep { defined $opt{$_} } @SPEC_KEYS;
        push @out, \%s;
    }
    close $fh;
    return @out;
}

sub unquote {
    my $s = shift;
    if ($s =~ s/^"(.*)"$/$1/s) { $s =~ s/\\(.)/$1/g }
    return $s;
}

# "yazi,ya" -> two binaries; "yazi:yazi-nightly" -> install yazi under a
# different file name. src is what to look for, out is what to write.
sub parse_names {
    my ($list, $fallback) = @_;
    my @n;
    for my $part (split /\s*,\s*/, $list) {
        next unless length $part;
        my ($src, $out) = split /:/, $part, 2;
        push @n, { src => $src, out => (defined $out && length $out) ? $out : $src };
    }
    return @n ? @n : ({ src => $fallback, out => $fallback });
}

sub label_of {
    my $spec = shift;
    return (parse_names($spec->{name}, '?'))[0]{out} if defined $spec->{name};
    my $r = $spec->{repo} // '?';
    $r =~ s{/+$}{};
    return basename($r);
}

sub summary {
    my $res = shift;
    # --quiet is meant for cron: report only what needs attention.
    my @rows = $opt{quiet}
      ? grep { $_->{status} =~ /^(error|outdated|updated)$/ } @$res
      : @$res;
    return unless @rows;
    my $w = 0;
    for (@rows) { $w = length $_->{label} if length $_->{label} > $w }
    print "\nSummary:\n";
    printf "  %-9s %-*s %s\n", $_->{status}, $w, $_->{label}, $_->{msg} // '' for @rows;
}

sub worst_code {
    my $res = shift;
    return EX_ERROR    if grep { $_->{status} eq 'error' } @$res;
    return EX_OUTDATED if grep { $_->{status} eq 'outdated' } @$res;
    return EX_OK;
}

# ==============================================================================
# HTTP
# ==============================================================================

# Use HTTP::Tiny only if it can really do TLS - otherwise curl/wget.
my $HTTP;
sub http_tiny {
    return $HTTP if defined $HTTP;
    $HTTP = eval {
        require HTTP::Tiny;
        HTTP::Tiny->can_ssl or die "no ssl\n";
        HTTP::Tiny->new(agent => $UA, timeout => $opt{timeout}, verify_SSL => 1);
    } || 0;
    return $HTTP;
}

sub transport {
    return 'tiny' if http_tiny();
    return 'curl' if which('curl');
    return 'wget' if which('wget');
    die "Found neither a TLS-capable HTTP::Tiny nor curl/wget.\n";
}

# wget cannot report response headers usefully, so no conditional requests.
sub conditional_ok { return transport() ne 'wget' }

sub auth_headers {
    my $ctx = shift;
    return {} unless $ctx->{token};
    # GitHub wants "Bearer", Gitea wants "token".
    return { Authorization => $ctx->{forge} eq 'github' ? "Bearer $ctx->{token}"
                                                        : "token $ctx->{token}" };
}

# Returns { status, headers => {lc => value}, content } - content only when no
# 'file' was given. Never dies on HTTP status; callers decide.
sub http_request {
    my %a  = @_;
    my $hdr = $a{headers} || {};

    if (my $h = http_tiny()) {
        my %o = (headers => $hdr);
        my $out;
        if ($a{file}) {
            open $out, '>', $a{file} or die "Cannot write $a{file}: $!\n";
            binmode $out;
            $o{data_callback} = sub { print {$out} $_[0] };
        }
        my $r = $h->get($a{url}, \%o);
        close $out if $out;
        return { status => $r->{status}, headers => $r->{headers} || {},
                 content => $a{file} ? '' : $r->{content} };
    }

    my $tmp = tempdir(CLEANUP => 1);
    my $body = $a{file} // File::Spec->catfile($tmp, 'body');
    my $hf   = File::Spec->catfile($tmp, 'head');

    if (transport() eq 'wget') {
        my @cmd = ('wget', '-q', '-O', $body, '--timeout', $opt{timeout}, "--user-agent=$UA",
                   map { "--header=$_: $hdr->{$_}" } sort keys %$hdr);
        my $rc = system(@cmd, $a{url});
        return { status => $rc == 0 ? 200 : 599, headers => {},
                 content => $a{file} ? '' : slurp($body) };
    }

    my @prog = ($a{progress} && !$opt{quiet} && -t STDOUT) ? ('--progress-bar') : ('-sS');
    my @cmd  = ('curl', @prog, '-L', '--retry', '2', '--connect-timeout', $opt{timeout},
                '-A', $UA, '-D', $hf, '-o', $body, '-w', '%{http_code}',
                map { ('-H', "$_: $hdr->{$_}") } sort keys %$hdr);

    my $code = '';
    if (open my $ph, '-|', @cmd, $a{url}) {
        local $/;
        $code = <$ph> // '';
        close $ph;
    }
    my $rc = $? >> 8;
    $code =~ s/\D//g;
    die "curl failed (exit $rc): $a{url}\n" if $rc != 0 && !length $code;
    die "curl failed (exit $rc, HTTP $code): $a{url}\n" if $rc != 0 && $code =~ /^[45]/;

    return { status => $code || 599, headers => parse_headers($hf),
             content => $a{file} ? '' : slurp($body) };
}

# Keep only the last response block; -L may have walked through redirects.
sub parse_headers {
    my $path = shift;
    open my $fh, '<', $path or return {};
    my %h;
    while (my $l = <$fh>) {
        $l =~ s/\r?\n$//;
        %h = () , next if $l =~ m{^HTTP/};
        $h{lc $1} = $2 if $l =~ /^([^:]+):\s*(.*)$/;
    }
    close $fh;
    return \%h;
}

sub slurp {
    my $path = shift;
    open my $fh, '<', $path or return '';
    binmode $fh;
    local $/;
    return <$fh> // '';
}

# GET a JSON API endpoint, revalidating a cached copy via ETag where supported.
# soft => 1 returns undef instead of dying on 404.
sub api_get {
    my ($ctx, $url, %o) = @_;
    my $accept = $ctx->{forge} eq 'github' ? 'application/vnd.github+json' : 'application/json';
    my %hdr    = (%{ auth_headers($ctx) }, Accept => $accept);
    $hdr{'X-GitHub-Api-Version'} = '2022-11-28' if $ctx->{forge} eq 'github';

    my $cf     = cache_file($url);
    my $cached = read_json($cf);
    $hdr{'If-None-Match'} = $cached->{etag}
      if $cached && $cached->{etag} && conditional_ok();

    my $r = http_request(url => $url, headers => \%hdr);

    if ($r->{status} == 304 && $cached) {
        verbose("304 not modified, using cached $url");
        return decode_json_or_die($cached->{body}, $url, $ctx);
    }
    return undef if $o{soft} && $r->{status} == 404;
    if ($r->{status} == 403 || $r->{status} == 429) {
        my $left = $r->{headers}{'x-ratelimit-remaining'};
        die "$ctx->{forge} rate limit reached"
          . (defined $left ? " (remaining: $left)" : '')
          . ". Set a token via --token or \$GITHUB_TOKEN.\n"
          if defined $left && $left eq '0';
    }
    die "HTTP $r->{status}: $url\n" unless $r->{status} == 200;

    my $data = decode_json_or_die($r->{content}, $url, $ctx);
    if (my $etag = $r->{headers}{etag}) {
        make_dir_always(cache_dir());
        write_json($cf, { url => $url, etag => $etag, body => $r->{content} });
    }
    return $data;
}

sub decode_json_or_die {
    my ($body, $url, $ctx) = @_;
    my $data = eval { JSON::PP->new->utf8->decode($body) };
    die "Response from $url is not JSON (is this really a $ctx->{forge} instance?).\n"
      unless defined $data;
    die ucfirst($ctx->{forge}) . " error: $data->{message}\n"
      if ref $data eq 'HASH' && $data->{message};
    return $data;
}

sub cache_file {
    my $url = shift;
    return File::Spec->catfile(cache_dir(), substr(sha256_hex($url), 0, 16) . '.json');
}

# Private GitHub assets are only reachable through the API URL; curl/HTTP::Tiny
# drop the Authorization header on the cross-host redirect to storage.
sub asset_url {
    my ($ctx, $a) = @_;
    return ("$ctx->{api}/releases/assets/$a->{id}", { Accept => 'application/octet-stream' })
      if $ctx->{forge} eq 'github' && $ctx->{token} && $a->{id};
    return ($a->{browser_download_url}, {});
}

# ==============================================================================
# Verification, extraction, installation
# ==============================================================================

sub verify_download {
    my ($ctx, $file, $asset, $assets, $tmpdir) = @_;

    my ($want, $src);
    if (($asset->{digest} // '') =~ /^sha256:([0-9a-f]{64})$/i) {
        ($want, $src) = (lc $1, 'asset digest');
    }
    else {
        my ($sum) = grep { $_->{name} eq "$asset->{name}.sha256" } @$assets;
        ($sum) = grep { $_->{name} =~ /^(sha256sums?(\.txt)?|checksums?\.txt)$/i } @$assets
          unless $sum;
        return unless $sum;

        my $sf = File::Spec->catfile($tmpdir, 'sums');
        my ($u, $h) = asset_url($ctx, $sum);
        my $r = eval {
            http_request(url => $u, headers => { %{ auth_headers($ctx) }, %$h }, file => $sf);
        };
        unless ($r && $r->{status} == 200) {
            warn "Checksum file could not be downloaded, skipping verification.\n";
            return;
        }
        open my $fh, '<', $sf or return;
        while (my $l = <$fh>) {
            # "<hash>  <name>", or just "<hash>" in a single-file .sha256
            next unless my ($hash, $fn) = $l =~ /^([0-9a-fA-F]{64})(?:\s+\*?(\S+))?/;
            next if defined $fn && basename($fn) ne $asset->{name};
            ($want, $src) = (lc $hash, $sum->{name});
            last;
        }
        close $fh;
        return unless $want;
    }

    my $got = file_sha256($file);
    die "SHA256 mismatch ($src)!\n  expected: $want\n  got:      $got\n" if $got ne $want;
    info("SHA256:   ok ($src)");
}

sub file_sha256 {
    my $path = shift;
    open my $fh, '<', $path or die "Cannot read $path: $!\n";
    binmode $fh;
    my $d = Digest::SHA->new(256)->addfile($fh)->hexdigest;
    close $fh;
    return $d;
}

# Single-file compression: the asset is the binary, just squeezed. Each entry
# lists the tools that can expand it, in order of preference; the first one
# found wins. All of them write to stdout with the flags given.
sub DECOMP {
    return {
        gz   => { tools => [qw(gzip gunzip)],     args => ['-dc'] },
        bz2  => { tools => [qw(bzip2 bunzip2)],   args => ['-dc'] },
        xz   => { tools => [qw(xz unxz)],         args => ['-dc'] },
        lzma => { tools => [qw(xz unxz lzma)],    args => ['-dc'] },
        zst  => { tools => [qw(zstd unzstd)],     args => ['-dcq'] },
        lz4  => { tools => [qw(lz4 unlz4)],       args => ['-dcq'] },
    };
}

sub decompressor {
    my $ext = shift;
    my $e   = DECOMP()->{$ext} or return;
    for my $t (@{ $e->{tools} }) {
        my $p = which($t) or next;
        return { prog => $p, args => $e->{args} };
    }
    return;
}

sub decompress_to {
    my ($dec, $src, $dst) = @_;
    open my $out, '>', $dst or die "Cannot write $dst: $!\n";
    binmode $out;
    my $pid = open my $in, '-|';
    die "fork failed: $!\n" unless defined $pid;
    unless ($pid) {
        open STDIN, '<', File::Spec->devnull;
        exec $dec->{prog}, @{ $dec->{args} }, $src;
        exit 127;
    }
    binmode $in;
    my $buf;
    print {$out} $buf while read $in, $buf, 1 << 20;
    close $in;
    my $rc = $?;
    close $out or die "Error writing $dst: $!\n";
    die "Failed to decompress $src (" . basename($dec->{prog}) . " exit "
      . ($rc >> 8) . ").\n" if $rc != 0;
}

# Returns the root of the extracted tree, or undef if the asset is a bare
# binary (then the download itself is the binary).
sub extract_if_archive {
    my ($file, $tmpdir) = @_;
    my $lc = lc basename($file);

    my $out = File::Spec->catdir($tmpdir, 'x');

    if ($lc =~ /\.(tar\.gz|tgz|tar\.bz2|tbz|tar\.xz|txz|tar\.zst|tzst|tar)$/) {
        mkdir $out or die "mkdir $out: $!\n";
        system('tar', '-xf', $file, '-C', $out) == 0 or die "Failed to extract $file.\n";
    }
    elsif ($lc =~ /\.zip$/) {
        which('unzip') or die "unzip not found, but needed for $lc.\n";
        mkdir $out or die "mkdir $out: $!\n";
        system('unzip', '-q', $file, '-d', $out) == 0 or die "Failed to extract $file.\n";
    }
    elsif ($lc =~ /\.7z$/) {
        my $z = which('7zz') || which('7z') || which('7za')
          or die "7z not found, but needed for $lc.\n";
        mkdir $out or die "mkdir $out: $!\n";
        system($z, 'x', '-y', "-o$out", $file) == 0 or die "Failed to extract $file.\n";
    }
    elsif (my ($ext) = $lc =~ /\.(gz|bz2|xz|zst|lz4|lzma)$/) {
        # A single compressed file, e.g. restic_0.19.1_darwin_arm64.bz2 -
        # the binary itself, just squeezed.
        my $dec = decompressor($ext)
          or die "Cannot unpack .$ext - install " . join('/', @{ DECOMP()->{$ext}{tools} })
               . " or pick another asset with --asset/--pattern.\n";
        mkdir $out or die "mkdir $out: $!\n";
        (my $stem = basename($file)) =~ s/\.\Q$ext\E$//i;
        my $plain = File::Spec->catfile($out, $stem);
        decompress_to($dec, $file, $plain);
    }
    else {
        return undef;    # bare binary
    }
    return $out;
}

# Look for a file called $name; fall back to the single executable in the tree.
sub find_in_tree {
    my ($root, $name) = @_;

    my @found;
    my @stack = ($root);
    while (my $dir = pop @stack) {
        opendir my $dh, $dir or next;
        for my $e (readdir $dh) {
            next if $e eq '.' || $e eq '..';
            my $p = File::Spec->catfile($dir, $e);
            if (-d $p) { push @stack, $p; next }
            push @found, $p;
        }
        closedir $dh;
    }

    my ($exact) = grep { basename($_) eq $name || basename($_) eq "$name.exe" } @found;
    return $exact if $exact;

    my @exec = grep { looks_executable($_) } @found;
    return $exec[0] if @exec == 1;
    verbose("archive contains " . scalar(@found) . " files, "
            . scalar(@exec) . " of them executable");
    return undef;
}

# Magic bytes beat guessing by file size: ELF, Mach-O (incl. fat), PE, script.
sub looks_executable {
    my $path = shift;
    open my $fh, '<', $path or return 0;
    binmode $fh;
    read $fh, my $m, 4;
    close $fh;
    return 0 unless defined $m && length $m >= 2;
    return 1 if $m =~ /^\x7fELF/;
    return 1 if $m =~ /^(?:\xcf\xfa\xed\xfe|\xce\xfa\xed\xfe|\xca\xfe\xba\xbe|\xbe\xba\xfe\xca)/;
    return 1 if $m =~ /^MZ/;
    return 1 if $m =~ /^#!/;
    return 0;
}

sub install_atomic {
    my ($src, $dest) = @_;
    my $dir = dirname($dest);
    -d $dir or die "Target directory $dir does not exist.\n";
    -w $dir or die "No write permission in $dir (use sudo, or pick another --install path).\n";

    my $tmp = "$dest.new.$$";
    push @TMPFILES, $tmp;
    open my $in,  '<', $src or die "Cannot read $src: $!\n";
    open my $out, '>', $tmp or die "Cannot write $tmp: $!\n";
    binmode $in;
    binmode $out;
    my $buf;
    print {$out} $buf while read $in, $buf, 1 << 20;
    close $in;
    close $out or die "Error writing $tmp: $!\n";
    chmod 0755, $tmp;

    # rename is atomic and works even while $dest is currently running.
    rename $tmp, $dest or do {
        my $err = $!;
        unlink $tmp;
        die "Cannot replace $dest: $err\n";
    };
    @TMPFILES = grep { $_ ne $tmp } @TMPFILES;
}

sub strip_quarantine {
    my $dest = shift;
    return unless -x '/usr/bin/xattr';
    system(qq{/usr/bin/xattr -d com.apple.quarantine \Q$dest\E >/dev/null 2>&1});
}

# ==============================================================================
# Helpers
# ==============================================================================

sub info    { print "$_[0]\n" unless $opt{quiet} }
sub verbose { print "  $_[0]\n" if $opt{verbose} }
sub trim    { my $s = shift // ''; $s =~ s/^\s+|\s+$//g; return $s }

sub parse_repo {
    my $url = shift;
    $url =~ s{/+$}{};
    $url =~ s{\.git$}{};
    $url = "https://$url" unless $url =~ m{^https?://};
    my ($scheme_host, $path) = $url =~ m{^(https?://[^/]+)(/.*)?$}
      or die "Cannot parse repository URL: $url\n";
    $path //= '';
    $path =~ s{^/}{};
    # Anything before <owner>/<repo> is a base path (Gitea under a sub-path).
    my @seg = split m{/}, $path;
    die "Repository URL needs <owner>/<repo>: $url\n" if @seg < 2;
    my ($o, $r) = splice @seg, -2;
    return ($scheme_host . (@seg ? '/' . join('/', @seg) : ''), $o, $r);
}

sub uri_esc {
    my $s = shift;
    $s =~ s{([^A-Za-z0-9._~-])}{sprintf '%%%02X', ord $1}ge;
    return $s;
}

sub host_of { my ($h) = ($_[0] // '') =~ m{^https?://([^/:]+)}i; return lc($h // '') }

# Gitea and GitHub expose the same release JSON, only under different API
# roots and with different auth headers.
sub detect_forge {
    my ($b, $override) = @_;
    if (defined $override) {
        my $f = lc $override;
        die "Unknown forge '$override' (use github or gitea).\n"
          unless $f eq 'github' || $f eq 'gitea';
        return $f;
    }
    return host_of($b) =~ /(^|\.)github\.com$/ ? 'github' : 'gitea';
}

sub api_base {
    my ($b, $f) = @_;
    return "$b/api/v1" unless $f eq 'github';
    return 'https://api.github.com' if host_of($b) =~ /(^|\.)github\.com$/;
    return "$b/api/v3";    # GitHub Enterprise
}

sub resolve_token {
    my ($forge, $explicit) = @_;
    return $explicit if defined $explicit;
    my @env = $forge eq 'github' ? qw(UPD_TOKEN GITHUB_TOKEN GH_TOKEN)
            :                      qw(UPD_TOKEN GITEA_TOKEN);
    for my $k (@env) {
        return $ENV{$k} if defined $ENV{$k} && length $ENV{$k};
    }
    return '';
}

sub detect_platform {
    my $spec = shift || {};
    my ($sysname, undef, undef, undef, $machine) = uname();
    my $o = lc $sysname;
    $o = 'windows' if $o =~ /mingw|msys|cygwin|windows/;
    $o = 'darwin'  if $o =~ /darwin/;
    $o = 'linux'   if $o =~ /linux/;

    my $m = lc $machine;
    my $a = $m =~ /^(x86_64|amd64)$/          ? 'amd64'
          : $m =~ /^(aarch64|arm64)$/         ? 'arm64'
          : $m =~ /^(i[3-6]86|x86)$/          ? '386'
          : $m =~ /^armv?[5-8]/               ? 'arm'
          : $m =~ /^(ppc64le|riscv64|s390x)$/ ? $m
          :                                     $m;

    return ($spec->{os} // $o, $spec->{arch} // $a);
}

sub in_path {
    my $dir = shift;
    return grep { $_ eq $dir } split /:/, ($ENV{PATH} // '');
}

sub norm_ver { my $v = shift // ''; $v =~ s/^v//i; $v =~ s/^\s+|\s+$//g; return $v }

# Run a program and capture stdout+stderr, with a timeout and no stdin.
sub capture {
    my ($timeout, $prog, @args) = @_;
    my $pid = open my $fh, '-|';
    return '' unless defined $pid;
    unless ($pid) {
        open STDIN,  '<', File::Spec->devnull;
        open STDERR, '>&', \*STDOUT;
        exec $prog, @args;
        exit 127;
    }
    my $out = '';
    local $SIG{ALRM} = sub { kill 'TERM', $pid };
    alarm $timeout;
    { local $/; $out = <$fh> // '' }
    alarm 0;
    close $fh;
    return $out;
}

# Legacy fallback: ask the binary for its version (used when no state exists).
sub installed_version {
    my ($path, $spec) = @_;
    return '' unless -x $path;
    my $flag = $spec->{'version-flag'} // $opt{'version-flag'};
    my $out  = capture(10, $path, $flag);
    my ($v)  = $out =~ /(\d+\.\d+(?:\.\d+)*(?:[-+][\w.]+)?)/;
    return norm_ver($v);
}

sub list_releases {
    my $rels = shift;
    for my $r (@$rels) {
        printf "%-12s %s%s\n", $r->{tag_name}, substr($r->{published_at} // '', 0, 10),
          $r->{prerelease} ? '  [prerelease]' : '';
        printf "    %-32s %s\n", $_->{name}, human_size($_->{size})
          for @{ $r->{assets} || [] };
    }
}

sub human_size {
    my $n = shift // 0;
    return sprintf '%.1f MB', $n / 1024 / 1024 if $n >= 1024 * 1024;
    return sprintf '%.1f kB', $n / 1024        if $n >= 1024;
    return "$n B";
}

sub which {
    my $cmd = shift;
    for my $dir (split /:/, ($ENV{PATH} // '')) {
        my $p = File::Spec->catfile($dir, $cmd);
        return $p if -x $p && !-d $p;
    }
    return;
}

sub read_json {
    my $path = shift;
    return undef unless -f $path;
    my $body = slurp($path);
    return eval { JSON::PP->new->utf8->decode($body) };
}

sub write_json {
    my ($path, $data) = @_;
    my $tmp = "$path.$$";
    push @TMPFILES, $tmp;
    open my $fh, '>', $tmp or die "Cannot write $tmp: $!\n";
    print {$fh} JSON::PP->new->utf8->canonical->pretty->encode($data);
    close $fh or die "Cannot write $tmp: $!\n";
    rename $tmp, $path or die "Cannot update $path: $!\n";
    @TMPFILES = grep { $_ ne $tmp } @TMPFILES;
}

sub usage {
    my $rc = shift;
    print <<"USAGE";
upd - download the matching binary from the latest Gitea/GitHub release

  upd [OPTIONS] REPO-URL
  upd --all [OPTIONS]

  REPO-URL    e.g. https://git.fhi.mpg.de/mike/mgsh
                   https://github.com/sxyazi/yazi
              (or use --repo / \$UPD_REPO)

Options:
  --install PATH, -i  target directory (created if missing), e.g. ~/bin.
                      A path whose last segment is one of the binary names,
                      or an existing file, means that exact file.
                      Also \$UPD_INSTALL. Default: the directory of a
                      same-named binary in \$PATH, else ~/.local/bin
  --name A[,B...]     binary name(s) to take out of one asset, e.g.
                      "yazi,ya" installs both. Use "SRC:DST" to install
                      under a different name (default: repository name)
  --all               update every entry of the config file
  --config PATH       config file (default: ~/.config/upd/tools)
  --check             only report whether an update is available
  --tag VERSION       install a specific release ("v1.2.3" or "1.2.3")
  --pre               consider prereleases as well
  --asset NAME        exact asset name instead of auto-detection
  --pattern REGEX     select the asset by regex
  --os OS             darwin|linux|windows|freebsd (default: detected)
  --arch ARCH         amd64|arm64|386|arm (default: detected)
  --forge NAME        github|gitea (default: github for github.com hosts,
                      gitea otherwise; needed for GitHub Enterprise)
  --token TOKEN       API token for private repositories. Also read from
                      \$UPD_TOKEN, then \$GITHUB_TOKEN/\$GH_TOKEN (GitHub)
                      or \$GITEA_TOKEN (Gitea)
  --version-flag F    flag to query the version of a binary installed
                      without upd (default: --version)
  --force             install even if it is already up to date
  --list              show releases and their assets
  --dry-run           show what would happen, write nothing
  --verbose           show asset scoring and cache decisions
  --quiet             print errors only
  --timeout SEC       network timeout (default: 30)
  --help              this help

Config file (one line per tool, "#" comments):
  https://git.fhi.mpg.de/mike/mgsh  install=~/bin
  https://github.com/sxyazi/yazi    install=~/bin  name=yazi,ya
  Keys: @{[ join ', ', @SPEC_KEYS ]}

State is kept in @{[ state_dir() ]}
Exit codes: 0 ok, 1 usage, 2 error, 10 update available (--check)
USAGE
    exit $rc;
}

1;
