/**
 * lib/sandbox.api.ts — typed REST client for the Sovereign-side Sandbox
 * surfaces (Wave 3 — UI scaffold).
 *
 * Sandbox = per-Org agent-coding workspace. Each session runs a chosen
 * agent CLI (`claude`, `cursor-agent`, `aider`, `qwen-code`, `opencode`,
 * `little-coder`) in a pod inside the user's vcluster; the pod's
 * `pty-server` shim pipes ANSI stdout to the browser's xterm.js over a
 * WebSocket. See `products/sandbox/docs/architecture.md` §1.
 *
 * Wire paths (Wave 1b backend stubs):
 *
 *   browser ──/api/v1/sandbox/sessions──▶ catalyst-api ──▶ Sandbox CRD list
 *   browser ──/api/v1/sandbox/sessions  POST──▶ catalyst-api ──▶ Sandbox CR
 *   browser ──/api/v1/sandbox/byos/claude-code/status──▶ catalyst-api
 *
 * All endpoints tolerate 404 / 5xx so the page renders its target-state
 * shape on first paint (per docs/INVIOLABLE-PRINCIPLES.md #1) — the
 * landing flips its "API pending" pill when the backend isn't wired.
 */

import { API_BASE } from '@/shared/config/urls'
import { authedFetch } from '@/shared/lib/authedFetch'

/**
 * Canonical agent catalogue. The id is the binary name the pty-server
 * spawns; the label / blurb drive the Landing's card grid. Adding a new
 * supported agent is a one-line append here — no other site touches the
 * list (per INVIOLABLE-PRINCIPLES.md #4 — never hardcode at call sites).
 */
export interface SandboxAgent {
  id:
    | 'aider'
    | 'claude-code'
    | 'cursor-agent'
    | 'little-coder'
    | 'opencode'
    | 'qwen-code'
  label: string
  blurb: string
}

export const SANDBOX_AGENTS: readonly SandboxAgent[] = [
  {
    id: 'aider',
    label: 'Aider',
    blurb: 'AI pair-programming in your terminal. Git-native edits, multi-file refactors.',
  },
  {
    id: 'claude-code',
    label: 'Claude Code',
    blurb: 'Anthropic’s official agent CLI. Connect Claude Max to use your subscription.',
  },
  {
    id: 'cursor-agent',
    label: 'Cursor Agent',
    blurb: 'Cursor’s background agent CLI for autonomous code changes.',
  },
  {
    id: 'little-coder',
    label: 'Little Coder',
    blurb: 'Lightweight scoped agent for small, focused edits.',
  },
  {
    id: 'opencode',
    label: 'OpenCode',
    blurb: 'Open-source agent runtime with a swappable model backend.',
  },
  {
    id: 'qwen-code',
    label: 'Qwen Code',
    blurb: 'Alibaba Qwen coding agent. OSS-friendly model defaults.',
  },
] as const

/* ── Sessions ────────────────────────────────────────────────────── */

export type SandboxStatus = 'pending' | 'running' | 'stopped' | 'failed' | 'unknown'

/**
 * Raw CRD phase vocabulary as the sandbox-controller emits it on
 * `.status.phase`. Surfaced verbatim alongside the FE-projected
 * `SandboxStatus` so the provisioning UI can render finer-grained
 * progress (e.g. "Provisioning" spinner vs. "Pending" queued state)
 * without re-implementing the controller's projection rules.
 *
 * The catalyst-api projection lives in
 * `products/catalyst/bootstrap/api/internal/handler/sandbox_sessions.go`
 * (PR #1673 — `mapSandboxStatus` + `readSandboxPhase`).
 */
export type SandboxPhase = 'Pending' | 'Provisioning' | 'Ready' | 'Failed' | ''

/**
 * K8s-style condition tuple — surfaced verbatim from
 * `.status.conditions[]` so the FE can render actionable failure
 * reasons (TokenMintFailed | ManifestRenderFailed | GitopsWriteFailed
 * | OwnerEmailInvalid | NoAllowedChannels) inline. The full list of
 * canonical reason strings lives on the controller's `fail()` helper —
 * see `core/controllers/sandbox/internal/sandboxapi/types.go`.
 *
 * Mirrors the catalyst-api wire shape verbatim (`sandboxCondition` in
 * sandbox_sessions.go).
 */
