const BASE = '/api';

/**
 * 4-state representation of a secret field on the wire. See design doc
 * 2026-05-21-settings-ui-and-config-restructure-design.md (Form Behavior →
 * Secret Inputs) for the canonical contract.
 *
 * Phase 1 (this release) keeps the on-wire representation backwards
 * compatible with the existing `apiKey: string` shape so that mask-
 * preservation in `ConfigManager.updateConfig` keeps working unchanged:
 *
 *   - `unchanged` → serialized as the masked sentinel `'********'`
 *   - `literal`   → serialized as the raw string value
 *   - `env_ref`   → serialized as `${ENV_NAME}`
 *   - `cleared`   → serialized as `''` (empty string)
 *
 * Phase 2 will switch to a tagged object on the wire and drop the magic
 * `'********'` sentinel; the UI form keeps the 4-state union today so the
 * Phase 2 migration is a server-side change only.
 */
export type SecretFieldValue =
  | { type: 'unchanged' }
  | { type: 'literal'; value: string }
  | { type: 'env_ref'; env_name: string }
  | { type: 'cleared' };

/** Server-side masked sentinel. Kept in sync with `MASKED` in src/config-manager.ts. */
export const SECRET_MASKED_SENTINEL = '********';

/**
 * Parse a stored string secret (as received from `GET /api/config`) into
 * the 4-state form used by the UI. The server masks literal secrets to
 * `'********'`, so any literal-looking string is treated as `unchanged`
 * unless it's an `${ENV_REF}` pattern. Empty / missing values map to
 * `cleared`.
 */
export function parseSecretValue(raw: string | null | undefined): SecretFieldValue {
  if (raw == null || raw === '') return { type: 'cleared' };
  if (raw === SECRET_MASKED_SENTINEL) return { type: 'unchanged' };
  const envMatch = /^\$\{([A-Z0-9_]+)\}$/.exec(raw.trim());
  if (envMatch) return { type: 'env_ref', env_name: envMatch[1] };
  // Anything else came back as plaintext (e.g. fresh UI form not yet
  // round-tripped through the server mask) — treat as literal.
  return { type: 'literal', value: raw };
}

/**
 * Serialize a 4-state secret into the string the server currently
 * expects. `unchanged` becomes the masked sentinel so server-side
 * mask-preservation in `ConfigManager.updateConfig` keeps the existing
 * literal in place.
 */
export function serializeSecretValue(v: SecretFieldValue): string {
  if (v.type === 'unchanged') return SECRET_MASKED_SENTINEL;
  if (v.type === 'literal') return v.value;
  if (v.type === 'env_ref') return `\${${v.env_name}}`;
  return '';
}

export type PieceName = string;  // Dynamically loaded from API
export type ProfileName = 'auto' | 'fast' | 'quality';
export type OutputFormat = 'text' | 'markdown' | 'json';
export type AskPolicy = 'low' | 'high';
export type Priority = 'low' | 'medium' | 'high';
export type Visibility = 'private' | 'org' | 'public';

export interface UserOrg {
  orgId: string;
  orgName: string;
  fetchedAt: string;
}

export async function fetchMyOrgs(): Promise<UserOrg[]> {
  const res = await fetch('/api/users/me/orgs');
  if (!res.ok) return [];
  const { orgs } = (await res.json()) as { orgs: Array<{ orgId: string; orgName: string; fetchedAt: string }> };
  return orgs;
}

export interface SubtaskInfo {
  id: string;
  issueNumber: number;
  status: string;
  instruction: string;
  worktreePath: string | null;
  createdAt: string;
  updatedAt: string;
  children?: SubtaskInfo[];
  childCount?: number;
  childCompleted?: number;
}

export interface SubtaskActivity {
  jobId: string;
  issueNumber: number;
  status: string;
  currentMovement: string | null;
  currentActivity: string | null;
  activityLog: string;
}

export type TitleSource = 'auto' | 'agent' | 'user';

export interface LocalTask {
  id: number;
  title: string;
  /** Provenance of the title: 'auto' (creation fallback), 'agent' (derived from goal), 'user' (manual edit). */
  titleSource?: TitleSource;
  body: string;
  pieceName: string;
  profile: string;
  outputFormat: string;
  askPolicy: string;
  priority: string;
  state: string;
  workspacePath: string | null;
  ownerId?: string | null;
  ownerName?: string | null;
  visibility?: Visibility;
  visibilityScopeOrgId?: string | null;
  visibilityScopeOrgName?: string | null;
  createdAt: string;
  updatedAt: string;
  latestJob?: {
    id: string;
    status: string;
    waitReason?: string | null;
    currentMovement?: string | null;
    currentActivity?: string | null;
    workerId?: string | null;
    /**
     * Physical backend id (e.g. LiteLLM deployment) for jobs run through
     * a proxy worker. NULL until the proxy has resolved a backend or for
     * direct workers entirely.
     * Phase A: docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md.
     */
    lastBackendId?: string | null;
    contextPromptTokens?: number | null;
    contextLimitTokens?: number | null;
    contextUpdatedAt?: string | null;
  } | null;
  subtasks?: SubtaskInfo[];
  subtaskCount?: number;
  subtaskCompleted?: number;
  feedbackRating?: 'good' | 'bad' | null;
  feedbackTags?: string[] | null;
  feedbackComment?: string | null;
  feedbackAt?: string | null;
  shareToken?: string | null;
  sharedAt?: string | null;
  missionBrief?: MissionBrief | null;
}

