/**
 * commands/ci-watch.mjs — Layer 1 cmd for `/apt:watch-ci` (C28).
 *
 * Subcommands: start | stop | status | tick
 *
 * Layer 1 rule: cmdCiWatch returns `{envelope, exitCode}` and MUST NOT call
 * output() / process.exit() / fail() directly. The Layer 2 wrapper in
 * dispatch.mjs is responsible for transport.
 *
 * `tick` is single-execution per invocation (D-01). The recurring beat is
 * delivered externally — by CC's CronCreate, Pi's pi-schedule-prompt, or by
 * the user re-invoking `--tick` on non-cron hosts. The cmd MUST NOT enter a
 * polling loop.
 *
 * Test seam: when `APT_TEST_GH_STUB` is set to a path on disk, the tick path
 * reads the gh payload from that file instead of shelling out. The stub
 * shape is `{ checks: [...], prMetadata: {...} }` — matches the classifier
 * input contract in stop-matrix.mjs.
 *
 * Production seam: when `--payload-file <path>` is passed, the tick path
 * reads the gh payload from that file. Same shape as the stub. The
 * orchestrator skill (apt-watch-ci §3) captures `gh pr checks ...` +
 * `gh pr view ...` into the payload file BEFORE invoking the cmd — the cmd
 * itself never shells out to gh.
 */

import { spawnSync } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { acquireLock, releaseLock } from '../ci-watch/lock.mjs'
import { deleteState, listWatches, readState, writeState } from '../ci-watch/state.mjs'
import { classify, isFailing } from '../ci-watch/stop-matrix.mjs'
import { parseFlags } from '../util/args.mjs'
import { err, exitWith, ok } from '../util/result.mjs'
import { requireMinVersion } from '../util/version-preflight.mjs'

/**
 * Installed kernel version — read once at module scope, same pattern as
 * commands/check-version.mjs:108-109. Used by the version-preflight in
 * `doTick` to fail loud (instead of infra-backoff-loop) when a future
 * framework fix introduces a new tick flag and the user is on a stale
 * install. See FRAMEWORK-BUG-017 rung 2 + the helper docstring for the
 * "future-guard" framing.
 */
const INSTALLED_KERNEL_VERSION = (() => {
	try {
		const here = dirname(fileURLToPath(import.meta.url))
		const pkgPath = resolve(here, '..', '..', '..', 'package.json')
		const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
		return typeof pkg?.version === 'string' ? pkg.version : null
	} catch {
		return null
	}
})()

const DEFAULT_TICK_INTERVAL_MS = 300_000 // 5 minutes
const MAX_TICK_INTERVAL_MS = 30 * 60 * 1000 // 30 min
const WALL_BUDGET_MS = 4 * 60 * 60 * 1000 // 4 hours

function newSessionId() {
	return randomBytes(8).toString('hex')
}

function nowIso() {
	return new Date().toISOString()
}

/**
 * Validate `--pr` is a positive-integer string before it gets interpolated
 * into a filesystem path. Without this guard, a value like `--pr ../escape`
 * would write the per-PR state/lock file outside `.aperant/ci-watches/`
 * (path traversal via `join(..., '../escape.json')`). PR numbers on GitHub
 * are positive integers, so digits-only is the correct shape.
 *
 * Returns the validated PR string on success, or an err() CommandResult on
 * failure — the caller returns it directly.
 *
 * @param {string|undefined} pr
 * @returns {string | import('../util/result.mjs').CommandResult}
 */
function validatePr(pr) {
	if (!pr) return err('Required: --pr <N>')
	if (!/^[1-9]\d*$/.test(pr)) {
		return err(`Invalid --pr value: ${JSON.stringify(pr)}. Expected a positive integer.`)
	}
	return pr
}

/**
 * @param {string} subcommand
 * @param {string} projectDir
 * @param {string[]} extraArgs
 */
