/**
 * src/http/diff.ts (N-diff-be, B1) — read-only structured git diff.
 *
 * The server stays a byte-shuttle: this is an out-of-band side-channel that runs
 * `git diff` in a directory and PARSES its text into DiffFile/DiffLine. Parsing
 * lives ONLY here (public/diff.ts is render-only — review #2). The frontend never
 * re-derives diff structure; it only renders these objects with textContent.
 *
 * Security (SP4, §B1.4):
 *  - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS (SEC-M9).
 *  - the trailing `--` terminates options so a path can't be read as a flag.
 *    (path → repo three-way validation lives in the ROUTE layer, SEC-H7.)
 *  - parsers NEVER throw: garbage lines degrade to `context`; getDiff is
 *    best-effort and returns an empty result rather than rejecting (house style).
 *  - diff content is carried verbatim in DiffLine.text — the FE renders it as
 *    inert text (AC-B1.4), never HTML.
 *
 * FR-B1.9 (`?base=<rev>`) — diff a whole branch against a base commit-ish. The
 * mitigation is a two-stage revision allow-list applied BEFORE any revision
 * reaches the diff CLI: (1) `isPlausibleRev` — a pure syntactic boundary check
 * (rejects flag-injection / `..` ranges / metachars); (2) `git rev-parse --verify`
 * — git itself is the authoritative allow-list, and only its canonical sha output
 * is passed to `git diff <sha>... --`, fully decoupling the raw user string from
 * the diff invocation.
 */

import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type {
  Config,
  DiffFile,
  DiffHunk,
  DiffLine,
  DiffLineKind,
  DiffResult,
  FileStatus,
} from '../types.js'

const execFileAsync = promisify(execFile)

// ── base-revision allow-list (pure) ─────────────────────────────────────────

/** First-stage syntactic gate for a user-supplied `?base=<rev>` (FR-B1.9). A
 *  plausible commit-ish starts with an alphanumeric and uses only the safe git
 *  ref charset; this rejects flag injection (leading `-`), `..` ranges,
 *  whitespace and shell metacharacters BEFORE any git call. It is NOT a full
 *  ref validator — `git rev-parse --verify` (resolveBaseRev) is the
 *  authoritative allow-list; this only fails obvious junk fast. Never throws. */
export function isPlausibleRev(base: string): boolean {
  if (typeof base !== 'string') return false
  if (base.includes('..')) return false // block A..B / A...B ranges
  return /^[A-Za-z0-9][A-Za-z0-9._/@^~{}-]{0,249}$/.test(base)
}

// ── numstat (pure) ──────────────────────────────────────────────────────────

/** One `git diff --numstat` row: `<added>\t<removed>\t<path>`; binary = `-\t-`. */
export interface NumstatEntry {
  added: number
  removed: number
  binary: boolean
}

/** Non-negative integer or 0 for `-`/junk (never NaN). */
function toCount(field: string): number {
  const n = Number.parseInt(field, 10)
  return Number.isFinite(n) && n >= 0 ? n : 0
}

/**
 * Expand a numstat path field into its old/new forms. A rename is shown either
 * as `old => new` or, with a shared prefix/suffix, as `pre/{old => new}/suf`.
 * Non-renames return the same path for both.
 */
function expandNumstatPath(raw: string): { oldPath: string; newPath: string } {
  const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(raw)
  if (braced !== null) {
    const [, pre = '', oldMid = '', newMid = '', suf = ''] = braced
    const collapse = (s: string): string => (pre + s + suf).replace(/\/{2,}/g, '/')
    return { oldPath: collapse(oldMid), newPath: collapse(newMid) }
  }
  const arrow = raw.split(' => ')
  if (arrow.length === 2) {
    return { oldPath: arrow[0]?.trim() ?? raw, newPath: arrow[1]?.trim() ?? raw }
  }
  return { oldPath: raw, newPath: raw }
}

/**
 * Parse `git diff --numstat` output into a path → counts map. Renames are keyed
 * under BOTH old and new paths so the unified-diff parser can find them by
 * whichever path it derived. Malformed lines are skipped; never throws.
 */
