import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createInterface } from "node:readline";
import chalk from "chalk";
import { LOBU_CONFIG_DIR } from "../internal/context.js";
import { agentApiBase, apiBaseFromContextUrl, getActiveOrg, getAgentApiToken, getCurrentContextName, resolveContext, resolveGatewayUrl, } from "../internal/index.js";
import { renderMarkdown } from "../utils/markdown.js";
import { loadProjectConfig } from "./_lib/apply/desired-state.js";
import { getPlatformDefinition } from "./platforms/registry.js";
const THREADS_FILE = join(LOBU_CONFIG_DIR, "threads.json");
async function getLastThread(context, agent) {
    try {
        const raw = await readFile(THREADS_FILE, "utf-8");
        const parsed = JSON.parse(raw);
        return parsed[`${context}|${agent}`];
    }
    catch {
        return undefined;
    }
}
async function setLastThread(context, agent, threadId) {
    let store = {};
    try {
        const raw = await readFile(THREADS_FILE, "utf-8");
        store = JSON.parse(raw);
    }
    catch {
        // first-write or corrupt; reset
    }
    store[`${context}|${agent}`] = threadId;
    await mkdir(LOBU_CONFIG_DIR, { recursive: true });
    await writeFile(THREADS_FILE, JSON.stringify(store, null, 2), {
        mode: 0o600,
    });
}
/**
 * Resolve which org the chat invocation targets. Precedence mirrors the other
 * org-scoped commands (`lobu memory run`, `lobu call`): explicit `--org` >
 * `LOBU_ORG` env > the context's stored `activeOrg`. `getActiveOrg` already
 * folds in the env var and context lookup, so an explicit flag is the only
 * thing layered on top.
 *
 * Returns `undefined` when nothing is configured — the server then falls back
 * to the PAT-bound org / the user's default membership, preserving the
 * pre-flag behavior for callers that never pass `--org`.
 */
export async function resolveChatOrg(options) {
    if (options.org?.trim())
        return options.org.trim();
    return getActiveOrg(options.context);
}
/**
 * Build the Authorization header plus an optional `x-lobu-org` override the
 * embedded gateway honors per-request (membership-checked server-side). The
 * header is omitted entirely when no org is resolved, so unflagged invocations
 * are byte-for-byte identical to the previous behavior.
 */
function agentApiHeaders(authToken, org, extra) {
    const headers = {
        Authorization: `Bearer ${authToken}`,
        ...extra,
    };
    if (org)
        headers["x-lobu-org"] = org;
    return headers;
}
/**
 * `lobu chat <prompt>` — send a prompt to an agent and stream the response.
 *
 * With --user platform:id, routes through Telegram/Slack.
 * With --continue, resumes the last thread for (context, agent).
 */