export function cmdCiWatch(subcommand, projectDir, extraArgs) {
	if (!subcommand) {
		return err('Usage: apt-tools ci-watch <start|stop|status|tick|sweep> <project-dir> [flags]')
	}
	if (!projectDir) {
		return err(`Usage: apt-tools ci-watch ${subcommand} <project-dir> [flags]`)
	}

	const root = resolve(projectDir)
	const flags = parseFlags(extraArgs)

	switch (subcommand) {
		case 'start':
			return doStart(root, flags)
		case 'stop':
			return doStop(root, flags)
		case 'status':
			return doStatus(root, flags)
		case 'tick':
			return doTick(root, flags)
		case 'sweep':
			return err(
				'sweep is async; call cmdCiWatchSweep directly (the apt-tools dispatcher routes it separately)',
			)
		default:
			return err(
				`Unknown ci-watch subcommand: ${subcommand}. Expected: start, stop, status, tick, sweep`,
			)
	}
}

/**
 * Async sibling for the `sweep` subcommand (the rate-limited orphaned-state
 * walker). Split out from the sync `cmdCiWatch` dispatcher so the latter's
 * return type stays `CommandResult` (not a `CommandResult | Promise` union)
 * and the synchronous tests + callers don't need to be wrapped in `await`.
 *
 * @param {string} projectDir
 * @param {string[]} extraArgs
 * @returns {Promise<CommandResult>}
 */
export async function cmdCiWatchSweep(projectDir, extraArgs) {
	if (!projectDir) {
		return err('Usage: apt-tools ci-watch sweep <project-dir> [flags]')
	}
	const root = resolve(projectDir)
	const flags = parseFlags(extraArgs)
	return await doSweep(root, flags)
}

// ---------------------------------------------------------------------------
// start
// ---------------------------------------------------------------------------

function doStart(root, flags) {
	const prCheck = validatePr(flags.get('pr'))
	if (typeof prCheck !== 'string') return prCheck
	const pr = prCheck
	const taskId = flags.get('task-id')
	if (!taskId) return err('Required: --task-id <id>')
	const host = flags.get('host') || 'unknown'

	// Refuse to start a second watcher for the same PR — exit 2 with a
	// busy envelope so the caller knows the PR is already armed. The
	// orchestrator skill prints the holder's session and exits.
	const existingState = readState(root, pr)
	if (existingState && !existingState.error) {
		return exitWith(
			{
				status: 'busy',
				command: 'ci-watch-start',
				pr: Number(pr),
				reason: 'already-armed',
				scheduler_handle: existingState.scheduler_handle ?? null,
				existing: {
					task_id: existingState.task_id,
					host: existingState.host,
					attempts: existingState.attempts,
					started_at: existingState.started_at,
					deadline: existingState.deadline,
					scheduler_handle: existingState.scheduler_handle ?? null,
				},
			},
			2,
		)
	}

	const sessionId = newSessionId()
	const lockResult = acquireLock(root, pr, sessionId)
	if (lockResult.status !== 'ok') {
		return exitWith(
			{
				status: 'busy',
				command: 'ci-watch-start',
				pr: Number(pr),
				reason: 'lock-held',
				holder: lockResult.holder,
			},
			2,
		)
	}

	try {
		const existing = readState(root, pr)
		const existingObj = existing && !existing.error ? existing : {}

		const startedAt = existingObj.started_at || nowIso()
		const deadline = existingObj.deadline || new Date(Date.now() + WALL_BUDGET_MS).toISOString()
		// Snapshot auto_merge from config at start so config changes mid-watch don't
		// surprise the orchestrator (spec.md §8).
		const autoMergeFromConfig = readAutoMergeFromConfig(root)
		const state = {
			pr: Number(pr),
			task_id: taskId,
			host,
			scheduler_handle: existingObj.scheduler_handle ?? null,
			attempts: existingObj.attempts || 0,
			started_at: startedAt,
			deadline,
			last_poll: existingObj.last_poll || nowIso(),
			last_status: existingObj.last_status ?? null,
			next_tick_interval_ms: existingObj.next_tick_interval_ms || DEFAULT_TICK_INTERVAL_MS,
			paused: existingObj.paused || false,
			auto_merge_on_green: autoMergeFromConfig,
		}
		writeState(root, pr, state)
		return ok({
			status: 'ok',
			command: 'ci-watch-start',
			pr: Number(pr),
			host,
			scheduler_handle: state.scheduler_handle,
			deadline: state.deadline,
		})
	} finally {
		releaseLock(root, pr, sessionId)
	}
}

