import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat";
import type {
  AgentEvent,
  ChatMessage,
  ConversationMeta,
  HostAPI,
  Logger,
  ReasoningEffort,
  StepId,
  StorageNamespace,
  StoredChunk,
  TurnMetrics,
} from "@dispatch/kernel";
import { DEFAULT_TEMPLATE } from "@dispatch/system-prompt";
import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store";
import type {
  DeleteWorkspaceResponse,
  QueuedMessage,
  QueueResponse,
  SystemPromptVariable,
  ThroughputResponse,
  WorkspaceListResponse,
  WorkspaceResponse,
} from "@dispatch/transport-contract";
import type { Computer, ComputerEntry, Workspace } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import { createApp } from "./app.js";
import { extractLastAssistantText } from "./logic.js";
import type {
  ComputerService,
  ConversationStore,
  CredentialStore,
  HeartbeatService,
  LspService,
  McpService,
  SessionOrchestrator,
  SystemPromptService,
  WarmService,
} from "./seam.js";
import { conversationOpened } from "./seam.js";

function createMemStorage(): StorageNamespace {
  const map = new Map<string, string>();
  return {
    get: async (k) => map.get(k) ?? null,
    set: async (k, v) => {
      map.set(k, v);
    },
    delete: async (k) => {
      map.delete(k);
    },
    has: async (k) => map.has(k),
    keys: async (prefix) =>
      [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
  };
}

interface CapturedLog {
  readonly level: "debug" | "info" | "warn" | "error";
  readonly msg: string;
  readonly attrs?: Record<string, unknown>;
}

function createFakeLogger(): Logger & { readonly records: readonly CapturedLog[] } {
  const records: CapturedLog[] = [];
  return {
    get records() {
      return records;
    },
    debug(msg, attrs) {
      records.push({ level: "debug", msg, ...(attrs ? { attrs } : {}) });
    },
    info(msg, attrs) {
      records.push({ level: "info", msg, ...(attrs ? { attrs } : {}) });
    },
    warn(msg, attrs) {
      records.push({ level: "warn", msg, ...(attrs ? { attrs } : {}) });
    },
    error(msg, attrs) {
      records.push({ level: "error", msg, ...(attrs ? { attrs } : {}) });
    },
    child() {
      return createFakeLogger();
    },
    span() {
      return {
        id: "fake-span",
        log: createFakeLogger(),
        setAttributes() {},
        addLink() {},
        child() {
          return this;
        },
        end() {},
      };
    },
  };
}

function createFakeConversationStore(
  store: Map<string, StoredChunk[]> = new Map(),
  metricsStore: Map<string, TurnMetrics[]> = new Map(),
  cwdStore: Map<string, string> = new Map(),
  reasoningEffortStore: Map<string, ReasoningEffort> = new Map(),
  modelStore: Map<string, string> = new Map(),
  computerStore: Map<string, string> = new Map(),
): ConversationStore {
  const sampleWorkspace = {
    id: "default",
    title: "default",
    defaultCwd: null,
    defaultComputerId: null,
    createdAt: 0,
    lastActivityAt: 0,
  };
  return {
    async append() {},
    async load() {
      return [];
    },
    async loadSince(conversationId, sinceSeq, window) {
      const chunks = store.get(conversationId) ?? [];
      const minSeq = sinceSeq ?? 0;
      const beforeSeq = window?.beforeSeq;
      const limit = window?.limit;
      const selected = chunks.filter(
        (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq),
      );
      // Window: keep only the NEWEST `limit`, still ascending by seq.
      if (limit !== undefined && selected.length > limit) {
        return selected.slice(selected.length - limit);
      }
      return selected;
    },
    async appendMetrics() {},
    async loadMetrics(conversationId) {
      return metricsStore.get(conversationId) ?? [];
    },
    async getCwd(conversationId) {
      return cwdStore.get(conversationId) ?? null;
    },
    async setCwd(conversationId, cwd) {
      cwdStore.set(conversationId, cwd);
    },
    async clearCwd(conversationId) {
      cwdStore.delete(conversationId);
    },
    async getComputerId(conversationId) {
      return computerStore.get(conversationId) ?? null;
    },
    async setComputerId(conversationId, alias) {
      if (alias === null) {
        computerStore.delete(conversationId);
      } else {
        computerStore.set(conversationId, alias);
      }
    },
    async clearComputerId(conversationId) {
      computerStore.delete(conversationId);
    },
    async getReasoningEffort(conversationId) {
      return reasoningEffortStore.get(conversationId) ?? null;
    },
    async setReasoningEffort(conversationId, effort) {
      reasoningEffortStore.set(conversationId, effort);
    },
    async getModel(conversationId) {
      return modelStore.get(conversationId) ?? null;
    },
    async setModel(conversationId, model) {
      if (model === "") {
        modelStore.delete(conversationId);
      } else {
        modelStore.set(conversationId, model);
      }
    },
    async listConversations() {
      return [];
    },
    async getConversationMeta() {
      return null;
    },
    async setConversationTitle() {},
    async getConversationStatus() {
      return null;
    },
    async setConversationStatus() {},
    async replaceHistory() {},
    async getCompactPercent() {
      return null;
    },
    async setCompactPercent() {},
    async forkHistory() {},
    async setCompactedFrom() {},
    async getWorkspace() {
      return null;
    },
    async ensureWorkspace() {
      return sampleWorkspace;
    },
    async setWorkspaceTitle() {
      return sampleWorkspace;
    },
    async setWorkspaceDefaultCwd() {
      return sampleWorkspace;
    },
    async setWorkspaceDefaultComputerId(id, defaultComputerId) {
      return { ...sampleWorkspace, id, defaultComputerId };
    },
    async deleteWorkspace() {
      return { closedCount: 0 };
    },
    async listWorkspaces() {
      return [];
    },
    async getWorkspaceId() {
      return "default";
    },
    async setWorkspaceId() {},
    async getEffectiveCwd(conversationId) {
      return cwdStore.get(conversationId) ?? null;
    },
    async getEffectiveComputer(conversationId) {
      return computerStore.get(conversationId) ?? null;
    },
  };
}

/**
 * Wraps a ConversationStore to record the ORDER of mutating calls
 * (ensureWorkspace, setWorkspaceId, setCwd) as a labeled list — so tests can
 * assert that workspace assignment happens BEFORE setCwd.
 */
function createCallTrackingStore(
  base: ConversationStore,
): ConversationStore & { readonly calls: readonly string[] } {
  const calls: string[] = [];
  return {
    ...base,
    get calls() {
      return calls;
    },
    async ensureWorkspace(id, opts) {
      calls.push(`ensureWorkspace:${id}`);
      return base.ensureWorkspace(id, opts);
    },
    async setWorkspaceId(conversationId, workspaceId) {
      calls.push(`setWorkspaceId:${workspaceId}`);
      await base.setWorkspaceId(conversationId, workspaceId);
    },
    async setCwd(conversationId, cwd) {
      calls.push(`setCwd:${cwd}`);
      await base.setCwd(conversationId, cwd);
    },
  };
}

function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator {
  return {
    startTurn() {
      return { started: true, turnId: "fake-turn" };
    },
    subscribe() {
      return () => {};
    },
    isActive() {
      return false;
    },
    enqueue() {
      return { startedTurn: false, queue: [] };
    },
    closeConversation() {
      return { abortedTurn: false };
    },
    stopTurn() {
      return { abortedTurn: false };
    },
    async handleMessage(input) {
      for (const event of events) {
        input.onEvent(event);
      }
    },
  };
}

function createCapturingOrchestrator(): SessionOrchestrator & {
  received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
} {
  const state: {
    received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
  } = { received: undefined };
  return {
    get received() {
      return state.received;
    },
    startTurn() {
      return { started: true, turnId: "fake-turn" };
    },
    subscribe() {
      return () => {};
    },
    isActive() {
      return false;
    },
    enqueue() {
      return { startedTurn: false, queue: [] };
    },
    closeConversation() {
      return { abortedTurn: false };
    },
    stopTurn() {
      return { abortedTurn: false };
    },
    async handleMessage(input) {
      state.received = input;
    },
  };
}

function createThrowingOrchestrator(error: Error): SessionOrchestrator {
  return {
    startTurn() {
      return { started: true, turnId: "fake-turn" };
    },
    subscribe() {
      return () => {};
    },
    isActive() {
      return false;
    },
    enqueue() {
      return { startedTurn: false, queue: [] };
    },
    closeConversation() {
      return { abortedTurn: false };
    },
    stopTurn() {
      return { abortedTurn: false };
    },
    async handleMessage() {
      throw error;
    },
  };
}

function createFakeCredentialStore(models: string[]): CredentialStore {
  return {
    resolve() {
      return undefined;
    },
    async getModelInfo() {
      return undefined;
    },
    async listCatalog() {
      return models;
    },
  };
}

function createThrowingCredentialStore(error: Error): CredentialStore {
  return {
    resolve() {
      return undefined;
    },
    async getModelInfo() {
      return undefined;
    },
    async listCatalog() {
      throw error;
    },
  };
}

function createFakeWarmService(
  result:
    | {
        inputTokens: number;
        outputTokens: number;
        cacheReadTokens: number;
        cacheWriteTokens: number;
      }
    | { error: string },
): WarmService {
  return {
    async warm() {
      return result;
    },
  };
}

function createFakeLspService(
  statuses: readonly {
    readonly id: string;
    readonly name: string;
    readonly root: string;
    readonly extensions: readonly string[];
    readonly state: "connected" | "starting" | "error" | "not-started";
    readonly error?: string;
    readonly configSource?: string;
  }[] = [],
): LspService {
  return {
    async status() {
      return statuses;
    },
  };
}

function createCapturingLspService(
  statuses: readonly {
    readonly id: string;
    readonly name: string;
    readonly root: string;
    readonly extensions: readonly string[];
    readonly state: "connected" | "starting" | "error" | "not-started";
    readonly error?: string;
    readonly configSource?: string;
  }[] = [],
): LspService & { readonly statusCalls: readonly string[] } {
  const calls: string[] = [];
  return {
    get statusCalls() {
      return calls;
    },
    async status(cwd) {
      calls.push(cwd);
      return statuses;
    },
  };
}

function createFakeMcpService(
  statuses: readonly {
    readonly id: string;
    readonly state: "connecting" | "connected" | "error" | "disconnected";
    readonly error?: string;
    readonly toolCount: number;
  }[] = [],
): McpService {
  return {
    async status() {
      return statuses;
    },
  };
}

function createCapturingMcpService(
  statuses: readonly {
    readonly id: string;
    readonly state: "connecting" | "connected" | "error" | "disconnected";
    readonly error?: string;
    readonly toolCount: number;
  }[] = [],
): McpService & { readonly statusCalls: readonly string[] } {
  const calls: string[] = [];
  return {
    get statusCalls() {
      return calls;
    },
    async status(cwd) {
      calls.push(cwd);
      return statuses;
    },
  };
}

function createFakeSystemPromptService(
  template: string = "custom template",
): SystemPromptService & {
  readonly setTemplateCalls: readonly string[];
  readonly getTemplateCalls: number;
} {
  const setCalls: string[] = [];
  let getTemplateCount = 0;
  let currentTemplate = template;
  return {
    get setTemplateCalls() {
      return setCalls;
    },
    get getTemplateCalls() {
      return getTemplateCount;
    },
    async construct() {
      return currentTemplate;
    },
    async get() {
      return currentTemplate;
    },
    async getTemplate() {
      getTemplateCount++;
      return currentTemplate;
    },
    async setTemplate(t) {
      setCalls.push(t);
      currentTemplate = t;
    },
  };
}

function createFakeComputerService(computers: readonly ComputerEntry[] = []): ComputerService {
  const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c]));
  return {
    async listComputers() {
      return computers;
    },
    async getComputer(alias) {
      return byAlias.get(alias) ?? null;
    },
    async getStatus(alias) {
      const known = byAlias.has(alias);
      return { alias, state: "disconnected", knownHost: known };
    },
    async test(alias) {
      return byAlias.has(alias)
        ? { alias, ok: true }
        : { alias, ok: false, error: "Computer not found" };
    },
  };
}

