#!/usr/bin/env bash

# Per-application window rules, written by the user.
#
# This is settings.json describing compositor behaviour, which is the same
# shape of risk custom shortcuts carry and is handled the same way: the stored
# entry is data -- a class, some booleans, two numbers -- and hypr/rules.lua is
# the only thing that turns it into a rule.
#
# Three properties, and every one of them was a real way to lose the desktop:
#
#   1. The class is matched LITERALLY. Hyprland matches with RE2, so a class
#      typed into a text field is a regular expression: "org.gnome.Files" would
#      also match "orgxgnomexFiles", and a half-typed "(" is a pattern error
#      rather than a rule that matches nothing.
#   2. An invalid entry is skipped WHOLE. A rule that half-applies -- the size
#      dropped, the float kept -- is harder to understand than one that is not
#      there.
#   3. The shell's own surfaces cannot be matched. A user floating or moving
#      Quickshell's windows from the Windows page is a person breaking their
#      desktop with a supported control.
#
# And one about ordering: user rules are ANONYMOUS. Hyprland evaluates every
# named rule before every anonymous one, so naming a user rule would make it
# lose to the shipped rules it is meant to override.
#
# The Lua is exercised with a stubbed `hl`, so the rules can be counted without
# a compositor and without touching the running desktop.

set -uo pipefail

repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hypr_dir="$repo_dir/config/dot/hypr"
rules="$hypr_dir/rules.lua"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"

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

command -v lua >/dev/null 2>&1 || {
    printf 'window rules contract: lua is not installed\n' >&2
    exit 1
}

work="$(mktemp -d /tmp/panama-window-rules.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/config/panama"

# Every window rule rules.lua emits for a given settings file, one per line, as
# "<name or -> <class pattern> <field=value ...>". Layer rules are swallowed:
# this is about window rules, and a layer rule is not one.
emit() {
    printf '%s' "$1" >"$work/config/panama/settings.json"
    XDG_CONFIG_HOME="$work/config" lua -e "
        package.path = '$hypr_dir/?.lua;' .. package.path
        hl = {
            layer_rule = function() end,
            window_rule = function(rule)
                local fields = {}
                for key, value in pairs(rule) do
                    if key ~= 'match' and key ~= 'name' then
                        if type(value) == 'table' then
                            value = tostring(value[1]) .. 'x' .. tostring(value[2])
                        end
                        fields[#fields + 1] = key .. '=' .. tostring(value)
                    end
                end
                table.sort(fields)
                local class = (rule.match or {}).class
                print((rule.name or '-') .. ' ' .. tostring(class) .. ' ' .. table.concat(fields, ' '))
            end,
        }
        dofile('$rules')
    " 2>/dev/null
}

count() { grep -c . <<<"$1" || true; }

# ── The shipped baseline ─────────────────────────────────────────────────────
#
# Read rather than pinned as a number. Pinning one means every new shipped rule
# fails this contract for no reason, and the property under test is the
# DIFFERENCE the user's rules make, not how many rules Panama ships.

shipped="$(emit '{"windowRules":[]}')"
shipped_count="$(count "$shipped")"
(( shipped_count > 0 )) || note 'rules.lua emitted no window rules at all, which cannot be right'

absent="$(emit '{}')"
(( "$(count "$absent")" == shipped_count )) \
    || note 'with no windowRules key at all, the shipped rule count changes'

# ── Five rules, two of which must not be emitted ──────────────────────────────
#
#   * Calculator  -- valid, and the one whose escaping is checked
#   * qs-dock     -- the shell's own surface, refused
#   * Steam       -- workspace 42, which does not exist; skipped whole
#   * foo(bar     -- a class that is not a valid regex, and must not become one
#   * mygame      -- valid, with every remaining field set

USER_RULES='{"windowRules":[
  {"class":"org.gnome.Calculator","label":"Calculator","float":true,"center":true,"size":[400,600]},
  {"class":"qs-dock","label":"Dock","float":true},
  {"class":"Steam","label":"Steam","workspace":42},
  {"class":"foo(bar","label":"Odd","float":true,"pin":true},
  {"class":"mygame","label":"Game","game":true,"noAnim":true,"noDim":true,"workspace":9}
]}'

applied="$(emit "$USER_RULES")"
applied_count="$(count "$applied")"

(( applied_count == shipped_count + 3 )) \
    || note "five user rules with two invalid emitted $(( applied_count - shipped_count )) rules, not 3"

user_lines="$(comm -13 <(sort <<<"$shipped") <(sort <<<"$applied"))"

# 1. The class is escaped and anchored, so it matches itself and nothing else.
grep -qF '^org\.gnome\.Calculator$' <<<"$user_lines" \
    || note 'the class is not regex-escaped, so a dotted application id matches classes the user did not name'
