"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ShamashScanner = exports.ScanTimeoutError = void 0;
exports.findBinary = findBinary;
const node_child_process_1 = require("node:child_process");
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const node_util_1 = require("node:util");
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
class ScanTimeoutError extends Error {
    timeoutMs;
    constructor(timeoutMs) {
        super(`Scan timed out after ${timeoutMs}ms`);
        this.name = "ScanTimeoutError";
        this.timeoutMs = timeoutMs;
    }
}
exports.ScanTimeoutError = ScanTimeoutError;
/**
 * Search order for locating the shamash binary:
 *   1. Explicit custom path from plugin config
 *   2. Bundled binary inside the package's bin/ directory
 *   3. System PATH (which, execFileSync --version)
 *   4. Common installation directories
 *
 * Returns the absolute path to the binary, or null if not found.
 */
function findBinary(customPath) {
    // 1. Explicit custom path
    if (customPath && customPath.length > 0) {
        const resolved = (0, node_path_1.resolve)(customPath);
        if ((0, node_fs_1.existsSync)(resolved)) {
            return resolved;
        }
    }
    // 2. Bundled binary inside package bin/
    const bundledPath = (0, node_path_1.join)(__dirname, "..", "bin", "shamash");
    if ((0, node_fs_1.existsSync)(bundledPath)) {
        return bundledPath;
    }
    // 3. System PATH via `which`
    try {
        const result = (0, node_child_process_1.execFileSync)("which", ["shamash"], {
            encoding: "utf-8",
            timeout: 5000,
            stdio: ["pipe", "pipe", "pipe"],
        }).trim();
        if (result && (0, node_fs_1.existsSync)(result)) {
            return result;
        }
    }
    catch {
        // `which` not available or shamash not in PATH
    }
    // 4. Common installation directories
    const commonPaths = [
        "/usr/local/bin/shamash",
        "/opt/homebrew/bin/shamash",
        (0, node_path_1.join)(process.env.HOME ?? "", ".cargo", "bin", "shamash"),
        (0, node_path_1.join)(process.env.HOME ?? "", ".local", "bin", "shamash"),
    ];
    for (const candidate of commonPaths) {
        if ((0, node_fs_1.existsSync)(candidate)) {
            return candidate;
        }
    }
    return null;
}
/**
 * Runtime validation guard for ScanResult shape.
 */
function isValidScanResult(value) {
    if (typeof value !== "object" || value === null) {
        return false;
    }
    const obj = value;
    return (typeof obj.safe === "boolean" &&
        typeof obj.threat_type === "string" &&
        typeof obj.confidence === "number" &&
        typeof obj.latency_ms === "number" &&
        typeof obj.tier_reached === "number" &&
        Array.isArray(obj.signals));
}
function parseResult(stdout) {
    const parsed = JSON.parse(stdout.trim());
    if (!isValidScanResult(parsed)) {
        throw new Error(`Shamash returned unexpected JSON: ${stdout.trim().slice(0, 200)}`);
    }
    return parsed;
}
/**
 * Manages a Shamash scanner instance.
 *
 * - Async scan (all gates): uses a persistent daemon process if
 *   the binary supports `--stdin` mode, otherwise spawns per request.
 * - scanWithTimeout: bounded async scan with AbortController cancellation.
 */