/**
 * A minimal HeartbeatService fake for the next-run route: only `nextRunAt` is
 * exercised by the route; the rest return inert defaults so the object
 * satisfies the interface without dragging in real stores/scheduler.
 */
function createFakeHeartbeatService(nextRunAt: string | null): HeartbeatService {
  return {
    getConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
    updateConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
    listRuns: async () => [],
    stopRun: async () => ({ ok: true }),
    startAll: async () => {},
    stopAll: () => {},
    nextRunAt: async () => nextRunAt,
  };
}

const noopLogger = createFakeLogger();

describe("GET /health", () => {
  it("returns ok", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/health");
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body).toEqual({ ok: true });
  });
});

describe("GET /models", () => {
  it("returns model catalog", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]),
      logger: noopLogger,
    });
    const res = await app.request("/models");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { models: readonly string[] };
    expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]);
  });

  it("returns empty array when no models", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/models");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { models: readonly string[] };
    expect(body.models).toEqual([]);
  });

  it("returns 502 when listCatalog throws", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createThrowingCredentialStore(new Error("db down")),
      logger: noopLogger,
    });
    const res = await app.request("/models");
    expect(res.status).toBe(502);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("Failed to retrieve model catalog");
  });
});

describe("POST /chat", () => {
  it("returns 400 for invalid JSON", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: "not json",
    });
    expect(res.status).toBe(400);
  });

  it("returns 400 for missing message", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });
    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ conversationId: "c1" }),
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("message");
  });

  it("returns 400 for empty message", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });
    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "" }),
    });
    expect(res.status).toBe(400);
  });

  it("streams events as NDJSON", async () => {
    const events: AgentEvent[] = [
      { type: "turn-start", conversationId: "tab1", turnId: "turn1" },
      { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: "Hello" },
      { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" },
      { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
    ];
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator(events),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
    });

    expect(res.status).toBe(200);
    expect(res.headers.get("Content-Type")).toBe("application/x-ndjson");
    expect(res.headers.get("X-Conversation-Id")).toBe("conv1");

    const text = await res.text();
    const lines = text.trim().split("\n");
    expect(lines).toHaveLength(4);

    const parsed = lines.map((line) => JSON.parse(line) as AgentEvent);
    expect(parsed[0]?.type).toBe("turn-start");
    expect(parsed[1]?.type).toBe("text-delta");
    expect((parsed[1] as { delta: string }).delta).toBe("Hello");
    expect(parsed[2]?.type).toBe("text-delta");
    expect(parsed[3]?.type).toBe("done");
  });

  it("generates conversationId when not provided", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([
        { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
      ]),
      credentialStore: createFakeCredentialStore([]),
      generateId: () => "generated-uuid",
    });

    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "hi" }),
    });

    expect(res.status).toBe(200);
    expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid");
  });

  it("emits error event when orchestrator throws", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createThrowingOrchestrator(new Error("provider unavailable")),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
    });

    expect(res.status).toBe(200);
    const text = await res.text();
    const lines = text.trim().split("\n");
    expect(lines.length).toBeGreaterThanOrEqual(1);

    const lastLine = lines[lines.length - 1];
    if (!lastLine) throw new Error("expected at least one line");
    const lastEvent = JSON.parse(lastLine) as AgentEvent;
    expect(lastEvent.type).toBe("error");
    if (lastEvent.type === "error") {
      expect(lastEvent.message).toContain("provider unavailable");
    }
  });

  it("handles empty event list", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "hi" }),
    });

    expect(res.status).toBe(200);
    const text = await res.text();
    expect(text).toBe("");
  });

  it("forwards modelName and cwd to orchestrator", async () => {
    const cap = createCapturingOrchestrator();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: cap,
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        message: "hi",
        conversationId: "conv1",
        model: "opencode/m1",
        cwd: "/tmp",
      }),
    });

    expect(res.status).toBe(200);
    expect(cap.received).toBeDefined();
    expect(cap.received?.conversationId).toBe("conv1");
    expect(cap.received?.text).toBe("hi");
    expect(cap.received?.modelName).toBe("opencode/m1");
    expect(cap.received?.cwd).toBe("/tmp");
  });

  it("omits modelName and cwd when not provided", async () => {
    const cap = createCapturingOrchestrator();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: cap,
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
    });

    expect(res.status).toBe(200);
    expect(cap.received).toBeDefined();
    expect(cap.received?.modelName).toBeUndefined();
    expect(cap.received?.cwd).toBeUndefined();
  });
});

describe("POST /chat/warm", () => {
  it("POST /chat/warm returns 200 with cachePct from the warm usage", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      warmService: createFakeWarmService({
        inputTokens: 1000,
        outputTokens: 200,
        cacheReadTokens: 800,
        cacheWriteTokens: 100,
      }),
      logger: noopLogger,
    });

    const res = await app.request("/chat/warm", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ conversationId: "conv1" }),
    });

    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      inputTokens: number;
      outputTokens: number;
      cacheReadTokens: number;
      cacheWriteTokens: number;
      cachePct: number;
      expectedCacheRate: number;
    };
    expect(body.inputTokens).toBe(1000);
    expect(body.outputTokens).toBe(200);
    expect(body.cacheReadTokens).toBe(800);
    expect(body.cacheWriteTokens).toBe(100);
    expect(body.cachePct).toBe(80);
    expect(body.expectedCacheRate).toBe(89);
  });

  it("POST /chat/warm returns expectedCacheRate = round(cacheRead/(cacheRead+cacheWrite)*100)", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      warmService: createFakeWarmService({
        inputTokens: 500,
        outputTokens: 100,
        cacheReadTokens: 400,
        cacheWriteTokens: 100,
      }),
      logger: noopLogger,
    });

    const res = await app.request("/chat/warm", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ conversationId: "conv1" }),
    });

    expect(res.status).toBe(200);
    const body = (await res.json()) as { expectedCacheRate: number };
    expect(body.expectedCacheRate).toBe(80);
  });

  it("POST /chat/warm returns expectedCacheRate = 0 when cacheRead+cacheWrite is 0", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      warmService: createFakeWarmService({
        inputTokens: 100,
        outputTokens: 50,
        cacheReadTokens: 0,
        cacheWriteTokens: 0,
      }),
      logger: noopLogger,
    });

    const res = await app.request("/chat/warm", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ conversationId: "conv1" }),
    });

    expect(res.status).toBe(200);
    const body = (await res.json()) as { expectedCacheRate: number };
    expect(body.expectedCacheRate).toBe(0);
  });

  it("POST /chat/warm returns 409 when the warm service reports the conversation is generating", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      warmService: createFakeWarmService({ error: "conversation is generating" }),
      logger: noopLogger,
    });

    const res = await app.request("/chat/warm", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ conversationId: "conv1" }),
    });

    expect(res.status).toBe(409);
    const body = (await res.json()) as { error: string };
    expect(body.error).toBe("conversation is generating");
  });

  it("POST /chat/warm returns 400 when conversationId is missing", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      warmService: createFakeWarmService({
        inputTokens: 0,
        outputTokens: 0,
        cacheReadTokens: 0,
        cacheWriteTokens: 0,
      }),
      logger: noopLogger,
    });

    const res = await app.request("/chat/warm", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });

    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("conversationId");
  });
});

