#!/bin/sh -e
#
# This is a simple package manager written in POSIX 'sh' for
# KISS Linux utlizing the core unix utilites where needed.
#
# The script runs with 'set -e' enabled. It will exit on any
# non-zero return code. This ensures that no function continues
# if it fails at any point.
#
# Keep in mind that this involves extra code in the case where
# an error is optional or required.
#
# Where possible the package manager should "error first".
# Check things first, die is necessary and continue if all is well.
#
# The code below conforms to shellcheck's rules. However, some
# lint errors *are* disabled as they relate to unexpected
# behavior (which we do expect).
#
# KISS is available under the MIT license.
#
# - Dylan Araps.

die() {
    # Print a message and exit with '1' (error).
    printf '\033[31m!>\033[m %s\n' "$@" >&2
    exit 1
}

log() {
    # Print a message with a colorful arrow to distinguish
    # from other output.
    printf '\033[32m=>\033[m %s\n' "$@"
}

pkg_lint() {
    # Check that each mandatory file in the package entry exists.
    log "[$1]: Checking repository files..."

    pkg_location=$(pkg_search "$1")

    cd "$pkg_location" || die "'$pkg_location' not accessible"

    [ -f sources ]  || die "Sources file not found."
    [ -x build ]    || die "Build file not found or not executable."
    [ -s licenses ] || die "License file not found or empty."
    [ -s version ]  || die "Version file not found or empty."

    # Ensure that the release field in the version file is set
    # to something.
    read -r _ rel < version
    [ "$rel" ] || die "Release field not found in version file."

    # Unset this variable so it isn't used again on a failed
    # source. There's no 'local' keyword in POSIX sh.
    rel=
}

pkg_search() {
    # Figure out which repository a package belongs to by
    # searching for directories matching the package name
    # in $KISS_PATH/*.
    [ "$KISS_PATH" ] || \
        die "\$KISS_PATH needs to be set." \
            "Example: KISS_PATH=/packages/core:/packages/extra:/packages/xorg" \
            "Repositories will be searched in the configured order." \
            "The variable should work just like \$PATH."

    # Disable globbing with 'set -f' to ensure that the unquoted
    # variable doesn't expand into anything nasty.
    # shellcheck disable=2086,2046
    {
        set -f
        set -- "$1" $(IFS=:; find $KISS_PATH -maxdepth 1 -name "$1")
        set +f
    }

    # A package may also not be found due to a repository not being
    # readable by the current user. Either way, we need to die here.
    [ -z "$2" ] && die "Package '$1' not in any repository."

    printf '%s\n' "$2"
}

pkg_list() {
    # List installed packages. As the format is files and
    # diectories, this just involves a simple for loop and
    # file read.

    # Optional arguments can be passed to check for specific
    # packages. If no arguments are passed, list all. As we
    # loop over '$@', if there aren't any arguments we can
    # just set the directory contents to the argument list.
    [ "$1" ] || set -- "$KISS_ROOT/var/db/$kiss/"*

    # If the 'glob' above failed, exit early as there are no
    # packages installed.
    [ "$1" = "$KISS_ROOT/var/db/$kiss/"\* ] && return

    # Loop over each version file and warn if one doesn't exist.
    # Also warn if a package is missing its version file.
    for pkg; do
        [ -d "$pkg" ] || {
            log "Package '$pkg' is not installed."
            return 1
        }

        [ -f "$pkg/version" ] || {
            log "Warning: Package '$pkg' has no version file."
            return
        }

        read -r version release < "$pkg/version" &&
            printf '%s\n' "${pkg%/*} $version-$release"
    done
}

pkg_sources() {
    # Download any remote package sources. The existence of local
    # files is also checked.
    log "[$1]: Downloading sources..."

    # Store each downloaded source in named after the package it
    # belongs to. This avoid conflicts between two packages having a
    # source of the same name.
    mkdir -p "$src_dir/$1" && cd "$src_dir/$1"

    # Find the package's repository files. This needs to keep
    # happening as we can't store this data in any kind of data
    # structure.
    repo_dir=$(pkg_search "$1")

    while read -r src _; do
        case $src in
            # Git repository.
            git:*)
                git clone "${src##git:}" "$mak_dir"
            ;;

            # Remote source.
            *://*)
                [ -f "${src##*/}" ] && {
                    log "[$1]: Found cached source '${src##*/}'."
                    continue
                }

                wget "$src" || {
                    rm -f "${src##*/}"
                    die "[$1]: Failed to download $src."
                }
            ;;

            # Local files (Any source that is non-remote is assumed to be local).
            *)
                [ -f "$repo_dir/$src" ] ||
                    die "[$1]: No local file '$src'."

                log "[$1]: Found local file '$src'."
            ;;
        esac
    done < "$repo_dir/sources"
}

