/**
 * StreamManager u2014 orchestrates screen-streamer and input-injector Swift
 * child processes, and broadcasts H.264 frames to WebSocket clients.
 *
 * Responsibilities:
 *  - Lifecycle: start/stop child processes on demand (idle timeout).
 *  - Frame broadcasting: relay video frames and screen-info messages.
 *  - Input routing: translate client binary messages into stdin commands.
 *  - Clipboard: pbpaste / pbcopy integration.
 *  - macOS actions: mission-control, launchpad, etc. via input-injector.
 */
import { execFile, spawn } from "node:child_process";
import { logger } from "../../logger.js";
import { MSG_S2C_VIDEO, MSG_S2C_SCREEN_INFO, MSG_S2C_CLIPBOARD, MSG_C2S_POINTER, MSG_C2S_KEY, MSG_C2S_CONFIG, MSG_C2S_SWITCH, MSG_C2S_CLIPBOARD_GET, MSG_C2S_CLIPBOARD_SET, MSG_C2S_ACTION, } from "./protocol.js";
import { ensureSwiftBinary, startStreamerProcess, startInjectorProcess, killProcess, } from "./swift-process.js";
import { StreamerParser } from "./streamer-parser.js";
// ---------------------------------------------------------------------------
// Adaptive bitrate/fps ladder
// ---------------------------------------------------------------------------
/**
 * Quality ladder for the adaptive controller. Each entry is a tuple of
 * (bitrate bps, fps). Lower indices = lower quality / bandwidth, higher
 * indices = better quality. The controller starts at INITIAL_LEVEL and
 * walks up/down based on WebSocket bufferedAmount (TCP send-queue depth).
 *
 * fps choices are intentionally "sticky": changing fps forces the Swift
 * encoder to rebuild (drops 1–2 frames), so neighbouring levels share the
 * same fps where possible. That way common 1-step bitrate nudges during
 * mild congestion cost zero frames.
 */
const QUALITY_LADDER = [
    { bitrate: 200_000, fps: 4 }, // L0 — dire path (heavy loss / saturated intl egress)
    { bitrate: 400_000, fps: 6 }, // L1 — severely congested path
    { bitrate: 700_000, fps: 8 }, // L2 — initial (realistic cross-Pacific default)
    { bitrate: 1_200_000, fps: 10 }, // L3 — moderate cross-region link
    { bitrate: 2_000_000, fps: 10 }, // L4 — shares fps with L3 (bitrate-only step)
    { bitrate: 3_000_000, fps: 15 }, // L5 — LAN / low-latency
];
const INITIAL_LEVEL = 2;
/**
 * bufferedAmount thresholds (bytes). Tuned tight because real-world links
 * from mainland China to CF edge have ~1 Mbps usable throughput with
 * bursty loss — we want to cut bitrate as soon as the sender queue starts
 * growing rather than waiting for a 1 MB backlog.
 */