describe("GET /conversations/:id", () => {
  const sampleChunks: StoredChunk[] = [
    { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
    { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
    { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } },
    { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } },
  ];

  it("returns the full seq-ordered StoredChunk history", async () => {
    const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
    const app = createApp({
      conversationStore: createFakeConversationStore(store),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/conversations/conv1");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    expect(body.chunks).toHaveLength(4);
    expect(body.chunks[0]?.seq).toBe(1);
    expect(body.chunks[3]?.seq).toBe(4);
    expect(body.latestSeq).toBe(4);
  });

  it("returns only chunks with seq > N and latestSeq = last seq", async () => {
    const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
    const app = createApp({
      conversationStore: createFakeConversationStore(store),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/conversations/conv1?sinceSeq=2");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    expect(body.chunks).toHaveLength(2);
    expect(body.chunks[0]?.seq).toBe(3);
    expect(body.chunks[1]?.seq).toBe(4);
    expect(body.latestSeq).toBe(4);
  });

  it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => {
    const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
    const app = createApp({
      conversationStore: createFakeConversationStore(store),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/conversations/conv1?sinceSeq=4");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    expect(body.chunks).toHaveLength(0);
    expect(body.latestSeq).toBe(4);
  });

  it("returns empty chunks and latestSeq 0 for unknown conversation", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/conversations/unknown");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    expect(body.chunks).toHaveLength(0);
    expect(body.latestSeq).toBe(0);
  });

  it("returns 400 for invalid sinceSeq", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/conversations/conv1?sinceSeq=abc");
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("sinceSeq");
  });

  it("returns 400 for negative sinceSeq", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/conversations/conv1?sinceSeq=-1");
    expect(res.status).toBe(400);
  });

  const sixChunks: StoredChunk[] = [
    { seq: 1, role: "user", chunk: { type: "text", text: "one" } },
    { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } },
    { seq: 3, role: "user", chunk: { type: "text", text: "three" } },
    { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } },
    { seq: 5, role: "user", chunk: { type: "text", text: "five" } },
    { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } },
  ];

  function appWithChunks(chunks: StoredChunk[]) {
    const store = new Map<string, StoredChunk[]>([["conv1", chunks]]);
    return createApp({
      conversationStore: createFakeConversationStore(store),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });
  }

  it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => {
    const app = appWithChunks(sixChunks);
    const res = await app.request("/conversations/conv1?limit=2");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]);
    expect(body.latestSeq).toBe(6);
  });

  it("?limit=N with N >= conversation size returns the full log", async () => {
    const app = appWithChunks(sixChunks);
    const res = await app.request("/conversations/conv1?limit=10");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]);
    expect(body.latestSeq).toBe(6);
  });

  it("?beforeSeq=S returns only chunks with seq < S", async () => {
    const app = appWithChunks(sixChunks);
    const res = await app.request("/conversations/conv1?beforeSeq=3");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]);
    expect(body.latestSeq).toBe(2);
  });

  it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => {
    const app = appWithChunks(sixChunks);
    const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    // selection = seq 1..4; newest 2 = [3, 4]
    expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
    expect(body.latestSeq).toBe(4);
  });

  it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => {
    const app = appWithChunks(sixChunks);
    const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
    expect(body.latestSeq).toBe(4);
  });

  describe("window param validation → 400 and store not called with an invalid window", () => {
    function appCapturingWindow() {
      const calls: {
        readonly sinceSeq: number | undefined;
        readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined;
      }[] = [];
      const store: ConversationStore = {
        async append() {},
        async load() {
          return [];
        },
        async loadSince(_conversationId, sinceSeq, window) {
          calls.push({ sinceSeq, window });
          return [];
        },
        async appendMetrics() {},
        async loadMetrics() {
          return [];
        },
        async getCwd() {
          return null;
        },
        async setCwd() {},
        async clearCwd() {},
        async getReasoningEffort() {
          return null;
        },
        async setReasoningEffort() {},
        async listConversations() {
          return [];
        },
        async getConversationMeta() {
          return null;
        },
        async setConversationTitle() {},
        async getConversationStatus() {
          return null;
        },
        async setConversationStatus() {},
        async replaceHistory() {},
        async getCompactPercent() {
          return null;
        },
        async setCompactPercent() {},
        async forkHistory() {},
        async setCompactedFrom() {},
        async getWorkspace() {
          return null;
        },
        async ensureWorkspace() {
          return {
            id: "default",
            title: "default",
            defaultCwd: null,
            defaultComputerId: null,
            createdAt: 0,
            lastActivityAt: 0,
          };
        },
        async setWorkspaceTitle() {
          return {
            id: "default",
            title: "default",
            defaultCwd: null,
            defaultComputerId: null,
            createdAt: 0,
            lastActivityAt: 0,
          };
        },
        async setWorkspaceDefaultCwd() {
          return {
            id: "default",
            title: "default",
            defaultCwd: null,
            defaultComputerId: null,
            createdAt: 0,
            lastActivityAt: 0,
          };
        },
        async deleteWorkspace() {
          return { closedCount: 0 };
        },
        async listWorkspaces() {
          return [];
        },
        async getWorkspaceId() {
          return "default";
        },
        async setWorkspaceId() {},
        async getEffectiveCwd() {
          return null;
        },
      };
      const app = createApp({
        conversationStore: store,
        orchestrator: createFakeOrchestrator([]),
        credentialStore: createFakeCredentialStore([]),
      });
      return { app, calls };
    }

    const cases: readonly { readonly name: string; readonly query: string }[] = [
      { name: "limit=0", query: "limit=0" },
      { name: "limit=-1", query: "limit=-1" },
      { name: "limit=abc", query: "limit=abc" },
      { name: "beforeSeq=0", query: "beforeSeq=0" },
      { name: "beforeSeq=1.5", query: "beforeSeq=1.5" },
    ];

    for (const { name, query } of cases) {
      it(`${name} → 400 { error } and loadSince is never called`, async () => {
        const { app, calls } = appCapturingWindow();
        const res = await app.request(`/conversations/conv1?${query}`);
        expect(res.status).toBe(400);
        const body = (await res.json()) as { error: string };
        expect(typeof body.error).toBe("string");
        expect(body.error.length).toBeGreaterThan(0);
        expect(calls).toHaveLength(0);
      });
    }
  });

  it("no params → byte-identical to the no-window read (regression guard)", async () => {
    // Rest-param spy: record how many args the route actually passes, so we
    // can prove the third (window) arg is OMITTED entirely — not merely
    // forwarded as undefined — preserving the existing two-arg call shape.
    const argCounts: number[] = [];
    const store: ConversationStore = {
      async append() {},
      async load() {
        return [];
      },
      loadSince(...args: Parameters<ConversationStore["loadSince"]>) {
        argCounts.push(args.length);
        const sinceSeq = args[1] ?? 0;
        return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq));
      },
      async appendMetrics() {},
      async loadMetrics() {
        return [];
      },
      async getCwd() {
        return null;
      },
      async setCwd() {},
      async clearCwd() {},
      async getReasoningEffort() {
        return null;
      },
      async setReasoningEffort() {},
      async listConversations() {
        return [];
      },
      async getConversationMeta() {
        return null;
      },
      async setConversationTitle() {},
      async getConversationStatus() {
        return null;
      },
      async setConversationStatus() {},
      async replaceHistory() {},
      async getCompactPercent() {
        return null;
      },
      async setCompactPercent() {},
      async forkHistory() {},
      async setCompactedFrom() {},
      async getWorkspace() {
        return null;
      },
      async ensureWorkspace() {
        return {
          id: "default",
          title: "default",
          defaultCwd: null,
          defaultComputerId: null,
          createdAt: 0,
          lastActivityAt: 0,
        };
      },
      async setWorkspaceTitle() {
        return {
          id: "default",
          title: "default",
          defaultCwd: null,
          defaultComputerId: null,
          createdAt: 0,
          lastActivityAt: 0,
        };
      },
      async setWorkspaceDefaultCwd() {
        return {
          id: "default",
          title: "default",
          defaultCwd: null,
          defaultComputerId: null,
          createdAt: 0,
          lastActivityAt: 0,
        };
      },
      async deleteWorkspace() {
        return { closedCount: 0 };
      },
      async listWorkspaces() {
        return [];
      },
      async getWorkspaceId() {
        return "default";
      },
      async setWorkspaceId() {},
      async getEffectiveCwd() {
        return null;
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/conversations/conv1");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
    expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4]);
    expect(body.latestSeq).toBe(4);
    // Called once, with exactly two arguments (no window arg).
    expect(argCounts).toEqual([2]);
  });
});

describe("GET /conversations/:id/metrics", () => {
  const sampleMetrics: TurnMetrics[] = [
    {
      turnId: "turn1",
      usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
      durationMs: 1000,
      steps: [
        {
          stepId: "step1" as StepId,
          usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
          ttftMs: 200,
          decodeMs: 300,
          genTotalMs: 500,
        },
      ],
    },
    {
      turnId: "turn2",
      usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
      durationMs: 1500,
      steps: [
        {
          stepId: "step2" as StepId,
          usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
          ttftMs: 300,
          decodeMs: 500,
          genTotalMs: 800,
        },
      ],
    },
  ];

  it("returns persisted turn metrics as { turns }", async () => {
    const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
    const app = createApp({
      conversationStore: createFakeConversationStore(new Map(), metricsStore),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/conversations/conv1/metrics");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { turns: readonly TurnMetrics[] };
    expect(body.turns).toHaveLength(2);
    expect(body.turns[0]?.turnId).toBe("turn1");
    expect(body.turns[1]?.turnId).toBe("turn2");
  });

  it("returns { turns: [] } for an unknown conversation", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/conversations/unknown/metrics");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { turns: readonly TurnMetrics[] };
    expect(body.turns).toHaveLength(0);
  });

  it("the metrics route does not collide with GET /conversations/:id history route", async () => {
    const sampleChunks: StoredChunk[] = [
      { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
    ];
    const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
    const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
    const app = createApp({
      conversationStore: createFakeConversationStore(store, metricsStore),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const metricsRes = await app.request("/conversations/conv1/metrics");
    expect(metricsRes.status).toBe(200);
    const metricsBody = (await metricsRes.json()) as { turns: readonly TurnMetrics[] };
    expect(metricsBody.turns).toHaveLength(2);

    const historyRes = await app.request("/conversations/conv1");
    expect(historyRes.status).toBe(200);
    const historyBody = (await historyRes.json()) as {
      chunks: readonly StoredChunk[];
      latestSeq: number;
    };
    expect(historyBody.chunks).toHaveLength(1);
  });

  it("a store failure on the metrics read returns an error status + logs an error", async () => {
    const logger = createFakeLogger();
    const brokenStore: ConversationStore = {
      async append() {},
      async load() {
        return [];
      },
      async loadSince() {
        return [];
      },
      async appendMetrics() {},
      async loadMetrics() {
        throw new Error("storage exploded");
      },
      async getCwd() {
        return null;
      },
      async setCwd() {},
      async clearCwd() {},
      async getReasoningEffort() {
        return null;
      },
      async setReasoningEffort() {},
      async listConversations() {
        return [];
      },
      async getConversationMeta() {
        return null;
      },
      async setConversationTitle() {},
      async getConversationStatus() {
        return null;
      },
      async setConversationStatus() {},
      async replaceHistory() {},
      async getCompactPercent() {
        return null;
      },
      async setCompactPercent() {},
      async forkHistory() {},
      async setCompactedFrom() {},
      async getWorkspace() {
        return null;
      },
      async ensureWorkspace() {
        return {
          id: "default",
          title: "default",
          defaultCwd: null,
          defaultComputerId: null,
          createdAt: 0,
          lastActivityAt: 0,
        };
      },
      async setWorkspaceTitle() {
        return {
          id: "default",
          title: "default",
          defaultCwd: null,
          defaultComputerId: null,
          createdAt: 0,
          lastActivityAt: 0,
        };
      },
      async setWorkspaceDefaultCwd() {
        return {
          id: "default",
          title: "default",
          defaultCwd: null,
          defaultComputerId: null,
          createdAt: 0,
          lastActivityAt: 0,
        };
      },
      async deleteWorkspace() {
        return { closedCount: 0 };
      },
      async listWorkspaces() {
        return [];
      },
      async getWorkspaceId() {
        return "default";
      },
      async setWorkspaceId() {},
      async getEffectiveCwd() {
        return null;
      },
    };
    const app = createApp({
      conversationStore: brokenStore,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger,
    });

    const res = await app.request("/conversations/conv1/metrics");
    expect(res.status).toBe(500);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("Failed to load conversation metrics");

    const errorLogs = logger.records.filter((r) => r.level === "error");
    expect(errorLogs).toHaveLength(1);
    expect(errorLogs[0]?.msg).toBe("conversations: metrics store failure");
    expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
  });
});

describe("POST /chat logging", () => {
  it("POST /chat logs an info line when a request is accepted", async () => {
    const logger = createFakeLogger();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([
        { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
      ]),
      credentialStore: createFakeCredentialStore([]),
      logger,
    });

    await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        message: "hi",
        conversationId: "conv1",
        model: "opencode/m1",
        cwd: "/tmp",
      }),
    });

    const infoLogs = logger.records.filter((r) => r.level === "info");
    expect(infoLogs).toHaveLength(1);
    expect(infoLogs[0]?.msg).toBe("chat: request accepted");
    expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
    expect(infoLogs[0]?.attrs?.hasModel).toBe(true);
    expect(infoLogs[0]?.attrs?.hasCwd).toBe(true);
  });

  it("POST /chat logs a warn on a malformed body (400)", async () => {
    const logger = createFakeLogger();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger,
    });

    await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: "not json",
    });

    const warnLogs = logger.records.filter((r) => r.level === "warn");
    expect(warnLogs.length).toBeGreaterThanOrEqual(1);
    expect(warnLogs[0]?.msg).toBe("chat: invalid JSON body");
  });

  it("POST /chat logs an error when the turn fails", async () => {
    const logger = createFakeLogger();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createThrowingOrchestrator(new Error("boom")),
      credentialStore: createFakeCredentialStore([]),
      logger,
    });

    await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
    });

    const errorLogs = logger.records.filter((r) => r.level === "error");
    expect(errorLogs).toHaveLength(1);
    expect(errorLogs[0]?.msg).toBe("chat: turn failed");
    expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
  });
});