export async function chatCommand(cwd, prompt, options) {
    let gatewayUrl;
    if (options.gateway) {
        gatewayUrl = options.gateway;
    }
    else {
        // Prefer an explicit `--context` over the active context, but fall back
        // to the active context before defaulting to a local `lobu run`. Without
        // this, `lobu chat` against a remote-context-set CLI silently sent every
        // request to localhost:8787 — failing with `fetch failed` if no local
        // gateway was running, or worse, hitting the wrong instance.
        const ctx = await resolveContext(options.context).catch(() => null);
        gatewayUrl = ctx
            ? apiBaseFromContextUrl(ctx.url)
            : await resolveGatewayUrl({ cwd });
    }
    // The Agent API lives under `<origin>/lobu` on every Lobu deployment; the
    // context apiUrl and `.env` PORT only give the origin.
    gatewayUrl = agentApiBase(gatewayUrl);
    const authToken = await getAgentApiToken(options.context);
    if (!authToken) {
        console.error(chalk.red("\n  Session expired or not logged in. Run `lobu login`.\n"));
        process.exit(1);
    }
    const agentId = options.agent ?? (await resolveAgentId(cwd));
    const platformUser = options.user ? parsePlatformUser(options.user) : null;
    const contextName = options.context ?? (await getCurrentContextName());
    // Explicit `--org` overrides the context's activeOrg for this invocation
    // only (no config write). Threaded as `x-lobu-org` on every Agent API call.
    const org = await resolveChatOrg(options);
    // Resolve thread: explicit --thread > --continue (last for this agent)
    let threadId = options.thread;
    if (!threadId && options.continue && agentId) {
        threadId = await getLastThread(contextName, agentId);
        if (!threadId) {
            console.error(chalk.dim(`\n  No prior thread for ${agentId} in context ${contextName}; starting fresh.\n`));
        }
    }
    if (platformUser) {
        await sendViaPlatform(gatewayUrl, authToken, {
            agentId,
            platform: platformUser.platform,
            userId: platformUser.userId,
            message: prompt,
            thread: threadId,
            json: options.json,
            org,
        });
    }
    else {
        await sendViaApi(gatewayUrl, authToken, {
            agentId,
            message: prompt,
            thread: threadId,
            dryRun: options.dryRun,
            forceNew: options.new && !threadId,
            autoApprove: options.autoApprove,
            json: options.json,
            contextName,
            org,
        });
    }
}
function parsePlatformUser(user) {
    const colonIndex = user.indexOf(":");
    if (colonIndex === -1)
        return null;
    return {
        platform: user.slice(0, colonIndex),
        userId: user.slice(colonIndex + 1),
    };
}
async function sendViaPlatform(gatewayUrl, authToken, opts) {
    const agentId = opts.agentId || `test-${opts.platform}`;
    const body = {
        platform: opts.platform,
        content: opts.message,
    };
    const chatTarget = getPlatformDefinition(opts.platform)?.chatTarget;
    if (chatTarget) {
        body[opts.platform] = {
            [chatTarget.key]: opts.userId,
            ...(chatTarget.includeThread ? { thread: opts.thread } : {}),
        };
    }
    const res = await fetch(`${gatewayUrl}/api/v1/agents/${encodeURIComponent(agentId)}/messages`, {
        method: "POST",
        headers: agentApiHeaders(authToken, opts.org, {
            "Content-Type": "application/json",
        }),
        body: JSON.stringify(body),
    });
    if (!res.ok) {
        const resBody = await res.text().catch(() => "");
        console.error(chalk.red(`\n  Failed to send message (${res.status}): ${resBody}\n`));
        process.exit(1);
    }
    const result = (await res.json());
    if (result.eventsUrl) {
        const sseUrl = result.eventsUrl.startsWith("http")
            ? result.eventsUrl
            : `${gatewayUrl}${result.eventsUrl}`;
        const sseController = new AbortController();
        await streamResponse(sseUrl, authToken, sseController, {
            expectedMessageId: result.messageId,
            json: opts.json,
            org: opts.org,
        });
    }
    else {
        console.log(chalk.dim(`  Message sent via ${opts.platform}. Response will appear on the platform.\n`));
    }
}
async function createSession(gatewayUrl, authToken, opts) {
    const createBody = {};
    if (opts.agentId)
        createBody.agentId = opts.agentId;
    if (opts.thread)
        createBody.thread = opts.thread;
    if (opts.dryRun)
        createBody.dryRun = true;
    if (opts.forceNew)
        createBody.forceNew = true;
    const createRes = await fetch(`${gatewayUrl}/api/v1/agents`, {
        method: "POST",
        headers: agentApiHeaders(authToken, opts.org, {
            "Content-Type": "application/json",
        }),
        body: JSON.stringify(createBody),
    });
    if (!createRes.ok) {
        const body = await createRes.text().catch(() => "");
        if (createRes.status === 401) {
            console.error(chalk.red("\n  Authentication required. Run `lobu login`.\n"));
            process.exit(1);
        }
        console.error(chalk.red(`\n  Failed to create session (${createRes.status}): ${body}\n`));
        process.exit(1);
    }
    const session = (await createRes.json());
    return {
        agentId: session.agentId,
        token: session.token,
        threadId: session.threadId ?? session.thread,
    };
}
async function sendViaApi(gatewayUrl, authToken, opts) {
    const session = await createSession(gatewayUrl, authToken, {
        agentId: opts.agentId,
        thread: opts.thread,
        dryRun: opts.dryRun,
        forceNew: opts.forceNew,
        org: opts.org,
    });
    const base = `${gatewayUrl}/api/v1/agents/${session.agentId}`;
    const sseUrl = `${base}/events`;
    const messagesUrl = `${base}/messages`;
    const sseController = new AbortController();
    const streaming = streamResponse(sseUrl, session.token, sseController, {
        autoApprove: opts.autoApprove,
        json: opts.json,
        org: opts.org,
    });
    const msgRes = await fetch(messagesUrl, {
        method: "POST",
        headers: agentApiHeaders(session.token, opts.org, {
            "Content-Type": "application/json",
        }),
        body: JSON.stringify({ content: opts.message }),
    });
    if (!msgRes.ok) {
        sseController.abort();
        const body = await msgRes.text().catch(() => "");
        console.error(chalk.red(`\n  Failed to send message (${msgRes.status}): ${body}\n`));
        process.exit(1);
    }
    await streaming;
    if (opts.contextName && session.threadId) {
        await setLastThread(opts.contextName, session.agentId, session.threadId);
    }
}
async function resolveAgentId(cwd) {
    try {
        const { project } = await loadProjectConfig(cwd);
        return project.agents[0]?.id;
    }
    catch {
        return undefined;
    }
}
async function writeStdout(text) {
    await new Promise((resolve, reject) => {
        process.stdout.write(text, (error) => {
            if (error) {
                reject(error);
                return;
            }
            resolve();
        });
    });
}
async function writeStderr(text) {
    await new Promise((resolve, reject) => {
        process.stderr.write(text, (error) => {
            if (error) {
                reject(error);
                return;
            }
            resolve();
        });
    });
}
async function streamResponse(sseUrl, token, controller, options = {}) {
    const OVERALL_TIMEOUT_MS = 5 * 60 * 1000;
    const IDLE_TIMEOUT_MS = 60 * 1000;
    const overallTimer = setTimeout(() => controller.abort(), OVERALL_TIMEOUT_MS);
    let idleTimer = setTimeout(() => controller.abort(), IDLE_TIMEOUT_MS);
    const resetIdleTimer = () => {
        clearTimeout(idleTimer);
        idleTimer = setTimeout(() => controller.abort(), IDLE_TIMEOUT_MS);
    };
    // Tracked across try/finally so we can cancel the body stream on early
    // `return` paths (complete / error / ephemeral). Without this, the reader
    // holds the lock and the SSE connection stays open until GC.
    let readerForCleanup;
    try {
        const res = await fetch(sseUrl, {
            headers: agentApiHeaders(token, options.org),
            signal: controller.signal,
        });
        if (!res.ok || !res.body) {
            console.error(chalk.red(`\n  SSE connection failed (${res.status})\n`));
            process.exit(1);
        }
        const reader = res.body.getReader();
        readerForCleanup = reader;
        const decoder = new TextDecoder();
        let buffer = "";
        let currentEvent = "";
        let sawFileUploadedEvent = false;
        let sawSandboxLink = false;
        while (true) {
            const { done, value } = await reader.read();
            if (done)
                break;
            resetIdleTimer();
            buffer += decoder.decode(value, { stream: true });
            const lines = buffer.split("\n");
            buffer = lines.pop() ?? "";
            for (const line of lines) {
                if (line.startsWith("event: ")) {
                    currentEvent = line.slice(7).trim();
                }
                else if (line.startsWith("data: ") && currentEvent) {
                    const data = parseJSON(line.slice(6));
                    if (!data)
                        continue;
                    if (options.expectedMessageId &&
                        currentEvent !== "connected" &&
                        currentEvent !== "ping" &&
                        typeof data.messageId === "string" &&
                        data.messageId !== options.expectedMessageId) {
                        currentEvent = "";
                        continue;
                    }
                    if (options.json) {
                        await writeStdout(`${JSON.stringify({ event: currentEvent, ...data })}\n`);
                        if (currentEvent === "complete" || currentEvent === "error") {
                            if (currentEvent === "error")
                                process.exitCode = 1;
                            controller.abort();
                            return;
                        }
                        currentEvent = "";
                        continue;
                    }
                    switch (currentEvent) {
                        case "output":
                            if (typeof data.content === "string") {
                                if (data.content.includes("sandbox:/")) {
                                    sawSandboxLink = true;
                                }
                                await writeStdout(data.content);
                            }
                            break;
                        case "ephemeral":
                            if (typeof data.content === "string") {
                                await writeStderr(`\n${renderMarkdown(data.content)}\n`);
                            }
                            controller.abort();
                            return;
                        case "tool-approval": {
                            const args = data.args;
                            const argsText = args
                                ? Object.entries(args)
                                    .map(([k, v]) => `  ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
                                    .join("\n")
                                : "";
                            await writeStderr(chalk.yellow(`\n  Tool Approval Required\n  ${data.mcpId} → ${data.toolName}\n${argsText}\n`));
                            let decision;
                            if (options.autoApprove) {
                                decision = "always";
                                await writeStderr(chalk.dim("\n  --auto-approve: approving (always).\n"));
                            }
                            else {
                                const choices = ["1h", "24h", "always", "deny"];
                                const labels = {
                                    "1h": "1h",
                                    "24h": "24h",
                                    always: "always",
                                    deny: "deny always",
                                };
                                await writeStderr(`${choices
                                    .map((o, i) => `  ${chalk.bold(`${i + 1}`)}. ${o === "deny" ? chalk.red(labels[o]) : chalk.green(labels[o])}`)
                                    .join("\n")}\n`);
                                const rl = createInterface({
                                    input: process.stdin,
                                    output: process.stderr,
                                });
                                const answer = await new Promise((resolve) => rl.question(chalk.dim("\n  Choice (1-4): "), (a) => {
                                    rl.close();
                                    resolve(a.trim());
                                }));
                                const idx = Number.parseInt(answer, 10) - 1;
                                decision = choices[idx] || "deny";
                            }
                            const approveUrl = sseUrl
                                .replace(/\/events$/, "")
                                .replace(/\/api\/v1\/agents\/[^/]+/, "/api/v1/agents/approve");
                            const approveRes = await fetch(approveUrl, {
                                method: "POST",
                                headers: agentApiHeaders(token, options.org, {
                                    "Content-Type": "application/json",
                                }),
                                body: JSON.stringify({ requestId: data.requestId, decision }),
                            });
                            if (approveRes.ok) {
                                const result = (await approveRes.json());
                                if (result.result?.content) {
                                    const text = result.result.content
                                        .map((c) => c.text)
                                        .join("\n");
                                    await writeStdout(renderMarkdown(text));
                                }
                                await writeStderr(chalk.green(`\n  Tool ${decision === "deny" ? "denied" : "approved"} (${decision})\n`));
                            }
                            else {
                                await writeStderr(chalk.red(`\n  Approval failed: ${await approveRes.text()}\n`));
                            }
                            break;
                        }
                        case "link-button":
                        case "question":
                        case "suggestion":
                        case "file-uploaded":
                            if (currentEvent === "file-uploaded") {
                                sawFileUploadedEvent = true;
                            }
                            await writeStderr(`${JSON.stringify({ event: currentEvent, ...data })}\n`);
                            break;
                        case "complete":
                            await writeStdout("\n");
                            if (sawSandboxLink && !sawFileUploadedEvent) {
                                await writeStderr(chalk.red("\n  Warning: assistant output contained a sandbox/local file link, but no file-uploaded event was emitted. Treat this as a failed file-delivery attempt.\n"));
                            }
                            controller.abort();
                            return;
                        case "error":
                            await writeStdout("\n");
                            await writeStderr(chalk.red(`\n  Agent error: ${String(data.error)}\n`));
                            // Surface the failure to the shell — a script wrapping `lobu chat`
                            // must not see exit 0 when the agent run errored.
                            process.exitCode = 1;
                            controller.abort();
                            return;
                    }
                    currentEvent = "";
                }
                else if (line === "") {
                    currentEvent = "";
                }
            }
        }
    }
    catch (err) {
        if (err instanceof Error && err.name === "AbortError")
            return;
        throw err;
    }
    finally {
        clearTimeout(overallTimer);
        clearTimeout(idleTimer);
        if (readerForCleanup) {
            // Cancel the body stream so the underlying connection is released
            // immediately on early return — otherwise the lock stays held until GC.
            await readerForCleanup.cancel().catch(() => undefined);
        }
    }
}
function parseJSON(str) {
    try {
        const parsed = JSON.parse(str);
        if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
            return parsed;
        }
        return null;
    }
    catch {
        return null;
    }
}
//# sourceMappingURL=chat.js.map