/**
 * src/http/git-log.ts (W3 quick-wins d) — read-only recent-commit log.
 *
 * The server stays a byte-shuttle: this is an out-of-band side-channel that runs
 * `git log` in a directory and PARSES its output into CommitLogEntry[]. Parsing
 * lives ONLY here; public/git-log.ts is render-only (mirrors diff.ts / gh.ts).
 *
 * Delimiter design (robust against nasty subjects): the format is
 *   %h %x1f %ct %x1f %s   with  -z  (records separated by NUL)
 * so a US (0x1f) field separator + a NUL (0x00) record separator can never be
 * corrupted by a subject containing tabs, spaces or newlines. We never parse the
 * fragile `--oneline` shape.
 *
 * Security (mirrors diff.ts):
 *  - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS.
 *  - `repoPath` is the cwd, never interpolated into argv; `n` is coerced to an
 *    int and clamped BEFORE reaching argv. Path→repo validation is the route's
 *    job (isValidGitDir, SEC-H7), exactly like /projects/diff.
 *  - parseGitLog NEVER throws: malformed records are skipped; getGitLog is
 *    best-effort and returns an empty result rather than rejecting.
 *  - subjects are carried verbatim and rendered as inert text (textContent) in
 *    the FE — never HTML.
 */

import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { CommitLogEntry, GitLogResult } from '../types.js'

const execFileAsync = promisify(execFile)

/** Hard cap on the number of commits a single request may return (DoS bound). */
export const GIT_LOG_MAX = 50
/** Default number of commits when the caller does not specify `n`. */
export const GIT_LOG_DEFAULT = 20
/** Cap a single commit subject so a pathological message can't bloat the payload. */
const SUBJECT_MAX_LEN = 500
/** Bound the captured stdout (DoS guard); 1 MB is ample for ≤50 subjects. */
const GIT_LOG_MAX_BUFFER = 1024 * 1024
/** Field separator (US, 0x1f) and record separator (NUL, 0x00). */
const FIELD_SEP = '\x1f'
const RECORD_SEP = '\x00'

/**
 * Clamp a possibly-junk `n` to an integer in [1, GIT_LOG_MAX]. Non-numeric /
 * missing → GIT_LOG_DEFAULT. Never throws.
 */
export function clampLogCount(n: unknown): number {
  const parsed = typeof n === 'number' ? n : Number.parseInt(String(n ?? ''), 10)
  if (!Number.isFinite(parsed)) return GIT_LOG_DEFAULT
  const floored = Math.floor(parsed)
  if (floored < 1) return 1
  if (floored > GIT_LOG_MAX) return GIT_LOG_MAX
  return floored
}

/**
 * Parse `git log -z --format=%h%x1f%ct%x1f%s` output into CommitLogEntry[].
 * Records are NUL-separated; fields are US-separated. A record missing a field
 * or with a non-numeric timestamp is skipped (never throws). `max` caps the
 * returned entries and drives the `truncated` flag (records === max ⇒ there may
 * be more). Empty stdout → an empty, non-truncated result.
 */
export function parseGitLog(stdout: string, max: number): GitLogResult {
  if (typeof stdout !== 'string' || stdout.length === 0) {
    return { commits: [], truncated: false }
  }
  const records = stdout.split(RECORD_SEP).filter((r) => r.length > 0)
  const commits: CommitLogEntry[] = []
  for (const record of records) {
    const parts = record.split(FIELD_SEP)
    if (parts.length < 3) continue // malformed — missing a field
    const hash = (parts[0] ?? '').trim()
    const secs = Number.parseInt(parts[1] ?? '', 10)
    // Subject may (in theory) contain a US char; re-join the tail so it is intact.
    const subject = parts.slice(2).join(FIELD_SEP)
    if (hash === '' || !Number.isFinite(secs) || secs < 0) continue
    commits.push({
      hash,
      at: secs * 1000,
      subject: subject.length > SUBJECT_MAX_LEN ? subject.slice(0, SUBJECT_MAX_LEN) : subject,
    })
  }
  const truncated = commits.length >= max && max > 0
  return { commits: max > 0 ? commits.slice(0, max) : commits, truncated }
}

export interface GetGitLogOptions {
  n?: number
  timeoutMs: number
}

/**
 * Read a repo's recent commits (newest first) as a structured GitLogResult.
 * `repoPath` must already be a validated absolute git directory (route layer,
 * SEC-H7). Best-effort: any git failure yields an empty result, never throws.
 */
export async function getGitLog(repoPath: string, opts: GetGitLogOptions): Promise<GitLogResult> {
  const n = clampLogCount(opts.n)
  try {
    const { stdout } = await execFileAsync(
      'git',
      ['log', '--no-color', '-z', '-n', String(n), '--format=%h%x1f%ct%x1f%s'],
      { cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
    )
    const parsed = parseGitLog(stdout, n)
    return await markUnpushed(repoPath, parsed, opts.timeoutMs)
  } catch {
    return { commits: [], truncated: false }
  }
}

/** Cap on how many unpushed commits we enumerate — a runaway branch must not
 *  turn one log read into an unbounded buffer. */
const UNPUSHED_MAX = 200

/**
 * w6/G4 — tag the commits that exist only locally, and name the upstream so the
 * client can draw the boundary between pushed and unpushed history.
 *
 * Marking is done HERE, from the authoritative `rev-list` set, and never inferred
 * on the client from row order: `git log` is date-ordered, so merging an older
 * branch interleaves unpushed commits below pushed ones and "the first N rows are
 * unpushed" silently mislabels them as pushed — the same class of confident lie
 * the sync band exists to prevent.
 *
 * No upstream / detached / empty repo → the input is returned untouched (no
 * `upstream`, no marks), so the client draws no boundary. Never throws.
 */
async function markUnpushed(
  repoPath: string,
  result: GitLogResult,
  timeoutMs: number,
): Promise<GitLogResult> {
  if (result.commits.length === 0) return result

  let upstream: string
  try {
    const { stdout } = await execFileAsync(
      'git',
      ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
      { cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
    )
    upstream = stdout.trim()
    if (upstream === '') return result
  } catch {
    return result // no upstream configured / detached — nothing to compare against
  }

  let fullHashes: string[]
  try {
    const { stdout } = await execFileAsync(
      'git',
      ['rev-list', `--max-count=${UNPUSHED_MAX}`, '@{u}..HEAD'],
      { cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
    )
    fullHashes = stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '')
  } catch {
    return result
  }

  // `hash` is abbreviated (%h) while rev-list yields full SHAs, and the two can
  // disagree on width, so match by prefix rather than equality. Bounded by
  // GIT_LOG_MAX × UNPUSHED_MAX string compares — trivial, and always correct.
  const commits = result.commits.map((c) =>
    fullHashes.some((full) => full.startsWith(c.hash)) ? { ...c, unpushed: true } : c,
  )
  return { ...result, commits, upstream }
}
