#!/usr/bin/env bash

# `panama update`: the routine command, and the promises that make it routine.
#
# An update that asks questions is an update nobody runs, and a machine nobody
# updates drifts until the next reinstall. So the properties below are the
# whole point of the command rather than details of it:
#
#   1. --upgrade NEVER runs the interview. This is the regression that started
#      the redesign: ./install asked the full first-install questionnaire every
#      time, including on a machine whose answers could not have changed.
#   2. --upgrade runs exactly the stages that need no answer, and never
#      setup-identity or install-hardware. Both act only on interview answers.
#   3. install-packages is gated on a content hash of the package lists, and
#      the hash is NOT recorded when the stage fails -- the same rule
#      panama-migrate applies to its markers, for the same reason.
#   4. A conflicted `git stash pop` leaves no conflict markers in the tree.
#      Every dotfile here is a symlink into the checkout, so a half-merged file
#      is not something to fix later: it is live in ~/.config immediately, and
#      a broken .qml costs the running shell.
#
# Driven against fixture stages in a throwaway PANAMA_PATH, with sudo and
# gsettings shimmed, so this never touches the machine running it.

set -uo pipefail

repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
installer="$repo_dir/install"
panama="$repo_dir/bin/panama"

findings=()
note() { findings+=("$1"); }

[[ -x "$installer" ]] || { printf 'update command contract: no installer at %s\n' "$installer" >&2; exit 1; }

tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
trap 'rm -rf "$tmp"' EXIT

STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
             link-vicinae-scripts setup-identity install-hardware)

