#!/bin/sh
set -eu

# SaneFish patches can modify the same Lipstick files.  Patchmanager must see
# those changes as one ordered stack, so package upgrades are serialized here
# and all previously enabled patches are restored only after every payload has
# been staged.

root="${SANEFISH_ROOT:-}"
state_parent="${root}/var/lib/sanefish-patches"
state_dir="${state_parent}/transaction"
lock_dir="${state_parent}/transaction.lock"
config_file="${root}/etc/patchmanager2.conf"
patch_root="${root}/usr/share/patchmanager/patches"
patchmanager_bin="${SANEFISH_PATCHMANAGER:-/usr/sbin/patchmanager}"
systemctl_bin="${SANEFISH_SYSTEMCTL:-systemctl}"
dbus_send_bin="${SANEFISH_DBUS_SEND:-dbus-send}"
service_name="dbus-org.SfietKonstantin.patchmanager.service"
dbus_service="org.SfietKonstantin.patchmanager"
dbus_path="/org/SfietKonstantin/patchmanager"
dbus_interface="org.SfietKonstantin.patchmanager"
sanefish_entry_source="${root}/usr/share/sanefish/runtime/sanefish.json"
sanefish_entry_target="${root}/usr/share/jolla-settings/entries/sanefish.json"
sanefish_hidden_marker="${state_parent}/sanefish-hidden"
lock_held=0

log()
{
    printf 'SaneFish patches: %s\n' "$*"
}

restore_sanefish_entry()
{
    [ -r "${sanefish_entry_source}" ] || return 0
    entry_parent="$(dirname "${sanefish_entry_target}")"
    if [ ! -d "${entry_parent}" ]; then
        install -d -m0755 "${entry_parent}" || return 1
    fi
    if [ -r "${sanefish_entry_target}" ] \
            && cmp -s "${sanefish_entry_source}" "${sanefish_entry_target}"; then
        rm -f -- "${sanefish_hidden_marker}"
        return 0
    fi
    install -m0644 "${sanefish_entry_source}" "${sanefish_entry_target}"
    rm -f -- "${sanefish_hidden_marker}"
}

restore_sanefish_entry_safely()
{
    if ! restore_sanefish_entry; then
        log "warning: the Settings entry could not be restored; the patch transaction itself completed"
    fi
    return 0
}

valid_id()
{
    case "${1:-}" in
        ''|*[!A-Za-z0-9_.-]*) return 1 ;;
        *) return 0 ;;
    esac
}

release_lock()
{
    if [ "${lock_held}" -eq 1 ]; then
        rm -f -- "${lock_dir}/pid"
        rmdir "${lock_dir}" 2>/dev/null || true
        lock_held=0
    fi
}

acquire_lock()
{
    install -d -m0755 "${state_parent}"
    if ! mkdir "${lock_dir}" 2>/dev/null; then
        stale_pid=""
        [ -r "${lock_dir}/pid" ] && stale_pid="$(cat "${lock_dir}/pid" 2>/dev/null || true)"
        if [ -n "${stale_pid}" ] && kill -0 "${stale_pid}" 2>/dev/null; then
            echo "Another SaneFish patch transaction is already running." >&2
            return 1
        fi
        rm -f -- "${lock_dir}/pid"
        rmdir "${lock_dir}" 2>/dev/null || {
            echo "Cannot recover the stale SaneFish transaction lock." >&2
            return 1
        }
        mkdir "${lock_dir}"
    fi
    printf '%s\n' "$$" > "${lock_dir}/pid"
    lock_held=1
    trap release_lock EXIT HUP INT TERM
}

config_value()
{
    key="$1"
    [ -r "${config_file}" ] || return 0
    sed -n "s/^${key}=//p" "${config_file}" | head -n 1
}

