import { randomUUID } from "node:crypto";
import * as nodePty from "node-pty";
import { config } from "./config.js";
/** Rolling output buffer cap per PTY (in bytes). 256 KB ≈ ~3000 lines of typical shell output. */
const OUTPUT_BUFFER_BYTES = 256 * 1024;
/**
 * Time a detached PTY (no WS attached) is held alive before being reaped.
 * Sourced from `config.terminalIdleReapMs` so operators in resource-
 * constrained deployments can shorten the window, AND so the integration
 * test can pin it down to ~200 ms to actually exercise the reap path
 * within a test budget.
 */
const IDLE_REAP_MS = config.terminalIdleReapMs;
const ptys = new Map();
function defaultShell() {
    if (process.platform === "win32") {
        // Windows 没有 SHELL 环境变量，使用 COMSPEC（通常指向 cmd.exe）。
        // node-pty 在 Windows 上需要 Windows 原生的可执行文件。
        return process.env.COMSPEC ?? "cmd.exe";
    }
    return process.env.SHELL ?? "/bin/sh";
}
/**
 * Find an existing PTY for `tabId` within `projectId`. Returns
 * undefined if none exists (caller should spawn) or if a PTY with
 * that tabId belongs to a DIFFERENT project (caller should treat as
 * "no match" — never reattach across projects, that would expose
 * one project's shell to another).
 */
export function findPtyByTabId(tabId, projectId) {
    for (const entry of ptys.values()) {
        if (entry.managed.tabId !== tabId)
            continue;
        if (entry.managed.projectId !== projectId)
            continue;
        return entry.managed;
    }
    return undefined;
}
export function spawnPty(opts) {
    const shell = opts.shell ?? defaultShell();
    const cols = opts.cols ?? 80;
    const rows = opts.rows ?? 24;
    const env = opts.env ?? process.env;
    const proc = nodePty.spawn(shell, [], {
        name: "xterm-color",
        cols,
        rows,
        cwd: opts.cwd,
        env: filterEnv(env),
    });
    const ptyId = randomUUID();
    const managed = {
        ptyId,
        tabId: opts.tabId,
        projectId: opts.projectId,
        process: proc,
        cwd: opts.cwd,
    };
    const entry = {
        managed,
        dataDisposable: undefined,
        closeActiveSocket: undefined,
        idleTimer: undefined,
        buffer: [],
        bufferBytes: 0,
    };
    ptys.set(ptyId, entry);
    // Always-on output capture, independent of any attached socket —
    // that way disconnected periods still accumulate the rolling
    // buffer for the next reattach to replay.
    const captureDisposable = proc.onData((chunk) => {
        appendToBuffer(entry, chunk);
    });
    proc.onExit(() => {
        captureDisposable.dispose();
        ptys.delete(ptyId);
        if (entry.idleTimer !== undefined)
            clearTimeout(entry.idleTimer);
    });
    return managed;
}
/**
 * Attach a socket-style sink to a managed PTY. Replays the rolling
 * output buffer immediately, then forwards every subsequent
 * `onData` chunk to `onData(chunk)`. Returns a detach function the
 * caller MUST invoke on socket close — without this, the prior
 * sink keeps receiving bytes and a reattach can't replace it.
 *
 * Cancels any pending idle reaper — the PTY is back in active use.
 *
 * `replayBytes` lets the caller request only the tail of the
 * buffer (e.g. xterm already has prior scrollback locally and only
 * wants the last ~16 KB). Pass `Infinity` (default) to replay all.
 *
 * `onData` receives raw bytes as `Buffer` — callers should forward
 * them through whatever transport they own without re-encoding. The
 * terminal WS route sends them as binary frames so reverse proxies
 * can't mangle ANSI escape sequences (see the comment on the
 * `socket.send(chunk)` call in `routes/terminal.ts`).
 */
