import type {
  Project,
  AvailableProject,
  Session,
  Chat,
  Message,
  ModelInfo,
  ModelCatalogResponse,
  SidebarResponse,
  ListDirResult,
  ViewFileResult,
  AgentsResponse,
  GitMeta,
  GitDiffMode,
  GitDiffResult,
  GitDiscardFileInfo,
  Skill,
  ToolCostStat,
  ProviderSnapshotEntry,
  CoderProvidersFile,
  ProviderConfigPatch,
  CoderSendMessageBody,
  CoderSendMessageResponse,
  CoderMessageWire,
  CoderTaskDetail,
  PermissionPrompt,
  AgentCommand,
  WorkspaceState,
  FlowRunRow,
  FlowStepRow,
  BattleShape,
  ContestantShape,
  CrossExaminationShape,
  AnalyticsSummary,
  SessionAnalyticsRow,
  ContextWindowStats,
  TokenBreakdownAgg,
  ToolTraceResponse,
  MemoryEntry,
  DailyMemoryEntry,
  DreamEntry,
} from './types';

// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
// GET /api/coder/sessions/:id/agent-sessions; drives the AgentComposerBar
// resumed/new-session chip via useAgentSessions. `has_session` is true when a
// resumable backend session id exists for that agent in the chat.
export interface AgentSessionInfo {
  agent: string;
  status: string;
  has_session: boolean;
  last_active_at: string | null;
  // v2.6.8 per-(chat,agent) running token/cost totals (sampling-streamjson-tokens
  // #8). input_tokens/output_tokens are BIGINT and may arrive as strings; cost is
  // DOUBLE. AgentComposerBar coerces with Number(...) before rendering.
  input_tokens: number;
  output_tokens: number;
  cost: number;
}

// write-edit-robustness #4: a pre-turn worktree snapshot anchored to an
// assistant message. Returned by GET .../checkpoints; drives the per-message
// "Restore to here" affordance in CoderMessageList.
export interface CoderCheckpoint {
  id: string;
  message_id: string;
  created_at: string;
  label: string | null;
}

// write-edit-robustness #4: result of POST .../checkpoints/:id/restore.
export interface CoderRestoreResult {
  checkpoint_id: string;
  messages_deleted: number;
  worktree_reset: boolean;
  backend_reset: boolean;
}

export class ApiError extends Error {
  constructor(
    public status: number,
    public body: unknown
  ) {
    super(typeof body === 'object' && body && 'error' in body ? String((body as { error: unknown }).error) : `HTTP ${status}`);
  }
}

async function request<T>(
  path: string,
  init: RequestInit = {}
): Promise<T> {
  const res = await fetch(path, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      ...(init.headers ?? {}),
    },
  });
  if (res.status === 204) return undefined as T;
  const text = await res.text();
  const data = text ? JSON.parse(text) : undefined;
  if (!res.ok) throw new ApiError(res.status, data);
  return data as T;
}

