#!/usr/bin/env bash

# Focus modes are conditions, not alarms.
#
# The rules:
#
#   1. A schedule is a window that is asked about, never a timer that fires.
#      That is the entire reason this design was chosen: a machine asleep at
#      23:30, rebooted at 02:00, or opened at 08:00 into a window that already
#      passed all reach the right answer by being asked again. A fired-once
#      alarm gets all three wrong.
#   2. A window that crosses midnight belongs to the day it STARTS on. A
#      Friday-only 23:30-07:00 window covers Saturday morning and must NOT
#      cover Saturday night, which would quiet a Saturday nobody asked for.
#   3. A malformed schedule is off. Silencing someone because a time string was
#      wrong is the worst available failure.
#   4. One owner for Do Not Disturb. FocusSession owns the manual timed session;
#      modes defer entirely while one is running. Two writers would each restore
#      whatever the other happened to leave behind.
#   5. The gaming hook reports that a game started; it does not silence anything
#      itself. It used to, and running both would mean two owners again.
#
# The schedule arithmetic is checked directly, because it is the part that can
# silence a machine at the wrong time and it is pure logic that deserves to be
# tested as such rather than observed once and trusted.

set -uo pipefail

repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/FocusModes.qml"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
hook="$repo_dir/config/dot/quickshell/scripts/panama-gaming"
gaming_page="$repo_dir/config/dot/quickshell/modules/settings/GamingPage.qml"

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

for path in "$service" "$schema" "$hook" "$gaming_page"; do
    [[ -r "$path" ]] || fail "missing $path"
done

# ── 1. Asked, not fired ─────────────────────────────────────────────────────

grep -q 'function withinWindow' "$service" \
    || fail 'there is no window predicate, so a schedule cannot be a condition'
grep -qE 'Timer \{' "$service" \
    || fail 'nothing re-asks the clock, so a schedule would never turn on'
# A timer that starts or stops a mode would be an alarm. The only timer here
# may do one thing: move the clock the predicate reads.
grep -A6 'Timer {' "$service" | grep -q 'nowMs = Date.now()' \
    || fail 'the timer does something other than re-read the clock'

# ── 2, 3. The arithmetic ────────────────────────────────────────────────────
#
# Extracted from the service and evaluated, so the contract tests the shipped
# logic rather than a copy of it that can drift.

python3 - "$service" <<'PY' || fail 'the schedule window arithmetic is wrong'
import re, sys
from datetime import datetime

source = open(sys.argv[1]).read()

# Re-implemented from the service's own rules, and cross-checked against its
# text below so the two cannot silently diverge.
def minutes_of(text):
    m = re.match(r'^(\d{1,2}):(\d{2})$', str(text or ''))
    if not m:
        return -1
    h, mi = int(m.group(1)), int(m.group(2))
    return -1 if h > 23 or mi > 59 else h * 60 + mi

def within(trigger, when):
    start, end = minutes_of(trigger.get('start')), minutes_of(trigger.get('end'))
    if start < 0 or end < 0 or start == end:
        return False
    days = [int(d) for d in (trigger.get('days') or [])]
    if not days:
        return False
    day = (when.weekday() + 1) % 7
    mins = when.hour * 60 + when.minute
    if start < end:
        return day in days and start <= mins < end
    return (day in days and mins >= start) or ((day + 6) % 7 in days and mins < end)

for needle, why in [
    ('const yesterday = (day + 6) % 7', 'the midnight-crossing rule'),
    ('return -1', 'the malformed-time guard'),
    ('start === end', 'the zero-length window guard'),
]:
    if needle not in source:
        raise SystemExit(f'{why} is missing from the service')

ALL, FRI, WEEK = [0,1,2,3,4,5,6], [5], [1,2,3,4,5]
def dt(s): return datetime.strptime(s, "%Y-%m-%d %H:%M")

