/**
 * public/preview-grid.ts — shared live-preview thumbnail helpers.
 *
 * The launcher (home session chooser) and the manage page both render a grid of
 * the host's running sessions as read-only xterm thumbnails fed by
 * GET /live-sessions/:id/preview. This module holds the DRY core: the small DOM
 * helper, time/status formatting, the preview card factory, the scaling fit, and
 * the preview fetch — so both pages import it instead of duplicating ~80%.
 */

import { Terminal } from '@xterm/xterm'
import type {
  LiveSessionInfo,
  OrphanSessionInfo,
  StatusTelemetry,
  ClaudeStatus,
} from '../src/types.js'

/** Shape of GET /live-sessions/:id/preview. */
export interface SessionPreview {
  id: string
  cols: number
  rows: number
  data: string
}

/** Reset screen + scrollback + cursor before writing the tail. */
export const PREVIEW_CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m'
export const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }

/** Create an element with an optional class and text content. */
export function el<K extends keyof HTMLElementTagNameMap>(
  tag: K,
  cls?: string,
  text?: string,
): HTMLElementTagNameMap[K] {
  const node = document.createElement(tag)
  if (cls) node.className = cls
  if (text !== undefined) node.textContent = text
  return node
}

/** Coarse "Ns / Nm / Nh / Nd ago" formatter. */
export function relTime(ms: number): string {
  const s = Math.max(0, (Date.now() - ms) / 1000)
  if (s < 60) return `${Math.floor(s)}s`
  if (s < 3600) return `${Math.floor(s / 60)}m`
  if (s < 86400) return `${Math.floor(s / 3600)}h`
  return `${Math.floor(s / 86400)}d`
}

/** Human label for a Claude activity status. Supports all ClaudeStatus values including 'stuck'. */
export function statusText(s: LiveSessionInfo['status']): string {
  if (s === 'working') return '⚙ working'
  if (s === 'waiting') return '⏳ waiting'
  if (s === 'idle') return '✓ idle'
  if (s === 'stuck') return '⚠ stuck'
  return '·'
}

/** Display name for a session: last cwd segment, else the short id. */
export function sessionName(s: LiveSessionInfo): string {
  return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
}

/** A rendered preview card: the element tree plus the live xterm and its parts. */
export interface PreviewCard {
  el: HTMLElement
  term: Terminal
  /** Set by disposeCard. A preview is fetched fire-and-forget, so a card can be
   *  torn down while its request is in flight, and xterm throws "Object has been
   *  disposed" on a disposed terminal. Checked by renderPreview. */
  disposed: boolean
  inner: HTMLElement
  thumb: HTMLElement
  watch: HTMLElement
  status: HTMLElement
  meta: HTMLElement
}

export interface MakeCardOpts {
  /** Called when the card (thumbnail or Open action) is activated. */
  onOpen: (id: string) => void
  /** Optional extra action button(s) appended to the card actions row. */
  extraActions?: (s: LiveSessionInfo) => HTMLElement[]
  /** Use an <a href> Open link instead of a button (manage page). */
  openHref?: (id: string) => string
}

/** Build a preview card for one session (read-only xterm thumbnail). */
export function makePreviewCard(s: LiveSessionInfo, opts: MakeCardOpts): PreviewCard {
  const cardEl = el('div', 'mg-card')

  const head = el('div', 'mg-card-head')
  const title = el('span', 'mg-name', sessionName(s))
  const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
  const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
  head.append(title, status, watch)

  const thumb = el('div', 'mg-thumb')
  const inner = el('div', 'mg-thumb-inner')
  thumb.append(inner)
  thumb.addEventListener('click', () => opts.onOpen(s.id))

  const meta = el('div', 'mg-meta')

  const actions = el('div', 'mg-actions')
  if (opts.openHref) {
    const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
    open.href = opts.openHref(s.id)
    actions.append(open)
  } else {
    const open = el('button', 'mg-open', 'Open ↗')
    open.addEventListener('click', () => opts.onOpen(s.id))
    actions.append(open)
  }
  if (opts.extraActions) actions.append(...opts.extraActions(s))

  const term = new Terminal({
    cols: Math.max(2, s.cols),
    rows: Math.max(2, s.rows),
    disableStdin: true,
    cursorBlink: false,
    fontFamily: 'Menlo, Consolas, monospace',
    fontSize: 12,
    scrollback: 0,
    theme: PREVIEW_THEME,
  })
  term.open(inner)

  cardEl.append(head, thumb, meta, actions)
  return { el: cardEl, term, inner, thumb, watch, status, meta, disposed: false }
}

