#!/bin/sh

set -eu

# Cleanup handler, runs on any exit (normal or signal).
cleanup() {
    # The idiom `test -n "${stagedir+x}"` means:
    #   - If `$stagedir` is unset, then `${stagedir+x}` expands to ''
    #     and `test -n` is false.
    #   - If `$stagedir` is set, then `${stagedir+x}` expands to `'x'`
    #     and `test -n` is true.
    # Thus the combination of `-n` and `+x` is a portable test for
    # "is stagedir set at all?" (regardless of its value).
    if test -n "${stagedir+x}"; then
        rm -rf "$stagedir"
    fi
}
trap cleanup EXIT INT TERM HUP

# Error helper. See EXIT STATUS below.
die() {
    printf "fpkginstall: %b\n" "$*" >&2
    exit 1
}

## NAME
##     fpkginstall - install a package tarball into a target directory
##
## SYNOPSIS
##     fpkginstall [--skip-installed-check]
##                 [--skip-paths-check]
##                 [--skip-overwrites-check]
##                 [--skip-dependencies-check]
##                 [--skip-all-checks]
##                 [-v|--verbose]
##                 PACKAGE TARGET
##

usage() {
    die "Usage: fpkginstall [--skip-installed-check] [--skip-paths-check] [--skip-overwrites-check] [--skip-dependencies-check] [--skip-all-checks] [-v|--verbose] PACKAGE TARGET"
}

## DESCRIPTION
##     Blindly extracting a PACKAGE tarball into a TARGET directory with
##     `tar -xf` is easy, but careless. It risks overwriting existing files,
##     exposing path traversal exploits, creating untracked files, or leaving
##     the system littered and inconsistent.
##
##     fpkginstall installs a package into a TARGET directory. Installation is
##     more than extraction: it enforces safety checks and records metadata
##     so the package can be managed later. The process has three stages:
##
##     1. Perform pre-installation safety checks any responsible
##        system administrator would carry out if installing manually.
##        For example, verify that dependencies are installed, the target
##        directory is writable, and no files will be overwritten.
##
##     2. Extract the PACKAGE tarball into the TARGET directory.
##
##     3. Record post-installation metadata about the package in
##        the TARGET/fpkg/ directory.
##
## OPTIONS
##     --skip-installed-check
##         Skip the pre-installation check that aborts if the package
##         is already installed. Proceeds even if a previous version
##         exists.
##
##     --skip-paths-check
##         Skip manifest path safety validation (absolute paths,
##         traversal, whitespace, etc.). Unsafe paths may escape
##         TARGET or create unpredictable files.
##
##     --skip-overwrites-check
##         Skip file conflict checks. Allows overwriting existing files
##         outside fpkg's control.
##
##     --skip-dependencies-check
##         Skip dependency validation. Ignores missing or unsatisfied
##         dependencies.
##
##     --skip-all-checks
##         Skip all of the above pre-installation checks. Equivalent to
##         specifying each --skip-*-check flag. Extremely dangerous.
##         Not recommended. You have been warned.
##
##     -v, --verbose
##         Print additional progress messages to stderr. Useful for tracing
##         what fpkginstall is doing internally (paths, staging directories,
##         metadata moves). By default fpkginstall is silent except for errors.
##

skip_installed=0
skip_paths=0
skip_overwrites=0
skip_dependencies=0
verbose=0

while test $# -gt 0; do
    case "$1" in
        --skip-installed-check)
            skip_installed=1
            shift
            ;;
        --skip-paths-check)
            skip_paths=1
            shift
            ;;
        --skip-overwrites-check)
            skip_overwrites=1
            shift
            ;;
        --skip-dependencies-check)
            skip_dependencies=1
            shift
            ;;
        --skip-all-checks)
            skip_installed=1
            skip_paths=1
            skip_overwrites=1
            skip_dependencies=1
            shift
            ;;
        -v|--verbose)
            verbose=1
            shift
            ;;
        -*)
            usage
            ;;
        *)
            break
            ;;
    esac
done

## OPERANDS
##     fpkginstall requires two operands.
##

if test $# -ne 2; then
    usage
