/**
 * commands/pr-review.mjs — per-PR persistent state for /apt:pr-review.
 *
 * Backs the R3/R4/R5 redesign: .aperant/pr-reviews/pr-{N}/pr-state.json is
 * the canonical PR-level record; rounds/{R}/ holds per-round artifacts; a
 * committed snapshot is written into rounds/{R}/pr-state-snapshot.json when
 * a round closes.
 *
 * Subcommands:
 *   init-pr                    seed pr-state.json (no-op if present); promotes
 *                              any legacy config.authorship_overrides['pr-{N}']
 *                              entry on first call (C56 A3)
 *   acquire-lock               take the PR-level lock (refuse if busy)
 *   release-lock               clear the lock (owner must match)
 *   start-round                increment rounds_completed + pin head SHA
 *   close-round                write snapshot to rounds/{R}/ and clear lock
 *   get-state                  emit current live pr-state.json as JSON
 *   detect-authorship          call gh CLI and return {authorship_mode, signal};
 *                              checks pr-state.json → config.legacy → config.global
 *                              for overrides (C56 A3 precedence)
 *   record-comment             append an entry to pr-state.json:comment_timeline
 *   set-authorship-override    user-forced authorship override written directly
 *                              to pr-state.json; --clear removes it (C56 A3)
 *
 * All state mutations wrap pr-state.json in withFileLock.
 */

import { execFileSync } from 'node:child_process'
import {
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	rmSync,
	unlinkSync,
	writeFileSync,
} from 'node:fs'
import { join, resolve } from 'node:path'
import { selfStageCommittableWrites } from '../artifacts/self-stage.mjs'
import { isPidAlive } from '../lock/pid.mjs'
import { parseFlags } from '../util/args.mjs'
import { withFileLock } from '../util/fs-lock.mjs'
import { emitInitPrStateHint } from '../util/init-hints.mjs'
import { err, exitWith, ok, UsageError } from '../util/result.mjs'
import { cmdPrReviewAuditFixer } from './pr-review-audit-fixer.mjs'

// C56 B4 — helper for self-staging committable pr-review writes. The
// pr-state.json (and round-snapshot.json) paths classify committable; everything
// under rounds/<N>/ (findings, fixer transcripts) classifies ephemeral.
function selfStagePrReviewPaths(root, paths) {
	return selfStageCommittableWrites(resolve(root), paths, { outputs: null })
}

const DEFAULT_MAX_ROUNDS = 4
const DISCOVERY_ROUNDS = 2 // rounds 1-2 = discovery, 3+ = verification

function prDir(root, prNumber) {
	return join(resolve(root), '.aperant', 'pr-reviews', `pr-${prNumber}`)
}

function stateFile(root, prNumber) {
	return join(prDir(root, prNumber), 'pr-state.json')
}

function roundDir(root, prNumber, round) {
	return join(prDir(root, prNumber), 'rounds', String(round))
}

function readState(path) {
	return JSON.parse(readFileSync(path, 'utf-8'))
}

function writeState(path, state) {
	writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, 'utf-8')
}

function phaseForRound(round) {
	return round <= DISCOVERY_ROUNDS ? 'discovery' : 'verification'
}

/**
 * Read `.aperant/config.json:pr_review` (best-effort; returns {} if missing).
 * R4 uses this to let users force authorship_mode without re-detecting.
 */
function readPrReviewConfig(root) {
	try {
		const path = join(resolve(root), '.aperant', 'config.json')
		if (!existsSync(path)) return {}
		const parsed = JSON.parse(readFileSync(path, 'utf-8'))
		return parsed?.pr_review ?? {}
	} catch {
		return {}
	}
}

/**
 * Resolve an authorship override for a specific PR.
 *
 * C56 A3 — precedence (new first):
 *   1. pr-state.json.authorship_override (per-PR, canonical)
 *   2. config.json.pr_review.authorship_overrides['pr-{N}'] (legacy, deprecated)
 *   3. config.json.pr_review.authorship_override (global, unchanged)
 *   4. null
 *
 * Returns either `null` (no override applies) or
 *   { mode: 'owned' | 'external', source: 'pr_state' | 'config_legacy' | 'config_global', signal: string }.
 *
 * The signal string is a human-readable trace of which branch fired, suitable
 * for writing into `state.authorship_signal` and surfacing in envelopes.
 *
 * The third arg `root` is optional so existing 2-arg call sites still work
 * (they get precedence 2+3+null; precedence 1 requires a root to read
 * pr-state.json).
 *
 * @param {any} prReviewCfg
 * @param {string | number} prNumber
 * @param {string | null} [root]
 */