export interface MissionBrief {
  goal: string;
  done: string;
  open: string;
  clarifications: string;
}

export async function updateMissionBrief(
  taskId: number,
  patch: Partial<MissionBrief>,
): Promise<MissionBrief | null> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/mission`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(patch),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: res.statusText }));
    throw new Error(err.error || res.statusText);
  }
  const data = await res.json();
  return data.missionBrief ?? null;
}

export type CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection';

export interface LocalTaskComment {
  id: number;
  taskId: number;
  author: string;
  kind: CommentKind;
  body: string;
  createdAt: string;
  injectedAt: string | null;
}

export interface LocalFileEntry {
  name: string;
  path: string;
  kind: 'directory' | 'file';
  size: number;
  modifiedAt: string;
}

export interface CreateLocalTaskInput {
  title?: string;
  body: string;
  piece: PieceName;
  profile: ProfileName;
  outputFormat: OutputFormat;
  askPolicy: AskPolicy;
  priority: Priority;
  attachments?: Array<{ name: string; contentBase64: string }>;
  visibility?: Visibility;
  visibilityScopeOrgId?: string | null;
  browserSessionProfileId?: number | null;
  options?: {
    mcpDisabled?: boolean;
    skillsDisabled?: boolean;
  };
}

export async function fetchLocalTasks(): Promise<LocalTask[]> {
  const res = await fetch(`${BASE}/local/tasks`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local tasks');
  return data.tasks ?? [];
}

export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ task: LocalTask; jobId: string }> {
  const res = await fetch(`${BASE}/local/tasks`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to create local task');
  return data;
}

export async function fetchLocalTask(taskId: number): Promise<LocalTask> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task');
  return data.task;
}

export async function fetchLocalTaskComments(taskId: number): Promise<LocalTaskComment[]> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task comments');
  return data.comments ?? [];
}

export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise<void> {
  const payload: Record<string, unknown> = { body, author };
  if (attachments && attachments.length > 0) payload.attachments = attachments;
  const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to post local task comment');
}

export async function updateLocalTask(
  taskId: number,
  updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null },
): Promise<LocalTask> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(updates),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to update local task');
  return data.task;
}

/** Trigger on-demand AI title regeneration. Owner/admin only. Returns the new title. */
export async function regenerateTaskTitle(taskId: number): Promise<string> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/regenerate-title`, { method: 'POST' });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to regenerate title');
  return data.title as string;
}

export async function continueTaskWithPiece(
  taskId: number,
  body: { piece: string; instruction: string },
): Promise<{ jobId: string }> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/continue`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to continue task');
  return data;
}

export async function deleteLocalTask(taskId: number): Promise<void> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}`, { method: 'DELETE' });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to delete local task');
}

export async function cancelLocalTask(taskId: number): Promise<void> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/cancel`, {
    method: 'POST',
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to cancel task');
}

export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
  const params = new URLSearchParams({ section });
  if (path) params.set('path', path);
  const res = await fetch(`${BASE}/local/tasks/${taskId}/files?${params.toString()}`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
  return data;
}

export async function fetchLocalFileContent(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): Promise<string> {
  const params = new URLSearchParams({ section, path });
  const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`);
  if (!res.ok) {
    const data = await res.json().catch(() => ({}));
    throw new Error(data?.error ?? 'Failed to read file');
  }
  return await res.text();
}

export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
  const params = new URLSearchParams({ section, path });
  return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}

export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
  const params = new URLSearchParams({ section, path, trusted: '1' });
  return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}

export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ section, path, content }),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: res.statusText }));
    throw new Error(err.error || res.statusText);
  }
}

// --- Config ---
export async function fetchConfig(): Promise<{ config: any; etag: string; overriddenByEnv: Record<string, boolean> }> {
  const res = await fetch(`${BASE}/config`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch config');
  return { config: data.config, etag: res.headers.get('etag') ?? '', overriddenByEnv: data.overriddenByEnv ?? {} };
}

export async function updateConfig(config: any, etag: string): Promise<{ ok: boolean; conflict?: boolean }> {
  const res = await fetch(`${BASE}/config`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json', 'If-Match': etag },
    body: JSON.stringify(config),
  });
  const data = await res.json();
  if (res.status === 409) return { ok: false, conflict: true };
  if (!res.ok) throw new Error(data?.error ?? 'Failed to update config');
  return data;
}

