/**
 * src/http/git-ops.ts (w4-commit-push) — the git WRITE engine (stage/commit/push).
 *
 * The highest-risk side-channel in the app: it stages files, commits, and pushes
 * the current branch. It mirrors the read-only diff.ts / worktrees.ts execFile
 * pattern exactly and adds three non-negotiable safety spines:
 *
 *  1. NO SHELL, EVER — execFile('git', argv, …) only. Untrusted strings (the
 *     commit message, every file path) are argv ELEMENTS, never interpolated. A
 *     trailing `--` terminates options before any pathspec, and file paths that
 *     start with `-` are rejected, so a path can never be read as a flag.
 *  2. REALPATH CONTAINMENT — every file in `files[]` is realpath-resolved and
 *     proven inside the repo (git-path.ts, the M2 pattern). Defeats `../` and
 *     pre-planted symlink escapes even though the route's isValidGitDir is lighter.
 *  3. SERVER-DERIVED push target — push NEVER accepts a remote or refspec from the
 *     client; both branch and remote are read back from the repo, and it is never
 *     a force-push (no `--force`, no `+refspec`).
 *
 * Every export never throws — failures return a structured GitOpResult with a
 * SAFE message (never raw git stderr, SEC-M10), classified by classifyGitError.
 */

import fs from 'node:fs/promises'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { GitOpResult } from '../types.js'
import { resolveRealPath, isContained } from './git-path.js'

const execFileAsync = promisify(execFile)

const GIT_MAX_BUFFER = 1024 * 1024
// GIT_TERMINAL_PROMPT=0 + ssh BatchMode make a credential/host-key prompt fail
// FAST (→ classified 401) instead of hanging until the exec timeout backstop.
const NO_PROMPT_ENV: NodeJS.ProcessEnv = {
  ...process.env,
  GIT_TERMINAL_PROMPT: '0',
  GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
}

export interface GitOpOptions {
  readonly timeoutMs: number
  /** Max number of files a single stage call may touch (reuses cfg.diffMaxFiles). */
  readonly maxFiles?: number
}

export interface CommitOptions {
  readonly timeoutMs: number
  /** Commit-message length cap (cfg.commitMsgMaxLen). */
  readonly maxLen: number
}

export interface PushOptions {
  readonly timeoutMs: number
}

// ── error classification (pure, SEC-M10) ──────────────────────────────────────

/** Pull a best-effort diagnostic string off a child_process error (never throws).
 *  Combines stderr AND stdout because git writes some conditions ("nothing to
 *  commit") to stdout, others ("! [rejected]", auth) to stderr; message is the
 *  fallback. Only substrings are matched — the raw text is never surfaced. */
function extractStderr(err: unknown): string {
  if (typeof err === 'object' && err !== null) {
    const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }
    const parts: string[] = []
    if (typeof e.stderr === 'string') parts.push(e.stderr)
    if (typeof e.stdout === 'string') parts.push(e.stdout)
    if (parts.join('').trim() !== '') return parts.join('\n')
    if (typeof e.message === 'string') return e.message
  }
  return ''
}

/**
 * Map a git stderr string to a structured {status, error}. The returned `error`
 * is ALWAYS a hard-coded safe phrase — raw git stderr (which can contain repo
 * paths / remote URLs) is never surfaced (SEC-M10). Ordered most-specific first.
 * Pure + unit-tested. Unknown → 500.
 */
export function classifyGitError(stderr: string): { status: number; error: string } {
  const s = (typeof stderr === 'string' ? stderr : '').toLowerCase()

  if (s.includes('index.lock') || (s.includes('unable to create') && s.includes('.lock'))) {
    return { status: 409, error: 'Another git operation is in progress.' }
  }
  if (
    s.includes('nothing to commit') ||
    s.includes('nothing added to commit') ||
    s.includes('no changes added to commit')
  ) {
    return { status: 409, error: 'Nothing staged to commit.' }
  }
  if (s.includes('please tell me who you are')) {
    return { status: 400, error: 'Set a git author identity (user.name / user.email) first.' }
  }
  if (s.includes('rejected') || s.includes('non-fast-forward') || s.includes('fetch first')) {
    return { status: 409, error: 'Push rejected — pull or rebase first.' }
  }
  if (
    s.includes('authentication failed') ||
    s.includes('could not read from remote') ||
    s.includes('could not read username') ||
    s.includes('terminal prompts disabled') ||
    s.includes('permission denied (publickey)') ||
    s.includes('no anonymous write access')
  ) {
    return { status: 401, error: 'Push authentication required on the host.' }
  }
  if (s.includes('not a git repository')) {
    return { status: 404, error: 'Not a git repository.' }
  }
  return { status: 500, error: 'Git operation failed.' }
}

