// REST API client — all calls go through the backend gateway.
// Auth: Authorization: Bearer <token> header. Token read from sessionStorage (preferred) or localStorage ('omnipus_auth_token'). Backend validates against per-user RBAC token hashes or legacy OMNIPUS_BEARER_TOKEN env var.
// CSRF: X-CSRF-Token header echoes the __Host-csrf cookie value on every
// state-changing request (double-submit cookie, issue #97). The cookie is
// issued by the backend on /auth/login, /auth/register-admin, and
// /onboarding/complete. State-changing calls made before the cookie exists
// fail fast client-side so the UI surfaces an actionable error instead of
// waiting for the server's 403.
//
// Errors: request() throws ApiError on non-2xx responses and on transport
// failures (network down, fetch threw). Callers should branch on err.status
// (or err.isAuthError() / err.isNotFound() / err.isRateLimited() / etc)
// rather than regex-matching err.message — see src/lib/api-error.ts.

import { ApiError } from './api-error'
export { ApiError, isApiError } from './api-error'
import { maybeDevToast } from './dev-toast'

import type { ZodType } from 'zod'
import { z } from 'zod'

// ── Generated Zod schemas (REST edge validation) ────────────────────────────
//
// These are the generated schemas from contracts/openapi.yaml. They are used
// to validate API responses at the SPA edge (hard-constraint #8). Callers
// that need a SPA-internal transform type before passing to consumers are
// validated against the wire schema first, then transformed.
//
// GAP REPORT — endpoints that genuinely cannot use a generated schema:
//   GET /config / PUT /config — Config is a deep SPA transform (rawToFrontendConfig /
//     frontendToRawConfig); no named schema component matches the wire shape exactly.
//     Wire shape is an untyped JSON object; the transform is the contract.
//   GET /channels/:id       — Record<string,unknown> passthrough; schema varies per channel.
//   PUT /channels/:id/configure — void; channel-specific body; no generated schema.
//   GET /credentials        — wire returns string[]; SPA shape is CredentialKey[]; the
//     SPA-internal shape is a SPA-only concern, not a generated schema component.
//   GET /about              — AboutInfo is a looser SPA-compatibility subset of AboutResponse
//     (different field names: uptime_seconds vs uptime); cannot validate without false negatives.

import {
  ChannelRouting as ChannelRoutingSchema,
  LoginResponse as LoginResponseSchema,
  ProbeProviderResponse as ProbeProviderResponseSchema,
  Agent as AgentSchema,
  AgentSession as AgentSessionSchema,
  AuditEntry as AuditEntrySchema,
  AuditLogToggle as AuditLogToggleSchema,
  ExecAllowlist as ExecAllowlistSchema,
  ExecProxyStatus as ExecProxyStatusSchema,
  GlobalToolPolicies as GlobalToolPoliciesSchema,
  PendingRestartEntry as PendingRestartEntrySchema,
  PromptGuardResponse as PromptGuardResponseSchema,
  RetentionConfig as RetentionConfigSchema,
  RetentionSweepResult as RetentionSweepResultSchema,
  SandboxConfig as SandboxConfigSchema,
  SandboxStatus as SandboxStatusSchema,
  SessionScopeResponse as SessionScopeResponseSchema,
  SkillTrustResponse as SkillTrustResponseSchema,
  UserCreateResponse as UserCreateResponseSchema,
  UserDeleteResponse as UserDeleteResponseSchema,
  UserResetPasswordResponse as UserResetPasswordResponseSchema,
  UserRoleChangeResponse as UserRoleChangeResponseSchema,
  // New generated Zod schemas (contract-first #8):
  AppState as AppStateSchema,
  ValidateTokenResponse as ValidateTokenResponseSchema,
  DoctorResult as DoctorResultSchema,
  DevicesResponse as DevicesResponseSchema,
  BackupEntry as BackupEntrySchema,
  StorageStats as StorageStatsSchema,
  MeInfo as MeInfoSchema,
  // Newly wired schemas:
  Provider as ProviderSchema,
  Task as TaskSchema,
  GatewayStatus as GatewayStatusSchema,
  ToolRegistryEntry as ToolRegistryEntrySchema,
  ChannelEntry as ChannelEntrySchema,
  ChannelEnabledResponse as ChannelEnabledResponseSchema,
  Skill as SkillSchema,
  McpServer as McpServerSchema,
  ActivityEvent as ActivityEventSchema,
  // Wire-shape schemas used for raw-to-SPA transform validation:
  Message as WireMessageSchema,
  Session as WireSessionSchema,
  SessionDetail as WireSessionDetailSchema,
  // Newly promoted from inline openapi.yaml schemas:
  AuditLogUpdateResponse as AuditLogUpdateResponseSchema,
  SkillTrustUpdateResponse as SkillTrustUpdateResponseSchema,
  PromptGuardUpdateResponse as PromptGuardUpdateResponseSchema,
  RateLimitsResponse as RateLimitsResponseSchema,
  RateLimitsUpdateResponse as RateLimitsUpdateResponseSchema,
  SessionScopeUpdateResponse as SessionScopeUpdateResponseSchema,
  RetentionUpdateResponse as RetentionUpdateResponseSchema,
  AgentToolsResponse as AgentToolsResponseSchema,
  ChannelTestResponse as ChannelTestResponseSchema,
  OperationResult as OperationResultSchema,
  UploadFilesResponse as UploadFilesResponseSchema,
  BackupCreateResponse as BackupCreateResponseSchema,
  User as UserSchema,
  // fix-AC: promoted from hand-written inline schemas:
  UserContextResponse as UserContextResponseSchema,
  McpServerToolsResponse as McpServerToolsResponseSchema,
} from '@/lib/api/generated/schemas'

// ── Schema validation error ────────────────────────────────────────────────────
//
// Thrown when an API response does not conform to the expected Zod schema.
// Only thrown when a schema was explicitly passed to request() — unvalidated
// calls fall back to the old untyped behaviour.
//
// In dev mode, a toast is emitted as well (see request() below).

export class ApiSchemaError extends Error {
  readonly endpoint: string
  readonly zodIssues: Array<{ path: (string | number)[]; message: string }>
  readonly rawBody: unknown

  constructor(endpoint: string, zodIssues: Array<{ path: (string | number)[]; message: string }>, rawBody: unknown) {
    super(`API response schema mismatch for ${endpoint}: ${zodIssues[0]?.message ?? 'unknown'}`)
    this.name = 'ApiSchemaError'
    this.endpoint = endpoint
    this.zodIssues = zodIssues
    this.rawBody = rawBody
  }
}

let _apiSchemaErrorCount = 0

export function getApiSchemaErrorCount(): number {
  return _apiSchemaErrorCount
}

export function resetApiSchemaErrorCount(): void {
  _apiSchemaErrorCount = 0
}

// Config validEnum coercion counter — incremented each time validEnum replaces
// an unexpected backend enum value with the fallback. Exposed on
// window.__omnipus_test_hooks so Playwright tests can assert coercion health.
let _configCoercionCount = 0

export function getConfigCoercionCount(): number {
  return _configCoercionCount
}

export function resetConfigCoercionCount(): void {
  _configCoercionCount = 0
}

// Expose counters on window.__omnipus_test_hooks in DEV/test builds
// so Playwright tests can assert on validation health without reaching into module
// internals.
if ((import.meta.env.DEV || import.meta.env.MODE === 'test') && typeof window !== 'undefined') {
  const w = window as unknown as { __omnipus_test_hooks?: Record<string, unknown> }
  w.__omnipus_test_hooks ??= {}
  w.__omnipus_test_hooks.getApiSchemaErrorCount = getApiSchemaErrorCount
  w.__omnipus_test_hooks.resetApiSchemaErrorCount = resetApiSchemaErrorCount
  w.__omnipus_test_hooks.getConfigCoercionCount = getConfigCoercionCount
  w.__omnipus_test_hooks.resetConfigCoercionCount = resetConfigCoercionCount
}

// ── Re-exports from generated types ───────────────────────────────────────────
//
// Wire-format types come from the generated openapi-types.ts. The hand-written
// interfaces below replace the generated types only when the SPA-internal shape
// differs from the wire shape (e.g. Session flattens nested stats, ToolCall uses
// `params` while the wire uses `parameters`).
//
// RULE: types with hand-written bodies below are imported aliased and NOT
// re-exported from generated — the local export interface is canonical.
// Types without local bodies are imported and immediately re-exported.
// See CLAUDE.md hard-constraint #8.

// Types whose generated shape is canonical (no local body) — import into scope
// so function return-type annotations compile, then re-export for consumers.
import type {
  LoginResponse,
  ProbeProviderResponse,
  AgentSession,
  AgentToolEntry,
  SessionStats,
  Attachment,
  User,
  UserCreateRequest,
  UserCreateResponse,
  UserDeleteResponse,
  UserRoleChangeRequest,
  UserRoleChangeResponse,
  UserResetPasswordRequest,
  UserResetPasswordResponse,
  SessionScopeRequest,
  SessionScopeResponse,
  ToolRegistryEntry,
  GlobalToolPolicies,
  ToolPolicy,
  ChannelEntry,
  RetentionConfig,
  RetentionSweepResult,
  SandboxConfig,
  SandboxConfigUpdate,
  SandboxStatus,
  AuditEntry,
  AuditLogToggle,
  RateLimitConfig,
  ExecAllowlist,
  ExecProxyStatus,
  SkillTrustResponse,
  PromptGuardResponse,
  PendingRestartEntry,
  AboutResponse,
  // Wire types migrated from hand-written interfaces to generated types:
  Agent,
  Provider,
  GatewayStatus,
  Skill,
  ActivityEvent,
  UploadedFile,
  AgentToolsCfg,
  OnboardingCompleteRequest,
  // New wire types (contract-first #8):
  Task,
  McpServer,
  McpServerCreate,
  AppState,
  ValidateTokenResponse,
  DoctorIssue,
  DoctorResult,
  DevicePending,
  DevicePaired,
  DevicesResponse,
  BackupEntry,
  StorageStats,
  MeInfo,
  // Newly promoted from inline openapi.yaml schemas:
  AuditLogUpdateResponse,
  SkillTrustUpdateRequest,
  SkillTrustUpdateResponse,
  PromptGuardUpdateRequest,
  PromptGuardUpdateResponse,
  RateLimitsResponse,
  RateLimitsUpdateRequest,
  RateLimitsUpdateResponse,
  SessionScopeUpdateResponse,
  RetentionUpdateResponse,
  ChannelEnabledResponse,
  AgentToolsResponse,
  UploadFilesResponse,
  BackupCreateResponse,
  ChannelTestResponse,
  OperationResult,
  // fix-AC: promoted from hand-written inline schemas:
  UserContextResponse,
  McpServerToolsResponse,
  AgentUpdateRequest,
  AgentCreateRequest,
  ChannelRouting,
} from '@/lib/api/generated/openapi-types'