export async function reloadConfig(): Promise<void> {
  const res = await fetch(`${BASE}/config/reload`, { method: 'POST' });
  if (!res.ok) throw new Error('Failed to reload config');
}

// --- Pieces ---
export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null }
export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; source?: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string; drift?: DriftStatus; requiredMcp?: string[] }
export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] }
/** Full response from GET /api/pieces/:name — includes the server-resolved source. */
export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string }

export async function fetchPieces(): Promise<PieceSummary[]> {
  const res = await fetch(`${BASE}/pieces`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pieces');
  return data.pieces;
}

export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<PieceFetchResult> {
  const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
  const res = await fetch(url);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece');
  return { piece: data.piece, source: data.source, ownerId: data.ownerId };
}

export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
  const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
  const res = await fetch(url, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(piece),
  });
  if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); }
}

export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' }

export async function createPiece(piece: PieceDef): Promise<PieceCreateResult> {
  const res = await fetch(`${BASE}/pieces`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(piece),
  });
  if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); }
  const d = await res.json();
  return { source: d.source ?? 'user-custom' };
}

export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
  const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
  const res = await fetch(url, { method: 'DELETE' });
  if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); }
}

// --- Tools ---
/**
 * Runtime tool catalog entry. Mirrors `ToolCatalogEntry` exported by
 * `src/bridge/tools-api.ts` (server side). See design doc step 4:
 * docs/superpowers/specs/2026-05-21-settings-ui-and-config-restructure-design.md
 */
export interface ToolCatalogEntry {
  name: string;
  source: 'builtin' | 'meta' | 'mcp';
  /**
   * Coarse grouping for UI. For builtin/meta tools this is a module name
   * (e.g. 'core', 'web'). For MCP tools the server uses `mcp:<serverId>`.
   */
  category: string;
  /** MCP server id (only set when source === 'mcp'). */
  serverId?: string;
  /** Whether the tool can be invoked right now. */
  available: boolean;
  /** Human-readable explanation when `available` is false. */
  reason?: string;
  /**
   * - 'global' → meta tools auto-injected by the agent loop
   * - 'piece'  → must be listed in a piece's `allowed_tools`
   * - 'user'   → per-user resource (MCP / SSH)
   */
  scope: 'global' | 'piece' | 'user';
}

export async function fetchTools(): Promise<ToolCatalogEntry[]> {
  const res = await fetch(`${BASE}/tools`);
  if (!res.ok) throw new Error('Failed to fetch tools');
  const data = (await res.json()) as { tools?: unknown };
  if (!Array.isArray(data.tools)) return [];
  // Server may still occasionally serve the legacy flat-string shape (e.g.
  // during a transient mismatch / proxy / cache). Filter to only well-formed
  // catalog entries so the UI never crashes; legacy strings are dropped, the
  // piece editor will then surface them as "unknown" entries (visible+disabled
  // with a warning) once they appear in an existing piece's allowed_tools.
  return data.tools.filter(
    (t): t is ToolCatalogEntry =>
      typeof t === 'object' && t !== null && typeof (t as { name?: unknown }).name === 'string',
  );
}

// --- Subtasks ---
export interface SubtaskFiles {
  files: string[];
  categories: Record<string, string[]>;
}

export async function fetchSubtaskFiles(taskId: number, jobId: string): Promise<SubtaskFiles> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files');
  return { files: data.files ?? [], categories: data.categories ?? {} };
}

export function subtaskFileRawUrl(taskId: number, jobId: string, filePath: string): string {
  return `${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files/${filePath}`;
}

export async function fetchSubtaskFileContent(taskId: number, jobId: string, filePath: string): Promise<string> {
  const res = await fetch(subtaskFileRawUrl(taskId, jobId, filePath));
  if (!res.ok) throw new Error('Failed to fetch subtask file content');
  return res.text();
}

export async function fetchSubtaskActivities(taskId: number): Promise<SubtaskActivity[]> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/activities`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities');
  return data.subtasks ?? [];
}

export async function fetchSubtaskActivity(taskId: number, jobId: string): Promise<string> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/activity`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity');
  return data.activityLog ?? '';
}