# A PANAMA_PATH that looks enough like the real one for install to run, and
# records what it was asked to do instead of doing it.
build_fixture() {
    local root="$1" packages_rc="${2:-0}"
    rm -rf "$root"
    mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
             "$root/config/dot/quickshell/scripts"

    cp "$installer" "$root/install"
    : >"$root/bin/ascii"
    printf 'base-package\n' >"$root/setup/packages/base"

    local stage
    for stage in "${STAGE_NAMES[@]}"; do
        cat >"$root/setup/scripts/$stage" <<EOF
#!/usr/bin/env bash
printf '%s\n' "$stage" >>"\$PANAMA_RAN"
EOF
        chmod +x "$root/setup/scripts/$stage"
    done
    # The one stage whose exit code the caller wants to control.
    cat >"$root/setup/scripts/install-packages" <<EOF
#!/usr/bin/env bash
printf 'install-packages\n' >>"\$PANAMA_RAN"
exit $packages_rc
EOF
    chmod +x "$root/setup/scripts/install-packages"

    cat >"$root/setup/scripts/interview" <<'EOF'
#!/usr/bin/env bash
printf 'interview\n' >>"$PANAMA_RAN"
: >"$PANAMA_ANSWERS"
EOF
    chmod +x "$root/setup/scripts/interview"

    cat >"$root/bin/panama-migrate" <<'EOF'
#!/usr/bin/env bash
printf 'migrate %s\n' "${1:-run}" >>"$PANAMA_RAN"
EOF
    chmod +x "$root/bin/panama-migrate"

    cat >"$root/config/dot/quickshell/scripts/panama-doctor" <<'EOF'
#!/usr/bin/env bash
printf 'doctor\n' >>"$PANAMA_RAN"
EOF
    chmod +x "$root/config/dot/quickshell/scripts/panama-doctor"

    # Nothing that reaches the real machine. sudo would prompt in a test run,
    # and gsettings would genuinely change the tester's screensaver.
    mkdir -p "$root/shim"
    cat >"$root/shim/sudo" <<'EOF'
#!/usr/bin/env bash
[[ "${1:-}" == -v || "${1:-}" == -n ]] && exit 0
exit 0
EOF
    cat >"$root/shim/gsettings" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
    cat >"$root/shim/hostnamectl" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
    chmod +x "$root/shim"/*
}

# Run the fixture installer and echo what ran, one stage per line.
run_install() {
    local root="$1"; shift
    : >"$root/ran"
    PATH="$root/shim:$PATH" \
    PANAMA_PATH="$root" \
    PANAMA_RAN="$root/ran" \
    XDG_STATE_HOME="$root/state" \
        bash "$root/install" "$@" >"$root/out" 2>&1
    printf '%s' "$?" >"$root/rc"
    cat "$root/ran"
}

# ── 1. The interview never runs on an upgrade ────────────────────────────────

build_fixture "$tmp/a"
ran="$(run_install "$tmp/a" --upgrade)"

if grep -qx 'interview' <<<"$ran"; then
    note 'install --upgrade ran the interview, which is the whole regression this prevents'
fi

# And the control: a real install must still ask.
build_fixture "$tmp/b"
ran_install="$(run_install "$tmp/b")"
if ! grep -qx 'interview' <<<"$ran_install"; then
    note 'a plain ./install no longer asks the interview, so a new machine is never configured'
fi

# ── 2. Exactly the answer-free stages ────────────────────────────────────────

for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts; do
    grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
done
for stage in setup-identity install-hardware; do
    grep -qx "$stage" <<<"$ran" \
        && note "install --upgrade ran $stage, which exists only to act on interview answers"
done

# A stage added to STAGES without a decision about which path owns it shows up
# here, because this list is written down twice on purpose.
mapfile -t declared < <(python3 - "$installer" <<'PY'
import re, sys
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
print("\n".join(re.findall(r"[\w-]+", line)[1:]))
PY
)
for stage in "${declared[@]}"; do
    printf '%s\n' "${STAGE_NAMES[@]}" | grep -qx "$stage" \
        || note "install declares a stage this contract has never heard of: $stage"
done

# ── 3. The packages hash gates the stage, and a failure does not record it ───

# Second run, nothing changed: the stage must be skipped.
ran_again="$(run_install "$tmp/a" --upgrade)"
grep -qx 'install-packages' <<<"$ran_again" \
    && note 'install-packages ran again with the package lists unchanged'

# --packages overrides the hash.
ran_forced="$(run_install "$tmp/a" --upgrade --packages)"
grep -qx 'install-packages' <<<"$ran_forced" \
    || note '--packages did not force install-packages to run'

# A changed list brings the stage back.
printf 'another-package\n' >>"$tmp/a/setup/packages/base"
ran_changed="$(run_install "$tmp/a" --upgrade)"
grep -qx 'install-packages' <<<"$ran_changed" \
    || note 'a changed package list did not bring install-packages back'

# A failing stage must not record the hash, or the failure is hidden forever.
build_fixture "$tmp/c" 1
run_install "$tmp/c" --upgrade >/dev/null
if [[ -r "$tmp/c/state/panama/packages-hash" ]]; then
    note 'install-packages failed but its hash was recorded, so it will never be retried'
fi

# A full install always runs the stage, whatever any recorded hash says.
build_fixture "$tmp/d"
run_install "$tmp/d" --upgrade >/dev/null
ran_full="$(run_install "$tmp/d")"
grep -qx 'install-packages' <<<"$ran_full" \
    || note 'a full ./install skipped install-packages because of a recorded hash'

# ── 4. A conflicted pop never leaves markers in a live config ────────────────
#
# Two halves. The first checks that git still behaves the way the design
# depends on; the second checks that panama acts on it. Neither is worth much
# without the other.

conflict="$tmp/conflict"
mkdir -p "$conflict"
(
    set -e
    cd "$conflict"
    git init -q up && cd up
    git config user.email contract@panama && git config user.name contract
    printf 'one\n' >f; git add -A; git commit -qm one
    cd "$conflict"; git clone -q up work; cd work
    git config user.email contract@panama && git config user.name contract
    cd "$conflict/up"; printf 'upstream\n' >f; git commit -qam two
    cd "$conflict/work"; printf 'local\n' >f
    git stash push --include-untracked -m contract >/dev/null
    git pull -q --ff-only
    git stash pop >/dev/null 2>&1 && exit 3   # a conflict was the point
    git reset -q --hard HEAD
    [[ -n "$(git stash list)" ]] || exit 4    # the stash must survive
    grep -q '<<<<<<<' f && exit 5             # and no markers may remain
    exit 0
) >/dev/null 2>&1
case $? in
    0) ;;
    3) note 'the conflict fixture did not conflict, so this check proves nothing' ;;
    4) note 'git no longer keeps the stash after a conflicted pop; panama update would lose work' ;;
    5) note 'git reset --hard left conflict markers behind' ;;
    *) note 'the stash conflict fixture could not be built' ;;
esac

# panama update must act on that: reset the tree rather than leave the markers.
body="$(sed -n '/^cmd_update()/,/^}/p' "$panama")"
if [[ -z "$body" ]]; then
    note 'bin/panama has no cmd_update to check'
else
    grep -q 'git stash push' <<<"$body" \
        || note 'cmd_update does not stash local changes, so a pull can fail on a dirty tree'
    grep -q 'git reset --hard' <<<"$body" \
        || note 'cmd_update does not reset after a failed pop, so conflict markers reach ~/.config'
    grep -q 'git pull --ff-only' <<<"$body" \
        || note 'cmd_update does not pull with --ff-only'
    grep -q -- '--upgrade' <<<"$body" \
        || note 'cmd_update does not hand off to install --upgrade, so it would ask the interview'
fi

# The two verbs stay separate: sync must never run the installer.
sync_body="$(sed -n '/^cmd_sync()/,/^}/p' "$panama")"
if [[ -z "$sync_body" ]]; then
    note 'bin/panama has no cmd_sync, so the git workflow lost its home'
else
    grep -q 'install' <<<"$sync_body" \
        && note 'cmd_sync runs the installer; committing and updating are separate jobs'
    grep -q 'git stash' <<<"$sync_body" \
        && note 'cmd_sync stashes, which the commit-before-pull order exists to avoid'
fi

if (( ${#findings[@]} > 0 )); then
    printf 'update command contract: %d finding(s)\n' "${#findings[@]}" >&2
    printf '  - %s\n' "${findings[@]}" >&2
    exit 1
fi

printf 'update command contract: PASS\n'