class ShamashScanner {
    binary;
    patternsPath;
    daemon = null;
    daemonTested = false;
    daemonSupported = false;
    responseBuffer = "";
    pendingRequests = new Map();
    nextId = 0;
    constructor(binary, patternsPath) {
        this.binary = binary;
        this.patternsPath = patternsPath && patternsPath.length > 0 ? patternsPath : undefined;
    }
    /**
     * Async scan for use in async hook contexts.
     * Tries daemon mode first, falls back to exec-per-scan.
     */
    async scan(text, mode = "input", signal) {
        if (signal?.aborted)
            throw new Error("Scan aborted");
        if (!this.daemonTested) {
            await this.probeAndStartDaemon();
        }
        if (this.daemonSupported && this.daemon && !this.daemon.killed) {
            try {
                return await this.scanViaDaemon(text, mode, signal);
            }
            catch {
                if (signal?.aborted)
                    throw new Error("Scan aborted");
                // Daemon failed mid-flight, fall through to exec
            }
        }
        return this.scanViaExec(text, mode, signal);
    }
    /**
     * Bounded async scan used by hook paths with strict latency budgets.
     */
    async scanWithTimeout(text, mode = "input", timeoutMs) {
        const controller = new AbortController();
        const timer = setTimeout(() => {
            controller.abort();
        }, timeoutMs);
        try {
            const result = await this.scan(text, mode, controller.signal);
            clearTimeout(timer);
            return result;
        }
        catch (err) {
            clearTimeout(timer);
            if (controller.signal.aborted) {
                throw new ScanTimeoutError(timeoutMs);
            }
            throw err instanceof Error ? err : new Error(String(err));
        }
    }
    /**
     * Synchronous scan for use in synchronous hook contexts
     * (e.g., tool_result_persist). Uses execFileSync — blocks the event loop
     * but matches OpenClaw's synchronous hook contract.
     */
    scanSync(text, mode = "input") {
        const extraArgs = mode === "output" ? ["--mode", "output"] : [];
        if (this.patternsPath)
            extraArgs.push("--patterns", this.patternsPath);
        const stdout = (0, node_child_process_1.execFileSync)(this.binary, ["scan", "--json", ...extraArgs, text], {
            encoding: "utf-8",
            timeout: 5_000,
            maxBuffer: 1024 * 1024,
        });
        return parseResult(stdout);
    }
    /**
     * Shut down the daemon process if running.
     */
    shutdown() {
        if (this.daemon && !this.daemon.killed) {
            this.daemon.kill("SIGTERM");
        }
        this.daemon = null;
        this.daemonSupported = false;
        for (const [, req] of this.pendingRequests) {
            clearTimeout(req.timer);
            req.reject(new Error("Scanner shutting down"));
        }
        this.pendingRequests.clear();
    }
    /** True if the daemon process is alive and accepting requests. */
    get isDaemonActive() {
        return this.daemonSupported && this.daemon !== null && !this.daemon.killed;
    }
    // ── Daemon management ──────────────────────────────────────────────
    async probeAndStartDaemon() {
        this.daemonTested = true;
        try {
            const spawnArgs = ["scan", "--json", "--stdin"];
            if (this.patternsPath)
                spawnArgs.push("--patterns", this.patternsPath);
            const proc = (0, node_child_process_1.spawn)(this.binary, spawnArgs, {
                stdio: ["pipe", "pipe", "pipe"],
            });
            // Wait briefly to see if the process stays alive (supports --stdin)
            const alive = await new Promise((resolve) => {
                let settled = false;
                const settle = (val) => {
                    if (!settled) {
                        settled = true;
                        resolve(val);
                    }
                };
                proc.on("error", () => settle(false));
                proc.on("exit", () => settle(false));
                // If still alive after 800ms, assume it supports persistent mode
                setTimeout(() => settle(!proc.killed && proc.exitCode === null), 800);
            });
            if (alive) {
                this.daemon = proc;
                this.daemonSupported = true;
                proc.stdout.on("data", (chunk) => {
                    this.handleDaemonOutput(chunk.toString());
                });
                proc.on("exit", () => {
                    this.daemon = null;
                    this.rejectAllPending("Daemon process exited");
                });
                proc.on("error", () => {
                    this.daemon = null;
                    this.rejectAllPending("Daemon process error");
                });
            }
            else {
                // Process exited quickly — --stdin not supported
                this.daemonSupported = false;
                if (!proc.killed)
                    proc.kill();
            }
        }
        catch {
            this.daemonSupported = false;
        }
    }
    scanViaDaemon(text, mode, signal) {
        const id = String(++this.nextId);
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.pendingRequests.delete(id);
                reject(new Error("Daemon scan timed out (30s)"));
            }, 30_000);
            this.pendingRequests.set(id, { resolve, reject, timer });
            if (signal) {
                const onAbort = () => {
                    if (this.pendingRequests.has(id)) {
                        this.pendingRequests.delete(id);
                        clearTimeout(timer);
                        reject(new Error("Scan aborted"));
                    }
                };
                signal.addEventListener("abort", onAbort, { once: true });
            }
            try {
                const payload = { id, text, mode };
                // Patterns loaded at daemon startup via --patterns CLI flag.
                // Do NOT send per-request — Rust daemon ignores it (shamash.rs:829).
                this.daemon.stdin.write(JSON.stringify(payload) + "\n");
            }
            catch (err) {
                this.pendingRequests.delete(id);
                clearTimeout(timer);
                reject(err instanceof Error ? err : new Error(String(err)));
            }
        });
    }
    async scanViaExec(text, mode, signal) {
        const extraArgs = mode === "output" ? ["--mode", "output"] : [];
        if (this.patternsPath)
            extraArgs.push("--patterns", this.patternsPath);
        const { stdout } = await execFileAsync(this.binary, ["scan", "--json", ...extraArgs, text], {
            encoding: "utf-8",
            timeout: 30_000,
            maxBuffer: 1024 * 1024,
            signal,
        });
        return parseResult(stdout);
    }
    // ── Daemon I/O ─────────────────────────────────────────────────────
    handleDaemonOutput(data) {
        this.responseBuffer += data;
        const lines = this.responseBuffer.split("\n");
        this.responseBuffer = lines.pop() ?? "";
        for (const line of lines) {
            if (!line.trim())
                continue;
            try {
                const obj = JSON.parse(line);
                const id = String(obj.id ?? "");
                const pending = this.pendingRequests.get(id);
                if (!pending)
                    continue;
                this.pendingRequests.delete(id);
                clearTimeout(pending.timer);
                // The response may wrap the result in a `result` field or be flat
                const candidate = obj.result ?? obj;
                if (isValidScanResult(candidate)) {
                    pending.resolve(candidate);
                }
                else {
                    pending.reject(new Error("Invalid daemon scan response"));
                }
            }
            catch {
                // Skip unparseable lines
            }
        }
    }
    rejectAllPending(reason) {
        for (const [, req] of this.pendingRequests) {
            clearTimeout(req.timer);
            req.reject(new Error(reason));
        }
        this.pendingRequests.clear();
    }
}
exports.ShamashScanner = ShamashScanner;
