import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { existsSync, statSync } from 'node:fs';
import { z } from 'zod';
import { logger } from '../utils/logger.js';
/**
 * Scan lifecycle store — in-memory cache, SSE fan-out, FIFO queue,
 * per-workspace history cache, on-disk persistence under
 * `<workspace>/.rothunter/scans/<scanId>.json`.
 *
 * All scan-related mutable state lives in this module. The route layer
 * holds no scan state of its own — it calls into these helpers + reads
 * via the `scans` map. Single-thread Node guarantees the bookkeeping
 * is race-free; the queue is FIFO (acquireScanSlot returns immediately
 * when no scan is running, otherwise enqueues a resolver).
 */
/**
 * SSE event shape (server → browser). Single source of truth for the
 * scan-lifecycle wire format. The zod schema is exported so the UI can
 * `z.infer<typeof ScanSseEventSchema>` for its TS type (eliminates the
 * old hand-maintained mirror in the React app that would silently drift
 * when a new state was added on the server).
 */
export const ScanVerdictSchema = z.object({
    detectorId: z.string(),
    race: z.boolean(),
    confidence: z.number().min(0).max(1),
    reason: z.string(),
    latencyMs: z.number().nonnegative(),
    cluster: z.string().optional(),
});
export const ScanSseEventSchema = z.object({
    scanId: z.string(),
    ts: z.number(),
    state: z.enum(['queued', 'parsing', 'detecting', 'llm-start', 'llm-verdict', 'done', 'error']),
    files: z.number().optional(),
    symbols: z.number().optional(),
    detector: z.string().optional(),
    llmDone: z.number().optional(),
    llmTotal: z.number().optional(),
    verdict: ScanVerdictSchema.optional(),
    findings: z.number().optional(),
    durationMs: z.number().optional(),
    error: z.string().optional(),
});
export function summarizeLlmStats(verdicts) {
    if (verdicts.length === 0) {
        return {
            calls: 0,
            totalLatencyMs: 0,
            meanLatencyMs: 0,
            p50LatencyMs: 0,
            p95LatencyMs: 0,
            byDetector: {},
        };
    }
    const lats = verdicts.map((v) => v.latencyMs).sort((a, b) => a - b);
    const total = lats.reduce((s, x) => s + x, 0);
    const byDetector = {};
    const perDetector = new Map();
    for (const v of verdicts) {
        const arr = perDetector.get(v.detectorId) ?? [];
        arr.push(v.latencyMs);
        perDetector.set(v.detectorId, arr);
    }
    for (const [id, arr] of perDetector) {
        arr.sort((a, b) => a - b);
        byDetector[id] = {
            calls: arr.length,
            totalLatencyMs: arr.reduce((s, x) => s + x, 0),
            p95LatencyMs: percentile(arr, 0.95),
        };
    }
    return {
        calls: verdicts.length,
        totalLatencyMs: total,
        meanLatencyMs: Math.round(total / verdicts.length),
        p50LatencyMs: percentile(lats, 0.5),
        p95LatencyMs: percentile(lats, 0.95),
        byDetector,
    };
}
function percentile(sorted, p) {
    if (sorted.length === 0)
        return 0;
    // Nearest-rank method. Suitable for small N (typical scan emits 10–500
    // verdicts) — anything fancier (linear interpolation) adds zero signal
    // at this sample size.
    const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
    return sorted[idx];
}
// In-memory scan cache. Bounded LRU-style: oldest finished entries are
// dropped first when SCAN_CACHE_LIMIT is exceeded. Disk history under
// `<workspace>/.rothunter/scans/` remains authoritative — eviction here
// just protects long-running server memory.
const SCAN_CACHE_LIMIT = 64;
export const scans = new Map();
export const sseClients = new Map();
// Set of scanIds the operator has cancelled. The detector loop is
// synchronous so we can't truly interrupt it mid-detector, but the LLM
// confirmation pass checks this set between verdicts and bails out —
// LLM time dominates total scan duration.
export const cancelledScans = new Set();
export function evictOldScans() {
    while (scans.size > SCAN_CACHE_LIMIT) {
        // Find the oldest scan that is not currently running/queued.
        let dropKey = null;
        let dropStart = Infinity;
        for (const [k, v] of scans) {
            if (v.state !== 'done' && v.state !== 'error')
                continue;
            if (v.startedAt < dropStart) {
                dropStart = v.startedAt;
                dropKey = k;
            }
        }
        if (!dropKey)
            break; // all live — nothing safe to drop
        scans.delete(dropKey);
        sseClients.delete(dropKey);
    }
}
// Single-flight scan execution. Concurrent POSTs to /api/scans would
// reparse the workspace and pile up LLM verdict requests, melting the
// local llama.cpp backend. We serialise: at most one running scan, the
// rest sit in a FIFO queue and report `state: 'queued'` until promoted.
let runningScanId = null;
const scanQueue = [];
export const SCAN_QUEUE_LIMIT = 8;
export function getRunningScanId() {
    return runningScanId;
}
export function getScanQueueLength() {
    return scanQueue.length;
}
export function acquireScanSlot(scanId) {
    return new Promise((resolve, reject) => {
        const begin = () => {
            runningScanId = scanId;
            resolve();
        };
        const abort = () => reject(new Error('queue entry dropped'));
        if (runningScanId === null)
            begin();
        else
            scanQueue.push({ scanId, begin, abort });
    });
}
export function releaseScanSlot() {
    runningScanId = null;
    const next = scanQueue.shift();
    if (next) {
        try {
            next.begin();
        }
        catch (err) {
            logger.error({ err: err.message }, 'queue starter threw — queue may be stuck');
        }
    }
}
export function dropQueuedScan(scanId) {
    const idx = scanQueue.findIndex((q) => q.scanId === scanId);
    if (idx < 0)
        return false;
    const [entry] = scanQueue.splice(idx, 1);
    entry?.abort();
    return true;
}
export function broadcast(scanId, event) {
    const clients = sseClients.get(scanId);
    if (!clients)
        return;
    const payload = `data: ${JSON.stringify(event)}\n\n`;
    for (const res of clients) {
        try {
            res.write(payload);
        }
        catch {
            clients.delete(res);
        }
    }
}
export function applyProgressToRecord(record, event) {
    const sse = {
        scanId: record.scanId,
        ts: Date.now(),
        state: event.state,
    };
    switch (event.state) {
        case 'parsing':
            record.state = 'parsing';
            if (event.files != null) {
                sse.files = event.files;
                record.filesCount = event.files;
            }
            if (event.symbols != null) {
                sse.symbols = event.symbols;
                record.symbolsCount = event.symbols;
            }
            break;
        case 'detecting':
            record.state = 'detecting';
            sse.detector = event.detector;
            // Track which detectors have entered + completed so a late
            // subscriber (reload mid-scan) can repaint the pipeline progress.
            // The previously-active detector is the one that just finished;
            // record both.
            if (record.activeDetector && record.activeDetector !== event.detector) {
                const done = record.doneDetectors ?? [];
                if (!done.includes(record.activeDetector)) {
                    record.doneDetectors = [...done, record.activeDetector];
                }
            }
            record.activeDetector = event.detector;
            break;
        case 'llm-start':
            record.state = 'llm-start';
            sse.llmTotal = event.total;
            record.llmTotal = event.total;
            record.llmDone = 0;
            // Entering the LLM pass means every detector finished — move the
            // last active detector into the done list.
            if (record.activeDetector) {
                const done = record.doneDetectors ?? [];
                if (!done.includes(record.activeDetector)) {
                    record.doneDetectors = [...done, record.activeDetector];
                }
                record.activeDetector = undefined;
            }
            break;
        case 'llm-verdict':
            record.state = 'llm-verdict';
            sse.llmDone = event.done;
            sse.llmTotal = event.total;
            record.llmDone = event.done;
            record.llmTotal = event.total;
            sse.verdict = {
                detectorId: event.detectorId,
                race: event.race,
                confidence: event.confidence,
                reason: event.reason,
                latencyMs: event.latencyMs,
                cluster: event.cluster,
            };
            record.verdictLog.push(sse.verdict);
            break;
        case 'done':
            record.state = 'done';
            sse.findings = event.findings;
            sse.durationMs = event.durationMs;
            break;
    }
    record.progress = sse;
    return sse;
}
export const scanHistoryCache = new Map();
export async function persistScan(record) {
    const dir = path.join(record.workspaceRoot, '.rothunter', 'scans');
    await fs.mkdir(dir, { recursive: true });
    const file = path.join(dir, `${record.scanId}.json`);
    await fs.writeFile(file, JSON.stringify(record, null, 2), 'utf-8');
    // Bust history cache for this workspace — the next /api/scans call
    // re-reads the new entry from disk.
    scanHistoryCache.delete(record.workspaceRoot);
}
export async function loadScanHistory(workspaceRoot) {
    const dir = path.join(workspaceRoot, '.rothunter', 'scans');
    if (!existsSync(dir))
        return [];
    let mtimeMs = 0;
    try {
        mtimeMs = statSync(dir).mtimeMs;
    }
    catch {
        // ignore — first read will populate
    }
    const cached = scanHistoryCache.get(workspaceRoot);
    if (cached && cached.mtimeMs === mtimeMs)
        return cached.records;
    const files = await fs.readdir(dir);
    const records = [];
    for (const f of files.filter((n) => n.endsWith('.json'))) {
        try {
            const raw = await fs.readFile(path.join(dir, f), 'utf-8');
            records.push(JSON.parse(raw));
        }
        catch (err) {
            logger.warn({ file: f, err }, 'Failed to load scan history entry');
        }
    }
    records.sort((a, b) => b.startedAt - a.startedAt);
    scanHistoryCache.set(workspaceRoot, { mtimeMs, records });
    return records;
}
//# sourceMappingURL=scan-store.js.map