fi

##     PACKAGE
##         Path to a package tarball of the form:
##
##             path/to/NAME-VERSION.txz
##
##         NAME is the package name (may contain hyphens).
##
##         VERSION is semantic versioning in the form M.m.p
##         (major.minor.patch, three dot-separated integers).
##
##         Examples:
##             hello-world-1.2.3.txz
##             ~/hello-world-1.2.3.txz
##             /opt/packages/hello-world-1.2.3.txz
##

package=$1

if test ! -f "$package"; then
    die "package not found: $package"
fi
if test ! -r "$package"; then
    die "package not readable: $package"
fi

# parse NAME-VERSION.txz
pkg_base=$(basename "$package")
case $pkg_base in
    *-*.txz) ;;
    *) usage ;;
esac
pkg_base=${pkg_base%.txz}
version=${pkg_base##*-}
name=${pkg_base%-"$version"}
unset pkg_base

# valid_name NAME
# --------------
# Validate that NAME is a valid package name.
# Arguments:
#     NAME package name string to validate
# Output:
#     None
# Returns:
#     0 if valid
#     1 if invalid
# Rules:
#   - Cannot be empty
#   - Cannot start with a hyphen
#   - Cannot end with a hyphen
#   - Cannot contain consecutive hyphens
#   - Only ASCII letters, digits, and hyphens
valid_name() {
    case $1 in
        '') return 1 ;;              # empty
        -*) return 1 ;;              # leading dash
        *-) return 1 ;;              # trailing dash
        *--*) return 1 ;;            # consecutive dashes
        *[!A-Za-z0-9-]*) return 1 ;; # illegal character
    esac
    return 0
}

if ! valid_name "$name"; then
    die "invalid package name: $name"
fi

# valid_version VERSION
# ---------------------
# Validate that VERSION is a valid semver version string.
# Arguments:
#     VERSION package version string to validate
# Output:
#     None
# Returns:
#     0 if valid
#     1 if invalid
# Rules:
#     - Exactly three segments
#     - Each segment must be digits only
#     - "0" is valid
#     - Leading zeros are not allowed (e.g. "01")
#     - Empty segments are not allowed
# Examples, valid:
#     1.2.3
#     0.0.0
# Examples, invalid :
#     1.2          too few segments
#     1.2.3.4      too many segments
#     01.2.3       leading zero
#     1..3         empty segment
#     1.2a.3       non-digit
valid_version() {

    if test -z "$1"; then
        return 1
    fi

    # Split version by dot into segments.
    OLDIFS=$IFS
    IFS=.
    set -- $1
    IFS=$OLDIFS

    # There must be exactly three segments.
    if test $# -ne 3; then
        return 1
    fi

    # Each segment must be digits.
    while test $# -gt 0; do
        case $1 in
            '') return 1 ;;       # empty
            *[!0-9]*) return 1 ;; # at least one non-digit
            0) ;;                 # plain "0" ok
            0[0-9]*) return 1 ;;  # leading zero
        esac
        shift
    done

    return 0
}

if ! valid_version "$version"; then
    die "invalid version: $version"
fi

##     TARGET
##         Directory where the package will be installed.
##
##         Examples:
##             .
##             ~/sites/example.com
##             /opt/example.com
##

target=$2

if test ! -d "$target"; then
    die "target not a directory"
fi
if test ! -w "$target"; then
    die "target not writable"
fi

# Debugging
if test "$verbose" -eq 1; then
    echo "script: $0" >&2

    flags=
    if test "$skip_installed" -eq 1; then
        flags="$flags --skip-installed-check"
    fi
    if test "$skip_paths" -eq 1; then
        flags="$flags --skip-paths-check"
    fi
    if test "$skip_overwrites" -eq 1; then
        flags="$flags --skip-overwrites-check"
    fi
    if test "$skip_dependencies" -eq 1; then
        flags="$flags --skip-dependencies-check"
    fi
    if test "$verbose" -eq 1; then
        flags="$flags --verbose"
    fi

    echo "flags:$flags" >&2
    unset flags

    echo "package: $package" >&2
    echo "target: $target" >&2
fi