export function resolveAuthorshipOverride(prReviewCfg, prNumber, root = null) {
	// 1. pr-state.json (canonical, C56 A3). Only attempted when `root` is passed.
	if (root) {
		try {
			const path = stateFile(root, prNumber)
			if (existsSync(path)) {
				const state = JSON.parse(readFileSync(path, 'utf-8'))
				const mode = state?.authorship_override
				if (mode === 'owned' || mode === 'external') {
					return {
						mode,
						source: 'pr_state',
						signal: `pr_state.authorship_override=${mode}${
							state?.authorship_override_source
								? ` (source=${state.authorship_override_source})`
								: ''
						}`,
					}
				}
			}
		} catch {
			// malformed pr-state.json — fall through to config
		}
	}
	if (!prReviewCfg || typeof prReviewCfg !== 'object') return null
	// 2. config.json.pr_review.authorship_overrides['pr-{N}'] (legacy).
	const perPr = prReviewCfg.authorship_overrides?.[`pr-${prNumber}`]
	if (perPr === 'owned' || perPr === 'external') {
		return {
			mode: perPr,
			source: 'config_legacy',
			signal: `config.pr_review.authorship_overrides['pr-${prNumber}']=${perPr} [DEPRECATED — will be removed next release; migrate to pr-state.json]`,
		}
	}
	// 3. config.json.pr_review.authorship_override (global).
	const global = prReviewCfg.authorship_override
	if (global === 'owned' || global === 'external') {
		return {
			mode: global,
			source: 'config_global',
			signal: `config.pr_review.authorship_override=${global}`,
		}
	}
	return null
}

function ensureStateFile(root, prNumber, seed) {
	const dir = prDir(root, prNumber)
	mkdirSync(dir, { recursive: true })
	const file = stateFile(root, prNumber)
	// withFileLock touches the target path (empty file) before running the
	// callback, so we treat missing AND empty files as uninitialized.
	const needsInit = !existsSync(file) || readFileSync(file, 'utf-8').trim().length === 0
	if (needsInit) {
		const init = {
			pr_number: Number(prNumber),
			head_ref: seed?.head_ref ?? null,
			base_ref: seed?.base_ref ?? null,
			head_sha_by_round: {},
			authorship: 'unknown',
			authorship_signal: null,
			// C56 A3 — canonical per-PR authorship override + its source trace.
			// null means "no override, let detection run". When set, wins over
			// every config-side override (see resolveAuthorshipOverride).
			authorship_override: null,
			authorship_override_source: null,
			rounds_completed: 0,
			max_rounds: seed?.max_rounds ?? DEFAULT_MAX_ROUNDS,
			round_phase: 'discovery',
			lock: null,
			comment_timeline: [],
			disputes: [],
			superseded_findings: [],
			created_at: new Date().toISOString(),
			updated_at: new Date().toISOString(),
		}
		writeState(file, init)
		return { created: true, state: init }
	}
	// C56 A3 — backfill missing fields for pre-migration pr-state.json files.
	// Preserving created=false so callers can't accidentally trigger a
	// refresh-on-existing flow; this is a schema migration, not an init.
	const existing = readState(file)
	if (
		!Object.hasOwn(existing, 'authorship_override') ||
		!Object.hasOwn(existing, 'authorship_override_source')
	) {
		existing.authorship_override = existing.authorship_override ?? null
		existing.authorship_override_source = existing.authorship_override_source ?? null
		writeState(file, existing)
	}
	return { created: false, state: existing }
}

/**
 * Sentinel thrown by helper validators (`requirePrFlag`, etc.) so handlers
 * can convert it to a structured `err()` return at the top of the cmd.
 * Layer 1 helpers MUST NOT call `fail()` directly anymore.
 */
class PrReviewUsageError extends UsageError {
	constructor(msg) {
		super(msg)
		this.name = 'PrReviewUsageError'
	}
}

function requirePrFlag(flags) {
	const pr = flags.get('pr')
	if (!pr || !/^\d+$/.test(pr)) throw new PrReviewUsageError('Required: --pr <number>')
	return pr
}

// ── Subcommand handlers ─────────────────────────────────────────────

