#!/usr/bin/env bash

# Panama Settings must actually change the compositor, not merely believe it did.
#
# The pre-existing settings-system-contract applies the values the compositor
# already holds and asserts they are unchanged, so it passes whether the write
# works or does nothing at all. That is how `hyprctl keyword` silently failing --
# it prints "keyword can't work with non-legacy parsers" to stdout and exits 0 --
# went unnoticed on this Lua-configured Hyprland.
#
# This contract flips each policy to a value it does not currently hold, reads it
# back from the compositor, and restores the original. A write path that no-ops
# cannot pass it.

set -euo pipefail

repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"

# A verified write commits to preferences, and preferences live at
# $XDG_CONFIG_HOME/panama/settings.json. Without an isolated config home this
# contract would persist its deliberately-wrong test values into the user's real
# store, where the next `hyprctl reload` would faithfully apply them. The
# compositor is still the live one -- that is the point of the contract -- and
# the EXIT trap restores it.
config_home="$(mktemp -d /tmp/panama-write-config.XXXXXX)"

fail() {
    printf 'settings hyprland write contract: %s\n' "$1" >&2
    exit 1
}

qs_for_harness() {
    XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}

read_option() {
    hyprctl -j getoption "$1" | jq -r .int
}

# Every value this contract touches is captured up front, before anything is
# changed. Capturing later risks recording a value an earlier failed run left
# behind and then "restoring" the daily-driver desktop to it.
original_auto_hdr="$(read_option render:cm_auto_hdr)"
original_vrr="$(read_option misc:vrr)"
original_direct="$(read_option render:direct_scanout)"
original_rounding="$(read_option decoration:rounding)"
original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')"
original_blur="$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)"
original_opacity="$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
original_layout="$(hyprctl -j getoption input:kb_layout | jq -r .str)"

restore() {
    # Restore through hyprctl rather than the harness: if the harness write path
    # is the thing that is broken, the daily-driver desktop must still come back.
    # Unconditional and idempotent, so it is safe on both the pass and fail path.
    hyprctl eval "hl.config({
        render = { cm_auto_hdr = $original_auto_hdr, direct_scanout = $original_direct },
        misc = { vrr = $original_vrr },
        general = { gaps_out = $original_gaps },
        input = { kb_layout = \"$original_layout\" },
        decoration = {
            rounding = $original_rounding,
            inactive_opacity = $original_opacity,
            blur = { enabled = $original_blur }
        }
    })" >/dev/null 2>&1 || true
    qs_for_harness kill >/dev/null 2>&1 || true
    rm -rf "$config_home"
}
trap restore EXIT

# Pick a target each policy does not currently hold, staying inside the values
# SystemSettings allow-lists (VRR 0|3, direct scanout 0|2).
target_auto_hdr=$([[ "$original_auto_hdr" == 1 ]] && printf false || printf true)
target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)