export const api = {
  health: () => request<{ status: string; db: boolean }>('/api/health'),

  projects: {
    list: (params?: { status?: 'open' | 'archived' }) =>
      request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
    available: () => request<AvailableProject[]>('/api/projects/available'),
    add: (body: { path: string; name?: string }) =>
      request<Project>('/api/projects', {
        method: 'POST',
        body: JSON.stringify(body),
      }),
    update: (
      id: string,
      body: Partial<Pick<Project, 'name' | 'default_system_prompt' | 'default_web_search_enabled'>>,
    ) =>
      request<Project>(`/api/projects/${id}`, {
        method: 'PATCH',
        body: JSON.stringify(body),
      }),
    get: (id: string) => request<Project>(`/api/projects/${id}`),
    archive: (id: string) =>
      request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
    unarchive: (id: string) =>
      request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }),
    // v1.9: bulk-archive every open session in this project. Server publishes
    // one session_archived frame per affected id, so the sidebar reducer
    // updates incrementally rather than waiting for a refetch.
    archiveAllSessions: (id: string) =>
      request<{ archived: number; ids: string[] }>(
        `/api/projects/${id}/sessions/archive-all`,
        { method: 'POST' },
      ),
    openSessionsCount: (id: string) =>
      request<{ count: number }>(`/api/projects/${id}/sessions/open-count`),
    create: (body: {
      name: string;
      commit_message?: string;
      visibility?: 'private' | 'public';
      create_gitea_remote?: boolean;
    }) =>
      request<{
        project: Project;
        bootstrap: {
          folder_created: boolean;
          git_initialized: boolean;
          first_commit: boolean;
          gitea_remote_created: boolean;
          gitea_pushed: boolean;
          warnings: string[];
        };
      }>(`/api/projects/create`, {
        method: 'POST',
        body: JSON.stringify(body),
      }),
    remove: (id: string) =>
      request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
    listDir: (id: string, path: string) =>
      request<ListDirResult>(`/api/projects/${id}/list_dir?path=${encodeURIComponent(path)}`),
    viewFile: (id: string, path: string) =>
      request<ViewFileResult>(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`),
    files: (id: string) =>
      request<{ files: string[] }>(`/api/projects/${id}/files`),
    git: (id: string) =>
      request<GitMeta>(`/api/projects/${id}/git`),
    gitDiff: (id: string, mode: GitDiffMode | null, hideWhitespace?: boolean) => {
      const params: string[] = [];
      if (mode !== null) params.push(`mode=${mode}`);
      if (hideWhitespace) params.push('whitespace=1');
      const qs = params.length > 0 ? `?${params.join('&')}` : '';
      return request<GitDiffResult>(`/api/projects/${id}/git/diff${qs}`);
    },
    gitStage: (id: string, files: string[]) =>
      request<{ ok: boolean }>(`/api/projects/${id}/git/stage`, {
        method: 'POST',
        body: JSON.stringify({ files }),
      }),
    gitUnstage: (id: string, files: string[]) =>
      request<{ ok: boolean }>(`/api/projects/${id}/git/unstage`, {
        method: 'POST',
        body: JSON.stringify({ files }),
      }),
    gitCommit: (id: string, body: { message: string; files?: string[] }) =>
      request<{ ok: boolean }>(`/api/projects/${id}/git/commit`, {
        method: 'POST',
        body: JSON.stringify(body),
      }),
    gitDiscard: (id: string, files: GitDiscardFileInfo[]) =>
      request<{ ok: boolean }>(`/api/projects/${id}/git/discard`, {
        method: 'POST',
        body: JSON.stringify({ files }),
      }),
    writeFile: (id: string, filePath: string, content: string) =>
      request<{ ok: boolean }>(`/api/projects/${id}/write_file`, {
        method: 'POST',
        body: JSON.stringify({ path: filePath, content }),
      }),
  },

  sessions: {
    listForProject: (projectId: string, status?: 'open' | 'archived') =>
      request<Session[]>(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`),
    create: (
      projectId: string,
      body: { name?: string; model?: string; system_prompt?: string; agent_id?: string | null }
    ) =>
      request<Session>(`/api/projects/${projectId}/sessions`, {
        method: 'POST',
        body: JSON.stringify(body),
      }),
    get: (id: string) => request<Session>(`/api/sessions/${id}`),
    update: (
      id: string,
      body: Partial<
        Pick<
          Session,
          | 'name'
          | 'model'
          | 'system_prompt'
          | 'agent_id'
          | 'web_search_enabled'
          // v1.13.17: revocation path — frontend sends the shortened list
          // when the user removes a grant. Grants are appended only via the
          // separate grantReadAccess endpoint below.
          | 'allowed_read_paths'
        >
      >
    ) =>
      request<Session>(`/api/sessions/${id}`, {
        method: 'PATCH',
        body: JSON.stringify(body),
      }),
    // force=true bypasses the server-side worktree work-loss guard. A blocked
    // delete throws ApiError(409) whose body carries { error, reports }.
    remove: (id: string, force = false) =>
      request<void>(`/api/sessions/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' }),
    // Stash the session's worktree (uncommitted changes) on the host, via the
    // BooCoder proxy. Recoverable escape from the work-at-risk dialog.
    worktreeStash: (id: string) =>
      request<{ results: { worktreePath: string; stashed: boolean; error?: string }[] }>(
        `/api/coder/sessions/${id}/worktree-stash`,
        { method: 'POST' },
      ),
    archive: (id: string) =>
      request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
    unarchive: (id: string) =>
      request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
    // v1.9: bulk-archive every open chat in this session. Same pattern as
    // archiveAllSessions — server publishes one chat_archived per id.
    archiveAllChats: (id: string) =>
      request<{ archived: number; ids: string[] }>(
        `/api/sessions/${id}/chats/archive-all`,
        { method: 'POST' },
      ),
    openChatsCount: (id: string) =>
      request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
    updateWorkspacePanes: (id: string, state: WorkspaceState) =>
      request<Session>(`/api/sessions/${id}/workspace`, {
        method: 'PATCH',
        body: JSON.stringify({ workspace_panes: state }),
      }),
  },

  chats: {
    listForSession: (sessionId: string, params?: { status?: 'open' | 'archived' }) =>
      request<Chat[]>(
        `/api/sessions/${sessionId}/chats${params?.status ? `?status=${params.status}` : ''}`
      ),
    create: (sessionId: string, body?: { name?: string }) =>
      request<Chat>(`/api/sessions/${sessionId}/chats`, {
        method: 'POST',
        body: JSON.stringify(body ?? {}),
      }),
    update: (chatId: string, body: { name?: string; model?: string }) =>
      request<Chat>(`/api/chats/${chatId}`, {
        method: 'PATCH',
        body: JSON.stringify(body),
      }),
    archive: (chatId: string) =>
      request<void>(`/api/chats/${chatId}/archive`, { method: 'POST' }),
    unarchive: (chatId: string) =>
      request<Chat>(`/api/chats/${chatId}/unarchive`, { method: 'POST' }),
    remove: (chatId: string) =>
      request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
    messages: (chatId: string) =>
      request<Message[]>(`/api/chats/${chatId}/messages`),
    // v1.11: anchored-rolling compaction. POST awaits the LLM call inside
    // the route's lifecycle; the new summary row arrives via the 'compacted'
    // WS frame (useSessionStream refetches + toasts).
    compact: (chatId: string) =>
      request<{ ok: true }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
    stop: (chatId: string) =>
      request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }),
    discardStale: (chatId: string, messageId: string) =>
      request<Message>(`/api/chats/${chatId}/discard_stale`, {
        method: 'POST',
        body: JSON.stringify({ message_id: messageId }),
      }),
    forceSend: (chatId: string, content: string) =>
      request<{ user_message_id: string; assistant_message_id: string }>(
        `/api/chats/${chatId}/force_send`,
        { method: 'POST', body: JSON.stringify({ content }) }
      ),
    // v1.8.2: extend an inference that hit the tool budget. `sentinelMessageId`
    // is the cap-hit sentinel message the user clicked Continue on.
    continue: (chatId: string, sentinelMessageId: string) =>
      request<{ assistant_message_id: string }>(
        `/api/chats/${chatId}/continue`,
        { method: 'POST', body: JSON.stringify({ sentinel_message_id: sentinelMessageId }) }
      ),
    fork: (chatId: string, body: { messageId: string; name?: string }) =>
      request<Chat>(`/api/chats/${chatId}/fork`, {
        method: 'POST',
        body: JSON.stringify({ message_id: body.messageId, name: body.name }),
      }),
    // Batch 9.6: slash-command invocation. Server loads the skill body
    // authoritatively (client doesn't get to forge file contents), persists
    // a synthetic skill_use tool_use + tool_result + user message + streaming
    // assistant, and enqueues inference. Returns all 4 new message IDs.
    skillInvoke: (chatId: string, skillName: string, userMessage: string | null) =>
      request<{
        synth_assistant_id: string;
        tool_message_id: string;
        user_message_id: string;
        assistant_message_id: string;
      }>(`/api/chats/${chatId}/skill_invoke`, {
        method: 'POST',
        body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
      }),
    // v2.8-compare: send the same message to N models and stream back
    // parallel responses. Returns compare_group_id + per-model message ids.
    compare: (chatId: string, message: string, models: string[]) =>
      request<{
        compare_group_id: string;
        user_message_id: string;
        responses: Array<{ model: string; assistant_message_id: string }>;
      }>(`/api/chats/${chatId}/compare`, {
        method: 'POST',
        body: JSON.stringify({ message, models }),
      }),
    // v1.13.17-cross-repo-reads: resume a paused request_read_access. On
    // 'allow' the server re-resolves the grant root and appends it to
    // sessions.allowed_read_paths; the returned list reflects the post-
    // grant state. On 'deny' the array is unchanged.
    grantReadAccess: (chatId: string, toolCallId: string, decision: 'allow' | 'deny') =>
      request<{
        tool_message_id: string;
        assistant_message_id: string;
        allowed_read_paths: string[];
      }>(`/api/chats/${chatId}/grant_read_access`, {
        method: 'POST',
        body: JSON.stringify({ tool_call_id: toolCallId, decision }),
      }),
    getTraces: (chatId: string, limit = 50, offset = 0) =>
      request<ToolTraceResponse>(
        `/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
      ),
    exportChat: (chatId: string, format: 'json' | 'markdown') =>
      request<string>(`/api/chats/${chatId}/export?format=${format}`),
    // MCP permission: approve/deny a tool call from an 'ask' state server.
    mcpApprove: (chatId: string, toolCallId: string, permission: 'allow_once' | 'allow_always' | 'deny') =>
      request<{ ok: true }>(`/api/chats/${chatId}/mcp-approve`, {
        method: 'POST',
        body: JSON.stringify({ tool_call_id: toolCallId, permission }),
      }),
  },

  messages: {
    list: (sessionId: string) =>
      request<Message[]>(`/api/sessions/${sessionId}/messages`),
    send: (chatId: string, content: string) =>
      request<{ user_message_id: string; assistant_message_id: string }>(
        `/api/chats/${chatId}/messages`,
        {
          method: 'POST',
          body: JSON.stringify({ content }),
        }
      ),
    regenerate: (chatId: string, messageId: string) =>
      request<{ assistant_message_id: string }>(
        `/api/chats/${chatId}/messages/${messageId}/regenerate`,
        { method: 'POST' }
      ),
    remove: (chatId: string, messageId: string) =>
      request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
        method: 'DELETE',
      }),
    // v1.14.x-html-artifact-panes: write the artifact to
    // <projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext> and return the
    // path + a /api/projects/.../artifacts/<filename> URL the browser can
    // GET to download. fmt=html requires the assistant message to carry an
    // html_artifact part (404 otherwise).
    downloadArtifact: (chatId: string, messageId: string, fmt: 'md' | 'html') =>
      request<{ path: string; url: string }>(
        `/api/chats/${chatId}/messages/${messageId}/artifacts/download?fmt=${fmt}`,
        { method: 'POST' },
      ),
    // v1.14.x-html-artifact-panes: fetch the html_artifact part payload so
    // HtmlArtifactPane can render the iframe srcdoc. 404 = no html_artifact
    // part on this message; MessageBubble uses that as a signal to fall back
    // to the markdown pane variant.
    getHtmlArtifact: (chatId: string, messageId: string) =>
      request<{ html_content: string; char_count: number; title: string }>(
        `/api/chats/${chatId}/messages/${messageId}/html_artifact`,
      ),
    feedback: (chatId: string, messageId: string, value: 'up' | 'down') =>
      request<{ ok: boolean }>(
        `/api/chats/${chatId}/messages/${messageId}/feedback`,
        { method: 'POST', body: JSON.stringify({ value }) },
      ),
  },

  models: () => request<ModelCatalogResponse>('/api/models'),

  coder: {
    snapshot: (cwd?: string) => {
      const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
      return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
    },
    // v2.3 Phase 4: optional subset narrows the reported `refreshed` count.
    refreshProviders: (providers?: string[]) =>
      request<{ refreshed: number }>('/api/coder/providers/refresh', {
        method: 'POST',
        ...(providers && providers.length > 0 ? { body: JSON.stringify({ providers }) } : {}),
      }),
    // v2.3 Phase 4: read/patch the provider config file. PATCH returns the new
    // config; a `null` value in the patch deletes that id's override.
    getProvidersConfig: () => request<CoderProvidersFile>('/api/coder/providers/config'),
    patchProvidersConfig: (patch: ProviderConfigPatch) =>
      request<{ ok: true } & CoderProvidersFile>('/api/coder/providers/config', {
        method: 'PATCH',
        body: JSON.stringify(patch),
      }),
    // v2.3 Phase 4: per-provider diagnostic — JSON { diagnostic: string } (§6.4).
    getProviderDiagnostic: (id: string) =>
      request<{ diagnostic: string }>(`/api/coder/providers/${encodeURIComponent(id)}/diagnostic`),
    sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
      request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
        method: 'POST',
        body: JSON.stringify(body),
      }),
    getTaskPermission: (taskId: string) =>
      request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
    respondTaskPermission: (taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>) =>
      request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
        method: 'POST',
        body: JSON.stringify({ option_id: optionId, ...(updatedInput ? { updated_input: updatedInput } : {}) }),
      }),
    getTaskCommands: (taskId: string) =>
      request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
    getTask: (taskId: string) =>
      request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
    cancelTask: (taskId: string) =>
      request<{ cancelled: boolean }>(`/api/coder/tasks/${taskId}/cancel`, { method: 'POST' }),
    listMessages: (sessionId: string, chatId?: string) =>
      request<CoderMessageWire[]>(
        `/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
      ),
    // v2.6 Phase 1-UX §9b: per-(chat,agent) backend-session state for the
    // resumed/new-session chip. Chat-scoped (NOT foldable into the project-level
    // provider snapshot). Proxied to boocoder at /api/sessions/:id/agent-sessions.
    agentSessions: (sessionId: string) =>
      request<AgentSessionInfo[]>(`/api/coder/sessions/${sessionId}/agent-sessions`),
    skillInvoke: (
      sessionId: string,
      paneId: string,
      skillName: string,
      userMessage: string | null,
      // v2.5.9: when the active provider is external, the skill runs under that
      // agent (body injected into a dispatched task) → response carries task_id.
      config?: { provider?: string; model?: string; mode_id?: string; thinking_option_id?: string },
    ) =>
      request<{
        user_message_id: string;
        assistant_message_id?: string;
        synth_assistant_id?: string;
        tool_message_id?: string;
        task_id?: string;
        dispatched?: boolean;
      }>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
        method: 'POST',
        body: JSON.stringify({
          pane_id: paneId,
          skill_name: skillName,
          user_message: userMessage,
          ...(config?.provider ? { provider: config.provider } : {}),
          ...(config?.model ? { model: config.model } : {}),
          ...(config?.mode_id ? { mode_id: config.mode_id } : {}),
          ...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
        }),
      }),
    // write-edit-robustness #4: worktree checkpoints. List which assistant
    // messages in a chat have a pre-turn worktree snapshot ("Restore to here"
    // is offered only on those). Proxied to boocoder.
    getCheckpoints: (sessionId: string, chatId: string) =>
      request<{ checkpoints: CoderCheckpoint[] }>(
        `/api/coder/sessions/${sessionId}/checkpoints?chat_id=${encodeURIComponent(chatId)}`,
      ),
    // write-edit-robustness #4: reset the worktree to a checkpoint, trim the
    // transcript past its anchor message, and reset the agent backend. After it
    // returns, the caller refetches messages (+ checkpoints) so the trimmed
    // transcript shows.
    restoreCheckpoint: (sessionId: string, checkpointId: string) =>
      request<CoderRestoreResult>(
        `/api/coder/sessions/${sessionId}/checkpoints/${encodeURIComponent(checkpointId)}/restore`,
        { method: 'POST' },
      ),
    // Queue a new-file create from the RightRail browser → BooCoder
    // pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel
    // for explicit apply. A WriteGuardError comes back as a 422 whose { error }
    // body ApiError exposes as .message for inline display.
    createPendingFile: (sessionId: string, file_path: string, content: string) =>
      request<{
        id: string;
        session_id: string;
        task_id: string | null;
        file_path: string;
        operation: string;
        status: string;
        created_at: string;
      }>(`/api/coder/sessions/${sessionId}/pending/create`, {
        method: 'POST',
        body: JSON.stringify({ file_path, content }),
      }),
  },

  // Orchestrator run API — proxied to boocoder at /api/coder/runs/*.
  runs: {
    launch: (body: {
      project_id: string;
      flow_name: string;
      band: 'small' | 'medium' | 'large';
      input: { question: string };
      model?: string;
    }) =>
      request<{ run_id: string }>('/api/coder/runs', {
        method: 'POST',
        body: JSON.stringify(body),
      }),
    list: (projectId: string) =>
      request<{ runs: FlowRunRow[] }>(
        `/api/coder/runs?project_id=${encodeURIComponent(projectId)}`,
      ),
    get: (runId: string) =>
      request<{ run: FlowRunRow; steps: FlowStepRow[] }>(
        `/api/coder/runs/${encodeURIComponent(runId)}`,
      ),
    cancel: (runId: string) =>
      request<{ cancelled: boolean }>(
        `/api/coder/runs/${encodeURIComponent(runId)}/cancel`,
        { method: 'POST' },
      ),
  },

  agents: {
    list: (projectId: string) =>
      request<AgentsResponse>(`/api/projects/${projectId}/agents`),
  },

  // Arena battle API — proxied to boocoder at /api/coder/battles/*.
  battles: {
    create: (body: {
      project_id: string;
      battle_type: 'coding' | 'qa';
      prompt: string;
      contestants: Array<{ identity: string; model: string }>;
    }) =>
      request<{ battle_id: string }>('/api/coder/battles', {
        method: 'POST',
        body: JSON.stringify(body),
      }),
    list: (projectId: string) =>
      request<{ battles: BattleShape[] }>(
        `/api/coder/battles?project_id=${encodeURIComponent(projectId)}`,
      ),
    get: (battleId: string) =>
      request<{
        battle: BattleShape;
        contestants: ContestantShape[];
        cross_examinations: CrossExaminationShape[];
      }>(`/api/coder/battles/${encodeURIComponent(battleId)}`),
    stop: (battleId: string) =>
      request<{ cancelled: boolean }>(
        `/api/coder/battles/${encodeURIComponent(battleId)}/stop`,
        { method: 'POST' },
      ),
    analyze: (battleId: string) =>
      request<{ triggered: boolean }>(
        `/api/coder/battles/${encodeURIComponent(battleId)}/analyze`,
        { method: 'POST' },
      ),
    crossExamine: (battleId: string, body: { identity: string; model: string }) =>
      request<{ cross_exam_id: string }>(
        `/api/coder/battles/${encodeURIComponent(battleId)}/cross-examine`,
        { method: 'POST', body: JSON.stringify(body) },
      ),
    getAnalysis: (battleId: string) =>
      request<{ text: string }>(
        `/api/coder/battles/${encodeURIComponent(battleId)}/analysis`,
      ),
    generatePrompt: (description: string) =>
      request<{ prompt: string }>('/api/coder/battles/generate-prompt', {
        method: 'POST',
        body: JSON.stringify({ description }),
      }),
    setWinner: (battleId: string, body: { winner_contestant_id: string | null }) =>
      request<{ ok: boolean }>(
        `/api/coder/battles/${encodeURIComponent(battleId)}/winner`,
        { method: 'PATCH', body: JSON.stringify(body) },
      ),
    getDiff: (battleId: string, contestantId: string) =>
      request<{ diff: string }>(
        `/api/coder/battles/${encodeURIComponent(battleId)}/contestants/${encodeURIComponent(contestantId)}/diff`,
      ),
  },

  skills: {
    list: () => request<{ skills: Skill[] }>('/api/skills'),
  },

  // v1.13.10: per-tool cost rolling-window stats (last 100 calls per tool,
  // equal-split attribution across multi-tool turns). Read endpoint backed by
  // the tool_cost_stats view. AgentPicker consumes this for per-agent cost
  // hints.
  tools: {
    costStats: () => request<{ stats: ToolCostStat[] }>('/api/tools/cost_stats'),
  },

  // token-analyzer-ui: analytics aggregate endpoints.
  analytics: {
    summary: () => request<AnalyticsSummary>('/api/coder/analytics/summary'),
    sessions: () => request<{ sessions: SessionAnalyticsRow[] }>('/api/coder/analytics/sessions'),
    context: () => request<ContextWindowStats>('/api/analytics/context'),
    tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'),
  },

  // memory-browser-ui: topic-based memory, daily log, dream diaries.
  memory: {
    list: (projectId: string) =>
      request<{ entries: MemoryEntry[] }>(
        `/api/memory?project_id=${encodeURIComponent(projectId)}`,
      ),
    daily: (projectId: string) =>
      request<{ entries: DailyMemoryEntry[] }>(
        `/api/memory/daily?project_id=${encodeURIComponent(projectId)}`,
      ),
    dreams: (projectId: string) =>
      request<{ entries: DreamEntry[] }>(
        `/api/memory/dreams?project_id=${encodeURIComponent(projectId)}`,
      ),
  },

  settings: {
    get: () => request<Record<string, unknown>>('/api/settings'),
    patch: (body: Record<string, unknown>) =>
      request<Record<string, unknown>>('/api/settings', {
        method: 'PATCH',
        body: JSON.stringify(body),
      }),
    inference: {
      get: () => request<Record<string, unknown>>('/api/settings/inference'),
      patch: (body: Record<string, unknown>) =>
        request<Record<string, unknown>>('/api/settings/inference', {
          method: 'PATCH',
          body: JSON.stringify(body),
        }),
    },
  },

  sidebar: {
    get: () => request<SidebarResponse>('/api/sidebar'),
  },

  // v1.10 booterm: REST control plane for terminal panes. WebSocket attach
  // lives at /ws/term/sessions/:sid/panes/:pid (handled directly by
  // TerminalPane). v1.10.8c: resize moved in-band onto the WebSocket as a
  // `{type:"resize",cols,rows}` text frame — the old /resize HTTP endpoint is
  // gone, eliminating the race between WS attach and PTY-map registration.
  terminals: {
    // cols/rows are optional. When passed, booterm sizes the per-pane tmux
    // session at creation time so the inner bash (and any TUI it spawns) is
    // born with the correct PTY dimensions instead of tmux's 80x24 default.
    start: (
      sessionId: string,
      paneId: string,
      cols?: number,
      rows?: number,
      description?: string,
      parentAgent?: string,
    ) => {
      const body: Record<string, unknown> = {};
      if (cols !== undefined) body.cols = cols;
      if (rows !== undefined) body.rows = rows;
      if (description !== undefined) body.description = description;
      if (parentAgent !== undefined) body.parentAgent = parentAgent;
      return request<{ tmux_session: string }>(
        `/api/term/sessions/${sessionId}/panes/${paneId}/start`,
        {
          method: 'POST',
          body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
        },
      );
    },
    kill: (sessionId: string, paneId: string) =>
      request<{ ok: true }>(
        `/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
        { method: 'POST' },
      ),
  },
};