pkg_extract() {
    # Extract all source archives to the build diectory and copy over
    # any local repository files.
    log "[$1]: Extracting sources..."

    # Store each downloaded source in named after the package it
    # belongs to. This avoid conflicts between two packages having a
    # source of the same name.
    mkdir -p "$mak_dir/$1" && cd "$mak_dir/$1"

    # Find the package's repository files. This needs to keep
    # happening as we can't store this data in any kind of data
    # structure.
    repo_dir=$(pkg_search "$1")

    while read -r src dest; do
        mkdir -p "./$dest"

        case $src in
            # Do nothing as git repository was downloaded to the build
            # diectory directly.
            git:*) ;;

            # Only 'tar' archives are currently supported for extaction.
            # Any other filetypes are simply copied to '$mak_dir' which
            # allows you to extract them manually.
            *://*.tar*|*://*.tgz)
                tar xf "$src_dir/$1/${src##*/}" -C "./$dest" \
                    --strip-components 1 \
                || die "[$1]: Couldn't extract ${src##*/}."
            ;;

            # Local files (Any source that is non-remote is assumed to be local).
            *)
                [ -f "$repo_dir/$src" ] || die "[$1]: Local file $src not found."
                cp -f "$repo_dir/$src" "./$dest"
            ;;
        esac
    done < "$repo_dir/sources"
}

pkg_depends() {
    # Resolve all dependencies and install them in the right order.

    # Find the package's repository files. This needs to keep
    # happening as we can't store this data in any kind of data
    # structure.
    repo_dir=$(pkg_search "$1")

    # This does a depth-first search. The deepest dependencies are
    # listed first and then the parents in reverse order.
    if pkg_list "$1" >/dev/null; then
        # If a package is already installed but 'pkg_depends' was
        # given an argument, add it to the list anyway.
        [ "$2" ] && missing_deps="$missing_deps $1 "
    else
        case $missing_deps in
            # Dependency is already in list, skip it.
            *" $1 "*) ;;

            *)
                # Recurse through the dependencies of the child
                # packages. Keep doing this.
                [ -f "$repo_dir/depends" ] &&
                    while read -r dep _; do
                        pkg_depends "$dep" ||:
                    done < "$repo_dir/depends"

                # After child dependencies are added to the list,
                # add the package which depends on them.
                missing_deps="$missing_deps $1 "
            ;;
        esac
    fi
}

pkg_verify() {
    # Verify all package checksums. This is achieved by generating
    # a new set of checksums and then comparing those with the old
    # set.

    # Find the package's repository files. This needs to keep
    # happening as we can't store this data in any kind of data
    # structure.
    repo_dir=$(pkg_search "$1")

    # Generate a second set of checksums to compare against the
    # repositorie's checksums for the package.
    pkg_checksums .checksums "$1"

    # Compare the checksums using 'cmp'.
    cmp -s "$repo_dir/.checksums" "$repo_dir/checksums" || {
        log "[$1]: Checksum mismatch."

        # Instead of dying above, log it to the terminal. Also define a
        # variable so we *can* die after all checksum files have been
        # checked.
        mismatch="$mismatch$1 "
    }

    # The second set of checksums use a temporary file, we need to
    # delete it.
    rm -f "$repo_dir/.checksums"
}

pkg_strip() {
    # Strip package binaries and libraries. This saves space on the
    # system as well as on the tarballs we ship for installation.

    # Find the package's repository files. This needs to keep
    # happening as we can't store this data in any kind of data
    # structure.
    repo_dir=$(pkg_search "$1")

    # Package has stripping disabled, stop here.
    [ -f "$repo_dir/nostrip" ] && return

    log "[$1]: Stripping binaries and libraries..."

    find "$pkg_dir/$1" -type f | while read -r binary; do
        case $(file -bi "$binary") in
            application/x-sharedlib*|application/x-pie-executable*)
                strip_opts=--strip-unneeded
            ;;

            application/x-archive*)    strip_opts=--strip-debug ;;
            application/x-executable*) strip_opts=--strip-all ;;

            *) continue ;;
        esac

        # Suppress errors here as some binaries and libraries may
        # fail to strip. This is OK.
        strip "$strip_opts" "$binary" 2>/dev/null ||:
    done
}

pkg_manifest() (
    # Generate the package's manifest file. This is a list of each file
    # and directory inside the package. The file is used when uninstalling
    # packages, checking for package conflicts and for general debugging.
    #
    # This funcion runs as a subshell to avoid having to 'cd' back to the
    # prior directory before being able to continue.
    cd "$pkg_dir/$1"

    # Find all files and directories in the package. Directories are printed
    # with a trailing forward slash '/'. The list is then reversed with
    # directories appearing *after* their contents.
    find . -type d -exec printf '%s/\n' {} + -or -print |
    sort -r | sed -e ss.ss > "$pkg_dir/$1/var/db/$kiss/$1/manifest"

    log "[$1]: Generated manifest."
)

