/**
 * src/http/gh.ts (W3 PR + CI status chip) — read-only PR / CI status via `gh`.
 *
 * The server stays a byte-shuttle: this is an out-of-band side-channel that runs
 * `gh pr view --json …` in a repo directory and PARSES its JSON into a PrStatus.
 * Structure mirrors src/http/diff.ts:
 *  - runGh: execFile('gh', [fixed argv]) — NO shell; timeout + maxBuffer bound
 *    DoS (SEC-M9). The ONLY user-influenced input reaching gh is `cwd`, the
 *    already-validated repoPath (route layer, SEC-H7). No untrusted string ever
 *    enters argv — gh derives the PR from the current branch.
 *  - pure, exported: parsePrView / summarizeChecks / classifyGhFailure. They
 *    NEVER throw: malformed / unknown input degrades to {availability:'error'}.
 *  - getPrStatus: module-scope short-TTL cache (cfg.projectScanTtlMs) keyed by
 *    repoPath + current branch (so a branch switch busts before TTL), with
 *    in-flight dedupe (cache-stampede guard) — caps outbound GitHub-API calls.
 *
 * Network egress note: unlike every other side-channel (all local), gh talks to
 * GitHub's API using the host's existing gh / GH_TOKEN credential. This module
 * NEVER accepts or forwards a token — it only triggers gh's own auth. It also
 * NEVER logs gh stdout (private PR titles) or the token.
 */

import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { Config, PrAvailability, PrCheckSummary, PrStatus } from '../types.js'

const execFileAsync = promisify(execFile)

/** One `gh pr view` spawn: exactly the fields parsePrView / summarizeChecks read. */
const PR_VIEW_FIELDS =
  'number,state,title,url,isDraft,mergeable,headRefName,baseRefName,statusCheckRollup'

/** Config subset getPrStatus needs; the full Config satisfies this Pick. */
export type GhOptions = Pick<Config, 'ghEnabled' | 'ghTimeoutMs' | 'projectScanTtlMs' | 'diffMaxBytes'>

// ── gh runner (injectable seam — default real, overridable in tests) ──────────

/** Normalized outcome of one gh spawn. `code` carries a spawn/system error code
 *  ('ENOENT', 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') or the numeric exit code. */
export interface GhExecResult {
  ok: boolean // process exited 0
  stdout: string
  stderr: string
  code?: string | number
}

/** A gh runner: same seam idea as getDiff's runGit, so getPrStatus is unit-testable
 *  without spawning gh. */
export type GhRunner = (
  repoPath: string,
  args: readonly string[],
  timeoutMs: number,
  maxBytes: number,
) => Promise<GhExecResult>

function asString(v: unknown): string {
  return typeof v === 'string' ? v : ''
}

/**
 * Real gh runner: execFile('gh', [...]) with NO shell, bounded by timeout +
 * maxBuffer. A spawn ENOENT (gh not installed), a non-zero exit (unauth / no PR),
 * a timeout, or a maxBuffer overflow all resolve (never reject) with ok:false so
 * the classifier can degrade — mirroring diff.ts's best-effort house style.
 */
async function realRunGh(
  repoPath: string,
  args: readonly string[],
  timeoutMs: number,
  maxBytes: number,
): Promise<GhExecResult> {
  try {
    const { stdout, stderr } = await execFileAsync('gh', [...args], {
      cwd: repoPath,
      timeout: timeoutMs,
      maxBuffer: maxBytes,
    })
    return { ok: true, stdout, stderr, code: 0 }
  } catch (err: unknown) {
    const e = err as { code?: unknown; stdout?: unknown; stderr?: unknown }
    const code =
      typeof e.code === 'string' || typeof e.code === 'number' ? e.code : undefined
    return { ok: false, stdout: asString(e.stdout), stderr: asString(e.stderr), code }
  }
}

// ── classifyGhFailure (pure) ──────────────────────────────────────────────────

/** A gh non-zero / spawn failure, reduced to what the classifier reads. */
export interface GhFailure {
  code?: string | number
  stderr: string
}

/**
 * Map a gh failure to a degrade reason (regex on lower-cased stderr). ENOENT ⇒
 * gh isn't installed; an auth pattern ⇒ not logged in; a "no PR / no remote"
 * pattern ⇒ no PR for the branch; anything else (timeout, maxBuffer overflow,
 * unknown) ⇒ generic error. Never throws.
 */