export interface SandboxCondition {
  /** Condition type (e.g. `Ready`, `Provisioning`, `TokenMintFailed`). */
  type: string
  /** Condition status (`True` | `False` | `Unknown`). */
  status: string
  /** Operator-facing reason code (e.g. `TokenMintFailed`). */
  reason?: string
  /** Free-form operator-facing message. */
  message?: string
  /** RFC3339 transition timestamp. */
  lastTransitionTime?: string
}

export interface Sandbox {
  /** Stable Sandbox CR name (sandbox-<slug>). */
  id: string
  /** Operator-facing label (defaults to id when empty). */
  name: string
  /** Agent binary id chosen at create time. */
  agent: SandboxAgent['id']
  status: SandboxStatus
  /**
   * Raw controller phase (Pending|Provisioning|Ready|Failed). Empty
   * when the controller hasn't observed the CR yet. Always available
   * post-PR #1673; older catalyst-api builds omit this field, in which
   * case the FE falls back to `status` for the phase badge.
   */
  phase: SandboxPhase
  /** ISO-8601 creation timestamp. */
  createdAt: string
  /** Repo path mounted at /repo (e.g. 'org/site'); empty when none. */
  repo: string
  /**
   * Controller-managed K8s-style conditions. Empty slice when the
   * controller hasn't observed the CR yet OR when no conditions have
   * been recorded. Always materialised as an array (never undefined)
   * so the FE never has to defensively `?? []`.
   */
  conditions: SandboxCondition[]
}

export interface SandboxesResponse {
  /** True when the BE returned a non-2xx — the page still renders
   *  its target-state shape with the "API pending" pill. */
  pendingApi: boolean
  sandboxes: Sandbox[]
}

const EMPTY_SANDBOXES: SandboxesResponse = { pendingApi: true, sandboxes: [] }

const SANDBOX_BASE = `${API_BASE}/v1/sandbox`

/**
 * getSandboxes — fetch the per-Org sandbox roster for the Landing's
 * "Recent sessions" rail.
 *
 * Returns `{ pendingApi: true, sandboxes: [] }` on 404 / 5xx / network
 * error so the Landing renders the agent grid + empty-state rail on
 * first paint without crashing the surface (per INVIOLABLE-PRINCIPLES.md
 * #1 — waterfall, target-state shape first time).
 */
export async function getSandboxes(): Promise<SandboxesResponse> {
  let res: Response
  try {
    res = await authedFetch(`${SANDBOX_BASE}/sessions`, {
      headers: { Accept: 'application/json' },
    })
  } catch {
    return EMPTY_SANDBOXES
  }
  if (!res.ok) {
    return EMPTY_SANDBOXES
  }
  try {
    const body = (await res.json()) as { sandboxes?: unknown } | null
    if (!body || typeof body !== 'object' || !Array.isArray(body.sandboxes)) {
      return { pendingApi: false, sandboxes: [] }
    }
    const sandboxes: Sandbox[] = body.sandboxes
      .map((raw): Sandbox | null => {
        if (!raw || typeof raw !== 'object') return null
        return normalizeSandbox(raw as Record<string, unknown>)
      })
      .filter((s): s is Sandbox => s !== null)
    return { pendingApi: false, sandboxes }
  } catch {
    return EMPTY_SANDBOXES
  }
}

export interface CreateSandboxRequest {
  /** Agent binary id (must match a SANDBOX_AGENTS row). */
  agent: SandboxAgent['id']
  /** Optional human label; defaults server-side to `<agent>-<short-id>`. */
  name?: string
  /** Optional repo to clone into /repo (e.g. 'org/site'). */
  repo?: string
}

/**
 * createSandbox — POST /v1/sandbox/sessions. Returns the freshly
 * provisioned Sandbox row on success. Surfaces the BE's `detail` /
 * `error` field on non-2xx so the Landing's create-modal can show the
 * actual server-side message.
 */