export async function putFeedback(
  taskId: number,
  feedback: { rating: 'good' | 'bad'; tags: string[]; comment?: string },
): Promise<LocalTask> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/feedback`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(feedback),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to update feedback');
  return data.task;
}

// --- Share ---
export async function shareTask(taskId: number): Promise<{ shareToken: string; shareUrl: string }> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'POST' });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to share task');
  return data;
}

export async function unshareTask(taskId: number): Promise<void> {
  const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'DELETE' });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to unshare task');
}

export async function fetchSharedTask(token: string): Promise<LocalTask> {
  const res = await fetch(`${BASE}/shared/${token}`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Not found');
  return data.task;
}

export async function fetchSharedTaskComments(token: string): Promise<LocalTaskComment[]> {
  const res = await fetch(`${BASE}/shared/${token}/comments`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch comments');
  return data.comments ?? [];
}

export async function fetchSharedFiles(token: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
  const params = new URLSearchParams();
  if (path) params.set('path', path);
  const res = await fetch(`${BASE}/shared/${token}/files?${params.toString()}`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
  return data;
}

export async function fetchSharedFileContent(token: string, path: string): Promise<string> {
  const params = new URLSearchParams({ path });
  const res = await fetch(`${BASE}/shared/${token}/files/content?${params.toString()}`);
  if (!res.ok) throw new Error('Failed to read file');
  return res.text();
}

export function getSharedFileRawUrl(token: string, path: string): string {
  const params = new URLSearchParams({ path });
  return `${BASE}/shared/${token}/files/raw?${params.toString()}`;
}

export async function fetchSharedSubtaskActivities(token: string): Promise<SubtaskActivity[]> {
  const res = await fetch(`${BASE}/shared/${token}/subtasks/activities`);
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities');
  return data.subtasks ?? [];
}

// --- Browser Session Profiles ---
export interface BrowserSessionProfile {
  id: number;
  label: string;
  startUrl: string;
  matchPatterns: string[];
  storageOrigins: string[];
  loggedInSelector: string | null;
  loginUrlPatterns: string[];
  status: 'pending' | 'active' | 'expired' | 'revoked' | 'error';
  stateVersion: number;
  lastSavedAt: string | null;
  lastUsedAt: string | null;
  lastValidatedAt: string | null;
  lastError: string | null;
  createdAt: string;
  updatedAt: string;
}

const SESS_BASE = `${BASE}/browser-sessions`;

export async function listBrowserSessionProfiles(): Promise<BrowserSessionProfile[]> {
  const r = await fetch(`${SESS_BASE}/profiles`, { credentials: 'same-origin' });
  if (!r.ok) throw new Error(`listBrowserSessionProfiles: ${r.status}`);
  return (await r.json() as { profiles: BrowserSessionProfile[] }).profiles;
}

export async function createBrowserSessionProfile(
  input: Partial<BrowserSessionProfile> & { label: string; startUrl: string },
): Promise<BrowserSessionProfile> {
  const r = await fetch(`${SESS_BASE}/profiles`, {
    method: 'POST', credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  if (!r.ok) throw new Error(`createBrowserSessionProfile: ${r.status}`);
  return (await r.json() as { profile: BrowserSessionProfile }).profile;
}

export async function startBrowserSessionLogin(id: number): Promise<{ sessionId: string; novncPath: string }> {
  const r = await fetch(`${SESS_BASE}/profiles/${id}/login`, { method: 'POST', credentials: 'same-origin' });
  if (!r.ok) throw new Error((await r.text().catch(() => '')) || `startLogin: ${r.status}`);
  return r.json();
}

export async function saveBrowserSession(id: number, sessionId: string): Promise<BrowserSessionProfile> {
  const r = await fetch(`${SESS_BASE}/profiles/${id}/save`, {
    method: 'POST', credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ sessionId }),
  });
  if (!r.ok) throw new Error(`saveBrowserSession: ${r.status}`);
  return (await r.json() as { profile: BrowserSessionProfile }).profile;
}

export async function cancelBrowserSession(id: number, sessionId: string): Promise<void> {
  await fetch(`${SESS_BASE}/profiles/${id}/cancel`, {
    method: 'POST', credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ sessionId }),
  });
}

export async function testBrowserSessionProfile(id: number): Promise<{
  verdict: { expired: boolean; reason?: string };
  finalUrl: string;
  statusCode: number;
}> {
  const r = await fetch(`${SESS_BASE}/profiles/${id}/test`, { method: 'POST', credentials: 'same-origin' });
  if (!r.ok) throw new Error(`testBrowserSession: ${r.status}`);
  return r.json();
}

export async function deleteBrowserSessionProfile(id: number): Promise<void> {
  const r = await fetch(`${SESS_BASE}/profiles/${id}`, { method: 'DELETE', credentials: 'same-origin' });
  if (!r.ok) throw new Error(`deleteBrowserSessionProfile: ${r.status}`);
}

// --- Reflection ---
export interface LatestReflectionForTask {
  snapshotId: string;
  outcome: string;
  memoryChanges: number | null;
  pieceEdited: boolean;
}

export async function getLatestReflectionForTask(
  taskId: number,
): Promise<LatestReflectionForTask | null> {
  const res = await fetch(`${BASE}/local/reflection/latest-for-task/${taskId}`);
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`getLatestReflectionForTask: ${res.status}`);
  const data = await res.json();
  // API returns null body when no reflection exists
  if (!data || !data.snapshotId) return null;
  return data as LatestReflectionForTask;
}

// --- User Folder Pets ---
export interface PetSettings {
  enabled: boolean;
  activePetId: string | null;
  size: 32 | 48 | 64 | 80;
  position: 'bottom-right';
  sound: boolean;
  reducedMotion: boolean;
  toolSparkEnabled: boolean;
  workerPets: Record<string, string>;
}

export interface WorkerInfo {
  id: string;
  endpoint: string | null;
  model: string | null;
  roles: string[];
  enabled: boolean;
  /** True if this worker fronts an LLM gateway / proxy (Phase A). */
  proxy?: boolean;
  /** Proxy implementation; only 'litellm' is currently shipped. */
  proxyType?: 'litellm';
}

export async function fetchWorkers(): Promise<WorkerInfo[]> {
  const res = await fetch('/api/workers', { credentials: 'include' });
  if (!res.ok) return [];
  const data = await res.json() as { workers?: WorkerInfo[] };
  return data.workers ?? [];
}

export interface BackendInfo {
  id: string;
  model: string | null;
  online: boolean;
}

export interface WorkerBackendsResponse {
  source: 'direct' | 'proxy';
  proxyType?: 'litellm';
  backends: BackendInfo[];
  /** Set when the proxy probe failed (network error, 5xx). UI renders degraded. */
  error?: string;
}

export async function fetchWorkerBackends(workerId: string): Promise<WorkerBackendsResponse> {
  const res = await fetch(`/api/workers/${encodeURIComponent(workerId)}/backends`, {
    credentials: 'include',
  });
  if (!res.ok && res.status !== 502) {
    // 502 still carries a typed payload from the server.
    return { source: 'direct', backends: [], error: `HTTP ${res.status}` };
  }
  return await res.json() as WorkerBackendsResponse;
}

export interface PetSummary {
  id: string;
  name: string;
  description: string | null;
  spriteFile: string | null;
  previewFile: string | null;
  frameWidth: number | null;
  frameHeight: number | null;
  gridCols: number | null;
  gridRows: number | null;
  updatedAt: string;
}

export interface PetDetail extends PetSummary {
  manifest: Record<string, unknown>;
}

export interface PetsResponse {
  pets: PetSummary[];
  settings: PetSettings;
}

const PETS_BASE = '/api/users/me/pets';

export function petAssetUrl(petId: string, file: string): string {
  return `${PETS_BASE}/${encodeURIComponent(petId)}/assets/${encodeURIComponent(file)}`;
}

export async function fetchPets(): Promise<PetsResponse> {
  const res = await fetch(PETS_BASE, { credentials: 'include' });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pets');
  return data;
}

export async function fetchPet(petId: string): Promise<PetDetail> {
  const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, { credentials: 'include' });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pet');
  return data.pet;
}

export async function importPet(file: File, options: { petId?: string; overwrite?: boolean } = {}): Promise<PetDetail> {
  const params = new URLSearchParams();
  params.set('filename', file.name);
  if (options.petId) params.set('petId', options.petId);
  if (options.overwrite) params.set('overwrite', 'true');
  const res = await fetch(`${PETS_BASE}/import?${params.toString()}`, {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/zip' },
    body: await file.arrayBuffer(),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data?.error ?? 'Failed to import pet');
  return data.pet;
}

export async function deletePet(petId: string): Promise<void> {
  const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, {
    method: 'DELETE',
    credentials: 'include',
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data?.error ?? 'Failed to delete pet');
}

export async function updatePetSettings(patch: Partial<PetSettings>): Promise<PetSettings> {
  const res = await fetch(`${PETS_BASE}/settings`, {
    method: 'PUT',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(patch),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error ?? 'Failed to update pet settings');
  return data.settings;
}

// ── Side Info Panel ────────────────────────────────────────────────────────

export type DashboardWidgetKind = 'markdown' | 'node-status';

export interface DashboardWidget {
  id: number;
  userId: string;
  slug: string;
  title: string;
  kind: DashboardWidgetKind;
  markdownContent: string;
  sortOrder: number;
  createdAt: string;
  updatedAt: string;
}

export interface NodeStatus {
  nodeId: string;
  workerId: string;
  source: 'direct' | 'proxy';
  online: boolean;
  busy: boolean;
  busySlots: number;
  totalSlots: number;
  loadedModel: string | null;
  throughputTps: number | null;
  lastSeen: string;
  lastProbeError?: string;
}

export interface WorkerStatusBackendRow {
  id: string;
  state: 'idle' | 'running';
  busySlots: number;
  totalSlots: number;
  online: boolean | null;
}

export interface WorkerStatusRow {
  id: string;
  name: string;
  roles: string[];
  state: 'idle' | 'running';
  /** True when this row represents a `proxy: true` worker. */
  proxy: boolean;
  /** Slot pressure from BackendStatusRegistry. Populated for direct workers with a registry probe row. */
  busySlots?: number;
  totalSlots?: number;
  online?: boolean;
  /** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */
  backends?: WorkerStatusBackendRow[];
}

export async function fetchDashboardWidgets(): Promise<DashboardWidget[]> {
  const res = await fetch('/api/local/dashboard/widgets');
  if (!res.ok) throw new Error(`Failed to list dashboard widgets: ${res.status}`);
  const body = await res.json();
  return body.widgets;
}

export async function createDashboardWidget(input: {
  slug: string;
  title: string;
  content?: string;
  kind?: DashboardWidgetKind;
}): Promise<DashboardWidget> {
  const res = await fetch('/api/local/dashboard/widgets', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  if (!res.ok) throw new Error(`Failed to create widget: ${res.status} ${await res.text()}`);
  return (await res.json()).widget;
}

export async function updateDashboardWidget(id: number, patch: {
  title?: string;
  content?: string;
}): Promise<DashboardWidget> {
  const res = await fetch(`/api/local/dashboard/widgets/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(patch),
  });
  if (!res.ok) throw new Error(`Failed to update widget: ${res.status} ${await res.text()}`);
  return (await res.json()).widget;
}