export function classifyGhFailure(f: GhFailure): PrAvailability {
  if (f.code === 'ENOENT') return 'not-installed'
  const s = asString(f.stderr).toLowerCase()
  if (/gh auth login|not logged|authentication|http 401/.test(s)) return 'unauthenticated'
  if (/no pull requests found|no default remote|no git remote/.test(s)) return 'no-pr'
  return 'error'
}

// ── summarizeChecks (pure) ────────────────────────────────────────────────────

type CheckBucket = 'passing' | 'failing' | 'pending'

const CHECKRUN_PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
const CHECKRUN_FAIL = new Set([
  'FAILURE',
  'TIMED_OUT',
  'CANCELLED',
  'ACTION_REQUIRED',
  'STARTUP_FAILURE',
  'STALE',
])
const CONTEXT_FAIL = new Set(['FAILURE', 'ERROR'])

/** A CheckRun's status/conclusion → bucket. A non-COMPLETED status (QUEUED,
 *  IN_PROGRESS, WAITING, PENDING, REQUESTED) is pending regardless of conclusion;
 *  a completed/absent status is bucketed by conclusion (null/unknown → pending). */
function bucketCheckRun(status: unknown, conclusion: unknown): CheckBucket {
  const st = asString(status).toUpperCase()
  if (st !== '' && st !== 'COMPLETED') return 'pending'
  const c = asString(conclusion).toUpperCase()
  if (CHECKRUN_PASS.has(c)) return 'passing'
  if (CHECKRUN_FAIL.has(c)) return 'failing'
  return 'pending'
}

/** A StatusContext's state → bucket (SUCCESS pass, FAILURE/ERROR fail, else pending). */
function bucketContext(state: unknown): CheckBucket {
  const s = asString(state).toUpperCase()
  if (s === 'SUCCESS') return 'passing'
  if (CONTEXT_FAIL.has(s)) return 'failing'
  return 'pending' // PENDING, EXPECTED, unknown
}

/** Classify one rollup item. Prefers __typename, else infers from present keys.
 *  Anything unrecognized still counts toward total, treated as pending. */
function bucketItem(raw: unknown): CheckBucket {
  if (raw === null || typeof raw !== 'object') return 'pending'
  const o = raw as Record<string, unknown>
  const typename = asString(o['__typename'])
  const isContext =
    typename === 'StatusContext' ||
    (typename !== 'CheckRun' &&
      o['state'] !== undefined &&
      o['status'] === undefined &&
      o['conclusion'] === undefined)
  if (isContext) return bucketContext(o['state'])
  if (typename === 'CheckRun' || o['status'] !== undefined || o['conclusion'] !== undefined) {
    return bucketCheckRun(o['status'], o['conclusion'])
  }
  return 'pending'
}

/**
 * Roll up gh's `statusCheckRollup` (a mix of CheckRun + StatusContext items) into
 * total / passing / failing / pending counts. Non-array / empty / undefined ⇒
 * all-zero. Every item lands in exactly one bucket so total === pass+fail+pending.
 * Never throws.
 */
export function summarizeChecks(rollup: unknown): PrCheckSummary {
  const summary: PrCheckSummary = { total: 0, passing: 0, failing: 0, pending: 0 }
  if (!Array.isArray(rollup)) return summary
  for (const item of rollup) {
    summary.total += 1
    summary[bucketItem(item)] += 1
  }
  return summary
}

// ── parsePrView (pure) ────────────────────────────────────────────────────────

function mapState(v: unknown): PrStatus['state'] {
  if (typeof v !== 'string') return undefined
  const s = v.toLowerCase()
  return s === 'open' || s === 'closed' || s === 'merged' ? s : undefined
}

function mapMergeable(v: unknown): PrStatus['mergeable'] {
  if (typeof v !== 'string') return undefined
  const s = v.toLowerCase()
  return s === 'mergeable' || s === 'conflicting' || s === 'unknown' ? s : undefined
}

/**
 * Parse `gh pr view --json …` output into a PrStatus. Valid PR JSON ⇒
 * availability:'ok' with lower-cased state/mergeable and a checks summary; a
 * malformed / non-object payload ⇒ {availability:'error'} (never throws — the
 * diff.ts "never throws" house style). Attacker-controllable strings (title/url)
 * are carried VERBATIM — the FE renders them inert via textContent (SEC-H4).
 */
