/**
 * public/git-log.ts (W3 quick-wins d) — render-only recent-commit list.
 *
 * Fetches GET /projects/log for a repo and renders each commit as an inert row.
 * Zero parsing lives here (parsing is in src/http/git-log.ts). It NEVER throws
 * and degrades to a short inert message on any failure.
 *
 * Security: SEC-H5 — ALL text is set via textContent / el(). Zero innerHTML. A
 * commit subject is attacker-influenced (anyone who can push to a repo the host
 * can read), so it appears strictly as literal text.
 */

import type { CommitLogEntry, GitLogResult } from '../src/types.js'

/* ── 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
}

/* ── normalize (never trust the API shape) ───────────────────────────────────── */

/** Coerce one untrusted /projects/log element into a safe CommitLogEntry, or null. */
function normalizeCommit(raw: unknown): CommitLogEntry | null {
  if (raw === null || typeof raw !== 'object') return null
  const o = raw as Record<string, unknown>
  if (typeof o['hash'] !== 'string' || typeof o['subject'] !== 'string') return null
  if (typeof o['at'] !== 'number' || !Number.isFinite(o['at'])) return null
  const entry: CommitLogEntry = { hash: o['hash'], at: o['at'], subject: o['subject'] }
  // w6/G4: only an explicit `true` marks a commit unpushed — anything else (absent,
  // truthy junk, an older server) must fall back to "pushed", because the failure
  // that matters is calling an unpushed commit pushed, not the reverse.
  return o['unpushed'] === true ? { ...entry, unpushed: true } : entry
}

/** Coerce an untrusted GET /projects/log response into a GitLogResult, or null. */
export function normalizeGitLog(raw: unknown): GitLogResult | null {
  if (raw === null || typeof raw !== 'object') return null
  const o = raw as Record<string, unknown>
  if (!Array.isArray(o['commits'])) return null
  const commits = o['commits']
    .map(normalizeCommit)
    .filter((c): c is CommitLogEntry => c !== null)
  const upstream = typeof o['upstream'] === 'string' && o['upstream'] !== '' ? o['upstream'] : undefined
  return {
    commits,
    truncated: o['truncated'] === true,
    ...(upstream !== undefined ? { upstream } : {}),
  }
}

/* ── fetch ───────────────────────────────────────────────────────────────────── */

/** Fetch the recent-commit log for a repo path. null on any error (best-effort). */
export async function fetchGitLog(repoPath: string): Promise<GitLogResult | null> {
  try {
    if (typeof fetch === 'undefined') return null
    const res = await fetch(`/projects/log?path=${encodeURIComponent(repoPath)}`)
    if (!res.ok) return null
    return normalizeGitLog(await res.json())
  } catch {
    return null
  }
}

/* ── render ──────────────────────────────────────────────────────────────────── */

/** Coarse "Ns / Nm / Nh / Nd ago" formatter (local copy — avoids importing xterm). */
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`
}

/** One commit row: short hash · relative time · subject (all inert text).
 *  w6/G4: an unpushed commit gets a rail + ↑ so the eye reads the group, not the
 *  individual rows. */
function renderCommitRow(c: CommitLogEntry): HTMLElement {
  const row = el('div', 'proj-commit-row')
  // The mark cell is rendered on EVERY row, empty when the commit is pushed.
  // Appending it only for unpushed rows left the grid's first track unoccupied on
  // pushed rows, so the sha, age and subject columns shifted between lines and
  // the unpushed group stopped reading as a block.
  const mark = el('span', 'proj-commit-mark', c.unpushed === true ? '↑' : '')
  if (c.unpushed === true) {
    row.classList.add('proj-commit-unpushed')
    mark.title = 'Not pushed yet'
  }
  row.append(mark)
  row.append(el('span', 'proj-commit-hash', c.hash))
  row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`))
  row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent
  return row
}

/** Render a GitLogResult into a container (clears first). Empty → an inert note. */
export function renderGitLog(
  container: HTMLElement,
  log: GitLogResult,
  labelEl?: HTMLElement,
): void {
  container.textContent = ''
  // Design: the count sits ON the section heading, not on a line of its own —
  // it qualifies "Recent commits", it is not a separate statement.
  const badgeHost = labelEl ?? container
  badgeHost.querySelector('.proj-commitlog-count')?.remove()
  if (log.commits.length === 0) {
    container.append(el('div', 'proj-empty', 'No commits yet.'))
    return
  }
  // Design: the section label carries the unpushed count, so the number is
  // readable before the eye has walked the rows looking for rails.
  const unpushedCount = log.commits.filter((c) => c.unpushed === true).length
  if (unpushedCount > 0 && log.upstream !== undefined) {
    const badge = el('span', 'proj-commitlog-count', `↑ ${unpushedCount} unpushed`)
    badge.title = `${unpushedCount} commit(s) not on ${log.upstream}`
    badgeHost.append(badge)
  }

  const list = el('div', 'proj-commitlog-list')
  // w6/G4: draw the upstream's position ONCE, right after the last unpushed row.
  // Only with a named upstream — with nothing to compare against there is no
  // boundary to claim, and an invented one would be a lie about what is pushed.
  const lastUnpushed = log.upstream === undefined
    ? -1
    : log.commits.reduce((acc, c, i) => (c.unpushed === true ? i : acc), -1)

  log.commits.forEach((c, i) => {
    list.append(renderCommitRow(c))
    if (i === lastUnpushed) {
      const boundary = el('div', 'proj-commit-boundary')
      boundary.append(el('span', 'proj-commit-boundary-label', log.upstream as string))
      boundary.append(el('span', 'proj-commit-boundary-rule'))
      list.append(boundary)
    }
  })
  container.append(list)
  if (log.truncated) {
    container.append(el('div', 'proj-commit-more', `Showing the latest ${log.commits.length} commits.`))
  }
}

/* ── mount ───────────────────────────────────────────────────────────────────── */

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

/**
 * Mount a recent-commit list into `container`: show a loading placeholder, fetch
 * the log, then swap in the rows. A fetch failure degrades to a short inert
 * message (never throws). destroy() removes the node and cancels the swap.
 */
export function mountGitLog(
  container: HTMLElement,
  repoPath: string,
  labelEl?: HTMLElement,
): GitLogHandle {
  let destroyed = false

  container.textContent = ''
  container.append(el('div', 'proj-commitlog-loading', 'Loading commits…'))

  void (async () => {
    const log = await fetchGitLog(repoPath)
    if (destroyed) return
    if (log === null) {
      container.textContent = ''
      container.append(el('div', 'proj-empty', 'Could not read recent commits.'))
      return
    }
    renderGitLog(container, log, labelEl)
  })()

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