/**
 * post-merge-sweep.mjs — dispatch-time passive sweeper for stuck
 * `shipped-pending-merge` tasks (C57 Piece 2).
 *
 * **Bookkeeping-only contract.** This hook runs the deterministic
 * close half of the post-merge handoff: poll `gh pr view`, flip
 * lifecycle_phase from `shipped-pending-merge` to `completed` via
 * the same `task close` path, strip per-PR authorship overrides,
 * and append a `pending_narration[]` ledger row. The LLM-side
 * narration work (registry sync + ROADMAP prose) is explicitly
 * out of scope here — the user drains the ledger via
 * `/apt:close-task --narrate-only` (see packages/framework/skills/
 * apt-close-task/SKILL.md §4) which is where those side effects belong.
 *
 * AI agents ship a PR and walk away. Tasks then rot in
 * `shipped-pending-merge` because nothing automatic invokes
 * `apt-tools task close-merged`. This hook runs on every `apt-tools`
 * dispatch (mirroring the gitignore-drift hook), checks a set of
 * fast preflights, and — at most once every 10 minutes per repo —
 * spawns a detached child to poll `gh pr view` and close any tasks
 * whose PRs have terminally landed.
 *
 * Non-blocking: never fails the command, never mutates exit code,
 * never throws. Two stderr lines max per dispatch: the
 * `Auto-sweeping N pending PR(s)` line on actual sweep kick, plus
 * the `Post-merge narration pending` banner when the ledger has
 * un-drained rows. Both lines are gated by `APT_SUPPRESS_MERGE_SWEEP=1`.
 *
 * Silenced by `APT_SUPPRESS_MERGE_SWEEP=1`. Skipped when
 * `APT_INTERNAL_MERGE_SWEEP=1` is set in env (recursion guard for
 * the spawned child's own dispatch loop).
 *
 * @internal — consumed only from bin/apt-tools.mjs dispatch.
 */