/** Wrap a caught child_process error as a failed GitOpResult (never throws). */
function failFromError(err: unknown): GitOpResult {
  const { status, error } = classifyGitError(extractStderr(err))
  return { ok: false, status, error }
}

// ── repo + file validation ─────────────────────────────────────────────────────

/** Three-prong entry check: absolute + isDirectory + has a `.git` entry. */
async function isGitDir(repoPath: string): Promise<boolean> {
  if (typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) return false
  try {
    const st = await fs.stat(repoPath)
    if (!st.isDirectory()) return false
    await fs.stat(path.join(repoPath, '.git'))
    return true
  } catch {
    return false
  }
}

/**
 * Validate an untrusted `files[]` for a stage call. Returns the ACCEPTED relative
 * paths (verbatim, to hand to `git <verb> -- <paths>`), or null on ANY rejection.
 * `repoRealPath` MUST already be the realpath of the repo. Rejections:
 *   - empty list / more than `maxFiles` entries (DoS bound)
 *   - non-string / empty entry
 *   - absolute path (a stage path is always repo-relative)
 *   - leading '-' (flag injection — even after `--`, belt-and-braces)
 *   - realpath escapes the repo (../ traversal or a symlink pointing outside, M2)
 * A deleted file's path (absent on disk) is fine: resolveRealPath resolves the
 * existing prefix and re-appends the missing tail, still contained. Never throws.
 */
export async function validateRepoFiles(
  repoRealPath: string,
  files: readonly unknown[],
  maxFiles: number,
): Promise<string[] | null> {
  if (!Array.isArray(files) || files.length === 0) return null
  if (files.length > maxFiles) return null

  const accepted: string[] = []
  for (const raw of files) {
    if (typeof raw !== 'string' || raw.length === 0) return null
    if (path.isAbsolute(raw)) return null
    if (raw.startsWith('-')) return null
    const realCandidate = await resolveRealPath(path.join(repoRealPath, raw))
    // Must be strictly INSIDE the repo — never the repo root itself (e.g. '.').
    if (realCandidate === repoRealPath || !isContained(repoRealPath, realCandidate)) {
      return null
    }
    accepted.push(raw)
  }
  return accepted
}

// ── stage / unstage ─────────────────────────────────────────────────────────────

/**
 * Stage (`git add`) or unstage (`git restore --staged`) the given repo-relative
 * files. `stage=true` → add; `false` → restore --staged. Validates the repo and
 * realpath-contains every file before running git (no shell, `--` terminates
 * options). Returns {ok, staged, count} on success. Never throws.
 */
export async function stageFiles(
  repoPath: string,
  files: readonly unknown[],
  stage: boolean,
  opts: GitOpOptions,
): Promise<GitOpResult> {
  if (!(await isGitDir(repoPath))) {
    return { ok: false, status: 404, error: 'Not a git repository.' }
  }
  const repoReal = await resolveRealPath(repoPath)
  const maxFiles = opts.maxFiles ?? 300
  const valid = await validateRepoFiles(repoReal, files, maxFiles)
  if (valid === null) {
    return { ok: false, status: 400, error: 'Invalid file selection.' }
  }

  const args = stage ? ['add', '--', ...valid] : ['restore', '--staged', '--', ...valid]
  try {
    await execFileAsync('git', args, {
      cwd: repoPath,
      timeout: opts.timeoutMs,
      maxBuffer: GIT_MAX_BUFFER,
    })
    return { ok: true, staged: stage, count: valid.length }
  } catch (err: unknown) {
    return failFromError(err)
  }
}

// ── commit ──────────────────────────────────────────────────────────────────────

/**
 * Commit the STAGED changes with `message`. The message is validated (non-empty
 * after trim, ≤ maxLen) and passed as the single value of `-m` via argv — never
 * a shell string, never a pathspec — so newlines / leading '-' / emoji are all
 * safe. Returns {ok, commit:<short sha>}. Empty index → 409, identity unset →
 * 400 (classified). Never throws.
 */
