import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { resolveProcessCwd } from './cwdResolver.js';
import { OutputBuffer } from './outputBuffer.js';
import { readTranscriptHistoryPage } from './transcriptHistory.js';
// Window for the output-rate component of busy detection. Slightly larger
// than the poll interval so a tab that just stopped emitting output
// transitions to idle on the very next poll (rather than lagging a tick),
// and small enough that "command finished" notifications fire within ~3s
// of the prompt returning.
const BUSY_OUTPUT_WINDOW_MS = 2_500;
// Interval between activity polls. Each tick spawns one `tmux list-panes`
// subprocess (~5-10ms) so even on a busy host with many terminals the
// overhead stays well under 1% CPU. 2000ms also caps the worst-case latency
// before a "command completed" notification reaches the operator at 2s,
// which is fast enough to feel responsive without spamming polls.
const STATUS_POLL_INTERVAL_MS = 2_000;
const HISTORY_PAGE_LINES = 1000;
const HISTORY_PAGE_MAX_LINES = 5000;
const HISTORY_CURSOR_PROBE_LINES = 1;
export class TerminalManager {
    config;
    ptyAdapter;
    terminals = new Map();
    resolveCwd;
    now;
    // Captured at construction so every spawn can compare a poll's
    // pane_current_command against the configured shell to decide "is this
    // terminal idle at a prompt or busy running something else?".
    shellBasename;
    statusPollTimer = null;
    statusPollInflight = false;
    constructor(config, ptyAdapter, options = {}) {
        this.config = config;
        this.ptyAdapter = ptyAdapter;
        this.resolveCwd = options.resolveCwd ?? resolveProcessCwd;
        this.now = options.now ?? (() => new Date());
        this.shellBasename = path.basename(this.config.shell);
        if (typeof this.ptyAdapter.pollSessions === 'function') {
            this.startStatusPoller();
        }
    }
    startStatusPoller() {
        if (this.statusPollTimer)
            return;
        this.statusPollTimer = setInterval(() => {
            if (this.statusPollInflight)
                return;
            this.statusPollInflight = true;
            void this.pollOnce().finally(() => {
                this.statusPollInflight = false;
            });
        }, STATUS_POLL_INTERVAL_MS);
        // Unref so the interval doesn't keep the Node event loop alive on its
        // own — the HTTP server is what should be holding the process up.
        this.statusPollTimer.unref?.();
    }
    async pollOnce() {
        const poll = this.ptyAdapter.pollSessions;
        if (!poll)
            return;
        let result;
        try {
            result = await poll.call(this.ptyAdapter);
        }
        catch {
            return;
        }
        const now = Date.now();
        for (const [terminalId, { currentCommand, busy }] of result) {
            const record = this.terminals.get(terminalId);
            if (!record || record.summary.status !== 'running')
                continue;
            // Two complementary signals for "is this tab working":
            //   - kernel run state: catches CPU-bound work like npm install,
            //     vim :%s, codex executing a tool call locally
            //   - PTY output rate: catches I/O-bound work like codex streaming
            //     tokens or claude printing a response — those processes spend
            //     most of their time sleeping in epoll_wait (state S) but
            //     output is steadily flowing, which is what the operator
            //     actually means by "it's working"
            // OR them together so either signal alone is enough.
            const recentOutput = now - record.lastOutputAt < BUSY_OUTPUT_WINDOW_MS;
            const nextActivity = busy || recentOutput ? 'busy' : 'idle';
            const nextCommand = currentCommand === this.shellBasename ? null : currentCommand;
            if (record.summary.activity === nextActivity &&
                record.summary.currentCommand === nextCommand) {
                continue;
            }
            record.summary.activity = nextActivity;
            record.summary.currentCommand = nextCommand;
            record.summary.updatedAt = this.timestamp();
            this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
        }
    }
    // Allow the process to shut down cleanly without leaking the polling
    // interval — called by the server's graceful-shutdown path.
    dispose() {
        if (this.statusPollTimer) {
            clearInterval(this.statusPollTimer);
            this.statusPollTimer = null;
        }
    }
    async createTerminal(options = {}) {
        const cwd = await this.resolveInitialCwd(options);
        const cols = normalizeDimension(options.cols, 80);
        const rows = normalizeDimension(options.rows, 24);
        const id = randomUUID();
        const timestamp = this.timestamp();
        const initialTitle = this.createTitle(cwd);
        const pty = this.ptyAdapter.spawn({
            sessionId: id,
            shell: this.config.shell,
            cwd,
            cols,
            rows,
            env: terminalEnv(),
            // Pass the initial title in so persistent adapters (tmux) name the
            // session with it from the start — otherwise an immediate restart
            // before the operator interacted would show a placeholder pill.
            title: initialTitle
        });
        const record = {
            summary: {
                id,
                title: initialTitle,
                cwd,
                pid: pty.pid,
                cols,
                rows,
                createdAt: timestamp,
                updatedAt: timestamp,
                status: 'running',
                exitCode: null
            },
            pty,
            output: new OutputBuffer(),
            subscribers: new Set(),
            dataDisposable: { dispose: () => undefined },
            exitDisposable: { dispose: () => undefined },
            pauseCount: 0,
            lastOutputAt: 0,
            history: null
        };
        this.wireUpPty(record, pty);
        this.terminals.set(id, record);
        return cloneSummary(record.summary);
    }
    // Repopulate the in-memory record map with any persistent sessions the
    // adapter is already holding for us — used at process start so that after
    // `nterminalctl restart` or a NTerminal version update the operator's panes
    // reattach to their live shells instead of being silently replaced by
    // fresh ones. A no-op for adapters without persistence (NodePtyAdapter).
    async restoreSessions() {
        const existing = this.ptyAdapter.listExisting?.() ?? [];
        const now = this.timestamp();
        for (const session of existing) {
            const sessionId = session.sessionId;
            if (this.terminals.has(sessionId))
                continue;
            // Use the adapter-provided title (tmux's window name) when the
            // operator had renamed the tab in a previous run — that's how a
            // human-readable label survives NTerminal restarts. Fall back to a
            // distinguishable placeholder when no name was set.
            const restoredTitle = session.title?.trim() || `Terminal ${sessionId.slice(0, 6)}`;
            this.terminals.set(sessionId, {
                summary: {
                    id: sessionId,
                    // We don't know the original cwd/cols/rows after a restart — the
                    // adapter (tmux) holds the real shell state. Use sensible
                    // defaults; the browser will resize on connect and any cwd-aware
                    // feature (file explorer, refresh_cwd) repulls live values.
                    title: restoredTitle,
                    cwd: this.config.workspaceRoot,
                    pid: null,
                    cols: 80,
                    rows: 24,
                    createdAt: now,
                    updatedAt: now,
                    status: 'running',
                    exitCode: null
                },
                pty: null, // null = persisted but no local client attached yet
                output: new OutputBuffer(),
                subscribers: new Set(),
                dataDisposable: { dispose: () => undefined },
                exitDisposable: { dispose: () => undefined },
                pauseCount: 0,
                lastOutputAt: 0,
                history: null
            });
        }
    }
    wireUpPty(record, pty) {
        const id = record.summary.id;
        record.pty = pty;
        record.summary.pid = pty.pid;
        record.summary.status = 'running';
        record.summary.exitCode = null;
        record.summary.updatedAt = this.timestamp();
        record.dataDisposable = pty.onData((data) => {
            record.output.push(data);
            record.lastOutputAt = Date.now();
            record.summary.updatedAt = this.timestamp();
            this.publish(record, { type: 'output', terminalId: id, data });
        });
        record.exitDisposable = pty.onExit((event) => {
            record.summary.status = 'exited';
            record.summary.exitCode = event.exitCode;
            record.summary.pid = null;
            record.summary.updatedAt = this.timestamp();
            record.pty = null;
            this.publish(record, { type: 'exit', terminalId: id, exitCode: event.exitCode });
        });
    }
    // Spawn a fresh local client for a restored session that hasn't been
    // attached since startup. Called lazily on the first attachTerminal so
    // sessions the operator never reopens don't consume processes.
    reattachToSession(record) {
        const pty = this.ptyAdapter.spawn({
            sessionId: record.summary.id,
            shell: this.config.shell,
            cwd: record.summary.cwd,
            cols: record.summary.cols,
            rows: record.summary.rows,
            env: terminalEnv()
        });
        this.wireUpPty(record, pty);
    }
    captureTerminalHistoryCursor(record) {
        const capture = this.ptyAdapter.captureHistory?.(record.summary.id, {
            beforeLine: 0,
            limit: HISTORY_CURSOR_PROBE_LINES
        });
        if (!capture) {
            return null;
        }
        if (!capture.hasMore && capture.endLine < capture.startLine) {
            return null;
        }
        return {
            startLine: 0,
            endLine: 0,
            hasMore: capture.hasMore || capture.endLine >= capture.startLine
        };
    }
    listTerminals() {
        return [...this.terminals.values()].map((record) => cloneSummary(record.summary));
    }
    getTerminal(id) {
        const record = this.terminals.get(id);
        return record ? cloneSummary(record.summary) : null;
    }
    getOutputSnapshot(id) {
        return this.terminals.get(id)?.output.snapshot() ?? null;
    }
    getTerminalHistory(id, options = {}) {
        const record = this.terminals.get(id);
        if (!record || record.summary.status !== 'running') {
            return null;
        }
        const limit = normalizeHistoryLimit(options.limit);
        const capture = this.ptyAdapter.captureHistory?.(id, {
            ...(options.beforeLine !== undefined ? { beforeLine: options.beforeLine } : {}),
            limit
        });
        if (!capture) {
            return null;
        }
        return {
            terminalId: id,
            startLine: capture.startLine,
            endLine: capture.endLine,
            hasMore: capture.hasMore,
            output: capture.output
        };
    }
    getTerminalTranscriptHistory(id, options = {}) {
        const record = this.terminals.get(id);
        if (!record || record.summary.status !== 'running') {
            return null;
        }
        const source = this.ptyAdapter.resolveTranscriptSource?.(id);
        if (!source) {
            return null;
        }
        return readTranscriptHistoryPage(id, source, options);
    }
    getTerminalTranscriptSource(id) {
        const record = this.terminals.get(id);
        if (!record || record.summary.status !== 'running') {
            return null;
        }
        const source = this.ptyAdapter.resolveTranscriptSource?.(id);
        if (!source) {
            return null;
        }
        return {
            terminalId: id,
            source: { provider: source.provider, sessionId: source.sessionId }
        };
    }
    async resolveTerminalCwd(id) {
        const record = this.terminals.get(id);
        if (!record || !this.ensureAttached(record)) {
            return null;
        }
        return this.safeResolveCwd(record.pty.pid);
    }
    // Push a new human title to the record and the persistence adapter so the
    // operator's rename of a tab survives both detach (the title shows up on
    // the pill) and NTerminal restart (tmux holds the title as its window
    // name). Returns the updated summary, or null when the id is unknown.
    renameTerminal(id, title) {
        const record = this.terminals.get(id);
        if (!record)
            return null;
        const trimmed = title.trim();
        if (!trimmed || trimmed === record.summary.title) {
            return cloneSummary(record.summary);
        }
        record.summary.title = trimmed;
        record.summary.updatedAt = this.timestamp();
        this.ptyAdapter.setSessionTitle?.(id, trimmed);
        this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
        return cloneSummary(record.summary);
    }
    async refreshTerminalCwd(id) {
        const record = this.terminals.get(id);
        if (!record || !this.ensureAttached(record)) {
            return null;
        }
        await this.refreshCwd(record);
        return cloneSummary(record.summary);
    }
    attachTerminal(id, subscriber, options = {}) {
        const record = this.terminals.get(id);
        if (!record) {
            return null;
        }
        // Lazy attach for restored sessions: if the record exists in memory but
        // has no local PTY client (we recovered the sessionId from the adapter
        // at startup but haven't reconnected yet), spawn one now. From the
        // client's perspective this is indistinguishable from a normal attach
        // — tmux replays the current screen on connect.
        if (!this.ensureAttached(record) && record.summary.status === 'running') {
            return null;
        }
        if (record.summary.status === 'running') {
            record.history = this.captureTerminalHistoryCursor(record);
        }
        const output = record.output.snapshot();
        record.subscribers.add(subscriber);
        if (options.replay ?? true) {
            for (const data of output) {
                subscriber({ type: 'output', terminalId: id, data });
            }
        }
        let attachmentPaused = false;
        const pause = () => {
            if (attachmentPaused)
                return;
            attachmentPaused = true;
            if (record.pauseCount++ === 0) {
                record.pty?.pause();
            }
        };
        const resume = () => {
            if (!attachmentPaused)
                return;
            attachmentPaused = false;
            if (--record.pauseCount === 0) {
                record.pty?.resume();
            }
        };
        return {
            terminal: cloneSummary(record.summary),
            output,
            history: record.history,
            pause,
            resume,
            dispose: () => {
                // Release any outstanding pause this attachment held so we don't
                // permanently stall the PTY when a paused subscriber drops.
                resume();
                record.subscribers.delete(subscriber);
            }
        };
    }
    writeToTerminal(id, data) {
        const record = this.terminals.get(id);
        if (!record?.pty || record.summary.status !== 'running') {
            return false;
        }
        record.pty.write(data);
        record.summary.updatedAt = this.timestamp();
        return true;
    }
    resizeTerminal(id, cols, rows) {
        const record = this.terminals.get(id);
        if (!record?.pty || record.summary.status !== 'running') {
            return false;
        }
        const normalizedCols = normalizeDimension(cols, record.summary.cols);
        const normalizedRows = normalizeDimension(rows, record.summary.rows);
        if (record.summary.cols === normalizedCols && record.summary.rows === normalizedRows) {
            return true;
        }
        record.pty.resize(normalizedCols, normalizedRows);
        record.summary.cols = normalizedCols;
        record.summary.rows = normalizedRows;
        record.summary.updatedAt = this.timestamp();
        this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
        return true;
    }
    closeTerminal(id) {
        const record = this.terminals.get(id);
        if (!record) {
            return false;
        }
        if (!record.pty && record.summary.status === 'running') {
            this.ptyAdapter.destroySession?.(id);
        }
        this.disposeRecord(record);
        this.terminals.delete(id);
        return true;
    }
    closeAll() {
        // Differs from closeTerminal: we tear down our in-memory bookkeeping but
        // DO NOT call pty.kill() when the adapter is persistent (tmux). Killing
        // here would destroy every tmux session on NTerminal shutdown — defeating
        // the entire across-restart persistence design. For non-persistent
        // adapters (NodePtyAdapter), the child shells get SIGHUP via process
        // tree exit anyway, so explicitly killing is just being polite.
        const persistent = typeof this.ptyAdapter.listExisting === 'function';
        for (const record of this.terminals.values()) {
            record.dataDisposable.dispose();
            record.exitDisposable.dispose();
            record.subscribers.clear();
            if (!persistent && record.pty && record.summary.status === 'running') {
                record.pty.kill();
            }
            record.pty = null;
        }
        this.terminals.clear();
    }
    ensureAttached(record) {
        if (record.pty) {
            return true;
        }
        if (record.summary.status !== 'running' || typeof this.ptyAdapter.listExisting !== 'function') {
            return false;
        }
        try {
            this.reattachToSession(record);
        }
        catch {
            return false;
        }
        return record.pty !== null;
    }
    async resolveInitialCwd(options) {
        if (options.cwd?.trim()) {
            return path.resolve(options.cwd);
        }
        if (options.parentTerminalId) {
            const parent = this.terminals.get(options.parentTerminalId);
            if (parent) {
                const resolved = parent.summary.pid ? await this.safeResolveCwd(parent.summary.pid) : null;
                return resolved ?? parent.summary.cwd;
            }
        }
        return this.config.workspaceRoot;
    }
    async safeResolveCwd(pid) {
        try {
            return await this.resolveCwd(pid);
        }
        catch {
            return null;
        }
    }
    async refreshCwd(record) {
        const pty = record.pty;
        if (!pty || record.summary.status !== 'running') {
            return;
        }
        const cwd = await this.safeResolveCwd(pty.pid);
        if (!cwd || record.pty !== pty || record.summary.status !== 'running' || cwd === record.summary.cwd) {
            return;
        }
        const previousAutoTitle = this.createTitle(record.summary.cwd);
        const shouldAutoRename = record.summary.title === previousAutoTitle;
        record.summary.cwd = cwd;
        if (shouldAutoRename) {
            record.summary.title = this.createTitle(cwd);
        }
        record.summary.updatedAt = this.timestamp();
        this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
    }
    publish(record, message) {
        for (const subscriber of record.subscribers) {
            subscriber(message);
        }
    }
    disposeRecord(record) {
        record.dataDisposable.dispose();
        record.exitDisposable.dispose();
        record.subscribers.clear();
        if (record.pty && record.summary.status === 'running') {
            record.pty.kill();
        }
        record.pty = null;
    }
    createTitle(cwd) {
        return path.basename(cwd) || 'Terminal';
    }
    timestamp() {
        return this.now().toISOString();
    }
}
function normalizeDimension(value, fallback) {
    return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
}
function normalizeHistoryLimit(value) {
    if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
        return HISTORY_PAGE_LINES;
    }
    return Math.min(value, HISTORY_PAGE_MAX_LINES);
}
function cloneSummary(summary) {
    return { ...summary };
}
function terminalEnv() {
    const env = {};
    const allowedKeys = ['HOME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'LOGNAME', 'PATH', 'SHELL', 'TMPDIR', 'USER'];
    for (const key of allowedKeys) {
        const value = process.env[key];
        if (value !== undefined) {
            env[key] = value;
        }
    }
    env.TERM = 'xterm-256color';
    env.COLORTERM = process.env.COLORTERM ?? 'truecolor';
    ensureUtf8TerminalLocale(env);
    return env;
}
const fallbackTerminalLocale = 'en_US.UTF-8';
function ensureUtf8TerminalLocale(env) {
    const characterLocale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG;
    if (isUtf8Locale(characterLocale)) {
        if (!env.LANG) {
            env.LANG = characterLocale;
        }
        if (!env.LC_ALL && !env.LC_CTYPE) {
            env.LC_CTYPE = characterLocale;
        }
        return;
    }
    env.LANG = fallbackTerminalLocale;
    if (!env.LC_ALL) {
        env.LC_CTYPE = fallbackTerminalLocale;
    }
}
function isUtf8Locale(value) {
    return value !== undefined && /utf-?8/i.test(value);
}
//# sourceMappingURL=TerminalManager.js.map