## REQUIREMENTS
##     fpkginstall is written in POSIX shell. It depends on standard POSIX
##     utilities for its operation, with the addition of the following
##     extensions that are widely available on modern systems:
##
##         * mktemp
##             Not specified by POSIX, but widely available on modern systems
##             (GNU, BusyBox, BSD, macOS). Must support -d to create
##             a directory.
##

if ! command -v mktemp >/dev/null 2>&1; then
    die "mktemp not found"
fi

if ! temp=$(mktemp -d /tmp/XXXXXXXXXX 2>/dev/null); then
    die "mktemp does not support -d for directory creation"
fi

##         * tar
##             Not specified by POSIX, but widely available on modern systems
##             (GNU, BusyBox, BSD, macOS). Must support -J for xz compression.
##             Directory entries in the archive, and as listed by `tar -tf`,
##             must end with '/'. This requirement depends on the local tar
##             implementation, regardless of how the archive was
##             originally created.
##

if ! mkdir "$temp/dir"; then
    rm -rf "$temp"
    die "failed to create $temp/dir"
fi

if ! tar -cJf "$temp/test.txz" -C "$temp" dir >/dev/null 2>&1; then
    rm -rf "$temp"
    die "tar does not support -J for xz compression"
fi

if ! tar -tf "$temp/test.txz" | grep -q 'dir/$'; then
    rm -rf "$temp"
    die "tar -tf does not output directories with trailing '/'"
fi

rm -rf "$temp"
unset temp

## EXIT STATUS
##     0   Success. The package was installed cleanly.
##     1   Failure. Something broke. Check stderr, fix it, try again.
##

## PRE-INSTALLATION
##     Before installing the package, fpkginstall validates the TARGET
##     environment and checks for conflicts.
##
##     If the package is already installed, then exit. Skip with
##     --skip-installed-check or --skip-all-checks.
##

if test $skip_installed -eq 0; then
    if test -e "$target/fpkg/$name"; then
        die "$name already installed in $target"
    fi
    if test "$verbose" -eq 1; then
        echo "Package not installed. Proceeding with fresh installation." >&2
    fi
else
    if test "$verbose" -eq 1; then
        echo "Skip installed check: proceeding even if package exists." >&2
    fi
fi

##     The metadata directory TARGET/fpkg must exist as a directory and
##     be usable. If it does not exist, fpkginstall will create it. If it
##     exists but is not a directory, or is not readable/writable, the
##     installation aborts.
##

if test -e "$target/fpkg" && test ! -d "$target/fpkg"; then
    die "$target/fpkg is not a directory"
fi
if ! mkdir -p "$target/fpkg"; then
    die "failed to create $target/fpkg"
fi
if test ! -r "$target/fpkg"; then
    die "$target/fpkg not readable"
fi
if test ! -w "$target/fpkg"; then
    die "$target/fpkg not writable"
fi

##     fpkginstall uses a staging directory inside TARGET/fpkg to prepare
##     metadata before committing it. This directory is created with
##     mktemp -d and includes a timestamp and pid for uniqueness. The
##     directory must exist, be a directory, and be readable/writable.
##

if ! stagedir=$(mktemp -d "$target/fpkg/$(date +%Y%m%d%H%M%S)~$$~XXXXXXXXXX"); then
    die "mktemp failed"
fi
if test -z "$stagedir"; then
    die "mktemp returned empty stagedir"
fi
if test ! -e "$stagedir"; then
    die "staging dir does not exist: $stagedir"
fi
if test ! -d "$stagedir"; then
    die "staging dir not a directory: $stagedir"
fi
if test ! -r "$stagedir"; then
    die "staging dir not readable: $stagedir"
fi
if test ! -w "$stagedir"; then
    die "staging dir not writable: $stagedir"
fi

##     fpkginstall generates a manifest of package contents before installing.
##     The manifest is a plain text file inside the staging directory
##     listing every path in the archive. This allows fpkginstall to validate
##     the package before any files are written to the TARGET.
##

manifest="$stagedir/MANIFEST"

if ! tar -tf "$package" >"$manifest"; then
    die "cannot list package contents"
