/**
 * public/diff.ts — N-diff-ui: diff viewer (render-only).
 *
 * This module is RENDER-ONLY: it receives pre-parsed DiffResult/DiffFile/DiffLine
 * structures from the server (GET /projects/diff) and renders them as DOM elements.
 * Zero diff parsing lives here (parsing is in src/http/diff.ts, review #2).
 *
 * Security: SEC-H4 — ALL diff content is set via textContent / el(). Zero innerHTML
 * anywhere in this file. <script>, & and ANSI escape sequences appear as plain text.
 */

import type { DiffResult, DiffFile, DiffHunk, DiffLine } from '../src/types.js'

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

const EMPTY_MESSAGE = 'No changes'

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

/* ── normalizeDiffResult ─────────────────────────────────────────────────────── */

/**
 * Coerce an untrusted API response into a DiffResult, or return null.
 * Never throws. Filters out any file entries that are not valid objects.
 */
export function normalizeDiffResult(raw: unknown): DiffResult | null {
  if (raw === null || typeof raw !== 'object') return null
  const o = raw as Record<string, unknown>

  if (!Array.isArray(o['files'])) return null
  if (typeof o['staged'] !== 'boolean') return null
  if (typeof o['truncated'] !== 'boolean') return null

  const files = (o['files'] as unknown[])
    .map(normalizeFile)
    .filter((f): f is DiffFile => f !== null)

  return {
    files,
    staged: o['staged'],
    truncated: o['truncated'],
    base: typeof o['base'] === 'string' ? o['base'] : undefined,
  }
}

function normalizeFile(raw: unknown): DiffFile | null {
  if (raw === null || typeof raw !== 'object') return null
  const o = raw as Record<string, unknown>

  if (typeof o['oldPath'] !== 'string') return null
  if (typeof o['newPath'] !== 'string') return null
  if (typeof o['status'] !== 'string') return null
  if (typeof o['added'] !== 'number') return null
  if (typeof o['removed'] !== 'number') return null
  if (typeof o['binary'] !== 'boolean') return null
  if (!Array.isArray(o['hunks'])) return null

  const hunks = (o['hunks'] as unknown[])
    .map(normalizeHunk)
    .filter((h): h is DiffHunk => h !== null)

  return {
    oldPath: o['oldPath'],
    newPath: o['newPath'],
    status: o['status'] as DiffFile['status'],
    added: o['added'],
    removed: o['removed'],
    binary: o['binary'],
    hunks,
  }
}

function normalizeHunk(raw: unknown): DiffHunk | null {
  if (raw === null || typeof raw !== 'object') return null
  const o = raw as Record<string, unknown>

  if (typeof o['header'] !== 'string') return null
  if (!Array.isArray(o['lines'])) return null

  const lines = (o['lines'] as unknown[])
    .map(normalizeLine)
    .filter((l): l is DiffLine => l !== null)

  return { header: o['header'], lines }
}

function normalizeLine(raw: unknown): DiffLine | null {
  if (raw === null || typeof raw !== 'object') return null
  const o = raw as Record<string, unknown>

  if (typeof o['kind'] !== 'string') return null
  if (typeof o['text'] !== 'string') return null

  return { kind: o['kind'] as DiffLine['kind'], text: o['text'] }
}

/* ── fetchDiff ───────────────────────────────────────────────────────────────── */

/** Options for {@link fetchDiff}: a `base` revision (branch/tag/sha) takes
 *  precedence over `staged` — the server ignores `staged` when `base` is set. */
export interface FetchDiffOpts {
  staged?: boolean
  base?: string
}

/**
 * Fetch a diff from the server for the given repo path. When `opts.base` is set
 * the URL carries `&base=<rev>` (working-tree/staged are omitted); otherwise it
 * carries `&staged=<bool>`. Returns null on any error or invalid response.
 */
