#!/bin/sh
#
# step2-recover-options.sh
#
# For the ports listed in `recover.list`, restore the `options` files 
# corresponding to `/var/db/ports` to a temporary directory based on the 
# `OPTIONS` information from the installed pkgs.
#
# This script does not modify `/var/db/ports`.
#
# Input:
#   recover.list
#
# Main output:
#   /tmp/recovered-ports/<OPTIONS_NAME>/options
#
# Log files:
#   recovered.tsv
#   no-options.tsv
#   no-package-options.tsv
#   obsolete-options.tsv
#   new-options.tsv
#   conflicts.tsv
#   failed.tsv
#
# Example usage:
#   cd /root/ports-options-recovery
#   ./step2-recover-options.sh
#

set -eu

PORTSDIR=${PORTSDIR:-/usr/ports}
INPUT=${INPUT:-./recover.list}
OUTPUT_DBDIR=${OUTPUT_DBDIR:-/tmp/recovered-ports}
OUTDIR=${OUTDIR:-.}

RECOVERED_LOG="${OUTDIR}/recovered.tsv"
NO_OPTIONS_LOG="${OUTDIR}/no-options.tsv"
NO_PACKAGE_OPTIONS_LOG="${OUTDIR}/no-package-options.tsv"
OBSOLETE_OPTIONS_LOG="${OUTDIR}/obsolete-options.tsv"
NEW_OPTIONS_LOG="${OUTDIR}/new-options.tsv"
CONFLICTS_LOG="${OUTDIR}/conflicts.tsv"
FAILED_LOG="${OUTDIR}/failed.tsv"

TMPDIR_BASE=${TMPDIR:-/tmp}
WORKDIR=$(mktemp -d "${TMPDIR_BASE}/ports-options-step2.XXXXXX")

cleanup()
{
    rm -rf "${WORKDIR}"
}
trap cleanup EXIT HUP INT TERM

PKG_OPTIONS="${WORKDIR}/pkg-options.tsv"
ORIGIN_OPTIONS="${WORKDIR}/origin-options.tsv"
CURRENT_OPTIONS="${WORKDIR}/current-options"
DEFAULT_OPTIONS="${WORKDIR}/default-options"
PACKAGE_OPTIONS="${WORKDIR}/package-options"
PACKAGE_OPTION_VALUES="${WORKDIR}/package-option-values"
GENERATED_SET="${WORKDIR}/generated-set"
GENERATED_UNSET="${WORKDIR}/generated-unset"

die()
{
    echo "Error: $*" >&2
    exit 1
}

contains_word()
{
    wanted=$1
    shift

    for word
    do
        if [ "${word}" = "${wanted}" ]; then
            return 0
        fi
    done

    return 1
}

list_contains()
{
    wanted=$1
    file=$2

    grep -Fqx -- "${wanted}" "${file}"
}

sanitize_field()
{
    # Ensure that tabs and line breaks do not disrupt the structure of the log.
    printf '%s' "$1" | tr '\t\r\n' '   '
}

[ -r "${INPUT}" ] ||
    die "input file is not readable: ${INPUT}"

[ -d "${PORTSDIR}" ] ||
    die "ports tree was not found: ${PORTSDIR}"

command -v pkg >/dev/null 2>&1 ||
    die "pkg command was not found"

command -v make >/dev/null 2>&1 ||
    die "make command was not found"

if [ -e "${OUTPUT_DBDIR}" ]; then
    die "output directory already exists: ${OUTPUT_DBDIR}
Remove it manually or specify another OUTPUT_DBDIR."
fi

mkdir -p "${OUTPUT_DBDIR}"
mkdir -p "${OUTDIR}"

#
# Initialize the output logs
#
printf 'origin\toptions_name\tpackage_count\tset_count\tunset_count\n' \
    > "${RECOVERED_LOG}"

printf 'origin\toptions_name\treason\n' \
    > "${NO_OPTIONS_LOG}"

printf 'origin\toptions_name\treason\n' \
    > "${NO_PACKAGE_OPTIONS_LOG}"

printf 'origin\toption\tpackage_value\n' \
    > "${OBSOLETE_OPTIONS_LOG}"

printf 'origin\toption\tselected_value\treason\n' \
    > "${NEW_OPTIONS_LOG}"

printf 'origin\toption\tvalues\tselected_value\n' \
    > "${CONFLICTS_LOG}"

printf 'origin\tstage\treason\n' \
    > "${FAILED_LOG}"