pkg_tar() {
    # Create a tarball from the built package's files.
    # This tarball also contains the package's database entry.

    # Find the package's repository files. This needs to keep
    # happening as we can't store this data in any kind of data
    # structure.
    repo_dir=$(pkg_search "$1")

    # Read the version information to name the package.
    read -r version release < "$repo_dir/version"

    # Create a tarball from the contents of the built package.
    tar zpcf "$bin_dir/$1-$version-$release.tar.gz" -C "$pkg_dir/$1" . ||
        die "[$1]: Failed to create tarball."

    log "[$1]: Successfully created tarball."
}

pkg_build() {
    # Build packages and turn them into packaged tarballs. This function
    # also checks checksums, downloads sources and ensure all dependencies
    # are installed.

    # Resolve dependencies and generate a list.
    # Send 'force' to 'pkg_depends' to always include the explicitly
    # requested packages.
    log "Resolving dependencies..."
    for pkg; do pkg_depends "$pkg" force; done

    # Disable globbing with 'set -f' to ensure that the unquoted
    # variable doesn't expand into anything nasty.
    # shellcheck disable=2086,2046
    {
        # Set the resolved dependency list as the function's arguments.
        set -f
        set -- $missing_deps
        set +f
    }
    log "Installing: $*."

    for pkg; do pkg_lint "$pkg"; done
    for pkg; do
        # Find the package's repository files. This needs to keep
        # happening as we can't store this data in any kind of data
        # structure.
        repo_dir=$(pkg_search "$pkg")

        # Ensure that checksums exist prior to building the package.
        [ -f "$repo_dir/checksums" ] || {
            log "[$pkg]: Checksums are missing."

            # Instead of dying above, log it to the terminal. Also define a
            # variable so we *can* die after all checksum files have been
            # checked.
            no_checkums="$no_checkums$pkg "
        }
    done

    # Die here as packages without checksums were found above.
    [ "$no_checkums" ] &&
        die "Run '$kiss checksum ${no_checkums% }' to generate checksums."

    for pkg; do pkg_sources "$pkg"; done
    for pkg; do pkg_verify  "$pkg"; done

    # Die here as packages with differing checksums were found above.
    [ "$mismatch" ] && die "Checksum mismatch with: ${mismatch% }"

    log "Verified all checksums."

    log "Extracting all sources..."
    for pkg; do pkg_extract "$pkg"; done
    log "Extracted all sources."

    log "Building packages..."
    for pkg; do
        # Find the package's repository files. This needs to keep
        # happening as we can't store this data in any kind of data
        # structure.
        repo_dir=$(pkg_search "$pkg")

        # Install built packages to a directory under the package name
        # to avod collisions with other packages.
        mkdir -p "$pkg_dir/$pkg/var/db/$kiss"

        # Move to the build directory and call the build script.
        (cd "$mak_dir/$pkg"; "$repo_dir/build" "$pkg_dir/$pkg") ||
            die "[$pkg]: Build failed."

        # Copy the repository files to the package directory.
        # This acts as the database entry.
        cp -Rf "$repo_dir" "$pkg_dir/$pkg/var/db/$kiss/"

        log "[$pkg]: Sucessfully built package."

        # Create the manifest file early and make it empty.
        # This ensure that the manifest is added to the manifest...
        : > "$pkg_dir/$pkg/var/db/$kiss/$pkg/manifest"
    done

    log "Stripping packages..."
    for pkg; do pkg_strip "$pkg"; done
    log "Stripped all binaries and libraries."

    log "Generating package manifests..."
    for pkg; do pkg_manifest "$pkg"; done
    log "Generated all manifests."

    log "Creating package tarballs..."
    for pkg; do pkg_tar "$pkg"; done
    log "Created all packages."
}

pkg_checksums() {
    # Generate checksums for packages.
    # This also downloads any remote sources.
    checksum_file=$1
    shift

    for pkg; do
        # Find the package's repository files. This needs to keep
        # happening as we can't store this data in any kind of data
        # structure.
        repo_dir=$(pkg_search "$pkg")

        while read -r src _; do
            case $src in
                # Git repository.
                # Skip checksums on git repositories.
                git:*) ;;

                *)
                    # File is local to the package and is stored in the
                    # repository.
                    [ -f "$repo_dir/$src" ] &&
                        src_path=$repo_dir/${src%/*}

                    # File is remote and was downloaded.
                    [ -f "$src_dir/$pkg/${src##*/}" ] &&
                        src_path=$src_dir/$pkg

                    # Die here if source for some reason, doesn't exist.
                    [ "$src_path" ] ||
                        die "[$pkg]: Couldn't find source '$src'."

                    # An easy way to get 'sha256sum' to print with the basenames
                    # of files is to 'cd' to the file's directory beforehand.
                    (cd "$src_path" && sha256sum "${src##*/}") ||
                        die "[$pkg]: Failed to generate checksums."

                    # Unset this variable so it isn't used again on a failed
                    # source. There's no 'local' keyword in POSIX sh.
                    src_path=
                ;;
            esac
        done < "$repo_dir/sources" > "$repo_dir/$checksum_file"

        log "[$pkg]: Generated/Verified checksums."
    done
}

