pragma Singleton

// Display configuration: resolution, refresh rate, scale, and rotation.
//
// This is the only page in Panama Settings where a wrong value can leave you
// unable to SEE the screen well enough to undo it. A mode the display cannot
// show, or a scale that makes everything unreadable, is not recoverable through
// the same UI that caused it.
//
// So a change is never applied irreversibly. It is applied, then reverted
// automatically after a countdown unless confirmed -- the same contract every
// desktop uses for this one setting, and for the same reason. Confirming is
// what writes it to the settings store; letting the countdown run leaves
// nothing behind.
//
// Applied with `hyprctl eval` and hl.monitor{}. As everywhere else in Panama,
// success means the value was read back from the compositor and matched, never
// that a command exited zero.

import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import "DisplayLayout.js" as DisplayLayout

Singleton {
    id: root

    // [{ name, description, width, height, refreshRate, scale, transform,
    //    modes: [{ label, mode, width, height, refresh }] }]
    property var monitors: []
    // Quickshell.screens is the topology authority. The override is only the
    // isolated harness model; normal sessions always observe Quickshell.
    property var screenOverride: null
    property string lastError: ""

    readonly property var screenModel: Array.isArray(root.screenOverride)
        ? root.screenOverride : Quickshell.screens
    readonly property string screenSignature: root.screenModel
        .map(screen => typeof screen === "string" ? screen : screen.name)
        .filter(name => !!name)
        .sort()
        .join("|")
    readonly property var primaryFirstMonitors: root.monitors.slice().sort((left, right) => {
        if (left.primary !== right.primary)
            return left.primary ? -1 : 1;
        return left.name.localeCompare(right.name);
    })

    // Set while a change is applied but not yet confirmed.
    property var pendingPreviousLayout: null
    property var pendingRequestedLayout: null
    property bool pendingVerified: false
    property bool revertQueued: false
    property var revertExpectedLayout: null
    property string revertReason: ""
    property bool revertVerificationActive: false
    property int operationGeneration: 0
    property int revertGeneration: -1
    property bool externalChangeBlocked: false
    property int secondsLeft: 0
    property bool identifying: false

    readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
    readonly property bool canConfirm: root.awaitingConfirmation
        && root.pendingVerified
        && !root.busy
    readonly property bool busy: query.running || applyRun.running || revertRun.running
        || root.revertExpectedLayout !== null

    readonly property int confirmSeconds: 15

    readonly property var transforms: [
        { value: 0, label: "Landscape" },
        { value: 1, label: "Portrait" },
        { value: 2, label: "Landscape (flipped)" },
        { value: 3, label: "Portrait (flipped)" }
    ]

    // Scales that divide this desktop's common resolutions into whole pixels.
    // Hyprland rejects a fractional scale that does not, and the message it
    // gives is not something to put in front of a user.
    readonly property var scales: [1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]

    Process {
        id: query
        property int generation: 0
        command: ["hyprctl", "-j", "monitors"]
        stdout: StdioCollector {
            onStreamFinished: root.parse(this.text, query.generation)
        }
        onExited: (exitCode, exitStatus) => {
            if (exitCode !== 0)
                root.lastError = "Could not read the connected displays.";
            if (root.revertQueued && !applyRun.running && root.awaitingConfirmation)
                root.performRevert();
        }
    }

    Process {
        id: applyRun
        onExited: (exitCode, exitStatus) => {
            if (!root.awaitingConfirmation)
                return;
            if (root.revertQueued) {
                if (!query.running)
                    root.performRevert();
                return;
            }
            if (exitCode !== 0) {
                root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
                return;
            }
            verifyTimer.ticks = 0;
            verifyTimer.restart();
        }
    }

    Process {
        id: revertRun
        onExited: (exitCode, exitStatus) => {
            // Exit status is advisory only. Hyprland's Lua bridge can report
            // success without applying a value, so exact readback decides.
            root.revertVerificationActive = true;
            revertVerifyTimer.ticks = 0;
            revertVerifyTimer.restart();
        }
    }

    Component.onCompleted: root.refresh()

    onScreenSignatureChanged: root.reconcileTopology()

    // A hotplug can invalidate the unconfirmed layout while the confirmation
    // is visible. Read the current compositor layout first; the normal queued
    // rollback then filters out any output that has disappeared.
    function reconcileTopology(): void {
        root.refresh();
        if (root.awaitingConfirmation) {
            root.revertWithMessage("A display was connected or disconnected, so Panama restored the previous setting.");
            return;
        }
        // Nothing in flight, so a display genuinely arrived or left. Give it
        // back the arrangement it was last confirmed with -- see restoreStored.
        restoreDebounce.restart();
    }

    // Docking and undocking should not cost you your arrangement.
    //
    // hypr/monitors.lua applies the stored per-output entries, but only when
    // the compositor reads its config. A monitor plugged in an hour later gets
    // the compositor's automatic placement instead of the position, scale and
    // rotation this machine was told to use, and until now the only way to get
    // them back was to open Settings and apply them again.
    //
    // This is deliberately NOT a confirmed transaction. applyLayout arms a
    // fifteen-second countdown because it is about to show you something you
    // might not be able to undo; this is restoring a layout you already
    // confirmed, on hardware you already had, so a countdown would be asking
    // you to re-approve your own decision every time you sat down at a desk.
    //
    // It refuses rather than guesses in two cases, because a wrong answer here
    // is a screen you cannot see to fix:
    //
    //   * A stored entry whose mode the connected panel does not offer. This
    //     is the same monitor name on different hardware, which happens with
    //     DP-1 on one dock and DP-1 on another.
    //   * A stored arrangement that leaves any output with no on-screen
    //     position at all.
    //
    // In both cases the compositor's automatic placement stands and a toast
    // says so, which is recoverable. Silence would not be.
    // The decision, with no side effects, so it can be tested without driving
    // a real compositor. Returns one of:
    //   { action: "none" }              nothing stored, or already correct
    //   { action: "apply",  layout }    restore this
    //   { action: "refuse" }            stored arrangement does not fit
    function plannedRestore(): var {
        if (root.busy || root.awaitingConfirmation || root.monitors.length === 0)
            return { action: "none" };

        const stored = DesktopPreferences.get("displays");
        const persisted = stored && typeof stored === "object" ? stored : {};

        // Start from what is on screen and overlay each stored entry, so an
        // output with nothing saved keeps the compositor's own placement.
        let changed = false;
        const layout = root.currentLayout();
        for (const record of layout) {
            const entry = persisted[record.name];
            if (!root.isPersistedLayoutEntry(entry))
                continue;
            const parts = root.modeParts(entry.mode);
            if (!parts)
                continue;
            if (entry.mode !== record.mode
                    || Math.abs(entry.scale - record.scale) >= 0.001
                    || entry.transform !== record.transform
                    || (entry.x !== undefined && entry.x !== record.x)
                    || (entry.y !== undefined && entry.y !== record.y))
                changed = true;
            record.mode = entry.mode;
            record.width = parts.width;
            record.height = parts.height;
            record.refreshRate = parts.refresh;
            record.scale = entry.scale;
            record.transform = entry.transform;
            if (entry.x !== undefined) record.x = entry.x;
            if (entry.y !== undefined) record.y = entry.y;
            record.primary = entry.primary === true;
        }

        if (!changed)
            return { action: "none" };

        // Exactly one primary, on a display that is actually here. Undocking
        // takes the primary away, and a layout with none is one
        // DisplayLayout.validate refuses outright.
        if (layout.filter(record => record.primary).length !== 1) {
            layout.forEach(record => record.primary = false);
            layout[0].primary = true;
        }

        // The same validator every user-initiated change goes through: it
        // checks the mode is one this panel offers, the scale is whole-pixel,
        // and the names match what is connected. A stored entry for hardware
        // that is no longer on this connector fails here, which is the point.
        const normalized = DisplayLayout.normalize(layout);
        if (!root.validRequestedLayout(normalized))
            return { action: "refuse" };

        return { action: "apply", layout: normalized };
    }

    function restoreStored(): void {
        const plan = root.plannedRestore();
        if (plan.action === "none")
            return;

        if (plan.action === "refuse") {
            StatusEvents.publish({
                key: "display-restore",
                icon: "video-display-symbolic",
                title: "Kept the automatic display arrangement",
                detail: "The saved arrangement does not fit the displays connected now",
                tone: "warn",
                priority: StatusEvents.importantPriority,
                actionId: "open-settings",
                actionData: "displays"
            });
            return;
        }

        root.pushLayout(plan.layout, applyRun);
    }

    // Displays announce themselves one at a time: plugging in a dock produces
    // several signature changes in quick succession, and applying a layout to
    // each intermediate topology would fight the compositor as it settles.
    Timer {
        id: restoreDebounce
        interval: 1200
        onTriggered: root.restoreStored()
    }

    function refresh(): bool {
        if (!query.running) {
            query.generation = root.operationGeneration;
            query.running = true;
            return true;
        }
        return false;
    }

    function identify(): void {
        root.identifying = true;
        identifyTimer.restart();
    }

    function parse(text: string, generation: int): void {
        try {
            const raw = JSON.parse(text);
            const stored = DesktopPreferences.get("displays");
            const persisted = stored && typeof stored === "object" ? stored : {};
            const persistedPrimaries = raw.filter(monitor => {
                const entry = persisted[monitor.name ?? ""];
                return root.isPersistedLayoutEntry(entry) && entry.primary === true;
            });
            const origin = raw.find(monitor => monitor.x === 0 && monitor.y === 0);
            const primaryName = persistedPrimaries.length === 1
                ? persistedPrimaries[0].name
                : (origin?.name ?? raw[0]?.name ?? "");
            root.monitors = raw.map(monitor => {
                const modes = root.normalizeModes(monitor.availableModes ?? []);
                const width = monitor.width ?? 0;
                const height = monitor.height ?? 0;
                const refreshRate = monitor.refreshRate ?? 0;
                const current = modes
                    .filter(mode => mode.width === width && mode.height === height)
                    .sort((left, right) =>
                        Math.abs(left.refresh - refreshRate) - Math.abs(right.refresh - refreshRate))[0];
                return {
                    name: monitor.name ?? "",
                    description: monitor.description ?? monitor.model ?? "Display",
                    width: width,
                    height: height,
                    refreshRate: refreshRate,
                    mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
                    scale: monitor.scale ?? 1,
                    transform: monitor.transform ?? 0,
                    x: Number.isInteger(monitor.x) ? monitor.x : 0,
                    y: Number.isInteger(monitor.y) ? monitor.y : 0,
                    primary: monitor.name === primaryName,
                    currentFormat: monitor.currentFormat ?? "",
                    colorPreset: monitor.colorManagementPreset ?? "",
                    vrr: monitor.vrr === true,
                    modes: modes
                };
            });
            if (root.awaitingConfirmation && root.pendingRequestedLayout
                    && generation === root.operationGeneration
                    && root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
                root.pendingVerified = true;
                verifyTimer.stop();
                root.lastError = "";
            } else if (root.revertVerificationActive
                    && generation === root.revertGeneration
                    && root.revertExpectedLayout
                    && root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
                revertVerifyTimer.stop();
                root.revertVerificationActive = false;
                root.revertGeneration = -1;
                root.revertExpectedLayout = null;
                if (root.revertReason === "")
                    root.lastError = "";
                else
                    root.lastError = root.revertReason;
                root.revertReason = "";
            } else if (!root.awaitingConfirmation && !root.revertExpectedLayout && (
                    root.lastError === "Could not read the connected displays."
                    || root.lastError === "The display list could not be read.")) {
                root.lastError = "";
            }
        } catch (error) {
            root.lastError = "The display list could not be read.";
        }
    }

    // "4500x3000@60.00Hz" -> a sortable record. The compositor reports the same
    // resolution at distinct rates such as 60.00 and 59.94. Those identities
    // remain separate because confirmation and recovery must read back the
    // exact mode the user chose, even when their rounded labels look similar.
    function normalizeModes(raw: var): var {
        const seen = {};
        const out = [];
        for (const entry of raw) {
            const match = String(entry).match(/^(\d+)x(\d+)@([\d.]+)Hz$/);
            if (!match)
                continue;
            const width = Number(match[1]);
            const height = Number(match[2]);
            const refreshText = match[3];
            const refresh = Number(refreshText);
            const roundedRefresh = Math.round(refresh);
            const key = `${width}x${height}@${refreshText}`;
            if (seen[key])
                continue;
            seen[key] = true;
            out.push({
                label: `${width} × ${height}`,
                refreshLabel: Math.abs(refresh - roundedRefresh) < 0.005
                    ? `${roundedRefresh} Hz`
                    : `${refresh.toFixed(2)} Hz`,
                mode: `${width}x${height}@${refreshText}`,
                width: width,
                height: height,
                refresh: refresh
            });
        }
        return out.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.refresh - a.refresh);
    }

    function monitorNamed(name: string): var {
        return root.monitors.find(monitor => monitor.name === name) ?? null;
    }

    function isPersistedLayoutEntry(entry: var): bool {
        return !!entry && typeof entry === "object"
            && root.modeParts(entry.mode) !== null
            && Number.isFinite(entry.scale) && entry.scale > 0
            && Number.isInteger(entry.transform)
            && entry.transform >= 0 && entry.transform <= 3
            && Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000
            && Number.isInteger(entry.y) && entry.y >= -100000 && entry.y <= 100000
            && typeof entry.primary === "boolean";
    }

    function modeParts(mode: string): var {
        const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
        if (!match)
            return null;
        return {
            width: Number(match[1]),
            height: Number(match[2]),
            refresh: Number(match[3])
        };
    }

    function isScaleClean(mode: string, scale: real): bool {
        const parts = root.modeParts(mode);
        if (!parts || root.scales.indexOf(scale) < 0 || !Number.isFinite(scale) || scale <= 0)
            return false;
        const logicalWidth = parts.width / scale;
        const logicalHeight = parts.height / scale;
        return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.0001
            && Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.0001;
    }

    function scalesForMode(mode: string): var {
        return root.scales.filter(scale => root.isScaleClean(mode, scale));
    }

    function nearestCleanScale(mode: string, preferred: real): real {
        const choices = root.scalesForMode(mode);
        if (choices.length === 0)
            return 1.0;
        return choices.reduce((best, candidate) =>
            Math.abs(candidate - preferred) < Math.abs(best - preferred) ? candidate : best,
            choices[0]);
    }

    function currentLayout(): var {
        return root.monitors.map(monitor => ({
            name: monitor.name,
            width: monitor.width,
            height: monitor.height,
            refreshRate: monitor.refreshRate,
            mode: monitor.mode,
            scale: monitor.scale,
            transform: monitor.transform,
            x: monitor.x,
            y: monitor.y,
            primary: monitor.primary === true
        }));
    }

    function matchesLayout(monitors: var, layout: var): bool {
        if (!Array.isArray(monitors) || !Array.isArray(layout)
                || monitors.length !== layout.length)
            return false;
        const expected = Array.from(layout).sort((a, b) => a.name.localeCompare(b.name));
        const actual = Array.from(monitors).sort((a, b) => a.name.localeCompare(b.name));
        for (let index = 0; index < expected.length; index++) {
            const requested = expected[index];
            const monitor = actual[index];
            const parts = root.modeParts(requested.mode);
            if (!parts || monitor.name !== requested.name
                    || monitor.width !== parts.width
                    || monitor.height !== parts.height
                    || Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
                    || Math.abs(monitor.scale - requested.scale) >= 0.001
                    || monitor.transform !== requested.transform
                    || monitor.x !== requested.x || monitor.y !== requested.y)
                return false;
        }
        return true;
    }

    function modeIsCurrent(monitor: var, candidate: var): bool {
        return !!monitor && !!candidate
            && monitor.width === candidate.width
            && monitor.height === candidate.height
            && Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
    }

    function validRequestedLayout(layout: var): bool {
        if (!DisplayLayout.validate(layout) || layout.length !== root.monitors.length)
            return false;
        const currentNames = root.monitors.map(monitor => monitor.name).sort();
        const requestedNames = layout.map(record => record.name).sort();
        if (JSON.stringify(currentNames) !== JSON.stringify(requestedNames))
            return false;
        return layout.every(record => {
            const monitor = root.monitorNamed(record.name);
            const parts = root.modeParts(record.mode);
            return !!monitor && !!parts
                && record.width === parts.width && record.height === parts.height
                && monitor.modes.some(candidate => candidate.mode === record.mode)
                && root.isScaleClean(record.mode, record.scale)
                && root.transforms.some(candidate => candidate.value === record.transform);
        });
    }

    // One-field controls remain callers of the complete-layout transaction.
    // Their edit is cloned into the current layout so every output's position
    // participates in apply, verification, and rollback.
    function apply(output: string, mode: string, scale: real, transform: int): bool {
        const layout = root.currentLayout();
        const record = layout.find(candidate => candidate.name === output);
        const parts = root.modeParts(mode);
        if (!record || !parts) {
            root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
            return false;
        }
        record.mode = mode;
        record.width = parts.width;
        record.height = parts.height;
        record.refreshRate = parts.refresh;
        record.scale = scale;
        record.transform = transform;
        return root.applyLayout(layout);
    }

    // Applies immediately and starts the countdown. Nothing is stored yet: the
    // complete connected layout is only written by confirm().
    function applyLayout(layout: var, protectedOperation: bool): bool {
        if (root.externalChangeBlocked && protectedOperation !== true) {
            root.lastError = "Wait for Settings to finish restoring before changing a display.";
            return false;
        }
        if (root.busy) {
            root.lastError = "Wait for the current display operation to finish.";
            return false;
        }
        if (root.awaitingConfirmation) {
            root.lastError = "Finish the current display change first.";
            return false;
        }
        const normalized = DisplayLayout.normalize(layout);
        if (!root.validRequestedLayout(normalized)) {
            root.lastError = "That complete display layout is not valid for the connected displays.";
            return false;
        }

        root.pendingPreviousLayout = root.currentLayout();
        root.operationGeneration++;
        root.pendingRequestedLayout = normalized;
        root.pendingVerified = false;
        root.revertQueued = false;
        root.secondsLeft = root.confirmSeconds;
        root.lastError = "";
        countdown.restart();

        root.pushLayout(normalized, applyRun);
        return true;
    }

    // Settings restore holds the external-change lock while it proves a
    // snapshot. This narrow entry point authorizes that one transaction while
    // keeping every user-facing control blocked until restore settles.
    function applyProtectedLayout(layout: var): bool {
        return root.applyLayout(layout, true);
    }

    function makePrimary(output: string): bool {
        const layout = root.currentLayout();
        if (!layout.some(record => record.name === output)) {
            root.lastError = "That display is not connected.";
            return false;
        }
        for (const record of layout)
            record.primary = record.name === output;
        return root.applyLayout(DisplayLayout.normalize(layout));
    }

    function pushLayout(layout: var, runner: var): void {
        const payload = layout.map(record =>
            `hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
        ).join("; ");
        runner.exec(["hyprctl", "eval", payload]);
    }

    function confirm(): bool {
        if (!root.canConfirm
                || !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
            if (root.awaitingConfirmation)
                root.lastError = "Wait for the display to finish applying before keeping it.";
            return false;
        }

        const stored = DesktopPreferences.get("displays");
        const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
        for (const record of root.pendingRequestedLayout) {
            next[record.name] = {
                mode: record.mode,
                scale: record.scale,
                transform: record.transform,
                x: record.x,
                y: record.y,
                primary: record.primary
            };
        }
        if (!DesktopPreferences.set("displays", next)) {
            root.lastError = "That display setting could not be saved. Revert it and try again.";
            return false;
        }

        root.clearPending();
        root.lastError = "";
        return true;
    }

    function clearPending(): void {
        countdown.stop();
        verifyTimer.stop();
        root.pendingPreviousLayout = null;
        root.pendingRequestedLayout = null;
        root.pendingVerified = false;
        root.revertQueued = false;
        root.secondsLeft = 0;
    }

    function revert(): void {
        root.revertWithMessage("");
    }

    function revertWithMessage(message: string): void {
        if (!root.awaitingConfirmation)
            return;
        countdown.stop();
        verifyTimer.stop();
        root.pendingVerified = false;
        root.revertReason = message;
        if (message !== "")
            root.lastError = message;
        if (applyRun.running || query.running) {
            root.revertQueued = true;
            return;
        }
        root.performRevert();
    }

    function performRevert(): void {
        const connected = {};
        for (const monitor of root.monitors)
            connected[monitor.name] = true;
        const previous = (root.pendingPreviousLayout || [])
            .filter(record => connected[record.name])
            .map(record => Object.assign({}, record));
        if (previous.length > 0 && !previous.some(record => record.primary)) {
            const origin = previous.find(record => record.x === 0 && record.y === 0);
            (origin || previous[0]).primary = true;
        }
        root.operationGeneration++;
        root.revertGeneration = root.operationGeneration;
        root.revertExpectedLayout = previous.length > 0 ? previous : null;
        root.revertVerificationActive = false;
        root.clearPending();
        if (previous.length > 0)
            root.pushLayout(previous, revertRun);
        else
            root.lastError = root.revertReason;
    }

    // Clears any stored override for an output so it returns to the value
    // shipped in hypr/monitors.lua on the next start.
    function forget(output: string): void {
        const stored = DesktopPreferences.get("displays");
        if (!stored || typeof stored !== "object" || stored[output] === undefined)
            return;
        const next = Object.assign({}, stored);
        delete next[output];
        DesktopPreferences.set("displays", next);
    }

    function isOverridden(output: string): bool {
        const stored = DesktopPreferences.get("displays");
        return !!(stored && typeof stored === "object" && stored[output] !== undefined);
    }

    function verificationTimedOut(): void {
        root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
    }

    function revertVerificationTimedOut(): void {
        revertVerifyTimer.stop();
        root.revertVerificationActive = false;
        root.revertGeneration = -1;
        root.revertExpectedLayout = null;
        root.revertReason = "";
        root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
    }

    Timer {
        id: identifyTimer
        interval: 3000
        repeat: false
        onTriggered: root.identifying = false
    }

    Timer {
        id: verifyTimer
        property int ticks: 0
        interval: 120
        repeat: true
        onTriggered: {
            ticks++;
            if (ticks > 50) {
                root.verificationTimedOut();
                return;
            }
            root.refresh();
        }
    }

    Timer {
        id: revertVerifyTimer
        property int ticks: 0
        interval: 120
        repeat: true
        onTriggered: {
            ticks++;
            if (ticks > 50) {
                root.revertVerificationTimedOut();
                return;
            }
            root.refresh();
        }
    }

    Timer {
        id: countdown
        interval: 1000
        repeat: true
        onTriggered: {
            root.secondsLeft -= 1;
            if (root.secondsLeft <= 0)
                root.revert();
        }
    }
}