#
# Retrieve all OPTIONS from the pkg database in bulk.
#
# If a single package has multiple OPTIONS, `pkg query` outputs one line for
# each option.
#
# Fields:
#   origin, package name, option name, option value
#
echo "Reading package OPTIONS from pkg database..."

pkg query '%o	%n	%Ok	%Ov' |
    awk -F '	' '
        BEGIN {
            OFS = "\t"
        }

        NF >= 4 && $1 != "" && $3 != "" {
            value = tolower($4)

            if (value == "on" || value == "yes" ||
                value == "true" || value == "1") {
                value = "on"
            } else if (value == "off" || value == "no" ||
                       value == "false" || value == "0") {
                value = "off"
            } else {
                next
            }

            print $1, $2, $3, value
        }
    ' |
    sort -t '	' -k1,1 -k3,3 -k2,2 \
    > "${PKG_OPTIONS}"

origin_total=$(grep -Ev '^[[:space:]]*(#|$)' "${INPUT}" |
    sort -u |
    wc -l |
    tr -d ' ')

origin_number=0

grep -Ev '^[[:space:]]*(#|$)' "${INPUT}" |
sort -u |
while IFS= read -r origin
do
    origin_number=$((origin_number + 1))
    portdir="${PORTSDIR}/${origin}"

    printf '[%d/%d] %s\n' \
        "${origin_number}" "${origin_total}" "${origin}"

    if [ ! -f "${portdir}/Makefile" ]; then
        printf '%s\t%s\t%s\n' \
            "$(sanitize_field "${origin}")" \
            "port" \
            "Makefile not found" \
            >> "${FAILED_LOG}"
        continue
    fi

    #
    # OPTIONS_NAME is the name of the directory directly under /var/db/ports.
    #
    # Specifying the PORT_DBDIR for restoration ensures that existing 
    # configurations and the contents of /var/db/ports are not affected.
    #
    if ! options_name=$(
        make -C "${portdir}" \
            PORT_DBDIR="${OUTPUT_DBDIR}" \
            BATCH=yes \
            -V OPTIONS_NAME 2>/dev/null
    ); then
        printf '%s\t%s\t%s\n' \
            "$(sanitize_field "${origin}")" \
            "OPTIONS_NAME" \
            "make failed" \
            >> "${FAILED_LOG}"
        continue
    fi

    if [ -z "${options_name}" ]; then
        printf '%s\t%s\t%s\n' \
            "$(sanitize_field "${origin}")" \
            "OPTIONS_NAME" \
            "empty OPTIONS_NAME" \
            >> "${FAILED_LOG}"
        continue
    fi

    #
    # Retrieve the PKGNAME from the current ports tree for use with
    # _OPTIONS_READ, which is recorded in the proper options file.
    #
    if ! current_pkgname=$(
        make -C "${portdir}" \
            PORT_DBDIR="${OUTPUT_DBDIR}" \
            BATCH=yes \
            -V PKGNAME 2>/dev/null
    ); then
        printf '%s\t%s\t%s\n' \
            "$(sanitize_field "${origin}")" \
            "PKGNAME" \
            "make failed" \
            >> "${FAILED_LOG}"
        continue
    fi

    if [ -z "${current_pkgname}" ]; then
        printf '%s\t%s\t%s\n' \
            "$(sanitize_field "${origin}")" \
            "PKGNAME" \
            "empty PKGNAME" \
            >> "${FAILED_LOG}"
        continue
    fi

    #
    # Retrieve the complete list of OPTIONS recognized by the current port.
    #
    if ! complete_options=$(
        make -C "${portdir}" \
            PORT_DBDIR="${OUTPUT_DBDIR}" \
            BATCH=yes \
            -V COMPLETE_OPTIONS_LIST 2>/dev/null
    ); then
        printf '%s\t%s\t%s\n' \
            "$(sanitize_field "${origin}")" \
            "COMPLETE_OPTIONS_LIST" \
            "make failed" \
            >> "${FAILED_LOG}"
        continue
    fi

    if [ -z "${complete_options}" ]; then
        printf '%s\t%s\t%s\n' \
            "$(sanitize_field "${origin}")" \
            "$(sanitize_field "${options_name}")" \
            "port has no configurable OPTIONS" \
            >> "${NO_OPTIONS_LOG}"
        continue
    fi

    #
    # Since OUTPUT_DBDIR is still empty, PORT_OPTIONS contains the OPTIONS
    # that are currently enabled by default.
    #
    if ! default_options=$(
        make -C "${portdir}" \
            PORT_DBDIR="${OUTPUT_DBDIR}" \
            BATCH=yes \
            -V PORT_OPTIONS 2>/dev/null
    ); then
        printf '%s\t%s\t%s\n' \
            "$(sanitize_field "${origin}")" \
            "PORT_OPTIONS" \
            "make failed while reading defaults" \
            >> "${FAILED_LOG}"
        continue
    fi

    printf '%s\n' ${complete_options} |
        sed '/^$/d' |
        sort -u > "${CURRENT_OPTIONS}"

    : > "${DEFAULT_OPTIONS}"

    for option in ${default_options}
    do
        printf '%s\n' "${option}"
    done |
        sed '/^$/d' |
        sort -u > "${DEFAULT_OPTIONS}"

    #
    # Extract the OPTIONS information for installed packages generated 
    # from this origin.
    #
    awk -F '	' -v origin="${origin}" '
        $1 == origin {
            print
        }
    ' "${PKG_OPTIONS}" > "${ORIGIN_OPTIONS}"

    package_count=$(
        awk -F '	' '{ print $2 }' "${ORIGIN_OPTIONS}" |
            sort -u |
            sed '/^$/d' |
            wc -l |
            tr -d ' '
    )

    if [ ! -s "${ORIGIN_OPTIONS}" ]; then
        printf '%s\t%s\t%s\n' \
            "$(sanitize_field "${origin}")" \
            "$(sanitize_field "${options_name}")" \
            "pkg database contains no OPTIONS for this origin" \
            >> "${NO_PACKAGE_OPTIONS_LOG}"
        continue
    fi

    #
    # package-options:
    #   option<TAB>value
    #
    # If multiple subpackages or flavors are generated from the same origin, 
    # multiple values for the same option may be found.
    #
    # If the value match across all packages, use that value.
    # In case of a conflict, prioritize "on," but always record it in 
    # conflicts.tsv.
    #
    awk -F '	' '
        BEGIN {
            OFS = "\t"
        }

        {
            option = $3
            value = $4

            if (value == "on") {
                seen_on[option] = 1
            } else if (value == "off") {
                seen_off[option] = 1
            }
        }

        END {
            for (option in seen_on) {
                if (seen_off[option]) {
                    print option, "conflict"
                } else {
                    print option, "on"
                }
            }

            for (option in seen_off) {
                if (!seen_on[option]) {
                    print option, "off"
                }
            }
        }
    ' "${ORIGIN_OPTIONS}" |
        sort -t '	' -k1,1 > "${PACKAGE_OPTION_VALUES}"

    cut -f1 "${PACKAGE_OPTION_VALUES}" |
        sort -u > "${PACKAGE_OPTIONS}"

    : > "${GENERATED_SET}"
    : > "${GENERATED_UNSET}"

    #
    # Determine the final values for all current OPTIONS.
    #
    while IFS= read -r option
    do
        [ -n "${option}" ] || continue

        package_value=$(
            awk -F '	' -v option="${option}" '
                $1 == option {
                    print $2
                    exit
                }
            ' "${PACKAGE_OPTION_VALUES}"
        )

        case "${package_value}" in
            on)
                printf '%s\n' "${option}" >> "${GENERATED_SET}"
                ;;

            off)
                printf '%s\n' "${option}" >> "${GENERATED_UNSET}"
                ;;

            conflict)
                # In the event of a conflict, "on" is used as the option that
                # does not cause the function to fail.
                printf '%s\n' "${option}" >> "${GENERATED_SET}"

                printf '%s\t%s\t%s\t%s\n' \
                    "$(sanitize_field "${origin}")" \
                    "$(sanitize_field "${option}")" \
                    "on,off" \
                    "on" \
                    >> "${CONFLICTS_LOG}"
                ;;

            "")
                #
                # A new option not documented in pkg.
                # Use the default value for the current port.
                #
                if list_contains "${option}" "${DEFAULT_OPTIONS}"; then
                    selected_value="on"
                    printf '%s\n' "${option}" >> "${GENERATED_SET}"
                else
                    selected_value="off"
                    printf '%s\n' "${option}" >> "${GENERATED_UNSET}"
                fi

                printf '%s\t%s\t%s\t%s\n' \
                    "$(sanitize_field "${origin}")" \
                    "$(sanitize_field "${option}")" \
                    "${selected_value}" \
                    "not recorded in installed package; current default used" \
                    >> "${NEW_OPTIONS_LOG}"
                ;;
        esac
    done < "${CURRENT_OPTIONS}"

    #
    # An option that is listed in the pkg but does not currently exist 
    # in the port.
    # Since it may have been removed or renamed, do not include it in the 
    # options file.
    #
    while IFS="$(printf '\t')" read -r option package_value
    do
        [ -n "${option}" ] || continue

        if ! list_contains "${option}" "${CURRENT_OPTIONS}"; then
            printf '%s\t%s\t%s\n' \
                "$(sanitize_field "${origin}")" \
                "$(sanitize_field "${option}")" \
                "$(sanitize_field "${package_value}")" \
                >> "${OBSOLETE_OPTIONS_LOG}"
        fi
    done < "${PACKAGE_OPTION_VALUES}"

    sort -u "${GENERATED_SET}" -o "${GENERATED_SET}"
    sort -u "${GENERATED_UNSET}" -o "${GENERATED_UNSET}"

    target_dir="${OUTPUT_DBDIR}/${options_name}"
    target_file="${target_dir}/options"

    mkdir -p "${target_dir}"

    #
    # Generate an options file in the standard format used by bsd.options.mk.
    #
    {
        echo "# This file was reconstructed from installed package metadata."
        echo "# Source origin: ${origin}"
        echo "# Review before copying to /var/db/ports."
        echo

	printf '_OPTIONS_READ=%s\n' "${current_pkgname}"

	printf '_FILE_COMPLETE_OPTIONS_LIST='
	first=yes

        while IFS= read -r option
        do
            [ -n "${option}" ] || continue

            if [ "${first}" = yes ]; then
                printf '%s' "${option}"
                first=no
            else
                printf ' %s' "${option}"
            fi
        done < "${CURRENT_OPTIONS}"

	printf '\n'

	while IFS= read -r option
	do
            [ -n "${option}" ] || continue
            printf 'OPTIONS_FILE_SET+=%s\n' "${option}"
        done < "${GENERATED_SET}"

        while IFS= read -r option
        do
            [ -n "${option}" ] || continue
            printf 'OPTIONS_FILE_UNSET+=%s\n' "${option}"
        done < "${GENERATED_UNSET}"
    } > "${target_file}"

    set_count=$(wc -l < "${GENERATED_SET}" | tr -d ' ')
    unset_count=$(wc -l < "${GENERATED_UNSET}" | tr -d ' ')

    printf '%s\t%s\t%s\t%s\t%s\n' \
        "$(sanitize_field "${origin}")" \
        "$(sanitize_field "${options_name}")" \
        "${package_count}" \
        "${set_count}" \
        "${unset_count}" \
        >> "${RECOVERED_LOG}"