const BUFFERED_DEGRADE_SOFT = 500_000; // > 512 KB sustained → step down 1
const BUFFERED_DEGRADE_HARD = 2_000_000; // > 2  MB spike      → step down 2
const BUFFERED_UPGRADE_MAX = 50_000; // < 50  KB sustained → step up 1
/** How many consecutive ticks a condition must hold before acting. */
const DEGRADE_SOFT_TICKS = 2; // 2 s of sustained congestion
const UPGRADE_TICKS = 8; // 8 s of clear channel before stepping up
/** Minimum time between level changes to avoid oscillation (ms). */
const COOLDOWN_MS = 3_000;
/** Adaptive tick interval (ms). */
const TICK_INTERVAL_MS = 1_000;
// ---------------------------------------------------------------------------
// StreamManager
// ---------------------------------------------------------------------------
export class StreamManager {
    streamerProc = null;
    injectorProc = null;
    clients = new Set();
    screenInfo = [];
    idleTimer = null;
    starting = false;
    framesSent = 0;
    // Adaptive-controller state -----------------------------------------------
    adaptiveTimer = null;
    currentLevel = INITIAL_LEVEL;
    congestedTicks = 0;
    idleTicks = 0;
    lastLevelChangeAt = 0;
    /** Idle timeout before stopping child processes (ms). */
    idleTimeoutMs;
    /** Parser for the screen-streamer binary protocol. */
    parser;
    /** State for VNC-style button mask tracking. */
    prevMask = 0;
    leftHeld = false;
    constructor(options) {
        this.idleTimeoutMs = options?.idleTimeoutMs ?? 5_000;
        this.parser = new StreamerParser((screens) => {
            this.screenInfo = screens;
            this.broadcastScreenInfo();
        }, (frameData) => {
            if (this.clients.size > 0) {
                this.broadcastVideoFrame(frameData);
            }
        });
    }
    // -------------------------------------------------------------------------
    // Public API
    // -------------------------------------------------------------------------
    /** Add a WebSocket client. Starts child processes if not already running. */
    async addClient(ws) {
        this.clients.add(ws);
        this.clearIdleTimer();
        const cleanup = () => {
            this.clients.delete(ws);
            if (this.clients.size === 0) {
                // Fold the "client disconnected" + "idle timer started" pair into one
                // line. The follow-up "Idle timeout: stopping…" below completes the
                // story with a second line only if no one reconnects in time.
                logger.system(`[rdp] Last client disconnected — idle shutdown in ${this.idleTimeoutMs / 1000}s`);
                this.startIdleTimer();
            }
            else {
                logger.debug(`[rdp] Client disconnected (${this.clients.size} remaining)`);
            }
        };
        ws.on("close", cleanup);
        ws.on("error", cleanup);
        ws.on("message", (data) => { this.handleClientMessage(data); });
        await this.ensureRunning();
        // Send current screen layout to the new client immediately.
        if (this.screenInfo.length > 0)
            this.sendScreenInfo(ws);
        // Request a keyframe so the client can start decoding right away.
        this.requestKeyframe();
    }
    /** Return the current screen layout (used when rendering the HTML page). */
    getScreenInfo() {
        return this.screenInfo;
    }
    /** Stop all child processes immediately. */
    stop() {
        this.clearIdleTimer();
        this.stopProcesses();
    }
    // -------------------------------------------------------------------------
    // Child-process lifecycle
    // -------------------------------------------------------------------------
    async ensureRunning() {
        if ((this.streamerProc && this.injectorProc) || this.starting)
            return;
        this.starting = true;
        try {
            // Compile input-injector (screen-streamer runs interpreted for entitlement).
            const injectorBin = await ensureSwiftBinary("input-injector");
            this.startStreamer();
            this.startInjector(injectorBin);
        }
        catch (err) {
            logger.err(`Failed to start RDP processes: ${err instanceof Error ? err.message : String(err)}`);
        }
        finally {
            this.starting = false;
        }
    }
    startStreamer() {
        if (this.streamerProc)
            return;
        this.parser.reset();
        this.streamerProc = startStreamerProcess((chunk) => { this.parser.feed(chunk); }, (_code) => {
            this.streamerProc = null;
            this.stopAdaptiveLoop();
            if (this.clients.size > 0) {
                logger.system("[rdp] Restarting screen-streamer...");
                setTimeout(() => { void this.ensureRunning(); }, 1_000);
            }
        });
        // Reset adaptive state for the fresh streamer and push the initial
        // bitrate/fps. The Swift encoder defaults to 3 Mbps / 15 fps, which
        // is too aggressive for a high-RTT cross-region tunnel; start at a
        // safer level and let the controller ramp up if the link is clean.
        this.currentLevel = INITIAL_LEVEL;
        this.congestedTicks = 0;
        this.idleTicks = 0;
        this.lastLevelChangeAt = Date.now();
        this.applyLevel(INITIAL_LEVEL, "initial");
        this.startAdaptiveLoop();
    }
    startInjector(binPath) {
        if (this.injectorProc)
            return;
        this.injectorProc = startInjectorProcess(binPath, (_code) => {
            this.injectorProc = null;
            if (this.clients.size > 0) {
                logger.system("[rdp] Restarting input-injector...");
                setTimeout(() => { void this.ensureRunning(); }, 1_000);
            }
        });
    }
    stopProcesses() {
        this.stopAdaptiveLoop();
        killProcess(this.streamerProc, "screen-streamer");
        this.streamerProc = null;
        killProcess(this.injectorProc, "input-injector");
        this.injectorProc = null;
    }
    // -------------------------------------------------------------------------
    // Idle timer
    // -------------------------------------------------------------------------
    startIdleTimer() {
        this.clearIdleTimer();
        // The caller (cleanup() in addClient) logs when the timer starts, so
        // we stay silent here and only announce the terminal action.
        this.idleTimer = setTimeout(() => {
            logger.system("[rdp] Idle timeout — stopped streamer + injector");
            this.stopProcesses();
        }, this.idleTimeoutMs);
    }
    clearIdleTimer() {
        if (this.idleTimer) {
            clearTimeout(this.idleTimer);
            this.idleTimer = null;
        }
    }
    // -------------------------------------------------------------------------
    // WebSocket broadcasting
    // -------------------------------------------------------------------------
    broadcastVideoFrame(frameData) {
        const msg = Buffer.allocUnsafe(1 + frameData.length);
        msg[0] = MSG_S2C_VIDEO;
        frameData.copy(msg, 1);
        let sent = 0;
        for (const ws of this.clients) {
            if (ws.readyState === 1 /* OPEN */) {
                ws.send(msg, { binary: true });
                sent++;
            }
            else if (ws.readyState >= 2 /* CLOSING | CLOSED */) {
                this.clients.delete(ws);
            }
        }
        this.framesSent++;
        if (this.framesSent <= 3 || this.framesSent % 1000 === 0) {
            logger.debug(`[rdp] frame #${this.framesSent} -> ${sent}/${this.clients.size} clients (${msg.length}B)`);
        }
    }
    broadcastScreenInfo() {
        for (const ws of this.clients)
            this.sendScreenInfo(ws);
    }
    sendScreenInfo(ws) {
        if (ws.readyState !== 1)
            return;
        const jsonBuf = Buffer.from(JSON.stringify(this.screenInfo));
        const msg = Buffer.allocUnsafe(1 + jsonBuf.length);
        msg[0] = MSG_S2C_SCREEN_INFO;
        jsonBuf.copy(msg, 1);
        ws.send(msg, { binary: true });
    }
    broadcastClipboard(text) {
        const textBuf = Buffer.from(text, "utf-8");
        const msg = Buffer.allocUnsafe(1 + textBuf.length);
        msg[0] = MSG_S2C_CLIPBOARD;
        textBuf.copy(msg, 1);
        for (const ws of this.clients) {
            if (ws.readyState === 1)
                ws.send(msg, { binary: true });
            else if (ws.readyState >= 2)
                this.clients.delete(ws);
        }
    }
    // -------------------------------------------------------------------------
    // Client message dispatch
    // -------------------------------------------------------------------------
    handleClientMessage(data) {
        if (data.length < 1)
            return;
        const type = data[0];
        switch (type) {
            case MSG_C2S_POINTER:
                this.handlePointerEvent(data);
                break;
            case MSG_C2S_KEY:
                this.handleKeyEvent(data);
                break;
            case MSG_C2S_CONFIG:
                this.handleConfigMessage(data);
                break;
            case MSG_C2S_SWITCH:
                this.handleSwitchMessage(data);
                break;
            case MSG_C2S_CLIPBOARD_GET:
                void this.handleClipboardGet();
                break;
            case MSG_C2S_CLIPBOARD_SET:
                this.handleClipboardSet(data);
                break;
            case MSG_C2S_ACTION:
                this.handleAction(data);
                break;
            default:
                logger.debug(`[rdp] Unknown client message type: 0x${type.toString(16)}`);
        }
    }
    // -------------------------------------------------------------------------
    // Input injection
    // -------------------------------------------------------------------------
    handlePointerEvent(data) {
        // [1B type] [1B mask] [2B x BE] [2B y BE]
        if (data.length < 6)
            return;
        const mask = data[1];
        const x = data.readUInt16BE(2);
        const y = data.readUInt16BE(4);
        this.injectPointer(mask, x, y);
    }
    handleKeyEvent(data) {
        // [1B type] [1B down] [4B keysym BE]
        if (data.length < 6)
            return;
        const down = data[1];
        const keysym = data.readUInt32BE(2);
        this.injectKey(keysym, down !== 0);
    }
    handleConfigMessage(data) {
        // [1B type] [JSON bytes]
        if (data.length < 2)
            return;
        const jsonStr = data.subarray(1).toString();
        try {
            const config = JSON.parse(jsonStr);
            if (this.streamerProc?.stdin?.writable) {
                this.streamerProc.stdin.write(JSON.stringify(config) + "\n");
            }
        }
        catch (err) {
            logger.debug(`[rdp] Invalid config JSON: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    handleSwitchMessage(data) {
        // [1B type] [4B displayId BE]
        if (data.length < 5)
            return;
        const displayId = data.readUInt32BE(1);
        logger.system(`[rdp] Switch to display ${displayId}`);
        if (this.streamerProc?.stdin?.writable) {
            this.streamerProc.stdin.write(`{"switch":${displayId}}\n`);
        }
    }
    // -------------------------------------------------------------------------
    // Clipboard
    // -------------------------------------------------------------------------
    async handleClipboardGet() {
        try {
            const text = await new Promise((resolve, reject) => {
                execFile("pbpaste", [], { timeout: 5_000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
                    if (err)
                        reject(new Error(err.message));
                    else
                        resolve(stdout);
                });
            });
            logger.debug(`[rdp] Clipboard get: ${text.length} chars`);
            this.broadcastClipboard(text);
        }
        catch (err) {
            logger.err(`[rdp] Clipboard get failed: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    handleClipboardSet(data) {
        // [1B type] [UTF-8 text]
        if (data.length < 2)
            return;
        const text = data.subarray(1).toString("utf-8");
        logger.debug(`[rdp] Clipboard set: ${text.length} chars`);
        const proc = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
        // pbcopy is short-lived; an async EPIPE would otherwise crash the process.
        proc.stdin.on("error", (err) => {
            logger.debug(`[rdp] pbcopy stdin error: ${err.message}`);
        });
        proc.stdin.write(text);
        proc.stdin.end();
    }
    // -------------------------------------------------------------------------
    // macOS system actions
    // -------------------------------------------------------------------------
    static VALID_ACTIONS = new Set([
        "mission-control", "app-windows", "show-desktop", "launchpad",
    ]);
    handleAction(data) {
        if (data.length < 2)
            return;
        const action = data.subarray(1).toString("utf-8");
        if (!StreamManager.VALID_ACTIONS.has(action)) {
            logger.debug(`[rdp] Unknown action: ${action}`);
            return;
        }
        logger.system(`[rdp] Action: ${action}`);
        const stdin = this.injectorProc?.stdin;
        if (!stdin?.writable) {
            logger.err("[rdp] Cannot execute action: input-injector not running");
            return;
        }
        stdin.write(`action ${action}\n`);
    }
    // -------------------------------------------------------------------------
    // Low-level input injection
    // -------------------------------------------------------------------------
    requestKeyframe() {
        if (this.streamerProc?.stdin?.writable) {
            this.streamerProc.stdin.write('{"keyframe":true}\n');
            logger.debug("[rdp] Requested keyframe");
        }
    }
    // -------------------------------------------------------------------------
    // Adaptive bitrate/fps controller
    // -------------------------------------------------------------------------
    /**
     * Periodically observe TCP back-pressure (ws.bufferedAmount) and adjust
     * the encoder's target bitrate/fps. AIMD-style: quick to degrade, slow
     * to recover, with a cooldown to damp oscillation.
     */
    startAdaptiveLoop() {
        if (this.adaptiveTimer)
            return;
        this.adaptiveTimer = setInterval(() => { this.adaptiveTick(); }, TICK_INTERVAL_MS);
    }
    stopAdaptiveLoop() {
        if (this.adaptiveTimer) {
            clearInterval(this.adaptiveTimer);
            this.adaptiveTimer = null;
        }
    }
    adaptiveTick() {
        if (!this.streamerProc || this.clients.size === 0)
            return;
        // Worst-case client wins: if any client is congested, we degrade.
        let maxBuffered = 0;
        for (const ws of this.clients) {
            if (ws.readyState !== 1 /* OPEN */)
                continue;
            if (ws.bufferedAmount > maxBuffered)
                maxBuffered = ws.bufferedAmount;
        }
        const now = Date.now();
        const inCooldown = now - this.lastLevelChangeAt < COOLDOWN_MS;
        // Emergency: huge spike → drop two levels immediately, bypass cooldown.
        if (maxBuffered > BUFFERED_DEGRADE_HARD && this.currentLevel > 0) {
            const target = Math.max(0, this.currentLevel - 2);
            this.applyLevel(target, `hard-degrade (buf=${maxBuffered})`);
            this.congestedTicks = 0;
            this.idleTicks = 0;
            return;
        }
        if (maxBuffered > BUFFERED_DEGRADE_SOFT) {
            this.congestedTicks++;
            this.idleTicks = 0;
            if (!inCooldown
                && this.congestedTicks >= DEGRADE_SOFT_TICKS
                && this.currentLevel > 0) {
                this.applyLevel(this.currentLevel - 1, `soft-degrade (buf=${maxBuffered})`);
                this.congestedTicks = 0;
            }
        }
        else if (maxBuffered < BUFFERED_UPGRADE_MAX) {
            this.idleTicks++;
            this.congestedTicks = 0;
            if (!inCooldown
                && this.idleTicks >= UPGRADE_TICKS
                && this.currentLevel < QUALITY_LADDER.length - 1) {
                this.applyLevel(this.currentLevel + 1, `upgrade (buf=${maxBuffered})`);
                this.idleTicks = 0;
            }
        }
        else {
            // In the mid-range: neither congested nor clearly idle. Decay both
            // counters so a single transient tick doesn't trigger a change later.
            this.congestedTicks = Math.max(0, this.congestedTicks - 1);
            this.idleTicks = Math.max(0, this.idleTicks - 1);
        }
    }
    applyLevel(level, reason) {
        // Clamp to ladder bounds so the index lookup below always resolves.
        const clamped = Math.max(0, Math.min(QUALITY_LADDER.length - 1, level));
        const cfg = QUALITY_LADDER[clamped];
        const stdin = this.streamerProc?.stdin;
        if (!stdin?.writable)
            return;
        // Changing fps recreates the encoder in Swift (drops 1-2 frames); bitrate
        // alone is a cheap live update. Send both in one line to keep it atomic.
        stdin.write(`${JSON.stringify({ bitrate: cfg.bitrate, fps: cfg.fps })}\n`);
        this.currentLevel = clamped;
        this.lastLevelChangeAt = Date.now();
        const msg = `[rdp] Adaptive L${clamped}: ${Math.round(cfg.bitrate / 1000)}kbps @ ${cfg.fps}fps (${reason})`;
        // Announce the initial pick at system level so operators can see the
        // starting point; subsequent live adjustments go to debug to avoid
        // flooding logs on a flaky link (they can fire every few seconds).
        if (reason === "initial") {
            logger.system(msg);
        }
        else {
            logger.debug(msg);
        }
    }
    injectPointer(mask, x, y) {
        const stdin = this.injectorProc?.stdin;
        if (!stdin?.writable)
            return;
        const leftDown = (mask & 1) !== 0;
        const rightDown = (mask & 4) !== 0;
        const scrollUp = (mask & 8) !== 0;
        const scrollDown = (mask & 16) !== 0;
        const prevLeft = (this.prevMask & 1) !== 0;
        const prevRight = (this.prevMask & 4) !== 0;
        if (scrollUp)
            stdin.write("scroll 3\n");
        if (scrollDown)
            stdin.write("scroll -3\n");
        if (leftDown && !prevLeft) {
            stdin.write(`dd ${x} ${y}\n`);
            this.leftHeld = true;
        }
        else if (!leftDown && prevLeft) {
            stdin.write(`du ${x} ${y}\n`);
            this.leftHeld = false;
        }
        else if (leftDown && this.leftHeld) {
            stdin.write(`dm ${x} ${y}\n`);
        }
        else if (!leftDown && !rightDown && !scrollUp && !scrollDown) {
            stdin.write(`m ${x} ${y}\n`);
        }
        if (rightDown && !prevRight)
            stdin.write(`rc ${x} ${y}\n`);
        this.prevMask = mask;
    }
    injectKey(keysym, down) {
        const stdin = this.injectorProc?.stdin;
        if (!stdin?.writable)
            return;
        stdin.write(`key ${keysym} ${down ? 1 : 0}\n`);
    }
}
//# sourceMappingURL=stream-manager.js.map