// ---------------------------------------------------------------------------
// stop
// ---------------------------------------------------------------------------

function doStop(root, flags) {
	const prCheck = validatePr(flags.get('pr'))
	if (typeof prCheck !== 'string') return prCheck
	const pr = prCheck
	const reason = flags.get('reason') || 'user-stop'

	const existing = readState(root, pr)
	if (!existing || existing.error) {
		// Idempotent — stopping a non-existent watch is a no-op.
		return ok({
			status: 'ok',
			command: 'ci-watch-stop',
			pr: Number(pr),
			scheduler_handle_removed: false,
			reason,
		})
	}
	const schedulerHandle = existing.scheduler_handle ?? null
	deleteState(root, pr)
	// Also remove any leftover lock file in case the watcher crashed mid-tick.
	const stopSessionId = newSessionId()
	const acq = acquireLock(root, pr, stopSessionId)
	if (acq.status === 'ok') {
		releaseLock(root, pr, stopSessionId)
	}

	return ok({
		status: 'ok',
		command: 'ci-watch-stop',
		pr: Number(pr),
		scheduler_handle_removed: schedulerHandle !== null,
		reason,
	})
}

// ---------------------------------------------------------------------------
// sweep — BUG-027 (c): manual GC for orphaned ci-watch state files.
//
// Enumerates all watcher state files via `listWatches(root)`, shells out
// to `gh pr view --json state` for each one (rate-limited to ≤5/sec),
// and removes the state files whose PR state is MERGED or CLOSED. Never
// auto-runs on startup — opt-in manual subcommand only, per brief.
// Honors APT_GH_FIXTURE_JSON for hermetic tests (returns the same body
// for every PR — fine since the test fixtures we ship cover each
// branch in isolation).
// ---------------------------------------------------------------------------

async function doSweep(root, flags) {
	if (flags.get('orphaned') !== true) {
		return err(
			'ci-watch sweep currently supports only `--orphaned`. Usage: apt-tools ci-watch sweep <project-dir> --orphaned',
		)
	}
	const dryRun = flags.get('dry-run') === true
	const watches = listWatches(root)
	const removed = []
	const skipped = []
	const ghFixture = process.env.APT_GH_FIXTURE_JSON
	for (let i = 0; i < watches.length; i++) {
		const watch = watches[i]
		const pr = watch.pr
		const prUrl = watch.pr_url ?? null
		// Validate pr_url shape before passing to gh: must be an https:// URL
		// or absent. An invalid value (e.g. a flag-injection string from a
		// tampered state file) falls back to the integer pr number.
		const safeUrl =
			typeof prUrl === 'string' && /^https:\/\/.+/.test(prUrl) && !prUrl.startsWith('-')
				? prUrl
				: null
		// Need a PR URL to call `gh pr view`; if absent, fall back to the
		// integer pr arg which gh accepts for the active repo.
		const target = safeUrl ?? String(pr)
		let body = null
		if (ghFixture) {
			body = ghFixture
		} else {
			try {
				const res = spawnSync('gh', ['pr', 'view', target, '--json', 'state'], {
					cwd: root,
					encoding: 'utf-8',
				})
				if (res.status === 0 && res.stdout) body = res.stdout
			} catch {
				/* gh transport error — skip this entry */
			}
		}
		if (!body) {
			skipped.push({ pr, reason: 'gh-unavailable' })
			continue
		}
		let parsed
		try {
			parsed = JSON.parse(body)
		} catch {
			skipped.push({ pr, reason: 'malformed-gh-json' })
			continue
		}
		const state = parsed?.state ?? null
		if (state !== 'MERGED' && state !== 'CLOSED') {
			skipped.push({ pr, reason: `pr-state-${state ?? 'unknown'}` })
			continue
		}
		if (!dryRun) deleteState(root, pr)
		removed.push({ pr, pr_state: state })
		// Rate-limit ≤5/sec per brief — sleep 200ms between live gh calls.
		// Use async setTimeout (non-blocking) rather than Atomics.wait.
		if (!ghFixture && i < watches.length - 1) {
			await new Promise((resolve) => setTimeout(resolve, 200))
		}
	}
	return ok({
		status: 'ok',
		command: 'ci-watch-sweep',
		dry_run: dryRun,
		removed,
		skipped,
		total_watches: watches.length,
	})
}

