pragma Singleton

// The health helper is deliberately not a state owner. This singleton accepts
// complete, typed snapshots and keeps the last valid one available while a
// later scan or repair fails.

import Quickshell
import Quickshell.Io
import QtQuick

Singleton {
    id: root

    property var snapshot: ({})
    property var checks: []
    property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
    property string status: "healthy"
    property bool diagnosticUnavailable: false
    property bool queuedRefresh: false
    property int generation: 0
    property int acceptedGeneration: 0
    property string lastError: ""
    property string repairingId: ""
    property var lastRepair: ({})
    property string lastCopyResult: ""
    property bool startupScanEnabled: true
    property bool postRepairScanPending: false

    readonly property bool actionable: root.status === "warning" || root.status === "error"
    readonly property bool busy: scanProcess.running || repairProcess.running || root.postRepairScanPending
    readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
        || Quickshell.shellDir + "/scripts/panama-doctor"
    readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
    readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
    readonly property var overallStatuses: ["healthy", "warning", "error"]
    readonly property var settingsTargets: ["my-home", "datetime"]
    readonly property var instructionTargets: ["ddc-permissions"]

    Process {
        id: scanProcess

        property int scanGeneration: 0
        property string outputText: ""
        property int exitCode: -1
        property bool exited: false
        property bool streamFinished: false
        property bool settled: false

        stdout: StdioCollector {
            id: scanOutput
            property int generation: 0
            onStreamFinished: {
                scanProcess.outputText = this.text;
                scanProcess.scanGeneration = generation;
                scanProcess.streamFinished = true;
                root.settleScan();
            }
        }

        onExited: (exitCode, exitStatus) => {
            scanProcess.exitCode = exitCode;
            scanProcess.exited = true;
            root.settleScan();
        }
    }

    Process {
        id: repairProcess

        property string checkId: ""
        property bool external: false
        property string outputText: ""
        property int exitCode: -1
        property bool exited: false
        property bool streamFinished: false
        property bool settled: false

        stdout: StdioCollector {
            onStreamFinished: {
                repairProcess.outputText = this.text;
                repairProcess.streamFinished = true;
                root.settleRepair();
            }
        }

        onExited: (exitCode, exitStatus) => {
            repairProcess.exitCode = exitCode;
            repairProcess.exited = true;
            root.settleRepair();
        }
    }

    Process {
        id: copyProcess

        property string payload: ""

        stdinEnabled: true
        onStarted: {
            copyProcess.write(copyProcess.payload);
            // wl-copy reads stdin until EOF before it exits; leaving the
            // channel open (Process.write alone never closes it) would hang
            // it forever waiting for more input. Disabling stdin closes the
            // write side -- see HomeAssistantConfig.qml's writeProc for the
            // same stdinEnabled pattern.
            copyProcess.stdinEnabled = false;
        }
        onExited: (exitCode, exitStatus) => {
            root.lastCopyResult = exitCode === 0
                ? "Report copied."
                : "Could not copy the health report."
            copyProcess.payload = ""
        }
    }

    Process {
        id: failureNotification
    }

    Timer {
        id: startupScan
        interval: 2200
        repeat: false
        running: root.startupScanEnabled
        onTriggered: root.refresh()
    }

    function refresh(): bool {
        if (root.postRepairScanPending)
            return false;
        if (scanProcess.running || repairProcess.running) {
            root.queuedRefresh = true;
            return false;
        }

        root.generation += 1;
        scanProcess.scanGeneration = root.generation;
        scanProcess.outputText = "";
        scanProcess.exitCode = -1;
        scanProcess.exited = false;
        scanProcess.streamFinished = false;
        scanProcess.settled = false;
        scanOutput.generation = root.generation;
        scanProcess.exec([root.helperPath, "--json"]);
        return true;
    }

    function settleScan(): void {
        if (scanProcess.settled || !scanProcess.exited || !scanProcess.streamFinished)
            return;
        scanProcess.settled = true;
        root.finishScan(scanProcess.exitCode, scanProcess.scanGeneration, scanProcess.outputText);
    }

    function finishScan(exitCode: int, scanGeneration: int, text: string): void {
        if (exitCode === 0)
            root.consumeSnapshot(text, scanGeneration);
        else
            root.rejectSnapshot("Panama diagnostics could not be read. Try refreshing.");

        if (!root.queuedRefresh)
            return;
        root.queuedRefresh = false;
        root.refresh();
    }

    function consumeSnapshot(text: string, scanGeneration: int): bool {
        if (scanGeneration < root.acceptedGeneration)
            return false;

        let candidate;
        try {
            candidate = JSON.parse(text.trim());
        } catch (error) {
            root.rejectSnapshot("Panama diagnostics returned an unreadable response.");
            return false;
        }

        if (!root.validSnapshot(candidate)) {
            root.rejectSnapshot("Panama diagnostics returned an invalid response.");
            return false;
        }

        const accepted = root.safeSnapshot(candidate);
        root.snapshot = accepted;
        root.checks = accepted.checks;
        root.summary = accepted.summary;
        root.status = accepted.summary.status;
        root.acceptedGeneration = scanGeneration;
        root.diagnosticUnavailable = false;
        root.lastError = "";
        return true;
    }

    function rejectSnapshot(message: string): void {
        root.diagnosticUnavailable = true;
        root.lastError = message;
    }

    function safeSnapshot(candidate: var): var {
        return {
            schemaVersion: 1,
            generatedAt: candidate.generatedAt,
            summary: {
                status: candidate.summary.status,
                healthy: candidate.summary.healthy,
                warnings: candidate.summary.warnings,
                errors: candidate.summary.errors,
                unconfigured: candidate.summary.unconfigured
            },
            context: {
                session: candidate.context.session,
                versions: candidate.context.versions.map(version => ({
                    id: version.id,
                    version: version.version
                }))
            },
            checks: candidate.checks.map(check => root.safeCheck(check))
        };
    }

    function safeCheck(candidate: var): var {
        const check = {
            id: candidate.id,
            group: candidate.group,
            title: candidate.title,
            status: candidate.status,
            detail: candidate.detail
        };
        if (candidate.action !== undefined)
            check.action = root.safeAction(candidate.action);
        return check;
    }

    function safeAction(candidate: var): var {
        const action = {
            kind: candidate.kind,
            label: candidate.label,
            confirm: candidate.confirm
        };
        if (Object.prototype.hasOwnProperty.call(candidate, "target"))
            action.target = candidate.target;
        return action;
    }

    function repair(id: string, external: bool): bool {
        if (root.busy)
            return false;

        const check = root.checks.find(candidate => candidate.id === id);
        if (!check || !check.action || check.action.kind !== "repair")
            return false;
        if (external && check.action.confirm)
            return false;

        root.repairingId = id;
        root.lastError = "";
        repairProcess.checkId = id;
        repairProcess.external = external;
        repairProcess.outputText = "";
        repairProcess.exitCode = -1;
        repairProcess.exited = false;
        repairProcess.streamFinished = false;
        repairProcess.settled = false;
        repairProcess.exec([root.helperPath, "--repair", id, "--json"]);
        return true;
    }

    function settleRepair(): void {
        if (repairProcess.settled || !repairProcess.exited || !repairProcess.streamFinished)
            return;
        repairProcess.settled = true;
        root.finishRepair(
            repairProcess.exitCode,
            repairProcess.checkId,
            repairProcess.external,
            repairProcess.outputText
        );
    }

    function finishRepair(exitCode: int, id: string, external: bool, text: string): void {
        let candidate;
        try {
            candidate = JSON.parse(text.trim());
        } catch (error) {
            candidate = null;
        }

        const result = root.validRepairResult(candidate, id, exitCode)
            ? {
                schemaVersion: 1,
                checkId: candidate.checkId,
                accepted: candidate.accepted,
                exitCode: candidate.exitCode,
                message: candidate.message
            }
            : {
                schemaVersion: 1,
                checkId: id,
                accepted: false,
                exitCode: exitCode,
                message: "Panama returned an invalid repair response."
            };
        const failed = !result.accepted || result.exitCode !== 0;
        root.repairingId = "";
        root.lastRepair = result;
        root.lastError = failed ? result.message : "";
        if (failed && external && !failureNotification.running) {
                failureNotification.exec([
                    "notify-send", "-a", "Panama", "-i", "dialog-error-symbolic",
                    "Panama action failed", "The requested health repair could not be completed."
                ]);
        }

        // Any refresh requested while the repair was running is satisfied by
        // this one observed post-repair scan. Process exit alone never changes
        // the accepted check rows.
        root.queuedRefresh = false;
        root.postRepairScanPending = true;
        Qt.callLater(root.startPostRepairScan);
    }

    function startPostRepairScan(): void {
        root.postRepairScanPending = false;
        root.queuedRefresh = false;
        root.refresh();
    }

    function validRepairResult(candidate: var, id: string, processExitCode: int): bool {
        if (!root.plainObject(candidate))
            return false;
        const keys = Object.keys(candidate).sort();
        const expectedKeys = ["accepted", "checkId", "exitCode", "message", "schemaVersion"];
        if (keys.length !== expectedKeys.length
                || !keys.every((key, index) => key === expectedKeys[index]))
            return false;
        return candidate.schemaVersion === 1
            && candidate.checkId === id
            && typeof candidate.accepted === "boolean"
            && Number.isInteger(candidate.exitCode)
            && candidate.exitCode === processExitCode
            && typeof candidate.message === "string"
            && candidate.message.length > 0;
    }

    function copyReport(): bool {
        if (copyProcess.running)
            return false;
        copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
        root.lastCopyResult = "";
        // Re-arm stdin: the previous run closed it (see copyProcess.onStarted)
        // and a disabled channel stays closed even after being set back to
        // true mid-run, so each new run needs it explicitly re-enabled.
        copyProcess.stdinEnabled = true;
        copyProcess.exec(["wl-copy"]);
        return true;
    }

    function diagnostics(): var {
        return {
            status: root.status,
            summary: root.summary,
            busy: root.busy,
            diagnosticUnavailable: root.diagnosticUnavailable,
            queuedRefresh: root.queuedRefresh,
            generation: root.generation,
            acceptedGeneration: root.acceptedGeneration,
            repairingId: root.repairingId,
            lastRepair: root.lastRepair,
            lastError: root.lastError,
            checks: root.checks.map(check => check.id),
            checkStates: root.checks.map(check => ({ id: check.id, status: check.status }))
        };
    }

    function validSnapshot(candidate: var): bool {
        if (!root.plainObject(candidate)
                || candidate.schemaVersion !== 1
                || typeof candidate.generatedAt !== "string" || candidate.generatedAt.length === 0
                || !root.validSummary(candidate.summary)
                || !root.validContext(candidate.context)
                || !Array.isArray(candidate.checks) || candidate.checks.length === 0)
            return false;

        const ids = {};
        const counts = { ok: 0, warning: 0, error: 0, unconfigured: 0 };
        for (const check of candidate.checks) {
            if (!root.validCheck(check) || ids[check.id])
                return false;
            ids[check.id] = true;
            counts[check.status] += 1;
        }

        const computedStatus = counts.error > 0 ? "error" : counts.warning > 0 ? "warning" : "healthy";
        return candidate.summary.healthy === counts.ok
            && candidate.summary.warnings === counts.warning
            && candidate.summary.errors === counts.error
            && candidate.summary.unconfigured === counts.unconfigured
            && candidate.summary.status === computedStatus;
    }

    function validSummary(candidate: var): bool {
        if (!root.plainObject(candidate) || root.overallStatuses.indexOf(candidate.status) < 0)
            return false;
        for (const key of ["healthy", "warnings", "errors", "unconfigured"]) {
            if (!Number.isInteger(candidate[key]) || candidate[key] < 0)
                return false;
        }
        return true;
    }

    function validContext(candidate: var): bool {
        if (!root.plainObject(candidate)
                || ["hyprland", "other"].indexOf(candidate.session) < 0
                || !Array.isArray(candidate.versions))
            return false;
        return candidate.versions.every(version => root.plainObject(version)
            && typeof version.id === "string" && version.id.length > 0
            && typeof version.version === "string" && version.version.length > 0);
    }

    function validCheck(candidate: var): bool {
        if (!root.plainObject(candidate)
                || !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/.test(candidate.id)
                || root.groups.indexOf(candidate.group) < 0
                || root.statuses.indexOf(candidate.status) < 0
                || typeof candidate.title !== "string" || candidate.title.length === 0
                || typeof candidate.detail !== "string" || candidate.detail.length === 0)
            return false;
        return candidate.action === undefined || root.validAction(candidate.action);
    }

    function validAction(candidate: var): bool {
        if (!root.plainObject(candidate)
                || ["repair", "open", "instructions"].indexOf(candidate.kind) < 0
                || typeof candidate.label !== "string" || candidate.label.length === 0
                || typeof candidate.confirm !== "boolean")
            return false;

        const allowedKeys = ["kind", "label", "confirm", "target"];
        if (!Object.keys(candidate).every(key => allowedKeys.indexOf(key) >= 0))
            return false;

        const hasTarget = Object.prototype.hasOwnProperty.call(candidate, "target");
        if (!hasTarget)
            return true;
        if (typeof candidate.target !== "string" || candidate.target.length === 0 || candidate.kind === "repair")
            return false;
        return candidate.kind === "open"
            ? root.settingsTargets.indexOf(candidate.target) >= 0
            : root.instructionTargets.indexOf(candidate.target) >= 0;
    }

    function plainObject(value: var): bool {
        return typeof value === "object" && value !== null && !Array.isArray(value);
    }
}