grep -qF '^foo\(bar$' <<<"$user_lines" \
    || note 'a class containing a regex metacharacter is not escaped, so it is a pattern rather than a name'

# 2. Invalid entries are gone, and gone whole.
grep -q 'qs-dock' <<<"$user_lines" \
    && note 'a rule matching the shell\047s own surfaces was emitted'
grep -q 'Steam' <<<"$user_lines" \
    && note 'a rule with an out-of-range workspace was emitted'
grep -qE 'Steam.*float' <<<"$user_lines" \
    && note 'an invalid rule was emitted with its bad field dropped rather than skipped whole'

# 3. The valid ones carry what they were given, under Hyprland's own names.
grep -qE '\^org\\\.gnome\\\.Calculator\$.*center=true.*float=true.*size=400x600' <<<"$user_lines" \
    || note 'the float/center/size rule did not survive translation: '"$(grep Calculator <<<"$user_lines")"
grep -qE '\^mygame\$.*content=game.*no_anim=true.*no_dim=true.*workspace=9' <<<"$user_lines" \
    || note 'the game rule did not survive translation: '"$(grep mygame <<<"$user_lines")"
grep -qE '\^foo\\\(bar\$.*pin=true' <<<"$user_lines" \
    || note 'pin did not survive translation'

# 4. Anonymous, and after the shipped rules. Named would outrank every
#    anonymous shipped rule; earlier would let a shipped rule win the last-match
#    tiebreak against the user's own.
while IFS= read -r line; do
    [[ -n "$line" ]] || continue
    [[ "${line%% *}" == "-" ]] \
        || note "a user rule was emitted with the name '${line%% *}', which makes it outrank the shipped rules"
done <<<"$user_lines"

first_user="$(grep -n 'mygame' <<<"$applied" | head -1 | cut -d: -f1)"
last_shipped="$(grep -c . <<<"$shipped")"
[[ -n "$first_user" && "$first_user" -gt "$last_shipped" ]] \
    || note 'user rules are not emitted after every shipped rule'

# ── Sizes and workspaces have bounds ─────────────────────────────────────────
#
# Not decoration: a 4-pixel window is unreachable with the pointer, and a
# workspace number outside what the keybinds reach strands a window somewhere
# there is no shortcut to.

for bad in \
    '{"class":"tiny","size":[4,4]}' \
    '{"class":"huge","size":[99999,99999]}' \
    '{"class":"half","size":[400]}' \
    '{"class":"zero","workspace":0}' \
    '{"class":"","float":true}' \
    '{"class":"quickshell","float":true}' \
    '{"class":"QS-Dock","float":true}'; do
    out="$(emit "{\"windowRules\":[$bad]}")"
    (( "$(count "$out")" == shipped_count )) \
        || note "an invalid rule was emitted anyway: $bad"
done

# A class of exactly the cap is fine; one past it is not.
ok_class="$(printf 'a%.0s' $(seq 1 128))"
long_class="$(printf 'a%.0s' $(seq 1 129))"
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$ok_class\"}]}")")" == shipped_count + 1 )) \
    || note 'a class of exactly 128 characters is refused, so the cap is off by one'
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$long_class\"}]}")")" == shipped_count )) \
    || note 'a class longer than 128 characters is emitted anyway'

# ── Nothing malformed can cost the compositor ────────────────────────────────
#
# The whole point of reading a hand-editable file at config time is that a bad
# read costs the setting and never the desktop. A raise here aborts rules.lua,
# and the desktop comes up with no window rules at all.

for hostile in \
    '{"windowRules":"not an array"}' \
    '{"windowRules":[null]}' \
    '{"windowRules":[42]}' \
    '{"windowRules":[{"class":123}]}' \
    '{"windowRules":[{"class":"ok","size":"400x600"}]}' \
    '{"windowRules":[{"class":"ok","workspace":"3"}]}'; do
    out="$(emit "$hostile")"
    (( "$(count "$out")" >= shipped_count )) \
        || note "a malformed windowRules value cost the shipped rules: $hostile"
done

# ── The preference cannot pretend to be an option ────────────────────────────

if grep -q 'key: "windowRules"' "$schema"; then
    block="$(sed -n '/key: "windowRules"/,/^        },/p' "$schema")"
    grep -q 'hypr:' <<<"$block" \
        && note 'windowRules declares a hypr option, but window rules are not settable options'
else
    note 'windowRules is not in the schema'
fi

# ── Report ───────────────────────────────────────────────────────────────────

if (( ${#findings[@]} > 0 )); then
    mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
    printf 'window rules contract: %d finding(s)\n' "${#findings[@]}" >&2
    printf '  - %s\n' "${findings[@]}" >&2
    exit 1
fi

printf 'window rules contract: PASS (%d shipped rules, user rules validated and escaped)\n' "$shipped_count"