describe("GET /conversations/:id logging", () => {
  it("GET /conversations/:id logs the read (conversationId + sinceSeq + count)", async () => {
    const logger = createFakeLogger();
    const sampleChunks: StoredChunk[] = [
      { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
      { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
    ];
    const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
    const app = createApp({
      conversationStore: createFakeConversationStore(store),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger,
    });

    await app.request("/conversations/conv1?sinceSeq=0");

    const infoLogs = logger.records.filter((r) => r.level === "info");
    expect(infoLogs).toHaveLength(1);
    expect(infoLogs[0]?.msg).toBe("conversations: read");
    expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
    expect(infoLogs[0]?.attrs?.sinceSeq).toBe(0);
    expect(infoLogs[0]?.attrs?.count).toBe(2);
  });
});

describe("CORS", () => {
  function createTestApp() {
    return createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([
        { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
      ]),
      credentialStore: createFakeCredentialStore(["opencode/m1"]),
    });
  }

  it("POST /chat response carries Access-Control-Allow-Origin: *", async () => {
    const app = createTestApp();
    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
    });
    expect(res.status).toBe(200);
    expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
  });

  it("GET /models response carries the CORS headers", async () => {
    const app = createTestApp();
    const res = await app.request("/models");
    expect(res.status).toBe(200);
    expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
    expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
  });

  it("GET /conversations/:id response carries the CORS headers", async () => {
    const app = createTestApp();
    const res = await app.request("/conversations/conv1");
    expect(res.status).toBe(200);
    expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
    expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
  });

  it("OPTIONS preflight for /chat returns 204 with Allow-Methods + Allow-Headers", async () => {
    const app = createTestApp();
    const res = await app.request("/chat", { method: "OPTIONS" });
    expect(res.status).toBe(204);
    expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
    expect(res.headers.get("Access-Control-Allow-Methods")).toContain("GET");
    expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST");
    expect(res.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS");
    expect(res.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type");
  });
});

describe("throughput recording + GET /metrics/throughput", () => {
  const ts = new Date(2026, 5, 10, 12, 0, 0).getTime();
  const day = dayKeyOf(ts);

  function appWith(
    throughputStore: ReturnType<typeof createThroughputStore>,
    events: AgentEvent[],
  ) {
    return createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator(events),
      credentialStore: createFakeCredentialStore([]),
      throughputStore,
      now: () => ts,
    });
  }

  async function postChat(app: ReturnType<typeof createApp>, body: Record<string, unknown>) {
    return app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
  }

  it("records a per-model sample from a turn and aggregates it (token-weighted tok/s)", async () => {
    const store = createThroughputStore({ storage: createMemStorage() });
    const events: AgentEvent[] = [
      {
        type: "step-complete",
        conversationId: "c1",
        turnId: "t1",
        stepId: "t1#0" as StepId,
        genTotalMs: 2000,
      },
      {
        type: "done",
        conversationId: "c1",
        turnId: "t1",
        reason: "stop",
        usage: { inputTokens: 10, outputTokens: 400 },
      },
    ];
    const app = appWith(store, events);

    const chat = await postChat(app, {
      conversationId: "c1",
      message: "hi",
      model: "claude/haiku",
    });
    expect(chat.status).toBe(200);

    const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
    expect(res.status).toBe(200);
    const report = (await res.json()) as ThroughputResponse;
    expect(report.period).toBe("day");
    expect(report.models).toHaveLength(1);
    expect(report.models[0]).toMatchObject({
      model: "claude/haiku",
      totalOutputTokens: 400,
      totalGenMs: 2000,
      tokensPerSecond: 200, // 400 tokens / 2s
      turns: 1,
    });
  });

  it("does not record a sample when no model is selected", async () => {
    const store = createThroughputStore({ storage: createMemStorage() });
    const events: AgentEvent[] = [
      {
        type: "step-complete",
        conversationId: "c1",
        turnId: "t1",
        stepId: "t1#0" as StepId,
        genTotalMs: 2000,
      },
      {
        type: "done",
        conversationId: "c1",
        turnId: "t1",
        reason: "stop",
        usage: { inputTokens: 1, outputTokens: 5 },
      },
    ];
    const app = appWith(store, events);

    await postChat(app, { conversationId: "c1", message: "hi" }); // no model
    const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
    const report = (await res.json()) as { models: unknown[] };
    expect(report.models).toEqual([]);
  });

  it("returns 400 for an invalid period", async () => {
    const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
    const res = await app.request("/metrics/throughput?period=year&date=2026");
    expect(res.status).toBe(400);
  });

  it("returns 400 for a malformed date", async () => {
    const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
    const res = await app.request("/metrics/throughput?period=day&date=nope");
    expect(res.status).toBe(400);
  });

  it("returns 400 when date is missing", async () => {
    const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
    const res = await app.request("/metrics/throughput?period=day");
    expect(res.status).toBe(400);
  });
});

describe("POST /conversations/:id/close", () => {
  it("closes via the orchestrator and returns CloseConversationResponse", async () => {
    const closeCalls: string[] = [];
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      closeConversation(conversationId) {
        closeCalls.push(conversationId);
        return { abortedTurn: true };
      },
    };
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv-9/close", { method: "POST" });
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true });
    expect(closeCalls).toEqual(["conv-9"]);
  });

  it("reports abortedTurn false for an idle conversation", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv-idle/close", { method: "POST" });
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false });
  });
});

describe("POST /conversations/:id/queue", () => {
  it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => {
    const queue: readonly QueuedMessage[] = [
      { id: "q1", text: "queued-msg", queuedAt: 1700000000000 },
    ];
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      enqueue() {
        return { startedTurn: false, queue };
      },
    };
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "hello" }),
    });

    expect(res.status).toBe(200);
    const body = (await res.json()) as QueueResponse;
    expect(body.conversationId).toBe("conv1");
    expect(body.startedTurn).toBe(false);
    expect(body.queue).toEqual(queue);
  });

  it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => {
    let enqueueCalled = false;
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      enqueue() {
        enqueueCalled = true;
        return { startedTurn: false, queue: [] };
      },
    };
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "   " }),
    });

    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("text");
    expect(enqueueCalled).toBe(false);
  });

  it("with missing text field → 400 { error } and enqueue is never called", async () => {
    let enqueueCalled = false;
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      enqueue() {
        enqueueCalled = true;
        return { startedTurn: false, queue: [] };
      },
    };
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });

    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("text");
    expect(enqueueCalled).toBe(false);
  });

  it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => {
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      enqueue() {
        return { startedTurn: true, queue: [] };
      },
    };
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv-idle/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "go" }),
    });

    expect(res.status).toBe(200);
    const body = (await res.json()) as QueueResponse;
    expect(body.conversationId).toBe("conv-idle");
    expect(body.startedTurn).toBe(true);
    expect(body.queue).toEqual([]);
  });

  it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => {
    const queue: readonly QueuedMessage[] = [
      { id: "q1", text: "second", queuedAt: 1700000000000 },
      { id: "q2", text: "third", queuedAt: 1700000001000 },
    ];
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      enqueue() {
        return { startedTurn: false, queue };
      },
    };
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv-active/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "steer" }),
    });

    expect(res.status).toBe(200);
    const body = (await res.json()) as QueueResponse;
    expect(body.conversationId).toBe("conv-active");
    expect(body.startedTurn).toBe(false);
    expect(body.queue).toEqual(queue);
  });

  it("forwards the path conversationId and trimmed text to enqueue", async () => {
    const calls: { conversationId: string; text: string }[] = [];
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      enqueue(input) {
        calls.push(input);
        return { startedTurn: false, queue: [] };
      },
    };
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv-1/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "  hello world  " }),
    });

    expect(res.status).toBe(200);
    expect(calls).toHaveLength(1);
    expect(calls[0]?.conversationId).toBe("conv-1");
    expect(calls[0]?.text).toBe("hello world");
  });

  it("returns 400 for invalid JSON body", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: "not json",
    });

    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("JSON");
  });

  it("returns 400 for a non-string text", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: 42 }),
    });

    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("text");
  });

  it("logs an info line on success and never logs the enqueued text", async () => {
    const logger = createFakeLogger();
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      enqueue() {
        return { startedTurn: true, queue: [] };
      },
    };
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
      logger,
    });

    await app.request("/conversations/conv1/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "secret-ish user message" }),
    });

    const infoLogs = logger.records.filter((r) => r.level === "info");
    expect(infoLogs).toHaveLength(1);
    expect(infoLogs[0]?.msg).toBe("conversations: enqueued");
    expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
    expect(infoLogs[0]?.attrs?.startedTurn).toBe(true);
    expect(infoLogs[0]?.attrs?.queueLength).toBe(0);
    // Restraint: the user's message text is never logged (mirrors POST /chat).
    expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message");
  });

  it("logs a warn on a malformed body (400)", async () => {
    const logger = createFakeLogger();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger,
    });

    await app.request("/conversations/conv1/queue", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: "" }),
    });

    const warnLogs = logger.records.filter((r) => r.level === "warn");
    expect(warnLogs.length).toBeGreaterThanOrEqual(1);
    expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed");
  });
});