export async function commit(
  repoPath: string,
  message: string,
  opts: CommitOptions,
): Promise<GitOpResult> {
  if (!(await isGitDir(repoPath))) {
    return { ok: false, status: 404, error: 'Not a git repository.' }
  }
  if (typeof message !== 'string' || message.trim() === '') {
    return { ok: false, status: 400, error: 'Commit message is required.' }
  }
  if (message.length > opts.maxLen) {
    return { ok: false, status: 400, error: 'Commit message is too long.' }
  }

  try {
    await execFileAsync('git', ['commit', '-m', message], {
      cwd: repoPath,
      timeout: opts.timeoutMs,
      maxBuffer: GIT_MAX_BUFFER,
    })
  } catch (err: unknown) {
    return failFromError(err)
  }

  // Read back the new commit's short SHA (best-effort; commit already succeeded).
  try {
    const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], {
      cwd: repoPath,
      timeout: opts.timeoutMs,
      maxBuffer: GIT_MAX_BUFFER,
    })
    return { ok: true, commit: stdout.trim() }
  } catch {
    return { ok: true, commit: '' }
  }
}

// ── push ──────────────────────────────────────────────────────────────────────

/** Read the current branch, or null if git failed / detached (output 'HEAD'). */
async function currentBranch(repoPath: string, timeoutMs: number): Promise<string | null> {
  try {
    const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
      cwd: repoPath,
      timeout: timeoutMs,
      maxBuffer: GIT_MAX_BUFFER,
    })
    const branch = stdout.trim()
    return branch === '' || branch === 'HEAD' ? null : branch
  } catch {
    return null
  }
}

/** The upstream remote name (e.g. 'origin') for the current branch, or null. */
async function upstreamRemote(repoPath: string, timeoutMs: number): Promise<string | null> {
  try {
    const { stdout } = await execFileAsync(
      'git',
      ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
      { cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER },
    )
    const ref = stdout.trim() // e.g. "origin/main"
    const slash = ref.indexOf('/')
    return slash > 0 ? ref.slice(0, slash) : null
  } catch {
    return null // no upstream configured
  }
}

/** List configured remotes (one per line), or [] on error. */
async function listRemotes(repoPath: string, timeoutMs: number): Promise<string[]> {
  try {
    const { stdout } = await execFileAsync('git', ['remote'], {
      cwd: repoPath,
      timeout: timeoutMs,
      maxBuffer: GIT_MAX_BUFFER,
    })
    return stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '')
  } catch {
    return []
  }
}

/**
 * Push the CURRENT branch. If it already has an upstream → plain `git push`
 * (git pushes the current branch to its configured upstream ref; never other
 * branches, never a force). If it has NO upstream: exactly one remote →
 * `git push -u <remote> <branch>` (sets it); zero remotes → 400; ≥2 remotes →
 * 409 (ambiguous, set an upstream first). The remote and branch are ALWAYS
 * derived server-side from the repo — never taken from the client — and it is
 * NEVER a force-push. Returns {ok, branch, remote}. Never throws.
 */
export async function push(repoPath: string, opts: PushOptions): Promise<GitOpResult> {
  if (!(await isGitDir(repoPath))) {
    return { ok: false, status: 404, error: 'Not a git repository.' }
  }
  const branch = await currentBranch(repoPath, opts.timeoutMs)
  if (branch === null) {
    return { ok: false, status: 400, error: 'Cannot push a detached HEAD.' }
  }
  // Defence in depth: a real checked-out branch never starts with '-', but a
  // branch/remote that did could be read as a flag in the -u form below.
  if (branch.startsWith('-')) {
    return { ok: false, status: 400, error: 'Cannot push this branch.' }
  }

  const remote = await upstreamRemote(repoPath, opts.timeoutMs)
  let args: string[]
  let targetRemote: string

  if (remote !== null) {
    // Upstream already set → plain push (safe; no remote/refspec from client).
    args = ['push']
    targetRemote = remote
  } else {
    const remotes = await listRemotes(repoPath, opts.timeoutMs)
    if (remotes.length === 0) {
      return { ok: false, status: 400, error: 'No remote configured.' }
    }
    if (remotes.length > 1) {
      return { ok: false, status: 409, error: 'Set an upstream first (multiple remotes).' }
    }
    const sole = remotes[0] as string
    if (sole.startsWith('-')) {
      return { ok: false, status: 400, error: 'Cannot push to this remote.' }
    }
    args = ['push', '-u', sole, branch]
    targetRemote = sole
  }

  try {
    await execFileAsync('git', args, {
      cwd: repoPath,
      timeout: opts.timeoutMs,
      maxBuffer: GIT_MAX_BUFFER,
      env: NO_PROMPT_ENV,
    })
    return { ok: true, branch, remote: targetRemote }
  } catch (err: unknown) {
    return failFromError(err)
  }
}