setup_caching() {
    # Setup the host machine for the package manager. Create any
    # directories which need to exist and set variables for easy
    # access to them.

    # Main cache directory (~/.cache/kiss/) typically.
    mkdir -p "${cac_dir:=${XDG_CACHE_HOME:=$HOME/.cache}/$kiss}" ||
        die "Couldn't create cache directory ($cac_dir)."

    # Build directory.
    mkdir -p "${mak_dir:=$cac_dir/build-$pid}" ||
        die "Couldn't create build directory ($mak_dir)."

    # Package directory.
    mkdir -p "${pkg_dir:=$cac_dir/pkg-$pid}" ||
        die "Couldn't create package directory ($pkg_dir)."

    # Tar directory.
    mkdir -p "${tar_dir:=$cac_dir/extract-$pid}" ||
        die "Couldn't create tar directory ($tar_dir)."

    # Source directory.
    mkdir -p "${src_dir:=$cac_dir/sources}" ||
        die "Couldn't create source directory ($src_dir)."

    # Binary directory.
    mkdir -p "${bin_dir:=$cac_dir/bin}" ||
        die "Couldn't create binary directory ($bin_dir)."
}

pkg_clean() {
    # Clean up on exit or error. This removes everything related
    # to the build.

    # Remove temporary directories.
    rm -rf -- "$mak_dir" "$pkg_dir" "$tar_dir"
}

root_check() {
    # Ensure that the user has write permissions to '$KISS_ROOT'.
    # When this variable is empty, a value of '/' is assumed.
    [ -w "$KISS_ROOT/" ] || \
        die "No write permissions to '${KISS_ROOT:-/}'." \
            "You may need to run '$kiss' as root."
}

args() {
    # Parse script arguments manually. POSIX 'sh' has no 'getopts'
    # or equivalent built in. This is rather easy to do in our case
    # since the first argument is always an "action" and the arguments
    # that follow are all package names.

    # Actions can be abbreviated to their first letter. This saves
    # keystrokes once you memorize themand it also has the side-effect
    # of "correcting" spelling mistakes assuming the first letter is
    # right.
    case $1 in
        # Build the list of packages.
        b*)
            shift
            [ "$1" ] || die "'kiss build' requires an argument."
            pkg_build "$@"
        ;;

        # Generate checksums for packages.
        c*)
            shift
            [ "$1" ] || die "'kiss checksum' requires an argument."

            for pkg; do pkg_lint    "$pkg"; done
            for pkg; do pkg_sources "$pkg"; done

            pkg_checksums checksums "$@"
        ;;

        # Install packages.
        i*)
            shift
            [ "$1" ] || die "'kiss install' requires an argument."
            root_check
        ;;

        # Remove packages.
        r*)
            shift
            [ "$1" ] || die "'kiss remove' requires an argument."
            root_check
        ;;

        # List installed packages.
        l*)
            shift
            pkg_list "$@"
        ;;

        # Print version and exit.
        v*)
            log "$kiss 0.1.10"
        ;;

        # Catch all invalid arguments as well as
        # any help related flags (-h, --help, help).
        *)
            log "$kiss [b|c|i|l|r|u] [pkg]" \
                "build:     Build a package." \
                "checksum:  Generate checksums." \
                "install:   Install a package (Runs build if needed)." \
                "list:      List packages." \
                "remove:    Remove a package." \
                "update:    Check for updates."
        ;;
    esac
}

main() {
    # Store the script name in a variable and use it everywhere
    # in place of 'kiss'. This allows the script name to be changed
    # easily.
    kiss=${0##*/}

    # The PID of the current shell process is used to isolate directories
    # to each specific KISS instance. This allows multiple package manager
    # instances to be run at once. Store the value in another variable so
    # that it doesn't change beneath us.
    pid=$$

    # Catch errors and ensure that build files and directories are cleaned
    # up before we die. This occurs on 'Ctrl+C' as well as sucess and error.
    trap pkg_clean EXIT INT

    setup_caching
    args "$@"
}

main "$@"