done

recovered_count=$(
    awk 'NR > 1 { count++ } END { print count + 0 }' "${RECOVERED_LOG}"
)

no_options_count=$(
    awk 'NR > 1 { count++ } END { print count + 0 }' "${NO_OPTIONS_LOG}"
)

no_package_options_count=$(
    awk 'NR > 1 { count++ } END { print count + 0 }' \
        "${NO_PACKAGE_OPTIONS_LOG}"
)

obsolete_count=$(
    awk 'NR > 1 { count++ } END { print count + 0 }' \
        "${OBSOLETE_OPTIONS_LOG}"
)

new_count=$(
    awk 'NR > 1 { count++ } END { print count + 0 }' \
        "${NEW_OPTIONS_LOG}"
)

conflict_count=$(
    awk 'NR > 1 { count++ } END { print count + 0 }' \
        "${CONFLICTS_LOG}"
)

failed_count=$(
    awk 'NR > 1 { count++ } END { print count + 0 }' "${FAILED_LOG}"
)

cat <<EOF

Step 2 completed.

Input origins:                         ${origin_total}
Generated options files:              ${recovered_count}
Ports without configurable OPTIONS:   ${no_options_count}
Origins without package OPTIONS:      ${no_package_options_count}
Obsolete package OPTIONS:             ${obsolete_count}
New OPTIONS using current defaults:   ${new_count}
Conflicting package OPTIONS:          ${conflict_count}
Failed origins:                       ${failed_count}

Recovered PORT_DBDIR:

  ${OUTPUT_DBDIR}

Logs:

  ${RECOVERED_LOG}
  ${NO_OPTIONS_LOG}
  ${NO_PACKAGE_OPTIONS_LOG}
  ${OBSOLETE_OPTIONS_LOG}
  ${NEW_OPTIONS_LOG}
  ${CONFLICTS_LOG}
  ${FAILED_LOG}

Nothing has been written to /var/db/ports.
EOF