fi

##     The generated manifest must be a regular file, non-empty, and
##     readable. If not, the installation aborts immediately.
##

if test ! -e "$manifest"; then
    die "manifest does not exist: $manifest"
fi
if test ! -f "$manifest"; then
    die "manifest not a regular file: $manifest"
fi
if test ! -r "$manifest"; then
    die "manifest not readable: $manifest"
fi
if ! test -s "$manifest"; then
    die "empty manifest"
fi

##     Each manifest entry must be a safe relative path. These rules
##     ensure packages cannot escape TARGET or create unpredictable files.
##     Skip with --skip-paths-check or --skip-all-checks.
##

if test $skip_paths -eq 0; then

    while IFS= read -r path; do
        case $path in

##         Rule: empty entries are rejected.
##         Justification: a blank line in MANIFEST is meaningless and unsafe.
##
            '') die "unsafe path (empty entry)" ;;

##         Rule: absolute paths are rejected.
##         Justification: a package must not write outside TARGET.
##
            /*) die "unsafe path (absolute): $path" ;;

##         Rule: traversal attempts (`..`) are rejected.
##         Justification: prevents escaping TARGET via parent directories.
##
            *../*|../*|*/..|..) die "unsafe path (traversal): $path" ;;

##         Rule: paths containing whitespace are rejected.
##         Justification: whitespace complicates parsing and is disallowed
##         for predictability.
##
            *[[:space:]]*) die "unsafe path (whitespace): $path" ;;

        esac
    done <"$manifest"

    if test "$verbose" -eq 1; then
        echo "Manifest paths are safe." >&2
    fi
else
    if test "$verbose" -eq 1; then
        echo "Skip manifest path checks: proceeding without validation." >&2
    fi
fi

##     Before installation, fpkginstall checks for file conflicts to prevent accidental
##     overwrites of files outside the package manager's control. Skip checks
##     with --skip-overwrites-check or --skip-all-checks.
##

if test $skip_overwrites -eq 0; then

    while IFS= read -r path; do
        case $path in

##     Directories may pre-exist safely if they are truly directories
##
            */)
                if test -e "$target/$path" && test ! -d "$target/$path"; then
                    die "file conflict: package wants directory, found non-directory: $path"
                fi
                ;;

##     Non-directories must not exist at all.
##
            *)
                if test -e "$target/$path"; then
                    die "file conflict: already exists: $path"
                fi
                ;;
        esac
    done <"$manifest"

    if test "$verbose" -eq 1; then
        echo "Manifest paths will not overwrite files during installation." >&2
    fi
else
    if test "$verbose" -eq 1; then
        echo "Skip overwrite checks: proceeding even if files exist." >&2
    fi
fi