export async function fetchDiff(repoPath: string, opts: FetchDiffOpts = {}): Promise<DiffResult | null> {
  try {
    let url = `/projects/diff?path=${encodeURIComponent(repoPath)}`
    if (opts.base !== undefined && opts.base !== '') {
      url += `&base=${encodeURIComponent(opts.base)}`
    } else {
      url += `&staged=${opts.staged === true}`
    }
    const res = await fetch(url)
    if (!res.ok) return null
    const data: unknown = await res.json()
    return normalizeDiffResult(data)
  } catch {
    return null
  }
}

/* ── W4 git write: stage / commit / push helpers ─────────────────────────────── */

/** Server response shape for the three W4 git-write routes (mirrors GitOpResult).
 *  Only `ok` is guaranteed; the rest are route-specific success/failure fields. */
export interface GitOpResponse {
  ok: boolean
  status?: number
  error?: string
  staged?: boolean
  count?: number
  commit?: string
  branch?: string
  remote?: string
}

/** Narrow an untrusted JSON body to a GitOpResponse (mirrors isWorktreeResult). */
export function isGitOpResult(v: unknown): v is GitOpResponse {
  return v !== null && typeof v === 'object' && typeof (v as Record<string, unknown>)['ok'] === 'boolean'
}

/** POST JSON to a same-origin git-write route; returns the parsed GitOpResponse
 *  or null on any transport/parse error. Same-origin, so the browser attaches the
 *  Origin header the server's CSRF guard checks — no manual header needed. */
async function postGitOp(url: string, body: unknown): Promise<GitOpResponse | null> {
  try {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body),
    })
    let data: unknown
    try {
      data = await res.json()
    } catch {
      data = null
    }
    if (isGitOpResult(data)) return data
    // Non-JSON / unexpected body (e.g. a 403/429 with no body): synthesize a
    // failure carrying the HTTP status so callers can show a message.
    return { ok: false, status: res.status }
  } catch {
    return null
  }
}

/** Stage (`stage=true`) or unstage the given repo-relative files. */
export function postStage(
  repoPath: string,
  files: string[],
  stage: boolean,
): Promise<GitOpResponse | null> {
  return postGitOp('/projects/git/stage', { path: repoPath, files, stage })
}

/** Commit the staged changes with `message`. */
export function postCommit(repoPath: string, message: string): Promise<GitOpResponse | null> {
  return postGitOp('/projects/git/commit', { path: repoPath, message })
}

/** Push the current branch to its upstream (or `-u <sole-remote> <branch>`). */
export function postPush(repoPath: string): Promise<GitOpResponse | null> {
  return postGitOp('/projects/git/push', { path: repoPath })
}

/* ── renderDiffFile ──────────────────────────────────────────────────────────── */

/** CSS class prefix for diff line kinds. */
const LINE_KIND_CLASS: Record<DiffLine['kind'], string> = {
  added: 'df-added',
  removed: 'df-removed',
  context: 'df-context',
  hunk: 'df-hunk',
  meta: 'df-meta',
}

/** Optional per-file stage/unstage control (W4). `staged` picks the button label
 *  (Unstage in the staged view, Stage otherwise); `onToggle` fires on click. */
export interface StageControl {
  staged: boolean
  onToggle: (file: DiffFile) => void
}

/**
 * Render a single DiffFile into an HTMLElement. When `stageCtl` is provided (W4,
 * working/staged views only) a Stage/Unstage button is added to the file header.
 *
 * Security: ALL text content is set via textContent — zero innerHTML.
 * <script>, ANSI sequences, & etc. are rendered as literal characters.
 */
