import { execFileSync } from 'node:child_process';
import { tmuxKeyToBytes } from './zellij-backend.js';
import { normaliseCaptureLineEndings } from './tmux-pipe-backend.js';
import { zellijEnv } from '../../setup/ensure-zellij.js';
import { logger } from '../../utils/logger.js';
/**
 * ZellijObserveBackend — the zellij analogue of TmuxPipeBackend, for /adopt.
 *
 * zellij has no `pipe-pane` (zero-touch raw byte tap). The non-invasive
 * primitives that DON'T disturb the user's session (size/focus) are:
 *   - observe: poll `zellij action dump-screen --pane-id <p> --ansi` (a query;
 *     no client attaches, no resize, no focus change — verified)
 *   - drive:   `zellij action write/write-chars/send-keys --pane-id <p>`
 *     (targets the pane regardless of focus — verified focus-neutral)
 *
 * So this backend OBSERVES by polling dump-screen and emitting a snapshot on
 * change (cleared + redrawn so the web-terminal xterm shows the current screen
 * rather than stacking frames), and DRIVES via targeted `action` calls. It owns
 * nothing: kill()/destroySession() only stop the pollers — the user's zellij
 * session is never touched.
 *
 * Tradeoff vs tmux pipe-pane: the live screen has ~POLL_MS latency and isn't
 * byte-exact. The authoritative conversation turns come from the per-CLI
 * transcript bridge watchers (multiplexer-agnostic), which are immediate; the
 * snapshot only drives the web-terminal/screenshot observation.
 */
const POLL_MS = 700;
const LIVENESS_MS = 1000;
/** Home + clear screen — prefix each emitted snapshot so the consuming xterm
 *  redraws the current screen instead of appending successive frames. */