describe("GET /conversations/:id/cwd", () => {
  it("returns null when unset", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/cwd");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; cwd: string | null };
    expect(body.conversationId).toBe("conv1");
    expect(body.cwd).toBeNull();
  });
});

describe("PUT then GET /conversations/:id/cwd", () => {
  it("round-trips the value", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const putRes = await app.request("/conversations/conv1/cwd", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ cwd: "/home/user/project" }),
    });
    expect(putRes.status).toBe(200);
    const putBody = (await putRes.json()) as { conversationId: string; cwd: string };
    expect(putBody.conversationId).toBe("conv1");
    expect(putBody.cwd).toBe("/home/user/project");

    const getRes = await app.request("/conversations/conv1/cwd");
    expect(getRes.status).toBe(200);
    const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
    expect(getBody.cwd).toBe("/home/user/project");
  });
});

describe("DELETE /conversations/:id/cwd", () => {
  it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const putRes = await app.request("/conversations/conv1/cwd", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ cwd: "/home/user/project" }),
    });
    expect(putRes.status).toBe(200);

    const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
    expect(deleteRes.status).toBe(200);
    const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
    expect(deleteBody.conversationId).toBe("conv1");
    expect(deleteBody.cwd).toBeNull();

    const getRes = await app.request("/conversations/conv1/cwd");
    expect(getRes.status).toBe(200);
    const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
    expect(getBody.cwd).toBeNull();
  });

  it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
    expect(deleteRes.status).toBe(200);
    const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
    expect(deleteBody.conversationId).toBe("conv1");
    expect(deleteBody.cwd).toBeNull();
  });

  it("does NOT affect other conversations' cwds (isolation)", async () => {
    const cwdStore = new Map<string, string>([
      ["conv1", "/home/user/project"],
      ["conv2", "/other/path"],
    ]);
    const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
    expect(deleteRes.status).toBe(200);

    const get1Res = await app.request("/conversations/conv1/cwd");
    expect(get1Res.status).toBe(200);
    const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null };
    expect(get1Body.cwd).toBeNull();

    const get2Res = await app.request("/conversations/conv2/cwd");
    expect(get2Res.status).toBe(200);
    const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null };
    expect(get2Body.cwd).toBe("/other/path");
  });
});

describe("PUT /conversations/:id/cwd", () => {
  it("with missing cwd returns 400", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/cwd", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("cwd");
  });

  it("with empty cwd returns 400", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/cwd", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ cwd: "" }),
    });
    expect(res.status).toBe(400);
  });

  it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => {
    const base = createFakeConversationStore();
    const store = createCallTrackingStore(base);
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/cwd", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; cwd: string };
    expect(body.cwd).toBe("/home/user/project");
    // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd.
    expect(store.calls).toEqual([
      "ensureWorkspace:my-team",
      "setWorkspaceId:my-team",
      "setCwd:/home/user/project",
    ]);
  });

  it("PUT cwd without workspaceId: only setCwd", async () => {
    const base = createFakeConversationStore();
    const store = createCallTrackingStore(base);
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/cwd", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ cwd: "/home/user/project" }),
    });
    expect(res.status).toBe(200);
    // ensureWorkspace and setWorkspaceId NOT called.
    expect(store.calls).toEqual(["setCwd:/home/user/project"]);
  });

  it("PUT cwd with invalid workspaceId: returns 400", async () => {
    const base = createFakeConversationStore();
    const store = createCallTrackingStore(base);
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    // Uppercase is not a valid workspace slug.
    const res = await app.request("/conversations/conv1/cwd", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }),
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toBe("Invalid workspaceId");
    // No mutating calls should have been made.
    expect(store.calls).toEqual([]);

    // Empty string is also invalid.
    const res2 = await app.request("/conversations/conv1/cwd", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }),
    });
    expect(res2.status).toBe(400);
  });
});

describe("GET /conversations/:id/lsp", () => {
  it("returns empty servers when cwd is unset", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      lspService: createFakeLspService(),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/lsp");
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      conversationId: string;
      cwd: string | null;
      servers: readonly unknown[];
    };
    expect(body.conversationId).toBe("conv1");
    expect(body.cwd).toBeNull();
    expect(body.servers).toEqual([]);
  });

  it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => {
    const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
    const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
    const lspStatuses = [
      {
        id: "typescript",
        name: "TypeScript",
        root: "/home/user/project",
        extensions: [".ts", ".tsx"],
        state: "connected" as const,
      },
      {
        id: "lua-lsp",
        name: "Lua LSP",
        root: "/home/user/project",
        extensions: [".luau"],
        state: "error" as const,
        error: "spawn failed",
      },
    ];
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      lspService: createFakeLspService(lspStatuses),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/lsp");
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      conversationId: string;
      cwd: string | null;
      servers: readonly {
        readonly id: string;
        readonly name: string;
        readonly root: string;
        readonly extensions: readonly string[];
        readonly state: string;
        readonly error?: string;
      }[];
    };
    expect(body.conversationId).toBe("conv1");
    expect(body.cwd).toBe("/home/user/project");
    expect(body.servers).toHaveLength(2);
    expect(body.servers[0]?.id).toBe("typescript");
    expect(body.servers[0]?.state).toBe("connected");
    expect(body.servers[0]?.error).toBeUndefined();
    expect(body.servers[1]?.id).toBe("lua-lsp");
    expect(body.servers[1]?.state).toBe("error");
    expect(body.servers[1]?.error).toBe("spawn failed");
  });

  it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => {
    const cwdStore = new Map<string, string>(); // no persisted cwd
    const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
    const lsp = createCapturingLspService([
      {
        id: "typescript",
        name: "TypeScript",
        root: "/irrelevant",
        extensions: [".ts"],
        state: "connected" as const,
      },
    ]);
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      lspService: lsp,
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/lsp");
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      conversationId: string;
      cwd: string | null;
      servers: readonly unknown[];
    };
    expect(body.conversationId).toBe("conv1");
    expect(body.cwd).toBeNull();
    expect(body.servers).toEqual([]);
    expect(lsp.statusCalls).toEqual([]); // status NOT called
  });

  it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
    // Persisted (relative) cwd differs from the resolved effective cwd.
    const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
    const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
    // Override getEffectiveCwd to return the resolved (absolute) value.
    const resolvedStore: ConversationStore = {
      ...store,
      async getEffectiveCwd() {
        return "/workspace/subdir";
      },
    };
    const lsp = createCapturingLspService([
      {
        id: "typescript",
        name: "TypeScript",
        root: "/workspace/subdir",
        extensions: [".ts", ".tsx"],
        state: "connected" as const,
      },
    ]);
    const app = createApp({
      conversationStore: resolvedStore,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      lspService: lsp,
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/lsp");
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      conversationId: string;
      cwd: string | null;
      servers: readonly { readonly id: string }[];
    };
    expect(body.conversationId).toBe("conv1");
    expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
    expect(lsp.statusCalls).toEqual(["/workspace/subdir"]);
    expect(body.servers).toHaveLength(1);
    expect(body.servers[0]?.id).toBe("typescript");
  });

  it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => {
    const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
    const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
    // Case 1: configSource is defined → reaches the wire verbatim.
    const lspWithSource = createFakeLspService([
      {
        id: "typescript",
        name: "TypeScript",
        root: "/home/user/project",
        extensions: [".ts", ".tsx"],
        state: "connected" as const,
        configSource: ".dispatch/lsp.json",
      },
    ]);
    const appWithSource = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      lspService: lspWithSource,
      logger: noopLogger,
    });
    const resWithSource = await appWithSource.request("/conversations/conv1/lsp");
    expect(resWithSource.status).toBe(200);
    const bodyWithSource = (await resWithSource.json()) as {
      conversationId: string;
      cwd: string | null;
      servers: readonly {
        readonly id: string;
        readonly configSource?: string;
      }[];
    };
    expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json");

    // Case 2: configSource is undefined → the field is OMITTED from the
    // response (proves exactOptionalPropertyTypes is respected — never
    // stamping `undefined` onto the wire object).
    const lspWithoutSource = createFakeLspService([
      {
        id: "typescript",
        name: "TypeScript",
        root: "/home/user/project",
        extensions: [".ts", ".tsx"],
        state: "connected" as const,
      },
    ]);
    const appWithoutSource = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      lspService: lspWithoutSource,
      logger: noopLogger,
    });
    const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp");
    expect(resWithoutSource.status).toBe(200);
    const bodyWithoutSource = (await resWithoutSource.json()) as {
      conversationId: string;
      cwd: string | null;
      servers: readonly Record<string, unknown>[];
    };
    expect(bodyWithoutSource.servers).toHaveLength(1);
    expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource");
  });
});

describe("GET /conversations/:id/mcp", () => {
  it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => {
    const cwdStore = new Map<string, string>(); // no persisted cwd
    const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
    const mcp = createCapturingMcpService([
      {
        id: "freecad",
        state: "connected" as const,
        toolCount: 3,
      },
    ]);
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      mcpService: mcp,
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/mcp");
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      conversationId: string;
      cwd: string | null;
      servers: readonly unknown[];
    };
    expect(body.conversationId).toBe("conv1");
    expect(body.cwd).toBeNull();
    expect(body.servers).toEqual([]);
    expect(mcp.statusCalls).toEqual([]); // status NOT called
  });

  it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => {
    const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
    const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
    const mcpStatuses = [
      {
        id: "freecad",
        state: "connected" as const,
        toolCount: 5,
      },
      {
        id: "broken",
        state: "error" as const,
        toolCount: 0,
        error: "spawn failed",
      },
    ];
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      mcpService: createFakeMcpService(mcpStatuses),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/mcp");
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      conversationId: string;
      cwd: string | null;
      servers: readonly {
        readonly id: string;
        readonly state: string;
        readonly toolCount: number;
        readonly error?: string;
      }[];
    };
    expect(body.conversationId).toBe("conv1");
    expect(body.cwd).toBe("/home/user/project");
    expect(body.servers).toHaveLength(2);
    expect(body.servers[0]?.id).toBe("freecad");
    expect(body.servers[0]?.state).toBe("connected");
    expect(body.servers[0]?.toolCount).toBe(5);
    expect(body.servers[0]?.error).toBeUndefined();
    expect(body.servers[1]?.id).toBe("broken");
    expect(body.servers[1]?.state).toBe("error");
    expect(body.servers[1]?.toolCount).toBe(0);
    expect(body.servers[1]?.error).toBe("spawn failed");
  });

  it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
    const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
    const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
    const resolvedStore: ConversationStore = {
      ...store,
      async getEffectiveCwd() {
        return "/workspace/subdir";
      },
    };
    const mcp = createCapturingMcpService([
      {
        id: "freecad",
        state: "connected" as const,
        toolCount: 2,
      },
    ]);
    const app = createApp({
      conversationStore: resolvedStore,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      mcpService: mcp,
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/mcp");
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      conversationId: string;
      cwd: string | null;
      servers: readonly { readonly id: string }[];
    };
    expect(body.conversationId).toBe("conv1");
    expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
    expect(mcp.statusCalls).toEqual(["/workspace/subdir"]);
    expect(body.servers).toHaveLength(1);
    expect(body.servers[0]?.id).toBe("freecad");
  });

  it("MCP: returns 503 when mcpService is undefined", async () => {
    const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
    const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      // mcpService intentionally omitted
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/mcp");
    expect(res.status).toBe(503);
    const body = (await res.json()) as { error: string };
    expect(body.error).toBe("MCP service not available");
  });
});