export function renderDiffFile(file: DiffFile, stageCtl?: StageControl): HTMLElement {
  const section = el('div', 'df-file')

  // ── file header ──────────────────────────────────────────────────────────
  const header = el('div', 'df-file-header')

  const pathEl = el('span', 'df-path')
  if (file.status === 'renamed' && file.oldPath !== file.newPath) {
    pathEl.textContent = `${file.oldPath} → ${file.newPath}`
  } else {
    pathEl.textContent = file.newPath
  }

  const statsEl = el('span', 'df-stats')
  const addedEl = el('span', 'df-stat-added', `+${file.added}`)
  const removedEl = el('span', 'df-stat-removed', `-${file.removed}`)
  statsEl.append(addedEl, removedEl)

  const statusEl = el('span', `df-status df-status-${file.status}`, file.status)

  header.append(pathEl, statsEl, statusEl)

  if (stageCtl !== undefined) {
    const stageBtn = el('button', 'df-file-stage', stageCtl.staged ? 'Unstage' : 'Stage')
    stageBtn.addEventListener('click', () => stageCtl.onToggle(file))
    header.append(stageBtn)
  }

  section.append(header)

  // ── binary indicator ─────────────────────────────────────────────────────
  if (file.binary) {
    section.append(el('div', 'df-binary', 'Binary file'))
    return section
  }

  // ── hunks ─────────────────────────────────────────────────────────────────
  for (const hunk of file.hunks) {
    section.append(renderHunk(hunk))
  }

  return section
}

function renderHunk(hunk: DiffHunk): HTMLElement {
  const hunkEl = el('div', 'df-hunk-block')

  // Hunk header (e.g. "@@ -1,3 +1,4 @@")
  hunkEl.append(el('div', 'df-hunk-header', hunk.header))

  const linesEl = el('div', 'df-lines')
  for (const line of hunk.lines) {
    linesEl.append(renderLine(line))
  }
  hunkEl.append(linesEl)

  return hunkEl
}

function renderLine(line: DiffLine): HTMLElement {
  const cls = LINE_KIND_CLASS[line.kind] ?? 'df-context'
  return el('div', `df-line ${cls}`, line.text)
}

/* ── renderDiff ──────────────────────────────────────────────────────────────── */

/**
 * Render a full DiffResult: all files grouped, with empty state and truncated warning.
 * When `onToggleStage` is provided (W4, working/staged views), each file row gets a
 * Stage/Unstage button whose direction is derived from `result.staged`.
 *
 * Security: ALL content via textContent — zero innerHTML.
 */
export function renderDiff(
  result: DiffResult,
  onToggleStage?: (file: DiffFile) => void,
): HTMLElement {
  const container = el('div', 'df-result')

  // Truncated warning
  if (result.truncated) {
    container.append(
      el('div', 'df-truncated-warning', 'Result truncated — diff is too large to display fully.'),
    )
  }

  // Empty state
  if (result.files.length === 0) {
    container.append(el('div', 'df-empty', EMPTY_MESSAGE))
    return container
  }

  const stageCtl: StageControl | undefined =
    onToggleStage !== undefined ? { staged: result.staged, onToggle: onToggleStage } : undefined

  for (const file of result.files) {
    container.append(renderDiffFile(file, stageCtl))
  }

  return container
}

/* ── mountDiffViewer ─────────────────────────────────────────────────────────── */

/** Handle returned by mountDiffViewer for programmatic control. */
export interface DiffViewerHandle {
  /** Switch to the working-tree (unstaged) diff. */
  showWorking(): void
  /** Switch to the staged diff. */
  showStaged(): void
  /** Programmatically trigger the onClose callback. */
  close(): void
  /** Remove all DOM content from the container (cleanup). */
  destroy(): void
}

export interface MountDiffViewerOpts {
  /** Called when the viewer's close button or close() is invoked. */
  onClose?: () => void
  /** Base revisions (branches) offered in the "compare base" picker. When
   *  empty/omitted no picker is rendered (backward-compatible). */
  bases?: string[]
}

/**
 * Mount a diff viewer into `container`.
 *
 * Renders a toolbar (Working / Staged toggle + Close), then fetches and renders
 * the diff below it. The toggle re-fetches when switched.
 *
 * Security: SEC-H4 — all content via textContent/el(). Zero innerHTML.
 */