function handleInitPr(root, flags) {
	const pr = requirePrFlag(flags)
	// CLI flag wins; otherwise honor pr_review.max_rounds from config (R5);
	// otherwise fall back to DEFAULT_MAX_ROUNDS inside ensureStateFile.
	const flagMax = flags.get('max-rounds')
	const prReviewCfg = readPrReviewConfig(root)
	const configMax = typeof prReviewCfg.max_rounds === 'number' ? prReviewCfg.max_rounds : undefined
	const rawMax = flagMax ? Number(flagMax) : configMax
	const seed = {
		head_ref: flags.get('head-ref') ?? null,
		base_ref: flags.get('base-ref') ?? null,
		max_rounds:
			typeof rawMax === 'number' && Number.isFinite(rawMax)
				? Math.max(1, Math.min(10, rawMax))
				: undefined,
	}

	const path = stateFile(root, pr)
	mkdirSync(prDir(root, pr), { recursive: true })

	const result = withFileLock(path, () => {
		const { created, state } = ensureStateFile(root, pr, seed)
		// If file already existed, optionally refresh head/base ref
		if (!created) {
			let changed = false
			if (seed.head_ref && state.head_ref !== seed.head_ref) {
				state.head_ref = seed.head_ref
				changed = true
			}
			if (seed.base_ref && state.base_ref !== seed.base_ref) {
				state.base_ref = seed.base_ref
				changed = true
			}
			if (changed) {
				state.updated_at = new Date().toISOString()
				writeState(path, state)
			}
		}
		// C56 A3 — one-shot promotion of legacy config.pr_review.authorship_overrides['pr-{N}']
		// into pr-state.json.authorship_override. Only runs when pr-state.json
		// doesn't already have an override (caller-set wins). Config entry is
		// NOT deleted — /apt:close-task owns stripping it on merge.
		let promoted = null
		if (state.authorship_override === null || state.authorship_override === undefined) {
			const legacyEntry = prReviewCfg?.authorship_overrides?.[`pr-${pr}`]
			if (legacyEntry === 'owned' || legacyEntry === 'external') {
				state.authorship_override = legacyEntry
				state.authorship_override_source = 'config_legacy'
				state.updated_at = new Date().toISOString()
				writeState(path, state)
				promoted = { mode: legacyEntry, source: 'config_legacy' }
			}
		}
		return { created, state, promoted }
	})

	const selfStagedInit = selfStagePrReviewPaths(root, [relToRoot(root, path)])

	// One-time UX hint: explain that pr-state.json is local-only coordination
	// (gitignored, auto-pruned by close-round on PR merge). Only fires on
	// the genuine first init-pr for this PR — subsequent calls (file already
	// existed, rounds_completed advancing) MUST stay silent. Suppressible
	// via APT_SUPPRESS_INIT_HINTS=1. Stderr only so the JSON envelope on
	// stdout stays parseable.
	if (result.created) {
		emitInitPrStateHint(pr)
	}

	return ok({
		status: 'ok',
		command: 'pr-review-init-pr',
		created: result.created,
		pr_number: result.state.pr_number,
		head_ref: result.state.head_ref,
		base_ref: result.state.base_ref,
		rounds_completed: result.state.rounds_completed,
		max_rounds: result.state.max_rounds,
		authorship: result.state.authorship,
		authorship_override: result.state.authorship_override ?? null,
		authorship_override_source: result.state.authorship_override_source ?? null,
		authorship_override_promoted: result.promoted,
		state_path: path,
		self_staged: selfStagedInit,
	})
}

// Relativize an absolute path against the project root so the classifier can
// bucket it (it expects repo-relative paths).
function relToRoot(root, abs) {
	const r = resolve(root)
	return abs.startsWith(r) ? abs.slice(r.length + 1) : abs
}

function handleAcquireLock(root, flags) {
	const pr = requirePrFlag(flags)
	const owner = flags.get('owner')
	if (!owner) throw new PrReviewUsageError('Required: --owner <id>')
	const ttlMs = flags.get('ttl-ms') ? Number(flags.get('ttl-ms')) : 30 * 60 * 1000

	const path = stateFile(root, pr)
	ensureStateFile(root, pr, {})

	const result = withFileLock(path, () => {
		const state = readState(path)
		const now = new Date()
		// Check existing lock liveness: if older than ttl AND the owning
		// process is dead, treat as stale and steal. If the wall-clock TTL
		// has elapsed but the process is still alive (long-running review,
		// clock skew, etc.), refuse to steal — the prior pure-TTL behavior
		// would silently kick a live worker (review M4).
		if (state.lock && state.lock.owner && state.lock.owner !== owner) {
			const acquired = state.lock.acquired_at ? Date.parse(state.lock.acquired_at) : 0
			const age = now.getTime() - acquired
			const ownerPid = state.lock.owner_pid ?? null
			if (age < ttlMs) {
				return {
					busy: true,
					reason: 'within_ttl',
					owner: state.lock.owner,
					acquired_at: state.lock.acquired_at,
					age_ms: age,
					owner_pid: ownerPid,
				}
			}
			// Past TTL: only steal if the recorded PID is dead (or unknown).
			// `isPidAlive` returns false for ESRCH and EPERM (different uid)
			// — both are safe to steal because we can't signal the process
			// anyway.
			if (ownerPid && isPidAlive(ownerPid)) {
				return {
					busy: true,
					reason: 'pid_alive_past_ttl',
					owner: state.lock.owner,
					acquired_at: state.lock.acquired_at,
					age_ms: age,
					owner_pid: ownerPid,
				}
			}
			// fall through → steal stale lock (PID dead or never recorded)
		}
		state.lock = {
			owner,
			acquired_at: now.toISOString(),
			owner_pid: process.pid,
		}
		state.updated_at = now.toISOString()
		writeState(path, state)
		return { busy: false, owner, owner_pid: process.pid }
	})

	if (result.busy) {
		return exitWith(
			{
				status: 'busy',
				command: 'pr-review-acquire-lock',
				pr_number: Number(pr),
				owner: result.owner,
				acquired_at: result.acquired_at,
				age_ms: result.age_ms,
				owner_pid: result.owner_pid,
				reason: result.reason,
			},
			2,
		)
	}

	return ok({
		status: 'ok',
		command: 'pr-review-acquire-lock',
		pr_number: Number(pr),
		owner: result.owner,
		owner_pid: result.owner_pid,
	})
}

