/**
 * Codex CLI Provider — wraps OpenAI Codex CLI as a Jait provider.
 *
 * Spawns `codex app-server` as a child process and communicates
 * via JSON-RPC 2.0 over NDJSON on stdin/stdout.
 *
 * Protocol (codex-cli ≥ 0.111.0):
 *   1. spawn `codex app-server` with piped stdio
 *   2. send `initialize` request → get response
 *   3. send `initialized` notification (no response)
 *   4. send `thread/start` request → get threadId
 *   5. send `turn/start` request per user message
 *   6. listen for notifications: item/agentMessage/delta, turn/completed, etc.
 */
import { spawn, spawnSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import readline from "node:readline";
import { uuidv7 } from "../db/uuidv7.js";
import { mapCodexNotification } from "./codex-event-mapper.js";
// ── Provider implementation ──────────────────────────────────────────
export class CodexProvider {
    id = "codex";
    info = {
        id: "codex",
        name: "OpenAI Codex",
        description: "OpenAI Codex CLI agent with sandboxed execution and MCP support",
        available: false,
        modes: ["full-access", "supervised"],
    };
    sessions = new Map();
    emitter = new EventEmitter();
    codexPath = null;
    async checkAvailability() {
        try {
            // ── Step 1: Check if binary is installed ──
            const paths = ["codex", "npx codex"];
            let found = false;
            for (const cmd of paths) {
                const available = await this.testCommand(cmd);
                if (available) {
                    this.codexPath = cmd;
                    found = true;
                    break;
                }
            }
            if (!found) {
                this.info.available = false;
                this.info.unavailableReason = "Codex CLI not installed. Install with: npm install -g @openai/codex";
                return false;
            }
            // ── Step 2: Check if authenticated ──
            const hasApiKey = !!process.env.OPENAI_API_KEY;
            const hasOAuthTokens = this.checkCodexAuthFile();
            if (!hasApiKey && !hasOAuthTokens) {
                this.info.available = false;
                this.info.unavailableReason = "Codex not authenticated. Run: codex login";
                return false;
            }
            this.info.available = true;
            this.info.unavailableReason = undefined;
            return true;
        }
        catch {
            this.info.available = false;
            this.info.unavailableReason = "Failed to check Codex CLI availability";
            return false;
        }
    }
    /**
     * Check if ~/.codex/auth.json (or CODEX_HOME/auth.json) contains OAuth tokens.
     */
    checkCodexAuthFile() {
        try {
            const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
            const authPath = join(codexHome, "auth.json");
            if (!existsSync(authPath))
                return false;
            const raw = readFileSync(authPath, "utf-8");
            const auth = JSON.parse(raw);
            // Has an API key stored in auth.json, or has OAuth tokens
            if (auth.OPENAI_API_KEY)
                return true;
            if (auth.tokens?.access_token)
                return true;
            return false;
        }
        catch {
            return false;
        }
    }
    /**
     * List available models by spawning a short-lived `codex app-server`,
     * performing the initialize handshake, then calling `model/list`.
     */
    async listModels() {
        const spawnSpec = parseCommand(this.codexPath ?? "codex");
        const child = spawn(spawnSpec.command, [...spawnSpec.args, "app-server"], {
            cwd: process.cwd(),
            env: process.env,
            stdio: ["pipe", "pipe", "pipe"],
        });
        const rl = readline.createInterface({ input: child.stdout });
        const pending = new Map();
        let nextId = 1;
        const writeMsg = (msg) => {
            if (child.stdin?.writable)
                child.stdin.write(JSON.stringify(msg) + "\n");
        };
        const sendReq = (method, params, timeoutMs = 15_000) => {
            const id = nextId++;
            return new Promise((resolve, reject) => {
                const timeout = setTimeout(() => {
                    pending.delete(String(id));
                    reject(new Error(`Timed out waiting for ${method}`));
                }, timeoutMs);
                pending.set(String(id), { method, timeout, resolve, reject });
                writeMsg({ method, id, params });
            });
        };
        // Listen for JSON-RPC responses
        rl.on("line", (line) => {
            try {
                const msg = JSON.parse(line);
                if (msg.id != null) {
                    const p = pending.get(String(msg.id));
                    if (p) {
                        pending.delete(String(msg.id));
                        clearTimeout(p.timeout);
                        if (msg.error) {
                            p.reject(new Error(msg.error.message ?? "RPC error"));
                        }
                        else {
                            p.resolve(msg.result);
                        }
                    }
                }
            }
            catch { /* ignore non-JSON lines */ }
        });
        try {
            // Handshake
            await sendReq("initialize", {
                clientInfo: { name: "jait", title: "Jait Gateway", version: "1.0.0" },
                capabilities: { experimentalApi: true },
            });
            writeMsg({ method: "initialized" });
            // Fetch model list — response shape: { data: [{id, model, displayName, description, isDefault, ...}], nextCursor }
            const result = await sendReq("model/list", {});
            const models = [];
            const items = result?.data ?? result?.models;
            if (Array.isArray(items)) {
                for (const m of items) {
                    const id = String(m.id ?? m.model ?? m.name ?? "");
                    const name = String(m.displayName ?? m.name ?? m.id ?? "");
                    if (!id)
                        continue;
                    models.push({
                        id,
                        name,
                        ...(m.description ? { description: String(m.description) } : {}),
                        ...(m.isDefault ? { isDefault: true } : {}),
                    });
                }
            }
            return models;
        }
        catch (err) {
            console.error("[codex] model/list failed:", err);
            return [];
        }
        finally {
            // Clean up
            for (const p of pending.values()) {
                clearTimeout(p.timeout);
                p.reject(new Error("Session closed"));
            }
            pending.clear();
            rl.close();
            child.kill("SIGTERM");
        }
    }
    async startSession(options) {
        const sessionId = uuidv7();
        const spawnSpec = parseCommand(this.codexPath ?? "codex");
        const child = spawn(spawnSpec.command, [...spawnSpec.args, "app-server", ...buildCodexMcpConfigArgs(options.mcpServers)], {
            cwd: options.workingDirectory,
            env: {
                ...process.env,
                ...options.env,
            },
            stdio: ["pipe", "pipe", "pipe"],
        });
        const rl = readline.createInterface({ input: child.stdout });
        // Collect stderr for diagnostics if startup fails
        const stderrChunks = [];
        child.stderr?.on("data", (data) => {
            stderrChunks.push(data.toString());
        });
        const session = {
            id: sessionId,
            providerId: "codex",
            threadId: options.threadId,
            status: "starting",
            runtimeMode: options.mode,
            startedAt: new Date().toISOString(),
        };
        const state = {
            session,
            child,
            rl,
            pending: new Map(),
            nextId: 1,
            providerThreadId: null,
            stopping: false,
        };
        this.sessions.set(sessionId, state);
        this.attachListeners(state);
        try {
            // ── Step 1: initialize handshake (45s — codex can be slow to start) ──
            await this.sendRequest(state, "initialize", {
                clientInfo: { name: "jait", title: "Jait Gateway", version: "1.0.0" },
                capabilities: { experimentalApi: true },
            }, 45_000);
            // ── Step 2: initialized notification (no response expected) ──
            this.writeMessage(state, { method: "initialized" });
            // ── Step 3: thread/start ──
            const { approvalPolicy, sandbox } = mapRuntimeMode(options.mode);
            const threadResponse = await this.sendRequest(state, "thread/start", {
                model: options.model ?? null,
                cwd: options.workingDirectory,
                approvalPolicy,
                sandbox,
                experimentalRawEvents: false,
            });
            const providerThreadId = threadResponse?.thread?.id ?? threadResponse?.threadId;
            if (!providerThreadId) {
                throw new Error("thread/start response did not include a thread id");
            }
            state.providerThreadId = providerThreadId;
            state.session.status = "running";
            this.emit({ type: "session.started", sessionId });
            return session;
        }
        catch (error) {
            state.session.status = "error";
            const baseMsg = error instanceof Error ? error.message : "Failed to start Codex session";
            const stderr = stderrChunks.join("").trim();
            state.session.error = stderr
                ? `${baseMsg}\n--- stderr ---\n${stderr.slice(0, 2000)}`
                : baseMsg;
            console.error(`[codex:${sessionId}] startSession failed: ${state.session.error}`);
            this.emit({ type: "session.error", sessionId, error: state.session.error });
            this.stopSession(sessionId);
            throw error;
        }
    }
    async sendTurn(sessionId, message, _attachments) {
        const state = this.getState(sessionId);
        if (!state.providerThreadId) {
            throw new Error("Session has no active thread");
        }
        await this.sendRequest(state, "turn/start", {
            threadId: state.providerThreadId,
            input: [{ type: "text", text: message, text_elements: [] }],
        });
    }
    async interruptTurn(sessionId) {
        const state = this.sessions.get(sessionId);
        if (!state?.providerThreadId)
            return;
        try {
            await this.sendRequest(state, "turn/interrupt", {
                threadId: state.providerThreadId,
            });
        }
        catch {
            // best effort
        }
    }
    async respondToApproval(sessionId, requestId, approved) {
        const state = this.getState(sessionId);
        // Respond to the server request with a JSON-RPC response using the original request id
        this.writeMessage(state, {
            id: requestId,
            result: { decision: approved ? "approve" : "deny" },
        });
    }
    async stopSession(sessionId) {
        const state = this.sessions.get(sessionId);
        if (!state)
            return;
        state.stopping = true;
        // Clear pending requests
        for (const [, pending] of state.pending) {
            clearTimeout(pending.timeout);
            pending.reject(new Error("Session stopped"));
        }
        state.pending.clear();
        // Close readline interface
        state.rl.close();
        // Kill the process tree
        if (!state.child.killed) {
            killChildTree(state.child);
        }
        this.sessions.delete(sessionId);
        // Emit session.completed so the onEvent handler in /start can unsubscribe
        this.emit({ type: "session.completed", sessionId });
    }
    onEvent(handler) {
        this.emitter.on("event", handler);
        return () => this.emitter.off("event", handler);
    }
    // ── Private helpers ────────────────────────────────────────────────
    emit(event) {
        this.emitter.emit("event", event);
    }
    getState(sessionId) {
        const state = this.sessions.get(sessionId);
        if (!state)
            throw new Error(`No Codex session found: ${sessionId}`);
        return state;
    }
    attachListeners(state) {
        state.rl.on("line", (line) => {
            this.handleStdoutLine(state, line);
        });
        state.child.stderr?.on("data", (data) => {
            const text = data.toString().trim();
            if (text) {
                // Filter out noisy info/debug/trace log lines from codex
                const isLogLine = /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN)\s+/.test(text);
                if (!isLogLine) {
                    console.error(`[codex:${state.session.id}] stderr: ${text}`);
                }
            }
        });
        state.child.on("error", (err) => {
            state.session.status = "error";
            state.session.error = err.message;
            this.emit({ type: "session.error", sessionId: state.session.id, error: err.message });
        });
        state.child.on("exit", (code, signal) => {
            if (state.stopping)
                return;
            const message = `codex app-server exited (code=${code}, signal=${signal})`;
            state.session.status = code === 0 ? "completed" : "error";
            state.session.completedAt = new Date().toISOString();
            if (code !== 0) {
                state.session.error = message;
            }
            this.emit({ type: "session.completed", sessionId: state.session.id });
            // Reject pending requests
            for (const [, pending] of state.pending) {
                clearTimeout(pending.timeout);
                pending.reject(new Error("Codex process exited"));
            }
            state.pending.clear();
            this.sessions.delete(state.session.id);
        });
    }
    handleStdoutLine(state, line) {
        let parsed;
        try {
            parsed = JSON.parse(line);
        }
        catch {
            return;
        }
        if (!parsed || typeof parsed !== "object")
            return;
        if (this.isResponse(parsed)) {
            this.handleResponse(state, parsed);
        }
        else if (this.isServerRequest(parsed)) {
            this.handleServerRequest(state, parsed);
        }
        else if (this.isServerNotification(parsed)) {
            this.handleServerNotification(state, parsed);
        }
    }
    /** Response: has id, no method */
    isResponse(value) {
        const obj = value;
        const hasId = typeof obj.id === "string" || typeof obj.id === "number";
        const hasMethod = typeof obj.method === "string";
        return hasId && !hasMethod;
    }
    /** Server request: has both method and id */
    isServerRequest(value) {
        const obj = value;
        return (typeof obj.method === "string" &&
            (typeof obj.id === "string" || typeof obj.id === "number"));
    }
    /** Server notification: has method, no id */
    isServerNotification(value) {
        const obj = value;
        return typeof obj.method === "string" && !("id" in obj);
    }
    handleResponse(state, response) {
        const key = String(response.id);
        const pending = state.pending.get(key);
        if (!pending)
            return;
        clearTimeout(pending.timeout);
        state.pending.delete(key);
        if (response.error?.message) {
            pending.reject(new Error(`${pending.method} failed: ${response.error.message}`));
        }
        else {
            pending.resolve(response.result);
        }
    }
    handleServerRequest(state, request) {
        const params = (request.params ?? {});
        // Approval requests from codex
        if (request.method === "item/commandExecution/requestApproval" ||
            request.method === "item/fileChange/requestApproval" ||
            request.method === "item/fileRead/requestApproval") {
            const tool = String(params.command ?? params.tool ?? request.method);
            this.emit({
                type: "tool.approval-required",
                sessionId: state.session.id,
                tool,
                args: params,
                requestId: String(request.id),
            });
            return;
        }
        // Unknown server request — respond with error
        this.writeMessage(state, {
            id: request.id,
            error: { code: -32601, message: `Unsupported server request: ${request.method}` },
        });
    }
    handleServerNotification(state, notification) {
        const params = (notification.params ?? {});
        const sessionId = state.session.id;
        // Local state mutations that the shared mapper can't handle
        if (notification.method === "turn/started") {
            state.session.status = "running";
        }
        if (notification.method === "turn/completed") {
            state.session.status = "idle";
        }
        const events = mapCodexNotification(notification.method, params, sessionId);
        for (const evt of events) {
            this.emit(evt);
        }
    }
    sendRequest(state, method, params, timeoutMs = 20_000) {
        const id = state.nextId++;
        return new Promise((resolve, reject) => {
            const timeout = setTimeout(() => {
                state.pending.delete(String(id));
                reject(new Error(`Timed out waiting for ${method}`));
            }, timeoutMs);
            state.pending.set(String(id), { method, timeout, resolve, reject });
            this.writeMessage(state, { method, id, params });
        });
    }
    writeMessage(state, message) {
        if (!state.child.stdin?.writable) {
            throw new Error("Cannot write to codex app-server stdin");
        }
        state.child.stdin.write(JSON.stringify(message) + "\n");
    }
    testCommand(cmd) {
        return new Promise((resolve) => {
            const spawnSpec = parseCommand(cmd);
            if (!spawnSpec.command) {
                resolve(false);
                return;
            }
            const child = spawn(spawnSpec.command, [...spawnSpec.args, "--version"], {
                stdio: "pipe",
            });
            const timer = setTimeout(() => {
                child.kill();
                resolve(false);
            }, 5000);
            child.on("exit", (code) => {
                clearTimeout(timer);
                resolve(code === 0);
            });
            child.on("error", () => {
                clearTimeout(timer);
                resolve(false);
            });
        });
    }
}
// ── Helpers ──────────────────────────────────────────────────────────
function mapRuntimeMode(mode) {
    if (mode === "supervised") {
        return { approvalPolicy: "on-request", sandbox: "workspace-write" };
    }
    return { approvalPolicy: "never", sandbox: "danger-full-access" };
}
export function buildCodexMcpConfigArgs(servers) {
    if (!servers?.length)
        return [];
    const args = [];
    for (const server of servers) {
        const prefix = `mcp_servers.${server.name}`;
        if (server.transport === "sse" && server.url) {
            args.push("-c", `${prefix}.url=${toTomlString(server.url)}`);
            continue;
        }
        if (server.transport === "stdio" && server.command) {
            args.push("-c", `${prefix}.command=${toTomlString(server.command)}`);
            args.push("-c", `${prefix}.args=${toTomlArray(server.args ?? [])}`);
            for (const [key, value] of Object.entries(server.env ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
                args.push("-c", `${prefix}.env.${key}=${toTomlString(value)}`);
            }
        }
    }
    return args;
}
function toTomlString(value) {
    return JSON.stringify(value);
}
function toTomlArray(values) {
    return `[${values.map((value) => toTomlString(value)).join(", ")}]`;
}
function parseCommand(commandLine) {
    const parts = commandLine.trim().split(/\s+/).filter(Boolean);
    return {
        command: parts[0] ?? "",
        args: parts.slice(1),
    };
}
/**
 * On Windows, use `taskkill /T` to kill the entire process tree.
 */
function killChildTree(child) {
    if (process.platform === "win32" && child.pid !== undefined) {
        try {
            spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
            return;
        }
        catch {
            // fallback
        }
    }
    child.kill();
}
//# sourceMappingURL=codex-provider.js.map