import { createHash, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { loadDevices, isDialableDevice } from '../../devices/registry.js';
import { deviceIdentityArgs, sshTargetFor } from '../../devices/connect.js';
import { machineId, normalizeHost } from '../../machine-id.js';
import { SSH_OPTS, controlOpts, shellQuote } from '../../ssh-exec.js';
import { buildWindowsAgentsCommand, remoteShellFor } from '../../hosts/remote-cmd.js';
import { isReapableOrphan } from '../active.js';
import { querySessions, readSessionSummaryAny } from '../db.js';
import { linearIssueUrl } from '../linear.js';
import { sessionAgentSupportsResume } from '../recovery.js';
import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, noteActiveSessionsJournalReader, resolveStreamSummaryState, } from '../session-cache.js';
export const SESSION_WATCH_VERSION = 1;
export const SESSION_WATCH_HEARTBEAT_MS = 15_000;
export const SESSION_WATCH_PREVIOUS_LIMIT = 50;
/** Stable, opaque identity for one row within one device scope. */
export function sessionWatchRowKey(scope, row) {
    const identity = activeSessionJournalIdentity(row);
    return createHash('sha256').update(`${scope}\0${identity}`).digest('base64url').slice(0, 22);
}
export function toSessionWatchRow(scope, row) {
    const rowKey = sessionWatchRowKey(scope, row);
    // A dead, days-stale crash-orphan is folded OUT of the reconnectable set — it
    // is not resumable, so the "Needs reconnecting" list stops ballooning with
    // leaked --device tunnel sessions (RUSH-3011 / issue #3b). A live or
    // recently-exited session stays resumable.
    const resumable = Boolean(row.sessionId) && !isReapableOrphan(row);
    const previous = row.status === 'closed'
        || row.status === 'crashed'
        || (row.status === 'abandoned' && row.pidAlive === false);
    const viewingIn = row.viewingIn
        ? [row.viewingIn.app, row.viewingIn.tab ? `tab ${row.viewingIn.tab}` : undefined].filter(Boolean).join(' ')
        : null;
    return {
        ...row,
        // The summarizer's per-row state is delivered on the stream: a live row with
        // no computed summary reads `pending` when the summarizer is on, `skipped`
        // when it is off (PHNX-3939). goal/checkpoints/summaryChecklist rode `...row`.
        summaryState: resolveStreamSummaryState(row.summaryState),
        rowKey,
        sourceDevice: scope,
        previous,
        resumable,
        unwatched: !viewingIn,
        viewingIn,
        recovery: resumable
            ? { command: 'agents', args: ['sessions', 'resume', row.sessionId, '--device', scope], ...(row.cwd ? { cwd: row.cwd } : {}) }
            : null,
    };
}
function epochMs(value) {
    const parsed = value ? Date.parse(value) : Number.NaN;
    return Number.isFinite(parsed) ? parsed : 0;
}
/** Stable identity for a durable Previous row. It is deliberately distinct
 * from the live row key for the same session id: both may coexist on the one
 * stream, and the presentation layer lets the live row win while it exists. */
export function previousSessionWatchRowKey(scope, sessionId) {
    return createHash('sha256').update(`${scope}\0previous\0${sessionId}`).digest('base64url').slice(0, 22);
}
/** Project one durable indexed session into the same canonical watch contract
 * as live sessions. This is the only history backfill consumed by AGI EXT. */