export function parseNumstat(out: string): Map<string, NumstatEntry> {
  const map = new Map<string, NumstatEntry>()
  if (typeof out !== 'string') return map
  for (const line of out.split('\n')) {
    if (line.trim() === '') continue
    const parts = line.split('\t')
    if (parts.length < 3) continue
    const addStr = parts[0] ?? ''
    const remStr = parts[1] ?? ''
    const rawPath = parts.slice(2).join('\t')
    const binary = addStr === '-' && remStr === '-'
    const entry: NumstatEntry = {
      added: binary ? 0 : toCount(addStr),
      removed: binary ? 0 : toCount(remStr),
      binary,
    }
    const { oldPath, newPath } = expandNumstatPath(rawPath)
    map.set(newPath, entry)
    if (oldPath !== newPath) map.set(oldPath, entry)
  }
  return map
}

// ── unified diff (pure) ──────────────────────────────────────────────────────

/** Strip git's `a/`/`b/` prefix and optional C-quoting; `/dev/null` is kept. */
function stripDiffPath(raw: string): string {
  let s = raw.trim()
  if (s === '/dev/null') return s
  if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
    s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
  }
  if (s.startsWith('a/') || s.startsWith('b/')) s = s.slice(2)
  return s
}

/** Best-effort path extraction from a `diff --git a/x b/y` header line. The
 *  authoritative paths come from ---/+++/rename lines, which override this. */
function parseDiffGitLine(line: string): { oldPath: string; newPath: string } {
  const rest = line.slice('diff --git '.length)
  const sep = rest.indexOf(' b/')
  if (sep !== -1) {
    return { oldPath: stripDiffPath(rest.slice(0, sep)), newPath: stripDiffPath(rest.slice(sep + 1)) }
  }
  const p = stripDiffPath(rest)
  return { oldPath: p, newPath: p }
}

/** Classify one in-hunk line by its leading marker; unknown → context (spec). */
function classifyHunkLine(line: string): DiffLine {
  const marker = line.charAt(0)
  if (marker === '+') return { kind: 'added', text: line.slice(1) }
  if (marker === '-') return { kind: 'removed', text: line.slice(1) }
  if (marker === ' ') return { kind: 'context', text: line.slice(1) }
  if (marker === '\\') return { kind: 'meta', text: line.slice(1).trim() }
  return { kind: 'context', text: line }
}

interface BlockState {
  oldPath: string
  newPath: string
  binary: boolean
  isNew: boolean
  isDeleted: boolean
  isRename: boolean
}

/** Apply one pre-hunk header line to the accumulating block state. */
function applyHeaderLine(st: BlockState, line: string): void {
  if (line.startsWith('diff --git ')) {
    const p = parseDiffGitLine(line)
    st.oldPath = p.oldPath
    st.newPath = p.newPath
  } else if (line.startsWith('new file')) st.isNew = true
  else if (line.startsWith('deleted file')) st.isDeleted = true
  else if (line.startsWith('rename from ')) {
    st.oldPath = stripDiffPath(line.slice('rename from '.length))
    st.isRename = true
  } else if (line.startsWith('rename to ')) {
    st.newPath = stripDiffPath(line.slice('rename to '.length))
    st.isRename = true
  } else if (line.startsWith('copy from ')) st.oldPath = stripDiffPath(line.slice('copy from '.length))
  else if (line.startsWith('copy to ')) st.newPath = stripDiffPath(line.slice('copy to '.length))
  else if (line.startsWith('--- ')) applyOldPath(st, line.slice(4))
  else if (line.startsWith('+++ ')) applyNewPath(st, line.slice(4))
  else if (line.startsWith('Binary files')) st.binary = true
}

function applyOldPath(st: BlockState, raw: string): void {
  if (raw.trim() === '/dev/null') st.isNew = true
  else st.oldPath = stripDiffPath(raw)
}

function applyNewPath(st: BlockState, raw: string): void {
  if (raw.trim() === '/dev/null') st.isDeleted = true
  else st.newPath = stripDiffPath(raw)
}

function deriveStatus(st: BlockState, binary: boolean): FileStatus {
  if (st.isRename) return 'renamed'
  if (st.isNew) return 'added'
  if (st.isDeleted) return 'deleted'
  if (binary) return 'binary'
  return 'modified'
}

function countKind(hunks: readonly DiffHunk[], kind: DiffLineKind): number {
  let n = 0
  for (const h of hunks) for (const l of h.lines) if (l.kind === kind) n += 1
  return n
}