export function attachSink(ptyId, onData, replayBytes = OUTPUT_BUFFER_BYTES, closeActiveSocket) {
    const entry = ptys.get(ptyId);
    if (entry === undefined)
        return undefined;
    if (entry.idleTimer !== undefined) {
        clearTimeout(entry.idleTimer);
        entry.idleTimer = undefined;
    }
    // Replace any existing data sink so a stale reconnect never
    // double-delivers chunks. The previous detach() the caller
    // captured still works (it disposes whatever disposable it
    // captured), but the route should always call the latest detach.
    if (entry.dataDisposable !== undefined) {
        entry.dataDisposable.dispose();
        entry.dataDisposable = undefined;
    }
    // Close the previously-attached WS, if any. Without this, two
    // browsers with the same tabId both keep their input handlers wired
    // up to write to the PTY; only the most recently attached one sees
    // output, but BOTH can send input — interleaved keystrokes corrupt
    // line-edit state. Closing the predecessor's WS lets the route's
    // close handler unwire the input listener cleanly.
    if (entry.closeActiveSocket !== undefined) {
        try {
            entry.closeActiveSocket();
        }
        catch {
            // socket already gone — fine
        }
        entry.closeActiveSocket = undefined;
    }
    entry.closeActiveSocket = closeActiveSocket;
    // Replay only the tail the caller asked for. The buffer is a
    // chunk array; flatten just enough from the right edge to hit
    // `replayBytes`. Edge case: replayBytes <= 0 → skip replay.
    if (replayBytes > 0 && entry.bufferBytes > 0) {
        let remaining = Math.min(replayBytes, entry.bufferBytes);
        const tail = [];
        for (let i = entry.buffer.length - 1; i >= 0 && remaining > 0; i--) {
            const chunk = entry.buffer[i];
            if (chunk.byteLength <= remaining) {
                tail.unshift(chunk);
                remaining -= chunk.byteLength;
            }
            else {
                tail.unshift(chunk.subarray(chunk.byteLength - remaining));
                remaining = 0;
            }
        }
        for (const chunk of tail)
            onData(chunk);
    }
    // node-pty's onData callback delivers strings (UTF-8 decoded
    // already, with `\u{FFFD}` substitution on invalid bytes). Convert
    // back to a Buffer once here so downstream sinks get the raw byte
    // stream that proxies / xterm.js parse natively. The replay loop
    // above already operates on Buffer (the rolling buffer stores
    // Buffers directly), so this is the only re-encoding hop in the
    // pipeline.
    const live = entry.managed.process.onData((chunk) => {
        onData(Buffer.from(chunk, "utf8"));
    });
    entry.dataDisposable = live;
    return () => {
        if (entry.dataDisposable === live) {
            live.dispose();
            entry.dataDisposable = undefined;
            // Only clear closeActiveSocket if WE are still the active
            // attachment — a newer attachSink may have already swapped a
            // different socket in.
            if (entry.closeActiveSocket === closeActiveSocket) {
                entry.closeActiveSocket = undefined;
            }
        }
        else {
            // A newer attach replaced ours; nothing to do.
        }
        // Start the idle reaper. If a fresh attach arrives within
        // IDLE_REAP_MS the timer is cancelled in the next attachSink.
        if (entry.idleTimer === undefined && ptys.has(ptyId)) {
            entry.idleTimer = setTimeout(() => {
                entry.idleTimer = undefined;
                killPty(ptyId);
            }, IDLE_REAP_MS);
        }
    };
}
function appendToBuffer(entry, chunk) {
    const buf = Buffer.from(chunk, "utf8");
    entry.buffer.push(buf);
    entry.bufferBytes += buf.byteLength;
    // Evict from the front until we're under cap. Keeps memory
    // bounded across multi-hour shells running noisy output (npm
    // install, pip install, etc.).
    while (entry.bufferBytes > OUTPUT_BUFFER_BYTES && entry.buffer.length > 0) {
        const head = entry.buffer.shift();
        entry.bufferBytes -= head.byteLength;
    }
}
export function getPty(ptyId) {
    return ptys.get(ptyId)?.managed;
}
/**
 * Grace window between SIGTERM and SIGKILL. Long enough for a
 * well-behaved shell to clean up (zsh trap, bash exit handler), short
 * enough that an unkillable shell doesn't linger past a deploy /
 * shutdown for noticeable time.
 */
const SIGKILL_GRACE_MS = 2_000;
export function killPty(ptyId) {
    const entry = ptys.get(ptyId);
    if (entry === undefined)
        return false;
    ptys.delete(ptyId);
    if (entry.idleTimer !== undefined)
        clearTimeout(entry.idleTimer);
    if (entry.dataDisposable !== undefined)
        entry.dataDisposable.dispose();
    // Try SIGTERM first (gives the shell a chance to flush, run trap
    // handlers, release tty). Schedule a SIGKILL fallback for trapped
    // / unresponsive shells. Without the fallback, a `trap '' TERM`
    // bash leaves an orphan process holding the PTY fd.
    let killed = false;
    const exitDisposable = entry.managed.process.onExit(() => {
        killed = true;
    });
    try {
        entry.managed.process.kill("SIGTERM");
    }
    catch {
        // already exited between get + kill; nothing to do
        return true;
    }
    setTimeout(() => {
        exitDisposable.dispose();
        if (killed)
            return;
        try {
            entry.managed.process.kill("SIGKILL");
        }
        catch {
            // already exited
        }
    }, SIGKILL_GRACE_MS).unref();
    return true;
}
export function ptyCount() {
    return ptys.size;
}
export function disposeAllPtys() {
    for (const ptyId of Array.from(ptys.keys())) {
        killPty(ptyId);
    }
}
let exitHandlerInstalled = false;
/**
 * Install a `process.on("exit")` last-resort SIGTERM-all handler.
 * Idempotent.
 *
 * The previous version of this module called `installExitHandler()`
 * at module load, which meant any unit test that imported the module
 * also installed the handler — and the handler couldn't be undone.
 * Tests that fork child processes ended up with the wrong handler
 * count and unpredictable shutdown behavior. The fix: require an
 * explicit `installPtyExitHandler()` call from `index.ts` (the
 * production entry point); tests that import `pty-manager` for unit
 * coverage skip the install.
 */