export function toPreviousSessionWatchRow(scope, session) {
    const resumable = sessionAgentSupportsResume(session.agent);
    const sourceDevice = normalizeHost(session.machine ?? scope);
    const startedAtMs = epochMs(session.timestamp);
    const lastActivityMs = epochMs(session.lastActivity) || startedAtMs;
    const worktree = session.worktreeSlug && session.cwd
        ? { slug: session.worktreeSlug, path: session.cwd, ...(session.gitBranch ? { branch: session.gitBranch } : {}) }
        : undefined;
    // Project the daemon-computed summary (PHNX-3939) onto the history row from the
    // transcript-keyed cache — the same store the live merge reads, so a closed or
    // fleet-mirrored session carries its goal/checkpoints/checklist with no
    // transcript re-parse and no model call. Prefer a value already on the meta row.
    const summary = session.goal !== undefined || session.summaryState !== undefined
        ? {
            goal: session.goal,
            checkpoints: session.checkpoints,
            summaryChecklist: session.summaryChecklist,
            summaryState: session.summaryState,
        }
        : readSessionSummaryAny(session.id);
    return {
        context: 'recent',
        kind: session.agent,
        ...(session.harness ? { harness: session.harness } : {}),
        sessionId: session.id,
        ...(session.cwd ? { cwd: session.cwd } : {}),
        ...(session.project ? { project: session.project } : {}),
        ...(session.label ? { label: session.label, title: session.label } : session.topic ? { title: session.topic } : {}),
        ...(session.topic ? { topic: session.topic } : {}),
        ...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
        ...(session.version ? { version: session.version } : {}),
        ...(session.account ? { account: session.account } : {}),
        ...(session.prUrl ? { pr: { url: session.prUrl, number: session.prNumber } } : {}),
        ...(worktree ? { worktree } : {}),
        ...(session.gitBranch ? { branch: session.gitBranch } : {}),
        ...(session.ticketId ? { ticket: { id: session.ticketId, url: linearIssueUrl(session.ticketId) } } : {}),
        ...(session.createdTickets ? { createdTickets: session.createdTickets } : {}),
        ...(session.spawnedTeam ? { spawnedTeam: session.spawnedTeam } : {}),
        ...(session.tokenCount != null ? { tokenCount: session.tokenCount } : {}),
        ...(session.durationMs != null ? { durationMs: session.durationMs } : {}),
        ...(session.subAgentCount != null ? { subAgentCount: session.subAgentCount } : {}),
        ...(summary?.goal ? { goal: summary.goal } : {}),
        ...(summary?.checkpoints ? { checkpoints: summary.checkpoints } : {}),
        ...(summary?.summaryChecklist ? { summaryChecklist: summary.summaryChecklist } : {}),
        summaryState: resolveStreamSummaryState(summary?.summaryState),
        startedAtMs,
        lastActivityMs,
        status: 'closed',
        phase: 'done',
        pidAlive: false,
        rowKey: previousSessionWatchRowKey(scope, session.id),
        sourceDevice,
        previous: true,
        resumable,
        unwatched: true,
        viewingIn: null,
        recovery: resumable
            ? { command: 'agents', args: ['sessions', 'resume', session.id, '--device', sourceDevice], ...(session.cwd ? { cwd: session.cwd } : {}) }
            : null,
    };
}
function demoteWatchRow(scope, row) {
    if (!row.sessionId || row.rowKey === previousSessionWatchRowKey(scope, row.sessionId))
        return null;
    const sourceDevice = row.sourceDevice || scope;
    return {
        ...row,
        context: 'recent',
        status: 'closed',
        phase: 'done',
        pidAlive: false,
        rowKey: previousSessionWatchRowKey(scope, row.sessionId),
        previous: true,
        unwatched: true,
        viewingIn: null,
        recovery: row.resumable
            ? { command: 'agents', args: ['sessions', 'resume', row.sessionId, '--device', sourceDevice], ...(row.cwd ? { cwd: row.cwd } : {}) }
            : null,
    };
}
function isDurablePreviousRow(scope, row) {
    return Boolean(row.sessionId) && row.rowKey === previousSessionWatchRowKey(scope, row.sessionId);
}
function rowRecency(row) {
    return row.lastActivityMs ?? row.startedAtMs ?? 0;
}
function sameRow(a, b) {
    return JSON.stringify(a) === JSON.stringify(b);
}
/** Stream-local sequencer and convergent row diff. */
export class SessionWatchState {
    streamId;
    sequence = 0;
    rows = new Map();
    constructor(streamId = randomUUID()) { this.streamId = streamId; }
    base(type) {
        return { version: SESSION_WATCH_VERSION, type, streamId: this.streamId, sequence: ++this.sequence, capturedAt: Date.now() };
    }
    /** Keep one visible Previous row per session and the newest bounded window.
     * Returns removed keys so delta callers can converge already-connected clients. */
    prunePrevious(scope, rows) {
        const removed = [];
        const bySession = new Map();
        for (const row of rows.values()) {
            if (!row.previous || !row.sessionId)
                continue;
            const existing = bySession.get(row.sessionId);
            if (!existing) {
                bySession.set(row.sessionId, row);
                continue;
            }
            const keepRow = rowRecency(row) > rowRecency(existing)
                || (rowRecency(row) === rowRecency(existing) && isDurablePreviousRow(scope, row));
            const drop = keepRow ? existing : row;
            const keep = keepRow ? row : existing;
            if (rows.delete(drop.rowKey))
                removed.push(drop.rowKey);
            bySession.set(row.sessionId, keep);
        }
        const previous = [...bySession.values()].sort((a, b) => rowRecency(b) - rowRecency(a));
        for (const row of previous.slice(SESSION_WATCH_PREVIOUS_LIMIT)) {
            if (rows.delete(row.rowKey))
                removed.push(row.rowKey);
        }
        return removed;
    }
    reset(scope, sourceRows, indexedRows = []) {
        const liveRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
        const sourceIds = new Set(liveRows.map((row) => row.sessionId).filter((id) => Boolean(id)));
        const rows = new Map(liveRows.map((row) => [row.rowKey, row]));
        for (const indexed of indexedRows) {
            if (sourceIds.has(indexed.id))
                continue;
            const row = toPreviousSessionWatchRow(scope, indexed);
            rows.set(row.rowKey, row);
        }
        this.prunePrevious(scope, rows);
        this.rows.set(scope, rows);
        return { ...this.base('reset'), scope, rows: [...rows.values()] };
    }
    update(scope, sourceRows) {
        const previous = this.rows.get(scope) ?? new Map();
        const nextRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
        const activeIds = new Set(nextRows.filter((row) => !row.previous).map((row) => row.sessionId).filter((id) => Boolean(id)));
        const next = new Map([...previous.values()]
            .filter((row) => isDurablePreviousRow(scope, row) && (!row.sessionId || !activeIds.has(row.sessionId)))
            .map((row) => [row.rowKey, row]));
        for (const row of nextRows)
            next.set(row.rowKey, row);
        const events = [];
        for (const [rowKey, row] of next) {
            if (!previous.has(rowKey) || !sameRow(previous.get(rowKey), row)) {
                events.push({ ...this.base('upsert'), scope, rowKey, row });
            }
        }
        for (const [rowKey, row] of previous) {
            if (next.has(rowKey))
                continue;
            if (isDurablePreviousRow(scope, row) && (!row.sessionId || !activeIds.has(row.sessionId)))
                continue;
            events.push({ ...this.base('remove'), scope, rowKey });
            const demoted = demoteWatchRow(scope, row);
            if (demoted && (!next.has(demoted.rowKey) || !sameRow(next.get(demoted.rowKey), demoted))) {
                next.set(demoted.rowKey, demoted);
                events.push({ ...this.base('upsert'), scope, rowKey: demoted.rowKey, row: demoted });
            }
        }
        for (const rowKey of this.prunePrevious(scope, next)) {
            events.push({ ...this.base('remove'), scope, rowKey });
        }
        this.rows.set(scope, next);
        return events;
    }
    patch(scope, upserts, removes) {
        const current = new Map(this.rows.get(scope) ?? []);
        const events = [];
        for (const source of upserts) {
            const row = toSessionWatchRow(scope, source);
            if (!row.previous && row.sessionId) {
                const previousKey = previousSessionWatchRowKey(scope, row.sessionId);
                if (current.delete(previousKey))
                    events.push({ ...this.base('remove'), scope, rowKey: previousKey });
            }
            if (!current.has(row.rowKey) || !sameRow(current.get(row.rowKey), row)) {
                current.set(row.rowKey, row);
                events.push({ ...this.base('upsert'), scope, rowKey: row.rowKey, row });
            }
        }
        for (const identity of removes) {
            const rowKey = createHash('sha256').update(`${scope}\0${identity}`).digest('base64url').slice(0, 22);
            const removed = current.get(rowKey);
            if (!current.delete(rowKey))
                continue;
            events.push({ ...this.base('remove'), scope, rowKey });
            const demoted = removed ? demoteWatchRow(scope, removed) : null;
            if (demoted && (!current.has(demoted.rowKey) || !sameRow(current.get(demoted.rowKey), demoted))) {
                current.set(demoted.rowKey, demoted);
                events.push({ ...this.base('upsert'), scope, rowKey: demoted.rowKey, row: demoted });
            }
        }
        for (const rowKey of this.prunePrevious(scope, current)) {
            events.push({ ...this.base('remove'), scope, rowKey });
        }
        this.rows.set(scope, current);
        return events;
    }
    scope(scope, status, reason) {
        // Deliberately retain rows while unavailable. A reconnecting scope replaces
        // them with a reset; transient fleet loss must not look like session death.
        return { ...this.base('scope'), scope, status, ...(reason ? { reason } : {}) };
    }
    heartbeat(scope, capturedAt = Date.now()) {
        return { ...this.base('heartbeat'), scope, capturedAt };
    }
}
/** One bounded index read when the watch starts. The stream then owns this
 * projection: active rows update through the journal, and removals demote into
 * Previous rows without another transcript/index poll. */
