#!/usr/bin/env bash

# Every other surface the shell draws sits on a ground the theme chose. The bar
# sits on the wallpaper, which the theme has never seen, so it is the one place
# where a palette that is correct can still be unreadable. The Shell › Bar page
# exists to fix that, and this contract pins the three halves of it that a
# refactor can quietly undo:
#
#   1. The bar has a neutral family of its own (barFg/barFgDim/barFgMuted) and
#      every bar widget binds to it. A widget left on Theme.fg is invisible on
#      the exact wallpaper the user turned the tone control on for.
#   2. The scrim and the shadow are PREFERENCE-DRIVEN. Both were hardcoded true
#      at one point during the build, which is not a cosmetic slip: it forces a
#      dark band and a whole extra layer on everyone, including the people whose
#      wallpaper never needed either.
#   3. Turning a widget off removes it, and turning all of a widget's readouts
#      off removes the pill rather than leaving a padded gap reporting nothing.
#
# Static checks only: no compositor, no shell, nothing read from the live
# desktop.

set -euo pipefail

repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_dir="$repo_dir/config/dot/quickshell"
theme="$shell_dir/config/Theme.qml"
settings="$shell_dir/config/Settings.qml"
bar="$shell_dir/modules/bar/Bar.qml"
vitals="$shell_dir/modules/bar/VitalsWidget.qml"

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

# The thirteen files that carry neutral bar text or glyphs. Two of them live
# outside modules/bar -- the clipboard button and the focus indicator are drawn
# into the bar by Bar.qml, so they answer to the bar's tone like the rest.
bar_widgets=(
    "$shell_dir/modules/bar/ActivityIndicator.qml"
    "$shell_dir/modules/bar/AgentUsageWidget.qml"
    "$shell_dir/modules/bar/CalendarIndicator.qml"
    "$shell_dir/modules/bar/Clock.qml"
    "$shell_dir/modules/bar/MediaWidget.qml"
    "$shell_dir/modules/bar/StatusCluster.qml"
    "$shell_dir/modules/bar/StatusGlyph.qml"
    "$shell_dir/modules/bar/VitalsField.qml"
    "$shell_dir/modules/bar/WallpaperIndicator.qml"
    "$shell_dir/modules/bar/WeatherWidget.qml"
    "$shell_dir/modules/bar/Workspaces.qml"
    "$shell_dir/modules/clipboard/ClipboardWidget.qml"
    "$shell_dir/modules/focus/FocusIndicator.qml"
)

