/**
 * public/gh-chip.ts (W3 PR + CI status chip) — render-only, mirrors public/diff.ts.
 *
 * Receives a pre-parsed PrStatus from the server (GET /projects/pr) and renders a
 * compact chip: PR state · N checks passing · mergeable. Zero PR parsing lives
 * here (parsing is in src/http/gh.ts). It NEVER throws and degrades to a
 * self-explaining chip when gh is missing / unauthenticated / has no PR.
 *
 * Security: SEC-H4 — ALL text content is set via textContent / el(). Zero
 * innerHTML anywhere in this file. A PR `title` is attacker-controllable (anyone
 * who can open a PR on a repo the host can read); it appears as literal text.
 */

import type { PrAvailability, PrCheckSummary, PrStatus } from '../src/types.js'

/* ── constants ───────────────────────────────────────────────────────────────── */

const GH_INSTALL_URL = 'https://cli.github.com'
const VALID_AVAILABILITY: ReadonlySet<string> = new Set<PrAvailability>([
  'ok',
  'no-pr',
  'not-installed',
  'unauthenticated',
  'disabled',
  'error',
])

/* ── DOM helper ──────────────────────────────────────────────────────────────── */

/** Create an element with an optional CSS class and text content. */
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
}

/* ── normalizePrStatus ───────────────────────────────────────────────────────── */

function normalizeChecks(raw: unknown): PrCheckSummary | undefined {
  if (raw === null || typeof raw !== 'object') return undefined
  const o = raw as Record<string, unknown>
  if (
    typeof o['total'] !== 'number' ||
    typeof o['passing'] !== 'number' ||
    typeof o['failing'] !== 'number' ||
    typeof o['pending'] !== 'number'
  ) {
    return undefined
  }
  return {
    total: o['total'],
    passing: o['passing'],
    failing: o['failing'],
    pending: o['pending'],
  }
}

/**
 * Coerce an untrusted API response into a PrStatus, or return null. Never throws.
 * Mirrors normalizeDiffResult (diff.ts): a non-object or an unknown `availability`
 * ⇒ null; otherwise the known optional fields are copied when well-typed.
 */
export function normalizePrStatus(raw: unknown): PrStatus | null {
  if (raw === null || typeof raw !== 'object') return null
  const o = raw as Record<string, unknown>
  if (typeof o['availability'] !== 'string' || !VALID_AVAILABILITY.has(o['availability'])) {
    return null
  }
  const status: PrStatus = { availability: o['availability'] as PrAvailability }
  if (typeof o['number'] === 'number') status.number = o['number']
  if (typeof o['title'] === 'string') status.title = o['title']
  if (typeof o['url'] === 'string') status.url = o['url']
  if (o['state'] === 'open' || o['state'] === 'closed' || o['state'] === 'merged') {
    status.state = o['state']
  }
  if (typeof o['isDraft'] === 'boolean') status.isDraft = o['isDraft']
  if (
    o['mergeable'] === 'mergeable' ||
    o['mergeable'] === 'conflicting' ||
    o['mergeable'] === 'unknown'
  ) {
    status.mergeable = o['mergeable']
  }
  if (typeof o['headRefName'] === 'string') status.headRefName = o['headRefName']
  if (typeof o['baseRefName'] === 'string') status.baseRefName = o['baseRefName']
  const checks = normalizeChecks(o['checks'])
  if (checks !== undefined) status.checks = checks
  return status
}

/* ── fetchPrStatus ───────────────────────────────────────────────────────────── */

/**
 * Fetch the PR + CI status for a repo path. Returns null on any error or invalid
 * response (mirrors fetchDiff) — the caller degrades that to an 'error' chip.
 */
export async function fetchPrStatus(repoPath: string): Promise<PrStatus | null> {
  try {
    const res = await fetch(`/projects/pr?path=${encodeURIComponent(repoPath)}`)
    if (!res.ok) return null
    const data: unknown = await res.json()
    return normalizePrStatus(data)
  } catch {
    return null
  }
}

/* ── chipText (pure) ─────────────────────────────────────────────────────────── */

/** The rendered chip's label, modifier CSS classes, and tooltip title. `null`
 *  when the chip should be hidden (availability 'disabled'). */
export interface ChipText {
  label: string
  cls: string
  title: string
}

/** PR-number prefix, reflecting draft / merged / closed state. */
function statePrefix(status: PrStatus): string {
  const n = typeof status.number === 'number' ? `#${status.number}` : ''
  if (status.isDraft === true) return `Draft ${n}`.trim()
  if (status.state === 'merged') return `Merged ${n}`.trim()
  if (status.state === 'closed') return `Closed ${n}`.trim()
  return `PR ${n}`.trim()
}