export function readPreviousSessionsForWatch(scope) {
    try {
        return querySessions({
            machine: normalizeHost(scope),
            agents: ['claude', 'codex', 'muse', 'opencode'],
            sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000,
            excludeTeamOrigin: true,
        })
            .filter((session) => !session.archived && Boolean(session.filePath))
            .slice(0, SESSION_WATCH_PREVIOUS_LIMIT);
    }
    catch {
        return [];
    }
}
/**
 * Keep one local subscription alive. Startup reads one canonical reset snapshot;
 * steady state tails the canonical writer journal and never invokes a gather.
 */
export async function watchLocalSessions(options) {
    const state = new SessionWatchState();
    const readCache = options.readCache ?? readActiveSessionsCache;
    const readPrevious = options.readPrevious ?? readPreviousSessionsForWatch;
    const journal = options.journalPath ?? activeSessionsJournalPath();
    const heartbeatMs = options.heartbeatMs ?? SESSION_WATCH_HEARTBEAT_MS;
    let offset = 0;
    try {
        offset = fs.statSync(journal).size;
    }
    catch { /* first publication */ }
    const initial = readCache('local');
    options.emit(state.reset(options.scope, initial?.sessions ?? [], readPrevious(options.scope)));
    // Known gap (out of scope for RUSH-2484): a present cache alone marks
    // 'available' with no staleness/age check, so a reconnect can briefly render
    // a stale snapshot as live before the out-of-band gather this file triggers
    // (see watchActiveSessionsReaderPresence in session-cache.ts) replaces it.
    options.emit(state.scope(options.scope, initial ? 'available' : 'unavailable', initial ? undefined : 'awaiting publisher'));
    if (options.signal.aborted)
        return;
    // Signal the daemon that a consumer is live so it does not skip the gather.
    noteActiveSessionsJournalReader();
    await new Promise((resolve) => {
        let partial = '';
        let reading = false;
        const readAppended = () => {
            if (reading || options.signal.aborted)
                return;
            reading = true;
            try {
                const size = fs.statSync(journal).size;
                if (size < offset) {
                    offset = 0;
                    partial = '';
                }
                if (size === offset)
                    return;
                const fd = fs.openSync(journal, 'r');
                try {
                    const buffer = Buffer.alloc(size - offset);
                    fs.readSync(fd, buffer, 0, buffer.length, offset);
                    offset = size;
                    const lines = (partial + buffer.toString('utf8')).split('\n');
                    partial = lines.pop() ?? '';
                    for (const line of lines) {
                        if (!line)
                            continue;
                        try {
                            const record = JSON.parse(line);
                            if (record.version !== 1 || record.scope !== 'local' || !Array.isArray(record.upserts) || !Array.isArray(record.removes))
                                continue;
                            for (const event of state.patch(options.scope, record.upserts, record.removes))
                                options.emit(event);
                            options.emit(state.scope(options.scope, 'available'));
                        }
                        catch { /* partial/corrupt journal lines are not state */ }
                    }
                }
                finally {
                    fs.closeSync(fd);
                }
            }
            catch { /* journal may not exist until the first publisher write */ }
            finally {
                reading = false;
            }
        };
        fs.mkdirSync(path.dirname(journal), { recursive: true });
        const journalListener = () => readAppended();
        fs.watchFile(journal, { interval: options.journalPollMs ?? 250 }, journalListener);
        const heartbeatTimer = setInterval(() => {
            noteActiveSessionsJournalReader();
            options.emit(state.heartbeat(options.scope));
        }, heartbeatMs);
        const stop = () => { fs.unwatchFile(journal, journalListener); clearInterval(heartbeatTimer); resolve(); };
        options.signal.addEventListener('abort', stop, { once: true });
        // Close the offset/read/watch handoff: a publisher may append after the
        // startup offset is captured but before watchFile is registered.
        readAppended();
    });
}
function remoteWatchCommand(os) {
    const args = ['sessions', 'watch', '--json', '--local'];
    return remoteShellFor(os) === 'powershell'
        ? buildWindowsAgentsCommand({ args })
        : `bash -lc ${shellQuote(`agents ${args.map(shellQuote).join(' ')}`)}`;
}
/**
 * Subscribe to every dialable compute device with one persistent SSH process.
 * A peer disconnect emits `scope: unavailable` but no removes; reconnecting
 * peer resets only its own scope. There is no recurring fleet list command.
 */