export type {
  LoginResponse,
  ProbeProviderResponse,
  AgentSession,
  AgentToolEntry,
  SessionStats,
  Attachment,
  User,
  UserCreateRequest,
  UserCreateResponse,
  UserDeleteResponse,
  UserRoleChangeRequest,
  UserRoleChangeResponse,
  UserResetPasswordRequest,
  UserResetPasswordResponse,
  SessionScopeRequest,
  SessionScopeResponse,
  ToolRegistryEntry,
  GlobalToolPolicies,
  ToolPolicy,
  ChannelEntry,
  RetentionConfig,
  RetentionSweepResult,
  SandboxConfig,
  SandboxStatus,
  AuditEntry,
  AuditLogToggle,
  RateLimitConfig,
  ExecAllowlist,
  ExecProxyStatus,
  SkillTrustResponse,
  PromptGuardResponse,
  PendingRestartEntry,
  AboutResponse,
  // Wire types migrated from hand-written interfaces:
  Agent,
  Provider,
  GatewayStatus,
  Skill,
  ActivityEvent,
  UploadedFile,
  AgentToolsCfg,
  OnboardingCompleteRequest,
  // New wire types:
  Task,
  McpServer,
  McpServerCreate,
  AppState,
  ValidateTokenResponse,
  DoctorIssue,
  DoctorResult,
  DevicePending,
  DevicePaired,
  DevicesResponse,
  BackupEntry,
  StorageStats,
  MeInfo,
  // Promoted from inline openapi.yaml schemas:
  AuditLogUpdateResponse,
  SkillTrustUpdateRequest,
  SkillTrustUpdateResponse,
  PromptGuardUpdateRequest,
  PromptGuardUpdateResponse,
  RateLimitsResponse,
  RateLimitsUpdateRequest,
  RateLimitsUpdateResponse,
  SessionScopeUpdateResponse,
  RetentionUpdateResponse,
  ChannelEnabledResponse,
  AgentToolsResponse,
  UploadFilesResponse,
  BackupCreateResponse,
  ChannelTestResponse,
  OperationResult,
  // fix-AC: promoted from hand-written inline schemas:
  UserContextResponse,
  McpServerToolsResponse,
  AgentUpdateRequest,
  AgentCreateRequest,
  ChannelRouting,
}

const BASE_URL = import.meta.env.VITE_API_URL ?? ''

// The server issues one of two cookie names depending on the request's TLS
// state. TLS: __Host-csrf (browser enforces Secure + Path=/ + no Domain).
// Plain HTTP: csrf (no __Host- prefix, Secure=false) — __Host- cookies are
// silently dropped by browsers on non-localhost plain-HTTP origins.
// Keep both constants in sync with pkg/gateway/middleware/csrf.go.
const CSRF_COOKIE_NAME = '__Host-csrf'
const CSRF_COOKIE_NAME_HTTP = 'csrf'
const CSRF_HEADER_NAME = 'X-CSRF-Token'
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])

// CSRF_EXEMPT_PATHS lists state-changing endpoints whose handler's job is to
// ISSUE the __Host-csrf cookie. They can't require the cookie to be present
// — that's the chicken-and-egg bootstrap problem. Keep this list in sync
// with pkg/gateway/middleware/csrf.go `exemptPaths` for /api/v1/* entries.
// Paths here are compared against the /api/v1/-prefixed URL.
//
// Why each entry is here:
//   - /api/v1/onboarding/complete — called on fresh install (no cookie exists).
//   - /api/v1/auth/login — called on first load of an existing install
//     (refresh, new tab); cookie may be absent until the login succeeds.
//   - /api/v1/auth/register-admin — first-boot admin account creation.
const CSRF_EXEMPT_PATHS = new Set<string>([
  '/api/v1/onboarding/complete',
  '/api/v1/onboarding/probe-provider',
  '/api/v1/auth/login',
  '/api/v1/auth/register-admin',
])

// readCSRFCookie parses document.cookie and returns the __Host-csrf value,
// or null if the cookie is absent. We intentionally do not cache — cookies
// can change after login/logout/onboarding and caching would cause stale
// tokens on the next state-changing call.
function readCSRFCookie(): string | null {
  if (typeof document === 'undefined') return null
  // Try __Host-csrf (TLS) first, then the plain-HTTP fallback.
  for (const name of [CSRF_COOKIE_NAME, CSRF_COOKIE_NAME_HTTP]) {
    const prefix = `${name}=`
    for (const part of document.cookie.split(';')) {
      const trimmed = part.trim()
      if (trimmed.startsWith(prefix)) {
        const raw = trimmed.slice(prefix.length)
        // Apply decodeURIComponent defensively: if the browser percent-encoded
        // the cookie value (e.g. standard base64 "=", "+", "/"), we decode it
        // so the header value matches what the server originally set. If
        // decoding fails (malformed sequence such as a lone "%"), fall back to
        // the raw string and let the server compare verbatim.
        try {
          return decodeURIComponent(raw)
        } catch {
          return raw
        }
      }
    }
  }
  return null
}

function getAuthHeaders(): HeadersInit {
  const token = sessionStorage.getItem('omnipus_auth_token') ?? localStorage.getItem('omnipus_auth_token')
  return token ? { Authorization: `Bearer ${token}` } : {}
}

// buildHeaders composes the standard request headers, layering (in order):
// content-type → bearer auth → CSRF header → caller overrides. The CSRF
// header is added unconditionally when the cookie exists; safe GETs pass
// it through too because doing so is harmless and keeps the code simple.
function buildHeaders(extra?: HeadersInit): HeadersInit {
  const csrf = readCSRFCookie()
  return {
    'Content-Type': 'application/json',
    ...getAuthHeaders(),
    ...(csrf ? { [CSRF_HEADER_NAME]: csrf } : {}),
    ...extra,
  }
}

// isPathCSRFExempt checks whether a state-changing call can skip the
// client-side cookie-presence check. Only onboarding-complete is exempt —
// see CSRF_EXEMPT_PATHS.
function isPathCSRFExempt(apiPath: string): boolean {
  return CSRF_EXEMPT_PATHS.has(`/api/v1${apiPath}`)
}

async function request<T>(path: string, init?: RequestInit, schema?: ZodType<T>): Promise<T> {
  // Client-side CSRF gate: reject state-changing calls that would be
  // guaranteed to 403 at the server. This gives a clear error immediately
  // instead of a cryptic "403 csrf cookie missing" from the network tab
  // and also prevents a cascade of dependent requests firing during a
  // broken auth state.
  //
  // We synthesize an ApiError with status 403 + a CSRF-specific code so
  // callers can branch on `err.code === 'csrf_missing'` without having to
  // string-match the message. The message text is preserved verbatim from
  // the previous implementation for any caller that hasn't migrated yet.
  const method = (init?.method ?? 'GET').toUpperCase()
  if (
    STATE_CHANGING_METHODS.has(method) &&
    !isPathCSRFExempt(path) &&
    readCSRFCookie() === null
  ) {
    throw new ApiError(
      403,
      `CSRF cookie missing — cannot ${method} ${path}. ` +
        `Log in or complete onboarding first so the server can issue the CSRF cookie.`,
      { code: 'csrf_missing' },
    )
  }

  let res: Response
  try {
    res = await fetch(`${BASE_URL}/api/v1${path}`, {
      ...init,
      headers: buildHeaders(init?.headers),
    })
  } catch (cause) {
    // Transport-level failure — DNS, TCP, TLS, AbortController, or fetch threw
    // for any other reason. Surface as a status-0 ApiError so callers can
    // distinguish "browser couldn't reach the server" from "server said no".
    throw new ApiError(0, 'Network unavailable. Check your connection.', { cause })
  }
  if (!res.ok) {
    throw await ApiError.fromResponse(res)
  }

  // Parse the response body, handling non-JSON (e.g. unexpected HTML 200)
  // gracefully — surface as ApiSchemaError with the raw text as rawBody so
  // callers can see what the server actually sent.
  let body: unknown
  try {
    body = await res.json() as unknown
  } catch (cause) {
    const rawText = String(cause instanceof Error ? cause.message : cause)
    if (schema !== undefined) {
      _apiSchemaErrorCount++
      const schemaErr = new ApiSchemaError(
        `${method} /api/v1${path}`,
        [{ path: [], message: 'Response is not valid JSON' }],
        rawText,
      )
      void maybeDevToast(`[api] Non-JSON response: ${path}`, `${method}:${path}:non-json`)
      throw schemaErr
    }
    // No schema — throw a generic ApiError for non-JSON bodies on non-2xx
    // (we already checked res.ok above, so a JSON parse error here on a 200
    // is itself unexpected; surface as a 0-status transport error).
    throw new ApiError(0, `Response from ${path} is not valid JSON`, { cause })
  }

  if (import.meta.env.DEV && schema === undefined) {
    console.warn(`[api] ${method} /api/v1${path}: no Zod schema — response validation skipped. Add schema from src/lib/api/generated/schemas.ts.`)
  }

  // When a Zod schema is provided, validate the response body against it.
  // On failure: throw ApiSchemaError (+ dev toast). Never silently return
  // schema-invalid data — callers that need the old unchecked behaviour
  // should not pass a schema.
  if (schema !== undefined) {
    const result = schema.safeParse(body)
    if (!result.success) {
      _apiSchemaErrorCount++
      const schemaErr = new ApiSchemaError(
        `${method} /api/v1${path}`,
        result.error.issues.map((i) => ({ path: i.path as (string | number)[], message: i.message })),
        body,
      )
      const first = schemaErr.zodIssues[0]
      void maybeDevToast(`[api] Schema mismatch: ${path} — ${first?.message ?? 'unknown'}`, `${method}:${path}:schema`)
      throw schemaErr
    }
    return result.data
  }

  return body as T
}