import { spawn } from 'node:child_process'
import { closeSync, existsSync, openSync, readFileSync, statSync, writeSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

/**
 * Hot-path commands whose stderr feeds back into LLM-facing routers /
 * detectors — chatty hook output would pollute classification signal.
 *
 * Plus `task` — so a `task close-merged` invocation does NOT re-trigger
 * the sweep on its own dispatch (recursion guard #1).
 */
const SKIP_COMMANDS = new Set([
	'route',
	'detect-project',
	'detect-worktree',
	'health-check',
	'toolchain-detect',
	'host-detect',
	'task', // recursion guard #1 — close-merged is a `task` subcommand
	'--version',
	'--help',
	'-v',
	'-h',
])

/**
 * Cooldown window between sweep kicks, in milliseconds. Tasks that
 * just shipped won't be merged in seconds — so polling more than
 * once per 10 minutes wastes `gh pr view` rate budget. Configurable
 * via `MERGE_SWEEP_COOLDOWN_MS` env var (mostly for tests).
 */
const DEFAULT_COOLDOWN_MS = 600_000 // 10 minutes

function getCooldownMs() {
	const raw = process.env.MERGE_SWEEP_COOLDOWN_MS
	if (!raw) return DEFAULT_COOLDOWN_MS
	const n = Number.parseInt(raw, 10)
	return Number.isFinite(n) && n > 0 ? n : DEFAULT_COOLDOWN_MS
}

/**
 * Pure preflight — returns the list of `shipped-pending-merge` task IDs
 * whose `pr_url` is set. Returns `null` if state can't be read or no
 * candidates exist (the dispatcher uses `null` as the short-circuit).
 *
 * @internal
 */
export function readShippedCandidates(projectRoot) {
	const statePath = join(projectRoot, '.aperant', 'state.json')
	if (!existsSync(statePath)) return null
	let state
	try {
		state = JSON.parse(readFileSync(statePath, 'utf-8'))
	} catch {
		return null
	}
	const active = state?.active_tasks
	if (!active || typeof active !== 'object') return null
	const candidates = []
	for (const [id, t] of Object.entries(active)) {
		if (!t || typeof t !== 'object') continue
		if (t.lifecycle_phase !== 'shipped-pending-merge') continue
		if (!t.pr_url) continue
		candidates.push({ id, pr_url: t.pr_url, worktree_path: t.worktree_path || null })
	}
	return candidates.length === 0 ? null : candidates
}

/**
 * Pure preflight — returns the integer length of state.pending_narration[].
 * Returns 0 on read/parse failure or when the ledger is absent (the banner
 * is suppressed in that case).
 *
 * @internal
 */
export function readPendingNarrationCount(projectRoot) {
	const statePath = join(projectRoot, '.aperant', 'state.json')
	if (!existsSync(statePath)) return 0
	try {
		const state = JSON.parse(readFileSync(statePath, 'utf-8'))
		const ledger = state?.pending_narration
		return Array.isArray(ledger) ? ledger.length : 0
	} catch {
		return 0
	}
}

/**
 * Returns true if the cooldown lock is "fresh" (last sweep too recent).
 * Lock content is `{pid}:{ms-since-epoch}` but we only consult mtime.
 *
 * @internal
 */
export function isCooldownActive(lockPath, cooldownMs = getCooldownMs()) {
	if (!existsSync(lockPath)) return false
	try {
		const st = statSync(lockPath)
		return Date.now() - st.mtimeMs < cooldownMs
	} catch {
		return false
	}
}

/**
 * Best-effort lease writer. Atomic enough for our needs (the lock
 * itself is the cooldown token, not a critical-section guard — the
 * destructive side effects all run in the detached child via the
 * existing `task close-merged → task close` pipeline).
 *
 * @internal
 */
export function writeSweepLease(lockPath) {
	try {
		const fd = openSync(lockPath, 'w')
		try {
			writeSync(fd, `${process.pid}:${Date.now()}\n`)
		} finally {
			closeSync(fd)
		}
		return true
	} catch {
		return false
	}
}

/**
 * Filter out candidates whose worktree contains the current cwd —
 * sweeping into your own active worktree would pull the rug out from
 * under you. Returns the deferred IDs alongside the kept ones.
 *
 * @internal
 */
export function partitionByCwd(candidates, cwd) {
	const kept = []
	const deferred = []
	for (const c of candidates) {
		if (c.worktree_path && cwd.startsWith(c.worktree_path)) {
			deferred.push(c.id)
		} else {
			kept.push(c)
		}
	}
	return { kept, deferred }
}

/**
 * Resolve the absolute path to `bin/apt-tools.mjs`. The hook lives in
 * `src/cli/config/`, the dispatcher in `bin/`. Same package, same install.
 *
 * @internal
 */
function resolveAptToolsBin() {
	const here = dirname(fileURLToPath(import.meta.url))
	// src/cli/config → ../../../bin/apt-tools.mjs
	return resolve(here, '..', '..', '..', 'bin', 'apt-tools.mjs')
}

/**
 * Spawn the detached close-merged child. Sets `APT_INTERNAL_MERGE_SWEEP=1`
 * in env so the child's own dispatch hook doesn't recurse.
 *
 * Returns the child's PID on success, null on spawn failure.
 *
 * @internal
 */
export function spawnDetachedSweep(projectRoot, options = {}) {
	const bin = options.binPath || resolveAptToolsBin()
	try {
		const child = spawn('node', [bin, 'task', 'close-merged', projectRoot], {
			cwd: projectRoot,
			env: {
				...process.env,
				APT_INTERNAL_MERGE_SWEEP: '1',
			},
			detached: true,
			stdio: 'ignore',
		})
		child.unref()
		return child.pid ?? null
	} catch {
		return null
	}
}

/**
 * Dispatch-time hook. Runs through:
 *   1. Suppression env → return.
 *   2. Recursion-guard env → return.
 *   3. SKIP_COMMANDS allowlist → return.
 *   4. Read shipped-pending-merge candidates (no lock, fast). None → return.
 *   5. Cooldown check via lease-file mtime → return if too recent.
 *   6. Filter out candidates whose worktree contains cwd. All deferred → return.
 *   7. Write lease (cooldown token).
 *   8. Spawn detached `task close-merged <root>` child with recursion-guard env.
 *   9. Print one stderr line so the user knows the sweep fired.
 *
 * Never throws. Returns `{ kicked, reason }` for tests; production callers
 * wrap in try/catch and ignore.
 *
 * @internal
 */
export function maybeSweepShippedMergedTasks(projectRoot, command, options = {}) {
	try {
		if (process.env.APT_SUPPRESS_MERGE_SWEEP === '1') {
			return { kicked: false, reason: 'suppressed' }
		}
		// Recursion guard #2 — child sets this so its own dispatcher noops.
		if (process.env.APT_INTERNAL_MERGE_SWEEP === '1') {
			return { kicked: false, reason: 'internal-recursion-guard' }
		}
		if (!command || SKIP_COMMANDS.has(command)) {
			return { kicked: false, reason: 'skip-command' }
		}
		const root = resolve(projectRoot)
		if (!existsSync(join(root, '.aperant'))) {
			return { kicked: false, reason: 'no-aperant-dir' }
		}

		// Narration-pending banner — fires on EVERY dispatch cycle when
		// the ledger has un-drained rows, regardless of whether a sweep is
		// kicked below. Cheap read, no spawn, no cooldown. Same suppression
		// env as the sweep banner (APT_SUPPRESS_MERGE_SWEEP=1 hides both).
		const pendingNarration = readPendingNarrationCount(root)
		if (pendingNarration > 0) {
			try {
				process.stderr.write(
					`[apt-tools] Post-merge narration pending (${pendingNarration}) — run /apt:close-task --narrate-only to refresh features registry + ROADMAP.\n`,
				)
			} catch {
				/* stderr write failure is non-fatal */
			}
		}

		const candidates = readShippedCandidates(root)
		if (!candidates) {
			return { kicked: false, reason: 'no-candidates', pending_narration: pendingNarration }
		}

		const lockPath = join(root, '.aperant', '.merge-sweep.lock')
		const cooldownMs = options.cooldownMs ?? getCooldownMs()
		if (isCooldownActive(lockPath, cooldownMs)) {
			return { kicked: false, reason: 'cooldown' }
		}

		const cwd = options.cwd ?? process.cwd()
		const { kept, deferred } = partitionByCwd(candidates, cwd)
		if (kept.length === 0) {
			return {
				kicked: false,
				reason: 'all-deferred-by-cwd',
				deferred,
			}
		}

		// Acquire the cooldown lease. If we can't write it, skip — better
		// to noop than to potentially fire two sweeps in quick succession.
		if (!writeSweepLease(lockPath)) {
			return { kicked: false, reason: 'lease-write-failed' }
		}

		// Spawn the detached child. The actual close work happens in the
		// child via the existing `task close-merged` command, which already
		// handles offline / rate-limit / unmerged-but-not-MERGED behavior.
		const pid = options.spawnFn
			? options.spawnFn(root)
			: spawnDetachedSweep(root, { binPath: options.binPath })
		if (pid === null) {
			return { kicked: false, reason: 'spawn-failed' }
		}

		try {
			process.stderr.write(
				`[apt-tools] Auto-sweeping ${kept.length} pending PR(s) — run 'apt-tools task close-merged --dry-run' to preview, or APT_SUPPRESS_MERGE_SWEEP=1 to silence.\n`,
			)
		} catch {
			/* stderr write failure is non-fatal */
		}

		return {
			kicked: true,
			reason: 'spawned',
			pid,
			candidate_count: kept.length,
			deferred,
		}
	} catch {
		// Belt-and-suspenders: never block user command on the sweep hook.
		return { kicked: false, reason: 'hook-error' }
	}
}
