pragma Singleton

// Home Assistant state for the Control Center. Credentials and REST details
// remain behind the helper; QML composes its catalog with Panama preferences.

import Quickshell
import Quickshell.Io
import QtQuick
import qs.config

Singleton {
    id: root

    readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant"

    // "loading" | "ready" | "degraded" | "unavailable"
    property string phase: "loading"
    property var catalog: []
    property var areas: []
    property var selectedEntities: []
    property bool stale: false
    property string lastError: ""
    property bool fixtureMode: false
    property var fixtureFavorites: []
    property string fixtureProcessMode: ""

    property var actionQueue: []
    property var activeAction: null
    property var busyEntityIds: []
    property var pendingBrightness: ({})
    property var entityErrors: ({})
    property string actionResponseText: ""
    property bool actionStreamFinished: false
    property bool actionExited: false
    property int actionExitCode: 0
    property bool fixtureTransitionDraining: false
    property bool fixtureTransitionStreamFinished: false
    property bool fixtureTransitionExited: false
    property var pendingFixtureTarget: null
    property int delayedFixtureExitCode: 0

    // Catalog lights grouped by Home Assistant area, recomputed whenever either
    // half changes. Both halves are read here so the binding tracks them.
    readonly property var rooms: root.composeRooms(root.catalog, root.areas)

    readonly property var visibleEntities: root.selectedEntities.slice(0, 4)
    readonly property int discoveredCount: root.catalog.length
    readonly property int configuredCount: root.selectedEntities.length
    readonly property bool actionProcessRunning: actionProc.running

    // Temporary aliases keep the current Home controls usable until the shelf
    // switches to the selected-entity and per-entity action interfaces.
    readonly property var entities: root.selectedEntities
    readonly property string busyEntityId: root.busyEntityIds.length > 0
        ? root.busyEntityIds[0]
        : ""

    function refresh(): void {
        if (root.fixtureMode || refreshProc.running)
            return;
        if (root.catalog.length === 0)
            root.phase = "loading";
        refreshProc.running = true;
        if (!areasProc.running)
            areasProc.running = true;
    }

    // Rooms hold the SAME entity objects as the catalog, so a tile bound to a
    // room light sees exactly what the catalog says. Areas keep the order Home
    // Assistant reports them in; catalog lights no area claims fall into a
    // trailing "Other" bucket. With no usable areas the whole catalog becomes a
    // single unnamed bucket, which the UI renders as a flat list with no header.
    function composeRooms(catalog: var, areas: var): var {
        const lights = Array.isArray(catalog) ? catalog : [];
        const flat = [{ id: "", name: "", lights: lights.slice() }];
        if (!Array.isArray(areas) || areas.length === 0)
            return flat;

        const catalogById = {};
        for (let index = 0; index < lights.length; index++)
            catalogById[lights[index].id] = lights[index];

        const claimed = {};
        const grouped = [];
        for (let index = 0; index < areas.length; index++) {
            const area = areas[index];
            const entityIds = area && Array.isArray(area.entities) ? area.entities : [];
            const roomLights = [];
            for (let inner = 0; inner < entityIds.length; inner++) {
                const entity = catalogById[entityIds[inner]];
                if (!entity || claimed[entity.id] === true)
                    continue;
                claimed[entity.id] = true;
                roomLights.push(entity);
            }
            if (roomLights.length === 0)
                continue;
            grouped.push({
                id: String(area.id || ""),
                name: String(area.name || ""),
                lights: roomLights
            });
        }

        // Areas that name nothing in the catalog leave every light an orphan;
        // one "Other" heading over the whole list is worse than no heading.
        if (grouped.length === 0)
            return flat;

        const orphans = lights.filter(entity => claimed[entity.id] !== true);
        if (orphans.length > 0)
            grouped.push({ id: "", name: "Other", lights: orphans });
        return grouped;
    }

    function consumeCatalog(text: string): void {
        if (root.fixtureMode)
            return;

        let result = null;
        try {
            result = JSON.parse(text);
        } catch (error) {
            result = { ok: false, error: "invalid-response" };
        }

        if (result.ok === true) {
            root.catalog = Array.isArray(result.entities) ? result.entities : [];
            HomePreferences.initialize(Array.isArray(result.legacyEntityIds)
                ? result.legacyEntityIds
                : []);
            root.rebuildSelection();
            root.phase = "ready";
            root.stale = false;
            root.lastError = "";
            return;
        }

        root.lastError = String(result.error || "unreachable");
        if (root.catalog.length > 0) {
            root.phase = "degraded";
            root.stale = true;
        } else {
            root.phase = "unavailable";
            root.stale = false;
        }
    }

    // Rooms are a nicety layered over the catalog: a Home Assistant whose
    // template endpoint is unavailable still gets its lights. A failure here
    // only empties `areas` -- it never touches `phase`, `stale`, or `lastError`.
    function consumeAreas(text: string): void {
        if (root.fixtureMode)
            return;

        let result = null;
        try {
            result = JSON.parse(text);
        } catch (error) {
            root.areas = [];
            return;
        }

        root.areas = result && result.ok === true && Array.isArray(result.areas)
            ? result.areas
            : [];
    }

    function rebuildSelection(): void {
        const favorites = root.fixtureMode
            ? root.fixtureFavorites
            : HomePreferences.favorites;
        const catalogById = {};
        for (let index = 0; index < root.catalog.length; index++) {
            const entity = root.catalog[index];
            catalogById[entity.id] = entity;
        }

        const nextSelection = [];
        for (let index = 0; index < favorites.length; index++) {
            const favorite = favorites[index];
            const entity = catalogById[favorite.id];
            const alias = String(favorite.alias || "").trim();
            if (entity) {
                nextSelection.push({
                    id: entity.id,
                    sourceName: entity.sourceName,
                    name: alias || entity.sourceName,
                    state: entity.state,
                    available: entity.available,
                    active: entity.active,
                    dimmable: entity.dimmable,
                    brightnessPct: entity.brightnessPct
                });
                continue;
            }

            const sourceName = favorite.id.split(".")[1].split("_").join(" ");
            nextSelection.push({
                id: favorite.id,
                sourceName,
                name: alias || sourceName,
                state: "unavailable",
                available: false,
                active: false,
                dimmable: false,
                brightnessPct: 0
            });
        }
        root.selectedEntities = nextSelection;
    }

    Connections {
        target: HomePreferences
        function onFavoritesChanged(): void {
            if (!root.fixtureMode)
                root.rebuildSelection();
        }
    }

    function isBusy(entityId: string): bool {
        return root.busyEntityIds.indexOf(entityId) >= 0;
    }

    function pendingFor(entityId: string): int {
        return Object.prototype.hasOwnProperty.call(root.pendingBrightness, entityId)
            ? root.pendingBrightness[entityId]
            : -1;
    }

    function errorFor(entityId: string): string {
        return String(root.entityErrors[entityId] || "");
    }

    function toggleEntity(entityId: string): void {
        root.enqueueAction({ kind: "toggle", entityId });
    }

    function setBrightness(entityId: string, percent: int): void {
        if (!Number.isInteger(percent) || percent < 0 || percent > 100)
            return;
        root.enqueueAction({ kind: "brightness", entityId, percent });
    }

    function enqueueAction(action: var): void {
        if (root.fixtureTransitionDraining)
            return;
        if (!action || (action.kind !== "toggle" && action.kind !== "brightness"))
            return;
        if (typeof action.entityId !== "string" || action.entityId === "" || root.isBusy(action.entityId))
            return;

        const entity = root.catalog.find(candidate => candidate.id === action.entityId);
        if (!entity || !entity.available || (action.kind === "brightness" && !entity.dimmable))
            return;

        const nextErrors = Object.assign({}, root.entityErrors);
        delete nextErrors[action.entityId];
        root.entityErrors = nextErrors;
        root.busyEntityIds = root.busyEntityIds.concat([action.entityId]);

        if (action.kind === "brightness") {
            const nextPending = Object.assign({}, root.pendingBrightness);
            nextPending[action.entityId] = action.percent;
            root.pendingBrightness = nextPending;
        }

        if (root.fixtureMode && root.fixtureProcessMode === "") {
            root.applyFixtureAction(action);
            root.finishActionState(action.entityId);
            return;
        }

        root.actionQueue = root.actionQueue.concat([action]);
        root.startNextAction();
    }

    function startNextAction(): void {
        if (root.fixtureTransitionDraining
                || actionProc.running
                || root.activeAction !== null
                || root.actionQueue.length === 0) {
            return;
        }

        root.activeAction = root.actionQueue[0];
        root.actionResponseText = "";
        root.actionStreamFinished = false;
        root.actionExited = false;
        root.actionExitCode = 0;
        if (root.fixtureProcessMode !== "") {
            actionProc.command = root.fixtureActionCommand(root.activeAction);
        } else {
            actionProc.command = root.activeAction.kind === "brightness"
                ? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)]
                : [root.helperPath, "toggle", root.activeAction.entityId];
        }
        actionProc.running = true;
    }

    function fixtureActionCommand(action: var): var {
        if (root.fixtureProcessMode === "delayed-exit")
            return ["/usr/bin/sh", "-c", "printf '%s' '{\"ok\":true}'; exit 7"];
        if (root.fixtureProcessMode === "no-output"
                && action.entityId === "light.fixture_kitchen") {
            return ["/usr/bin/sh", "-c", "sleep 0.5; exit 7"];
        }
        if (root.fixtureProcessMode === "slow-success")
            return ["/usr/bin/sh", "-c", "sleep 2; printf '%s' '{\"ok\":true}'"];
        return ["/usr/bin/sh", "-c", "sleep 0.5; printf '%s' '{\"ok\":true}'"];
    }

    function handleActionStreamFinished(text: string): void {
        if (root.fixtureTransitionDraining) {
            root.fixtureTransitionStreamFinished = true;
            fixtureTransitionTimer.restart();
            return;
        }
        if (root.activeAction === null || root.actionStreamFinished)
            return;
        root.actionResponseText = text;
        root.actionStreamFinished = true;
        actionCompletionTimer.restart();
    }

    function handleActionExited(exitCode: int): void {
        // This fixture-only delay gives the contract a deterministic window
        // where the process stopped but its exit bookkeeping is still pending.
        if (root.fixtureMode
                && root.fixtureProcessMode === "delayed-exit"
                && root.activeAction !== null
                && !root.fixtureTransitionDraining) {
            root.delayedFixtureExitCode = exitCode;
            delayedFixtureExitTimer.restart();
            return;
        }
        root.recordActionExited(exitCode);
    }

    function recordActionExited(exitCode: int): void {
        if (root.fixtureTransitionDraining) {
            root.fixtureTransitionExited = true;
            fixtureTransitionTimer.restart();
            return;
        }
        if (root.activeAction === null || root.actionExited)
            return;
        root.actionExited = true;
        root.actionExitCode = exitCode;
        actionCompletionTimer.restart();
    }

    function tryCompleteAction(): void {
        if (root.activeAction === null)
            return;
        if (actionProc.running) {
            actionCompletionTimer.restart();
            return;
        }
        if (!root.actionExited || !root.actionStreamFinished)
            return;
        root.consumeAction(root.actionResponseText, root.actionExitCode);
    }

    function consumeAction(text: string, exitCode: int): void {
        if (root.activeAction === null)
            return;

        const completedAction = root.activeAction;
        let ok = false;
        let errorCode = text === "" ? "action-failed" : "invalid-response";
        try {
            const result = JSON.parse(text);
            ok = exitCode === 0 && result.ok === true;
            errorCode = String(result.error || "action-failed");
        } catch (error) {
            // Empty output from a failed process is an action failure, while
            // malformed non-empty output remains an invalid response.
        }

        actionCompletionTimer.stop();
        root.actionQueue = root.actionQueue.slice(1);
        root.activeAction = null;
        root.finishActionState(completedAction.entityId);

        const nextErrors = Object.assign({}, root.entityErrors);
        if (ok) {
            delete nextErrors[completedAction.entityId];
            if (root.fixtureMode && root.fixtureProcessMode !== "")
                root.applyFixtureAction(completedAction);
            else
                refreshDelay.restart();
        } else {
            nextErrors[completedAction.entityId] = errorCode;
        }
        root.entityErrors = nextErrors;
        queueAdvanceTimer.restart();
    }

    function finishActionState(entityId: string): void {
        root.busyEntityIds = root.busyEntityIds.filter(id => id !== entityId);
        const nextPending = Object.assign({}, root.pendingBrightness);
        delete nextPending[entityId];
        root.pendingBrightness = nextPending;
    }

    function applyFixtureAction(action: var): void {
        root.catalog = root.catalog.map(entity => {
            if (entity.id !== action.entityId)
                return entity;
            if (action.kind === "brightness") {
                return Object.assign({}, entity, {
                    state: action.percent === 0 ? "off" : "on",
                    active: action.percent > 0,
                    brightnessPct: action.percent
                });
            }
            return Object.assign({}, entity, {
                active: !entity.active,
                state: entity.active ? "off" : "on"
            });
        });
        root.rebuildSelection();
    }

    function open(): void {
        Quickshell.execDetached([root.helperPath, "open"]);
    }

    function fixtureEntities(): var {
        return [
            { id: "light.fixture_all", sourceName: "All lights", state: "on", available: true, active: true, dimmable: true, brightnessPct: 82 },
            { id: "light.fixture_kitchen", sourceName: "Kitchen", state: "on", available: true, active: true, dimmable: true, brightnessPct: 71 },
            { id: "light.fixture_living", sourceName: "Living room", state: "off", available: true, active: false, dimmable: true, brightnessPct: 36 },
            { id: "light.fixture_bedroom", sourceName: "Bedroom", state: "on", available: true, active: true, dimmable: true, brightnessPct: 48 },
            { id: "light.fixture_hall", sourceName: "Hall", state: "off", available: true, active: false, dimmable: false, brightnessPct: 0 },
            { id: "light.fixture_desk", sourceName: "Desk", state: "on", available: true, active: true, dimmable: true, brightnessPct: 24 },
            { id: "light.fixture_corner", sourceName: "Corner lamp", state: "unavailable", available: false, active: false, dimmable: false, brightnessPct: 0 }
        ];
    }

    // Deliberately leaves light.fixture_all and light.fixture_corner unclaimed
    // so fixtures exercise the trailing "Other" bucket as well as the rooms.
    function fixtureAreas(): var {
        return [
            { id: "kitchen", name: "Kitchen", entities: ["light.fixture_kitchen"] },
            { id: "living_room", name: "Living room", entities: ["light.fixture_living", "light.fixture_desk"] },
            { id: "bedroom", name: "Bedroom", entities: ["light.fixture_bedroom", "light.fixture_hall"] }
        ];
    }

    function fixturePreferenceRecords(): var {
        return [
            { id: "light.fixture_all", alias: "  Whole home  " },
            { id: "light.fixture_kitchen", alias: "Kitchen island" },
            { id: "light.fixture_living", alias: "" },
            { id: "light.fixture_bedroom", alias: "" },
            { id: "light.fixture_hall", alias: "" },
            { id: "light.fixture_desk", alias: "Office" },
            { id: "light.fixture_corner", alias: "" }
        ];
    }

    function resetActionState(): void {
        actionCompletionTimer.stop();
        queueAdvanceTimer.stop();
        root.actionQueue = [];
        root.activeAction = null;
        root.busyEntityIds = [];
        root.pendingBrightness = {};
        root.entityErrors = {};
        root.actionResponseText = "";
        root.actionStreamFinished = false;
        root.actionExited = false;
        root.actionExitCode = 0;
    }

    function beginFixtureTransition(): void {
        if (root.fixtureTransitionDraining)
            return;

        const shouldDrain = root.fixtureMode
            && root.fixtureProcessMode !== ""
            && (actionProc.running || root.activeAction !== null);
        const streamAlreadyFinished = root.actionStreamFinished;
        const exitAlreadyObserved = root.actionExited;

        fixtureTransitionTimer.stop();
        root.fixtureTransitionDraining = shouldDrain;
        root.fixtureTransitionStreamFinished = shouldDrain && streamAlreadyFinished;
        root.fixtureTransitionExited = shouldDrain && exitAlreadyObserved;
        root.resetActionState();

        if (!shouldDrain)
            return;
        if (actionProc.running)
            actionProc.running = false;
        fixtureTransitionTimer.restart();
    }

    function tryFinishFixtureTransition(): void {
        if (!root.fixtureTransitionDraining)
            return;
        if (actionProc.running) {
            fixtureTransitionTimer.restart();
            return;
        }
        if (!root.fixtureTransitionStreamFinished || !root.fixtureTransitionExited)
            return;

        const target = root.pendingFixtureTarget;
        root.pendingFixtureTarget = null;
        root.fixtureTransitionDraining = false;
        root.fixtureTransitionStreamFinished = false;
        root.fixtureTransitionExited = false;
        if (target !== null)
            root.installFixtureTarget(target);
        queueAdvanceTimer.restart();
    }

    function applyFixture(name: string): void {
        if (["ready", "available-extra", "stale", "stale-authentication", "stale-not-configured",
                "unavailable", "missing-selected", "action-error",
                "process-actions", "process-no-output", "process-delayed-exit",
                "process-slow-actions"].indexOf(name) < 0)
            return;

        root.requestFixtureTarget({ fixture: true, name });
    }

    function requestFixtureTarget(target: var): void {
        if (root.fixtureTransitionDraining) {
            // Preserve the old process's callback guard and keep only the
            // latest replacement requested during that drain.
            root.pendingFixtureTarget = target;
            root.resetActionState();
            return;
        }

        root.pendingFixtureTarget = target;
        root.beginFixtureTransition();
        if (root.fixtureTransitionDraining)
            return;

        root.pendingFixtureTarget = null;
        root.installFixtureTarget(target);
    }

    function installFixtureTarget(target: var): void {
        if (target.fixture)
            root.installFixture(target.name);
        else
            root.installLiveState();
    }

    function installFixture(name: string): void {
        root.fixtureMode = true;
        root.fixtureProcessMode = name === "process-actions"
            ? "success"
            : (name === "process-no-output"
                ? "no-output"
                : (name === "process-delayed-exit"
                    ? "delayed-exit"
                    : (name === "process-slow-actions" ? "slow-success" : "")));
        if (name === "unavailable") {
            root.catalog = [];
            root.areas = [];
            root.fixtureFavorites = [];
            root.rebuildSelection();
            root.phase = "unavailable";
            root.stale = false;
            root.lastError = "not-configured";
            return;
        }

        root.catalog = root.fixtureEntities();
        root.areas = root.fixtureAreas();
        root.fixtureFavorites = root.fixturePreferenceRecords();
        if (name === "available-extra") {
            root.catalog = root.catalog.concat([{
                id: "light.fixture_guest",
                sourceName: "Guest lamp",
                state: "off",
                available: true,
                active: false,
                dimmable: true,
                brightnessPct: 0
            }]);
        }
        if (name === "missing-selected") {
            root.fixtureFavorites = root.fixtureFavorites.concat([
                { id: "light.fixture_missing", alias: "Porch" }
            ]);
        }
        root.rebuildSelection();
        const isStale = ["stale", "stale-authentication", "stale-not-configured"].indexOf(name) >= 0;
        root.phase = isStale ? "degraded" : "ready";
        root.stale = isStale;
        root.lastError = name === "stale-authentication"
            ? "authentication-required"
            : (name === "stale-not-configured"
                ? "not-configured"
                : (name === "stale" ? "unreachable" : ""));
        if (name === "action-error") {
            root.entityErrors = {
                "light.fixture_kitchen": "request-failed"
            };
        }
    }

    function clearFixture(): void {
        root.requestFixtureTarget({ fixture: false, name: "" });
    }

    function installLiveState(): void {
        root.fixtureMode = false;
        root.fixtureProcessMode = "";
        root.phase = "loading";
        root.catalog = [];
        root.areas = [];
        root.selectedEntities = [];
        root.fixtureFavorites = [];
        root.stale = false;
        root.lastError = "";
        root.refresh();
    }

    Process {
        id: refreshProc
        command: [root.helperPath, "catalog"]
        stdout: StdioCollector {
            onStreamFinished: root.consumeCatalog(this.text)
        }
    }

    Process {
        id: areasProc
        command: [root.helperPath, "areas"]
        stdout: StdioCollector {
            onStreamFinished: root.consumeAreas(this.text)
        }
    }

    Process {
        id: actionProc
        stdout: StdioCollector {
            onStreamFinished: root.handleActionStreamFinished(this.text)
        }
        onExited: (code, status) => root.handleActionExited(code)
    }

    Timer {
        id: actionCompletionTimer
        interval: 0
        onTriggered: root.tryCompleteAction()
    }

    Timer {
        id: queueAdvanceTimer
        interval: 0
        onTriggered: {
            if (root.fixtureTransitionDraining)
                return;
            if (actionProc.running) {
                queueAdvanceTimer.restart();
                return;
            }
            root.startNextAction();
        }
    }

    Timer {
        id: fixtureTransitionTimer
        interval: 0
        onTriggered: root.tryFinishFixtureTransition()
    }

    Timer {
        id: delayedFixtureExitTimer
        interval: 1000
        onTriggered: root.recordActionExited(root.delayedFixtureExitCode)
    }

    Timer {
        interval: 60000
        repeat: true
        running: ShellState.quickSettingsOpen && !root.fixtureMode
        onTriggered: root.refresh()
    }

    Timer {
        id: refreshDelay
        interval: 350
        onTriggered: root.refresh()
    }

    Component.onCompleted: root.refresh()
}