export async function deleteDashboardWidget(id: number): Promise<void> {
  const res = await fetch(`/api/local/dashboard/widgets/${id}`, { method: 'DELETE' });
  if (!res.ok && res.status !== 204) throw new Error(`Failed to delete widget: ${res.status}`);
}

export async function reorderDashboardWidgets(ids: number[]): Promise<void> {
  const res = await fetch('/api/local/dashboard/widgets/reorder', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ids }),
  });
  if (!res.ok) throw new Error(`Failed to reorder widgets: ${res.status}`);
}

export async function fetchWorkerStatuses(): Promise<WorkerStatusRow[]> {
  const res = await fetch('/api/local/dashboard/workers');
  if (!res.ok) throw new Error(`Failed to list worker statuses: ${res.status}`);
  return (await res.json()).workers;
}

/** Thrown by fetchNodeStatus when the registry is not configured (HTTP 503). */
export class NodeStatusUnavailableError extends Error {
  constructor() {
    super('node-status registry not configured');
    this.name = 'NodeStatusUnavailableError';
  }
}

export async function fetchNodeStatus(): Promise<NodeStatus[]> {
  const res = await fetch('/api/local/dashboard/node-status');
  if (!res.ok) {
    // 503 = registry not configured (e.g. legacy install). Surface as an
    // error so the React Query hook can back off polling instead of
    // hammering the server every 5s indefinitely. The hook turns this
    // into an empty list for rendering.
    if (res.status === 503) throw new NodeStatusUnavailableError();
    throw new Error(`Failed to list node status: ${res.status}`);
  }
  return (await res.json()).nodes;
}