/** Re-apply a session's live status/watch/meta fields to its card in place. */
export function updatePreviewCard(card: PreviewCard, s: LiveSessionInfo): void {
  card.status.className = `mg-status mg-${s.status}`
  card.status.textContent = statusText(s.status)
  card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch'
  card.watch.textContent = `👁 ${s.clientCount}`
}

/** Tear a card down: mark it, dispose its terminal, detach its element. One place,
 *  so no caller can dispose the terminal and forget to set the flag. */
export function disposeCard(card: PreviewCard): void {
  card.disposed = true
  card.term.dispose()
  card.el.remove()
}

/** Scale the rendered xterm down so the full screen fits `thumbW` px wide. */
export function fitThumb(card: PreviewCard, thumbW: number, thumbMaxH: number): void {
  const w = card.inner.offsetWidth
  const h = card.inner.offsetHeight
  if (w === 0 || h === 0) return
  const scale = Math.min(1, thumbW / w)
  card.inner.style.transform = `scale(${scale})`
  card.thumb.style.height = `${Math.min(h * scale, thumbMaxH)}px`
}

/** Fetch a session preview, returning null on any failure (best-effort). */
export async function fetchPreview(id: string): Promise<SessionPreview | null> {
  try {
    const res = await fetch(`/live-sessions/${id}/preview`)
    if (!res.ok) return null
    return (await res.json()) as SessionPreview
  } catch {
    return null
  }
}

/** Render a preview into a card's xterm, resizing + scaling to fit.
 *  cols/rows of 0 mean "size unknown, keep what the card already has" — the
 *  orphan preview omits them because its dimensions came with the list, and
 *  resizing to Math.max(2, 0) would collapse the thumbnail to 2×2. */
export function renderPreview(card: PreviewCard, p: SessionPreview, thumbW: number, thumbMaxH: number): void {
  // A preview is fetched fire-and-forget, so the card can be torn down while its
  // request is in flight, and xterm throws "Object has been disposed". An explicit
  // flag, not el.isConnected: a card that has not been appended YET is alive, and
  // conflating the two would drop the first render.
  if (card.disposed) return
  if (p.cols > 0 && p.rows > 0 && (card.term.cols !== p.cols || card.term.rows !== p.rows)) {
    card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows))
  }
  card.term.reset()
  card.term.write(PREVIEW_CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card, thumbW, thumbMaxH)))
}

/** Fetch + render a session's preview into its card (best-effort, no throw). */
export async function loadPreviewInto(
  id: string,
  card: PreviewCard,
  thumbW: number,
  thumbMaxH: number,
): Promise<void> {
  const p = await fetchPreview(id)
  if (p) renderPreview(card, p, thumbW, thumbMaxH)
}

/** Fetch the host's live sessions list, returning [] on failure. */
export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
  try {
    const res = await fetch('/live-sessions')
    const data: unknown = await res.json()
    return Array.isArray(data) ? (data as LiveSessionInfo[]) : []
  } catch {
    return []
  }
}

/* ── A: orphan tmux sessions ─────────────────────────────────────────────────
   Sessions that outlived the server process which created them. They reuse the
   card machinery above through orphanAsLive, so there is one thumbnail
   implementation rather than two that drift. */

/**
 * Fetch untracked tmux sessions. Returns null on FAILURE, distinct from [] meaning
 * "there are none" — collapsing the two would let a 429, a 500 or a dropped request
 * read as "all recovered sessions are gone", tearing the grid down and re-mounting
 * every xterm on the next success.
 */
export async function fetchOrphanSessions(): Promise<OrphanSessionInfo[] | null> {
  try {
    const res = await fetch('/orphan-sessions')
    if (!res.ok) return null
    const data: unknown = await res.json()
    return Array.isArray(data) ? (data as OrphanSessionInfo[]) : null
  } catch {
    return null
  }
}

/** How many sessions the bulk cleanup would actually end. null on failure. */
export async function countIdleOrphans(days: number): Promise<number | null> {
  try {
    const res = await fetch(`/orphan-sessions/count-idle?idleDays=${encodeURIComponent(String(days))}`)
    if (!res.ok) return null
    const body = (await res.json()) as { count?: unknown }
    return typeof body.count === 'number' ? body.count : null
  } catch {
    return null
  }
}

/** Fetch an orphan's screen (tmux capture-pane). null on any failure. */
export async function fetchOrphanPreview(id: string): Promise<SessionPreview | null> {
  try {
    const res = await fetch(`/orphan-sessions/${encodeURIComponent(id)}/preview`)
    if (!res.ok) return null
    return (await res.json()) as SessionPreview
  } catch {
    return null
  }
}