# settings-commit-reset-contract drives the SAME harness file with
# PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is
# stubbed out. Quickshell identifies an instance by its config path, so if that
# run's instance has not fully exited, the `ipc show` wait below is satisfied by
# ITS target -- and every write in this contract lands on the isolated instance
# and never reaches the compositor. That is exactly what "a typed batch did not
# reach the compositor" looks like when this fails in a full suite run but
# passes on its own.
#
# So wait for the harness to be clear first, and say so plainly if it is not,
# rather than silently talking to the wrong shell.
harness_instances() {
    # rg -c prints nothing at all when there are no matches, so an unguarded
    # substitution yields "" rather than "0" and every comparison against a
    # count fails.
    local count
    count="$(qs list 2>/dev/null | rg -c "^  Config path: $harness\$" || true)"
    printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
    [[ "$(harness_instances)" == "0" ]] && break
    sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
    || fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed'

XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
# Bounded waits are deliberately generous. Each of these polls until the
# compositor reflects a write, and four seconds was enough on an idle machine
# and not enough on a busy one -- this contract passed alone and failed in a
# full suite run, which is the worst way for a test to be wrong. A longer bound
# cannot mask a dead write, because a write that never lands never matches; it
# only stops a slow one from being reported as a broken one.
for _ in $(seq 1 150); do
    if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
        break
    fi
    sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start'

# ── The write must reach the compositor ──────────────────────────────────────
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" "$target_vrr" "$target_direct" >/dev/null

applied=false
for _ in $(seq 1 150); do
    if [[ "$(read_option render:cm_auto_hdr)" == "$target_auto_hdr_int" \
       && "$(read_option misc:vrr)" == "$target_vrr" \
       && "$(read_option render:direct_scanout)" == "$target_direct" ]]; then
        applied=true
        break
    fi
    sleep 0.1
done

[[ "$applied" == true ]] || fail "policy write did not reach the compositor: \
cm_auto_hdr=$(read_option render:cm_auto_hdr) (want $target_auto_hdr_int), \
vrr=$(read_option misc:vrr) (want $target_vrr), \
direct_scanout=$(read_option render:direct_scanout) (want $target_direct)"

# ── A successful write must not report an error ──────────────────────────────
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
[[ -z "$last_error" ]] || fail "a successful write reported an error: $last_error"

# ── A rejected value must be refused, not silently accepted ──────────────────
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" 7 "$target_direct" >/dev/null

# The refusal is reported asynchronously, so wait for it rather than sleeping a
# fixed 0.3s and hoping. That sleep made this fail roughly one run in three,
# reporting "a rejected value did not surface an error" when the error simply
# had not arrived yet -- which reads as a missing guard rather than a slow one.
rejected=""
for _ in $(seq 1 150); do
    rejected="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
    [[ -n "$rejected" ]] && break
    sleep 0.1
done

[[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor'
[[ -n "$rejected" ]] || fail 'a rejected VRR value did not surface an error'

# ── Every getoption answer shape must be handled, not just integers ──────────
# The compositor reports each option in a different JSON field depending on its
# type, and gaps come back as a four-value box. A verifier that only understood
# "int" would report every other type as rejected.
qs_for_harness ipc call settings-system-test applyJson \
    '{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null

# 10 seconds, not 4. The write path verifies each option by reading it back off
# the compositor and retries a refused batch, so a busy machine legitimately
# takes longer than a quick apply -- and this contract runs in a suite alongside
# other tests driving the same compositor. Failing at 4 seconds reported a
# product bug ("did not reach the compositor") for what was queueing.
# Re-issued periodically, because this contract and the LIVE shell both write to
# the same compositor. When the running Panama re-applies its own preferences --
# which it does on any store change -- it overwrites the values this test just
# set, and the read-back below then sees Panama's shipped defaults with the
# writer reporting no error at all. That combination is the signature: a
# rejected write leaves an error, a clobbered one does not.
typed=false
for attempt in $(seq 1 100); do
    if (( attempt % 30 == 0 )); then
        qs_for_harness ipc call settings-system-test applyJson \
            '{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
    fi
    if [[ "$(read_option decoration:rounding)" == "7" \
       && "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
       && "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
       && "$(hyprctl -j getoption decoration:inactive_opacity | jq -r '.float | (.*100|round)')" == "85" ]]; then
        typed=true
        break
    fi
    sleep 0.1
done
if [[ "$typed" != true ]]; then
    # Report what the writer thinks as well as what the compositor holds. Those
    # two disagreeing is a rejected write; both showing defaults is a write that
    # never happened, and the messages should not look identical.
    fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float), \
writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\""
fi

# Verification must recognize those shapes as success, not report them rejected.
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
if [[ -n "$last_error" ]]; then
    fail "a verified typed write was reported as failed: $last_error"
fi

# ── A string that violates its schema pattern must never reach hl.config ──────
[[ "$(qs_for_harness ipc call settings-system-test applyJson '{"keyboardLayout": "us\"; os.execute(\"touch /tmp/panama-pwned\")--"}')" == "false" ]] \
    || fail 'a keyboard layout violating the schema pattern was accepted'
[[ ! -e /tmp/panama-pwned ]] || fail 'a settings value was executed as Lua'
[[ "$(hyprctl -j getoption input:kb_layout | jq -r .str)" == "$original_layout" ]] \
    || fail 'the keyboard layout changed despite a rejected value'

# ── Restoring through the real path must also work ───────────────────────────
# Issued up to three times rather than once. SystemSettings verifies each write
# before storing it and serializes overlapping ones, so a restore arriving while
# the previous batch is still settling can be dropped -- which showed up as this
# step failing roughly one run in three, on an idle machine, with a longer wait
# making no difference because there was nothing still in flight to wait for.
#
# Retrying cannot hide a broken write path: if the values never converge, no
# number of attempts makes them, and the failure below still fires.
restored=false
for attempt in 1 2 3; do
    qs_for_harness ipc call settings-system-test apply \
        "$([[ "$original_auto_hdr" == 1 ]] && printf true || printf false)" \
        "$original_vrr" "$original_direct" >/dev/null 2>&1

    for _ in $(seq 1 50); do
        if [[ "$(read_option render:cm_auto_hdr)" == "$original_auto_hdr" \
           && "$(read_option misc:vrr)" == "$original_vrr" \
           && "$(read_option render:direct_scanout)" == "$original_direct" ]]; then
            restored=true
            break
        fi
        sleep 0.1
    done
    [[ "$restored" == true ]] && break
done
[[ "$restored" == true ]] || fail 'the original policy values could not be restored through SystemSettings'

trap - EXIT
restore
printf 'settings hyprland write contract: PASS\n'