export function mountDiffViewer(
  container: HTMLElement,
  repoPath: string,
  opts: MountDiffViewerOpts,
): DiffViewerHandle {
  let destroyed = false
  let staged = false
  let base: string | null = null // null → working-tree/staged mode; string → base-diff

  // ── skeleton ──────────────────────────────────────────────────────────────
  const root = el('div', 'df-viewer')

  // Toolbar
  const toolbar = el('div', 'df-toolbar')

  const workingBtn = el('button', 'df-tab df-tab-active', 'Working')
  const stagedBtn = el('button', 'df-tab', 'Staged')
  const closeBtn = el('button', 'df-close', '✕ Close')

  toolbar.append(workingBtn, stagedBtn)

  // Optional "compare against base" picker: Working tree + one option per base.
  const bases = opts.bases ?? []
  let baseSelect: HTMLSelectElement | null = null
  if (bases.length > 0) {
    baseSelect = document.createElement('select')
    baseSelect.className = 'df-base-select'
    baseSelect.setAttribute('aria-label', 'Compare against base')
    const wtOpt = el('option', undefined, 'Working tree')
    wtOpt.value = ''
    baseSelect.append(wtOpt)
    for (const b of bases) {
      const opt = el('option', undefined, b) // textContent — inert (SEC-H4)
      opt.value = b
      baseSelect.append(opt)
    }
    baseSelect.addEventListener('change', () => {
      const v = baseSelect?.value ?? ''
      base = v === '' ? null : v
      updateTabState()
      void loadDiff()
    })
    toolbar.append(baseSelect)
  }

  toolbar.append(closeBtn)
  root.append(toolbar)

  // In base mode the Working/Staged tabs are inert (a base diff can't be staged)
  // and the commit/push bar is hidden (you can only commit the working index).
  function updateTabState(): void {
    const inBase = base !== null
    workingBtn.disabled = inBase
    stagedBtn.disabled = inBase
    workingBtn.classList.toggle('df-tab-disabled', inBase)
    stagedBtn.classList.toggle('df-tab-disabled', inBase)
    commitBar.style.display = inBase ? 'none' : ''
  }

  // Content area
  const content = el('div', 'df-content')
  root.append(content)

  // ── W4 commit / push bar (working/staged mode only) ─────────────────────────
  // Message textarea + Commit + Push. Hidden in base-compare mode. All rendered
  // status/error text goes through textContent (SEC-H4) — zero innerHTML.
  const commitBar = el('div', 'df-commitbar')
  const commitMsg = el('textarea', 'df-commit-msg')
  commitMsg.placeholder = 'Commit message'
  commitMsg.rows = 2
  const commitBtn = el('button', 'df-commit-btn', 'Commit')
  commitBtn.disabled = true // enabled once the message is non-empty
  const pushBtn = el('button', 'df-push-btn', 'Push')
  const opStatus = el('div', 'df-op-status')
  opStatus.style.display = 'none'
  commitBar.append(commitMsg, commitBtn, pushBtn, opStatus)
  root.append(commitBar)

  container.append(root)

  let busy = false

  /** Reflect an in-flight git-write op: disable buttons, mark the root busy. */
  function setBusy(v: boolean): void {
    busy = v
    root.classList.toggle('df-op-busy', v)
    pushBtn.disabled = v
    commitBtn.disabled = v || commitMsg.value.trim() === ''
  }

  /** Show a status line (error or notice) via textContent only (SEC-H4). */
  function showOp(msg: string, kind: 'error' | 'notice'): void {
    opStatus.textContent = msg
    opStatus.className = kind === 'error' ? 'df-op-status df-op-error' : 'df-op-status df-op-notice'
    opStatus.style.display = ''
  }
  function clearOp(): void {
    opStatus.textContent = ''
    opStatus.style.display = 'none'
  }

  /** Stage (working view) or unstage (staged view) one file, then reload. */
  async function toggleStage(file: DiffFile): Promise<void> {
    if (busy || destroyed) return
    const files =
      file.status === 'renamed' && file.oldPath !== file.newPath
        ? [file.oldPath, file.newPath]
        : [file.newPath]
    setBusy(true)
    clearOp()
    const r = await postStage(repoPath, files, !staged) // working → add; staged → unstage
    if (destroyed) return
    setBusy(false)
    if (r === null || !r.ok) {
      showOp(r?.error ?? 'Stage failed.', 'error')
      return
    }
    void loadDiff()
  }

  /** Commit the staged changes with the textarea message, then reload. */
  async function doCommit(): Promise<void> {
    if (busy || destroyed) return
    const message = commitMsg.value
    if (message.trim() === '') return
    setBusy(true)
    clearOp()
    const r = await postCommit(repoPath, message)
    if (destroyed) return
    setBusy(false)
    if (r === null || !r.ok) {
      showOp(r?.error ?? 'Commit failed.', 'error')
      return
    }
    commitMsg.value = ''
    commitBtn.disabled = true
    showOp(r.commit !== undefined && r.commit !== '' ? `Committed ${r.commit}` : 'Committed.', 'notice')
    void loadDiff()
  }

  /** Push the current branch; surface a safe success/error notice (no reload). */
  async function doPush(): Promise<void> {
    if (busy || destroyed) return
    setBusy(true)
    clearOp()
    const r = await postPush(repoPath)
    if (destroyed) return
    setBusy(false)
    if (r === null || !r.ok) {
      showOp(r?.error ?? 'Push failed.', 'error')
      return
    }
    const to = [r.branch, r.remote].filter((x): x is string => typeof x === 'string' && x !== '').join(' → ')
    showOp(to !== '' ? `Pushed ${to}` : 'Pushed.', 'notice')
  }

  commitMsg.addEventListener('input', () => {
    if (!busy) commitBtn.disabled = commitMsg.value.trim() === ''
  })
  commitBtn.addEventListener('click', () => {
    void doCommit()
  })
  pushBtn.addEventListener('click', () => {
    void doPush()
  })

  // ── event handlers ────────────────────────────────────────────────────────
  workingBtn.addEventListener('click', () => {
    if (!staged) return
    staged = false
    workingBtn.className = 'df-tab df-tab-active'
    stagedBtn.className = 'df-tab'
    void loadDiff()
  })

  stagedBtn.addEventListener('click', () => {
    if (staged) return
    staged = true
    stagedBtn.className = 'df-tab df-tab-active'
    workingBtn.className = 'df-tab'
    void loadDiff()
  })

  closeBtn.addEventListener('click', () => {
    opts.onClose?.()
  })

  // ── fetch + render ────────────────────────────────────────────────────────
  async function loadDiff(): Promise<void> {
    if (destroyed) return

    content.textContent = 'Loading…'

    const result = await fetchDiff(repoPath, base !== null ? { base } : { staged })

    if (destroyed) return

    content.textContent = ''
    if (result === null) {
      content.append(el('div', 'df-error', 'Failed to load diff.'))
    } else {
      // Stage toggles only in working/staged mode (never for a base-compare diff).
      const onToggle = base !== null ? undefined : (file: DiffFile) => void toggleStage(file)
      content.append(renderDiff(result, onToggle))
    }
  }

  void loadDiff()

  // ── handle ────────────────────────────────────────────────────────────────
  return {
    showWorking() {
      if (staged) {
        staged = false
        workingBtn.className = 'df-tab df-tab-active'
        stagedBtn.className = 'df-tab'
        void loadDiff()
      }
    },
    showStaged() {
      if (!staged) {
        staged = true
        stagedBtn.className = 'df-tab df-tab-active'
        workingBtn.className = 'df-tab'
        void loadDiff()
      }
    },
    close() {
      opts.onClose?.()
    },
    destroy() {
      destroyed = true
      container.textContent = ''
    },
  }
}
