#!/bin/sh
#
# step2-recover-options.sh
#
# recover.list に列挙されたportsについて、インストール済みpkgの
# OPTIONS情報を基に、/var/db/ports 相当のoptionsファイルを
# 一時ディレクトリへ復元する。
#
# このスクリプトは /var/db/ports を変更しない。
#
# 入力:
#   recover.list
#
# 主な出力:
#   /tmp/recovered-ports/<OPTIONS_NAME>/options
#
# ログ:
#   recovered.tsv
#   no-options.tsv
#   no-package-options.tsv
#   obsolete-options.tsv
#   new-options.tsv
#   conflicts.tsv
#   failed.tsv
#
# 使用例:
#   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()
{
    # タブおよび改行がログの構造を壊さないようにする。
    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}"

#
# 出力ログを初期化
#
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}"

#
# pkgデータベース中のOPTIONSを一括取得する。
#
# 一つのパッケージに複数のOPTIONSがある場合、pkg queryは
# optionごとに1行を出力する。
#
# フィールド:
#   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は/var/db/ports直下のディレクトリ名になる。
    #
    # 復元先のPORT_DBDIRを指定することで、既存の設定や
    # /var/db/portsの状態に影響されないようにする。
    #
    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

    #
    # 正規のoptionsファイルに記録される_OPTIONS_READ用に、
    # 現在のports treeからPKGNAMEを取得する。
    #
    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

    #
    # 現在のportが認識するOPTIONSの完全な一覧を取得する。
    #
    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

    #
    # OUTPUT_DBDIRはまだ空なので、PORT_OPTIONSには現在の
    # デフォルトで有効になるOPTIONSが格納される。
    #
    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}"

    #
    # このoriginから生成されたインストール済みパッケージの
    # OPTIONS情報を抽出する。
    #
    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
    #
    # 同じoriginから複数のsubpackageやflavorが生成されていると、
    # 同一optionについて複数の値が見つかる場合がある。
    #
    # 全パッケージで一致すればその値を採用する。
    # 矛盾する場合はonを優先するが、必ず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}"

    #
    # 現在のすべての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)
                # 矛盾時は機能を失わない方向としてonを採用する。
                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}"
                ;;

            "")
                #
                # pkgに記録されていない新しいoption。
                # 現在の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}"

    #
    # pkgには記録されているが、現在のportには存在しないoption。
    # 削除・改名された可能性があるため、optionsファイルには書かない。
    #
    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}"

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