export async function watchFleetSessions(options) {
    const local = watchLocalSessions({ scope: machineId(), signal: options.signal, emit: options.emit });
    let devices;
    try {
        devices = await loadDevices();
    }
    catch {
        await local;
        return;
    }
    const self = machineId();
    const peers = Object.values(devices).filter((device) => isDialableDevice(device)
        && normalizeHost(device.name) !== self
        && ['windows', 'linux', 'macos'].includes(device.platform));
    const reconnectMs = options.reconnectMs ?? 2_000;
    const peerTasks = peers.map(async (device) => {
        const scope = normalizeHost(device.name);
        const state = new SessionWatchState();
        while (!options.signal.aborted) {
            let target;
            try {
                target = sshTargetFor(device);
            }
            catch (error) {
                options.emit(state.scope(scope, 'unavailable', error instanceof Error ? error.message : String(error)));
                break;
            }
            const child = spawn('ssh', [
                ...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(device), target, remoteWatchCommand(device.platform),
            ], { stdio: ['ignore', 'pipe', 'ignore'] });
            const stop = () => child.kill('SIGTERM');
            options.signal.addEventListener('abort', stop, { once: true });
            const reader = createInterface({ input: child.stdout });
            reader.on('line', (line) => {
                try {
                    const event = JSON.parse(line);
                    if (event.version === SESSION_WATCH_VERSION && typeof event.sequence === 'number')
                        options.emit(event);
                }
                catch { /* incomplete/non-protocol peer output is not state */ }
            });
            const code = await new Promise((resolve) => {
                child.once('error', () => resolve(null));
                child.once('close', resolve);
            });
            reader.close();
            options.signal.removeEventListener('abort', stop);
            if (options.signal.aborted)
                break;
            options.emit(state.scope(scope, 'unavailable', code === null ? 'ssh failed' : `ssh exited ${code}`));
            await new Promise((resolve) => {
                const timer = setTimeout(resolve, reconnectMs);
                options.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
            });
        }
    });
    await Promise.all([local, ...peerTasks]);
}