function finalizeFile(
  st: BlockState,
  hunks: DiffHunk[],
  numstat?: Map<string, NumstatEntry>,
): DiffFile {
  const stat = numstat?.get(st.newPath) ?? numstat?.get(st.oldPath)
  const binary = st.binary || stat?.binary === true
  const added = stat?.added ?? countKind(hunks, 'added')
  const removed = stat?.removed ?? countKind(hunks, 'removed')
  return {
    oldPath: st.oldPath,
    newPath: st.newPath,
    status: deriveStatus(st, binary),
    added,
    removed,
    binary,
    hunks,
  }
}

/** Parse one `diff --git` block (header lines + hunks) into a DiffFile. */
function parseFileBlock(block: readonly string[], numstat?: Map<string, NumstatEntry>): DiffFile | null {
  const st: BlockState = {
    oldPath: '',
    newPath: '',
    binary: false,
    isNew: false,
    isDeleted: false,
    isRename: false,
  }
  const hunks: DiffHunk[] = []
  let current: DiffHunk | null = null
  for (const line of block) {
    if (line.startsWith('@@')) {
      current = { header: line, lines: [] }
      hunks.push(current)
    } else if (current === null) {
      applyHeaderLine(st, line)
    } else {
      current.lines.push(classifyHunkLine(line))
    }
  }
  if (st.oldPath === '' && st.newPath === '') return null
  return finalizeFile(st, hunks, numstat)
}

/**
 * Parse a full unified `git diff` patch into DiffFile[]. `numstat` (optional)
 * supplies authoritative +/- counts and binary flags; without it counts are
 * derived from the hunk bodies. Empty / non-diff input → []; never throws.
 */
export function parseUnifiedDiff(patch: string, numstat?: Map<string, NumstatEntry>): DiffFile[] {
  if (typeof patch !== 'string' || patch.length === 0) return []
  const lines = patch.replace(/\n$/, '').split('\n')
  const files: DiffFile[] = []
  let i = 0
  while (i < lines.length) {
    if (lines[i]?.startsWith('diff --git ') !== true) {
      i += 1
      continue
    }
    const start = i
    i += 1
    while (i < lines.length && lines[i]?.startsWith('diff --git ') !== true) i += 1
    const file = parseFileBlock(lines.slice(start, i), numstat)
    if (file !== null) files.push(file)
  }
  return files
}

// ── getDiff (git runner) ─────────────────────────────────────────────────────

/** Just the diff limits getDiff needs; the full Config satisfies this Pick. */
export interface GetDiffOptions {
  staged: boolean
  /** When set, diff the current HEAD against this base commit-ish (three-dot).
   *  Wins over `staged`; untracked files are not listed. Guarded by rev-parse. */
  base?: string
  cfg: Pick<Config, 'diffTimeoutMs' | 'diffMaxBytes' | 'diffMaxFiles'>
}

interface ExecErrorShape {
  code?: string
  stdout?: string
}

function asExecError(err: unknown): ExecErrorShape {
  if (err === null || typeof err !== 'object') return {}
  const e = err as { code?: unknown; stdout?: unknown }
  return {
    code: typeof e.code === 'string' ? e.code : undefined,
    stdout: typeof e.stdout === 'string' ? e.stdout : undefined,
  }
}

interface GitOutput {
  out: string
  truncated: boolean
}

/**
 * Run a read-only git command, capturing stdout. Bounded by timeout + maxBuffer
 * (DoS guard). On a maxBuffer overflow the partial stdout is returned with
 * `truncated:true`; any other failure yields empty output (best-effort).
 */
async function runGit(
  cwd: string,
  args: readonly string[],
  timeoutMs: number,
  maxBytes: number,
): Promise<GitOutput> {
  try {
    const { stdout } = await execFileAsync('git', args, {
      cwd,
      timeout: timeoutMs,
      maxBuffer: maxBytes,
    })
    return { out: stdout, truncated: stdout.length >= maxBytes }
  } catch (err: unknown) {
    const { code, stdout } = asExecError(err)
    if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
      return { out: stdout ?? '', truncated: true }
    }
    return { out: '', truncated: false }
  }
}

/** Minimal unquote of a C-quoted porcelain path (`"a\"b"` → `a"b`). */
function unquotePorcelain(raw: string): string {
  const s = raw.trim()
  if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
    return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
  }
  return s
}

/** Untracked files (`git status --porcelain` `??` rows) as `untracked` DiffFiles.
 *  Content is not diffed (we avoid the `--no-index` two-operand trap, L3). */