// ── fetch (w6/G2) ─────────────────────────────────────────────────────────────

/**
 * Refresh the remote-tracking refs so `behind` stops lying. This is the ONLY
 * network read the panel performs, and it is deliberately the weakest possible
 * one: `git fetch` updates `refs/remotes/*` and nothing else — no working tree,
 * no index, no branch, no merge. It is NOT a pull.
 *
 * Same discipline as push (SEC): the remote is ALWAYS derived server-side — the
 * current branch's upstream, else the sole remote — and no remote or refspec is
 * ever read from the caller, so a client cannot point it at an arbitrary URL.
 * Zero remotes → 400; ≥2 remotes with no upstream → 409 (ambiguous).
 *
 * Returns {ok, remote, lastFetchMs}. Never throws.
 */
export async function fetch(repoPath: string, opts: PushOptions): Promise<GitOpResult> {
  if (!(await isGitDir(repoPath))) {
    return { ok: false, status: 404, error: 'Not a git repository.' }
  }

  const upstream = await upstreamRemote(repoPath, opts.timeoutMs)
  let args: string[]
  let targetRemote: string

  if (upstream !== null) {
    // Upstream set → bare fetch; git resolves the current branch's remote itself.
    args = ['fetch', '--no-tags', '--quiet']
    targetRemote = upstream
  } else {
    const remotes = await listRemotes(repoPath, opts.timeoutMs)
    if (remotes.length === 0) {
      return { ok: false, status: 400, error: 'No remote configured.' }
    }
    if (remotes.length > 1) {
      return { ok: false, status: 409, error: 'Set an upstream first (multiple remotes).' }
    }
    const sole = remotes[0] as string
    // A remote named like a flag would otherwise be parsed as one.
    if (sole.startsWith('-')) {
      return { ok: false, status: 400, error: 'Cannot fetch from this remote.' }
    }
    args = ['fetch', '--no-tags', '--quiet', sole]
    targetRemote = sole
  }

  try {
    await execFileAsync('git', args, {
      cwd: repoPath,
      timeout: opts.timeoutMs,
      maxBuffer: GIT_MAX_BUFFER,
      env: NO_PROMPT_ENV,
    })
  } catch (err: unknown) {
    // Offline / auth failure: leave lastFetchMs alone so the UI keeps showing
    // "stale" rather than silently marking the data as freshly verified.
    return failFromError(err)
  }

  return { ok: true, remote: targetRemote, lastFetchMs: await fetchHeadMtime(repoPath) }
}

/** FETCH_HEAD mtime after a successful fetch; undefined if it cannot be read.
 *  Resolves `.git` first so a linked worktree reads the shared common dir. */
async function fetchHeadMtime(repoPath: string): Promise<number | undefined> {
  try {
    const dotGit = path.join(repoPath, '.git')
    const stat = await fs.stat(dotGit)
    let commonDir = dotGit
    if (!stat.isDirectory()) {
      const match = /^gitdir:\s*(.+)$/m.exec(await fs.readFile(dotGit, 'utf8'))
      const raw = match?.[1]?.trim()
      if (raw === undefined || raw === '') return undefined
      const gitDir = path.isAbsolute(raw) ? raw : path.resolve(repoPath, raw)
      const parent = path.dirname(gitDir)
      commonDir = path.basename(parent) === 'worktrees' ? path.dirname(parent) : gitDir
    }
    return (await fs.stat(path.join(commonDir, 'FETCH_HEAD'))).mtimeMs
  } catch {
    return undefined
  }
}
