pragma Singleton

// Named focus modes, turned on by conditions rather than by alarms.
//
// A mode is active because something is true right now -- a game is running, a
// window is fullscreen on a given display, a workspace is focused, the clock is
// inside a window. That is re-evaluated continuously rather than fired once,
// which is the whole reason schedules here do not have the usual failure modes:
// there is no alarm to have missed. A machine asleep at 23:30, rebooted at
// 02:00, or opened at 08:00 into a window that has already passed all arrive at
// the correct answer by simply asking again.
//
// This deliberately does not own the manual timed session. FocusSession already
// does that, with its own capsule, shortcut, Quick Settings entry and contracts,
// and two things writing Do Not Disturb would fight. Modes whose only trigger is
// "manual" are started through FocusSession; everything automatic lives here,
// and defers entirely while a manual session is running.

import Quickshell
import Quickshell.Hyprland
import QtQuick
import qs.config

Singleton {
    id: root

    readonly property var modes: {
        const stored = DesktopPreferences.get("focusModes");
        return Array.isArray(stored) ? stored : [];
    }

    // Modes something could switch on without being asked.
    readonly property var automatic: root.modes.filter(mode =>
        mode.enabled === true && (mode.triggers ?? []).some(trigger => trigger.kind !== "manual"))

    // Re-read on a timer only because one trigger kind depends on the clock.
    // Everything else is driven by change signals; this is what makes a schedule
    // a condition rather than an alarm.
    property double nowMs: Date.now()

    // Set by the gamemode start/end hooks over IPC. The hook is the only thing
    // that reliably knows, and it is already talking to the shell.
    property bool gameRunning: false

    // The mode in force, or null. First match wins, so the order in the list is
    // the priority: a person who wants Gaming to beat Sleep moves it up.
    readonly property var activeMode: {
        // A manual session owns Do Not Disturb while it runs. Evaluating on top
        // of it would mean two owners for one piece of state and a restore that
        // puts back whatever the loser happened to see.
        if (FocusSession.active)
            return null;
        for (const mode of root.automatic) {
            if (root.triggered(mode))
                return mode;
        }
        return null;
    }

    readonly property bool active: root.activeMode !== null

    // Applications the mode in force lets through anyway, by the same id the
    // per-application rules use. Empty whenever no mode is active, which is
    // what keeps a Do Not Disturb somebody set by hand absolute: an exception
    // belongs to a mode, so without a mode there are no exceptions.
    readonly property var allowedApps: {
        const mode = root.activeMode;
        if (!mode || mode.silence !== true)
            return [];
        return Array.isArray(mode.allow) ? mode.allow.map(String) : [];
    }

    function allows(appId: string): bool {
        return root.allowedApps.indexOf(String(appId)) >= 0;
    }
    readonly property string activeName: String(root.activeMode?.name ?? "")

    // Why it is on, in the words the page uses, so "something silenced my
    // notifications" is always answerable.
    readonly property string activeReason: {
        if (!root.activeMode)
            return "";
        for (const trigger of (root.activeMode.triggers ?? [])) {
            if (root.triggerActive(trigger))
                return root.describeTrigger(trigger);
        }
        return "";
    }

    function triggered(mode: var): bool {
        return (mode.triggers ?? []).some(trigger => root.triggerActive(trigger));
    }

    function triggerActive(trigger: var): bool {
        switch (String(trigger?.kind ?? "")) {
        case "game":
            // Told to us by the gamemode hook rather than read from the Gaming
            // service, which only polls while its settings page is open and
            // would therefore be stale exactly when a game starts.
            return root.gameRunning;
        case "schedule":
            return root.withinWindow(trigger, new Date(root.nowMs));
        case "workspace":
            return Hyprland.focusedWorkspace?.id === Number(trigger.id ?? -1);
        case "fullscreen": {
            // Hyprland reports fullscreen on the workspace, not the window, and
            // Quickshell has no typed property for it -- so this reads the raw
            // IPC object, which does carry it and has a change notification, so
            // the binding still updates. Checked rather than assumed: the typed
            // property does not exist in this version.
            const workspace = Hyprland.focusedWorkspace;
            const fullscreen = workspace?.lastIpcObject?.hasfullscreen === true;
            if (!fullscreen)
                return false;
            const monitor = String(trigger.monitor ?? "");
            return monitor === "" || Hyprland.focusedMonitor?.name === monitor;
        }
        default:
            // "manual" included: FocusSession owns those.
            return false;
        }
    }

    // Minutes since midnight, or -1 for anything unparseable. A malformed
    // schedule must never read as "on" -- silencing someone because a string
    // was wrong is the worst way for this to fail.
    function minutesOf(text: string): int {
        const match = /^(\d{1,2}):(\d{2})$/.exec(String(text ?? ""));
        if (!match)
            return -1;
        const hours = Number(match[1]);
        const minutes = Number(match[2]);
        if (hours > 23 || minutes > 59)
            return -1;
        return hours * 60 + minutes;
    }

    // Is `when` inside this schedule?
    //
    // Windows that cross midnight are the case worth being careful about. A
    // window belongs to the day it STARTS on, so 23:30 Friday to 07:00 Saturday
    // is Friday's window, and Saturday morning is inside it only because Friday
    // is enabled. Treating it as Saturday's would silence a Saturday morning
    // nobody asked to be quiet.
    function withinWindow(trigger: var, when: var): bool {
        const start = root.minutesOf(trigger.start);
        const end = root.minutesOf(trigger.end);
        if (start < 0 || end < 0 || start === end)
            return false;

        const days = Array.isArray(trigger.days) ? trigger.days.map(Number) : [];
        if (days.length === 0)
            return false;

        const day = when.getDay();
        const minutes = when.getHours() * 60 + when.getMinutes();

        if (start < end)
            return days.indexOf(day) >= 0 && minutes >= start && minutes < end;

        // Crosses midnight: either late on an enabled day, or early on the day
        // after an enabled one.
        const yesterday = (day + 6) % 7;
        return (days.indexOf(day) >= 0 && minutes >= start)
            || (days.indexOf(yesterday) >= 0 && minutes < end);
    }

    function describeTrigger(trigger: var): string {
        switch (String(trigger?.kind ?? "")) {
        case "game":
            return "a game is running";
        case "schedule":
            return "scheduled " + String(trigger.start ?? "") + " to " + String(trigger.end ?? "");
        case "workspace":
            return "workspace " + String(trigger.id ?? "");
        case "fullscreen":
            return String(trigger.monitor ?? "") === ""
                ? "a window is fullscreen"
                : "a window is fullscreen on " + String(trigger.monitor);
        case "manual":
            return "started by hand";
        default:
            return "";
        }
    }

    function summary(mode: var): string {
        const triggers = (mode.triggers ?? []).map(trigger => root.describeTrigger(trigger))
            .filter(text => text !== "");
        const effects = [];
        if (mode.silence === true)
            effects.push("silences notifications");
        if (mode.keepAwake === true)
            effects.push("keeps the screen awake");
        const allow = Array.isArray(mode.allow) ? mode.allow : [];
        if (allow.length > 0)
            effects.push(allow.length + (allow.length === 1 ? " app may interrupt" : " apps may interrupt"));
        const when = triggers.length > 0 ? "When " + triggers.join(" or ") : "Never turns on";
        return effects.length > 0 ? when + " · " + effects.join(", ") : when;
    }

    function save(next: var): void {
        DesktopPreferences.set("focusModes", next);
    }

    function setEnabled(id: string, enabled: bool): void {
        root.save(root.modes.map(mode =>
            mode.id === id ? Object.assign({}, mode, { enabled: enabled }) : mode));
    }

    function update(id: string, changes: var): void {
        root.save(root.modes.map(mode =>
            mode.id === id ? Object.assign({}, mode, changes) : mode));
    }

    // ── applying ────────────────────────────────────────────────────────────
    //
    // What was true before a mode took over, so it can be put back. Recorded at
    // the moment of taking over rather than read at release, which would return
    // whatever the mode itself had set.

    property bool holding: false
    property bool previousDnd: false
    property bool previousCaffeine: false

    onActiveModeChanged: {
        const mode = root.activeMode;
        if (mode && !root.holding) {
            root.previousDnd = Notifs.doNotDisturb;
            root.previousCaffeine = Caffeine.enabled;
            root.holding = true;
        }
        if (mode) {
            if (mode.silence === true)
                Notifs.doNotDisturb = true;
            if (mode.keepAwake === true)
                Caffeine.enabled = true;
            return;
        }
        if (root.holding) {
            Notifs.doNotDisturb = root.previousDnd;
            Caffeine.enabled = root.previousCaffeine;
            root.holding = false;
        }
    }

    // Only runs while a schedule could change the answer. Thirty seconds is
    // finer than any window boundary anyone sets and costs nothing.
    Timer {
        running: root.automatic.some(mode =>
            (mode.triggers ?? []).some(trigger => trigger.kind === "schedule"))
        interval: 30000
        repeat: true
        onTriggered: root.nowMs = Date.now()
    }
}