// ── AAO Gateway: virtual key admin (Phase 2a + 2b) ──────────────────────
//
// Talks to /api/admin/gateway/keys/* — requires admin role. The raw
// bearer key is returned ONCE from POST / and POST /:id/rotate; UI must
// surface it to the user immediately and not expose it again.

export interface GatewayKey {
  id: string;
  object: 'gateway.key';
  keyPrefix: string;
  team: string;
  allowedModels: string[] | null;
  source: 'admin' | 'config-import';
  createdAt: string;
  createdBy: string | null;
  revokedAt: string | null;
  revokedBy: string | null;
  lastUsedAt: string | null;
  tokensBudget: number | null;
  rateLimitRpm: number | null;
  /** Only present on POST / rotate responses. */
  key?: string;
}

export interface GatewayKeyUsageResponse {
  keyId: string;
  currentPeriod: string;
  tokensIn: number;
  tokensOut: number;
  tokensTotal: number;
  tokensBudget: number | null;
  remaining: number | null;
  requestsThisMonth: number;
  rateLimitRpm: number | null;
  // Phase 3a F9: `rateRecentRequests` removed. The field was always
  // null because the admin process doesn't own the gateway's live
  // RateLimiter. Phase 3b/3c may re-add it via gateway IPC.
  history: Array<{ period: string; tokensIn: number; tokensOut: number; requests: number }>;
}

export async function listGatewayKeys(params?: { team?: string; activeOnly?: boolean }): Promise<GatewayKey[]> {
  const q = new URLSearchParams();
  if (params?.team) q.set('team', params.team);
  if (params?.activeOnly) q.set('activeOnly', 'true');
  const qs = q.toString();
  const res = await fetch(`/api/admin/gateway/keys${qs ? `?${qs}` : ''}`);
  if (!res.ok) throw new Error(`Failed to list gateway keys: ${res.status}`);
  return (await res.json()).keys;
}

export async function createGatewayKey(input: {
  team: string;
  allowedModels?: string[];
  tokensBudget?: number | null;
  rateLimitRpm?: number | null;
}): Promise<GatewayKey> {
  const res = await fetch(`/api/admin/gateway/keys`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Failed to create gateway key (${res.status}): ${text}`);
  }
  return res.json();
}

export async function getGatewayKey(id: string): Promise<GatewayKey> {
  const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`);
  if (!res.ok) throw new Error(`Failed to get gateway key: ${res.status}`);
  return res.json();
}