// ---------------------------------------------------------------------------
// status
// ---------------------------------------------------------------------------

function doStatus(root, flags) {
	if (flags.has('pr')) {
		const prCheck = validatePr(flags.get('pr'))
		if (typeof prCheck !== 'string') return prCheck
		const pr = prCheck
		const s = readState(root, pr)
		if (!s || s.error) {
			return ok({ status: 'ok', command: 'ci-watch-status', watches: [] })
		}
		return ok({
			status: 'ok',
			command: 'ci-watch-status',
			watches: [summarize(s)],
		})
	}
	const watches = listWatches(root).map(summarize)
	return ok({ status: 'ok', command: 'ci-watch-status', watches })
}

function summarize(s) {
	return {
		pr: s.pr,
		attempts: s.attempts,
		last_status: s.last_status,
		deadline: s.deadline,
		last_poll: s.last_poll,
		paused: s.paused,
	}
}

// ---------------------------------------------------------------------------
// tick
// ---------------------------------------------------------------------------

function doTick(root, flags) {
	// FRAMEWORK-BUG-017 rung 2 (future-guard): emit a structured
	// version_drift envelope when a future framework fix requires a
	// new tick flag and the installed kernel predates it. The current
	// pin to 0.6.7 (the version that introduced --payload-file) is a
	// no-op on every 0.6.7+ install — but the scaffolding is in place
	// for the NEXT new-flag fix to bump `introducedIn` without
	// re-discovering the silent-infra-backoff failure mode.
	const versionGate = requireMinVersion({
		feature: 'payload-file',
		introducedIn: '0.6.7',
		installedVersion: INSTALLED_KERNEL_VERSION,
		missingFlag: '--payload-file',
	})
	if (versionGate) return exitWith(versionGate, 1)

	const prCheck = validatePr(flags.get('pr'))
	if (typeof prCheck !== 'string') return prCheck
	const pr = prCheck

	const sessionId = newSessionId()
	const lockResult = acquireLock(root, pr, sessionId)
	if (lockResult.status !== 'ok') {
		return exitWith(
			{
				status: 'busy',
				command: 'ci-watch-tick',
				pr: Number(pr),
				reason: 'lock-held',
				holder: lockResult.holder,
			},
			2,
		)
	}

	try {
		const state = readState(root, pr)
		if (!state || state.error) {
			return err(`No active watch for PR ${pr} — run \`ci-watch start\` first`)
		}
		if (state.paused) {
			return ok({
				status: 'stopped',
				command: 'ci-watch-tick',
				pr: Number(pr),
				action: 'reviewer-pause',
				attempts_used: state.attempts,
				reason: 'watch is paused (reviewer-pause); resume with /apt:watch-ci --resume',
			})
		}

		const payloadFile = flags.get('payload-file')
		const payload = payloadFile ? loadPayloadFile(resolve(root, payloadFile)) : fetchGhPayload(pr)
		if (payload.error) {
			// gh CLI not available / auth failed → infra-backoff. Don't count it,
			// double the next-tick interval (capped at MAX_TICK_INTERVAL_MS), and reschedule.
			state.last_poll = nowIso()
			state.next_tick_interval_ms = Math.min(
				(state.next_tick_interval_ms || DEFAULT_TICK_INTERVAL_MS) * 2,
				MAX_TICK_INTERVAL_MS,
			)
			writeState(root, pr, state)
			return ok({
				status: 'ok',
				command: 'ci-watch-tick',
				pr: Number(pr),
				action: 'infra-backoff',
				attempts_used: state.attempts,
				next_tick_at: nextTickAt(state),
				reason: `gh payload fetch failed: ${payload.error}`,
			})
		}

		const result = classify({
			checks: payload.checks,
			state,
			prMetadata: payload.prMetadata,
		})
		const { action, counts_toward_budget, reason } = result

		// Apply action-specific state mutation.
		if (action === 'green' || action === 'merged' || action === 'closed') {
			// Terminal — delete state before releasing lock so no concurrent tick
			// can slip in between the release and the delete.
			deleteState(root, pr)
			releaseLock(root, pr, sessionId)
			return ok({
				status: 'stopped',
				command: 'ci-watch-tick',
				pr: Number(pr),
				action,
				attempts_used: state.attempts,
				reason,
			})
		}
		if (action === 'attempts-cap' || action === 'wall-cap' || action === 'identical-log') {
			deleteState(root, pr)
			releaseLock(root, pr, sessionId)
			return ok({
				status: 'stopped',
				command: 'ci-watch-tick',
				pr: Number(pr),
				action,
				attempts_used: state.attempts,
				reason,
			})
		}
		if (action === 'reviewer-pause') {
			state.paused = true
			state.last_poll = nowIso()
			writeState(root, pr, state)
			return ok({
				status: 'stopped',
				command: 'ci-watch-tick',
				pr: Number(pr),
				action,
				attempts_used: state.attempts,
				reason,
			})
		}
		if (action === 'flake-skip') {
			state.last_poll = nowIso()
			// Reset interval on a clean (non-infra) tick.
			state.next_tick_interval_ms = DEFAULT_TICK_INTERVAL_MS
			writeState(root, pr, state)
			return ok({
				status: 'ok',
				command: 'ci-watch-tick',
				pr: Number(pr),
				action,
				attempts_used: state.attempts,
				next_tick_at: nextTickAt(state),
				reason,
			})
		}
		if (action === 'infra-backoff') {
			state.last_poll = nowIso()
			state.next_tick_interval_ms = Math.min(
				(state.next_tick_interval_ms || DEFAULT_TICK_INTERVAL_MS) * 2,
				MAX_TICK_INTERVAL_MS,
			)
			writeState(root, pr, state)
			return ok({
				status: 'ok',
				command: 'ci-watch-tick',
				pr: Number(pr),
				action,
				attempts_used: state.attempts,
				next_tick_at: nextTickAt(state),
				reason,
			})
		}
		if (action === 'wait') {
			state.last_poll = nowIso()
			state.next_tick_interval_ms = DEFAULT_TICK_INTERVAL_MS
			writeState(root, pr, state)
			return ok({
				status: 'ok',
				command: 'ci-watch-tick',
				pr: Number(pr),
				action,
				attempts_used: state.attempts,
				next_tick_at: nextTickAt(state),
				reason,
			})
		}
		// fix / diverged-rebased / diverged-bailed — all count toward budget.
		if (counts_toward_budget) {
			state.attempts = (state.attempts || 0) + 1
		}
		state.last_poll = nowIso()
		state.next_tick_interval_ms = DEFAULT_TICK_INTERVAL_MS
		// Track the failed-check fingerprint aggregate for next-tick identical-log detection.
		const failed = (payload.checks || []).filter(isFailing)
		state.last_status =
			failed.length === 0
				? null
				: failed
						.map((f) => `${f.name}:${f.log_fingerprint ?? ''}`)
						.sort()
						.join('|')
		writeState(root, pr, state)
		return ok({
			status: 'ok',
			command: 'ci-watch-tick',
			pr: Number(pr),
			action,
			attempts_used: state.attempts,
			next_tick_at: nextTickAt(state),
			reason,
		})
	} finally {
		// `try/finally` so every branch releases the lock — even thrown errors.
		// The terminal branches above release explicitly before deleting state
		// (releaseLock no-ops on a missing file, so calling it twice is safe).
		releaseLock(root, pr, sessionId)
	}
}

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------