export async function createSandbox(req: CreateSandboxRequest): Promise<Sandbox> {
  const res = await authedFetch(`${SANDBOX_BASE}/sessions`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify(req),
  })
  if (!res.ok) {
    let detail = `HTTP ${res.status}`
    try {
      const body = (await res.json()) as { detail?: string; error?: string }
      detail = body.detail ?? body.error ?? detail
    } catch {
      // non-JSON body — keep the status-line message
    }
    throw new Error(`create sandbox: ${detail}`)
  }
  const raw = (await res.json()) as Record<string, unknown>
  const normalised = normalizeSandbox(raw)
  if (!normalised) {
    throw new Error('create sandbox: invalid response body')
  }
  return normalised
}

/**
 * getSandbox — GET /v1/sandbox/sessions/{id}. Fetches a single Sandbox
 * row by name; returns `null` on 404 (operator deleted the sandbox in
 * another tab) so the SandboxSession provisioning panel can short-
 * circuit its poll loop without surfacing a misleading "still
 * provisioning" spinner.
 *
 * Other non-2xx responses throw with the server-side `detail` /
 * `error` field so the FE can surface them in a retry banner.
 *
 * Used by the SandboxProvisioningPanel to poll Phase + Conditions
 * until `phase==='Ready'` (terminal-success) or `phase==='Failed'`
 * (terminal-failure → surface conditions[] + retry CTA).
 */
export async function getSandbox(id: string): Promise<Sandbox | null> {
  const res = await authedFetch(
    `${SANDBOX_BASE}/sessions/${encodeURIComponent(id)}`,
    { headers: { Accept: 'application/json' } },
  )
  if (res.status === 404) return null
  if (!res.ok) {
    let detail = `HTTP ${res.status}`
    try {
      const body = (await res.json()) as { detail?: string; error?: string }
      detail = body.detail ?? body.error ?? detail
    } catch {
      // non-JSON body — keep the status-line message
    }
    throw new Error(`get sandbox: ${detail}`)
  }
  const raw = (await res.json()) as Record<string, unknown>
  const normalised = normalizeSandbox(raw)
  if (!normalised) {
    throw new Error('get sandbox: invalid response body')
  }
  return normalised
}

/**
 * deleteSandbox — DELETE /v1/sandbox/sessions/{id}. The catalyst-api
 * handler is idempotent (204 even when the CR is already gone) so the
 * FE can retry safely. Used by the "Delete + retry" flow when a
 * provisioning attempt lands in `Failed`.
 */
export async function deleteSandbox(id: string): Promise<void> {
  const res = await authedFetch(
    `${SANDBOX_BASE}/sessions/${encodeURIComponent(id)}`,
    { method: 'DELETE', headers: { Accept: 'application/json' } },
  )
  if (!res.ok && res.status !== 204 && res.status !== 404) {
    let detail = `HTTP ${res.status}`
    try {
      const body = (await res.json()) as { detail?: string; error?: string }
      detail = body.detail ?? body.error ?? detail
    } catch {
      // non-JSON body — keep the status-line message
    }
    throw new Error(`delete sandbox: ${detail}`)
  }
}

/**
 * normalizeSandbox — single defensive projection from the catalyst-api
 * wire shape to the FE `Sandbox` type. Shared by `getSandboxes`,
 * `getSandbox`, and `createSandbox` so they cannot drift.
 *
 * Returns `null` when the row is missing the `id` field (the FE keys
 * every list/lookup by id; an unkeyed row is unusable).
 */
function normalizeSandbox(raw: Record<string, unknown>): Sandbox | null {
  const id = typeof raw.id === 'string' ? raw.id : ''
  if (id === '') return null
  return {
    id,
    name: typeof raw.name === 'string' && raw.name !== '' ? raw.name : id,
    agent: normalizeAgent(raw.agent),
    status: normalizeStatus(raw.status),
    phase: normalizePhase(raw.phase),
    createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
    repo: typeof raw.repo === 'string' ? raw.repo : '',
    conditions: normalizeConditions(raw.conditions),
  }
}