export async function patchGatewayKey(
  id: string,
  patch: {
    tokensBudget?: number | null;
    rateLimitRpm?: number | null;
    allowedModels?: string[] | null;
  },
): Promise<GatewayKey> {
  const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(patch),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Failed to update gateway key (${res.status}): ${text}`);
  }
  return res.json();
}

export async function revokeGatewayKey(id: string): Promise<void> {
  const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/revoke`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({}),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Failed to revoke gateway key (${res.status}): ${text}`);
  }
}

export async function rotateGatewayKey(id: string): Promise<GatewayKey> {
  const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/rotate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({}),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Failed to rotate gateway key (${res.status}): ${text}`);
  }
  return res.json();
}

export async function deleteGatewayKey(id: string): Promise<void> {
  const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, { method: 'DELETE' });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Failed to delete gateway key (${res.status}): ${text}`);
  }
}

export async function getGatewayKeyUsage(id: string): Promise<GatewayKeyUsageResponse> {
  const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/usage`);
  if (!res.ok) throw new Error(`Failed to get gateway key usage: ${res.status}`);
  return res.json();
}

// ============================================================
// Gateway Server status (Phase 3c) — read-only admin endpoint.
// Drives the Settings → Gateway Server badge and error list.
// ============================================================
export type GatewayServerState =
  | 'unavailable'
  | 'disabled'
  | 'starting'
  | 'running'
  | 'stopping'
  | 'misconfigured';

export interface GatewayServerStatus {
  state: GatewayServerState;
  /** Desired-enabled flag read from current config. Null when no ConfigManager. */
  enabled: boolean | null;
  /** Validation errors that prevented the gateway from starting. */
  errors: string[];
  mounted: boolean;
  sharedPort: number;
  /** Only present when state==='unavailable'. */
  message?: string;
}

export async function getGatewayServerStatus(): Promise<GatewayServerStatus> {
  const res = await fetch('/api/admin/gateway/status');
  if (!res.ok) throw new Error(`Failed to get gateway status: ${res.status}`);
  return res.json();
}

// ── Skills API ──────────────────────────────────────────────────────────

export interface SkillSummary {
  name: string;
  description: string;
  triggers: string[];
  source: 'system' | 'user';
  hasDir: boolean;
}

export interface SkillDetail extends SkillSummary {
  /** Body only (frontmatter stripped) — for read-only preview. */
  content: string;
  /** Full SKILL.md incl. frontmatter — what the editor must edit/save. */
  raw: string;
  files: string[];
  findings: Array<{ severity: 'medium' | 'high'; pattern: string; match: string; line: number; file?: string }>;
  maxSeverity: 'high' | 'medium' | 'none';
}

export async function fetchSkills(scope?: string): Promise<SkillSummary[]> {
  const params = scope ? `?scope=${scope}` : '';
  const res = await fetch(`/api/skills${params}`);
  if (!res.ok) throw new Error('Failed to fetch skills');
  const data = await res.json();
  return data.skills;
}

export async function fetchSkillDetail(name: string, scope?: string): Promise<SkillDetail> {
  const params = scope ? `?scope=${scope}` : '';
  const res = await fetch(`/api/skills/${encodeURIComponent(name)}${params}`);
  if (!res.ok) throw new Error('Skill not found');
  return res.json();
}

export async function createSkill(name: string, content: string, scope: string): Promise<{ name: string; severity: string; findings: any[] }> {
  const res = await fetch('/api/skills', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, content, scope }),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: 'Unknown error' }));
    throw new Error(err.error);
  }
  return res.json();
}