function nextTickAt(state) {
	return new Date(
		Date.now() + (state.next_tick_interval_ms || DEFAULT_TICK_INTERVAL_MS),
	).toISOString()
}

/**
 * Fetch the gh CLI payload OR read the test-stub JSON. Returns
 *   { checks, prMetadata } on success
 *   { error: '<message>' } when gh / stub failed.
 *
 * Note: this is a thin wrapper — the orchestrator skill is responsible for
 * the actual `gh run view --log` + fix-CLI shell-out (spec.md §5.5). The
 * cmd's tick only reads checks + classifies + updates state; the heavy
 * fix-loop work happens in the skill body.
 */
function fetchGhPayload(_pr) {
	const isTestRuntime =
		process.env.NODE_ENV === 'test' ||
		process.env.VITEST != null ||
		process.env.APT_INTERNAL_TEST != null
	const stub = isTestRuntime ? process.env.APT_TEST_GH_STUB : undefined
	if (stub) {
		try {
			if (!existsSync(stub)) {
				return { error: 'APT_TEST_GH_STUB path not found' }
			}
			const data = JSON.parse(readFileSync(stub, 'utf-8'))
			return {
				checks: data.checks || [],
				prMetadata: data.prMetadata || { state: 'OPEN' },
			}
		} catch (_e) {
			return { error: 'stub parse error' }
		}
	}
	// Real gh shell-out lives in the skill body. The cmd's tick treats
	// "no stub + no payload" as infra-backoff so unit tests can exercise the
	// path without a live gh binary. The orchestrator skill is responsible
	// for pre-fetching and re-invoking with APT_TEST_GH_STUB set (tests) OR
	// `--payload-file <path>` (production — see loadPayloadFile below).
	return {
		error:
			'gh shell-out is the skill orchestrator boundary — set APT_TEST_GH_STUB for unit tests or pass --payload-file for production',
	}
}

/**
 * Read a gh payload from disk for the production path (FRAMEWORK-BUG-010).
 * Returns the same shape as fetchGhPayload's stub branch on success, or an
 * { error } envelope on missing path / parse failure — the existing
 * infra-backoff branch surfaces the error cleanly.
 *
 * @param {string} path absolute or already-resolved path
 */
function loadPayloadFile(path) {
	try {
		if (!existsSync(path)) {
			return { error: `payload-file path not found: ${path}` }
		}
		const data = JSON.parse(readFileSync(path, 'utf-8'))
		return {
			checks: data.checks || [],
			prMetadata: data.prMetadata || { state: 'OPEN' },
		}
	} catch (e) {
		return { error: `payload-file parse error: ${e.message}` }
	}
}

/**
 * Read `ci_watch.auto_merge_on_green` from `.aperant/config.json` (default
 * false). Best-effort — missing file or unparsable JSON returns false.
 */
function readAutoMergeFromConfig(root) {
	try {
		const path = resolve(root, '.aperant', 'config.json')
		if (!existsSync(path)) return false
		const parsed = JSON.parse(readFileSync(path, 'utf-8'))
		return parsed?.ci_watch?.auto_merge_on_green === true
	} catch {
		return false
	}
}
