/**
 * PrimaryLink — link a deployed gateway to an upstream/primary gateway as a
 * browseable filesystem node.
 *
 * When `JAIT_PRIMARY_GATEWAY` is configured, this gateway opens an outbound
 * WebSocket to the primary, registers itself via `fs.register-node`, and then
 * answers the primary's `fs.browse-request` / `fs.roots-request` /
 * `fs.op-request` messages from its own local disk. This makes the node appear
 * (and be openable/editable) in the primary's Open Project modal — mirroring
 * exactly what the Electron desktop client does, so the primary needs zero
 * changes (it reuses its existing proxyFsBrowse/proxyFsRoots/proxyFsOp paths).
 *
 * The link is purely additive and off by default: with no primary configured,
 * nothing here runs.
 */
import { WebSocket } from "ws";
import { readFile, writeFile, mkdir, readdir, stat as fsStat, access } from "node:fs/promises";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve, dirname, relative } from "node:path";
import { homedir, hostname, platform } from "node:os";
import { randomUUID } from "node:crypto";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { NODE_PROTOCOL_VERSION } from "@jait/shared";
import { TerminalSurface } from "../surfaces/terminal.js";
const execAsync = promisify(exec);
/** Directories to hide from browse listings (mirror of routes/filesystem.ts) */
const HIDDEN = new Set([
    "$RECYCLE.BIN", "System Volume Information", "$WinREAgent",
    "DumpStack.log.tmp", "pagefile.sys", "hiberfil.sys", "swapfile.sys",
]);
// Every tool this headless node can execute remotely. MUST stay in sync with
// REMOTE_EXECUTABLE_TOOLS in packages/gateway/src/tools/remote-executor.ts —
// the gateway only proxies tools in that allow-list, and every entry there
// must be implemented here (and in the desktop app's electron-main.ts).
const NODE_TOOLS = [
    "terminal.run",
    "jait.terminal",
    "execute",
    "read",
    "file.read",
    "edit",
    "file.write",
    "file.patch",
    "file.list",
    "file.stat",
    "image.view",
    "search",
    "file.search",
    "os.query",
];
function detectPlatform() {
    const p = platform();
    if (p === "win32")
        return "windows";
    if (p === "darwin")
        return "macos";
    return "linux";
}
/** Stable per-machine node id, persisted under ~/.jait/node-id. */
function getNodeId() {
    try {
        const idPath = join(homedir(), ".jait", "node-id");
        if (existsSync(idPath)) {
            const existing = readFileSync(idPath, "utf-8").trim();
            if (existing)
                return existing;
        }
        const id = `node-${randomUUID()}`;
        mkdir(dirname(idPath), { recursive: true }).catch(() => { });
        writeFileSync(idPath, id, "utf-8");
        return id;
    }
    catch {
        // Fall back to a hostname-derived id if persistence fails.
        return `node-${hostname()}`;
    }
}
/** Normalize a primary gateway URL into a ws:// or wss:// origin. */
function toWsUrl(input, token) {
    let url = input.trim();
    if (/^https:\/\//i.test(url))
        url = url.replace(/^https:\/\//i, "wss://");
    else if (/^http:\/\//i.test(url))
        url = url.replace(/^http:\/\//i, "ws://");
    else if (!/^wss?:\/\//i.test(url))
        url = `ws://${url}`;
    // Strip any trailing slash, then add the token query if provided.
    url = url.replace(/\/+$/, "");
    if (token)
        url += `?token=${encodeURIComponent(token)}`;
    return url;
}
export class PrimaryLink {
    opts;
    ws = null;
    stopped = false;
    reconnectDelay = 1000;
    maxReconnectDelay = 30_000;
    reconnectTimer = null;
    terminalSessions = new Map();
    nodeId = getNodeId();
    constructor(opts) {
        this.opts = opts;
    }
    start() {
        if (this.stopped)
            return;
        this.connect();
    }
    stop() {
        this.stopped = true;
        if (this.reconnectTimer)
            clearTimeout(this.reconnectTimer);
        for (const terminal of this.terminalSessions.values()) {
            terminal.stop({ reason: "primary link stopped" }).catch(() => { });
        }
        this.terminalSessions.clear();
        this.ws?.close();
        this.ws = null;
    }
    connect() {
        const url = toWsUrl(this.opts.primaryGateway, this.opts.primaryToken);
        const display = url.replace(/token=[^&]+/, "token=***");
        console.log(`[primary-link] connecting to primary gateway ${display} as node ${this.nodeId}`);
        let ws;
        try {
            ws = new WebSocket(url);
        }
        catch (err) {
            console.error(`[primary-link] failed to open WS: ${err instanceof Error ? err.message : String(err)}`);
            this.scheduleReconnect();
            return;
        }
        this.ws = ws;
        ws.on("open", () => {
            this.reconnectDelay = 1000;
            console.log(`[primary-link] connected — registering fs node`);
            const nodeName = this.opts.nodeName?.trim() || hostname();
            const platform = detectPlatform();
            const providers = this.opts.providers ?? [];
            this.send({
                type: "node.hello",
                payload: {
                    id: this.nodeId,
                    name: nodeName,
                    platform,
                    role: "remote",
                    protocolVersion: NODE_PROTOCOL_VERSION,
                    capabilities: {
                        providers,
                        surfaces: ["filesystem", "terminal"],
                        tools: [...NODE_TOOLS],
                        screenShare: false,
                        voice: false,
                        preview: false,
                    },
                },
            });
            this.send({
                type: "fs.register-node",
                payload: {
                    id: this.nodeId,
                    name: nodeName,
                    platform,
                    providers,
                },
            });
        });
        ws.on("message", (raw) => {
            let msg;
            try {
                msg = JSON.parse(raw.toString());
            }
            catch {
                return;
            }
            void this.handleMessage(msg);
        });
        ws.on("close", () => {
            if (this.stopped)
                return;
            console.log(`[primary-link] disconnected from primary — will reconnect`);
            this.scheduleReconnect();
        });
        ws.on("error", (err) => {
            console.error(`[primary-link] socket error: ${err instanceof Error ? err.message : String(err)}`);
            // 'close' will follow and trigger reconnect.
        });
    }
    scheduleReconnect() {
        if (this.stopped)
            return;
        if (this.reconnectTimer)
            clearTimeout(this.reconnectTimer);
        const delay = this.reconnectDelay;
        this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
        this.reconnectTimer = setTimeout(() => this.connect(), delay);
    }
    send(msg) {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify(msg));
        }
    }
    async handleMessage(msg) {
        const payload = (msg.payload ?? {});
        switch (msg.type) {
            case "fs.browse-request": {
                const requestId = String(payload["requestId"] ?? "");
                try {
                    const result = await this.browse(String(payload["path"] ?? homedir()));
                    this.send({ type: "fs.browse-response", payload: { requestId, ...result } });
                }
                catch (err) {
                    this.send({ type: "fs.browse-response", payload: { requestId, error: errMsg(err) } });
                }
                break;
            }
            case "fs.roots-request": {
                const requestId = String(payload["requestId"] ?? "");
                try {
                    this.send({ type: "fs.roots-response", payload: { requestId, roots: this.roots() } });
                }
                catch (err) {
                    this.send({ type: "fs.roots-response", payload: { requestId, error: errMsg(err) } });
                }
                break;
            }
            case "fs.op-request": {
                const requestId = String(payload["requestId"] ?? "");
                const { requestId: _omit, op, ...params } = payload;
                void _omit;
                try {
                    const result = await this.fsOp(String(op ?? ""), params);
                    this.send({ type: "fs.op-response", payload: { requestId, result } });
                }
                catch (err) {
                    this.send({ type: "fs.op-response", payload: { requestId, error: errMsg(err) } });
                }
                break;
            }
            case "tool.op-request": {
                const requestId = String(payload["requestId"] ?? "");
                const tool = String(payload["tool"] ?? "");
                const args = (payload["args"] ?? {});
                const projectRoot = typeof payload["projectRoot"] === "string"
                    ? payload["projectRoot"]
                    : undefined;
                try {
                    const result = await this.toolOp(tool, args, projectRoot);
                    this.send({ type: "tool.op-response", payload: { requestId, result } });
                }
                catch (err) {
                    this.send({ type: "tool.op-response", payload: { requestId, error: errMsg(err) } });
                }
                break;
            }
            case "terminal.op-request": {
                const requestId = typeof payload["requestId"] === "string" ? payload["requestId"] : "";
                const op = String(payload["op"] ?? "");
                try {
                    const result = await this.terminalOp(op, payload);
                    if (requestId)
                        this.send({ type: "terminal.op-response", payload: { requestId, result } });
                }
                catch (err) {
                    if (requestId)
                        this.send({ type: "terminal.op-response", payload: { requestId, error: errMsg(err) } });
                }
                break;
            }
            default:
                // Provider requests are not served here; the headless node exposes
                // filesystem and terminal/tool execution to the primary gateway.
                break;
        }
    }
    async terminalOp(op, params) {
        const terminalId = String(params["terminalId"] ?? "");
        if (!terminalId)
            throw new Error("Missing terminalId");
        switch (op) {
            case "start": {
                const existing = this.terminalSessions.get(terminalId);
                if (existing) {
                    const snapshot = existing.snapshot();
                    return { ok: true, pid: snapshot.metadata.pid ?? null, shell: snapshot.metadata.shell ?? null };
                }
                const projectRoot = resolve(String(params["projectRoot"] ?? process.cwd()));
                const sessionId = String(params["sessionId"] ?? "default");
                const shell = typeof params["shell"] === "string" ? params["shell"] : undefined;
                const cols = typeof params["cols"] === "number" ? params["cols"] : 120;
                const rows = typeof params["rows"] === "number" ? params["rows"] : 30;
                const terminal = new TerminalSurface(terminalId, { ...(shell ? { shell } : {}), cols, rows });
                terminal.onOutput = (data) => this.send({ type: "terminal.output", payload: { terminalId, data } });
                terminal.onExit = (exitCode, signal) => {
                    this.terminalSessions.delete(terminalId);
                    this.send({ type: "terminal.exit", payload: { terminalId, exitCode, signal: signal ?? null } });
                };
                this.terminalSessions.set(terminalId, terminal);
                try {
                    await terminal.start({ sessionId, projectRoot });
                }
                catch (err) {
                    this.terminalSessions.delete(terminalId);
                    throw err;
                }
                const snapshot = terminal.snapshot();
                return { ok: true, pid: snapshot.metadata.pid ?? null, shell: snapshot.metadata.shell ?? null };
            }
            case "input": {
                const terminal = this.terminalSessions.get(terminalId);
                if (!terminal)
                    return { ok: false, message: "Terminal is not running" };
                terminal.write(String(params["data"] ?? ""));
                return { ok: true };
            }
            case "resize": {
                const terminal = this.terminalSessions.get(terminalId);
                if (!terminal)
                    return { ok: false, message: "Terminal is not running" };
                const cols = typeof params["cols"] === "number" ? params["cols"] : 120;
                const rows = typeof params["rows"] === "number" ? params["rows"] : 30;
                terminal.resize(cols, rows);
                return { ok: true };
            }
            case "stop": {
                const terminal = this.terminalSessions.get(terminalId);
                if (terminal) {
                    this.terminalSessions.delete(terminalId);
                    await terminal.stop({ reason: "remote terminal stopped" });
                }
                return { ok: true };
            }
            default:
                throw new Error(`Unsupported terminal op: ${op}`);
        }
    }
    // ── Filesystem operations (local disk) ────────────────────────────
    roots() {
        const home = homedir();
        const roots = [];
        if (process.platform === "win32") {
            // Minimal: list C:\; a full drive scan isn't needed for Linux nodes.
            roots.push({ name: "C:", path: "C:\\", type: "dir" });
        }
        else {
            roots.push({ name: "/", path: "/", type: "dir" });
        }
        roots.push({ name: "Home", path: home, type: "dir" });
        return roots;
    }
    async browse(dirPath) {
        const target = dirPath === "~" ? homedir() : (dirPath || homedir());
        const resolved = resolve(target);
        const info = await fsStat(resolved);
        if (!info.isDirectory())
            throw new Error("Path is not a directory");
        const raw = await readdir(resolved, { withFileTypes: true });
        const entries = [];
        for (const d of raw) {
            if (d.name.startsWith(".") || HIDDEN.has(d.name))
                continue;
            if (d.isDirectory())
                entries.push({ name: d.name, path: join(resolved, d.name), type: "dir" });
            else if (d.isFile())
                entries.push({ name: d.name, path: join(resolved, d.name), type: "file" });
        }
        entries.sort((a, b) => (a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name)));
        return {
            path: resolved,
            parent: dirname(resolved) !== resolved ? dirname(resolved) : null,
            entries,
        };
    }
    async fsOp(op, params) {
        switch (op) {
            case "stat": {
                const info = await fsStat(resolve(params["path"]));
                return { size: info.size, isDirectory: info.isDirectory(), modified: info.mtime.toISOString() };
            }
            case "read":
            case "git-file-read": {
                const content = await readFile(resolve(params["path"]), "utf-8");
                return { content, size: content.length };
            }
            case "write": {
                const filePath = resolve(params["path"]);
                const content = String(params["content"] ?? "");
                await mkdir(dirname(filePath), { recursive: true });
                await writeFile(filePath, content, "utf-8");
                return { ok: true, size: content.length };
            }
            case "patch": {
                const filePath = resolve(params["path"]);
                const search = String(params["search"] ?? "");
                const replace = String(params["replace"] ?? "");
                if (!search)
                    throw new Error("Missing search text");
                const content = await readFile(filePath, "utf-8");
                const index = content.indexOf(search);
                if (index === -1)
                    return { matched: false };
                const next = content.slice(0, index) + replace + content.slice(index + search.length);
                await writeFile(filePath, next, "utf-8");
                return { matched: true };
            }
            case "list": {
                const entries = await readdir(resolve(params["path"]), { withFileTypes: true });
                return entries.map((e) => (e.isDirectory() ? e.name + "/" : e.name));
            }
            case "exists": {
                try {
                    await access(resolve(params["path"]));
                    return true;
                }
                catch {
                    return false;
                }
            }
            case "mkdir": {
                await mkdir(resolve(params["path"]), { recursive: true });
                return { ok: true };
            }
            case "readdir": {
                const dirPath = resolve(params["path"]);
                const raw = await readdir(dirPath, { withFileTypes: true });
                return raw.map((d) => ({ name: d.name, path: join(dirPath, d.name), type: d.isDirectory() ? "dir" : "file" }));
            }
            case "search-project": {
                return this.searchProject(resolve(params["path"]), String(params["query"] ?? ""), params["mode"] === "content" ? "content" : "files", Math.min(Math.max(Number(params["limit"]) || 50, 1), 200));
            }
            case "git": {
                const cwd = resolve(params["cwd"]);
                const args = params["args"];
                if (typeof args !== "string" || !args)
                    throw new Error("Missing git args");
                const { stdout, stderr } = await execAsync(`git ${args}`, {
                    cwd, timeout: 60_000, maxBuffer: 10 * 1024 * 1024,
                    env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
                });
                return { stdout, stderr };
            }
            case "gh": {
                const cwd = resolve(params["cwd"]);
                const args = params["args"];
                if (typeof args !== "string" || !args)
                    throw new Error("Missing gh args");
                const { GH_TOKEN: _g, GITHUB_TOKEN: _gh, ...cleanEnv } = process.env;
                void _g;
                void _gh;
                const { stdout, stderr } = await execAsync(`gh ${args}`, {
                    cwd, timeout: 60_000, maxBuffer: 10 * 1024 * 1024, env: cleanEnv,
                });
                return { stdout, stderr };
            }
            default:
                throw new Error(`Unsupported fs op: ${op}`);
        }
    }
    async toolOp(tool, args, projectRoot) {
        switch (tool) {
            case "terminal.run":
            case "jait.terminal":
            case "execute":
                return this.runCommand(args, projectRoot);
            // Aliases: the simplified core tool names map to the same handlers as
            // their canonical dotted counterparts (see REMOTE_EXECUTABLE_TOOLS).
            case "read":
            case "file.read":
                return { ok: true, message: "File read", data: await this.fsOp("read", args) };
            case "edit":
            case "file.write":
                return { ok: true, message: "File written", data: await this.fsOp("write", args) };
            case "file.patch": {
                const result = await this.fsOp("patch", args);
                return result.matched
                    ? { ok: true, message: "File patched", data: result }
                    : { ok: false, message: `Search string not found in ${String(args["path"] ?? "file")}` };
            }
            case "file.list":
                return { ok: true, message: "Directory listed", data: await this.fsOp("readdir", args) };
            case "file.stat":
                return { ok: true, message: "File stat", data: await this.fsOp("stat", args) };
            case "image.view": {
                // Read a file (typically an image) and return base64 for the gateway.
                const filePath = resolve(String(args["path"] ?? ""));
                const content = await readFile(filePath);
                return {
                    ok: true,
                    message: `Read ${content.length} bytes from ${filePath}`,
                    data: { path: filePath, base64: content.toString("base64"), size: content.length },
                };
            }
            case "search":
            case "file.search":
                return {
                    ok: true,
                    message: "Search completed",
                    data: await this.searchProject(resolve(String(args["path"] ?? projectRoot ?? homedir())), String(args["query"] ?? args["pattern"] ?? ""), args["mode"] === "content" ? "content" : "files", Math.min(Math.max(Number(args["limit"]) || 50, 1), 200)),
                };
            case "os.query": {
                const os = await import("node:os");
                return {
                    ok: true,
                    message: "OS query",
                    data: {
                        platform: os.platform(),
                        arch: os.arch(),
                        hostname: os.hostname(),
                        cpus: os.cpus().length,
                        totalMemory: os.totalmem(),
                        freeMemory: os.freemem(),
                        uptime: os.uptime(),
                        homedir: os.homedir(),
                        tmpdir: os.tmpdir(),
                    },
                };
            }
            default:
                throw new Error(`Unsupported tool on node: ${tool}`);
        }
    }
    async runCommand(args, projectRoot) {
        const command = String(args["command"] ?? "");
        if (!command.trim())
            throw new Error("Missing command");
        const timeout = Math.max(Number(args["timeout"]) || 30_000, 0);
        const cwd = resolve(String(args["cwd"] ?? args["projectRoot"] ?? projectRoot ?? process.cwd()));
        if (args["isBackground"] === true) {
            exec(command, {
                cwd,
                env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
            });
            return {
                ok: true,
                message: "Background command started on node",
                data: { output: "(background — not waiting for output)", exitCode: null, timedOut: false },
            };
        }
        try {
            const { stdout, stderr } = await execAsync(command, {
                cwd,
                timeout: timeout || undefined,
                maxBuffer: 10 * 1024 * 1024,
                env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
            });
            const output = [stdout, stderr].filter(Boolean).join(stderr ? "\n" : "").trim() || "(no output)";
            return {
                ok: true,
                message: "Command completed (exit code 0)",
                data: { output, exitCode: 0, timedOut: false },
            };
        }
        catch (err) {
            const failure = err;
            const output = [failure.stdout, failure.stderr].filter(Boolean).join(failure.stderr ? "\n" : "").trim() || errMsg(err);
            return {
                ok: false,
                message: failure.killed
                    ? `Command timed out after ${timeout}ms`
                    : `Command failed (exit code ${failure.code ?? "unknown"})`,
                data: { output, exitCode: failure.code ?? null, timedOut: Boolean(failure.killed) },
            };
        }
    }
    async searchProject(projectRoot, query, mode, maxResults) {
        const safeDir = projectRoot.replace(/"/g, '\\"');
        const safeQuery = query.replace(/"/g, '\\"');
        try {
            if (mode === "content") {
                const cmd = `rg --no-heading --line-number --max-count ${maxResults} --ignore-case --fixed-strings -- "${safeQuery}" "${safeDir}" 2>/dev/null`
                    + ` || grep -rn -i -F --max-count=${maxResults} -- "${safeQuery}" "${safeDir}" 2>/dev/null`;
                const { stdout } = await execAsync(cmd, { timeout: 15_000, maxBuffer: 2 * 1024 * 1024 });
                const matches = stdout.trim().split("\n").filter(Boolean).slice(0, maxResults).map((line) => {
                    const m = line.match(/^(.+?):(\d+):(.*)$/);
                    if (!m)
                        return null;
                    return { file: relative(projectRoot, m[1]).replace(/\\/g, "/"), line: parseInt(m[2], 10), content: m[3].trim() };
                }).filter(Boolean);
                return { query, mode, matches };
            }
            const cleaned = query.replace(/[*?[\]]/g, "").trim();
            if (!cleaned)
                return { query, mode, files: [] };
            const safeFileQuery = cleaned.replace(/"/g, '\\"');
            const cmd = `rg --files "${safeDir}" 2>/dev/null | grep -iF -- "${safeFileQuery}" | head -n ${maxResults}`;
            const { stdout } = await execAsync(cmd, { timeout: 15_000, maxBuffer: 2 * 1024 * 1024 });
            const files = stdout.trim().split("\n").filter(Boolean).slice(0, maxResults).map((absPath) => {
                const relPath = relative(projectRoot, absPath.trim()).replace(/\\/g, "/");
                return { path: relPath, name: relPath.split("/").pop() || relPath };
            });
            return { query, mode, files };
        }
        catch (err) {
            const stderr = err?.stderr || "";
            if (stderr && !stderr.includes("No such file"))
                throw new Error(stderr.slice(0, 200));
            return mode === "content" ? { query, mode, matches: [] } : { query, mode, files: [] };
        }
    }
}
function errMsg(err) {
    return err instanceof Error ? err.message : String(err);
}
//# sourceMappingURL=primary-link.js.map