describe("POST /chat reasoningEffort", () => {
  const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];

  for (const level of allLevels) {
    it(`forwards reasoningEffort="${level}" to orchestrator`, async () => {
      const cap = createCapturingOrchestrator();
      const app = createApp({
        conversationStore: createFakeConversationStore(),
        orchestrator: cap,
        credentialStore: createFakeCredentialStore([]),
      });

      const res = await app.request("/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          message: "hi",
          conversationId: "conv1",
          reasoningEffort: level,
        }),
      });

      expect(res.status).toBe(200);
      expect(cap.received).toBeDefined();
      expect(cap.received?.reasoningEffort).toBe(level);
    });
  }

  it("omits reasoningEffort from orchestrator input when not provided", async () => {
    const cap = createCapturingOrchestrator();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: cap,
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
    });

    expect(res.status).toBe(200);
    expect(cap.received).toBeDefined();
    expect(cap.received?.reasoningEffort).toBeUndefined();
  });

  it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => {
    let handleMessageCalled = false;
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      async handleMessage(input) {
        handleMessageCalled = true;
        return createFakeOrchestrator([]).handleMessage(input);
      },
    };
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        message: "hi",
        conversationId: "conv1",
        reasoningEffort: "banana",
      }),
    });

    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("reasoningEffort");
    expect(handleMessageCalled).toBe(false);
  });

  it("returns 400 for non-string reasoningEffort", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
    });

    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        message: "hi",
        conversationId: "conv1",
        reasoningEffort: 42,
      }),
    });

    expect(res.status).toBe(400);
  });
});

describe("GET /conversations/:id/reasoning-effort", () => {
  it("returns null when never set", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/reasoning-effort");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
    expect(body.conversationId).toBe("conv1");
    expect(body.reasoningEffort).toBeNull();
  });

  it("returns the level after PUT", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    await app.request("/conversations/conv1/reasoning-effort", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ reasoningEffort: "xhigh" }),
    });

    const res = await app.request("/conversations/conv1/reasoning-effort");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
    expect(body.reasoningEffort).toBe("xhigh");
  });

  it("returns null for an unknown conversation", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/unknown/reasoning-effort");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
    expect(body.conversationId).toBe("unknown");
    expect(body.reasoningEffort).toBeNull();
  });
});

describe("PUT /conversations/:id/reasoning-effort", () => {
  it("persists a valid level and returns it", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/reasoning-effort", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ reasoningEffort: "low" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; reasoningEffort: string };
    expect(body.conversationId).toBe("conv1");
    expect(body.reasoningEffort).toBe("low");
  });

  it("returns 400 for an invalid level", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/reasoning-effort", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ reasoningEffort: "banana" }),
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("reasoningEffort");
  });

  it("returns 400 when reasoningEffort is missing from body", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/reasoning-effort", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
  });

  it("returns 400 for invalid JSON body", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/reasoning-effort", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: "not json",
    });
    expect(res.status).toBe(400);
  });

  it("does not call store on validation failure", async () => {
    let storeCalled = false;
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async setReasoningEffort() {
        storeCalled = true;
      },
      async listConversations() {
        return [];
      },
      async getConversationMeta() {
        return null;
      },
      async setConversationTitle() {},
      async getConversationStatus() {
        return null;
      },
      async setConversationStatus() {},
      async replaceHistory() {},
      async getCompactPercent() {
        return null;
      },
      async setCompactPercent() {},
      async forkHistory() {},
      async setCompactedFrom() {},
      async getWorkspace() {
        return null;
      },
      async ensureWorkspace() {
        return {
          id: "default",
          title: "default",
          defaultCwd: null,
          defaultComputerId: null,
          createdAt: 0,
          lastActivityAt: 0,
        };
      },
      async setWorkspaceTitle() {
        return {
          id: "default",
          title: "default",
          defaultCwd: null,
          defaultComputerId: null,
          createdAt: 0,
          lastActivityAt: 0,
        };
      },
      async setWorkspaceDefaultCwd() {
        return {
          id: "default",
          title: "default",
          defaultCwd: null,
          defaultComputerId: null,
          createdAt: 0,
          lastActivityAt: 0,
        };
      },
      async deleteWorkspace() {
        return { closedCount: 0 };
      },
      async listWorkspaces() {
        return [];
      },
      async getWorkspaceId() {
        return "default";
      },
      async setWorkspaceId() {},
      async getEffectiveCwd() {
        return null;
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/reasoning-effort", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ reasoningEffort: "invalid" }),
    });
    expect(res.status).toBe(400);
    expect(storeCalled).toBe(false);
  });
});

describe("GET /conversations/:id/model", () => {
  it("returns null when never set", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/model");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; model: string | null };
    expect(body.conversationId).toBe("conv1");
    expect(body.model).toBeNull();
  });

  it("returns the model after PUT", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    await app.request("/conversations/conv1/model", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
    });

    const res = await app.request("/conversations/conv1/model");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; model: string | null };
    expect(body.model).toBe("umans/umans-glm-5.2");
  });

  it("returns null for an unknown conversation", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/unknown/model");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; model: string | null };
    expect(body.conversationId).toBe("unknown");
    expect(body.model).toBeNull();
  });
});

describe("PUT /conversations/:id/model", () => {
  it("persists a non-empty model and returns it", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/model", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; model: string | null };
    expect(body.conversationId).toBe("conv1");
    expect(body.model).toBe("umans/umans-glm-5.2");

    // A subsequent GET reflects the persisted value.
    const getRes = await app.request("/conversations/conv1/model");
    const getBody = (await getRes.json()) as { model: string | null };
    expect(getBody.model).toBe("umans/umans-glm-5.2");
  });

  it("clears the model when model is null and GET returns null", async () => {
    const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
    const store = createFakeConversationStore(
      new Map(),
      new Map(),
      new Map(),
      new Map(),
      modelStore,
    );
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    // Preconditions: a model is set.
    const before = await app.request("/conversations/conv1/model");
    expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2");

    const res = await app.request("/conversations/conv1/model", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ model: null }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; model: string | null };
    expect(body.model).toBeNull();

    const getRes = await app.request("/conversations/conv1/model");
    const getBody = (await getRes.json()) as { model: string | null };
    expect(getBody.model).toBeNull();
  });

  it("clears the model when model is an empty string and GET returns null", async () => {
    const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
    const store = createFakeConversationStore(
      new Map(),
      new Map(),
      new Map(),
      new Map(),
      modelStore,
    );
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/model", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ model: "" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; model: string | null };
    expect(body.model).toBeNull();

    const getRes = await app.request("/conversations/conv1/model");
    const getBody = (await getRes.json()) as { model: string | null };
    expect(getBody.model).toBeNull();
  });

  it("returns 400 for invalid JSON body", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/model", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: "not json",
    });
    expect(res.status).toBe(400);
  });

  it("returns 400 when model field is missing", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/model", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("model");
  });

  it("returns 400 when model is a non-string non-null type", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/model", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ model: 42 }),
    });
    expect(res.status).toBe(400);
  });

  it("does not call store on validation failure", async () => {
    let storeCalled = false;
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async setModel() {
        storeCalled = true;
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/model", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
    expect(storeCalled).toBe(false);
  });
});

describe("GET /conversations", () => {
  const sampleConvos: ConversationMeta[] = [
    {
      id: "conv-1",
      createdAt: 1000,
      lastActivityAt: 2000,
      title: "First",
      status: "idle",
      workspaceId: "default",
    },
    {
      id: "conv-2",
      createdAt: 1500,
      lastActivityAt: 2500,
      title: "Second",
      status: "idle",
      workspaceId: "default",
    },
    {
      id: "other-1",
      createdAt: 3000,
      lastActivityAt: 4000,
      title: "Other",
      status: "idle",
      workspaceId: "default",
    },
  ];

  function appWithList(list: ConversationMeta[]) {
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async listConversations() {
        return list;
      },
    };
    return createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
  }

  it("returns 200 with list", async () => {
    const app = appWithList(sampleConvos);
    const res = await app.request("/conversations");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversations: ConversationMeta[] };
    expect(body.conversations).toHaveLength(3);
    expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2", "other-1"]);
  });

  it("?q= filters by id prefix", async () => {
    const app = appWithList(sampleConvos);
    const res = await app.request("/conversations?q=conv-");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversations: ConversationMeta[] };
    expect(body.conversations).toHaveLength(2);
    expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2"]);
  });

  it("?q= returns all when q is empty", async () => {
    const app = appWithList(sampleConvos);
    const res = await app.request("/conversations?q=");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversations: ConversationMeta[] };
    expect(body.conversations).toHaveLength(3);
  });

  it("?q= with whitespace-only returns all (trimmed to empty)", async () => {
    const app = appWithList(sampleConvos);
    const res = await app.request("/conversations?q=%20%20%20");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversations: ConversationMeta[] };
    expect(body.conversations).toHaveLength(3);
  });

  it("returns 500 when listConversations throws", async () => {
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async listConversations() {
        throw new Error("db down");
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations");
    expect(res.status).toBe(500);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("Failed to list conversations");
  });
});