/** State → modifier class (draft wins over the raw open/closed/merged state). */
function stateClass(status: PrStatus): string {
  if (status.isDraft === true) return 'proj-pr-draft'
  if (status.state === 'merged') return 'proj-pr-merged'
  if (status.state === 'closed') return 'proj-pr-closed'
  return 'proj-pr-open'
}

/** Checks segment (glyph + passing/total) and its class; null when total === 0. */
function checksSegment(checks: PrCheckSummary): { text: string; cls: string } | null {
  if (checks.total <= 0) return null
  if (checks.failing > 0) {
    return { text: `✕ ${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-fail' }
  }
  if (checks.pending > 0) {
    return { text: `⧗ ${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-pending' }
  }
  return { text: `✓ ${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-ok' }
}

const DEGRADED: Record<Exclude<PrAvailability, 'ok' | 'disabled'>, ChipText> = {
  'no-pr': { label: 'No PR', cls: 'proj-pr-none', title: 'No pull request for the current branch' },
  'not-installed': {
    label: 'gh not installed',
    cls: 'proj-pr-unavailable',
    title: `Install the GitHub CLI to see PR status: ${GH_INSTALL_URL}`,
  },
  unauthenticated: {
    label: 'gh auth login',
    cls: 'proj-pr-unavailable',
    title: 'Run `gh auth login` on the host to see PR status',
  },
  error: {
    label: 'PR status unavailable',
    cls: 'proj-pr-unavailable',
    title: 'Could not read PR status',
  },
}

/**
 * Pure map from a PrStatus to the chip's {label, cls, title}. Returns null for
 * 'disabled' (the chip is hidden). For 'ok' the label combines the PR-state
 * prefix, the checks glyph (✓ / ✕ / ⧗ passing/total), and a "⚠ conflicts" marker
 * when mergeable is 'conflicting'; cls collects the matching modifier classes.
 */
export function chipText(status: PrStatus): ChipText | null {
  if (status.availability === 'disabled') return null
  if (status.availability !== 'ok') return DEGRADED[status.availability]

  const parts: string[] = [statePrefix(status)]
  const classes: string[] = [stateClass(status)]

  if (status.checks !== undefined) {
    const seg = checksSegment(status.checks)
    if (seg !== null) {
      parts.push(seg.text)
      classes.push(seg.cls)
    }
  }
  if (status.mergeable === 'conflicting') {
    parts.push('⚠ conflicts')
    classes.push('proj-pr-conflict')
  }

  return {
    label: parts.join(' '),
    cls: classes.join(' '),
    title: status.title !== undefined && status.title !== '' ? status.title : parts[0] ?? 'PR',
  }
}

/* ── renderPrChip ────────────────────────────────────────────────────────────── */

/**
 * Render a PrStatus into a chip element. 'disabled' ⇒ an empty display:none span.
 *
 * Security: SEC-H4 — all text via textContent. The PR `title` (attacker-
 * controllable) is set as the tooltip attribute AND appended as an inert text
 * span; it can never inject an element.
 */
export function renderPrChip(status: PrStatus): HTMLElement {
  const info = chipText(status)
  if (info === null) {
    const hidden = el('span', 'proj-pr-chip proj-pr-hidden')
    hidden.style.display = 'none'
    return hidden
  }

  const chip = el('span', `proj-pr-chip ${info.cls}`)
  chip.title = info.title // attribute — never HTML-parsed (SEC-H4)
  chip.append(el('span', 'proj-pr-label', info.label))

  // The PR title is attacker-controllable; render it as literal text (SEC-H4).
  if (status.availability === 'ok' && typeof status.title === 'string' && status.title !== '') {
    chip.append(el('span', 'proj-pr-title', status.title))
  }
  return chip
}

/* ── mountPrChip ─────────────────────────────────────────────────────────────── */

/** Handle returned by mountPrChip for cleanup. */
export interface PrChipHandle {
  destroy(): void
}

/**
 * Mount a PR-status chip into `container`: show a loading placeholder, fetch the
 * status, then swap in the resolved chip. A fetch failure degrades to an 'error'
 * chip (never throws). destroy() removes the node and cancels the swap.
 */
export function mountPrChip(container: HTMLElement, repoPath: string): PrChipHandle {
  let destroyed = false

  container.textContent = ''
  container.append(el('span', 'proj-pr-chip proj-pr-loading', '…'))

  void (async () => {
    const status = await fetchPrStatus(repoPath)
    if (destroyed) return
    container.textContent = ''
    container.append(renderPrChip(status ?? { availability: 'error' }))
  })()

  return {
    destroy() {
      destroyed = true
      container.textContent = ''
    },
  }
}