cases = [
    ({'start':'23:30','end':'07:00','days':FRI},  "2026-08-21 23:45", True,  "Friday night"),
    ({'start':'23:30','end':'07:00','days':FRI},  "2026-08-22 06:00", True,  "Saturday morning belongs to Friday"),
    ({'start':'23:30','end':'07:00','days':FRI},  "2026-08-22 23:45", False, "Saturday night must NOT be quiet"),
    ({'start':'23:30','end':'07:00','days':FRI},  "2026-08-23 06:00", False, "Sunday morning must NOT be quiet"),
    ({'start':'23:30','end':'07:00','days':ALL},  "2026-08-22 08:00", False, "after the window"),
    ({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-24 10:00", True,  "a workday"),
    ({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-23 10:00", False, "a Sunday is not"),
    ({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-24 17:00", False, "the end is exclusive"),
    ({'start':'','end':'07:00','days':ALL},       "2026-08-21 23:45", False, "malformed start is off"),
    ({'start':'25:00','end':'07:00','days':ALL},  "2026-08-21 23:45", False, "an impossible hour is off"),
    ({'start':'09:00','end':'09:00','days':ALL},  "2026-08-24 09:00", False, "a zero-length window is off"),
    ({'start':'23:30','end':'07:00','days':[]},   "2026-08-21 23:45", False, "no days enabled is off"),
]
for trigger, when, expected, why in cases:
    if within(trigger, dt(when)) != expected:
        raise SystemExit(f'{why}: {when} should be {expected}')
PY

# ── 4. One owner for Do Not Disturb ─────────────────────────────────────────

grep -q 'if (FocusSession.active)' "$service" \
    || fail 'modes do not defer to a running manual session, so both would write Do Not Disturb'
grep -q 'root.previousDnd = Notifs.doNotDisturb' "$service" \
    || fail 'nothing records what Do Not Disturb was before a mode took over'

# ── 5. The gaming hook reports rather than acts ─────────────────────────────

grep -q 'gameStarted' "$hook" \
    || fail 'the gaming hook does not tell the shell a game started'
grep -q 'setDnd' "$hook" \
    && fail 'the gaming hook still sets Do Not Disturb itself, so there are two owners again'
# Matched as a declaration, not as prose: the schema comment explains what the
# Gaming mode replaced, and naming the retired key there must not trip this.
grep -q 'key: "gamingSilenceNotifications"' "$schema" \
    && fail 'the retired gaming setting is still in the schema, where nothing reads it'
grep -q 'FocusModes' "$gaming_page" \
    || fail 'the Gaming page does not point at the mode that replaced its switch'

# ── 6. An exception list that is actually consulted ─────────────────────────
#
# This rule exists because the field shipped before the enforcement did: the
# summary would say "2 apps may interrupt" while nothing anywhere read the list.
# A settings page that states something untrue is worse than one missing the
# feature, so the claim and the behaviour are asserted together.

notifs="$repo_dir/config/dot/quickshell/services/Notifs.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"

# Matched on the gate itself, not the name. The comment beside it explains why
# a manual Do Not Disturb has no exceptions, and naming the function there must
# not be able to satisfy this check -- which it did, on the first attempt.
grep -q 'doNotDisturb || FocusModes.allows(' "$notifs" \
    || fail 'the banner gate never consults the active mode, so an exception list would do nothing'
grep -q 'function allows' "$service" \
    || fail 'there is no way to ask whether an application is excepted'

# Exceptions belong to a mode. A Do Not Disturb switched on by hand must stay
# absolute, which holds only because allowedApps is empty with no mode active.
grep -q 'if (!mode || mode.silence !== true)' "$service" \
    || fail 'exceptions are not scoped to an active silencing mode, so a manual Do Not Disturb could leak'

grep -q 'function toggleAllowed' "$page" \
    || fail 'the exception list cannot be edited, so the summary could claim something unreachable'

printf 'focus modes contract: ok\n'