const CLEAR_HOME = '\x1b[H\x1b[2J';
export class ZellijObserveBackend {
    session;
    paneId;
    cliPid;
    cols;
    rows;
    dataCbs = [];
    exitCbs = [];
    pollTimer = null;
    livenessTimer = null;
    lastSnapshot = '';
    exited = false;
    /** # of live web-terminal `zellij attach` clients currently connected. While
     *  >0 the pollers go quiet — see setLiveAttach. */
    liveAttachCount = 0;
    // PtyHandle fields the worker may set (parallel to the other backends).
    claudeJsonlPath;
    cliCwd;
    constructor(session, paneId, opts) {
        this.session = session;
        this.paneId = paneId;
        this.cliPid = opts?.cliPid ?? null;
        this.cols = 80;
        this.rows = 24;
    }
    // ─── SessionBackend ───────────────────────────────────────────────────────
    /** bin/args are ignored — we observe an existing pane, we don't launch. */
    spawn(_bin, _args, opts) {
        this.cols = opts.cols;
        this.rows = opts.rows;
        logger.debug(`[zellij-observe] ${this.session}/${this.paneId} observe ${this.cols}x${this.rows} cliPid=${this.cliPid}`);
        this.pollTimer = setInterval(() => this.poll(), POLL_MS);
        this.livenessTimer = setInterval(() => this.checkLiveness(), LIVENESS_MS);
    }
    poll() {
        const snap = this.captureViewport();
        if (snap && snap !== this.lastSnapshot) {
            this.lastSnapshot = snap;
            const frame = CLEAR_HOME + snap;
            for (const cb of this.dataCbs)
                cb(frame);
        }
    }
    checkLiveness() {
        if (this.exited)
            return;
        // While a live web-attach client is connected, skip the list-panes `action`
        // (it makes the zellij server repaint every attached client → flicker). The
        // pid syscall covers CLI exit; the attach PTY's own exit covers pane/session
        // death, and full pane liveness resumes the moment the client detaches.
        if (this.liveAttachCount > 0) {
            if (!this.isCliPidAlive())
                this.handlePaneExit();
            return;
        }
        // Two exit signals: (a) the pane vanished, (b) the adopted CLI pid is gone.
        // (b) is essential for user-typed CLIs: when the CLI exits, the pane drops
        // back to a shell and stays "alive", so a pane-only check would keep the
        // worker running and route subsequent Lark input INTO the user's shell.
        // Mirrors the pid guard the restore path already uses.
        if (!this.isPaneAlive() || !this.isCliPidAlive())
            this.handlePaneExit();
    }
    /**
     * Mark a live web-terminal `zellij attach` client as (dis)connected.
     *
     * Every `zellij action` the pollers run (dump-screen poll @700ms, list-panes
     * liveness @1000ms) connects a transient client to the server, which makes the
     * server repaint ALL attached clients — so an attached web client redraws its
     * zellij chrome ~2×/s, i.e. the flicker 申晗 saw. While ≥1 attach client is up
     * the dump-screen poll is redundant (the attach IS the live view) so we stop
     * it, and liveness degrades to the churn-free pid syscall (see checkLiveness).
     * Reference-counted so multiple browser tabs compose; resumes on the last one.
     */
    setLiveAttach(active) {
        this.liveAttachCount = Math.max(0, this.liveAttachCount + (active ? 1 : -1));
        if (this.exited)
            return;
        if (this.liveAttachCount > 0) {
            if (this.pollTimer) {
                clearInterval(this.pollTimer);
                this.pollTimer = null;
            }
        }
        else if (!this.pollTimer) {
            this.pollTimer = setInterval(() => this.poll(), POLL_MS);
        }
    }
    /** True while ≥1 live web-attach client is up — the dump-screen poller is
     *  paused (see setLiveAttach), so screen changes flow only to the attach PTY
     *  and never bump the worker's onData/onPtyData activity watermark. Consumers
     *  that gate on that watermark must capture unconditionally in this window. */
    isLiveAttachActive() {
        return this.liveAttachCount > 0;
    }
    /** Whether the adopted CLI process is still running. Unknown pid → defer to
     *  pane liveness only. EPERM (exists, not ours) counts as alive; ESRCH = gone. */
    isCliPidAlive() {
        if (this.cliPid === null)
            return true;
        try {
            process.kill(this.cliPid, 0);
            return true;
        }
        catch (e) {
            return e?.code === 'EPERM';
        }
    }
    handlePaneExit() {
        if (this.exited)
            return;
        this.exited = true;
        this.stopTimers();
        for (const cb of this.exitCbs)
            cb(0, null);
    }
    // ── Input: targeted `action` calls (focus-neutral, non-invasive) ──
    write(data) {
        this.writeBytes(data);
    }
    /** Literal text via write-chars (preserves UTF-8). */
    sendText(text) {
        if (!text)
            return;
        this.action(['write-chars', '--pane-id', this.paneId, '--', text]);
    }
    /** Special keys by tmux-style name → raw bytes → `action write`. */
    sendSpecialKeys(...keys) {
        for (const key of keys)
            this.writeBytes(tmuxKeyToBytes(key));
    }
    /** Bracketed paste — wrap so TUIs detect the boundary (mirrors paste-buffer -p). */
    pasteText(text) {
        this.writeBytes('\x1b[200~');
        this.sendText(text);
        this.writeBytes('\x1b[201~');
    }
    /** Write arbitrary bytes via `action write <decimal>…` (handles control/escape).
     *  Chunked so a large web-terminal paste can't blow the argv limit; zellij
     *  serialises the writes in arrival order. */
    writeBytes(data) {
        if (!data)
            return;
        const buf = Buffer.from(data, 'utf-8');
        const CHUNK = 512;
        for (let i = 0; i < buf.length; i += CHUNK) {
            const bytes = Array.from(buf.subarray(i, i + CHUNK), b => String(b));
            this.action(['write', '--pane-id', this.paneId, ...bytes]);
        }
    }
    /** Resize is a NO-OP in observe mode — the pane size is the user's, and
     *  resizing it would disturb their layout. The transient snapshot reads the
     *  real pane size via getPaneSize(). */
    resize(_cols, _rows) { }
    onData(cb) { this.dataCbs.push(cb); }
    onExit(cb) { this.exitCbs.push(cb); }
    getChildPid() { return this.cliPid; }
    /** Detach observer — stop polling. Never touches the user's zellij session. */
    kill() { this.stopTimers(); }
    /** Adopt owns nothing — destroy == detach (don't kill the user's session). */
    destroySession() { this.kill(); }
    stopTimers() {
        if (this.pollTimer) {
            clearInterval(this.pollTimer);
            this.pollTimer = null;
        }
        if (this.livenessTimer) {
            clearInterval(this.livenessTimer);
            this.livenessTimer = null;
        }
    }
    // ─── ObserveBackend snapshot surface ──────────────────────────────────────
    captureCurrentScreen() {
        return this.dumpScreen(true);
    }
    captureViewport() {
        return this.dumpScreen(false);
    }
    getPaneSize() {
        const pane = this.listPane();
        if (!pane)
            return null;
        const cols = Number(pane.pane_content_columns ?? pane.pane_columns);
        const rows = Number(pane.pane_content_rows ?? pane.pane_rows);
        if (!Number.isFinite(cols) || !Number.isFinite(rows))
            return null;
        return { cols, rows };
    }
    isPaneAlive() {
        const pane = this.listPane();
        return !!pane && pane.exited !== true;
    }
    // ─── zellij plumbing ──────────────────────────────────────────────────────
    dumpScreen(full) {
        const args = ['dump-screen', '--pane-id', this.paneId, '--ansi'];
        if (full)
            args.push('--full');
        // zellij dump-screen separates rows with bare `\n` (no `\r`). Fed to an
        // xterm as-is, each line continues from the previous line's end column
        // instead of returning to column 0 → the staircase/right-drift garble.
        // Same fix tmux's capture path uses (normaliseCaptureLineEndings).
        return normaliseCaptureLineEndings(this.action(args) ?? '');
    }
    listPane() {
        try {
            const out = execFileSync('zellij', ['--session', this.session, 'action', 'list-panes', '--json'], {
                encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, env: zellijEnv(),
            });
            const arr = JSON.parse(out);
            if (!Array.isArray(arr))
                return null;
            return arr.find((p) => !p.is_plugin && `terminal_${p.id}` === this.paneId) ?? null;
        }
        catch {
            return null;
        }
    }
    /** Run a `zellij --session S action …`, returning stdout or null on failure. */
    action(args) {
        try {
            return execFileSync('zellij', ['--session', this.session, 'action', ...args], {
                encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, env: zellijEnv(),
            });
        }
        catch (err) {
            logger.debug(`[zellij-observe] action ${args[0]} failed: ${err?.message}`);
            return null;
        }
    }
}
//# sourceMappingURL=zellij-observe-backend.js.map