csv_to_lines()
{
    awk -v value="$1" 'BEGIN {
        if (value == "" || value == "@Invalid()") exit
        count = split(value, parts, ",")
        for (i = 1; i <= count; ++i) {
            gsub(/^[[:space:]]+|[[:space:]]+$/, "", parts[i])
            if (parts[i] != "") print parts[i]
        }
    }'
}

line_present()
{
    file="$1"
    value="$2"
    [ -r "${file}" ] && grep -Fqx -- "${value}" "${file}"
}

is_applied()
{
    wanted="$1"
    csv_to_lines "$(config_value applied)" | grep -Fqx -- "${wanted}"
}

wait_for_applied_state()
{
    patch="$1"
    expected="$2"
    attempts=0
    while [ "${attempts}" -lt 45 ]; do
        if is_applied "${patch}"; then
            actual=applied
        else
            actual=unapplied
        fi
        [ "${actual}" = "${expected}" ] && return 0
        attempts=$((attempts + 1))
        sleep 1
    done
    return 1
}

wait_for_empty_applied_set()
{
    attempts=0
    while [ "${attempts}" -lt 15 ]; do
        if ! csv_to_lines "$(config_value applied)" | grep -q .; then
            return 0
        fi
        attempts=$((attempts + 1))
        sleep 1
    done
    return 1
}

build_restore_order()
{
    output="$1"
    applied_tmp="${state_dir}/.applied.$$"
    ordered_tmp="${state_dir}/.ordered.$$"
    safe_tmp="${state_dir}/.safe.$$"

    csv_to_lines "$(config_value applied)" > "${applied_tmp}"
    : > "${ordered_tmp}"

    csv_to_lines "$(config_value order)" | while IFS= read -r patch; do
        if line_present "${applied_tmp}" "${patch}" && \
                ! line_present "${ordered_tmp}" "${patch}"; then
            printf '%s\n' "${patch}" >> "${ordered_tmp}"
        fi
    done
    while IFS= read -r patch; do
        if ! line_present "${ordered_tmp}" "${patch}"; then
            printf '%s\n' "${patch}" >> "${ordered_tmp}"
        fi
    done < "${applied_tmp}"

    # The organizer establishes the movable Top Menu layout.  The lock-button
    # patch augments that layout, so always restore them in that order even if
    # an older Patchmanager order list contains the reverse order.
    awk '
        function isOrganizer(value) {
            return value ~ /^top_menu_organizer(_sfos(51|52))?_htheb$/
        }
        function isLockButton(value) {
            return value ~ /^organizable_lock_button(_sfos(51|52))?_htheb$/
        }
        {
            item[NR] = $0
            if (isOrganizer($0)) organizer = NR
            if (isLockButton($0)) lockButton = NR
        }
        END {
            if (organizer && lockButton && organizer > lockButton) {
                for (i = 1; i <= NR; ++i) {
                    if (i == lockButton) print item[organizer]
                    if (i != organizer) print item[i]
                }
            } else {
                for (i = 1; i <= NR; ++i) print item[i]
            }
        }
    ' "${ordered_tmp}" > "${safe_tmp}"

    mv -f -- "${safe_tmp}" "${output}"
    rm -f -- "${applied_tmp}" "${ordered_tmp}"
}

reverse_lines()
{
    awk '{ line[NR] = $0 } END { for (i = NR; i >= 1; --i) print line[i] }' "$1"
}

unapply_one()
{
    patch="$1"
    is_applied "${patch}" || return 0
    log "unapplying ${patch}"
    if ! "${patchmanager_bin}" -u "${patch}" >> "${state_dir}/patchmanager.log" 2>&1; then
        echo "Patchmanager could not unapply ${patch}." >&2
        return 1
    fi
    if ! wait_for_applied_state "${patch}" unapplied; then
        echo "Patchmanager still reports ${patch} as applied." >&2
        return 1
    fi
}