describe("GET /conversations/:id/last", () => {
  function appWithMessages(messagesByConv: Map<string, ChatMessage[]>) {
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async load(conversationId) {
        return messagesByConv.get(conversationId) ?? [];
      },
    };
    return createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
  }

  it("returns last assistant text", async () => {
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "hello" }] },
      { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
      { role: "user", chunks: [{ type: "text", text: "more" }] },
      { role: "assistant", chunks: [{ type: "text", text: "final reply" }] },
    ];
    const app = appWithMessages(new Map([["conv1", messages]]));
    const res = await app.request("/conversations/conv1/last");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
    expect(body.conversationId).toBe("conv1");
    expect(body.content).toBe("final reply");
    expect(body.turnId).toBeUndefined();
  });

  it("returns empty content for unknown conversation", async () => {
    const app = appWithMessages(new Map());
    const res = await app.request("/conversations/unknown/last");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
    expect(body.conversationId).toBe("unknown");
    expect(body.content).toBe("");
    expect(body.turnId).toBeUndefined();
  });

  it("blocks until turn settles", async () => {
    const turnId = "sealed-turn";
    const orchestrator: SessionOrchestrator = {
      ...createFakeOrchestrator([]),
      subscribe(conversationId, listener) {
        const event = { type: "turn-sealed" as const, conversationId, turnId };
        setTimeout(() => listener(event), 0);
        return () => {};
      },
      isActive() {
        return true;
      },
    };
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "hello" }] },
      { role: "assistant", chunks: [{ type: "text", text: "after seal" }] },
    ];
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async load() {
        return messages;
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator,
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const res = await app.request("/conversations/conv1/last");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
    expect(body.conversationId).toBe("conv1");
    expect(body.content).toBe("after seal");
    expect(body.turnId).toBe(turnId);
  });
});

describe("POST /conversations/:id/open", () => {
  it("returns 200", async () => {
    const emit: HostAPI["emit"] = () => {};
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      emit,
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/open", { method: "POST" });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string };
    expect(body.conversationId).toBe("conv1");
  });

  it("calls emit with conversationOpened", async () => {
    const emitCalls: Array<{ readonly hook: unknown; readonly payload: unknown }> = [];
    const emit: HostAPI["emit"] = (hook, payload) => {
      emitCalls.push({ hook, payload });
    };
    // A store whose getWorkspaceId returns a non-default id, so the test
    // proves the handler resolves and forwards the PERSISTED workspace id
    // (not a hard-coded "default").
    const store = createFakeConversationStore();
    store.getWorkspaceId = async () => "open-workspace";
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      emit,
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/open", { method: "POST" });
    expect(res.status).toBe(200);
    expect(emitCalls).toHaveLength(1);
    expect(emitCalls[0]?.hook).toBe(conversationOpened);
    expect(emitCalls[0]?.payload).toEqual({
      conversationId: "conv1",
      workspaceId: "open-workspace",
    });
  });

  it("returns 500 when emit is absent", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/open", { method: "POST" });
    expect(res.status).toBe(500);
    const body = (await res.json()) as { error: string };
    expect(body.error).toBe("not available");
  });
});

describe("PUT /conversations/:id/title", () => {
  it("returns 200 with title", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/title", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "My Conversation" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; title: string };
    expect(body.conversationId).toBe("conv1");
    expect(body.title).toBe("My Conversation");
  });

  it("rejects empty title with 400", async () => {
    let setTitleCalled = false;
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async setConversationTitle() {
        setTitleCalled = true;
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/title", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "   " }),
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("title");
    expect(setTitleCalled).toBe(false);
  });

  it("returns 400 when title is missing", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/title", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("title");
  });

  it("forwards the trimmed title to setConversationTitle", async () => {
    const calls: { conversationId: string; title: string }[] = [];
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async setConversationTitle(conversationId, title) {
        calls.push({ conversationId, title });
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/title", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "  trimmed title  " }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { conversationId: string; title: string };
    expect(body.title).toBe("trimmed title");
    expect(calls).toHaveLength(1);
    expect(calls[0]?.conversationId).toBe("conv1");
    expect(calls[0]?.title).toBe("trimmed title");
  });

  it("returns 400 for invalid JSON body", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/title", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: "not json",
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("JSON");
  });
});

describe("extractLastAssistantText", () => {
  it("returns last assistant text chunk", () => {
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "hello" }] },
      { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
      { role: "user", chunks: [{ type: "text", text: "how are you?" }] },
      { role: "assistant", chunks: [{ type: "text", text: "I'm good!" }] },
    ];
    expect(extractLastAssistantText(messages)).toBe("I'm good!");
  });

  it("returns empty string when no assistant message", () => {
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "hello" }] },
      { role: "system", chunks: [{ type: "text", text: "system prompt" }] },
    ];
    expect(extractLastAssistantText(messages)).toBe("");
  });

  it("returns the LAST text chunk when an assistant message has multiple", () => {
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "hello" }] },
      {
        role: "assistant",
        chunks: [
          { type: "text", text: "first" },
          { type: "thinking", text: "internal reasoning" },
          { type: "text", text: "second" },
        ],
      },
    ];
    expect(extractLastAssistantText(messages)).toBe("second");
  });

  it("returns empty string when the last assistant message has no text chunk", () => {
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "hello" }] },
      {
        role: "assistant",
        chunks: [
          {
            type: "tool-call",
            toolCallId: "tc1",
            toolName: "read_file",
            input: { path: "/tmp" },
          },
        ],
      },
    ];
    expect(extractLastAssistantText(messages)).toBe("");
  });

  it("returns empty string for an empty message list", () => {
    expect(extractLastAssistantText([])).toBe("");
  });
});

describe("Workspaces", () => {
  const sampleWorkspace: Workspace = {
    id: "proj",
    title: "proj",
    defaultCwd: null,
    defaultComputerId: null,
    createdAt: 1000,
    lastActivityAt: 2000,
  };

  it("GET /workspaces returns list", async () => {
    const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }];
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async listWorkspaces() {
        return workspaceEntries;
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces");
    expect(res.status).toBe(200);
    const body = (await res.json()) as WorkspaceListResponse;
    expect(body.workspaces).toEqual(workspaceEntries);
  });

  it("PUT /workspaces/:id creates on miss", async () => {
    let ensured = false;
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async ensureWorkspace(id, opts) {
        ensured = true;
        return { ...sampleWorkspace, id, ...opts };
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/proj", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }),
    });
    expect(res.status).toBe(200);
    expect(ensured).toBe(true);
    const body = (await res.json()) as WorkspaceResponse;
    expect(body.id).toBe("proj");
    expect(body.title).toBe("Project");
    expect(body.defaultCwd).toBe("/home/proj");
  });

  it("PUT /workspaces/:id returns existing", async () => {
    const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" };
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async ensureWorkspace() {
        return existing;
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/proj", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "New Title" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as WorkspaceResponse;
    expect(body.title).toBe("Existing");
    expect(body.defaultCwd).toBe("/old");
  });

  it("PUT /workspaces/:id rejects invalid slug", async () => {
    let ensured = false;
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async ensureWorkspace() {
        ensured = true;
        return sampleWorkspace;
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/Bad Slug!", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
    expect(ensured).toBe(false);
  });

  it("GET /workspaces/:id returns 404 for missing", async () => {
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async getWorkspace() {
        return null;
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/unknown");
    expect(res.status).toBe(404);
  });

  it("PUT /workspaces/:id/title renames", async () => {
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async setWorkspaceTitle(id, title) {
        return { ...sampleWorkspace, id, title };
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/proj/title", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Renamed" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as WorkspaceResponse;
    expect(body.title).toBe("Renamed");
  });

  it("PUT /workspaces/:id/default-cwd sets", async () => {
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async setWorkspaceDefaultCwd(id, defaultCwd) {
        return { ...sampleWorkspace, id, defaultCwd };
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/proj/default-cwd", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ defaultCwd: "/new/cwd" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as WorkspaceResponse;
    expect(body.defaultCwd).toBe("/new/cwd");
  });

  it("DELETE /workspaces/:id closes conversations", async () => {
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async deleteWorkspace() {
        return { closedCount: 3 };
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/proj", { method: "DELETE" });
    expect(res.status).toBe(200);
    const body = (await res.json()) as DeleteWorkspaceResponse;
    expect(body.workspaceId).toBe("proj");
    expect(body.closedCount).toBe(3);
  });

  it("DELETE /workspaces/default returns 409", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/default", { method: "DELETE" });
    expect(res.status).toBe(409);
  });
});

it("POST /chat threads workspaceId", async () => {
  const cap = createCapturingOrchestrator();
  const app = createApp({
    conversationStore: createFakeConversationStore(),
    orchestrator: cap,
    credentialStore: createFakeCredentialStore([]),
  });
  const res = await app.request("/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }),
  });
  expect(res.status).toBe(200);
  expect(cap.received).toBeDefined();
  expect(cap.received?.workspaceId).toBe("proj");
});

it("GET /conversations?workspaceId= filters", async () => {
  const calls: Parameters<ConversationStore["listConversations"]>[0][] = [];
  const store: ConversationStore = {
    ...createFakeConversationStore(),
    async listConversations(filter) {
      calls.push(filter);
      return [];
    },
  };
  const app = createApp({
    conversationStore: store,
    orchestrator: createFakeOrchestrator([]),
    credentialStore: createFakeCredentialStore([]),
    logger: noopLogger,
  });
  const res = await app.request("/conversations?workspaceId=proj");
  expect(res.status).toBe(200);
  expect(calls).toHaveLength(1);
  expect(calls[0]).toEqual({ workspaceId: "proj" });
});

it("GET /conversations/:id/lsp uses effective cwd", async () => {
  let effectiveCwdCalled = false;
  let getCwdCalled = false;
  let lspCwd: string | null = null;
  const store: ConversationStore = {
    ...createFakeConversationStore(),
    async getEffectiveCwd(_conversationId) {
      effectiveCwdCalled = true;
      return "/effective";
    },
    async getCwd(_conversationId) {
      getCwdCalled = true;
      return "/explicit";
    },
  };
  const lsp: LspService = {
    async status(cwd) {
      lspCwd = cwd;
      return [];
    },
  };
  const app = createApp({
    conversationStore: store,
    orchestrator: createFakeOrchestrator([]),
    credentialStore: createFakeCredentialStore([]),
    lspService: lsp,
    logger: noopLogger,
  });
  const res = await app.request("/conversations/conv1/lsp");
  expect(res.status).toBe(200);
  expect(effectiveCwdCalled).toBe(true);
  expect(getCwdCalled).toBe(true); // gated on persisted cwd first
  expect(lspCwd).toBe("/effective");
  const body = (await res.json()) as {
    conversationId: string;
    cwd: string | null;
    servers: readonly unknown[];
  };
  expect(body.cwd).toBe("/effective");
});

describe("GET /system-prompt", () => {
  it("returns stored template", async () => {
    const service = createFakeSystemPromptService("custom template");
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      systemPromptService: service,
      logger: noopLogger,
    });
    const res = await app.request("/system-prompt");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { template: string };
    expect(body.template).toBe("custom template");
    expect(service.getTemplateCalls).toBe(1);
  });

  it("returns default when service unavailable", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/system-prompt");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { template: string };
    expect(body.template).toBe(DEFAULT_TEMPLATE);
  });
});

