import assert from "node:assert/strict";
import { test, afterEach } from "node:test";
import { createServer } from "node:http";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequestHandler, shutdownServe } from "../src/serve/server.js";
import { AgentBridge } from "../src/serve/agent-bridge.js";
import { Session } from "@sean.holung/minicode-sdk";
import { DuplicateSessionLabelError } from "../src/session/session-store.js";
import { createTestAgentConfig } from "./test-utils.js";
/**
 * Lightweight AgentBridge subclass for testing.
 * Overrides all public methods so no real config/agent/index is needed.
 */
class MockBridge extends AgentBridge {
    _busy = false;
    _currentSessionId = "sess-1";
    _baseConfig = createTestAgentConfig("/tmp/test-workspace");
    _config = createTestAgentConfig("/tmp/test-workspace");
    turnHistory = [];
    openRouterKey;
    openRouterSessionActive = false;
    anthropicApiKey;
    anthropicSessionActive = false;
    openAiCompatibleApiKey;
    openAiCompatibleSessionActive = false;
    refreshIndexCount = 0;
    deletedSessionIds = [];
    constructor() {
        super(() => { }, false);
    }
    isBusy() {
        return this._busy;
    }
    getConfig() {
        return this._config;
    }
    async runTurn(message) {
        if (this._busy)
            throw new Error("busy");
        this._busy = true;
        this.turnHistory.push(message);
        // Simulate streaming by emitting events via the listener system
        this.emit({ type: "streaming_chunk", content: `Echo: ${message}` });
        this._busy = false;
        return {
            text: `Echo: ${message}`,
            usage: { inputTokens: 10, outputTokens: 5 },
        };
    }
    /** Expose the private emit for test streaming simulation. */
    emit(msg) {
        // Call listeners registered via addListener
        for (const fn of this.listeners) {
            fn(msg);
        }
    }
    async listSess() {
        return [
            {
                id: "sess-1",
                label: "test-session",
                createdAt: "2026-01-01T00:00:00.000Z",
                savedAt: "2026-01-01T00:00:00.000Z",
                messageCount: 3,
            },
        ];
    }
    async saveSess(label) {
        return {
            id: "sess-new",
            label: label ?? "auto-label",
            createdAt: "2026-01-01T00:00:00.000Z",
            savedAt: "2026-01-01T00:00:00.000Z",
            messageCount: 1,
        };
    }
    async loadSess(label) {
        if (label === "nonexistent")
            return null;
        const session = new Session("sess-1");
        session.addMessage({
            role: "user",
            content: "[Conversation Summary — earlier messages were compacted to save context]\nOlder summary",
        });
        for (let i = 1; i <= 11; i += 1) {
            session.addMessage({
                role: i % 2 === 0 ? "assistant" : "user",
                content: `message-${i}`,
            });
        }
        return { session, label };
    }
    async deleteSess(sessionId) {
        if (sessionId !== "sess-1") {
            return false;
        }
        this.deletedSessionIds.push(sessionId);
        return true;
    }
    getCurrentSessionId() {
        return this._currentSessionId;
    }
    connectOpenRouter(apiKey) {
        this.openRouterKey = apiKey;
        this.openRouterSessionActive = true;
        this.anthropicApiKey = undefined;
        this.anthropicSessionActive = false;
        this.openAiCompatibleApiKey = undefined;
        this.openAiCompatibleSessionActive = false;
        this._config.modelProvider = "openai-compatible";
        this._config.openAiBaseUrl = "https://openrouter.ai/api/v1";
        this._config.openAiApiKey = apiKey;
        delete this._config.anthropicApiKey;
    }
    connectOpenAiCompatible(baseUrl, apiKey) {
        this.openAiCompatibleSessionActive = true;
        this.openRouterKey = undefined;
        this.openRouterSessionActive = false;
        this.anthropicApiKey = undefined;
        this.anthropicSessionActive = false;
        this.openAiCompatibleApiKey = apiKey?.trim() || undefined;
        this._config.modelProvider = "openai-compatible";
        this._config.openAiBaseUrl = baseUrl.trim().replace(/\/+$/, "");
        if (this.openAiCompatibleApiKey) {
            this._config.openAiApiKey = this.openAiCompatibleApiKey;
        }
        else {
            delete this._config.openAiApiKey;
        }
        delete this._config.anthropicApiKey;
    }
    connectAnthropic(apiKey) {
        this.anthropicApiKey = apiKey;
        this.anthropicSessionActive = true;
        this.openRouterKey = undefined;
        this.openRouterSessionActive = false;
        this.openAiCompatibleApiKey = undefined;
        this.openAiCompatibleSessionActive = false;
        this._config.modelProvider = "anthropic";
        this._config.anthropicApiKey = apiKey;
        delete this._config.openAiApiKey;
    }
    disconnectOpenRouter() {
        if (!this.openRouterSessionActive) {
            return false;
        }
        this.openRouterSessionActive = false;
        this.openRouterKey = undefined;
        this._config.modelProvider = this._baseConfig.modelProvider;
        this._config.model = this._baseConfig.model;
        this._config.openAiBaseUrl = this._baseConfig.openAiBaseUrl;
        delete this._config.openAiApiKey;
        delete this._config.anthropicApiKey;
        return true;
    }
    isOpenRouterSessionConnected() {
        return this.openRouterSessionActive;
    }
    disconnectOpenAiCompatible() {
        if (!this.openAiCompatibleSessionActive) {
            return false;
        }
        this.openAiCompatibleSessionActive = false;
        this.openAiCompatibleApiKey = undefined;
        this._config.modelProvider = this._baseConfig.modelProvider;
        this._config.model = this._baseConfig.model;
        this._config.openAiBaseUrl = this._baseConfig.openAiBaseUrl;
        delete this._config.openAiApiKey;
        delete this._config.anthropicApiKey;
        return true;
    }
    isOpenAiCompatibleSessionConnected() {
        return this.openAiCompatibleSessionActive;
    }
    disconnectAnthropic() {
        if (!this.anthropicSessionActive) {
            return false;
        }
        this.anthropicSessionActive = false;
        this.anthropicApiKey = undefined;
        this._config.modelProvider = this._baseConfig.modelProvider;
        this._config.model = this._baseConfig.model;
        this._config.openAiBaseUrl = this._baseConfig.openAiBaseUrl;
        delete this._config.openAiApiKey;
        delete this._config.anthropicApiKey;
        return true;
    }
    isAnthropicSessionConnected() {
        return this.anthropicSessionActive;
    }
    switchModel(modelId) {
        this._config.model = modelId;
    }
    setBusy(busy) {
        this._busy = busy;
    }
    hasIndex() {
        return true;
    }
    async refreshIndex() {
        this.refreshIndexCount += 1;
        return true;
    }
    getSymbols() {
        return [
            { name: "foo", qualifiedName: "foo", kind: "function", filePath: "src/foo.ts", startLine: 1, endLine: 5, signature: "function foo(): void", exported: true },
            { name: "Bar", qualifiedName: "Bar", kind: "class", filePath: "src/bar.ts", startLine: 1, endLine: 20, signature: "class Bar", exported: true },
            { name: "helper", qualifiedName: "Bar.helper", kind: "method", filePath: "src/bar.ts", startLine: 10, endLine: 15, signature: "helper(): string", exported: false },
        ];
    }
    getSymbol(name) {
        const [match] = this.getSymbolMatches(name);
        if (!match)
            return undefined;
        return match;
    }
    getSymbolMatches(name) {
        return this.getSymbols()
            .filter((s) => s.qualifiedName === name || s.name === name)
            .map((match) => ({
            ...match,
            dependencies: [],
            kind: match.kind,
        }));
    }
    getDependencies(symbolName) {
        if (symbolName === "foo") {
            return [
                { name: "foo", qualifiedName: "foo", kind: "function", filePath: "src/foo.ts", signature: "function foo(): void" },
                { name: "Bar", qualifiedName: "Bar", kind: "class", filePath: "src/bar.ts", signature: "class Bar" },
            ];
        }
        return undefined;
    }
    getReferences(symbolName) {
        if (symbolName === "Bar") {
            return [{ from: "foo", kind: "calls" }];
        }
        return undefined;
    }
    getCodeMap() {
        return { text: "## src/foo.ts\n- foo(): void", shownCount: 1, totalCount: 3 };
    }
    getGraph() {
        return {
            nodes: [
                { id: "foo", name: "foo", kind: "function", filePath: "src/foo.ts", exported: true },
                { id: "Bar", name: "Bar", kind: "class", filePath: "src/bar.ts", exported: true },
            ],
            edges: [{ from: "foo", to: "Bar", kind: "calls" }],
        };
    }
    getStructuralAnalysis() {
        return {
            version: 1,
            findings: [
                {
                    id: "hotspot:foo",
                    type: "hotspot",
                    severity: "warning",
                    title: "foo is a structural hotspot",
                    summary: "foo has total degree 4.",
                    symbols: ["foo"],
                    files: ["src/foo.ts"],
                    metrics: {
                        totalDegree: 4,
                        fanIn: 1,
                        fanOut: 3,
                        threshold: 4,
                        score: 4,
                    },
                    rationale: ["Total degree exceeds the deterministic hotspot threshold."],
                },
            ],
            symbolMetrics: [
                {
                    qualifiedName: "foo",
                    name: "foo",
                    kind: "function",
                    filePath: "src/foo.ts",
                    spanLines: 12,
                    fanIn: 1,
                    fanOut: 3,
                    totalDegree: 4,
                    inboundKinds: ["calls"],
                    outboundKinds: ["calls"],
                },
            ],
            fileMetrics: [
                {
                    filePath: "src/foo.ts",
                    symbolCount: 1,
                    incomingEdgeCount: 1,
                    outgoingEdgeCount: 3,
                    internalEdgeCount: 0,
                    afferentCoupling: 1,
                    efferentCoupling: 2,
                    totalCoupling: 3,
                    instability: 0.667,
                },
            ],
            summary: {
                symbolCount: 2,
                edgeCount: 1,
                fileCount: 2,
                findingCount: 1,
                cycleCount: 0,
                hotspotCount: 1,
                thresholds: {
                    fanIn: 3,
                    fanOut: 3,
                    hotspot: 4,
                    fileCoupling: 3,
                },
            },
        };
    }
    getPinnedSymbols() {
        return [...this._pinned];
    }
    _pinned = new Set();
    pinSymbol(name) {
        if (name === "nonexistent")
            return false;
        this._pinned.add(name);
        return true;
    }
    unpinSymbol(name) {
        this._pinned.delete(name);
        return true;
    }
    // Annotation state for testing
    _annotations = new Map();
    getAnnotations() {
        return Object.fromEntries(this._annotations);
    }
    getAnnotationsForSymbol(name) {
        // Resolve symbol name to qualifiedName
        const sym = this.getSymbol(name);
        const key = sym ? sym.qualifiedName : name;
        return this._annotations.get(key) ?? [];
    }
    addAnnotation(name, text) {
        const sym = this.getSymbol(name);
        if (!sym)
            return false;
        const trimmed = text.slice(0, 500).trim();
        if (trimmed.length === 0)
            return false;
        const key = sym.qualifiedName;
        const existing = this._annotations.get(key) ?? [];
        existing.push(trimmed);
        this._annotations.set(key, existing);
        return true;
    }
    removeAnnotation(name, index) {
        const notes = this._annotations.get(name);
        if (!notes || index < 0 || index >= notes.length)
            return false;
        notes.splice(index, 1);
        if (notes.length === 0)
            this._annotations.delete(name);
        return true;
    }
    clearAnnotations(name) {
        this._annotations.delete(name);
    }
    async explainSymbol(name, onEvent) {
        const sym = this.getSymbol(name);
        if (!sym)
            throw new Error(`Symbol "${name}" not found`);
        onEvent({ type: "streaming_chunk", content: `Explaining ${name}...` });
        return `Explaining ${name}...`;
    }
    async explainStructuralFinding(findingId, onEvent) {
        if (findingId !== "hotspot:foo") {
            throw new Error(`Structural finding "${findingId}" not found`);
        }
        onEvent({ type: "streaming_chunk", content: `Interpreting ${findingId}...` });
        return `Interpreting ${findingId}...`;
    }
}
class AmbiguousMockBridge extends MockBridge {
    getSymbols() {
        return [
            ...super.getSymbols(),
            {
                name: "Review (type)",
                qualifiedName: "Review#type",
                kind: "type",
                filePath: "src/review.ts",
                startLine: 1,
                endLine: 1,
                signature: "type Review = { id: string }",
                exported: true,
            },
            {
                name: "Review (class)",
                qualifiedName: "Review#class",
                kind: "class",
                filePath: "src/review.ts",
                startLine: 3,
                endLine: 7,
                signature: "class Review",
                exported: true,
            },
        ];
    }
    getSymbolMatches(name) {
        if (name === "Review") {
            return this.getSymbols()
                .filter((symbol) => symbol.qualifiedName.startsWith("Review#"))
                .map((match) => ({
                ...match,
                dependencies: [],
                kind: match.kind,
            }));
        }
        return super.getSymbolMatches(name);
    }
}
class EmptySessionsBridge extends MockBridge {
    async listSess() {
        return [];
    }
    async saveSess(label) {
        return {
            id: "sess-1",
            label: label ?? "auto-label",
            createdAt: "2026-01-01T00:00:00.000Z",
            savedAt: "2026-01-01T00:00:00.000Z",
            messageCount: 1,
        };
    }
}
class DuplicateSessionLabelBridge extends MockBridge {
    async saveSess() {
        throw new DuplicateSessionLabelError("test-session", "sess-1");
    }
}
// ── Test harness ──
let activeServer;
function startTestServer(bridge, options = {}) {
    const handler = createRequestHandler(bridge, undefined, options);
    const server = createServer(handler);
    activeServer = server;
    return new Promise((resolve) => {
        server.listen(0, "127.0.0.1", () => {
            const addr = server.address();
            if (typeof addr === "object" && addr) {
                resolve(`http://127.0.0.1:${addr.port}`);
            }
        });
    });
}
function stopTestServer() {
    return new Promise((resolve) => {
        if (activeServer) {
            activeServer.close(() => resolve());
            activeServer = undefined;
        }
        else {
            resolve();
        }
    });
}
afterEach(async () => {
    await stopTestServer();
});
// ── REST API tests ──
test("GET /api/status returns status and config info", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/status`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.status, "ready");
    assert.equal(body.workspace, "/tmp/test-workspace");
    assert.equal(body.model, "test-model");
    assert.equal(body.provider, "anthropic");
});
test("GET /api/status returns busy when agent is busy", async () => {
    const bridge = new MockBridge();
    bridge.setBusy(true);
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/status`);
    const body = (await res.json());
    assert.equal(body.status, "busy");
});
test("GET /api/config returns formatted config plus structured settings", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/config`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.ok(body.config.includes("workspaceRoot"));
    assert.ok(body.config.includes("test-model"));
    assert.equal(body.restartRequired, true);
    assert.equal(body.secretsUiSupported, false);
    assert.ok(body.settings.configPath.endsWith("/.minicode/.env"));
    assert.ok(body.settings.entries.some((entry) => entry.key === "maxSteps"));
});
test("GET /api/sessions returns session list", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/sessions`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.sessions.length, 1);
    assert.equal(body.currentSessionId, "sess-1");
    assert.equal(body.sessions[0].id, "sess-1");
    assert.equal(body.sessions[0].label, "test-session");
});
test("POST /api/sessions/save saves a session", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/sessions/save`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ label: "my-save" }),
    });
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.label, "my-save");
});
test("POST /api/sessions/save rejects duplicate session labels", async () => {
    const bridge = new DuplicateSessionLabelBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/sessions/save`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ label: "test-session" }),
    });
    assert.equal(res.status, 409);
    const body = (await res.json());
    assert.match(body.error, /already exists/);
});
test("session APIs support the first save when no sessions exist yet", async () => {
    const bridge = new EmptySessionsBridge();
    const base = await startTestServer(bridge);
    const listRes = await fetch(`${base}/api/sessions`);
    assert.equal(listRes.status, 200);
    const listBody = (await listRes.json());
    assert.equal(listBody.sessions.length, 0);
    assert.equal(listBody.currentSessionId, "sess-1");
    const saveRes = await fetch(`${base}/api/sessions/save`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({}),
    });
    assert.equal(saveRes.status, 200);
    const saveBody = (await saveRes.json());
    assert.equal(saveBody.id, "sess-1");
    assert.equal(saveBody.label, "auto-label");
});
test("POST /api/sessions/load returns 404 for unknown session", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/sessions/load`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ label: "nonexistent" }),
    });
    assert.equal(res.status, 404);
});
test("POST /api/sessions/load returns success for known session", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/sessions/load`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ label: "test-session" }),
    });
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.label, "test-session");
    assert.equal(body.messages.length, 10);
    assert.equal(body.messages[0]?.content, "message-2");
    assert.equal(body.messages[9]?.content, "message-11");
    assert.ok(body.messages.every((message) => !message.content.startsWith("[Conversation Summary")));
});
test("DELETE /api/sessions/:id deletes a saved session", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/sessions/sess-1`, {
        method: "DELETE",
    });
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.ok, true);
    assert.equal(body.deleted, true);
    assert.equal(body.id, "sess-1");
    assert.deepEqual(bridge.deletedSessionIds, ["sess-1"]);
});
test("DELETE /api/sessions/:id returns 404 for unknown session", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/sessions/missing`, {
        method: "DELETE",
    });
    assert.equal(res.status, 404);
    const body = (await res.json());
    assert.match(body.error, /Session not found/);
});
test("POST /api/chat returns agent response", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/chat`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: "hello" }),
    });
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.text, "Echo: hello");
    assert.equal(body.usage.inputTokens, 10);
    assert.equal(body.usage.outputTokens, 5);
});
test("POST /api/chat returns 400 when message is missing", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/chat`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({}),
    });
    assert.equal(res.status, 400);
});
test("POST /api/chat returns 429 when agent is busy", async () => {
    const bridge = new MockBridge();
    bridge.setBusy(true);
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/chat`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: "hello" }),
    });
    assert.equal(res.status, 429);
});
test("POST /api/openrouter/connect exchanges code and stores a session-only key", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const originalFetch = globalThis.fetch;
    globalThis.fetch = async (input, init) => {
        if (String(input) !== "https://openrouter.ai/api/v1/auth/keys") {
            return originalFetch(input, init);
        }
        assert.equal(init?.method, "POST");
        assert.equal((init?.headers)["Content-Type"], "application/json");
        const body = JSON.parse(String(init?.body));
        assert.equal(body.code, "oauth-code");
        assert.equal(body.code_verifier, "pkce-verifier");
        assert.equal(body.code_challenge_method, "S256");
        return new Response(JSON.stringify({ key: "sk-or-v1-session-key" }), { status: 200, headers: { "content-type": "application/json" } });
    };
    try {
        const res = await originalFetch(`${base}/api/openrouter/connect`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ code: "oauth-code", codeVerifier: "pkce-verifier" }),
        });
        assert.equal(res.status, 200);
        const body = await res.json();
        assert.equal(body.ok, true);
        assert.equal(body.sessionOnly, true);
        assert.equal(body.persistedToEnv, false);
        assert.equal(body.persistedEnvPath, null);
        assert.equal(body.persistWarning, null);
        assert.equal(body.baseUrl, "https://openrouter.ai/api/v1");
        assert.equal(body.needsSetup, false);
        assert.deepEqual(body.missing, []);
        assert.equal(body.message, "OpenRouter connected for this serve session.");
        assert.equal(bridge.openRouterKey, "sk-or-v1-session-key");
        assert.equal(bridge.getConfig().modelProvider, "openai-compatible");
        assert.equal(bridge.getConfig().openAiBaseUrl, "https://openrouter.ai/api/v1");
        assert.equal(bridge.getConfig().openAiApiKey, "sk-or-v1-session-key");
    }
    finally {
        globalThis.fetch = originalFetch;
    }
});
test("POST /api/openrouter/connect can persist OpenRouter setup to ~/.minicode/.env", async () => {
    const bridge = new MockBridge();
    const minicodeHome = mkdtempSync(path.join(os.tmpdir(), "minicode-openrouter-home-"));
    const base = await startTestServer(bridge, { minicodeHome });
    const originalFetch = globalThis.fetch;
    globalThis.fetch = async (input, init) => {
        if (String(input) !== "https://openrouter.ai/api/v1/auth/keys") {
            return originalFetch(input, init);
        }
        return new Response(JSON.stringify({ key: "sk-or-v1-session-key" }), { status: 200, headers: { "content-type": "application/json" } });
    };
    try {
        const res = await originalFetch(`${base}/api/openrouter/connect`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ code: "oauth-code", codeVerifier: "pkce-verifier", persistToEnv: true }),
        });
        assert.equal(res.status, 200);
        const body = await res.json();
        assert.equal(body.persistedToEnv, true);
        assert.equal(body.persistWarning, null);
        assert.equal(body.persistedEnvPath, path.join(minicodeHome, ".env"));
        assert.match(body.message, /saved to ~\/\.minicode\/\.env/);
        const envContents = readFileSync(path.join(minicodeHome, ".env"), "utf8");
        assert.match(envContents, /^MODEL_PROVIDER=openai-compatible$/m);
        assert.match(envContents, /^OPENAI_BASE_URL=https:\/\/openrouter\.ai\/api\/v1$/m);
        assert.match(envContents, /^OPENROUTER_API_KEY=sk-or-v1-session-key$/m);
    }
    finally {
        globalThis.fetch = originalFetch;
        rmSync(minicodeHome, { recursive: true, force: true });
    }
});
test("POST /api/model persists the selected model to ~/.minicode/.env", async () => {
    const bridge = new MockBridge();
    const minicodeHome = mkdtempSync(path.join(os.tmpdir(), "minicode-model-home-"));
    const envPath = path.join(minicodeHome, ".env");
    writeFileSync(envPath, [
        "MODEL_PROVIDER=openai-compatible",
        "OPENAI_BASE_URL=https://openrouter.ai/api/v1",
        "OPENROUTER_API_KEY=sk-or-v1-session-key",
        "",
    ].join("\n"), "utf8");
    const base = await startTestServer(bridge, { minicodeHome });
    try {
        const res = await fetch(`${base}/api/model`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ model: "openrouter/test-model" }),
        });
        assert.equal(res.status, 200);
        const body = await res.json();
        assert.equal(body.model, "openrouter/test-model");
        assert.equal(body.persistedToEnv, true);
        assert.equal(body.persistedEnvPath, envPath);
        const envContents = readFileSync(envPath, "utf8");
        assert.match(envContents, /^MODEL=openrouter\/test-model$/m);
        assert.match(envContents, /^OPENROUTER_API_KEY=sk-or-v1-session-key$/m);
        assert.equal(bridge.getConfig().model, "openrouter/test-model");
    }
    finally {
        rmSync(minicodeHome, { recursive: true, force: true });
    }
});
test("POST /api/openrouter/disconnect removes the session-only OpenRouter connection", async () => {
    const bridge = new MockBridge();
    bridge.connectOpenRouter("sk-or-v1-session-key");
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/openrouter/disconnect`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
    });
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.ok, true);
    assert.equal(body.disconnected, true);
    assert.equal(body.sessionOnly, true);
    assert.equal(body.provider, "anthropic");
    assert.equal(body.baseUrl, "http://localhost:1234/v1");
    assert.equal(body.message, "Removed the session-only OpenRouter connection and restored your original provider settings.");
    assert.equal(bridge.openRouterSessionActive, false);
    assert.equal(bridge.getConfig().modelProvider, "anthropic");
    assert.equal(bridge.getConfig().openAiBaseUrl, "http://localhost:1234/v1");
    assert.equal(bridge.getConfig().openAiApiKey, undefined);
});
test("GET /api/status exposes OpenRouter session state and base URL", async () => {
    const bridge = new MockBridge();
    bridge.connectOpenRouter("sk-or-v1-session-key");
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/status`);
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.provider, "openai-compatible");
    assert.equal(body.baseUrl, "https://openrouter.ai/api/v1");
    assert.equal(body.sessionOpenRouterConnected, true);
});
test("POST /api/anthropic/connect stores a session-only Anthropic key", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/anthropic/connect`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ apiKey: " sk-ant-session-key " }),
    });
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.ok, true);
    assert.equal(body.sessionOnly, true);
    assert.equal(body.persistedToEnv, false);
    assert.equal(body.persistedEnvPath, null);
    assert.equal(body.persistWarning, null);
    assert.equal(body.needsSetup, false);
    assert.deepEqual(body.missing, []);
    assert.equal(body.message, "Anthropic connected for this serve session.");
    assert.equal(bridge.getConfig().modelProvider, "anthropic");
    assert.equal(bridge.getConfig().anthropicApiKey, "sk-ant-session-key");
    assert.equal(bridge.getConfig().openAiApiKey, undefined);
    assert.equal(bridge.isAnthropicSessionConnected(), true);
});
test("POST /api/anthropic/connect can persist Anthropic setup to ~/.minicode/.env", async () => {
    const bridge = new MockBridge();
    const minicodeHome = mkdtempSync(path.join(os.tmpdir(), "minicode-anthropic-home-"));
    const base = await startTestServer(bridge, { minicodeHome });
    try {
        const res = await fetch(`${base}/api/anthropic/connect`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ apiKey: "sk-ant-session-key", persistToEnv: true }),
        });
        assert.equal(res.status, 200);
        const body = await res.json();
        assert.equal(body.persistedToEnv, true);
        assert.equal(body.persistWarning, null);
        assert.equal(body.persistedEnvPath, path.join(minicodeHome, ".env"));
        assert.match(body.message, /saved to ~\/\.minicode\/\.env/);
        const envContents = readFileSync(path.join(minicodeHome, ".env"), "utf8");
        assert.match(envContents, /^MODEL_PROVIDER=anthropic$/m);
        assert.match(envContents, /^ANTHROPIC_API_KEY=sk-ant-session-key$/m);
    }
    finally {
        rmSync(minicodeHome, { recursive: true, force: true });
    }
});
test("POST /api/anthropic/disconnect removes the session-only Anthropic connection", async () => {
    const bridge = new MockBridge();
    bridge.connectAnthropic("sk-ant-session-key");
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/anthropic/disconnect`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
    });
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.ok, true);
    assert.equal(body.disconnected, true);
    assert.equal(body.sessionOnly, true);
    assert.equal(body.provider, "anthropic");
    assert.equal(body.message, "Removed the session-only Anthropic connection and restored your original provider settings.");
    assert.equal(bridge.isAnthropicSessionConnected(), false);
    assert.equal(bridge.getConfig().modelProvider, "anthropic");
    assert.equal(bridge.getConfig().anthropicApiKey, undefined);
});
test("GET /api/status exposes Anthropic session state", async () => {
    const bridge = new MockBridge();
    bridge.connectAnthropic("sk-ant-session-key");
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/status`);
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.provider, "anthropic");
    assert.equal(body.sessionAnthropicConnected, true);
    assert.equal(body.sessionOpenRouterConnected, false);
    assert.equal(body.sessionOpenAiCompatibleConnected, false);
});
test("POST /api/openai-compatible/connect stores a session-only endpoint", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/openai-compatible/connect`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ baseUrl: "http://localhost:1234/v1/", apiKey: "local-key" }),
    });
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.ok, true);
    assert.equal(body.sessionOnly, true);
    assert.equal(body.persistedToEnv, false);
    assert.equal(body.persistedEnvPath, null);
    assert.equal(body.persistWarning, null);
    assert.equal(body.baseUrl, "http://localhost:1234/v1");
    assert.equal(body.needsSetup, false);
    assert.deepEqual(body.missing, []);
    assert.equal(body.message, "The OpenAI-compatible provider connected for this serve session.");
    assert.equal(bridge.getConfig().modelProvider, "openai-compatible");
    assert.equal(bridge.getConfig().openAiBaseUrl, "http://localhost:1234/v1");
    assert.equal(bridge.getConfig().openAiApiKey, "local-key");
    assert.equal(bridge.isOpenAiCompatibleSessionConnected(), true);
});
test("POST /api/openai-compatible/connect can persist the endpoint to ~/.minicode/.env", async () => {
    const bridge = new MockBridge();
    const minicodeHome = mkdtempSync(path.join(os.tmpdir(), "minicode-openai-compatible-home-"));
    const base = await startTestServer(bridge, { minicodeHome });
    try {
        const res = await fetch(`${base}/api/openai-compatible/connect`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                baseUrl: "http://127.0.0.1:1234/v1",
                apiKey: "",
                persistToEnv: true,
            }),
        });
        assert.equal(res.status, 200);
        const body = await res.json();
        assert.equal(body.persistedToEnv, true);
        assert.equal(body.persistWarning, null);
        assert.equal(body.persistedEnvPath, path.join(minicodeHome, ".env"));
        assert.match(body.message, /saved to ~\/\.minicode\/\.env/);
        const envContents = readFileSync(path.join(minicodeHome, ".env"), "utf8");
        assert.match(envContents, /^MODEL_PROVIDER=openai-compatible$/m);
        assert.match(envContents, /^OPENAI_BASE_URL=http:\/\/127\.0\.0\.1:1234\/v1$/m);
        assert.doesNotMatch(envContents, /^OPENAI_API_KEY=/m);
    }
    finally {
        rmSync(minicodeHome, { recursive: true, force: true });
    }
});
test("POST /api/openai-compatible/disconnect removes the session-only endpoint", async () => {
    const bridge = new MockBridge();
    bridge.connectOpenAiCompatible("http://localhost:1234/v1", "local-key");
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/openai-compatible/disconnect`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
    });
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.ok, true);
    assert.equal(body.disconnected, true);
    assert.equal(body.sessionOnly, true);
    assert.equal(body.provider, "anthropic");
    assert.equal(body.baseUrl, "http://localhost:1234/v1");
    assert.equal(body.message, "Removed the session-only OpenAI-compatible connection and restored your original provider settings.");
    assert.equal(bridge.isOpenAiCompatibleSessionConnected(), false);
    assert.equal(bridge.getConfig().modelProvider, "anthropic");
    assert.equal(bridge.getConfig().openAiApiKey, undefined);
});
test("GET /api/status exposes OpenAI-compatible session state and base URL", async () => {
    const bridge = new MockBridge();
    bridge.connectOpenAiCompatible("http://localhost:1234/v1");
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/status`);
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.provider, "openai-compatible");
    assert.equal(body.baseUrl, "http://localhost:1234/v1");
    assert.equal(body.sessionOpenAiCompatibleConnected, true);
});
test("POST /api/openrouter/connect returns 400 when code is missing", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/openrouter/connect`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ codeVerifier: "pkce-verifier" }),
    });
    assert.equal(res.status, 400);
});
test("POST /api/anthropic/connect returns 400 when API key is missing", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/anthropic/connect`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ apiKey: "  " }),
    });
    assert.equal(res.status, 400);
    const body = await res.json();
    assert.equal(body.error, "apiKey is required");
});
test("POST /api/openrouter/connect surfaces exchange failures", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const originalFetch = globalThis.fetch;
    globalThis.fetch = async (input, init) => {
        if (String(input) !== "https://openrouter.ai/api/v1/auth/keys") {
            return originalFetch(input, init);
        }
        return new Response("Invalid code", { status: 403 });
    };
    try {
        const res = await originalFetch(`${base}/api/openrouter/connect`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ code: "bad-code", codeVerifier: "pkce-verifier" }),
        });
        assert.equal(res.status, 403);
        const body = await res.json();
        assert.ok(body.error.includes("OpenRouter OAuth exchange failed"));
        assert.ok(body.error.includes("Invalid code"));
    }
    finally {
        globalThis.fetch = originalFetch;
    }
});
test("POST /api/openai-compatible/connect rejects an invalid endpoint", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/openai-compatible/connect`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ baseUrl: "localhost:1234/v1" }),
    });
    assert.equal(res.status, 400);
    const body = await res.json();
    assert.match(body.error, /valid absolute http\(s\) URL/);
});
// ── OpenAI-compatible API tests ──
test("GET /v1/models returns minicode-agent model", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/v1/models`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.object, "list");
    assert.equal(body.data.length, 1);
    assert.equal(body.data[0].id, "minicode-agent");
    assert.equal(body.data[0].owned_by, "minicode");
});
test("POST /v1/chat/completions non-streaming returns OpenAI-format response", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/v1/chat/completions`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            model: "minicode-agent",
            messages: [{ role: "user", content: "list files" }],
        }),
    });
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.ok(body.id.startsWith("chatcmpl-"));
    assert.equal(body.object, "chat.completion");
    assert.equal(body.model, "minicode-agent");
    assert.equal(body.choices.length, 1);
    assert.equal(body.choices[0].message.role, "assistant");
    assert.equal(body.choices[0].message.content, "Echo: list files");
    assert.equal(body.choices[0].finish_reason, "stop");
    assert.equal(body.usage.prompt_tokens, 10);
    assert.equal(body.usage.completion_tokens, 5);
    assert.equal(body.usage.total_tokens, 15);
});
test("POST /v1/chat/completions returns 400 for empty messages", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/v1/chat/completions`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ model: "minicode-agent", messages: [] }),
    });
    assert.equal(res.status, 400);
});
test("POST /v1/chat/completions returns 400 for no user message", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/v1/chat/completions`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            model: "minicode-agent",
            messages: [{ role: "system", content: "You are helpful" }],
        }),
    });
    assert.equal(res.status, 400);
});
test("POST /v1/chat/completions returns 429 when busy", async () => {
    const bridge = new MockBridge();
    bridge.setBusy(true);
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/v1/chat/completions`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            model: "minicode-agent",
            messages: [{ role: "user", content: "hello" }],
        }),
    });
    assert.equal(res.status, 429);
});
test("POST /v1/chat/completions streaming returns SSE chunks", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/v1/chat/completions`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            model: "minicode-agent",
            messages: [{ role: "user", content: "stream test" }],
            stream: true,
        }),
    });
    assert.equal(res.status, 200);
    assert.equal(res.headers.get("content-type"), "text/event-stream");
    const text = await res.text();
    const lines = text.split("\n").filter((l) => l.startsWith("data: "));
    // Should have at least: role chunk, finish chunk, [DONE]
    assert.ok(lines.length >= 2, `Expected at least 2 data lines, got ${lines.length}`);
    // First chunk should have role
    const firstChunk = JSON.parse(lines[0].slice(6));
    assert.equal(firstChunk.choices[0].delta.role, "assistant");
    // Last data line before [DONE] should have finish_reason
    const lastDataLine = lines.filter((l) => l !== "data: [DONE]").pop();
    const lastChunk = JSON.parse(lastDataLine.slice(6));
    assert.equal(lastChunk.choices[0].finish_reason, "stop");
    // Should end with [DONE]
    assert.ok(text.includes("data: [DONE]"));
});
// ── Static file serving tests ──
test("GET / serves index.html", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/`);
    assert.equal(res.status, 200);
    assert.equal(res.headers.get("content-type"), "text/html");
    assert.equal(res.headers.get("cache-control"), "no-store");
    const html = await res.text();
    assert.ok(html.includes("minicode"));
});
test("GET /style.css serves CSS file", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/style.css`);
    assert.equal(res.status, 200);
    assert.equal(res.headers.get("content-type"), "text/css");
    assert.equal(res.headers.get("cache-control"), "no-store");
});
test("GET /app.js serves JS file", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/app.js`);
    assert.equal(res.status, 200);
    assert.equal(res.headers.get("content-type"), "application/javascript");
    assert.equal(res.headers.get("cache-control"), "no-store");
});
test("GET /nonexistent returns 404", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/nonexistent.html`);
    assert.equal(res.status, 404);
});
// ── CLI args tests ──
test("parseCliArgs parses serve subcommand", async () => {
    const { parseCliArgs } = await import("../src/cli/args.js");
    const parsed = parseCliArgs(["node", "minicode", "serve"]);
    assert.equal(parsed.serve, true);
    assert.equal(parsed.port, 4567);
});
test("parseCliArgs parses serve with --port", async () => {
    const { parseCliArgs } = await import("../src/cli/args.js");
    const parsed = parseCliArgs(["node", "minicode", "serve", "--port", "8080"]);
    assert.equal(parsed.serve, true);
    assert.equal(parsed.port, 8080);
});
test("parseCliArgs parses serve with --port=value", async () => {
    const { parseCliArgs } = await import("../src/cli/args.js");
    const parsed = parseCliArgs(["node", "minicode", "serve", "--port=9090"]);
    assert.equal(parsed.serve, true);
    assert.equal(parsed.port, 9090);
});
test("validateCliArgs rejects serve with --oneshot", async () => {
    const { validateCliArgs, CliUsageError } = await import("../src/cli/args.js");
    assert.throws(() => validateCliArgs({ verbose: false, oneshot: true, json: false, serve: true, port: 4567, task: "test" }), (err) => err instanceof CliUsageError);
});
// ── Graph / Index API tests ──
test("GET /api/symbols returns all symbols", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.symbols.length, 3);
    assert.equal(body.symbols[0].name, "foo");
    assert.equal(body.symbols[1].name, "Bar");
    assert.equal(body.symbols[2].name, "helper");
});
test("GET /api/symbols/:name/dependencies returns dependency cone", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/foo/dependencies`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.symbol, "foo");
    assert.equal(body.dependencies.length, 2);
});
test("GET /api/symbols/:name/dependencies returns 404 for unknown symbol", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/nonexistent/dependencies`);
    assert.equal(res.status, 404);
});
test("GET /api/symbols/:name/dependencies returns 409 for ambiguous symbol", async () => {
    const bridge = new AmbiguousMockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/Review/dependencies`);
    assert.equal(res.status, 409);
    const body = (await res.json());
    assert.equal(body.error, 'Symbol "Review" is ambiguous');
    assert.deepEqual(body.candidates.map((candidate) => candidate.qualifiedName).sort(), ["Review#class", "Review#type"]);
});
test("GET /api/symbols/:name/references returns references", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/Bar/references`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.symbol, "Bar");
    assert.equal(body.references.length, 1);
    assert.equal(body.references[0].from, "foo");
});
test("GET /api/symbols/:name/references returns 404 for unknown symbol", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/nonexistent/references`);
    assert.equal(res.status, 404);
});
test("GET /api/symbols/:name/references returns 409 for ambiguous symbol", async () => {
    const bridge = new AmbiguousMockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/Review/references`);
    assert.equal(res.status, 409);
    const body = (await res.json());
    assert.equal(body.error, 'Symbol "Review" is ambiguous');
    assert.deepEqual(body.candidates.map((candidate) => candidate.qualifiedName).sort(), ["Review#class", "Review#type"]);
});
test("GET /api/code-map returns code map", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/code-map`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.ok(body.text.includes("foo"));
    assert.equal(body.shownCount, 1);
    assert.equal(body.totalCount, 3);
});
test("GET /api/graph returns nodes and edges", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/graph`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.nodes.length, 2);
    assert.equal(body.edges.length, 1);
    assert.equal(body.edges[0].from, "foo");
    assert.equal(body.edges[0].to, "Bar");
    assert.equal(body.edges[0].kind, "calls");
});
test("POST /api/index/refresh rebuilds the project index", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/index/refresh`, { method: "POST" });
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.ok, true);
    assert.equal(body.symbolCount, 2);
    assert.equal(body.edgeCount, 1);
    assert.equal(bridge.refreshIndexCount, 1);
});
test("GET /api/analysis returns deterministic structural findings", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/analysis`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.version, 1);
    assert.equal(body.summary.hotspotCount, 1);
    assert.equal(body.summary.findingCount, 1);
    assert.equal(body.findings[0]?.type, "hotspot");
    assert.deepEqual(body.findings[0]?.symbols, ["foo"]);
});
test("POST /api/analysis/explain returns SSE stream", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/analysis/explain`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ findingId: "hotspot:foo" }),
    });
    assert.equal(res.status, 200);
    assert.equal(res.headers.get("content-type"), "text/event-stream");
    const text = await res.text();
    assert.ok(text.includes("Interpreting hotspot:foo"));
    assert.ok(text.includes("[DONE]"));
});
test("POST /api/analysis/explain rejects missing finding id", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/analysis/explain`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({}),
    });
    assert.equal(res.status, 400);
    assert.deepEqual(await res.json(), { error: "findingId is required" });
});
test("POST /api/analysis/explain streams error event for unknown finding", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/analysis/explain`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ findingId: "missing" }),
    });
    assert.equal(res.status, 200);
    const text = await res.text();
    const errorLine = text.split("\n").find((line) => line.startsWith("data: {") && line.includes('"type":"error"'));
    assert.ok(errorLine);
    const payload = JSON.parse(errorLine.slice(6));
    assert.equal(payload.type, "error");
    assert.equal(payload.message, 'Structural finding "missing" not found');
});
test("GET /api/focus returns pinned symbols", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/focus`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.deepEqual(body.pinned, []);
});
test("POST /api/focus pins and unpins symbols", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    // Pin
    const pinRes = await fetch(`${base}/api/focus`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "pin", symbol: "foo" }),
    });
    assert.equal(pinRes.status, 200);
    const pinBody = (await pinRes.json());
    assert.ok(pinBody.pinned.includes("foo"));
    // Unpin
    const unpinRes = await fetch(`${base}/api/focus`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "unpin", symbol: "foo" }),
    });
    assert.equal(unpinRes.status, 200);
    const unpinBody = (await unpinRes.json());
    assert.ok(!unpinBody.pinned.includes("foo"));
});
test("POST /api/focus returns 404 for unknown symbol", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/focus`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "pin", symbol: "nonexistent" }),
    });
    assert.equal(res.status, 404);
});
test("POST /api/focus returns 400 for invalid action", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/focus`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "toggle", symbol: "foo" }),
    });
    assert.equal(res.status, 400);
});
// ── Symbol source endpoint tests ──
test("GET /api/symbols/:name/source returns source code for known symbol", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    // Create a temp file matching the mock symbol's filePath
    const wsRoot = "/tmp/test-workspace";
    mkdirSync(`${wsRoot}/src`, { recursive: true });
    writeFileSync(`${wsRoot}/src/foo.ts`, "line1\nfunction foo(): void {\n  return;\n}\nline5\n");
    try {
        const res = await fetch(`${base}/api/symbols/foo/source`);
        assert.equal(res.status, 200);
        const body = (await res.json());
        assert.equal(body.symbol, "foo");
        assert.equal(body.filePath, "src/foo.ts");
        assert.equal(body.startLine, 1);
        assert.equal(body.endLine, 5);
        assert.ok(body.source.includes("line1"));
    }
    finally {
        rmSync(wsRoot, { recursive: true, force: true });
    }
});
test("GET /api/symbols/:name/source returns 404 for unknown symbol", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/nonexistent/source`);
    assert.equal(res.status, 404);
    const body = (await res.json());
    assert.ok(body.error.includes("nonexistent"));
});
test("GET /api/symbols/:name/source returns 409 for ambiguous symbol", async () => {
    const bridge = new AmbiguousMockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/Review/source`);
    assert.equal(res.status, 409);
    const body = (await res.json());
    assert.equal(body.error, 'Symbol "Review" is ambiguous');
    assert.deepEqual(body.candidates.map((candidate) => candidate.qualifiedName).sort(), ["Review#class", "Review#type"]);
});
test("GET /api/symbols/:name/source returns 500 when file is missing", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    // Don't create the file — let it fail with a read error
    const res = await fetch(`${base}/api/symbols/foo/source`);
    assert.equal(res.status, 500);
    const body = (await res.json());
    assert.ok(body.error.includes("src/foo.ts"));
});
test("GET /api/file-source returns full file contents for a workspace file", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const wsRoot = "/tmp/test-workspace";
    mkdirSync(`${wsRoot}/src`, { recursive: true });
    writeFileSync(`${wsRoot}/src/foo.ts`, "line1\nfunction foo(): void {\n  return;\n}\nline5\n");
    try {
        const res = await fetch(`${base}/api/file-source?path=${encodeURIComponent("src/foo.ts")}`);
        assert.equal(res.status, 200);
        const body = (await res.json());
        assert.equal(body.filePath, "src/foo.ts");
        assert.ok(body.source.includes("function foo(): void"));
        assert.ok(body.source.includes("line5"));
    }
    finally {
        rmSync(wsRoot, { recursive: true, force: true });
    }
});
test("GET /api/file-source rejects path traversal", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/file-source?path=${encodeURIComponent("../secret.txt")}`);
    assert.equal(res.status, 403);
    const body = (await res.json());
    assert.ok(body.error.includes("Invalid workspace file path"));
});
// ── Annotations API tests ──
test("GET /api/annotations returns empty annotations initially", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/annotations`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.deepEqual(body.annotations, {});
});
test("POST /api/symbols/:name/annotations adds annotation", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/foo/annotations`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: "don't modify, stable API" }),
    });
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.symbol, "foo");
    assert.equal(body.annotations.length, 1);
    assert.equal(body.annotations[0], "don't modify, stable API");
});
test("GET /api/symbols/:name/annotations returns annotations for symbol", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    // Add one first
    await fetch(`${base}/api/symbols/foo/annotations`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: "note 1" }),
    });
    const res = await fetch(`${base}/api/symbols/foo/annotations`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.annotations.length, 1);
    assert.equal(body.annotations[0], "note 1");
});
test("POST /api/symbols/:name/annotations returns 404 for unknown symbol", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/nonexistent/annotations`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: "hello" }),
    });
    assert.equal(res.status, 404);
});
test("POST /api/symbols/:name/annotations returns 400 for missing text", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/foo/annotations`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({}),
    });
    assert.equal(res.status, 400);
});
test("DELETE /api/symbols/:name/annotations/:index removes annotation", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    // Add two annotations
    await fetch(`${base}/api/symbols/foo/annotations`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: "first" }),
    });
    await fetch(`${base}/api/symbols/foo/annotations`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: "second" }),
    });
    // Remove first
    const res = await fetch(`${base}/api/symbols/foo/annotations/0`, {
        method: "DELETE",
    });
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.equal(body.annotations.length, 1);
    assert.equal(body.annotations[0], "second");
});
test("DELETE /api/symbols/:name/annotations/:index returns 404 for invalid index", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/foo/annotations/99`, {
        method: "DELETE",
    });
    assert.equal(res.status, 404);
});
test("DELETE /api/symbols/:name/annotations clears all annotations", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    // Add annotations
    await fetch(`${base}/api/symbols/foo/annotations`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: "note" }),
    });
    // Clear all
    const res = await fetch(`${base}/api/symbols/foo/annotations`, {
        method: "DELETE",
    });
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.deepEqual(body.annotations, []);
});
test("GET /api/annotations returns all annotations after adding", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    await fetch(`${base}/api/symbols/foo/annotations`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: "foo note" }),
    });
    await fetch(`${base}/api/symbols/Bar/annotations`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: "bar note" }),
    });
    const res = await fetch(`${base}/api/annotations`);
    assert.equal(res.status, 200);
    const body = (await res.json());
    assert.ok(Object.keys(body.annotations).length >= 2);
});
test("GET /api/symbols/:name/explain returns SSE stream", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/foo/explain`);
    assert.equal(res.status, 200);
    assert.equal(res.headers.get("content-type"), "text/event-stream");
    const text = await res.text();
    assert.ok(text.includes("data: "));
    assert.ok(text.includes("[DONE]"));
});
test("GET /api/symbols/:name/explain returns error for unknown symbol", async () => {
    const bridge = new MockBridge();
    const base = await startTestServer(bridge);
    const res = await fetch(`${base}/api/symbols/nonexistent/explain`);
    assert.equal(res.status, 200); // SSE always starts 200
    const text = await res.text();
    assert.ok(text.includes("error"));
});
// ── Graceful shutdown tests ──
test("shutdownServe terminates WebSocket clients and calls exit(0)", async () => {
    const bridge = new MockBridge();
    const handler = createRequestHandler(bridge);
    const server = createServer(handler);
    const { WebSocketServer, WebSocket } = await import("ws");
    const wss = new WebSocketServer({ server });
    const openSockets = new Set();
    server.on("connection", (socket) => {
        openSockets.add(socket);
        socket.on("close", () => openSockets.delete(socket));
    });
    // Start server on random port
    await new Promise((resolve) => {
        server.listen(0, "127.0.0.1", () => resolve());
    });
    const addr = server.address();
    // Connect a WebSocket client (simulates browser tab)
    const ws = new WebSocket(`ws://127.0.0.1:${addr.port}`);
    await new Promise((resolve) => ws.on("open", resolve));
    assert.ok(wss.clients.size >= 1, "Should have at least 1 WS client connected");
    assert.ok(openSockets.size >= 1, "Should have at least 1 open socket");
    // Call shutdownServe with a mock exit function
    let exitCode;
    shutdownServe(server, wss, openSockets, (code) => {
        exitCode = code;
    });
    // Wait a tick for async cleanup to propagate
    await new Promise((resolve) => setTimeout(resolve, 100));
    assert.equal(exitCode, 0, "Should have called exit with code 0");
    assert.equal(openSockets.size, 0, "All sockets should be cleared");
    assert.equal(wss.clients.size, 0, "All WS clients should be removed");
});
test("shutdownServe works when no clients are connected", async () => {
    const bridge = new MockBridge();
    const handler = createRequestHandler(bridge);
    const server = createServer(handler);
    const { WebSocketServer } = await import("ws");
    const wss = new WebSocketServer({ server });
    const openSockets = new Set();
    await new Promise((resolve) => {
        server.listen(0, "127.0.0.1", () => resolve());
    });
    let exitCode;
    shutdownServe(server, wss, openSockets, (code) => {
        exitCode = code;
    });
    await new Promise((resolve) => setTimeout(resolve, 100));
    assert.equal(exitCode, 0, "Should exit cleanly with no clients");
});
test("shutdownServe terminates multiple WebSocket clients", async () => {
    const bridge = new MockBridge();
    const handler = createRequestHandler(bridge);
    const server = createServer(handler);
    const { WebSocketServer, WebSocket } = await import("ws");
    const wss = new WebSocketServer({ server });
    const openSockets = new Set();
    server.on("connection", (socket) => {
        openSockets.add(socket);
        socket.on("close", () => openSockets.delete(socket));
    });
    await new Promise((resolve) => {
        server.listen(0, "127.0.0.1", () => resolve());
    });
    const addr = server.address();
    // Connect 3 WebSocket clients
    const clients = [];
    for (let i = 0; i < 3; i++) {
        const ws = new WebSocket(`ws://127.0.0.1:${addr.port}`);
        await new Promise((resolve) => ws.on("open", resolve));
        clients.push(ws);
    }
    assert.equal(wss.clients.size, 3, "Should have 3 WS clients connected");
    let exitCode;
    shutdownServe(server, wss, openSockets, (code) => {
        exitCode = code;
    });
    await new Promise((resolve) => setTimeout(resolve, 100));
    assert.equal(exitCode, 0, "Should have called exit with code 0");
    assert.equal(openSockets.size, 0, "All sockets should be cleared");
});