apply_one()
{
    patch="$1"
    is_applied "${patch}" && return 0
    if [ ! -r "${patch_root}/${patch}/patch.json" ] || \
            [ ! -r "${patch_root}/${patch}/unified_diff.patch" ]; then
        echo "Cannot restore ${patch}: its active payload is incomplete." >&2
        return 1
    fi
    log "restoring ${patch}"
    if ! "${patchmanager_bin}" -a "${patch}" >> "${state_dir}/patchmanager.log" 2>&1; then
        echo "Patchmanager could not restore ${patch}." >&2
        return 1
    fi
    if ! wait_for_applied_state "${patch}" applied; then
        echo "Patchmanager did not register ${patch} as applied." >&2
        return 1
    fi
}

restart_patchmanager()
{
    if [ -n "${root}" ] && [ -z "${SANEFISH_SYSTEMCTL:-}" ]; then
        return 0
    fi

    log "refreshing Patchmanager once"
    "${systemctl_bin}" restart "${service_name}"

    attempts=0
    while [ "${attempts}" -lt 30 ]; do
        if "${systemctl_bin}" is-active --quiet "${service_name}"; then
            # systemd reports the service as active slightly before its D-Bus
            # object is ready.  Applying the first patch inside that window
            # loses that patch from Patchmanager's serialized enabled list.
            # Probe the actual API, then allow its settings object to settle.
            if "${dbus_send_bin}" --system --print-reply --reply-timeout=1000 \
                    --dest="${dbus_service}" "${dbus_path}" \
                    "${dbus_interface}.getLoaded" >/dev/null 2>&1; then
                sleep 1
                return 0
            fi
        fi
        attempts=$((attempts + 1))
        sleep 1
    done
    echo "Patchmanager did not become ready after its restart." >&2
    return 1
}

restore_after_failed_prepare()
{
    failed=0
    if [ -r "${state_dir}/restore.list" ]; then
        while IFS= read -r patch; do
            [ -n "${patch}" ] || continue
            apply_one "${patch}" || failed=1
        done < "${state_dir}/restore.list"
    fi
    if [ "${failed}" -eq 0 ]; then
        rm -rf -- "${state_dir}"
    fi
    return "${failed}"
}

start_transaction()
{
    participant="$1"
    disposition="${2:-keep}"
    valid_id "${participant}" || {
        echo "Invalid transaction participant: ${participant}" >&2
        return 2
    }
    case "${disposition}" in keep|drop) ;; *) return 2 ;; esac

    acquire_lock
    if [ "${3:-shared}" = exclusive ] && [ -f "${state_dir}/active" ]; then
        echo "Another SaneFish patch transaction needs to finish first." >&2
        return 1
    fi
    if [ ! -f "${state_dir}/active" ]; then
        install -d -m0755 "${state_dir}/participants"
        build_restore_order "${state_dir}/restore.list"
        cp -f -- "${state_dir}/restore.list" "${state_dir}/unapply.list"
        printf '%s\n' preparing > "${state_dir}/phase"
        : > "${state_dir}/patchmanager.log"
        : > "${state_dir}/active"

        failed=0
        reverse_lines "${state_dir}/unapply.list" | while IFS= read -r patch; do
            [ -n "${patch}" ] || continue
            unapply_one "${patch}" || exit 1
        done || failed=1
        if [ "${failed}" -ne 0 ]; then
            echo "The update was stopped before package files were changed." >&2
            restore_after_failed_prepare || true
            return 1
        fi
        if ! wait_for_empty_applied_set; then
            echo "Patchmanager retained enabled state after the unapply pass." >&2
            printf '%s\n' recovery-needed > "${state_dir}/phase"
            restore_after_failed_prepare || true
            return 1
        fi
        printf '%s\n' staging > "${state_dir}/phase"
    else
        install -d -m0755 "${state_dir}/participants"
    fi

    if [ "${disposition}" = drop ]; then
        awk -v removed="${participant}" '$0 != removed { print }' \
            "${state_dir}/restore.list" > "${state_dir}/restore.list.new"
        mv -f -- "${state_dir}/restore.list.new" "${state_dir}/restore.list"
    fi
    : > "${state_dir}/participants/${participant}"
}

