#!/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.

    # Changing directories is similar to storing the full
    # full path in a variable, only there is no variable as
    # you can access children relatively.
    cd "$KISS_ROOT/var/db/kiss" || \
        die "KISS database doesn't exist or is inaccessible."

    # 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 -- *

    # 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.
    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 "*) ;;

            *)
                [ -f "$repo_dir/depends" ] &&
                    while read -r dep _; do
                        pkg_depends "$dep"
                    done < "$repo_dir/depends"

                missing_deps="$missing_deps $1 "
            ;;
        esac
    fi
}

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
        # 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")

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

        # Compare the checksums using 'cmp'.
        cmp -s "$repo_dir/.checksums" "$repo_dir/checksums" || {
            log "[$pkg]: 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$pkg "
        }

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

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

    log "Verified all checksums."

    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")

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

        # Copy the repository files to the package directory.
        # This acts as the database entry.
        cp -Rf "$repo_dir" "$pkg_db"

        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_db/$pkg/manifest"
    done
}

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_db:=${pkg_dir:=$cac_dir/pkg-$pid}/var/db/$kiss}" ||
        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"

    # Remove temporary checksum files.
    rm -rf -- "$cac_dir/checksums-"*
}

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 "$@"