describe("PUT /system-prompt", () => {
  it("sets template", async () => {
    const service = createFakeSystemPromptService();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      systemPromptService: service,
      logger: noopLogger,
    });
    const res = await app.request("/system-prompt", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ template: "new" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { template: string };
    expect(body.template).toBe("new");
    expect(service.setTemplateCalls).toEqual(["new"]);
  });

  it("missing template → 400", async () => {
    const service = createFakeSystemPromptService();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      systemPromptService: service,
      logger: noopLogger,
    });
    const res = await app.request("/system-prompt", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
    expect(service.setTemplateCalls).toEqual([]);
  });

  it("service unavailable → 503", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/system-prompt", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ template: "new" }),
    });
    expect(res.status).toBe(503);
    const body = (await res.json()) as { error: string };
    expect(body.error).toBe("System prompt service not available");
  });
});

describe("GET /system-prompt/variables", () => {
  it("returns catalog", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/system-prompt/variables");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { variables: readonly SystemPromptVariable[] };
    expect(Array.isArray(body.variables)).toBe(true);
    // Contains at least system:time, prompt:cwd, and a dynamic file:<path>.
    const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time");
    const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd");
    const fileEntry = body.variables.find((v) => v.type === "file");
    expect(hasSystemTime).toBe(true);
    expect(hasPromptCwd).toBe(true);
    expect(fileEntry).toBeDefined();
    expect(fileEntry?.dynamic).toBe(true);
  });
});

// ─── Computers (mirrors the cwd / workspace routes) ─────────────────────────

const sampleComputer: Computer = {
  alias: "myserver",
  hostName: "10.0.0.5",
  port: 22,
  user: "deploy",
  identityFile: "/home/user/.ssh/id_ed25519",
  knownHost: true,
};

describe("GET /computers", () => {
  it("returns [] when no ComputerService is wired (graceful degrade)", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/computers");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { computers: readonly ComputerEntry[] };
    expect(body.computers).toEqual([]);
  });

  it("delegates to the ComputerService when wired", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]),
      logger: noopLogger,
    });
    const res = await app.request("/computers");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { computers: readonly ComputerEntry[] };
    expect(body.computers).toHaveLength(1);
    expect(body.computers[0]?.alias).toBe("myserver");
    expect(body.computers[0]?.usageCount).toBe(2);
  });
});

describe("GET /computers/:alias", () => {
  it("returns the computer when the alias is configured", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]),
      logger: noopLogger,
    });
    const res = await app.request("/computers/myserver");
    expect(res.status).toBe(200);
    const body = (await res.json()) as Computer;
    expect(body.alias).toBe("myserver");
    expect(body.hostName).toBe("10.0.0.5");
  });

  it("returns 404 when the alias is not in the config", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      computerService: createFakeComputerService([]),
      logger: noopLogger,
    });
    const res = await app.request("/computers/unknown");
    expect(res.status).toBe(404);
  });

  it("returns 404 when no ComputerService is wired (no ssh)", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/computers/myserver");
    expect(res.status).toBe(404);
  });
});

describe("GET /computers/:alias/status", () => {
  it("returns disconnected + knownHost:false when no ComputerService is wired", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/computers/myserver/status");
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      alias: string;
      state: string;
      knownHost: boolean;
    };
    expect(body.alias).toBe("myserver");
    expect(body.state).toBe("disconnected");
    expect(body.knownHost).toBe(false);
  });
});

describe("POST /computers/:alias/test", () => {
  it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/computers/myserver/test", { method: "POST" });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { alias: string; ok: boolean; error?: string };
    expect(body.alias).toBe("myserver");
    expect(body.ok).toBe(false);
    expect(body.error).toBe("SSH not configured");
  });
});

describe("GET then PUT then GET /conversations/:id/computer", () => {
  it("round-trips the value", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const get0 = await app.request("/conversations/conv1/computer");
    expect(get0.status).toBe(200);
    const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null };
    expect(get0Body.conversationId).toBe("conv1");
    expect(get0Body.computerId).toBeNull();

    const putRes = await app.request("/conversations/conv1/computer", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ computerId: "myserver" }),
    });
    expect(putRes.status).toBe(200);
    const putBody = (await putRes.json()) as { conversationId: string; computerId: string };
    expect(putBody.conversationId).toBe("conv1");
    expect(putBody.computerId).toBe("myserver");

    const getRes = await app.request("/conversations/conv1/computer");
    expect(getRes.status).toBe(200);
    const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null };
    expect(getBody.computerId).toBe("myserver");
  });
});

describe("PUT /conversations/:id/computer with null clears (→ DELETE parity)", () => {
  it("PUT null clears a previously-set computer", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const putRes = await app.request("/conversations/conv1/computer", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ computerId: "myserver" }),
    });
    expect(putRes.status).toBe(200);

    const clearRes = await app.request("/conversations/conv1/computer", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ computerId: null }),
    });
    expect(clearRes.status).toBe(200);
    const clearBody = (await clearRes.json()) as {
      conversationId: string;
      computerId: string | null;
    };
    expect(clearBody.computerId).toBeNull();

    const getRes = await app.request("/conversations/conv1/computer");
    expect(getRes.status).toBe(200);
    const getBody = (await getRes.json()) as { computerId: string | null };
    expect(getBody.computerId).toBeNull();
  });
});

describe("DELETE /conversations/:id/computer", () => {
  it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => {
    const store = createFakeConversationStore();
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const putRes = await app.request("/conversations/conv1/computer", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ computerId: "myserver" }),
    });
    expect(putRes.status).toBe(200);

    const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
    expect(deleteRes.status).toBe(200);
    const deleteBody = (await deleteRes.json()) as {
      conversationId: string;
      computerId: string | null;
    };
    expect(deleteBody.conversationId).toBe("conv1");
    expect(deleteBody.computerId).toBeNull();

    const getRes = await app.request("/conversations/conv1/computer");
    expect(getRes.status).toBe(200);
    const getBody = (await getRes.json()) as { computerId: string | null };
    expect(getBody.computerId).toBeNull();
  });

  it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
    expect(deleteRes.status).toBe(200);
    const deleteBody = (await deleteRes.json()) as {
      conversationId: string;
      computerId: string | null;
    };
    expect(deleteBody.computerId).toBeNull();
  });

  it("does NOT affect other conversations' computers (isolation)", async () => {
    const computerStore = new Map<string, string>([
      ["conv1", "myserver"],
      ["conv2", "otherbox"],
    ]);
    const store = createFakeConversationStore(
      new Map(),
      new Map(),
      new Map(),
      new Map(),
      new Map(),
      computerStore,
    );
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });

    const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
    expect(deleteRes.status).toBe(200);

    const get1 = await app.request("/conversations/conv1/computer");
    expect(get1.status).toBe(200);
    expect((await get1.json()).computerId).toBeNull();

    const get2 = await app.request("/conversations/conv2/computer");
    expect(get2.status).toBe(200);
    expect((await get2.json()).computerId).toBe("otherbox");
  });
});

describe("PUT /conversations/:id/computer validation", () => {
  it("with missing computerId returns 400", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/computer", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
    const body = (await res.json()) as { error: string };
    expect(body.error).toContain("computerId");
  });

  it("with empty-string computerId returns 400", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/conversations/conv1/computer", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ computerId: "" }),
    });
    expect(res.status).toBe(400);
  });
});

describe("PUT /workspaces/:id/default-computer", () => {
  const wsSample: Workspace = {
    id: "proj",
    title: "proj",
    defaultCwd: null,
    defaultComputerId: null,
    createdAt: 1000,
    lastActivityAt: 2000,
  };

  it("sets the default computer", async () => {
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async setWorkspaceDefaultComputerId(id, defaultComputerId) {
        return { ...wsSample, id, defaultComputerId };
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/proj/default-computer", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ computerId: "myserver" }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as WorkspaceResponse;
    expect(body.defaultComputerId).toBe("myserver");
  });

  it("clears the default computer with null", async () => {
    const store: ConversationStore = {
      ...createFakeConversationStore(),
      async setWorkspaceDefaultComputerId(id, defaultComputerId) {
        return { ...wsSample, id, defaultComputerId };
      },
    };
    const app = createApp({
      conversationStore: store,
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/proj/default-computer", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ computerId: null }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as WorkspaceResponse;
    expect(body.defaultComputerId).toBeNull();
  });
});

describe("POST /chat threads computerId", () => {
  it("forwards computerId into the orchestrator input when present", async () => {
    const cap = createCapturingOrchestrator();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: cap,
      credentialStore: createFakeCredentialStore([]),
    });
    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        message: "hi",
        conversationId: "conv1",
        computerId: "myserver",
      }),
    });
    expect(res.status).toBe(200);
    expect(cap.received).toBeDefined();
    expect(cap.received?.conversationId).toBe("conv1");
    expect(cap.received?.computerId).toBe("myserver");
  });

  it("omits computerId when not provided", async () => {
    const cap = createCapturingOrchestrator();
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: cap,
      credentialStore: createFakeCredentialStore([]),
    });
    const res = await app.request("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
    });
    expect(res.status).toBe(200);
    expect(cap.received).toBeDefined();
    expect(cap.received?.computerId).toBeUndefined();
  });
});

describe("GET /workspaces/:id/heartbeat/next-run", () => {
  it("returns { nextRunAt: null } when no HeartbeatService is wired (graceful degrade)", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/ws-1/heartbeat/next-run");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { nextRunAt: string | null };
    expect(body.nextRunAt).toBeNull();
  });

  it("delegates to the HeartbeatService and returns the next-run ISO timestamp", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      heartbeatService: createFakeHeartbeatService("2026-06-25T14:05:00Z"),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/ws-1/heartbeat/next-run");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { nextRunAt: string | null };
    expect(body.nextRunAt).toBe("2026-06-25T14:05:00Z");
  });

  it("returns { nextRunAt: null } when the service reports no scheduled run", async () => {
    const app = createApp({
      conversationStore: createFakeConversationStore(),
      orchestrator: createFakeOrchestrator([]),
      credentialStore: createFakeCredentialStore([]),
      heartbeatService: createFakeHeartbeatService(null),
      logger: noopLogger,
    });
    const res = await app.request("/workspaces/ws-1/heartbeat/next-run");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { nextRunAt: string | null };
    expect(body.nextRunAt).toBeNull();
  });
});