async function listUntracked(cwd: string, timeoutMs: number, maxBytes: number): Promise<DiffFile[]> {
  const { out } = await runGit(cwd, ['status', '--porcelain', '--'], timeoutMs, maxBytes)
  const files: DiffFile[] = []
  for (const line of out.split('\n')) {
    if (!line.startsWith('?? ')) continue
    const p = unquotePorcelain(line.slice(3))
    if (p === '') continue
    files.push({
      oldPath: p,
      newPath: p,
      status: 'untracked',
      added: 0,
      removed: 0,
      binary: false,
      hunks: [],
    })
  }
  return files
}

/** Cap the file list at diffMaxFiles, propagating a truncation flag (DoS bound). */
function boundFiles(
  files: readonly DiffFile[],
  diffMaxFiles: number,
  truncated: boolean,
): { files: DiffFile[]; truncated: boolean } {
  if (files.length > diffMaxFiles) return { files: files.slice(0, diffMaxFiles), truncated: true }
  return { files: [...files], truncated }
}

/**
 * Resolve a user-supplied base revision to a canonical sha, or null (FR-B1.9).
 * Two-stage allow-list: `isPlausibleRev` (defense-in-depth — the route also
 * guards) then `git rev-parse --verify --quiet --end-of-options <base>^{commit}`.
 * Only a `[0-9a-f]{7,64}` sha is accepted; anything else (unknown ref, non-commit
 * peel, junk) → null. Never throws. The raw `base` is never interpolated: it is
 * a single argv element after `--end-of-options`, and only the sha reaches diff.
 */
async function resolveBaseRev(
  cwd: string,
  base: string,
  timeoutMs: number,
  maxBytes: number,
): Promise<string | null> {
  if (!isPlausibleRev(base)) return null
  const { out } = await runGit(
    cwd,
    ['rev-parse', '--verify', '--quiet', '--end-of-options', `${base}^{commit}`],
    timeoutMs,
    maxBytes,
  )
  const sha = out.trim()
  return /^[0-9a-f]{7,64}$/.test(sha) ? sha : null
}

/**
 * Diff the current HEAD against a base commit-ish, three-dot (`<base>...HEAD` —
 * the changes introduced on this branch since it diverged from base, matching a
 * PR view). `base` is echoed on the result; untracked files are NOT listed.
 * Best-effort: an unresolvable base → empty result rather than throwing.
 */
async function getBaseDiff(
  repoPath: string,
  base: string,
  timeout: number,
  maxBytes: number,
  diffMaxFiles: number,
): Promise<DiffResult> {
  const resolved = await resolveBaseRev(repoPath, base, timeout, maxBytes)
  if (resolved === null) return { files: [], staged: false, truncated: false, base }

  const range = `${resolved}...` // <sha>...HEAD; trailing `--` terminates options
  const patch = await runGit(repoPath, ['diff', '--no-color', range, '--'], timeout, maxBytes)
  const num = await runGit(repoPath, ['diff', '--numstat', range, '--'], timeout, maxBytes)

  const files = parseUnifiedDiff(patch.out, parseNumstat(num.out))
  const { files: bounded, truncated } = boundFiles(
    files,
    diffMaxFiles,
    patch.truncated || num.truncated,
  )
  return { files: bounded, staged: false, truncated, base }
}

/**
 * Read a repo's diff (working tree, `--staged`, or against a `base` revision) as
 * a structured DiffResult. `repoPath` must already be a validated absolute git
 * directory (route layer, SEC-H7). `opts.base` (when set) wins over `staged`.
 * Best-effort: git failures yield an empty result rather than throwing.
 */
export async function getDiff(repoPath: string, opts: GetDiffOptions): Promise<DiffResult> {
  const { staged, base, cfg } = opts
  const { diffTimeoutMs: timeout, diffMaxBytes: maxBytes, diffMaxFiles } = cfg

  if (base !== undefined) {
    return getBaseDiff(repoPath, base, timeout, maxBytes, diffMaxFiles)
  }

  const stagedArg = staged ? ['--staged'] : []
  const patch = await runGit(repoPath, ['diff', '--no-color', ...stagedArg, '--'], timeout, maxBytes)
  const num = await runGit(repoPath, ['diff', '--numstat', ...stagedArg, '--'], timeout, maxBytes)

  const files = parseUnifiedDiff(patch.out, parseNumstat(num.out))
  if (!staged) {
    files.push(...(await listUntracked(repoPath, timeout, maxBytes)))
  }

  const { files: bounded, truncated } = boundFiles(
    files,
    diffMaxFiles,
    patch.truncated || num.truncated,
  )
  return { files: bounded, staged, truncated }
}