# version_cmp A OP B
# ------------------
# Compare two versions using a relational operator.
# Arguments:
#     A   left-hand version (N.N.N)
#     OP  comparison operator (=, >, >=, <, <=)
#     B   right-hand version (N.N.N)
# Output:
#     None
# Returns:
#     0 if comparison is true
#     1 if comparison is false
#     2 if invalid operator or invalid version
# Rules:
#     - Versions must pass valid_version
#     - Operators must be one of =, >, >=, <, <=
# Examples, true (return 0):
#     1.2.3 = 1.2.3
#     1.2.3 > 1.2.2
#     1.2.3 >= 1.2.3
#     1.2.2 < 1.2.3
#     1.2.2 <= 1.2.3
# Examples, false (return 1):
#     1.2.3 = 1.2.2
#     1.2.3 < 1.2.3
#     1.2.3 <= 1.2.2
# Examples, invalid (return 2):
#     1.2 = 1.2.3       invalid version
#     1.2.3 <> 1.2.3    invalid operator
version_cmp() {
    a=$1 op=$2 b=$3

    if ! valid_version "$a"; then
        return 2
    fi
    if ! valid_version "$b"; then
        return 2
    fi

    # Split versions by dot into segments.
    OLDIFS=$IFS
    IFS=.
    set -- $a; a1=$1 a2=$2 a3=$3
    set -- $b; b1=$1 b2=$2 b3=$3
    IFS=$OLDIFS

    case $op in
        =)
            if test "$a1" -eq "$b1" && test "$a2" -eq "$b2" && test "$a3" -eq "$b3"; then
                return 0
            fi
            return 1
            ;;
        \>)
            if test "$a1" -gt "$b1"; then
                return 0
            elif test "$a1" -eq "$b1"; then
                if test "$a2" -gt "$b2"; then
                    return 0
                elif test "$a2" -eq "$b2" && test "$a3" -gt "$b3"; then
                    return 0
                fi
            fi
            return 1
            ;;
        \>=)
            if test "$a1" -gt "$b1"; then
                return 0
            elif test "$a1" -eq "$b1"; then
                if test "$a2" -gt "$b2"; then
                    return 0
                elif test "$a2" -eq "$b2" && test "$a3" -ge "$b3"; then
                    return 0
                fi
            fi
            return 1
            ;;
        \<)
            if test "$a1" -lt "$b1"; then
                return 0
            elif test "$a1" -eq "$b1"; then
                if test "$a2" -lt "$b2"; then
                    return 0
                elif test "$a2" -eq "$b2" && test "$a3" -lt "$b3"; then
                    return 0
                fi
            fi
            return 1
            ;;
        \<=)
            if test "$a1" -lt "$b1"; then
                return 0
            elif test "$a1" -eq "$b1"; then
                if test "$a2" -lt "$b2"; then
                    return 0
                elif test "$a2" -eq "$b2" && test "$a3" -le "$b3"; then
                    return 0
                fi
            fi
            return 1
            ;;
        *)
            return 2
            ;;
    esac
}

##     Before installation, fpkginstall ensures all declared dependencies are
##     already installed and satisfy their constraints. Otherwise, fpkginstall
##     aborts. Skip checks with --skip-dependencies-check or --skip-all-checks.
##
if test $skip_dependencies -eq 0; then

##     A package may declare dependencies in doc/NAME/DEPENDENCIES.
##
    depfile=
    if grep -q "^\(\./\)\?doc/$name/DEPENDENCIES$" "$manifest"; then
        depfile="doc/$name/DEPENDENCIES"
    fi

##     The DEPENDENCIES file is optional.
##
    if test -n "$depfile"; then

        # Extract the DEPENDENCIES file to staging
        depout="$stagedir/DEPENDENCIES"
        if ! tar -xOf "$package" "$depfile" >"$depout"; then
            die "failed to extract dependencies"
        fi
        # sanity checks on extracted DEPENDENCIES file
        if test ! -e "$depout"; then
            die "DEPENDENCIES file missing after extraction"
        fi
        if test ! -f "$depout"; then
            die "DEPENDENCIES not a regular file"
        fi
        if test ! -r "$depout"; then
            die "DEPENDENCIES not readable"
        fi

##     The DEPENDENCIES file is plain text, one dependency per line, optionally
##     with version constraints. Examples:
##
##         auth >=1.3.4 <2.0.0
##         storage =2.3.4
##         logger
##

        errors=
        while IFS= read -r dep; do

##     Blank lines are ignored. Comment lines beginning with '#' are ignored.
##
            case $dep in ''|\#*) continue ;; esac

            if test "$verbose" -eq 1; then
                echo "Checking dependency: $dep" >&2
            fi

            set -- $dep
            dep_pkg=$1
            shift

##     Each dependency must already be installed in TARGET.
##
            version_file="$target/fpkg/$dep_pkg/VERSION"
            if test ! -r "$version_file"; then
                error="    missing dependency: $dep_pkg"
                if test "$verbose" -eq 1; then
                    echo "$error" >&2
                fi
                errors="$errors\n$error"
                continue
            else
                if test "$verbose" -eq 1; then
                    echo "    dependency installed: $dep_pkg" >&2
                fi
            fi

            inst_ver=$(<"$version_file")

            if ! valid_version "$inst_ver"; then
                die "invalid version for installed dependency: $dep_pkg ($inst_ver)"
            else
                if test "$verbose" -eq 1; then
                    echo "    installed version of $dep_pkg: $inst_ver" >&2
                fi
            fi

