#!/usr/bin/env bash

set -euo pipefail

repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/Notifs.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
harness_fixture="$repo_dir/tests/quickshell/NotificationAppRulesHarness.qml"
desktop_entry_fixture="$repo_dir/tests/quickshell/fixtures/org.persist.App.desktop"

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

[[ -f "$service" ]] || fail 'notification service is missing'
[[ -f "$page" ]] || fail 'notification settings page is missing'
[[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing'
[[ -f "$desktop_entry_fixture" ]] || fail 'runtime desktop entry fixture is missing'

SERVICE_PATH="$service" PAGE_PATH="$page" bun -e '
const source = await Bun.file(process.env.SERVICE_PATH).text();
const page = await Bun.file(process.env.PAGE_PATH).text();

function fail(message) {
    console.error(`notification application rules contract: ${message}`);
    process.exit(1);
}

function functionBody(name) {
    const start = source.indexOf(`function ${name}(`);
    if (start === -1)
        fail(`missing ${name}()`);
    const open = source.indexOf("{", start);
    let depth = 0;
    for (let index = open; index < source.length; index++) {
        if (source[index] === "{") depth++;
        if (source[index] === "}" && --depth === 0)
            return source.slice(open + 1, index);
    }
    fail(`${name}() is unterminated`);
}

const notificationAppId = Function("notification", functionBody("notificationAppId"));
const normalizedAppRule = Function("rule", functionBody("normalizedAppRule"));

const identityFixtures = [
    { notification: { desktopEntry: "org.signal.Signal.desktop", appName: "Signal" }, expected: "org.signal.Signal.desktop" },
    { notification: { desktopEntry: "", appName: "Terminal" }, expected: "Terminal" },
    { notification: { desktopEntry: "", appName: "" }, expected: "Notifications" }
];
for (const fixture of identityFixtures) {
    const actual = notificationAppId(fixture.notification);
    if (actual !== fixture.expected)
        fail(`stable app identity expected ${fixture.expected}, got ${actual}`);
}

const defaultRule = normalizedAppRule({});
if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true }))
    fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`);

// Old stored rules may still carry lock-screen fields from the era when the
// page offered switches for them; normalization must drop dead fields, not
// carry them forward as if something read them.
const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false });
if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false }))
    fail(`stale lock-screen fields were not dropped: ${JSON.stringify(explicitRule)}`);

for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification"]) {
    functionBody(required);
}

const handler = source.indexOf("function handleNotification(notification: var)");
const tracked = source.indexOf("notification.tracked = true", handler);
const muted = source.indexOf("!root.appRule(appId).enabled", handler);
if (handler === -1 || tracked === -1 || muted === -1 || muted > tracked)
    fail("muted applications are not rejected before tracking/history/unread/toast work");
if (!source.includes("root.handleNotification(notification)"))
    fail("NotificationServer does not delegate delivery to the callable handler");
if (!source.includes("PreferenceSchema.has(\"notificationAppRules\")"))
    fail("the schema dependency is not explicit");
if (!source.includes("DesktopPreferences.get(\"notificationAppRules\")"))
    fail("rules are not read through DesktopPreferences");
if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)"))
    fail("rules are not written through DesktopPreferences");
if (!source.includes("next[knownAppId] = root.appRule(knownAppId)"))
    fail("persisted rules are not normalized to the required three-field shape");
if (!source.includes("root.fallbackAppRules = next"))
    fail("missing-schema preference writes do not retain an in-memory fallback");
// Do Not Disturb still gates popups; it gained exactly one exception, and the
// assertion names that exception rather than being loosened. A bare
// if (!doNotDisturb) would mean the allow list of the active focus mode is
// never consulted. Anything wider would let a manual Do Not Disturb be
// overridden. No apostrophes here: this block is inside a single-quoted shell
// string, and one closes it.
if (!source.includes("if (!root.doNotDisturb || FocusModes.allows("))
    fail("Do Not Disturb no longer gates popups with a focus-mode exception");
for (const required of [
    "DesktopEntries.applications.values",
    "DesktopEntries.byId(appId)",
    "DesktopEntries.heuristicLookup(appId)"
]) {
    if (!source.includes(required))
        fail(`persisted desktop entry ids are not reactively resolved through ${required}`);
}

for (const required of [
    "Notifs.applications",
    // Read through a binding on Notifs.appRule rather than a stored copy, so a
    // rule changed elsewhere still reaches the row. The exact expression moved
    // when the per-app rows were collapsed behind a summary; what must hold is
    // that the call is still in a binding, not that it is spelled one way.
    "Notifs.appRule(",
    ".enabled",
    "Notifs.setAppRule"
]) {
    if (!page.includes(required))
        fail(`settings page is missing ${required}`);
}

// The lock screen is hyprlock, which cannot render notifications. Two per-app
// lock-screen switches once shipped anyway, controlling nothing -- the page
// must not grow controls the session cannot honor.
for (const forbidden of ["showOnLockScreen", "showContentOnLockScreen"]) {
    if (page.includes(forbidden))
        fail(`settings page offers ${forbidden}, which nothing in a hyprlock session reads`);
}

console.log("notification application rules contract: PASS");
'

state_home="$(mktemp -d /tmp/panama-notification-rules-state.XXXXXX)"
config_home="$(mktemp -d /tmp/panama-notification-rules-config.XXXXXX)"
data_home="$(mktemp -d /tmp/panama-notification-rules-data.XXXXXX)"
config_path="$state_home/quickshell"
harness="$config_path/notification-app-rules-harness.qml"
shell_log="$state_home/notification-app-rules.log"

cleanup() {
    if [[ -n "${bus_pid:-}" ]]; then
        kill "$bus_pid" >/dev/null 2>&1 || true
    fi
    rm -rf "$state_home" "$config_home" "$data_home"
}
trap cleanup EXIT

cp -a "$repo_dir/config/dot/quickshell" "$config_path"
cp "$harness_fixture" "$harness"
mkdir -p "$data_home/applications"
cp "$desktop_entry_fixture" "$data_home/applications/org.persist.App.desktop"

# This is intentionally a copy-local integration dependency. The production
# schema is Claude's change; the runtime contract proves persistence only once
# that key exists and never stages a schema edit from this branch.
perl -0pi -e 's@(\n        // ── Capture)@\n        {\n            key: "notificationAppRules", type: "json", def: {}, group: "notifications", internal: true\n        },$1@' \
    "$config_path/config/PreferenceSchema.qml"
rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \
    || fail 'temporary schema integration key was not installed'

mapfile -t dbus_info < <(dbus-daemon --session --fork --print-address=1 --print-pid=1)
bus_address="${dbus_info[0]:-}"
bus_pid="${dbus_info[1]:-}"
[[ -n "$bus_address" && "$bus_pid" =~ ^[0-9]+$ ]] || fail 'private D-Bus session did not start'

qs_for_test() {
    DBUS_SESSION_BUS_ADDRESS="$bus_address" \
        XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
        XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_home" \
        qs -p "$harness" "$@"
}

stop_harness() {
    qs_for_test kill >/dev/null 2>&1 || true
    for _ in $(seq 1 40); do
        ! qs_for_test ipc show >/dev/null 2>&1 && return
        sleep 0.1
    done
    fail 'isolated notification harness did not stop cleanly'
}

start_harness() {
    qs_for_test --daemonize >"$shell_log" 2>&1
    for _ in $(seq 1 40); do
        qs_for_test ipc show 2>/dev/null | rg -q '^target notification-app-rules-test$' && return
        sleep 0.1
    done
    sed -n '1,240p' "$shell_log" >&2
    fail 'isolated notification harness did not start'
}

start_harness
exercise="$(qs_for_test ipc call notification-app-rules-test exercise)"
jq -e '
    .appId == "org.signal.Signal.desktop" and
    .initialRules == {
        "org.signal.Signal.desktop": { enabled: true }
    } and
    .muted == { tracked: false, history: 0, popups: 0, unread: 0 } and
    .dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and
    .fallback == {
        id: "Fallback Terminal",
        application: { id: "Fallback Terminal", name: "Fallback Terminal" }
    }
' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise"

persisted="$(qs_for_test ipc call notification-app-rules-test persist)"
jq -e '. == {
    "org.persist.App.desktop": { enabled: false }
}' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted"

settings_file="$config_home/panama/settings.json"
for _ in $(seq 1 40); do
    [[ -f "$settings_file" ]] && jq -e '.notificationAppRules["org.persist.App.desktop"].enabled == false' "$settings_file" >/dev/null && break
    sleep 0.1
done
[[ -f "$settings_file" ]] || fail 'runtime persistence fixture did not write settings.json'

wait_for_persisted_application() {
    local expected="$1"
    local applications=""
    for _ in $(seq 1 80); do
        applications="$(qs_for_test ipc call notification-app-rules-test applications)"
        if jq -e '.[] | select(.id == "org.persist.App.desktop" and .name == "Persisted Fixture App")' \
            <<<"$applications" >/dev/null; then
            [[ "$applications" == *"$expected"* ]] && printf '%s' "$applications" && return
        fi
        sleep 0.1
    done
    fail "persisted desktop entry did not resolve to a friendly name: $applications"
}

before_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')"

stop_harness
start_harness
restored="$(qs_for_test ipc call notification-app-rules-test restored)"
[[ "$restored" == "$persisted" ]] || fail "notification rules did not survive isolated restart: $restored"
after_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')"
[[ "$before_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \
    || fail "persisted application name was wrong before restart: $before_restart_applications"
[[ "$after_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \
    || fail "persisted application name was wrong after restart: $after_restart_applications"
stop_harness

printf 'notification application rules runtime contract: PASS\n'