for file in "$theme" "$settings" "$bar" "$vitals" "${bar_widgets[@]}"; do
    [[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done

# ── The bar's neutral family ────────────────────────────────────────────────
# Left alone the family IS the fg family, by identity rather than by a copied
# literal: the moment `theme` returns a hand-picked colour instead of root.fg,
# the default bar stops following the theme and every custom palette is wrong
# in the one place the user looks at most.
python3 - "$theme" <<'PY' || fail 'the bar text tokens drifted from the fg family'
import re
import sys

text = open(sys.argv[1], encoding="utf-8").read()

if 'DesktopPreferences.get("barTextTone")' not in text:
    raise SystemExit("Theme no longer reads barTextTone")

# token -> the fg role its "theme" branch must fall back to, unchanged.
expected = {
    "barFg": "fg",
    "barFgDim": "fgDim",
    "barFgMuted": "fgMuted",
}

for token, role in expected.items():
    block = re.search(
        r"readonly property color " + token + r":\s*\{(?P<body>.*?)\n    \}",
        text,
        re.S,
    )
    if not block:
        raise SystemExit(f"Theme no longer defines {token}")
    body = re.sub(r"//.*", "", block.group("body"))

    for tone in ("light", "dark"):
        if f'=== "{tone}"' not in body:
            raise SystemExit(f"{token} does not answer the {tone} tone")

    # The fallthrough is the last return in the block, and it is the whole
    # promise of the default: follow theme means follow theme.
    returns = re.findall(r"return\s+([^;]+);", body)
    if not returns:
        raise SystemExit(f"{token} returns nothing")
    if returns[-1].strip() != f"root.{role}":
        raise SystemExit(
            f'{token} falls back to {returns[-1].strip()!r}, expected root.{role}'
        )

# The forced tones are anchored on one literal each and the two dims are MIXED
# off it, so light and dark stay families rather than three unrelated colours
# somebody has to keep in step by hand.
for token in ("barFgDim", "barFgMuted"):
    block = re.search(
        r"readonly property color " + token + r":\s*\{(?P<body>.*?)\n    \}",
        text,
        re.S,
    )
    body = block.group("body")
    if body.count("root.mix(root.barFg") != 2:
        raise SystemExit(f"{token} no longer derives both forced tones from barFg")
PY

# ── Settings exposes what the page writes ───────────────────────────────────
for key in barTextShadow barBackdrop showWeatherWidget showMediaWidget \
    showClipboardButton showCalendarCountdown; do
    rg -Fq "DesktopPreferences.get(\"$key\")" "$settings" \
        || fail "Settings does not expose $key"
done

# ── Every bar widget speaks in bar tones ────────────────────────────────────
# Theme.alpha(Theme.fg, ...) is allowed: those are hover and separator FILLS
# drawn against the widget's own pill, not text read against the wallpaper.
# Semantic tones (warn/danger/accent/ok) are allowed for the same reason -- a
# battery at 4% should be red whatever tone the neutrals were forced to.
python3 - "${bar_widgets[@]}" <<'PY' || fail 'a bar widget still paints neutral text with the fg family'
import re
import sys
from pathlib import Path

neutral = re.compile(r"Theme\.fg(?:Dim|Muted)?\b")
fill = re.compile(r"Theme\.alpha\(\s*Theme\.fg(?:Dim|Muted)?\b")

for arg in sys.argv[1:]:
    path = Path(arg)
    text = re.sub(r"//.*", "", path.read_text(encoding="utf-8"))

    if "Theme.barFg" not in text:
        raise SystemExit(f"{path.name} binds no text to the bar's own tone")

    stripped = fill.sub("", text)
    leftover = neutral.findall(stripped)
    if leftover:
        raise SystemExit(
            f"{path.name} still uses {sorted(set(leftover))} for bar text; "
            "use barFg/barFgDim/barFgMuted so the tone control reaches it"
        )
PY

# ── The scrim and the shadow are preferences, not decisions ─────────────────
for binding in \
    'visible: Settings.barBackdrop' \
    'layer.enabled: Settings.barTextShadow' \
    'layer.effect: MultiEffect' \
    'shadowEnabled: true'; do
    rg -Fq "$binding" "$bar" || fail "Bar.qml is missing \`$binding\`"
done

# The regression this catches actually happened: both landed as literal trues
# during the build, so the scrim and the extra layer shipped to everybody
# regardless of what the page said.
for hardcoded in 'visible: true' 'layer.enabled: true'; do
    ! rg -Fq "$hardcoded" "$bar" \
        || fail "Bar.qml hardcodes \`$hardcoded\` instead of reading the preference"
done

# One layer for the whole bar, not one per widget: the shadow is drawn under a
# flattened copy of the content, so a widget added tomorrow picks it up without
# opting in.
[[ "$(rg -c 'layer.enabled' "$bar")" == "1" ]] \
    || fail 'Bar.qml no longer flattens its content into exactly one shadow layer'

# The bar reserves its own height and nothing else. A computed zone here means
# maximised windows either overlap the bar or leave a strip of wallpaper.
rg -Fq 'exclusiveZone: Theme.barHeight' "$bar" \
    || fail 'the bar no longer reserves exactly its own height'

# Nothing in the bar animates. It already repaints once a second for the clock;
# anything that repaints continuously on top of that is a permanent GPU cost on
# a surface that is always on screen. The backdrop in particular is a static
# gradient by design.
! rg -q '\b(Behavior|NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|PropertyAnimation|AnimatedImage)\b' "$bar" \
    || fail 'Bar.qml has grown an animation; the bar is always on screen and never animates'

# ── Widget gates ────────────────────────────────────────────────────────────
# Each toggle is ANDed with the widget's own state condition rather than
# replacing it, so switching one ON never conjures a pill with nothing in it.
check_gate() {
    local file="$shell_dir/$1" needle="$2"
    rg -Fq "$needle" "$file" || fail "${1##*/} is missing \`$needle\`"
}
check_gate modules/bar/WeatherWidget.qml \
    'visible: Settings.showWeatherWidget && Weather.available'
check_gate modules/bar/MediaWidget.qml \
    'visible: Settings.showMediaWidget && root.player !== null'
check_gate modules/bar/CalendarIndicator.qml \
    'visible: Settings.showCalendarCountdown && CalendarAgenda.capsuleVisible'
check_gate modules/clipboard/ClipboardWidget.qml \
    'visible: Settings.showClipboardButton'

# ── The vitals pill leaves when it has nothing to say ───────────────────────
# An invisible child still occupies its Row, so gating the three fields alone
# left a padded, empty pill sitting in the bar. The pill has to answer for
# itself.
rg -Fq 'visible: Settings.showCpu || Settings.showMemory || (Settings.showGpu && Vitals.gpuAvailable)' "$vitals" \
    || fail 'the vitals pill does not disappear when all three readouts are off'
for field in 'visible: Settings.showCpu' 'visible: Settings.showMemory' \
    'visible: Settings.showGpu && Vitals.gpuAvailable'; do
    rg -Fq "$field" "$vitals" || fail "the vitals row is missing \`$field\`"
done

# ── Right-click lands where the toggles are ─────────────────────────────────
# Both of these used to open the retired Desktop page. Whichever widget you
# right-click, you should arrive at the card holding its own switch.
rg -Fq 'ShellState.openSettings("bar")' "$vitals" \
    || fail 'the vitals pill no longer jumps to Shell › Bar'
rg -Fq 'ShellState.openSettings("bar")' "$shell_dir/modules/bar/AgentUsageWidget.qml" \
    || fail 'the agent usage pill no longer jumps to Shell › Bar'

printf 'bar visibility contract: PASS (%d bar widgets on bar tones)\n' "${#bar_widgets[@]}"