// ── Agents ────────────────────────────────────────────────────────────────────

// 'none' is a UI-only sentinel meaning "inherit global default" — it is NOT a
// valid contract value and must be stripped before submission (see AgentProfile.tsx).
// The wire format only accepts: workspace | workspace+net | host | off.
export type SandboxProfile = 'none' | 'workspace' | 'workspace+net' | 'host' | 'off' // not-wire-format: 'none' is UI-only; strip to undefined before send

export interface AgentShellPolicy { // not-wire-format: SPA-internal helper type — the shell_policy field on the generated Agent type is an inline anonymous object; this interface is never sent to or received from the gateway as a standalone value
  enable_deny_patterns?: boolean
  custom_deny_patterns?: string[]
}

// Agent — re-exported from generated openapi-types (contract-first #8).
// The generated type is the source of truth; see contracts/components/schemas/Agent.yaml.

export function fetchAgents(): Promise<Agent[]> {
  return request<Agent[]>('/agents', undefined, z.array(AgentSchema) as ZodType<Agent[]>)
}

export function fetchAgent(id: string): Promise<Agent> {
  return request<Agent>(`/agents/${encodeURIComponent(id)}`, undefined, AgentSchema as ZodType<Agent>)
}

export function createAgent(data: AgentCreateRequest): Promise<Agent> {
  return request<Agent>('/agents', { method: 'POST', body: JSON.stringify(data) }, AgentSchema as ZodType<Agent>)
}