/* ── BYOS (Bring-Your-Own-Subscription) — Claude Max OAuth ─────────
 *
 * Wave 1b backend stubs:
 *   GET    /v1/sandbox/byos/claude-code/status
 *   POST   /v1/sandbox/byos/claude-code/connect       → returns OAuth URL
 *   DELETE /v1/sandbox/byos/claude-code/disconnect
 *
 * The Connect button on SandboxSettings opens the OAuth URL in a new tab;
 * Anthropic redirects to /v1/sandbox/byos/claude-code/callback which the
 * BE persists. Status flips to `connected` on the next status poll.
 */

export type ByosStatus = 'connected' | 'disconnected' | 'pending' | 'error'

export interface ByosClaudeCodeStatus {
  /** True when the BE returned a non-2xx — the page still renders
   *  but flags the "API pending" pill. */
  pendingApi: boolean
  status: ByosStatus
  /** Account label shown next to the Disconnect button (e.g. user's
   *  Anthropic email). Empty when disconnected. */
  accountLabel: string
  /** ISO-8601 timestamp of the last successful token refresh. */
  connectedAt: string
}

const DISCONNECTED_BYOS: ByosClaudeCodeStatus = {
  pendingApi: true,
  status: 'disconnected',
  accountLabel: '',
  connectedAt: '',
}

/**
 * getByosStatus — fetch the BYOS Claude Code connection status for
 * the SandboxSettings page. Tolerates 404 / 5xx / network error by
 * returning `{ pendingApi: true, status: 'disconnected', ... }` so the
 * page renders its full Connect / Disconnect chrome on first paint.
 */
export async function getByosStatus(): Promise<ByosClaudeCodeStatus> {
  let res: Response
  try {
    res = await authedFetch(`${SANDBOX_BASE}/byos/claude-code/status`, {
      headers: { Accept: 'application/json' },
    })
  } catch {
    return DISCONNECTED_BYOS
  }
  if (!res.ok) {
    return DISCONNECTED_BYOS
  }
  try {
    const body = (await res.json()) as Partial<ByosClaudeCodeStatus> | null
    if (!body || typeof body !== 'object') return DISCONNECTED_BYOS
    return {
      pendingApi: false,
      status: normalizeByosStatus(body.status),
      accountLabel: typeof body.accountLabel === 'string' ? body.accountLabel : '',
      connectedAt: typeof body.connectedAt === 'string' ? body.connectedAt : '',
    }
  } catch {
    return DISCONNECTED_BYOS
  }
}

/**
 * connectByosClaudeCode — POST /v1/sandbox/byos/claude-code/connect.
 * Returns the OAuth authorization URL the SandboxSettings page opens in
 * a new tab. The BE persists the returned tokens via the OAuth callback.
 */
export async function connectByosClaudeCode(): Promise<{ authorizeUrl: string }> {
  const res = await authedFetch(`${SANDBOX_BASE}/byos/claude-code/connect`, {
    method: 'POST',
    headers: { Accept: 'application/json' },
  })
  if (!res.ok) {
    let detail = `HTTP ${res.status}`
    try {
      const body = (await res.json()) as { detail?: string; error?: string }
      detail = body.detail ?? body.error ?? detail
    } catch {
      // ignore
    }
    throw new Error(`connect claude-code: ${detail}`)
  }
  const body = (await res.json()) as { authorizeUrl?: string }
  return { authorizeUrl: typeof body.authorizeUrl === 'string' ? body.authorizeUrl : '' }
}

/**
 * BYOS config shape — the pre-flight the SandboxSettings card runs
 * before showing the Connect button. When `clientIdConfigured` is false
 * the chart's `SANDBOX_BYOS_CLAUDE_CODE_CLIENT_ID` is still the
 * `PLACEHOLDER-AWAITING-FOUNDER-REGISTRATION` sentinel (PR #1619) and
 * the FE MUST render the button as DISABLED with the amber pendingApi
 * pill + a tooltip pointing the operator at the founder action.
 */
export interface ByosClaudeCodeConfig {
  /** True when the FE was unable to reach `/config` — treated as
   *  pending operator setup so the UI never offers a button that would
   *  fail at Anthropic. */
  pendingApi: boolean
  clientIdConfigured: boolean
  /** Optional informational hint; only populated when configured. */
  oauthAuthorizeURL: string
}

const PENDING_BYOS_CONFIG: ByosClaudeCodeConfig = {
  pendingApi: true,
  clientIdConfigured: false,
  oauthAuthorizeURL: '',
}