function handleReleaseLock(root, flags) {
	const pr = requirePrFlag(flags)
	const owner = flags.get('owner')
	if (!owner) throw new PrReviewUsageError('Required: --owner <id>')

	const path = stateFile(root, pr)
	if (!existsSync(path)) throw new PrReviewUsageError(`pr-state.json not found for PR ${pr}`)

	const result = withFileLock(path, () => {
		const state = readState(path)
		if (state.lock && state.lock.owner && state.lock.owner !== owner) {
			return { mismatch: true, owner: state.lock.owner }
		}
		state.lock = null
		state.updated_at = new Date().toISOString()
		writeState(path, state)
		return { mismatch: false }
	})

	if (result.mismatch) {
		return exitWith(
			{
				status: 'mismatch',
				command: 'pr-review-release-lock',
				pr_number: Number(pr),
				owner: result.owner,
			},
			2,
		)
	}

	return ok({
		status: 'ok',
		command: 'pr-review-release-lock',
		pr_number: Number(pr),
	})
}

function handleStartRound(root, flags) {
	const pr = requirePrFlag(flags)
	const headSha = flags.get('head-sha')
	if (!headSha) throw new PrReviewUsageError('Required: --head-sha <sha>')

	const path = stateFile(root, pr)
	ensureStateFile(root, pr, {})

	const result = withFileLock(path, () => {
		const state = readState(path)
		const nextRound = state.rounds_completed + 1
		if (nextRound > state.max_rounds) {
			return {
				exhausted: true,
				rounds_completed: state.rounds_completed,
				max_rounds: state.max_rounds,
			}
		}
		state.rounds_completed = nextRound
		state.head_sha_by_round = state.head_sha_by_round ?? {}
		state.head_sha_by_round[String(nextRound)] = headSha
		state.round_phase = phaseForRound(nextRound)
		state.updated_at = new Date().toISOString()
		writeState(path, state)
		// Ensure rounds/{R}/ directory exists so artifacts can be written.
		mkdirSync(roundDir(root, pr, nextRound), { recursive: true })
		return {
			exhausted: false,
			round: nextRound,
			round_phase: state.round_phase,
			head_sha: headSha,
			state,
		}
	})

	if (result.exhausted) {
		return exitWith(
			{
				status: 'exhausted',
				command: 'pr-review-start-round',
				pr_number: Number(pr),
				rounds_completed: result.rounds_completed,
				max_rounds: result.max_rounds,
			},
			2,
		)
	}

	return ok({
		status: 'ok',
		command: 'pr-review-start-round',
		pr_number: Number(pr),
		round: result.round,
		round_phase: result.round_phase,
		head_sha: result.head_sha,
	})
}