join_transaction()
{
    participant="$1"
    valid_id "${participant}" || return 2
    acquire_lock
    [ -f "${state_dir}/active" ] || return 1
    install -d -m0755 "${state_dir}/participants"
    : > "${state_dir}/participants/${participant}"
}

map_restore_id()
{
    old_id="$1"
    new_id="$2"
    valid_id "${old_id}" && valid_id "${new_id}" || return 2
    acquire_lock
    [ -f "${state_dir}/active" ] || return 1
    awk -v old="${old_id}" -v new="${new_id}" \
        '{ if ($0 == old) print new; else print }' \
        "${state_dir}/restore.list" | awk '!seen[$0]++' \
        > "${state_dir}/restore.list.new"
    mv -f -- "${state_dir}/restore.list.new" "${state_dir}/restore.list"
}

finish_transaction()
{
    failed=0
    printf '%s\n' restoring > "${state_dir}/phase"
    restart_patchmanager || failed=1

    if [ "${failed}" -eq 0 ]; then
        while IFS= read -r patch; do
            [ -n "${patch}" ] || continue
            apply_one "${patch}" || failed=1
        done < "${state_dir}/restore.list"
    fi

    if [ "${failed}" -ne 0 ]; then
        printf '%s\n' recovery-needed > "${state_dir}/phase"
        echo "SaneFish kept its recovery state in ${state_dir}." >&2
        return 1
    fi

    rm -rf -- "${state_dir}"
    restore_sanefish_entry_safely
    log "transaction completed"
}

complete_transaction()
{
    participant="$1"
    valid_id "${participant}" || return 2
    acquire_lock
    if [ ! -f "${state_dir}/active" ]; then
        restore_sanefish_entry_safely
        # A package may reach %posttrans without having opened a SaneFish
        # transaction (for example an RPM --replacepkgs reinstall).  There is
        # no captured patch stack to restore in that case, so restarting the
        # Patchmanager daemon can only discard its currently loaded state.
        # Leave the daemon and the enabled patches untouched.
        return 0
    fi

    rm -f -- "${state_dir}/participants/${participant}"
    if find "${state_dir}/participants" -mindepth 1 -maxdepth 1 -type f \
            -print -quit | grep -q .; then
        return 0
    fi
    finish_transaction
}

force_finish()
{
    acquire_lock
    [ -f "${state_dir}/active" ] || return 0
    rm -f -- "${state_dir}/participants/"*
    finish_transaction
}

transaction_active()
{
    [ -f "${state_dir}/active" ]
}

case "${1:-}" in
    prepare-exclusive)
        [ "$#" -eq 2 ] || exit 2
        start_transaction "$2" keep exclusive
        ;;
    prepare)
        [ "$#" -ge 2 ] || exit 2
        start_transaction "$2" "${3:-keep}"
        ;;
    join)
        [ "$#" -eq 2 ] || exit 2
        join_transaction "$2"
        ;;
    map)
        [ "$#" -eq 3 ] || exit 2
        map_restore_id "$2" "$3"
        ;;
    complete)
        [ "$#" -eq 2 ] || exit 2
        complete_transaction "$2"
        ;;
    force-finish|recover)
        force_finish
        ;;
    refresh)
        acquire_lock
        if transaction_active; then
            echo "A SaneFish patch transaction is already active." >&2
            exit 1
        fi
        restart_patchmanager
        ;;
    active)
        transaction_active
        ;;
    is-applied)
        [ "$#" -eq 2 ] || exit 2
        is_applied "$2"
        ;;
    *)
        echo "Usage: $0 {prepare ID [keep|drop]|join ID|map OLD NEW|complete ID|force-finish|recover|refresh|active|is-applied ID}" >&2
        exit 2
        ;;
esac