/**
 * getByosClaudeCodeConfig — GET /v1/sandbox/byos/claude-code/config.
 *
 * The SandboxSettings card calls this before rendering the Connect
 * button. On 404 / 5xx / network error returns
 * `{pendingApi: true, clientIdConfigured: false, ...}` so the UI defaults
 * to the safer "disabled with pending tooltip" state — never the
 * "enabled but the OAuth URL will 400" state.
 */
export async function getByosClaudeCodeConfig(): Promise<ByosClaudeCodeConfig> {
  let res: Response
  try {
    res = await authedFetch(`${SANDBOX_BASE}/byos/claude-code/config`, {
      headers: { Accept: 'application/json' },
    })
  } catch {
    return PENDING_BYOS_CONFIG
  }
  if (!res.ok) {
    return PENDING_BYOS_CONFIG
  }
  try {
    const body = (await res.json()) as Partial<ByosClaudeCodeConfig> | null
    if (!body || typeof body !== 'object') return PENDING_BYOS_CONFIG
    return {
      pendingApi: false,
      clientIdConfigured: body.clientIdConfigured === true,
      oauthAuthorizeURL:
        typeof body.oauthAuthorizeURL === 'string' ? body.oauthAuthorizeURL : '',
    }
  } catch {
    return PENDING_BYOS_CONFIG
  }
}

/**
 * disconnectByosClaudeCode — DELETE /v1/sandbox/byos/claude-code/
 * disconnect. Drops the stored OAuth tokens. Surfaces the BE's `detail` /
 * `error` field on non-2xx.
 */
export async function disconnectByosClaudeCode(): Promise<void> {
  const res = await authedFetch(`${SANDBOX_BASE}/byos/claude-code/disconnect`, {
    method: 'DELETE',
    headers: { Accept: 'application/json' },
  })
  if (!res.ok && res.status !== 204) {
    let detail = `HTTP ${res.status}`
    try {
      const body = (await res.json()) as { detail?: string; error?: string }
      detail = body.detail ?? body.error ?? detail
    } catch {
      // ignore
    }
    throw new Error(`disconnect claude-code: ${detail}`)
  }
}

/* ── Normalisers ─────────────────────────────────────────────────── */

function normalizeAgent(raw: unknown): SandboxAgent['id'] {
  if (typeof raw !== 'string') return 'claude-code'
  const hit = SANDBOX_AGENTS.find((a) => a.id === raw)
  return hit ? hit.id : 'claude-code'
}

function normalizeStatus(raw: unknown): SandboxStatus {
  if (typeof raw !== 'string') return 'unknown'
  const s = raw.toLowerCase()
  if (s === 'pending' || s === 'running' || s === 'stopped' || s === 'failed') return s
  return 'unknown'
}

function normalizePhase(raw: unknown): SandboxPhase {
  if (typeof raw !== 'string') return ''
  const s = raw.trim()
  if (s === 'Pending' || s === 'Provisioning' || s === 'Ready' || s === 'Failed') return s
  return ''
}

function normalizeConditions(raw: unknown): SandboxCondition[] {
  if (!Array.isArray(raw)) return []
  const out: SandboxCondition[] = []
  for (const entry of raw) {
    if (!entry || typeof entry !== 'object') continue
    const r = entry as Record<string, unknown>
    const type = typeof r.type === 'string' ? r.type.trim() : ''
    if (type === '') continue
    out.push({
      type,
      status: typeof r.status === 'string' ? r.status.trim() : '',
      reason: typeof r.reason === 'string' ? r.reason.trim() : undefined,
      message: typeof r.message === 'string' ? r.message.trim() : undefined,
      lastTransitionTime:
        typeof r.lastTransitionTime === 'string'
          ? r.lastTransitionTime.trim()
          : undefined,
    })
  }
  return out
}

function normalizeByosStatus(raw: unknown): ByosStatus {
  if (typeof raw !== 'string') return 'disconnected'
  const s = raw.toLowerCase()
  if (s === 'connected' || s === 'disconnected' || s === 'pending' || s === 'error') return s
  return 'disconnected'
}
