/**
 * 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";
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",
]);
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;
    nodeId = getNodeId();
    constructor(opts) {
        this.opts = opts;
    }
    start() {
        if (this.stopped)
            return;
        this.connect();
    }
    stop() {
        this.stopped = true;
        if (this.reconnectTimer)
            clearTimeout(this.reconnectTimer);
        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`);
            this.send({
                type: "fs.register-node",
                payload: {
                    id: this.nodeId,
                    name: this.opts.nodeName?.trim() || `Gateway (${hostname()})`,
                    platform: detectPlatform(),
                    providers: this.opts.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;
            }
            default:
                // Other request types (tool.op-request, provider.op-request) are not
                // served yet — those would let the primary run terminals/agents on
                // this node (a future phase).
                break;
        }
    }
    // ── 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 "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 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