##     If there are dependency version constraints, they are checked in order.
##
            while test $# -gt 0; do
                token=$1; shift

                if test "$verbose" -eq 1; then
                    echo "    checking token: $token" >&2
                fi

                case $token in

##     The supported dependency version constraint operators are >=, >, =, <, <=.
##
                    ">="*|">"*|"="*|"<"*|"<="*)
                        case $token in
                            ">="*) op=">="; ver=${token#>=} ;;
                            ">"*) op=">"; ver=${token#>} ;;
                            "<="*) op="<="; ver=${token#<=} ;;
                            "<"*) op="<"; ver=${token#<} ;;
                            "="*) op="="; ver=${token#=} ;;
                            *) die "unrecognized operator in $token" ;;
                        esac

                        if test "$verbose" -eq 1; then
                            echo "    checking version: $inst_ver $op $ver" >&2
                        fi

                        # Carefully capture $? without triggering `set -e` exit.
                        if version_cmp "$inst_ver" "$op" "$ver"; then
                            r=0
                        else
                            r=$?
                        fi
                        if test $r -ne 0; then
                            if test "$verbose" -eq 1; then
                                echo "    check failed" >&2
                            fi
                            case $r in
                                1) errors="$errors\n    unsatisfied: $dep_pkg $op $ver (have $inst_ver)" ;;
                                2) errors="$errors\n    invalid constraint: $dep_pkg $op $ver" ;;
                            esac
                        fi
                        ;;

                    *)
                        die "unrecognized dependency token: $token"
                        ;;

                esac
            done

        done <"$depout"

        if test -n "$errors"; then
            die "dependency errors:$errors"
        fi
    fi

    if test "$verbose" -eq 1; then
        echo "Dependencies are satisfied." >&2
    fi
else
    if test "$verbose" -eq 1; then
        echo "Skip dependency checks: proceeding without validation." >&2
    fi
fi

# ============================================================================

# Debugging
if test "$verbose" -eq 1; then
    echo "All pre-extraction checks passed." >&2
    echo "Extract the package!" >&2
fi

## FILES
##     After installation, the following files remain in TARGET:
##
##     TARGET/fpkg/NAME/VERSION
##         Contains the package version string.
##         Used by fpkginstall to check other packages' dependencies.
##
if ! echo "$version" >"$stagedir/VERSION"; then
    die "failed to write VERSION file"
fi

##     TARGET/fpkg/NAME/MANIFEST
##         Lists all paths extracted from the package.
##         Used by fpkgremove to delete the package's files.
##
##     TARGET/fpkg/NAME/DEPENDENCIES (optional)
##         Lists the package's declared dependencies, one per line.
##         Used by fpkginstall to verify requirements before installation,
##         and by fpkgremove to warn if removing this package would break
##         installed dependents.
##
##     The package's files are extracted into TARGET.
##
if ! tar -xJpf "$package" -C "$target"; then
    die "failed to extract package files"
fi

# Destination metadata directory.
metadir="$target/fpkg/$name"

# If forcing installation by skipping checks, then replace any existing
# metadata directory.
rm -rf "$metadir"

# Move staged metadata into place.
if ! mv "$stagedir" "$metadir"; then
    die "failed to commit package metadata"
fi

exit 0

## EXAMPLES
##     Install a package from the current directory:
##
##         fpkginstall notes-1.0.0.txz /opt/app
##
##     Install a package from a local repository into the current directory:
##
##         fpkginstall path/to/repo/notes-1.0.0.txz .
##
##     Download and install a package from a remote repository:
##
##         curl -O https://fpkg.org/downloads/packages/notes-1.0.0.txz
##         fpkginstall notes-1.0.0.txz /opt/app
##
##     Download and install a package from a private repository over SSH:
##
##         rsync -avz user@example.com:/srv/fpkg/packages/notes-1.0.0.txz .
##         fpkginstall notes-1.0.0.txz /opt/app
##
##     Skip all pre-installation checks:
##
##         fpkginstall --skip-all-checks notes-1.0.0.txz /opt/app
##
## SEE ALSO
##      fpkgremove, fpkglist