/**
 * Present an orphan as a LiveSessionInfo so makePreviewCard can render it.
 *
 * `status` is 'unknown', never 'idle': status comes from Claude Code's hooks into
 * a session this server is streaming, and for an orphan we have not looked — so
 * claiming 'idle' would assert something we do not know. `cwd` is null for the
 * same reason. `clientCount` mirrors tmux's client count, so a session someone is
 * attached to from a real terminal reads as watched rather than abandoned.
 */
export function orphanAsLive(o: OrphanSessionInfo): LiveSessionInfo {
  return {
    id: o.id,
    createdAt: o.createdAt,
    clientCount: o.attached ? 1 : 0,
    status: 'unknown',
    exited: false,
    cwd: null,
    cols: o.cols,
    rows: o.rows,
    lastOutputAt: o.lastActivityAt,
  }
}

/** Kill one orphan. Resolves true on 204. */
export async function killOrphanReq(id: string): Promise<boolean> {
  try {
    const res = await fetch(`/orphan-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' })
    return res.status === 204
  } catch {
    return false
  }
}

/** Bulk-kill unattached orphans idle longer than `days`. Returns ids killed. */
export async function cleanupOrphansReq(days: number): Promise<string[]> {
  try {
    const res = await fetch(`/orphan-sessions?idleDays=${encodeURIComponent(String(days))}`, {
      method: 'DELETE',
    })
    if (!res.ok) return []
    const body = (await res.json()) as { ids?: unknown }
    return Array.isArray(body.ids) ? (body.ids as string[]) : []
  } catch {
    return []
  }
}

/**
 * Render a telemetry gauge into `container` (clears first).
 *
 * Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
 * and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
 * receives the class `tg-stale` so CSS can grey it out. When `costBudgetUsd` is
 * set (>0) and `costUsd >= costBudgetUsd`, the cost chip gets `tg-cost-warn`
 * (W3 quick-wins b), mirroring the ctx>80% warn path.
 *
 * Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
 * link href is only set when `url.protocol === 'https:'` (SEC-L5).
 * Zero `innerHTML` anywhere.
 */
export function renderTelemetryGauge(
  container: HTMLElement,
  telemetry: StatusTelemetry | null,
  staleTtlMs: number,
  costBudgetUsd?: number,
): void {
  // Clear existing children
  while (container.firstChild) container.removeChild(container.firstChild)

  if (!telemetry) {
    container.classList.remove('tg-stale')
    return
  }

  const isStale = Date.now() - telemetry.at > staleTtlMs
  if (isStale) container.classList.add('tg-stale')
  else container.classList.remove('tg-stale')

  // Context-usage bar
  if (telemetry.contextUsedPct !== undefined) {
    const bar = el('div', 'tg-ctx-bar')
    const fill = el('div', 'tg-ctx-fill')
    fill.style.width = `${Math.min(100, telemetry.contextUsedPct)}%`
    if (telemetry.contextUsedPct > 80) fill.classList.add('tg-ctx-warn')
    bar.append(fill, el('span', 'tg-ctx-label', `ctx ${Math.round(telemetry.contextUsedPct)}%`))
    container.append(bar)
  }

  // Cost chip — W3(b): warn-styled once cost crosses the configured budget.
  if (telemetry.costUsd !== undefined) {
    const cost = el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`)
    if (costBudgetUsd !== undefined && costBudgetUsd > 0 && telemetry.costUsd >= costBudgetUsd) {
      cost.classList.add('tg-cost-warn')
    }
    container.append(cost)
  }

  // Model chip
  if (telemetry.model !== undefined) {
    container.append(el('span', 'tg-model', telemetry.model))
  }

  // PR badge
  if (telemetry.pr !== undefined) {
    const badge = el('span', 'tg-pr')
    const link = el('a', 'tg-pr-link')
    link.textContent = `PR #${telemetry.pr.number}`

    // SEC-L5: only set href for https URLs
    try {
      const parsed = new URL(telemetry.pr.url)
      if (parsed.protocol === 'https:') {
        link.href = telemetry.pr.url
        link.target = '_blank'
        link.rel = 'noopener noreferrer'
      }
    } catch {
      // Invalid URL — leave href unset
    }

    badge.append(link)
    if (telemetry.pr.reviewState !== undefined) {
      badge.append(el('span', 'tg-pr-state', telemetry.pr.reviewState))
    }
    container.append(badge)
  }
}

/**
 * Render a Claude status badge into `container` (clears first).
 * 'stuck' shows the ⚠ warning symbol (A5). Uses only textContent (SEC-H5).
 */
export function renderStatusBadge(container: HTMLElement, status: ClaudeStatus): void {
  while (container.firstChild) container.removeChild(container.firstChild)
  container.append(el('span', `sb-badge sb-${status}`, statusText(status)))
}