export function installPtyExitHandler() {
    if (exitHandlerInstalled)
        return;
    exitHandlerInstalled = true;
    process.on("exit", () => {
        for (const entry of ptys.values()) {
            try {
                entry.managed.process.kill("SIGTERM");
            }
            catch {
                // Process already gone — fine. We're in the exit handler so
                // there's no point trying anything more aggressive.
            }
        }
        ptys.clear();
    });
}
/**
 * Allowlist of env-var names the PTY shell (and the `!` exec route)
 * may inherit from the Huiyu Pi process. Everything else is dropped.
 *
 * Allowlist instead of denylist because the threat model is "any
 * secret an operator has in their host env leaks into the shell." A
 * denylist of named secrets is fail-open — every newly-named provider
 * key (`SOMEPROVIDER_API_KEY`), kube credential, cloud token, etc.
 * leaks until we add it by name. An allowlist is fail-safe: anything
 * not on this list is hidden by default; operators with a legitimate
 * need pass specific vars through via `TERMINAL_PASSTHROUGH_ENV`.
 *
 * What's on the list and why:
 *   PATH, HOME, USER, LOGNAME, SHELL — required for a usable shell
 *   PWD                              — the shell sets it but inheriting
 *                                      avoids a transient empty value
 *   TERM, COLORTERM                  — xterm rendering / 256-color
 *   TERMINFO                         — fallback terminfo lookup
 *   LANG, LC_*                       — locale (UTF-8, sorting, dates)
 *   TZ                               — timezone-aware tools (`date`)
 *   HOSTNAME                         — prompt customization (`\h` in PS1)
 *   EDITOR, PAGER, VISUAL            — interactive defaults (git
 *                                      commit, less, etc.)
 *
 * Conspicuously NOT on the list (must be opt-in via
 * `TERMINAL_PASSTHROUGH_ENV` if the operator wants them in-shell):
 *   - Huiyu Pi secrets: JWT_SECRET, API_KEY, UI_PASSWORD
 *   - Provider keys:     ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
 *   - Cloud credentials: AWS_*, GOOGLE_APPLICATION_CREDENTIALS,
 *                        GH_TOKEN, GITHUB_TOKEN, KUBECONFIG, ...
 *   - Anything else      — assume it's sensitive until proven harmless.
 */
const TERMINAL_ENV_ALLOWLIST = new Set([
    "PATH",
    "HOME",
    "USER",
    "LOGNAME",
    "SHELL",
    "PWD",
    "TERM",
    "COLORTERM",
    "TERMINFO",
    "LANG",
    "TZ",
    "HOSTNAME",
    "EDITOR",
    "PAGER",
    "VISUAL",
]);
/**
 * Variable names matching this prefix-style pattern are also allowed
 * — covers the locale family (`LC_ALL`, `LC_CTYPE`, `LC_MESSAGES`, …)
 * without enumerating every variant.
 */
const TERMINAL_ENV_ALLOWLIST_PREFIXES = ["LC_"];
function isAllowedByDefault(name) {
    if (TERMINAL_ENV_ALLOWLIST.has(name))
        return true;
    for (const prefix of TERMINAL_ENV_ALLOWLIST_PREFIXES) {
        if (name.startsWith(prefix))
            return true;
    }
    return false;
}
function filterEnv(env) {
    const passthrough = new Set(config.terminalPassthroughEnv);
    const out = {};
    for (const [k, v] of Object.entries(env)) {
        if (typeof v !== "string")
            continue;
        if (!isAllowedByDefault(k) && !passthrough.has(k))
            continue;
        out[k] = v;
    }
    return out;
}
/**
 * Re-export so non-PTY callers (the user-bash `!` exec route) get the
 * same env allowlist without having to re-implement it. The PTY and
 * the one-shot user-bash share an identical threat model: an
 * authenticated browser user must not be able to `echo
 * $JWT_SECRET` (or any other host-env secret) from a shell the
 * Huiyu Pi spawned on their behalf.
 */
export const scrubbedEnv = () => filterEnv(process.env);
//# sourceMappingURL=pty-manager.js.map