export function updateAgent(id: string, data: AgentUpdateRequest): Promise<Agent> {
  return request<Agent>(`/agents/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) }, AgentSchema as ZodType<Agent>)
}

// AgentSession — re-exported from generated openapi-types (no local body needed).

export function fetchAgentSessions(agentId: string): Promise<AgentSession[]> {
  return request<AgentSession[]>(`/agents/${encodeURIComponent(agentId)}/sessions`, undefined, z.array(AgentSessionSchema))
}

// ── Sessions ──────────────────────────────────────────────────────────────────

export interface Session { // not-wire-format: SPA transformation type produced by rawToSession(). Flattens the nested stats sub-object from the wire RawSession into top-level fields (message_count, total_tokens, total_cost). The wire shape is the generated Session schema; this SPA shape is intentionally different.
  id: string
  agent_id: string
  title: string
  type: 'chat' | 'task' | 'channel'
  status?: 'active' | 'archived' | 'interrupted'
  task_id?: string
  created_at: string
  updated_at: string
  message_count: number
  total_tokens?: number
  total_cost?: number
  // Multi-agent session fields — present on sessions created with the joined
  // session model. For legacy single-agent sessions these are absent; callers
  // should fall back to [agent_id] when agent_ids is undefined.
  agent_ids?: string[]      // all agents that participated in this session
  active_agent_id?: string  // the agent currently handling this session
}

interface _RawSessionInternal { // not-wire-format: SPA-internal adapter that renames nested stats fields before public Session type; the wire shape is validated via WireSessionSchema, this type only models the pre-transform intermediate
  id: string
  agent_id: string
  title: string
  type?: 'chat' | 'task' | 'channel'
  status?: 'active' | 'archived' | 'interrupted'
  task_id?: string
  created_at: string
  updated_at: string
  agent_ids?: string[]
  active_agent_id?: string
  stats?: {
    tokens_in: number
    tokens_out: number
    tokens_total: number
    cost: number
    tool_calls: number
    message_count: number
  }
}

// Alias for backward-compat within this file (rawToSession signature).
type RawSession = _RawSessionInternal

function rawToSession(raw: RawSession): Session {
  return {
    id: raw.id,
    agent_id: raw.agent_id,
    title: raw.title,
    // Legacy sessions without a type field default to 'chat'
    type: raw.type ?? 'chat',
    status: raw.status,
    task_id: raw.task_id,
    created_at: raw.created_at,
    updated_at: raw.updated_at,
    message_count: raw.stats?.message_count ?? 0,
    total_tokens: raw.stats?.tokens_total,
    total_cost: raw.stats?.cost,
    agent_ids: raw.agent_ids,
    active_agent_id: raw.active_agent_id,
  }
}

// ── Role-discriminated Message union (#3) ─────────────────────────────────────
//
// Each role declares exactly the statuses that are legal for it:
//   user      — 'done' (normal) | 'error' (failed send, retriable via UserMessageRetryButton)
//   assistant — 'streaming' | 'done' | 'error' | 'interrupted'
//   system    — 'done' (informational banners; no error/streaming states)
//
// Using a discriminated union means that `(role:'user', status:'error')` is
// representable AND handled: TypeScript exhausts the union in switch/if blocks
// so adding a new role or status without updating renderers becomes a type error.
//
// Consumers that accept any message use the union alias `Message` (unchanged
// surface area). Narrowing by role gives the role-specific status set.

interface MessageBase { // not-wire-format
  id: string
  session_id?: string
  content: string
  timestamp: string
  tokens?: number
  cost?: number
}

export interface UserMessage extends MessageBase { // not-wire-format: SPA-internal user message. Status 'error' means the WS send failed; Retry button re-sends the content.
  role: 'user'
  /** 'done' — delivered to gateway. 'error' — WS send failed; show Retry. */
  status?: 'done' | 'error'
  tool_calls?: never
}

export interface AssistantMessage extends MessageBase { // not-wire-format: SPA-internal assistant message. Status diverges from wire ('streaming'/'done' vs wire 'ok'/'error'). tool_calls uses params (not wire parameters). NOT the same as the generated Message schema.
  role: 'assistant'
  /** 'streaming' — turn in progress. 'done' — complete. 'error' — agent error. 'interrupted' — user cancelled. */
  status?: 'streaming' | 'done' | 'error' | 'interrupted'
  tool_calls?: ToolCall[]
}

export interface SystemMessage extends MessageBase { // not-wire-format: SPA-internal system/banner message.
  role: 'system'
  status?: 'done'
  tool_calls?: never
}

/** Union of all SPA-internal message shapes. Discriminate on `role`. */ // not-wire-format
export type Message = UserMessage | AssistantMessage | SystemMessage

export interface ToolCall { // not-wire-format: SPA-internal tool call shape. Uses 'params' for the input parameters while the wire ToolCall schema uses 'parameters'. The status enum also differs (SPA adds 'running'; wire uses 'pending'/'denied'). This type is intentionally different from the generated ToolCall schema.
  id: string
  tool: string
  params: Record<string, unknown>
  result?: unknown
  status: 'running' | 'success' | 'error' | 'cancelled'
  duration_ms?: number
  error?: string
}

// ── Tool Results ──────────────────────────────────────────────────────────────
//
// Typed shapes for the JSON result payloads emitted by specific tools.
// These are parsed from ToolCall.result (which is `unknown` on the wire) by
// the tool-UI components in src/components/chat/tools/.
//
// Pre-unification result shapes: ServeWorkspaceResult, RunInWorkspaceResult.
// New tool-result code on the agent side emits `WebServeResult` (defined in
// WebServeUI.tsx); the two shapes here remain the iframe prop carriers that
// `WebServeBlock` casts into and that `IframePreview` consumes — so they
// are NOT dead. Replay paths (chat transcripts saved before unification)
// also rely on these shapes when rendering historical sessions.

/**
 * Result shape originally emitted by the legacy `serve_workspace` tool.
 *
 * Used as the static-mode iframe prop carrier on the canonical `web_serve`
 * code path: `WebServeBlock` casts the static-mode result of `web_serve`
 * into this shape and feeds it to `IframePreview` (kind=`'web_serve'`).
 * Also produced by the legacy `serve_workspace` tool in pre-unification
 * chat transcripts so the SPA can render historical sessions.
 *
 * Tool-result authors should produce `WebServeResult`; only this shape is
 * exposed to the iframe layer.
 *
 * `path` is the relative preview path (e.g. `"/preview/<agent>/<token>/"`
 * or the legacy `"/serve/<agent>/<token>/"`).
 * `url` is the absolute URL preserved for transcript replay safety — old
 * transcripts may contain legacy `0.0.0.0` URLs that the SPA rewrites via
 * `rewriteLegacyURL` in `src/lib/preview-url.ts`.
 */
export interface ServeWorkspaceResult { // not-wire-format: parsed from ToolCall.result (typed as unknown on the wire). Not a direct REST or WebSocket response schema — this is a tool-result payload shape that the SPA casts from the opaque result field. See WebServeBlock in chat/tools/.
  /** Relative preview path, e.g. `"/preview/<agent>/<token>/"`. */
  path: string
  /** Absolute URL — preserved for replay safety; may contain legacy hosts. */
  url: string
  /** ISO-8601 token expiry timestamp. */
  expires_at: string
}

/**
 * Result shape originally emitted by the legacy `run_in_workspace` tool.
 *
 * Used as the dev-mode iframe prop carrier on the canonical `web_serve`
 * code path: `WebServeBlock` casts the dev-mode result of `web_serve`
 * into this shape and feeds it to `IframePreview` (kind=`'run_in_workspace'`,
 * which is a mode discriminator, not a current tool name). Also produced
 * by the legacy `run_in_workspace` tool in pre-unification chat transcripts.
 *
 * Tool-result authors should produce `WebServeResult`; only this shape is
 * exposed to the iframe layer.
 *
 * `path` is the relative dev preview path (e.g. `"/preview/<agent>/<token>/"`
 * or the legacy `"/dev/<agent>/<token>/"`).
 * `url` is the absolute URL preserved for transcript replay safety.
 * `command` is the command string that was executed.
 * `port` is the local port the dev server is listening on (inside the workspace).
 */
export interface RunInWorkspaceResult { // not-wire-format: parsed from ToolCall.result (typed as unknown on the wire). Not a direct REST or WebSocket response schema — this is a tool-result payload shape that the SPA casts from the opaque result field. See WebServeBlock in chat/tools/.
  /** Relative dev path, e.g. `"/preview/<agent>/<token>/"`. */
  path: string
  /** Absolute URL — preserved for replay safety; may contain legacy hosts. */
  url: string
  /** ISO-8601 token expiry timestamp. */
  expires_at: string
  /** The command string that was executed (e.g. `"npm run dev"`). */
  command: string
  /** Local port the dev server is listening on inside the workspace. */
  port: number
}

// ── Wire ToolCall / Message adapters ─────────────────────────────────────────
//
// The wire ToolCall schema uses `parameters` (matching the Go struct tag
// `json:"parameters,omitempty"`). The SPA-internal ToolCall uses `params`.
// These adapter types are NOT new wire-format types — they are aliases over
// the generated Message/ToolCall wire shape used only in the transform layer.
// They carry a `// not-wire-format` marker as adapter aliases per CLAUDE.md #8.

interface RawToolCall { // not-wire-format: adapter alias over the generated ToolCall wire schema. Used only in rawToToolCall() to rename `parameters`→`params` before the SPA consumer sees it. The wire name is `parameters` (Go json tag); the SPA-internal name is `params` (ToolCall interface above). Never sent to or received as a standalone type from the gateway.
  id: string
  tool: string
  status: 'success' | 'error' | 'pending' | 'denied' | 'running' | 'cancelled'
  duration_ms?: number
  parameters?: Record<string, unknown>
  result?: unknown
  parent_tool_call_id?: string
}

interface RawMessage { // not-wire-format: adapter alias over the generated Message wire schema. Used only in rawToMessage() to delegate ToolCall transformation. The wire `status` enum values differ from the SPA's ('ok'|'error'|'interrupted' vs 'streaming'|'done'|'error'|'interrupted'). Never sent to or received as a standalone type from the gateway.
  id: string
  type?: 'message' | 'compaction' | 'system'
  role?: 'user' | 'assistant' | 'system'
  content?: string
  summary?: string
  timestamp: string
  tokens?: number
  cost?: number
  status?: 'ok' | 'error' | 'interrupted'
  attachments?: Attachment[]
  tool_calls?: RawToolCall[]
  agent_id: string
  messages_compacted?: number
}

function rawToToolCall(raw: RawToolCall): ToolCall {
  // Map wire status → SPA status.
  // Wire values: 'success' | 'error' | 'pending' | 'denied' | 'running' | 'cancelled'
  // SPA values:  'running' | 'success' | 'error' | 'cancelled'
  //
  // 'pending' → 'running': the SPA uses 'running' for in-progress calls; in a
  //   completed transcript 'pending' should never occur, but we map it safely.
  // 'denied'  → 'cancelled': the tool call was rejected by policy — the SPA
  //   has no 'denied' state, so 'cancelled' is the closest accurate status.
  let status: ToolCall['status']
  switch (raw.status) {
    case 'success':
    case 'error':
    case 'running':
    case 'cancelled':
      status = raw.status
      break
    case 'denied':
      status = 'cancelled'
      break
    case 'pending':
    default:
      status = 'running'
      break
  }
  return {
    id: raw.id,
    tool: raw.tool,
    status,
    params: raw.parameters ?? {},
    result: raw.result,
    duration_ms: raw.duration_ms,
    error: undefined,
  }
}

function rawToMessage(raw: RawMessage): Message {
  const role = raw.role ?? 'assistant'
  const baseStatus = raw.status === 'ok' ? ('done' as const) : raw.status
  // #3: construct the correct discriminated variant based on role.
  // Tool calls only appear on assistant messages per the wire schema, so the
  // cast here is correct and the compiler accepts it once role is narrowed.
  if (role === 'user') {
    return {
      id: raw.id,
      session_id: undefined,
      role: 'user',
      content: raw.content ?? raw.summary ?? '',
      timestamp: raw.timestamp,
      tokens: raw.tokens,
      cost: raw.cost,
      status: (baseStatus === 'done' || baseStatus === 'error') ? baseStatus : 'done',
    } satisfies UserMessage
  }
  if (role === 'system') {
    return {
      id: raw.id,
      session_id: undefined,
      role: 'system',
      content: raw.content ?? raw.summary ?? '',
      timestamp: raw.timestamp,
      tokens: raw.tokens,
      cost: raw.cost,
      status: 'done',
    } satisfies SystemMessage
  }
  // role === 'assistant' (default)
  return {
    id: raw.id,
    session_id: undefined,
    role: 'assistant',
    content: raw.content ?? raw.summary ?? '',
    timestamp: raw.timestamp,
    tokens: raw.tokens,
    cost: raw.cost,
    // Wire status is 'ok'→'done' | 'error' | 'interrupted'. 'streaming' is SPA-only
    // (never on persisted wire messages) so this branch guards for undefined only.
    status: (baseStatus === 'done' || baseStatus === 'error' || baseStatus === 'interrupted') ? baseStatus : 'done',
    tool_calls: raw.tool_calls?.map(rawToToolCall),
  } satisfies AssistantMessage
}

export async function fetchSessions(agentId?: string, type?: Session['type']): Promise<Session[]> {
  const params: Record<string, string> = {}
  if (agentId) params.agent_id = agentId
  if (type) params.type = type
  const qs = Object.keys(params).length > 0 ? '?' + new URLSearchParams(params).toString() : ''
  // The OpenAPI contract for GET /sessions describes a oneOf response: a
  // plain JSON array when there are no partial errors, OR
  // {sessions: [...], partial_errors: [...]} when one or more agents failed
  // to list. The previous code validated only the array variant — when
  // partial_errors fired (e.g. a session with a missing .context entry),
  // Zod rejected the whole response and the session panel showed empty.
  // Accept both shapes so legitimate sessions still render alongside any
  // partial-error info.
  const unionSchema = z.union([
    z.array(WireSessionSchema),
    z.object({
      sessions: z.array(WireSessionSchema),
      partial_errors: z.array(z.string()).optional(),
    }),
  ])
  const resp = await request<unknown>(`/sessions${qs}`, undefined, unionSchema as ZodType<unknown>)
  const raw: RawSession[] = Array.isArray(resp)
    ? (resp as RawSession[])
    : ((resp as { sessions: RawSession[] }).sessions ?? [])
  return raw.map(rawToSession)
}

export async function fetchSessionMessages(sessionId: string): Promise<Message[]> {
  // Validate with the wire Message schema first, then transform each message
  // so that tool_calls[].parameters is renamed to tool_calls[].params.
  const raw = await request<RawMessage[]>(
    `/sessions/${encodeURIComponent(sessionId)}/messages`,
    undefined,
    z.array(WireMessageSchema) as ZodType<RawMessage[]>,
  )
  return raw.map(rawToMessage)
}

export async function installSkillFromFile(content: string, filename: string): Promise<void> {
  await request<void>('/skills/install', {
    method: 'POST',
    body: JSON.stringify({ content, filename }),
  })
}

export interface SessionDetail { // not-wire-format: SPA-internal detail type. Uses the SPA-internal Session (stats-flattened) and SPA-internal Message (params field), not the wire-format generated SessionDetail. See fetchSessionDetail() which transforms the raw response.
  session: Session
  messages: Message[]
  agent_removed?: boolean
}

export async function fetchSessionDetail(sessionId: string): Promise<SessionDetail> {
  // Validate with the wire SessionDetail schema (session + messages array),
  // then transform both the nested session stats and each message's tool_calls.
  type RawSessionDetail = { session: RawSession; messages: RawMessage[]; agent_removed?: boolean }
  const raw = await request<RawSessionDetail>(
    `/sessions/${encodeURIComponent(sessionId)}`,
    undefined,
    WireSessionDetailSchema as ZodType<RawSessionDetail>,
  )
  return {
    session: rawToSession(raw.session),
    messages: raw.messages.map(rawToMessage),
    agent_removed: raw.agent_removed,
  }
}

export async function createSession(agentId: string): Promise<Session> {
  // Wire returns the wire Session shape (nested stats); transform to SPA Session.
  const raw = await request<RawSession>('/sessions', {
    method: 'POST',
    body: JSON.stringify({ agent_id: agentId }),
  }, WireSessionSchema as ZodType<RawSession>)
  return rawToSession(raw)
}

// ── Config ────────────────────────────────────────────────────────────────────

// Frontend-shaped config. Mapped from raw backend response via rawToFrontendConfig().
export interface Config { // not-wire-format: SPA-internal configuration shape produced by rawToFrontendConfig(). The backend returns a raw nested JSON object with different field names (e.g. gateway.host instead of gateway.bind_address, nested storage.retention.session_days instead of data.session_retention_days). This type is the SPA's normalised view, not the wire format.
  gateway: {
    bind_address: string
    port: number
    auth_mode: 'none' | 'token'
    token?: string
    hot_reload?: boolean
    log_level?: string
    // dev_mode_bypass is read-only in the UI — it cannot be toggled via the
    // config PUT endpoint (which blocks it via blockedPaths). The UI uses this
    // to hide admin-only controls that are inoperative when bypass is on.
    dev_mode_bypass?: boolean
  }
  security: {
    policy_mode: 'allow' | 'deny'
    exec_approval: 'auto' | 'ask' | 'deny'
    // Prompt guard strictness is owned by the dedicated /security/prompt-guard
    // endpoint since Wave 3. This field is still populated on read for
    // backward compatibility but must NOT be sent on updateConfig calls.
    prompt_injection_level?: 'off' | 'low' | 'medium' | 'high'
    daily_cost_cap?: number
    exec_timeout_seconds?: number
    max_background_seconds?: number
    enable_deny_patterns?: boolean
    rate_limits: {
      max_tokens_per_day?: number
      max_cost_per_day?: number
      max_agent_llm_calls_per_hour?: number
      max_agent_tool_calls_per_minute?: number
    }
  }
  data: {
    session_retention_days: number
  }
  tools?: {
    exec?: {
      enable_proxy?: boolean
    }
  }
  agents?: {
    defaults?: {
      default_agent_id?: string
    }
  }
}

const VALID_AUTH_MODES = ['none', 'token'] as const
const VALID_POLICY_MODES = ['allow', 'deny'] as const
const VALID_EXEC_APPROVALS = ['auto', 'ask', 'deny'] as const
const VALID_INJECTION_LEVELS = ['off', 'low', 'medium', 'high'] as const

function validEnum<T extends string>(value: unknown, valid: readonly T[], fallback: T): T {
  if (typeof value === 'string' && (valid as readonly string[]).includes(value)) return value as T
  if (value !== undefined && value !== null) {
    _configCoercionCount++
    if (import.meta.env.DEV) {
      console.warn(`[api] validEnum coercion: ${JSON.stringify(value)} → ${fallback}`)
    }
  }
  return fallback
}

// cast provides a type-safe wrapper around the repetitive (raw.foo ?? fallback) as T pattern.
function cast<T>(obj: unknown, fallback: T): T {
  return (obj ?? fallback) as T
}

function rawToFrontendConfig(raw: Record<string, unknown>): Config {
  const gateway = cast<Record<string, unknown>>(raw.gateway, {})
  const storage = cast<Record<string, unknown>>(raw.storage, {})
  const retention = cast<Record<string, unknown>>(storage.retention, {})
  const security = cast<Record<string, unknown>>(raw.security, {})
  const rateLimits = cast<Record<string, unknown>>(security.rate_limits, {})
  const agents = cast<Record<string, unknown>>(raw.agents, {})
  const agentDefaults = cast<Record<string, unknown>>(agents.defaults, {})
  return {
    gateway: {
      bind_address: cast<string>(gateway.host, '127.0.0.1'),
      port: cast<number>(gateway.port, 8080),
      auth_mode: validEnum(gateway.auth_mode, VALID_AUTH_MODES, 'none'),
      token: gateway.token as string | undefined,
      hot_reload: gateway.hot_reload as boolean | undefined,
      log_level: gateway.log_level as string | undefined,
      dev_mode_bypass: gateway.dev_mode_bypass as boolean | undefined,
    },
    security: {
      policy_mode: validEnum(security.policy_mode, VALID_POLICY_MODES, 'deny'),
      exec_approval: validEnum(security.exec_approval, VALID_EXEC_APPROVALS, 'ask'),
      prompt_injection_level: validEnum(security.prompt_injection_level, VALID_INJECTION_LEVELS, 'medium'),
      daily_cost_cap: security.daily_cost_cap as number | undefined,
      exec_timeout_seconds: security.exec_timeout_seconds as number | undefined,
      max_background_seconds: security.max_background_seconds as number | undefined,
      enable_deny_patterns: security.enable_deny_patterns as boolean | undefined,
      rate_limits: {
        max_tokens_per_day: rateLimits.max_tokens_per_day as number | undefined,
        max_cost_per_day: rateLimits.max_cost_per_day as number | undefined,
        max_agent_llm_calls_per_hour: rateLimits.max_agent_llm_calls_per_hour as number | undefined,
        max_agent_tool_calls_per_minute: rateLimits.max_agent_tool_calls_per_minute as number | undefined,
      },
    },
    data: {
      session_retention_days: cast<number>(retention.session_days, 90),
    },
    agents: {
      defaults: {
        default_agent_id: agentDefaults.default_agent_id as string | undefined,
      },
    },
  }
}

export async function fetchConfig(): Promise<Config> {
  const raw = await request<Record<string, unknown>>('/config')
  return rawToFrontendConfig(raw)
}

// frontendToRawConfig is the inverse of rawToFrontendConfig. It serialises the
// SPA-shaped Config back to the wire shape the backend expects on PUT /config.
// The gateway's config.json uses `gateway.host` (not `bind_address`), and the
// session-retention field lives at `storage.retention.session_days` (not
// `data.session_retention_days`). Sending the SPA shape directly causes silent
// data loss — the backend ignores unknown keys.
//
// This function only serialises fields the PUT /config handler accepts. It
// intentionally omits `gateway.dev_mode_bypass` (blocked server-side) and
// `security.prompt_injection_level` (owned by PUT /security/prompt-guard).
function frontendToRawConfig(data: Partial<Config>): Record<string, unknown> {
  const raw: Record<string, unknown> = {}
  if (data.gateway) {
    const gw: Record<string, unknown> = {}
    if (data.gateway.bind_address !== undefined) gw.host = data.gateway.bind_address
    if (data.gateway.port !== undefined) gw.port = data.gateway.port
    if (data.gateway.auth_mode !== undefined) gw.auth_mode = data.gateway.auth_mode
    if (data.gateway.token !== undefined) gw.token = data.gateway.token
    if (data.gateway.hot_reload !== undefined) gw.hot_reload = data.gateway.hot_reload
    if (data.gateway.log_level !== undefined) gw.log_level = data.gateway.log_level
    // dev_mode_bypass is intentionally omitted — PUT /config blocks that field.
    raw.gateway = gw
  }
  if (data.security) {
    const sec: Record<string, unknown> = {}
    if (data.security.policy_mode !== undefined) sec.policy_mode = data.security.policy_mode
    if (data.security.exec_approval !== undefined) sec.exec_approval = data.security.exec_approval
    // prompt_injection_level intentionally omitted — owned by PUT /security/prompt-guard.
    if (data.security.daily_cost_cap !== undefined) sec.daily_cost_cap = data.security.daily_cost_cap
    if (data.security.exec_timeout_seconds !== undefined) sec.exec_timeout_seconds = data.security.exec_timeout_seconds
    if (data.security.max_background_seconds !== undefined) sec.max_background_seconds = data.security.max_background_seconds
    if (data.security.enable_deny_patterns !== undefined) sec.enable_deny_patterns = data.security.enable_deny_patterns
    if (data.security.rate_limits) {
      sec.rate_limits = { ...data.security.rate_limits }
    }
    raw.security = sec
  }
  if (data.data) {
    raw.storage = {
      retention: {
        session_days: data.data.session_retention_days,
      },
    }
  }
  if (data.agents?.defaults) {
    raw.agents = { defaults: { ...data.agents.defaults } }
  }
  return raw
}

export async function updateConfig(data: Partial<Config>): Promise<Config> {
  // Translate SPA shape → wire shape before sending, then transform the
  // raw wire response back to SPA shape on success.
  const wireBody = frontendToRawConfig(data)
  const raw = await request<Record<string, unknown>>('/config', {
    method: 'PUT',
    body: JSON.stringify(wireBody),
  })
  return rawToFrontendConfig(raw)
}

// ── Providers ─────────────────────────────────────────────────────────────────

// Provider — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/Provider.yaml.

export function fetchProviders(): Promise<Provider[]> {
  return request<Provider[]>('/providers', undefined, z.array(ProviderSchema) as ZodType<Provider[]>)
}

export function configureProvider(id: string, apiKey?: string, endpoint?: string, model?: string): Promise<Provider> {
  const body: Record<string, string> = {}
  if (apiKey !== undefined) body.api_key = apiKey
  if (endpoint !== undefined) body.endpoint = endpoint
  if (model !== undefined) body.model = model
  return request<Provider>(`/providers/${id}`, {
    method: 'PUT',
    body: JSON.stringify(body),
  }, ProviderSchema as ZodType<Provider>)
}

export function testProvider(id: string): Promise<OperationResult> {
  return request<OperationResult>(`/providers/${id}/test`, { method: 'POST' }, OperationResultSchema as ZodType<OperationResult>)
}

export function rotateGatewayToken(): Promise<{ token: string }> {
  return request('/config/gateway/rotate-token', { method: 'POST' })
}

// ── Tasks ─────────────────────────────────────────────────────────────────────

// Task — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/Task.yaml.

export function fetchTasks(status?: Task['status']): Promise<Task[]> {
  const qs = status ? '?' + new URLSearchParams({ status }).toString() : ''
  return request<Task[]>(`/tasks${qs}`, undefined, z.array(TaskSchema) as ZodType<Task[]>)
}

export function fetchSubtasks(taskId: string): Promise<Task[]> {
  return request<Task[]>(`/tasks/${encodeURIComponent(taskId)}/subtasks`, undefined, z.array(TaskSchema) as ZodType<Task[]>)
}

export function createTask(data: {
  title: string
  prompt: string
  agent_id?: string
  priority?: number
  parent_task_id?: string
}): Promise<Task> {
  return request<Task>('/tasks', { method: 'POST', body: JSON.stringify(data) }, TaskSchema as ZodType<Task>)
}

export function updateTask(id: string, data: Partial<Task>): Promise<Task> {
  return request<Task>(`/tasks/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) }, TaskSchema as ZodType<Task>)
}

export function startTask(id: string): Promise<void> {
  return request(`/tasks/${encodeURIComponent(id)}/start`, { method: 'POST' })
}

export function deleteTask(id: string): Promise<void> {
  return request(`/tasks/${encodeURIComponent(id)}`, { method: 'DELETE' })
}

// ── Gateway Status ────────────────────────────────────────────────────────────

// GatewayStatus — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/GatewayStatus.yaml.

export function fetchGatewayStatus(): Promise<GatewayStatus> {
  return request<GatewayStatus>('/status', undefined, GatewayStatusSchema as ZodType<GatewayStatus>)
}

// ── Tools & Channels ──────────────────────────────────────────────────────────

// Tool — type alias for ToolRegistryEntry (contract-first #8).
// GET /tools returns ToolRegistryEntry[]; this alias preserves backward compat.
// See contracts/components/schemas/ToolRegistryEntry.yaml.
export type Tool = ToolRegistryEntry

// fetchTools is a backward-compat alias for fetchRegistryTools.
// New callers should use fetchRegistryTools (or fetchBuiltinTools) directly.
export function fetchTools(): Promise<ToolRegistryEntry[]> { return fetchRegistryTools() }

// Channel — type alias for ChannelEntry (contract-first #8).
// GET /channels returns ChannelEntry[]; this alias preserves backward compat.
// See contracts/components/schemas/ChannelEntry.yaml.
export type Channel = ChannelEntry

export function fetchChannels(): Promise<ChannelEntry[]> {
  return request<ChannelEntry[]>('/channels', undefined, z.array(ChannelEntrySchema) as ZodType<ChannelEntry[]>)
}

export function enableChannel(id: string): Promise<ChannelEnabledResponse> {
  // Backend returns ChannelEnabledResponse {id, enabled} — not a full ChannelEntry.
  return request<ChannelEnabledResponse>(
    `/channels/${encodeURIComponent(id)}/enable`,
    { method: 'PUT' },
    ChannelEnabledResponseSchema as ZodType<ChannelEnabledResponse>,
  )
}

export function disableChannel(id: string): Promise<ChannelEnabledResponse> {
  // Backend returns ChannelEnabledResponse {id, enabled} — not a full ChannelEntry.
  return request<ChannelEnabledResponse>(
    `/channels/${encodeURIComponent(id)}/disable`,
    { method: 'PUT' },
    ChannelEnabledResponseSchema as ZodType<ChannelEnabledResponse>,
  )
}

export function fetchChannelConfig(id: string): Promise<Record<string, unknown>> {
  // no-schema: channel config structure varies per channel type; no generated schema component.
  return request<Record<string, unknown>>(`/channels/${encodeURIComponent(id)}`)
}

export function configureChannel(id: string, config: Record<string, unknown>): Promise<void> {
  // no-schema: void response; channel-specific body.
  return request<void>(`/channels/${encodeURIComponent(id)}/configure`, {
    method: 'PUT',
    body: JSON.stringify(config),
  })
}

export function testChannel(id: string): Promise<ChannelTestResponse> {
  return request<ChannelTestResponse>(`/channels/${encodeURIComponent(id)}/test`, {
    method: 'POST',
  }, ChannelTestResponseSchema as ZodType<ChannelTestResponse>)
}

// ChannelRouting — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/ChannelRouting.yaml.

export function getChannelRouting(id: string): Promise<ChannelRouting> {
  return request<ChannelRouting>(
    `/channels/${encodeURIComponent(id)}/routing`,
    undefined,
    ChannelRoutingSchema as ZodType<ChannelRouting>,
  )
}

export function setChannelRouting(id: string, body: ChannelRouting): Promise<ChannelRouting> {
  return request<ChannelRouting>(
    `/channels/${encodeURIComponent(id)}/routing`,
    { method: 'PUT', body: JSON.stringify(body) },
    ChannelRoutingSchema as ZodType<ChannelRouting>,
  )
}

// ── Skills ────────────────────────────────────────────────────────────────────

// Skill — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/Skill.yaml.

// McpServer — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/McpServer.yaml.
// McpServerCreate — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/McpServerCreate.yaml.

export function fetchSkills(): Promise<Skill[]> {
  return request<Skill[]>('/skills', undefined, z.array(SkillSchema) as ZodType<Skill[]>)
}

export function deleteSkill(name: string): Promise<void> {
  // no-schema: void response; DELETE has no body.
  return request<void>(`/skills/${encodeURIComponent(name)}`, { method: 'DELETE' })
}

// fetchMcpServers is a backward-compat alias for fetchMcpServersForAgent.
// New callers should use fetchMcpServersForAgent directly.
export function fetchMcpServers(): Promise<McpServer[]> { return fetchMcpServersForAgent() }

export function addMcpServer(data: McpServerCreate): Promise<McpServer> {
  return request<McpServer>('/mcp-servers', { method: 'POST', body: JSON.stringify(data) }, McpServerSchema as ZodType<McpServer>)
}

export function deleteMcpServer(id: string): Promise<void> {
  // no-schema: void response; DELETE has no body.
  return request<void>(`/mcp-servers/${encodeURIComponent(id)}`, { method: 'DELETE' })
}

export async function fetchMcpServerTools(id: string): Promise<string[]> {
  const resp = await request<McpServerToolsResponse>(`/mcp-servers/${encodeURIComponent(id)}/tools`, undefined, McpServerToolsResponseSchema as ZodType<McpServerToolsResponse>)
  return resp.tools
}

// ── Storage Stats ─────────────────────────────────────────────────────────────

// StorageStats — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/StorageStats.yaml.

export function fetchStorageStats(): Promise<StorageStats> {
  return request<StorageStats>('/storage/stats', undefined, StorageStatsSchema)
}

// ── App State ─────────────────────────────────────────────────────────────────

// AppState — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/AppState.yaml.

export function fetchAppState(): Promise<AppState> {
  return request<AppState>('/state', undefined, AppStateSchema)
}

export function completeOnboarding(): Promise<void> {
  return request('/state', {
    method: 'PATCH',
    body: JSON.stringify({ onboarding_complete: true }),
  })
}

// ── Auth / Login ─────────────────────────────────────────────────────────────────
//
// LoginResponse is re-exported from @/lib/api/generated/openapi-types at the
// top of this file. The hand-written interface has been removed.

export async function login(username: string, password: string): Promise<LoginResponse> {
  return request<LoginResponse>('/auth/login', {
    method: 'POST',
    body: JSON.stringify({ username, password }),
  }, LoginResponseSchema)
}

export async function registerAdmin(username: string, password: string): Promise<LoginResponse> {
  return request<LoginResponse>('/auth/register-admin', {
    method: 'POST',
    body: JSON.stringify({ username, password }),
  }, LoginResponseSchema)
}

export async function completeOnboardingTransaction(req: OnboardingCompleteRequest): Promise<LoginResponse> {
  return request<LoginResponse>('/onboarding/complete', {
    method: 'POST',
    body: JSON.stringify(req),
  }, LoginResponseSchema)
}

// probeProvider is a non-persistent "test + fetch model list" call used during
// onboarding, before the __Host-csrf cookie can be issued. It accepts the
// api_key in the request body, asks the server to hit the provider's /models
// endpoint with that key, and returns both a success flag and the model list.
// Nothing is written to disk or in-memory config.
//
// After onboarding completes, the server returns HTTP 409 from this endpoint.
// Admins who want to add providers post-onboarding use configureProvider
// (PUT /providers/{id}) + fetchProviders (GET /providers) — both work
// because the browser has the __Host-csrf cookie at that point.
//
// ProbeProviderResponse is re-exported from @/lib/api/generated/openapi-types.
export async function probeProvider(
  id: string,
  apiKey: string,
  endpoint?: string,
): Promise<ProbeProviderResponse> {
  return request<ProbeProviderResponse>('/onboarding/probe-provider', {
    method: 'POST',
    body: JSON.stringify({ id, api_key: apiKey, endpoint: endpoint ?? '' }),
  }, ProbeProviderResponseSchema)
}

// ValidateTokenResponse — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/ValidateTokenResponse.yaml.

export async function validateToken(): Promise<ValidateTokenResponse> {
  return request<ValidateTokenResponse>('/auth/validate', undefined, ValidateTokenResponseSchema)
}

// ── Doctor ────────────────────────────────────────────────────────────────────

// DoctorIssue — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/DoctorIssue.yaml.
// DoctorResult — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/DoctorResult.yaml.

export function fetchDoctorResults(): Promise<DoctorResult | null> {
  return request<DoctorResult | null>('/doctor', undefined, DoctorResultSchema.nullable())
}

export function runDoctor(): Promise<DoctorResult> {
  return request<DoctorResult>('/doctor', { method: 'POST' }, DoctorResultSchema)
}

// ── Activity Feed ─────────────────────────────────────────────────────────────

// ActivityEvent — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/ActivityEvent.yaml.

export function fetchActivity(): Promise<ActivityEvent[]> {
  return request<ActivityEvent[]>('/activity', undefined, z.array(ActivityEventSchema) as ZodType<ActivityEvent[]>)
}

// ── Credentials ───────────────────────────────────────────────────────────────

export interface CredentialKey { // not-wire-format: SPA-internal credential display shape. The wire GET /credentials endpoint returns string[] (key names only); the component accesses .key on each entry. This interface reflects how the SPA displays credential entries but does NOT match the wire format. The wire format is defined inline in the openapi.yaml /credentials GET response schema as string[].
  key: string
  created_at?: string
  updated_at?: string
}

export async function fetchCredentials(): Promise<CredentialKey[]> {
  // Wire format: GET /credentials returns string[] (key names only).
  // The SPA uses CredentialKey[] (objects with .key) so SecuritySection.tsx can
  // render cred.key and use it as a React key. We validate the wire shape, then
  // transform string[] → {key:string}[].
  const wire = await request<string[]>('/credentials', undefined, z.array(z.string()) as ZodType<string[]>)
  return wire.map((key) => ({ key }))
}

export function addCredential(key: string, value: string): Promise<void> {
  // no-schema: void response; POST body is a write-only operation.
  return request<void>('/credentials', { method: 'POST', body: JSON.stringify({ key, value }) })
}

export function deleteCredential(key: string): Promise<void> {
  // no-schema: void response; DELETE has no body.
  return request<void>(`/credentials/${encodeURIComponent(key)}`, { method: 'DELETE' })
}

// ── Devices ───────────────────────────────────────────────────────────────────

// DevicePending — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/DevicePending.yaml.
// DevicePaired — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/DevicePaired.yaml.
// DevicesResponse — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/DevicesResponse.yaml.

export function fetchDevices(): Promise<DevicesResponse> {
  return request<DevicesResponse>('/devices', undefined, DevicesResponseSchema)
}

// ── Backup / Restore ──────────────────────────────────────────────────────────

// BackupEntry — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/BackupEntry.yaml.

export function createBackup(): Promise<BackupCreateResponse> {
  return request<BackupCreateResponse>('/backup', { method: 'POST' }, BackupCreateResponseSchema as ZodType<BackupCreateResponse>)
}

export function fetchBackups(): Promise<BackupEntry[]> {
  return request<BackupEntry[]>('/backups', undefined, z.array(BackupEntrySchema))
}

export function restoreBackup(filename: string): Promise<void> {
  // no-schema: void response; 204 No Content on success.
  return request<void>('/restore', { method: 'POST', body: JSON.stringify({ filename }) })
}

export function clearAllSessions(): Promise<void> {
  // no-schema: void response; DELETE returns 204 No Content.
  return request<void>('/sessions/all', { method: 'DELETE' })
}

export async function renameSession(id: string, title: string): Promise<Session> {
  // Wire returns the wire Session shape (nested stats); transform to SPA Session.
  const raw = await request<RawSession>(`/sessions/${encodeURIComponent(id)}`, {
    method: 'PUT',
    body: JSON.stringify({ title }),
  }, WireSessionSchema as ZodType<RawSession>)
  return rawToSession(raw)
}

export function deleteSession(id: string): Promise<OperationResult> {
  return request<OperationResult>(`/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }, OperationResultSchema as ZodType<OperationResult>)
}

// ── About ─────────────────────────────────────────────────────────────────────

export interface AboutInfo { // not-wire-format: SPA-internal backward-compatible subset of AboutResponse. The generated AboutResponse has required fields (uptime, pid, frame_ancestors_fallback) and different optionality for preview_port/preview_listener_enabled. The SPA uses this looser interface to maintain backward compatibility with older gateway versions that may not send all AboutResponse fields.
  version: string
  go_version: string
  os: string
  arch: string
  uptime_seconds: number
  // Preview listener fields — added by FR-009 / iframe-preview feature.
  // preview_port is the port the preview listener is bound on (default:
  // gateway.port + 1). Absent when preview_listener_enabled is false.
  preview_port?: number
  // preview_origin is the fully-qualified origin operators set via
  // gateway.preview_origin (e.g. "https://preview.acme.com"). Absent when
  // not configured — the SPA constructs the iframe URL from hostname +
  // preview_port in that case.
  preview_origin?: string
  // preview_listener_enabled reflects whether the preview listener is bound.
  // Absent on old gateway versions that predate this feature (treat as true).
  preview_listener_enabled?: boolean
  // warmup_timeout_seconds is sourced from
  // cfg.Tools.RunInWorkspace.WarmupTimeoutSeconds (default 60). Used by
  // RunInWorkspaceUI to cap the warmup polling loop.
  warmup_timeout_seconds?: number
}

export function fetchAboutInfo(): Promise<AboutInfo> {
  // no-schema: AboutInfo is a looser SPA-compatibility subset of the wire AboutResponse
  // (different field names: uptime_seconds vs uptime). Validating against a strict schema
  // would produce false negatives on older gateway versions.
  return request<AboutInfo>('/about')
}

/**
 * Returns whether the preview listener is enabled.
 *
 * `preview_listener_enabled` is an optional bool where `undefined` means "true"
 * — old gateway versions that predate the field did not include it, and those
 * versions always ran the preview listener. Reading the field directly risks
 * treating `undefined` as falsy; use this accessor instead.
 */
export function isPreviewListenerEnabled(info: AboutInfo | undefined): boolean {
  return info?.preview_listener_enabled !== false
}

// ── Audit Log ─────────────────────────────────────────────────────────────────

export type AuditEventType = 'tool_call' | 'exec' | 'file_op' | 'llm_call' | 'policy_eval' | 'rate_limit' | 'ssrf' | 'startup' | 'shutdown'
export type AuditDecision = 'allow' | 'deny' | 'error'

// AuditEntry — re-exported from generated openapi-types (no local body needed).
// AuditEventType and AuditDecision remain as local type aliases for UI use.

export function fetchAuditLog(): Promise<AuditEntry[]> {
  return request<AuditEntry[]>('/audit-log', undefined, z.array(AuditEntrySchema))
}

// ── User Context (USER.md) ────────────────────────────────────────────────────

export function fetchUserContext(): Promise<UserContextResponse> {
  return request<UserContextResponse>('/user-context', undefined, UserContextResponseSchema as ZodType<UserContextResponse>)
}

export function updateUserContext(content: string): Promise<void> {
  // no-schema: void response; PUT returns 204 No Content.
  return request<void>('/user-context', {
    method: 'PUT',
    body: JSON.stringify({ content }),
  })
}

// ── RBAC / Me ─────────────────────────────────────────────────────────────────

export type UserRole = 'admin' | 'user'

// MeInfo — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/MeInfo.yaml.

export async function fetchMe(): Promise<MeInfo> {
  return request<MeInfo>('/me', undefined, MeInfoSchema)
}

// ── File Upload ───────────────────────────────────────────────────────────────

// UploadedFile — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/UploadedFile.yaml.

export async function uploadFiles(sessionId: string, files: File[]): Promise<UploadFilesResponse> {
  const formData = new FormData()
  formData.append('session_id', sessionId)
  for (const file of files) {
    formData.append('files', file)
  }
  const token = sessionStorage.getItem('omnipus_auth_token') ?? localStorage.getItem('omnipus_auth_token')
  const csrf = readCSRFCookie()
  // Upload is a state-changing POST — fail fast if we have no CSRF cookie
  // (see request() for the same pattern). We still send the header on the
  // off chance the user explicitly set the cookie externally.
  if (csrf === null) {
    throw new ApiError(
      403,
      'CSRF cookie missing — cannot upload files. Log in first so the server can issue the CSRF cookie.',
      { code: 'csrf_missing' },
    )
  }
  // Build headers by hand because FormData must NOT have a Content-Type
  // set — the browser needs to fill in the multipart boundary itself.
  const headers: Record<string, string> = {
    [CSRF_HEADER_NAME]: csrf,
  }
  if (token) {
    headers.Authorization = `Bearer ${token}`
  }
  let res: Response
  try {
    res = await fetch(`${BASE_URL}/api/v1/upload`, {
      method: 'POST',
      headers,
      body: formData,
    })
  } catch (cause) {
    throw new ApiError(0, 'Network unavailable. Check your connection.', { cause })
  }
  if (!res.ok) {
    throw await ApiError.fromResponse(res)
  }
  const raw: unknown = await res.json()
  const parsed = (UploadFilesResponseSchema as ZodType<UploadFilesResponse>).safeParse(raw)
  if (!parsed.success) {
    _apiSchemaErrorCount++
    const issues = parsed.error.issues.map((i) => ({ path: i.path as (string | number)[], message: i.message }))
    void maybeDevToast(`[api] uploadFiles response schema mismatch: ${issues[0]?.message ?? 'unknown'}`, 'POST:/upload:schema')
    throw new ApiSchemaError('/upload', issues, raw)
  }
  return parsed.data
}

// ── Auth ──────────────────────────────────────────────────────────────────────

export function changePassword(currentPassword: string, newPassword: string): Promise<OperationResult> {
  return request<OperationResult>('/auth/change-password', {
    method: 'POST',
    body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
  }, OperationResultSchema as ZodType<OperationResult>)
}

// ── Exec Allowlist ────────────────────────────────────────────────────────────

// ExecAllowlist — re-exported from generated openapi-types (no local body needed).

export function fetchExecAllowlist(): Promise<ExecAllowlist> {
  return request<ExecAllowlist>('/security/exec-allowlist', undefined, ExecAllowlistSchema)
}

export function updateExecAllowlist(patterns: string[]): Promise<ExecAllowlist> {
  return request<ExecAllowlist>('/security/exec-allowlist', {
    method: 'PUT',
    body: JSON.stringify({ allowed_binaries: patterns }),
  }, ExecAllowlistSchema)
}

// ── Security Admin Endpoints ──────────────────────────────────────────────────
//
// Enums and typed helpers for the security and admin endpoints.
// These are separate from the pre-existing /security/* helpers above — they use
// the canonical request/response shapes and are wired to the admin UI panels.

export type SkillTrustLevel = 'block_unverified' | 'warn_unverified' | 'allow_all'
export type PromptInjectionLevel = 'low' | 'medium' | 'high'
export type DMScope = 'main' | 'per-peer' | 'per-channel-peer' | 'per-account-channel-peer'

// PendingRestartEntry — re-exported from generated openapi-types (no local body needed).

export function fetchPendingRestart(): Promise<PendingRestartEntry[]> {
  return request<PendingRestartEntry[]>('/config/pending-restart', undefined, z.array(PendingRestartEntrySchema) as ZodType<PendingRestartEntry[]>)
}

// Audit log toggle — distinct from GET /audit-log (which returns AuditEntry[]).
// This endpoint controls whether audit logging is enabled at all.
// AuditLogToggle is re-exported from generated openapi-types above.

// AuditLogUpdateResponse — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/AuditLogUpdateResponse.yaml.

export function fetchAuditLogToggle(): Promise<AuditLogToggle> {
  return request<AuditLogToggle>('/security/audit-log', undefined, AuditLogToggleSchema)
}

export function updateAuditLog(enabled: boolean): Promise<AuditLogUpdateResponse> {
  return request<AuditLogUpdateResponse>('/security/audit-log', {
    method: 'PUT',
    body: JSON.stringify({ enabled }),
  }, AuditLogUpdateResponseSchema)
}

// Skill trust — controls how unverified community skills are handled.
// SkillTrustResponse is re-exported from generated openapi-types above.
// SkillTrustUpdateRequest — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/SkillTrustUpdateRequest.yaml.
// SkillTrustUpdateResponse — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/SkillTrustUpdateResponse.yaml.

export function fetchSkillTrust(): Promise<SkillTrustResponse> {
  return request<SkillTrustResponse>('/security/skill-trust', undefined, SkillTrustResponseSchema)
}

export function updateSkillTrust(level: SkillTrustLevel): Promise<SkillTrustUpdateResponse> {
  return request<SkillTrustUpdateResponse>('/security/skill-trust', {
    method: 'PUT',
    body: JSON.stringify({ level } satisfies SkillTrustUpdateRequest),
  }, SkillTrustUpdateResponseSchema)
}

// Prompt guard — uses `level` field, aligns with PromptInjectionLevel.
// PromptGuardResponse is re-exported from generated openapi-types above.
// PromptGuardUpdateRequest — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/PromptGuardUpdateRequest.yaml.
// PromptGuardUpdateResponse — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/PromptGuardUpdateResponse.yaml.

export function fetchPromptGuardLevel(): Promise<PromptGuardResponse> {
  return request<PromptGuardResponse>('/security/prompt-guard', undefined, PromptGuardResponseSchema)
}

export function updatePromptGuardLevel(level: PromptInjectionLevel): Promise<PromptGuardUpdateResponse> {
  return request<PromptGuardUpdateResponse>('/security/prompt-guard', {
    method: 'PUT',
    body: JSON.stringify({ level } satisfies PromptGuardUpdateRequest),
  }, PromptGuardUpdateResponseSchema)
}

// Rate limits — adds write support and configures spending/throughput caps.
// RateLimitsResponse — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/RateLimitsResponse.yaml.
// RateLimitsUpdateRequest — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/RateLimitsUpdateRequest.yaml.
// RateLimitsUpdateResponse — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/RateLimitsUpdateResponse.yaml.

export function fetchRateLimits(): Promise<RateLimitsResponse> {
  return request<RateLimitsResponse>('/security/rate-limits', undefined, RateLimitsResponseSchema)
}

export function updateRateLimits(body: RateLimitsUpdateRequest): Promise<RateLimitsUpdateResponse> {
  return request<RateLimitsUpdateResponse>('/security/rate-limits', {
    method: 'PUT',
    body: JSON.stringify(body),
  }, RateLimitsUpdateResponseSchema)
}

// Sandbox config — mode, allowed paths, SSRF controls, and the global
// agent defaults (default_profile, shell_deny_patterns).
// SandboxConfig — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/SandboxConfig.yaml.
// SandboxConfigUpdate — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/SandboxConfigUpdate.yaml.

// SandboxConfigResponse is a backward-compat alias for the generated SandboxConfig.
// SandboxConfig already contains requires_restart, applied_mode, saved, and all
// sandbox fields — no extra SPA-specific shape is needed.
export type SandboxConfigResponse = SandboxConfig

export function fetchSandboxConfig(): Promise<SandboxConfigResponse> {
  return request<SandboxConfigResponse>('/security/sandbox-config', undefined, SandboxConfigSchema)
}

export function updateSandboxConfig(body: SandboxConfigUpdate): Promise<SandboxConfigResponse> {
  return request<SandboxConfigResponse>('/security/sandbox-config', {
    method: 'PUT',
    body: JSON.stringify(body),
  }, SandboxConfigSchema)
}

// Session scope — controls DM conversation isolation granularity.
// SessionScopeResponse is re-exported from generated openapi-types above.
// SessionScopeRequest (request body) — re-exported from generated openapi-types.
// See contracts/components/schemas/SessionScopeRequest.yaml.
// SessionScopeUpdateResponse — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/SessionScopeUpdateResponse.yaml.

export function fetchSessionScope(): Promise<SessionScopeResponse> {
  return request<SessionScopeResponse>('/security/session-scope', undefined, SessionScopeResponseSchema as ZodType<SessionScopeResponse>)
}

export function updateSessionScope(dm_scope: DMScope): Promise<SessionScopeUpdateResponse> {
  return request<SessionScopeUpdateResponse>('/security/session-scope', {
    method: 'PUT',
    body: JSON.stringify({ dm_scope } satisfies SessionScopeRequest),
  }, SessionScopeUpdateResponseSchema)
}

// Retention — session log retention policy.

// RetentionMode mirrors the Go pkg/config.RetentionMode enum. Derive with
// retentionMode(resp) from the flat wire shape; the backend does not send
// this as a field.
export type RetentionMode = 'default' | 'custom' | 'forever'

export function retentionMode(resp: {
  session_days?: number
  disabled?: boolean
}): RetentionMode {
  if (resp.disabled) return 'forever'
  if ((resp.session_days ?? 0) > 0) return 'custom'
  return 'default'
}

// GET /security/retention returns RetentionConfig (session_days?, disabled?).
// RetentionConfig — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/RetentionConfig.yaml.

// RetentionUpdateBody is a backward-compat alias for the PUT request body.
export type RetentionUpdateBody = RetentionConfig

// RetentionUpdateResponse — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/RetentionUpdateResponse.yaml.

export function fetchRetention(): Promise<RetentionConfig> {
  return request<RetentionConfig>('/security/retention', undefined, RetentionConfigSchema)
}

export function updateRetention(body: RetentionUpdateBody): Promise<RetentionUpdateResponse> {
  return request<RetentionUpdateResponse>('/security/retention', {
    method: 'PUT',
    body: JSON.stringify(body),
  }, RetentionUpdateResponseSchema)
}

// Retention sweep — immediately purge sessions beyond the retention window.
// RetentionSweepResult is re-exported from generated openapi-types above.

export function triggerRetentionSweep(): Promise<RetentionSweepResult> {
  return request<RetentionSweepResult>('/security/retention/sweep', { method: 'POST' }, RetentionSweepResultSchema)
}

// Users — list, create, delete, reset password, change role.
// UserEntry — type alias for the generated User schema (contract-first #8).
// See contracts/components/schemas/User.yaml.
export type UserEntry = User

// UserEntry is the SPA-internal type; the generated User schema is compatible (passthrough).
export function fetchUsers(): Promise<UserEntry[]> {
  return request<UserEntry[]>('/users', undefined, z.array(UserSchema) as ZodType<UserEntry[]>)
}

export async function createUser(body: UserCreateRequest): Promise<UserCreateResponse> {
  const response = await request<UserCreateResponse & { token?: string }>('/users', {
    method: 'POST',
    body: JSON.stringify(body),
  }, UserCreateResponseSchema)
  if ('token' in response) {
    throw new Error('unexpected token in create response')
  }
  return response
}

export function deleteUser(username: string): Promise<UserDeleteResponse> {
  return request<UserDeleteResponse>(`/users/${encodeURIComponent(username)}`, { method: 'DELETE' }, UserDeleteResponseSchema)
}

export function resetUserPassword(username: string, password: string): Promise<UserResetPasswordResponse> {
  return request<UserResetPasswordResponse>(`/users/${encodeURIComponent(username)}/password`, {
    method: 'PUT',
    body: JSON.stringify({ password } satisfies UserResetPasswordRequest),
  }, UserResetPasswordResponseSchema)
}

export function updateUserRole(username: string, role: UserRole): Promise<UserRoleChangeResponse> {
  return request<UserRoleChangeResponse>(`/users/${encodeURIComponent(username)}/role`, {
    method: 'PATCH',
    body: JSON.stringify({ role } satisfies UserRoleChangeRequest),
  }, UserRoleChangeResponseSchema)
}

// ── Exec Proxy ────────────────────────────────────────────────────────────────
// ExecProxyStatus — re-exported from generated openapi-types (no local body needed).

export function fetchExecProxyStatus(): Promise<ExecProxyStatus> {
  return request<ExecProxyStatus>('/security/exec-proxy-status', undefined, ExecProxyStatusSchema)
}


// ── Agent Tools ───────────────────────────────────────────────────────────────

// RegistryTool — type alias for ToolRegistryEntry (contract-first #8).
// Central registry tool entry (FR-027, FR-029). Includes a source discriminator
// so the UI can badge MCP tools differently from builtin ones.
// See contracts/components/schemas/ToolRegistryEntry.yaml.
export type RegistryTool = ToolRegistryEntry

/** Backward-compat alias — existing callers that reference BuiltinTool still work. */
export type BuiltinTool = RegistryTool

// AgentToolsCfg — re-exported from generated openapi-types (contract-first #8).
// See contracts/components/schemas/AgentToolsCfg.yaml.

// AgentToolEntry — re-exported from generated openapi-types (no local body needed).

/** Fetch all tools from the central registry (FR-027). Includes both builtin and MCP tools. */
export function fetchRegistryTools(): Promise<RegistryTool[]> {
  return request<RegistryTool[]>('/tools', undefined, z.array(ToolRegistryEntrySchema) as ZodType<RegistryTool[]>)
}

/** Backward-compat alias — callers that used fetchBuiltinTools() still work. */
export const fetchBuiltinTools = fetchRegistryTools

export function fetchMcpServersForAgent(): Promise<McpServer[]> {
  return request<McpServer[]>('/mcp-servers', undefined, z.array(McpServerSchema) as ZodType<McpServer[]>)
}

// AgentToolsResponse — imported from generated openapi-types (contract-first #8).
// AgentToolsResponseSchema — imported from generated schemas (contract-first #8).
export function fetchAgentTools(agentId: string): Promise<AgentToolsResponse> {
  return request<AgentToolsResponse>(`/agents/${encodeURIComponent(agentId)}/tools`, undefined, AgentToolsResponseSchema as ZodType<AgentToolsResponse>)
}

export function updateAgentTools(agentId: string, cfg: AgentToolsCfg): Promise<AgentToolsResponse> {
  return request<AgentToolsResponse>(`/agents/${encodeURIComponent(agentId)}/tools`, {
    method: 'PUT',
    body: JSON.stringify(cfg),
  }, AgentToolsResponseSchema as ZodType<AgentToolsResponse>)
}

/**
 * POST /api/v1/tool-approvals/{approvalId} — resolve a pending tool approval.
 * FR-011, FR-082. Throws with status code prefix on non-2xx (e.g. "403: ...").
 */
export function postToolApproval(approvalId: string, action: 'approve' | 'deny' | 'cancel'): Promise<void> {
  // no-schema: void response; POST returns 204 No Content.
  return request<void>(`/tool-approvals/${encodeURIComponent(approvalId)}`, {
    method: 'POST',
    body: JSON.stringify({ action }),
  })
}

// ── Global Tool Policies ──────────────────────────────────────────────────────
// GlobalToolPolicies — re-exported from generated openapi-types (no local body needed).

export function fetchGlobalToolPolicies(): Promise<GlobalToolPolicies> {
  return request<GlobalToolPolicies>('/security/tool-policies', undefined, GlobalToolPoliciesSchema)
}

export function updateGlobalToolPolicies(cfg: GlobalToolPolicies): Promise<GlobalToolPolicies> {
  return request<GlobalToolPolicies>('/security/tool-policies', {
    method: 'PUT',
    body: JSON.stringify(cfg),
  }, GlobalToolPoliciesSchema)
}

// ── Sandbox Status ────────────────────────────────────────────────────────────
// SandboxStatus — re-exported from generated openapi-types (no local body needed).
// The generated schema is a superset of the previous hand-written shape.

export function fetchSandboxStatus(): Promise<SandboxStatus> {
  return request<SandboxStatus>('/security/sandbox-status', undefined, SandboxStatusSchema)
}

// ── Tool Results (lazy fetch for ToolResultRef sentinels) ─────────────────────
// Endpoint: GET /api/v1/sessions/{session_id}/tool-results/{ref}
// Session-scoped: a ref is only readable in the session that produced it.
export function fetchToolResult(sessionId: string, ref: string): Promise<unknown> {
  return request<unknown>(
    `/sessions/${encodeURIComponent(sessionId)}/tool-results/${encodeURIComponent(ref)}`,
  )
}