export async function updateSkill(name: string, content: string, scope: string): Promise<any> {
  const res = await fetch(`/api/skills/${encodeURIComponent(name)}?scope=${scope}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ content }),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: 'Unknown error' }));
    throw new Error(err.error);
  }
  return res.json();
}

export async function deleteSkill(name: string, scope: string): Promise<void> {
  const res = await fetch(`/api/skills/${encodeURIComponent(name)}?scope=${scope}`, { method: 'DELETE' });
  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: 'Unknown error' }));
    throw new Error(err.error);
  }
}

export async function installSkillFromUrl(url: string, scope: string, selectedSkills?: string[]): Promise<any> {
  const res = await fetch('/api/skills/install-from-url', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ url, scope, selectedSkills }),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: 'Unknown error' }));
    throw new Error(err.error);
  }
  return res.json();
}

// ── Notifications V2 (Web Push) ───────────────────────────────────────

export type NotifyEventType = 'running' | 'succeeded' | 'failed' | 'waiting_human';

export interface NotificationPrefsDTO {
  userId: string;
  enabled: boolean;
  events: Record<NotifyEventType, boolean>;
  includeDetails: boolean;
  v1Migrated: boolean;
  updatedAt: string;
}

export interface NotificationPrefsInput {
  enabled?: boolean;
  events?: Partial<Record<NotifyEventType, boolean>>;
  includeDetails?: boolean;
}

export interface PushSubscriptionPublic {
  id: string;
  endpointHost: string;
  userAgent: string | null;
  createdAt: string;
  lastSuccessAt: string | null;
  lastFailureAt: string | null;
  failureCount: number;
}

export interface VapidPublicKeyDTO {
  publicKey: string;
  keyId: string;
}

async function notificationsJsonOrThrow<T>(res: Response, fallback: string): Promise<T> {
  const data = await res.json().catch(() => ({} as Record<string, unknown>));
  if (!res.ok) throw new Error((data as { error?: string }).error ?? fallback);
  return data as T;
}

export async function fetchVapidPublicKey(): Promise<VapidPublicKeyDTO> {
  const res = await fetch(`${BASE}/notifications/vapid-public-key`);
  return notificationsJsonOrThrow(res, 'failed to fetch VAPID key');
}

export async function listPushSubscriptions(): Promise<PushSubscriptionPublic[]> {
  const res = await fetch(`${BASE}/notifications/subscriptions`);
  const data = await notificationsJsonOrThrow<{ subscriptions: PushSubscriptionPublic[] }>(
    res, 'failed to list subscriptions',
  );
  return data.subscriptions;
}

export async function postPushSubscription(input: {
  endpoint: string;
  p256dh: string;
  auth: string;
  userAgent?: string;
}): Promise<{ id: string }> {
  const res = await fetch(`${BASE}/notifications/subscriptions`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  return notificationsJsonOrThrow(res, 'failed to register subscription');
}

export async function deletePushSubscription(id: string): Promise<void> {
  const res = await fetch(`${BASE}/notifications/subscriptions/${id}`, { method: 'DELETE' });
  await notificationsJsonOrThrow(res, 'failed to delete subscription');
}

export async function fetchNotificationPrefs(): Promise<NotificationPrefsDTO> {
  const res = await fetch(`${BASE}/notifications/preferences`);
  return notificationsJsonOrThrow(res, 'failed to fetch preferences');
}

export async function updateNotificationPrefs(
  input: NotificationPrefsInput,
): Promise<NotificationPrefsDTO> {
  const res = await fetch(`${BASE}/notifications/preferences`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  return notificationsJsonOrThrow(res, 'failed to update preferences');
}

export async function migrateLocalStoragePrefs(
  input: NotificationPrefsInput,
): Promise<{ ok: boolean; prefs: NotificationPrefsDTO } | { alreadyMigrated: true }> {
  const res = await fetch(`${BASE}/notifications/preferences/migrate-from-localstorage`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  if (res.status === 409) return { alreadyMigrated: true };
  return notificationsJsonOrThrow(res, 'failed to migrate preferences');
}

export async function postTestNotification(): Promise<{ ok: boolean }> {
  const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' });
  return notificationsJsonOrThrow(res, 'failed to send test notification');
}

// ============================================================
// LLM usage dashboard v2 (per-user, multi-axis group-by + local timezone).
// Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
export interface UsageCounters {
  tokensIn: number;
  tokensOut: number;
  requests: number;
}
export type UsageGroupBy = 'source' | 'model' | 'route' | 'user' | 'org';

/** One time bucket: a per-series-key map of counters. Keys come from `keys`. */
export interface UsageBucket {
  bucket: string;            // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM'
  segments: Record<string, UsageCounters>;
}
export interface UsageByUser extends UsageCounters {
  userId: string;
  /** Resolved display name (real users); 'local' / 'system' for sentinels. */
  displayName: string;
}
export interface UsageDailyResponse {
  from: string;
  to: string;
  granularity: 'day' | 'week' | 'month';
  groupBy: UsageGroupBy;
  tzOffset: number;
  scope: 'all' | 'self';
  /** Ordered series keys (legend/palette order). */
  keys: string[];
  /** Human labels for keys (only populated for groupBy=user; else key=label). */
  labels: Record<string, string>;
  series: UsageBucket[];
  totals: Record<string, UsageCounters>;
  byUser?: UsageByUser[];   // admin / local mode only
}

export async function getUsageDaily(params: {
  from?: string;
  to?: string;
  granularity?: 'day' | 'week' | 'month';
  groupBy?: UsageGroupBy;
  tzOffset?: number;
}): Promise<UsageDailyResponse> {
  const qs = new URLSearchParams();
  if (params.from) qs.set('from', params.from);
  if (params.to) qs.set('to', params.to);
  if (params.granularity) qs.set('granularity', params.granularity);
  if (params.groupBy) qs.set('groupBy', params.groupBy);
  if (params.tzOffset !== undefined) qs.set('tzOffset', String(params.tzOffset));
  const q = qs.toString();
  const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`);
  if (!res.ok) throw new Error(`Failed to load usage (${res.status})`);
  return res.json();
}