export function parsePrView(json: string): PrStatus {
  let raw: unknown
  try {
    raw = JSON.parse(json)
  } catch {
    return { availability: 'error' }
  }
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
    return { availability: 'error' }
  }
  const o = raw as Record<string, unknown>
  const status: PrStatus = { availability: 'ok' }
  if (typeof o['number'] === 'number') status.number = o['number']
  if (typeof o['title'] === 'string') status.title = o['title']
  if (typeof o['url'] === 'string') status.url = o['url']
  const state = mapState(o['state'])
  if (state !== undefined) status.state = state
  if (typeof o['isDraft'] === 'boolean') status.isDraft = o['isDraft']
  const mergeable = mapMergeable(o['mergeable'])
  if (mergeable !== undefined) status.mergeable = mergeable
  if (typeof o['headRefName'] === 'string') status.headRefName = o['headRefName']
  if (typeof o['baseRefName'] === 'string') status.baseRefName = o['baseRefName']
  status.checks = summarizeChecks(o['statusCheckRollup'])
  return status
}

// ── current-branch read (cheap; cache-key input) ──────────────────────────────

/** Current branch from `<repo>/.git/HEAD` (ref line only). Detached HEAD / junk /
 *  unreadable ⇒ null. Cheap: no spawn (mirrors projects.ts readBranch/parseGitHead). */
async function readHeadBranch(repoPath: string): Promise<string | null> {
  try {
    const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8')
    const match = /^ref:\s+refs\/heads\/(.+)$/.exec(head.trim())
    const branch = match?.[1]?.trim()
    return branch !== undefined && branch !== '' ? branch : null
  } catch {
    return null
  }
}

// ── getPrStatus (cached runner) ───────────────────────────────────────────────

interface PrCacheEntry {
  expiresAt: number
  status: PrStatus
}

const prCache = new Map<string, PrCacheEntry>()
/** Shared in-flight promises so concurrent cache-miss callers join one gh run. */
const inflightPr = new Map<string, Promise<PrStatus>>()

/** repoPath + branch: a branch switch changes the key, busting stale PR data. */
function cacheKey(repoPath: string, branch: string | null): string {
  return `${repoPath}\n${branch ?? ''}`
}

async function runPrStatus(
  repoPath: string,
  cfg: GhOptions,
  runner: GhRunner,
): Promise<PrStatus> {
  const exec = await runner(
    repoPath,
    ['pr', 'view', '--json', PR_VIEW_FIELDS],
    cfg.ghTimeoutMs,
    cfg.diffMaxBytes,
  )
  if (exec.ok) return parsePrView(exec.stdout)
  return { availability: classifyGhFailure({ code: exec.code, stderr: exec.stderr }) }
}

/**
 * Read a repo's PR + CI status. `repoPath` must already be a validated absolute
 * git directory (route layer, SEC-H7). Best-effort: gh missing / unauthed / no PR
 * / timeout all resolve to a PrStatus whose `availability` names the reason —
 * NEVER throws, never blocks the caller. Cached at module scope with
 * cfg.projectScanTtlMs and in-flight dedupe. `runner` is injectable for tests.
 */
export async function getPrStatus(
  repoPath: string,
  cfg: GhOptions,
  runner: GhRunner = realRunGh,
): Promise<PrStatus> {
  if (!cfg.ghEnabled) return { availability: 'disabled' }

  const branch = await readHeadBranch(repoPath)
  const key = cacheKey(repoPath, branch)
  const now = Date.now()

  const cached = prCache.get(key)
  if (cached !== undefined && cached.expiresAt > now) return cached.status

  const inflight = inflightPr.get(key)
  if (inflight !== undefined) return inflight

  const promise = runPrStatus(repoPath, cfg, runner)
    .then((status) => {
      prCache.set(key, { expiresAt: Date.now() + cfg.projectScanTtlMs, status })
      inflightPr.delete(key)
      return status
    })
    .catch((e: unknown) => {
      inflightPr.delete(key)
      throw e
    })
  inflightPr.set(key, promise)
  return promise
}

/** Test-only: drop the PR cache and any in-flight run so each test sees a fresh gh call. */
export function _clearPrCache(): void {
  prCache.clear()
  inflightPr.clear()
}
