pragma Singleton
pragma ComponentBehavior: Bound

import QtCore
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services

// Native MangoWM IPC client. mango advertises a JSON-over-Unix-socket protocol
// via MANGO_INSTANCE_SIGNATURE; each connection issues one `watch <target>` verb
// and gets a full JSON snapshot followed by newline-delimited updates. Exposes
// a dwl-style tag API plus a per-client window list.
Singleton {
    id: root
    readonly property var log: Log.scoped("MangoService")

    readonly property string socketPath: Quickshell.env("MANGO_INSTANCE_SIGNATURE")
    readonly property bool available: socketPath.length > 0

    readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation))
    readonly property string configPath: configDir + "/mango/config.conf"
    readonly property string mangoDmsDir: configDir + "/mango/dms"
    readonly property string bindsPath: mangoDmsDir + "/binds.conf"
    readonly property string colorsPath: mangoDmsDir + "/colors.conf"
    readonly property string outputsPath: mangoDmsDir + "/outputs.conf"
    readonly property string layoutPath: mangoDmsDir + "/layout.conf"
    readonly property string cursorPath: mangoDmsDir + "/cursor.conf"
    readonly property string windowRulesPath: mangoDmsDir + "/windowrules.conf"

    property int _lastGapValue: -1
    property real _ignoreWatchedReloadUntil: 0
    property real _lastWatchedReloadAt: 0

    // name -> { name, active, x, y, width, height, scale, layoutIndex,
    //           layoutSymbol, lastOpenSurface, kbLayout, keymode,
    //           tags: [{ tag, state, clients, focused, urgent, layout }] }
    property var outputs: ({})
    property string activeOutput: ""
    readonly property bool inOverview: isOutputInOverview(activeOutput)
    property int tagCount: 9
    property var displayScales: ({})
    property string currentKeyboardLayout: ""
    // Rich client list from `watch all-clients` (mango "clients").
    property var windows: []

    // windowsChanged is auto-generated by the `windows` property's change signal.
    signal stateChanged

    // One connection per watch target; mango streams a fresh full snapshot on
    // every change, so each line is treated as the complete state.

    FileView {
        id: mangoConfigWatcher
        path: CompositorService.isMango ? root.configPath : ""
        watchChanges: CompositorService.isMango
        onFileChanged: root.handleWatchedConfigChanged()
    }

    FileView {
        id: mangoBindsWatcher
        path: CompositorService.isMango ? root.bindsPath : ""
        watchChanges: CompositorService.isMango
        onFileChanged: root.handleWatchedConfigChanged()
    }

    FileView {
        id: mangoColorsWatcher
        path: CompositorService.isMango ? root.colorsPath : ""
        watchChanges: CompositorService.isMango
        onFileChanged: root.handleWatchedConfigChanged()
    }

    FileView {
        id: mangoLayoutWatcher
        path: CompositorService.isMango ? root.layoutPath : ""
        watchChanges: CompositorService.isMango
        onFileChanged: root.handleWatchedConfigChanged()
    }

    FileView {
        id: mangoCursorWatcher
        path: CompositorService.isMango ? root.cursorPath : ""
        watchChanges: CompositorService.isMango
        onFileChanged: root.handleWatchedConfigChanged()
    }

    FileView {
        id: mangoOutputsWatcher
        path: CompositorService.isMango ? root.outputsPath : ""
        watchChanges: CompositorService.isMango
        onFileChanged: root.handleWatchedConfigChanged()
    }

    FileView {
        id: mangoWindowRulesWatcher
        path: CompositorService.isMango ? root.windowRulesPath : ""
        watchChanges: CompositorService.isMango
        onFileChanged: root.handleWatchedConfigChanged()
    }

    DankSocket {
        id: monitorsSocket
        path: root.socketPath
        connected: root.available

        onConnectionStateChanged: {
            if (connected)
                send("watch all-monitors");
        }

        parser: SplitParser {
            onRead: line => root._handleMonitors(line)
        }
    }

    DankSocket {
        id: clientsSocket
        path: root.socketPath
        connected: root.available

        onConnectionStateChanged: {
            if (connected)
                send("watch all-clients");
        }

        parser: SplitParser {
            onRead: line => root._handleClients(line)
        }
    }

    // mango closes the connection after each non-watch command; queued
    // dispatches drain one per reconnect cycle.
    DankSocket {
        id: dispatchSocket
        path: root.socketPath
        connected: root.available
        reconnectBaseMs: 25

        onConnectionStateChanged: {
            if (linkUp) {
                root._dispatchConnectionUsed = false;
                root._flushDispatch();
                return;
            }
            const entry = root._dispatchInFlight;
            root._dispatchInFlight = null;
            if (entry?.callback)
                entry.callback("");
        }

        parser: SplitParser {
            onRead: line => root._onDispatchReply(line)
        }
    }

    property var _dispatchQueue: []
    property var _dispatchInFlight: null
    property bool _dispatchConnectionUsed: false

    function dispatch(command, callback) {
        if (!root.available)
            return;
        _dispatchQueue.push({
            "command": command,
            "callback": callback ?? null
        });
        _flushDispatch();
    }

    function _flushDispatch() {
        if (_dispatchInFlight || _dispatchConnectionUsed || _dispatchQueue.length === 0 || !dispatchSocket.linkUp)
            return;
        _dispatchConnectionUsed = true;
        _dispatchInFlight = _dispatchQueue.shift();
        dispatchSocket.send("dispatch " + _dispatchInFlight.command);
    }

    function _onDispatchReply(line) {
        const entry = _dispatchInFlight;
        _dispatchInFlight = null;
        if (entry?.callback)
            entry.callback(line);
    }

    function _handleMonitors(line) {
        if (!line || !line.trim())
            return;
        let data;
        try {
            data = JSON.parse(line);
        } catch (e) {
            log.warn("Failed to parse all-monitors:", e);
            return;
        }
        const monitors = data.monitors;
        if (!Array.isArray(monitors))
            return;

        const newOutputs = {};
        const newScales = {};
        let newActive = "";
        let newTagCount = root.tagCount;
        let newKbLayout = root.currentKeyboardLayout;

        for (const m of monitors) {
            if (!m.name)
                continue;
            const activeTags = m.active_tags || [];
            const inOverview = activeTags.length === 0 || activeTags.every(t => t === 0);
            const tags = (m.tags || []).map(t => ({
                        // 0-based to match the legacy dwl tag model used by consumers
                        "tag": (t.index ?? 1) - 1,
                        "state": t.is_urgent ? 2 : (!inOverview && t.is_active ? 1 : 0),
                        "clients": t.client_count ?? 0,
                        "focused": !inOverview && !!t.is_active,
                        "urgent": !!t.is_urgent,
                        "layout": t.layout ?? ""
                    }));
            newOutputs[m.name] = {
                "name": m.name,
                "active": !!m.active,
                "x": m.x ?? 0,
                "y": m.y ?? 0,
                "width": m.width ?? 0,
                "height": m.height ?? 0,
                "scale": m.scale ?? 1.0,
                "layoutIndex": m.layout_index ?? 0,
                "layout": m.layout_index ?? 0,
                "activeTags": activeTags,
                "inOverview": inOverview,
                "layoutSymbol": m.layout_symbol ?? "",
                "lastOpenSurface": m.last_open_surface ?? "",
                "keymode": m.keymode ?? "",
                "kbLayout": m.keyboardlayout ?? "",
                "tags": tags
            };
            if (typeof m.scale === "number" && m.scale > 0)
                newScales[m.name] = m.scale;
            if (m.active) {
                newActive = m.name;
                if (m.keyboardlayout)
                    newKbLayout = m.keyboardlayout;
            }
            if (tags.length > 0)
                newTagCount = tags.length;
        }

        root.outputs = newOutputs;
        root.displayScales = newScales;
        root.tagCount = newTagCount;
        if (newActive)
            root.activeOutput = newActive;
        root.currentKeyboardLayout = newKbLayout;
        root.stateChanged();
    }

    function _handleClients(line) {
        if (!line || !line.trim())
            return;
        let data;
        try {
            data = JSON.parse(line);
        } catch (e) {
            log.warn("Failed to parse all-clients:", e);
            return;
        }
        if (!Array.isArray(data.clients))
            return;
        root.windows = data.clients;
    }

    function getOutputState(outputName) {
        return (outputs && outputs[outputName]) ? outputs[outputName] : null;
    }

    function getActiveTags(outputName) {
        const output = getOutputState(outputName);
        if (!output || !output.tags)
            return [];
        return output.tags.filter(tag => tag.state === 1).map(tag => tag.tag);
    }

    // mango reports active_tags=[0] (no real tag selected) while the overview is open.
    function isOutputInOverview(outputName) {
        const output = getOutputState(outputName);
        if (!output)
            return false;
        if (output.inOverview !== undefined)
            return output.inOverview;
        const at = output.activeTags || [];
        return at.length === 0 || at.every(t => t === 0);
    }

    function getVisibleTags(outputName) {
        const output = getOutputState(outputName);
        if (!output || !output.tags)
            return [];
        if (isOutputInOverview(outputName))
            return [];
        const visibleTags = new Set();
        output.tags.forEach(tag => {
            if (tag.state === 1 || tag.clients > 0)
                visibleTags.add(tag.tag);
        });
        return Array.from(visibleTags).sort((a, b) => a - b);
    }

    function getOutputScale(outputName) {
        return displayScales[outputName];
    }

    // Match mango clients to wlr foreign-toplevels by appId+title to enrich them
    // with owning tags/monitor for per-tag filtering and stable ordering.

    function _screenName(screenOrName) {
        return (typeof screenOrName === "string") ? screenOrName : (screenOrName?.name ?? "");
    }

    function _orderedClients() {
        const list = (windows || []).slice();
        list.sort((a, b) => {
            const ma = outputs[a.monitor], mb = outputs[b.monitor];
            const ax = ma?.x ?? 1e9, ay = ma?.y ?? 1e9;
            const bx = mb?.x ?? 1e9, by = mb?.y ?? 1e9;
            if (ax !== bx)
                return ax - bx;
            if (ay !== by)
                return ay - by;
            if ((a.y ?? 0) !== (b.y ?? 0))
                return (a.y ?? 0) - (b.y ?? 0);
            if ((a.x ?? 0) !== (b.x ?? 0))
                return (a.x ?? 0) - (b.x ?? 0);
            return (a.id ?? 0) - (b.id ?? 0);
        });
        return list;
    }

    function _matchAndEnrich(toplevels, clients) {
        const used = new Set();
        const result = [];
        for (const client of clients) {
            let bestMatch = null;
            let bestScore = -1;
            for (const toplevel of toplevels) {
                if (used.has(toplevel))
                    continue;
                if (toplevel.appId !== client.appid)
                    continue;
                let score = 1;
                if (client.title && toplevel.title) {
                    if (toplevel.title === client.title)
                        score = 3;
                    else if (toplevel.title.includes(client.title) || client.title.includes(toplevel.title))
                        score = 2;
                }
                if (score > bestScore) {
                    bestScore = score;
                    bestMatch = toplevel;
                    if (score === 3)
                        break;
                }
            }
            if (!bestMatch)
                continue;
            used.add(bestMatch);

            const enriched = {
                "appId": bestMatch.appId,
                "title": bestMatch.title,
                "activated": !!client.is_focused,
                "mangoWindowId": client.id,
                "mangoTags": client.tags || [],
                "mangoMonitor": client.monitor
            };
            for (let prop in bestMatch) {
                if (!(prop in enriched))
                    enriched[prop] = bestMatch[prop];
            }
            result.push(enriched);
        }
        return result;
    }

    function sortToplevels(toplevels) {
        if (!toplevels || toplevels.length === 0 || windows.length === 0)
            return [...toplevels];
        const enriched = _matchAndEnrich(toplevels, _orderedClients());
        const used = new Set(enriched.map(e => e.mangoWindowId));
        // Append wlr toplevels that had no mango client match (rare).
        const matchedTitles = new Set(enriched.map(e => e.title + "\u0000" + e.appId));
        for (const t of toplevels) {
            if (!matchedTitles.has((t.title || "") + "\u0000" + (t.appId || "")))
                enriched.push(t);
        }
        return enriched;
    }

    function _activeTagSet(screenName) {
        const out = outputs[screenName];
        return new Set((out?.activeTags) || []);
    }

    function filterCurrentWorkspace(toplevels, screenOrName) {
        const screenName = _screenName(screenOrName);
        if (!screenName)
            return toplevels;
        // Overview reports active_tags=[0], which would match no client tags.
        if (isOutputInOverview(screenName))
            return toplevels;
        const active = _activeTagSet(screenName);
        if (active.size === 0)
            return toplevels;

        const onActive = tags => (tags || []).some(t => active.has(t));

        if (toplevels.length > 0 && toplevels[0].mangoTags !== undefined)
            return toplevels.filter(t => t.mangoMonitor === screenName && onActive(t.mangoTags));

        const clients = (windows || []).filter(c => c.monitor === screenName && onActive(c.tags));
        return _matchAndEnrich(toplevels, clients);
    }

    function filterCurrentDisplay(toplevels, screenOrName) {
        const screenName = _screenName(screenOrName);
        if (!toplevels || toplevels.length === 0 || !screenName)
            return toplevels;

        if (toplevels.length > 0 && toplevels[0].mangoMonitor !== undefined)
            return toplevels.filter(t => t.mangoMonitor === screenName);

        const clients = (windows || []).filter(c => c.monitor === screenName);
        return _matchAndEnrich(toplevels, clients);
    }

    function suppressWatchedConfigReloads(ms) {
        root._ignoreWatchedReloadUntil = Math.max(root._ignoreWatchedReloadUntil, Date.now() + (ms || 1500));
    }

    function handleWatchedConfigChanged() {
        if (!CompositorService.isMango || !root.available)
            return;
        const now = Date.now();
        if (now < root._ignoreWatchedReloadUntil)
            return;
        if (now - root._lastWatchedReloadAt < 700)
            return;
        root._lastWatchedReloadAt = now;
        root.reloadConfig(false, false);
    }

    function reloadConfig(showToast, suppressWatch) {
        if (!CompositorService.isMango || !root.available)
            return;
        const shouldShowToast = showToast !== false && SessionData.showConfigReloadToast;
        const shouldSuppressWatch = suppressWatch !== false;
        if (shouldSuppressWatch)
            suppressWatchedConfigReloads(1500);
        dispatch("reload_config", line => {
            let ok = false;
            try {
                ok = JSON.parse(line).success === true;
            } catch (e) {}
            if (!ok) {
                log.warn("reload_config dispatch failed:", line);
                if (shouldShowToast)
                    ToastService.showError(I18n.tr("mango: failed to reload config"), line || "", "", "mango-config");
                return;
            }
            if (shouldShowToast)
                ToastService.showInfo(I18n.tr("mango: config reloaded"), "", "", "mango-config");
        });
    }

    function quit() {
        dispatch("quit");
    }

    // mango tag dispatches act on the focused monitor; tagIndex is 0-based
    // (dwl model), mango `view`/`toggleview` take a 1-based tag number.
    function switchToTag(outputName, tagIndex) {
        dispatch("view," + (tagIndex + 1));
    }

    function toggleTag(outputName, tagIndex) {
        dispatch("toggleview," + (tagIndex + 1));
    }

    // mango's tiling layouts are a fixed compiled-in set the IPC doesn't expose,
    // so mirror it here in mango's layouts[] order (layout_index aligns). The
    // parallel name list exists because `setlayout` dispatches by name, not index.
    readonly property var layouts: ["T", "S", "G", "M", "K", "CT", "RT", "VS", "VT", "VG", "VK", "DW", "F", "VF"]
    readonly property var _layoutNames: ["tile", "scroller", "grid", "monocle", "deck", "center_tile", "right_tile", "vertical_scroller", "vertical_tile", "vertical_grid", "vertical_deck", "dwindle", "fair", "vertical_fair"]

    function setLayout(outputName, index) {
        const name = _layoutNames[index];
        if (name)
            dispatch("setlayout," + name);
    }

    function cycleKeyboardLayout() {
        dispatch("switch_keyboard_layout");
    }

    function powerOffMonitors() {
        const screens = Quickshell.screens || [];
        for (let i = 0; i < screens.length; i++) {
            if (screens[i] && screens[i].name)
                dispatch("sleep_monitor," + screens[i].name);
        }
    }

    function powerOnMonitors() {
        const screens = Quickshell.screens || [];
        for (let i = 0; i < screens.length; i++) {
            if (screens[i] && screens[i].name)
                dispatch("wakeup_monitor," + screens[i].name);
        }
    }

    Connections {
        target: SettingsData
        function onBarConfigsChanged() {
            if (!CompositorService.isMango)
                return;
            const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4));
            if (newGaps === root._lastGapValue)
                return;
            root._lastGapValue = newGaps;
            generateLayoutConfig();
        }
    }

    Connections {
        target: CompositorService
        function onIsMangoChanged() {
            if (CompositorService.isMango)
                generateLayoutConfig();
        }
    }

    function transformToMango(transform) {
        switch (transform) {
        case "Normal":
            return 0;
        case "90":
            return 1;
        case "180":
            return 2;
        case "270":
            return 3;
        case "Flipped":
            return 4;
        case "Flipped90":
            return 5;
        case "Flipped180":
            return 6;
        case "Flipped270":
            return 7;
        default:
            return 0;
        }
    }

    function generateOutputsConfig(outputsData, callback) {
        if (!outputsData || Object.keys(outputsData).length === 0) {
            if (callback)
                callback(false);
            return;
        }
        let lines = ["# Auto-generated by DMS - do not edit manually", ""];

        for (const outputName in outputsData) {
            const output = outputsData[outputName];
            if (!output)
                continue;
            let width = 1920;
            let height = 1080;
            let refreshRate = 60;
            const configured = (output.configured_mode || "").match(/^(\d+)x(\d+)@([\d.]+)$/);
            if (configured) {
                width = parseInt(configured[1], 10);
                height = parseInt(configured[2], 10);
                refreshRate = Math.round(parseFloat(configured[3]));
            } else if (output.modes && output.current_mode !== undefined) {
                const mode = output.modes[output.current_mode];
                if (mode) {
                    width = mode.width || 1920;
                    height = mode.height || 1080;
                    refreshRate = Math.round((mode.refresh_rate || 60000) / 1000);
                }
            }

            const x = output.logical?.x ?? 0;
            const y = output.logical?.y ?? 0;
            const scale = output.logical?.scale ?? 1.0;
            const transform = transformToMango(output.logical?.transform ?? "Normal");
            const vrr = output.vrr_enabled ? 1 : 0;

            // Anchor the name regex: mango matches `name:` unanchored (first-match
            // wins), so a bare "DP-1" would also match "eDP-1" and collapse outputs.
            const rule = ["name:^" + outputName + "$", "width:" + width, "height:" + height, "refresh:" + refreshRate, "x:" + x, "y:" + y, "scale:" + scale, "rr:" + transform, "vrr:" + vrr].join(",");

            lines.push("monitorrule=" + rule);
        }

        lines.push("");

        const content = lines.join("\n");

        suppressWatchedConfigReloads(1500);
        Proc.runCommand("mango-write-outputs", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
            if (exitCode !== 0) {
                log.warn("Failed to write outputs config:", output);
                if (callback)
                    callback(false);
                return;
            }
            log.info("Generated outputs config at", outputsPath);
            if (CompositorService.isMango)
                reloadConfig(false);
            if (callback)
                callback(true);
        });
    }

    function generateLayoutConfig() {
        if (!CompositorService.isMango)
            return;

        const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12;
        const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4;
        const defaultBorderSize = 2;

        const cornerRadius = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutRadiusOverride >= 0) ? SettingsData.mangoLayoutRadiusOverride : defaultRadius;
        const gapsOverride = typeof SettingsData !== "undefined" ? SettingsData.mangoLayoutGapsOverride : -1;
        const manageGaps = gapsOverride !== -2;
        const gapsIn = gapsOverride >= 0 ? gapsOverride : defaultGaps;
        const gapsOut = (gapsOverride >= 0 && SettingsData.mangoLayoutGapsOutOverride >= 0) ? SettingsData.mangoLayoutGapsOutOverride : gapsIn;
        const borderSize = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutBorderSize >= 0) ? SettingsData.mangoLayoutBorderSize : defaultBorderSize;

        let content = `# Auto-generated by DMS - do not edit manually
border_radius=${cornerRadius}
borderpx=${borderSize}
`;

        if (manageGaps)
            content += `gappih=${gapsIn}
gappiv=${gapsIn}
gappoh=${gapsOut}
gappov=${gapsOut}
`;

        suppressWatchedConfigReloads(1500);
        Proc.runCommand("mango-write-layout", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${layoutPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
            if (exitCode !== 0) {
                log.warn("Failed to write layout config:", output);
                return;
            }
            log.info("Generated layout config at", layoutPath);
            reloadConfig(false);
        });
    }

    function generateCursorConfig() {
        if (!CompositorService.isMango)
            return;

        const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null;
        if (!settings) {
            suppressWatchedConfigReloads(1500);
            Proc.runCommand("mango-write-cursor", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && : > "${cursorPath}"`], (output, exitCode) => {
                if (exitCode !== 0)
                    log.warn("Failed to write cursor config:", output);
            });
            return;
        }

        const themeName = settings.theme === "System Default" ? (SettingsData.systemDefaultCursorTheme || "") : settings.theme;
        const size = settings.size || 24;
        const hideTimeout = settings.mango?.cursorHideTimeout || 0;
        const naturalScrolling = SettingsData.mangoTrackpadNaturalScrolling ? 1 : 0;

        let content = `# Auto-generated by DMS - do not edit manually
trackpad_natural_scrolling=${naturalScrolling}
cursor_size=${size}`;

        if (themeName)
            content += `\ncursor_theme=${themeName}`;

        if (hideTimeout > 0)
            content += `\ncursor_hide_timeout=${hideTimeout}`;

        content += `\n`;

        suppressWatchedConfigReloads(1500);
        Proc.runCommand("mango-write-cursor", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${cursorPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
            if (exitCode !== 0) {
                log.warn("Failed to write cursor config:", output);
                return;
            }
            log.info("Generated cursor config at", cursorPath);
            reloadConfig(false);
        });
    }
}