function handleCloseRound(root, flags) {
	const pr = requirePrFlag(flags)
	const round = flags.get('round')
	if (!round || !/^\d+$/.test(round)) throw new PrReviewUsageError('Required: --round <number>')

	const path = stateFile(root, pr)
	if (!existsSync(path)) throw new PrReviewUsageError(`pr-state.json not found for PR ${pr}`)

	const result = withFileLock(path, () => {
		const state = readState(path)
		// Write committed snapshot to rounds/{round}/pr-state-snapshot.json
		const rDir = roundDir(root, pr, round)
		mkdirSync(rDir, { recursive: true })
		const snapshotPath = join(rDir, 'pr-state-snapshot.json')
		// Snapshot without live-only fields (lock is ephemeral)
		const snapshot = {
			...state,
			lock: null,
			snapshot_round: Number(round),
			snapshot_at: new Date().toISOString(),
		}
		writeFileSync(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf-8')
		// Clear live lock
		state.lock = null
		state.updated_at = new Date().toISOString()
		writeState(path, state)
		return { snapshot_path: snapshotPath }
	})

	const selfStagedClose = selfStagePrReviewPaths(root, [
		relToRoot(root, result.snapshot_path),
		relToRoot(root, path),
	])

	// C56 B6 — incidental post-merge cleanup. When gh reports the PR is
	// merged-or-closed, unlink matching `pr-<N>-*.json` workflow files and
	// strip the stale `config.pr_review.authorship_overrides['pr-<N>']` key.
	// Best-effort; offline failures degrade to `cleanup: { skipped: true }`.
	const cleanup = maybeRunCloseRoundCleanup(root, pr)

	return ok({
		status: 'ok',
		command: 'pr-review-close-round',
		pr_number: Number(pr),
		round: Number(round),
		snapshot_path: result.snapshot_path,
		self_staged: selfStagedClose,
		cleanup,
	})
}

/**
 * C56 B6 — close-round cleanup helper. Queries `gh pr view` for the PR's
 * merge state; when MERGED/CLOSED:
 *   1. Best-effort unlinks matching workflow files (`pr-${N}-*.json`).
 *   2. Strips stale `config.pr_review.authorship_overrides['pr-${N}']`.
 *   3. Prunes the per-PR coordination dir (`.aperant/pr-reviews/pr-${N}/`).
 *      The dir holds local framework state — gitignored, no cross-machine
 *      value post-merge. See artifact-lifecycle.md "PR-review artifacts are
 *      local-only" for rationale.
 * Never throws.
 *
 * @param {string} root
 * @param {string | number} pr
 * @returns {{ pr_state: string | null, workflows_unlinked: string[], config_stripped: boolean, pr_dir_pruned: boolean, warnings: string[], skipped?: boolean }}
 * @internal
 */
function maybeRunCloseRoundCleanup(root, pr) {
	const warnings = []
	const workflowsUnlinked = []
	let configStripped = false
	let prState = null

	// Test hook: reuse APT_GH_FIXTURE_JSON for deterministic test runs.
	let ghJson = null
	if (process.env.APT_GH_FIXTURE_JSON) {
		ghJson = process.env.APT_GH_FIXTURE_JSON
	} else {
		try {
			const res = execFileSync('gh', ['pr', 'view', String(pr), '--json', 'state,mergedAt'], {
				cwd: resolve(root),
				encoding: 'utf-8',
				stdio: ['ignore', 'pipe', 'pipe'],
			})
			ghJson = res
		} catch (err) {
			warnings.push(`gh pr view failed: ${err?.message ?? String(err)}`)
			return {
				pr_state: null,
				workflows_unlinked: [],
				config_stripped: false,
				pr_dir_pruned: false,
				warnings,
				skipped: true,
			}
		}
	}

	try {
		const parsed = JSON.parse(ghJson)
		prState = parsed.state ?? null
	} catch (err) {
		warnings.push(`gh output parse failed: ${err?.message ?? String(err)}`)
		return {
			pr_state: null,
			workflows_unlinked: [],
			config_stripped: false,
			warnings,
			skipped: true,
		}
	}

	if (prState !== 'MERGED' && prState !== 'CLOSED') {
		return {
			pr_state: prState,
			workflows_unlinked: [],
			config_stripped: false,
			pr_dir_pruned: false,
			warnings,
		}
	}

	// 1. Unlink matching workflow files. Naming convention: pr-<N>-*.json
	try {
		const workflowsDir = join(resolve(root), '.aperant', 'workflows')
		if (existsSync(workflowsDir)) {
			const prefix = `pr-${pr}-`
			for (const entry of readdirSync(workflowsDir)) {
				if (!entry.endsWith('.json')) continue
				if (!entry.startsWith(prefix)) continue
				try {
					unlinkSync(join(workflowsDir, entry))
					workflowsUnlinked.push(entry)
				} catch (err) {
					warnings.push(`unlink workflow ${entry} failed: ${err?.message ?? String(err)}`)
				}
			}
		}
	} catch (err) {
		warnings.push(`workflow scan failed: ${err?.message ?? String(err)}`)
	}

	// 2. Strip authorship override from config.
	try {
		const configPath = join(resolve(root), '.aperant', 'config.json')
		if (existsSync(configPath)) {
			const cfg = JSON.parse(readFileSync(configPath, 'utf-8'))
			const key = `pr-${pr}`
			if (cfg?.pr_review?.authorship_overrides && key in cfg.pr_review.authorship_overrides) {
				delete cfg.pr_review.authorship_overrides[key]
				if (Object.keys(cfg.pr_review.authorship_overrides).length === 0) {
					delete cfg.pr_review.authorship_overrides
				}
				// Use withFileLock for the actual write to avoid racing with
				// other config mutators.
				withFileLock(
					configPath,
					() => {
						writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf-8')
					},
					{ projectRoot: resolve(root) },
				)
				configStripped = true
			}
		}
	} catch (err) {
		warnings.push(`config strip failed: ${err?.message ?? String(err)}`)
	}

	// 3. Prune the per-PR coordination dir. Files under .aperant/pr-reviews/pr-{N}/
	// (pr-state.json + rounds/{R}/pr-state-snapshot.json) are local framework
	// coordination — gitignored, no cross-machine value once the PR is merged.
	// See packages/framework/docs/artifact-lifecycle.md → "PR-review artifacts
	// are local-only" for the rationale.
	let prDirPruned = false
	try {
		const prDir = join(resolve(root), '.aperant', 'pr-reviews', `pr-${pr}`)
		if (existsSync(prDir)) {
			rmSync(prDir, { recursive: true, force: true })
			prDirPruned = true
		}
	} catch (err) {
		warnings.push(`pr-dir prune failed: ${err?.message ?? String(err)}`)
	}

	return {
		pr_state: prState,
		workflows_unlinked: workflowsUnlinked,
		config_stripped: configStripped,
		pr_dir_pruned: prDirPruned,
		warnings,
	}
}

function handleGetState(root, flags) {
	const pr = requirePrFlag(flags)
	const path = stateFile(root, pr)
	if (!existsSync(path)) throw new PrReviewUsageError(`pr-state.json not found for PR ${pr}`)
	const state = readState(path)
	return ok({
		status: 'ok',
		command: 'pr-review-get-state',
		state_path: path,
		state,
	})
}

function parseGhPrView(raw) {
	try {
		return JSON.parse(raw)
	} catch {
		return null
	}
}

/**
 * Decide authorship_mode from a gh pr view JSON payload.
 * Exported so the unit tests don't need to shell out.
 *
 * IMPORTANT (review H1): "fork" must be detected by comparing the head-repo
 * owner against the BASE-repo owner (the repo the PR targets), NOT against
 * the viewer's personal login. For an org repo like `Mikalsen-AI/foo`, the
 * head repo owner is `Mikalsen-AI` while `gh api user --jq .login` returns
 * the viewer's personal handle (e.g. `AndyMik90`). They never match, which
 * previously caused every same-repo, owned PR to be mis-classified as
 * `external` and silently disabled auto-fix. The third arg `baseRepoOwner`
 * is the source of truth for "is this a fork PR?". `viewerLogin` is kept
 * only for backward-compatible signal reporting.
 *
 * @param {Record<string, any> | null | undefined} gh    parsed `gh pr view` JSON (may have `authorAssociation` grafted in by the REST-fetch path)
 * @param {string | null | undefined} viewerLogin        personal login from `gh api user` (fallback comparand for fork detection)
 * @param {string | null | undefined} [baseRepoOwner]    the org/user that owns the repo the PR targets (preferred comparand)
 * @returns {{ authorship_mode: 'owned' | 'external' | 'unknown', authorship_signal: string }}
 */
export function decideAuthorshipFromGh(gh, viewerLogin, baseRepoOwner = null) {
	if (!gh) return { authorship_mode: 'unknown', authorship_signal: 'gh_payload_missing' }
	const association = gh.authorAssociation ?? gh.author?.association ?? null
	const headRepoOwner =
		gh.headRepository?.owner?.login ??
		gh.headRepositoryOwner?.login ??
		gh.headRepository?.owner ??
		null
	// Prefer baseRepoOwner when supplied; fall back to viewerLogin only for
	// older callers that haven't been updated yet (e.g. legacy tests). The
	// fallback preserves prior behavior for the personal-repo case.
	const compareOwner = baseRepoOwner ?? viewerLogin
	const isFork = headRepoOwner && compareOwner && headRepoOwner !== compareOwner

	const ownerLike = ['OWNER', 'MEMBER', 'COLLABORATOR']
	const externalLike = ['NONE', 'CONTRIBUTOR', 'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER']

	if (!isFork && association && ownerLike.includes(association)) {
		return {
			authorship_mode: 'owned',
			authorship_signal: `authorAssociation=${association}, same-repo branch (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'})`,
		}
	}
	if (isFork || (association && externalLike.includes(association))) {
		return {
			authorship_mode: 'external',
			authorship_signal: `authorAssociation=${association}, fork=${Boolean(isFork)} (head=${headRepoOwner ?? '?'}, base=${compareOwner ?? '?'})`,
		}
	}
	return {
		authorship_mode: 'unknown',
		authorship_signal: `authorAssociation=${association ?? 'null'}, fork=${Boolean(isFork)}`,
	}
}

function handleDetectAuthorship(root, flags) {
	const pr = requirePrFlag(flags)

	// C56 A3 — ensure pr-state.json exists before resolving so the pr_state
	// precedence branch has a file to read. resolveAuthorshipOverride is the
	// single source of truth for precedence across pr_state / config_legacy /
	// config_global.
	const prReviewCfg = readPrReviewConfig(root)
	ensureStateFile(root, pr, {})
	const override = resolveAuthorshipOverride(prReviewCfg, pr, root)
	if (override) {
		const path = stateFile(root, pr)
		withFileLock(path, () => {
			const state = readState(path)
			state.authorship = override.mode
			state.authorship_signal = override.signal
			state.updated_at = new Date().toISOString()
			writeState(path, state)
		})
		const selfStagedOverride = selfStagePrReviewPaths(root, [relToRoot(root, path)])
		const payload = {
			status: 'ok',
			command: 'pr-review-detect-authorship',
			pr_number: Number(pr),
			authorship_mode: override.mode,
			authorship_signal: override.signal,
			source: override.source === 'pr_state' ? 'pr_state' : 'config',
			override_source: override.source,
			self_staged: selfStagedOverride,
		}
		if (override.source === 'config_legacy') {
			payload.deprecation =
				"reading authorship override from config.pr_review.authorship_overrides is deprecated; it will be removed next release. Run 'apt-tools pr-review init-pr' to promote the entry into pr-state.json, or 'apt-tools pr-review set-authorship-override' to set it directly."
		}
		return ok(payload)
	}

	// Optional override via --gh-json <path> for tests / offline dev.
	const ghJsonPath = flags.get('gh-json')
	let gh = null
	let viewerLogin = flags.get('viewer-login') ?? null
	// `base-repo-owner` (or `--base-owner` short) lets tests bypass the gh
	// shell-out for resolving the base repo owner. Production code path
	// reads it from `gh repo view --json owner -q .owner.login` below.
	let baseRepoOwner = flags.get('base-repo-owner') ?? flags.get('base-owner') ?? null
	if (ghJsonPath) {
		if (!existsSync(ghJsonPath))
			throw new PrReviewUsageError(`gh-json fixture not found: ${ghJsonPath}`)
		gh = parseGhPrView(readFileSync(ghJsonPath, 'utf-8'))
	} else {
		// NOTE (framework bug #1, 2026-04): `gh pr view --json authorAssociation`
		// is NOT a valid field on any `gh` CLI version — the cli/cli source never
		// exposes it on `sharedIssuePRFields` or `PullRequestFields`, it only
		// lives nested inside reviews/comments subobjects. Previously this call
		// exited non-zero with `Unknown JSON field: "authorAssociation"` and the
		// catch-block silently reported `authorship_mode=unknown`, which forced
		// the user's global `authorship_override` config into the only working
		// code path. Fix: fetch repo/ref info via `gh pr view` (valid fields
		// only), then fetch `author_association` via `gh api .../pulls/{N}`
		// (REST endpoint that DOES return it), and graft the REST value back
		// onto the parsed gh object as `authorAssociation` so the existing
		// `decideAuthorshipFromGh` contract (and every existing `--gh-json`
		// test fixture) keeps working unchanged.
		try {
			const raw = execFileSync(
				'gh',
				[
					'pr',
					'view',
					pr,
					'--json',
					'author,headRepository,headRepositoryOwner,isCrossRepository,baseRefName,headRefName',
				],
				{ cwd: root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
			)
			gh = parseGhPrView(raw)
		} catch (e) {
			return ok({
				status: 'ok',
				command: 'pr-review-detect-authorship',
				pr_number: Number(pr),
				authorship_mode: 'unknown',
				authorship_signal: `gh_failed: ${String(e?.message ?? e).slice(0, 120)}`,
			})
		}
		// Resolve OWNER/REPO for the REST call, and reuse the owner as the
		// BASE-repo owner (fork detection). Single shell-out covers both.
		let ownerRepo = null
		if (!baseRepoOwner) {
			try {
				ownerRepo = execFileSync(
					'gh',
					['repo', 'view', '--json', 'owner,name', '--jq', '"\\(.owner.login)/\\(.name)"'],
					{ cwd: root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
				).trim()
				baseRepoOwner = ownerRepo.split('/')[0] || null
			} catch {
				baseRepoOwner = null
			}
		} else {
			// Caller supplied --base-repo-owner; we still need OWNER/REPO for
			// the REST call. Ask gh repo view for name only.
			try {
				const name = execFileSync('gh', ['repo', 'view', '--json', 'name', '--jq', '.name'], {
					cwd: root,
					encoding: 'utf-8',
					stdio: ['ignore', 'pipe', 'pipe'],
				}).trim()
				if (name) ownerRepo = `${baseRepoOwner}/${name}`
			} catch {
				ownerRepo = null
			}
		}
		// Fetch author_association via REST and graft onto the gh payload.
		if (ownerRepo && gh) {
			try {
				const restAssociation = execFileSync(
					'gh',
					['api', `repos/${ownerRepo}/pulls/${pr}`, '--jq', '.author_association'],
					{ cwd: root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
				).trim()
				if (restAssociation && restAssociation !== 'null') {
					gh.authorAssociation = restAssociation
				}
			} catch {
				// leave gh.authorAssociation unset → decideAuthorshipFromGh
				// falls back to 'unknown' with a clear signal.
			}
		}
		if (!viewerLogin) {
			try {
				viewerLogin = execFileSync('gh', ['api', 'user', '--jq', '.login'], {
					cwd: root,
					encoding: 'utf-8',
					stdio: ['ignore', 'pipe', 'pipe'],
				}).trim()
			} catch {
				viewerLogin = null
			}
		}
	}

	const decision = decideAuthorshipFromGh(gh, viewerLogin, baseRepoOwner)

	// Persist into pr-state.json so downstream rounds don't re-ask.
	const path = stateFile(root, pr)
	ensureStateFile(root, pr, {})
	withFileLock(path, () => {
		const state = readState(path)
		state.authorship = decision.authorship_mode
		state.authorship_signal = decision.authorship_signal
		state.updated_at = new Date().toISOString()
		writeState(path, state)
	})

	const selfStagedDetect = selfStagePrReviewPaths(root, [relToRoot(root, path)])
	return ok({
		status: 'ok',
		command: 'pr-review-detect-authorship',
		pr_number: Number(pr),
		authorship_mode: decision.authorship_mode,
		authorship_signal: decision.authorship_signal,
		self_staged: selfStagedDetect,
	})
}

/**
 * Append an entry to pr-state.json:comment_timeline under withFileLock.
 *
 * Phase 6b of /apt:pr-review (external PRs) posts a single comment per
 * round and then needs to record url + body hash so re-runs can detect
 * duplicate posts. Prior to this subcommand SKILL.md asked the LLM to
 * hand-edit pr-state.json — the only state mutation not covered by a
 * tool. Review M1 forced moving it into apt-tools so it's testable and
 * locked.
 */
function handleRecordComment(root, flags) {
	const pr = requirePrFlag(flags)
	const round = flags.get('round')
	if (!round || !/^\d+$/.test(round)) throw new PrReviewUsageError('Required: --round <number>')
	const url = flags.get('url')
	if (!url) throw new PrReviewUsageError('Required: --url <url>')
	const bodyHash = flags.get('body-hash')
	if (!bodyHash) throw new PrReviewUsageError('Required: --body-hash <sha256>')
	const reviewId = flags.get('review-id') ?? null
	const postedAt = flags.get('posted-at') ?? new Date().toISOString()

	const path = stateFile(root, pr)
	if (!existsSync(path)) throw new PrReviewUsageError(`pr-state.json not found for PR ${pr}`)

	const entry = {
		round: Number(round),
		url,
		posted_at: postedAt,
		body_hash: bodyHash,
		...(reviewId ? { review_id: reviewId } : {}),
	}

	const result = withFileLock(path, () => {
		const state = readState(path)
		state.comment_timeline = state.comment_timeline ?? []
		state.comment_timeline.push(entry)
		state.updated_at = new Date().toISOString()
		writeState(path, state)
		return { count: state.comment_timeline.length }
	})

	const selfStagedComment = selfStagePrReviewPaths(root, [relToRoot(root, path)])
	return ok({
		status: 'ok',
		command: 'pr-review-record-comment',
		pr_number: Number(pr),
		round: Number(round),
		url,
		body_hash: bodyHash,
		comment_timeline_length: result.count,
		self_staged: selfStagedComment,
	})
}

/**
 * C56 A3 — `apt-tools pr-review set-authorship-override <project-dir> --pr N --mode owned|external`.
 *
 * Writes the override directly into pr-state.json under a file lock.
 * `authorship_override_source` is always `'user'` for this path — distinguishes
 * user-forced overrides from `'config_legacy'` (promoted in init-pr).
 */
function handleSetAuthorshipOverride(root, flags) {
	const pr = requirePrFlag(flags)
	const modeRaw = flags.get('mode')
	const clear = flags.has('clear')
	if (clear && modeRaw) throw new PrReviewUsageError('--clear and --mode are mutually exclusive')
	if (!clear && modeRaw !== 'owned' && modeRaw !== 'external') {
		throw new PrReviewUsageError('Required: --mode owned|external (or --clear to remove)')
	}

	ensureStateFile(root, pr, {})
	const path = stateFile(root, pr)

	const result = withFileLock(path, () => {
		const state = readState(path)
		const before = {
			mode: state.authorship_override ?? null,
			source: state.authorship_override_source ?? null,
		}
		if (clear) {
			state.authorship_override = null
			state.authorship_override_source = null
		} else {
			state.authorship_override = modeRaw
			state.authorship_override_source = 'user'
		}
		state.updated_at = new Date().toISOString()
		writeState(path, state)
		return {
			before,
			after: {
				mode: state.authorship_override ?? null,
				source: state.authorship_override_source ?? null,
			},
		}
	})

	const selfStagedSet = selfStagePrReviewPaths(root, [relToRoot(root, path)])
	return ok({
		status: 'ok',
		command: 'pr-review-set-authorship-override',
		pr_number: Number(pr),
		before: result.before,
		after: result.after,
		state_path: path,
		self_staged: selfStagedSet,
	})
}

// ── Dispatcher ──────────────────────────────────────────────────────

/**
 * Layer 1 (post-CLI-layering refactor, subtask 11). cmdPrReview returns
 * { envelope, exitCode } — the 4 documented exit-2 cases (acquire-lock busy,
 * release-lock mismatch, start-round exhausted, dispatch error) are encoded
 * via exitWith(envelope, 2). Handlers throw PrReviewUsageError for usage
 * violations; the dispatcher catches those and converts to err(msg).
 *
 * audit-fixer was migrated to the same { envelope, exitCode } shape in
 * subtask 17; subtask 20 removed wrap()'s legacy-tolerance branch entirely.
 * Both cmdPrReview and cmdPrReviewAuditFixer now return CommandResult
 * tuples — wrap() rejects any other shape as a partial-migration bug.
 */
export function cmdPrReview(subcommand, projectDir, extraArgs) {
	if (!subcommand)
		return err(
			'Usage: apt-tools pr-review <init-pr|acquire-lock|release-lock|start-round|close-round|get-state|detect-authorship|record-comment|set-authorship-override|audit-fixer> <project-dir> [flags]',
		)

	// audit-fixer has its own argv shape (accepts --help in projectDir
	// slot, tolerates missing .aperant when only rendering usage) and
	// writes into an arbitrary --review-dir that isn't necessarily under
	// <project-dir>/.aperant. Delegate BEFORE the .aperant existence guard.
	// audit-fixer returns the standard { envelope, exitCode } tuple
	// (migrated in subtask 17).
	if (subcommand === 'audit-fixer') {
		return cmdPrReviewAuditFixer(projectDir, extraArgs)
	}

	if (!projectDir) return err(`Usage: apt-tools pr-review ${subcommand} <project-dir> [flags]`)

	const root = resolve(projectDir)
	if (!existsSync(join(root, '.aperant'))) return err(`.aperant/ not found under ${root}`)
	const flags = parseFlags(extraArgs)

	try {
		switch (subcommand) {
			case 'init-pr':
				return handleInitPr(root, flags)
			case 'acquire-lock':
				return handleAcquireLock(root, flags)
			case 'release-lock':
				return handleReleaseLock(root, flags)
			case 'start-round':
				return handleStartRound(root, flags)
			case 'close-round':
				return handleCloseRound(root, flags)
			case 'get-state':
				return handleGetState(root, flags)
			case 'detect-authorship':
				return handleDetectAuthorship(root, flags)
			case 'record-comment':
				return handleRecordComment(root, flags)
			case 'set-authorship-override':
				return handleSetAuthorshipOverride(root, flags)
			default:
				return err(
					`Unknown pr-review subcommand: ${subcommand}. Available: init-pr, acquire-lock, release-lock, start-round, close-round, get-state, detect-authorship, record-comment, set-authorship-override, audit-fixer`,
				)
		}
	} catch (e) {
		if (e instanceof PrReviewUsageError) return err(e.message)
		throw e
	}
}
