/**
 * commands/route.mjs — router-0.3.0 LLM-first context provider.
 *
 * Replaces the pre-0.3.0 code-first classifier. The new router:
 *
 *   - Scans the filesystem for installed skills (skill-discover.mjs)
 *     and emits them in a thin envelope (~3 KB, 8 KB cap). The host
 *     LLM classifies from the envelope — the router never keyword-
 *     matches free text or carries a hardcoded skill set.
 *
 *   - Keeps every deterministic branch unchanged: --quick/--deep/--debug
 *     flag overrides, empty input + no config = init, empty input +
 *     config = status, `help`, skill-passthrough (when the first word
 *     of free text is the name of an installed skill).
 *
 *   - Hard-errors on unknown host with a structured envelope (spec §2.2)
 *     when free text is in flight and detectHost() returns `cli:
 *     'unknown'`. Deterministic branches are unaffected.
 *
 * See CHANGELOG.md 0.3.0 for the list of tables + mode branches that
 * were deleted in this rewrite. See
 * `.aperant/context/notes/router-llm-first-redesign.md` for the locked
 * decisions (D-01..D-09) and the rationalization tables behind each
 * choice.
 */

import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
	getTaskIsolationConfig,
	getTrustedSkillSources,
	loadMergedProjectConfig,
} from '../config/load.mjs'
import '../gate/gates/index.mjs'
import * as gateRegistry from '../gate/registry.mjs'
import { detectHost, SUPPORTED_HOSTS } from '../host/detect.mjs'
import { detectSourceWorkspaceDrift } from '../route/drift-detect.mjs'
import { buildEnvelope } from '../route/envelope.mjs'
import { discoverSkills } from '../route/skill-discover.mjs'
import { executionModesList, isValidExecutionMode } from '../task/execution-mode.mjs'
import { err, exitWith, ok } from '../util/result.mjs'

/**
 * Read the installed kernel's own version from its package.json.
 * Same pattern as commands/check-version.mjs:108-109 — `__dirname` of
 * the running module resolves to the installed kernel for npm-installed
 * users and to the source workspace for in-monorepo runs. Returns null
 * on any I/O / parse failure (drift-detect treats null as "skip").
 */
function readInstalledKernelVersion() {
	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
	}
}

/**
 * Read the shared update-check cache (written by the SessionStart hook
 * via apt-check-update-worker.js) and extract the fields /apt's SKILL.md
 * needs to decide whether to render the update chip. Pure cache read —
 * never touches the network, so this is safe on the /apt hot path.
 */
function readUpdateCheckCache() {
	const cachePath = join(homedir(), '.cache', 'aperant', 'apt-update-check.json')
	if (!existsSync(cachePath)) return null
	let data
	try {
		data = JSON.parse(readFileSync(cachePath, 'utf-8'))
	} catch {
		return null
	}
	const installed = data?.framework?.installed_version ?? null
	const latest = data?.framework?.latest_version ?? null
	if (data.update_available === true && installed && latest) {
		return {
			update_available: true,
			installed_version: installed,
			latest_version: latest,
			reason: 'newer version on npm',
		}
	}
	if (data.stale_files_total && data.stale_files_total > 0) {
		return {
			update_available: true,
			installed_version: installed,
			latest_version: latest,
			reason: `${data.stale_files_total} stale file(s) detected`,
		}
	}
	return null
}

/**
 * Deterministic mapping from an explicit track flag (--quick/--deep/--debug)
 * to its canonical {track, primary_skill} pair. Flag overrides are user
 * intent, so no classification or keyword-matching is involved — we just
 * honor the flag. Free-text inputs (no override) are classified by the
 * host LLM via the envelope.
 */
function trackFromOverride(trackOverride) {
	if (trackOverride === 'DEBUG') return { track: 'DEBUG', primary_skill: 'debug' }
	if (trackOverride === 'QUICK') return { track: 'QUICK', primary_skill: 'quick' }
	if (trackOverride === 'DEEP') return { track: 'DEEP', primary_skill: 'plan' }
	return null
}

/**
 * Build a purely-informational `gate_preview` for the resolved skill, or
 * null when the skill has no registered gates. This is NOT enforcement —
 * enforcement happens inside the SKILL.md Section 0 call to `gate check`.
 */
function previewGatesFor(skill) {
	if (!skill || typeof skill !== 'string') return null
	const gates = gateRegistry.listGates({ skill })
	if (gates.length === 0) return null
	return gates.map((g) => ({ id: g.id, severity: g.severity }))
}

function normalizeAptInvocation(input) {
	if (!input || typeof input !== 'string') return ''
	let out = input.trim()
	const match = out.match(/^[$/]?apt(?=[:\s]|$)(?::|\s+)?/i)
	if (!match) return out
	out = out.slice(match[0].length).trim()
	return out
}

/**
 * PR-review freetext signal used by the skill-passthrough carve-out below.
 * Hoisted to module scope so the regex is compiled once at load time
 * (cheaper than re-declaring on every cmdRoute call) and so reviewers see
 * the pattern upfront. Matches `PR 42` / `PR#42` / `#7` / `pull request 99`.
 * All three arms require at least one digit — the digit anchor keeps the
 * regex tight so pedestrian phrases like `review pull request docs` (no
 * digit, not a PR reference) fall through to apt:review passthrough.
 * Leading `\bPR` / `\bpull` guard against matches inside longer words
 * (`PRs`, `prelude`, `pulling`); the `#\d+` arm has no leading `\b`
 * because `#` is not a word char — `\b#` only matches when preceded by
 * a word char, which would exclude the bare-hashtag case `review #7`.
 */
const PR_REVIEW_SIGNAL = /(?:\bPR\s*#?\s*\d+|#\d+|\bpull\s*request\s*#?\s*\d+)\b/i

/**
 * Canonical skill-slug index — reads every skill's agent_name and
 * spawns_agent flag from its frontmatter so the router doesn't carry a
 * hardcoded lookup table. Keyed on the slug (`plan`, `execute`, ...),
 * not the full `apt:plan` form.
 *
 * `knownSlugs` contains only **user-invocable** skills (frontmatter
 * `user_invocable: true`). Internal skills like `apt:classify` are
 * indexed for programmatic lookup (agentOf / spawnsAgent) but are
 * deliberately NOT surfaced in skill-passthrough matching — typing
 * `/apt classify ...` must not dispatch to an internal helper that
 * declared `user_invocable: false`. The host-LLM classify path also
 * hides internal skills (envelope.mjs filters them out of skills[]),
 * so this keeps the two paths consistent.
 *
 * @param {import('../route/skill-discover.mjs').DiscoveredSkill[]} skills
 * @returns {{ agentOf: (slug: string) => string|null, spawnsAgent: (slug: string) => boolean, taskContextOf: (slug: string) => string|null, defaultTrackOf: (slug: string) => string|null, knownSlugs: Set<string>, isUserInvocable: (slug: string) => boolean }}
 */
function skillIndex(skills) {
	const byName = new Map()
	for (const s of skills) {
		// Skill names are `apt:plan` / `apt` (router). We index by the
		// slug (`plan`) because that's what the skill-passthrough branch
		// compares against the first word of free text.
		const slug = s.name === 'apt' ? 'apt' : s.name.replace(/^apt:/, '')
		byName.set(slug, s)
	}
	const userInvocableSlugs = new Set()
	for (const [slug, s] of byName) {
		if (s.user_invocable !== false) userInvocableSlugs.add(slug)
	}
	return {
		agentOf(slug) {
			return byName.get(slug)?.agent_name ?? null
		},
		spawnsAgent(slug) {
			return Boolean(byName.get(slug)?.spawns_agent)
		},
		taskContextOf(slug) {
			return byName.get(slug)?.task_context ?? null
		},
		defaultTrackOf(slug) {
			return byName.get(slug)?.default_track ?? null
		},
		isUserInvocable(slug) {
			return userInvocableSlugs.has(slug)
		},
		knownSlugs: userInvocableSlugs,
	}
}

export function cmdRoute(projectDir, extraArgs) {
	if (!projectDir)
		return err('Usage: apt-tools route <project-dir> --input "user text" [--flags ...]')
	const targetDir = resolve(projectDir)

	let rawInput = ''
	let executionModeOverride = null
	let runtimeOverride = null
	const flags = []
	for (let i = 0; i < extraArgs.length; i++) {
		if (extraArgs[i] === '--input' && extraArgs[i + 1]) {
			rawInput = extraArgs[++i]
		} else if (extraArgs[i] === '--execution-mode' && extraArgs[i + 1]) {
			executionModeOverride = extraArgs[++i]
		} else if (extraArgs[i] === '--runtime' && extraArgs[i + 1]) {
			runtimeOverride = extraArgs[++i]
		} else if (extraArgs[i].startsWith('--')) {
			flags.push(extraArgs[i].replace(/^--/, ''))
		}
	}

	if (executionModeOverride !== null && !isValidExecutionMode(executionModeOverride)) {
		return err(
			`Invalid --execution-mode: "${executionModeOverride}". Expected one of: ${executionModesList()}`,
		)
	}

	// --runtime <id> override (completes D-02 unknown-host hint). Validate
	// against the same SUPPORTED_HOSTS list we advertise in the unknown-host
	// error envelope — if the flag is present but invalid, emit a structured
	// error instead of silently falling back to auto-detection (silent
	// fallback would mask the typo and produce surprising routing).
	if (runtimeOverride !== null && !SUPPORTED_HOSTS.includes(runtimeOverride)) {
		// Sanitize the user-supplied value before interpolation to prevent
		// log-injection when consumers render the `reason` field as raw text
		// (SEC-003). Strip anything that isn't alphanumeric, hyphen, or dot.
		const safeRuntime = runtimeOverride.replace(/[^a-zA-Z0-9.-]/g, '_')
		return exitWith(
			{
				status: 'error',
				command: 'route',
				error_code: 'invalid_runtime_override',
				reason: `--runtime ${safeRuntime} must be one of: ${SUPPORTED_HOSTS.join(', ')}`,
			},
			1,
		)
	}

	let autonomy_override = null
	let track_override = null
	const flagWords = rawInput.match(/--(\w+)/g) || []
	for (const fw of flagWords) {
		const f = fw.replace('--', '')
		if (f === 'supervised') autonomy_override = 0
		else if (f === 'guided') autonomy_override = 1
		else if (f === 'autonomous') autonomy_override = 2
		else if (f === 'yolo') autonomy_override = 3
		else if (f === 'quick') track_override = 'QUICK'
		else if (f === 'deep') track_override = 'DEEP'
		else if (f === 'debug') track_override = 'DEBUG'
	}
	for (const f of flags) {
		if (f === 'supervised') autonomy_override = 0
		else if (f === 'guided') autonomy_override = 1
		else if (f === 'autonomous') autonomy_override = 2
		else if (f === 'yolo') autonomy_override = 3
		else if (f === 'quick') track_override = 'QUICK'
		else if (f === 'deep') track_override = 'DEEP'
		else if (f === 'debug') track_override = 'DEBUG'
	}

	// FRAMEWORK-RFC-001 — consent signal for spar pipeline auto-inject.
	// Reads merged config (config.json ∪ config.local.json) via the same
	// helper apt-setup writes through. AUDIT-001 — spar fields live in
	// .aperant/config.local.json, not shared config. Resolved once and
	// folded into the envelope by both classify branches below.
	// `loadMergedProjectConfig` returns null when no shared config exists
	// (first-time init path) — treat as consent-off + no partner.
	const merged = loadMergedProjectConfig(targetDir) ?? {}
	const sparEnabled = merged.spar?.enabled === true
	const sparPartner = merged.spar?.partner ?? null
	const userPassedExplicitSparGates =
		rawInput.includes('--spar-gates') || flags.some((f) => f.startsWith('spar-gates'))

	const taskDescription = normalizeAptInvocation(rawInput.replace(/--\w+/g, '').trim())

	const configPath = join(targetDir, '.aperant', 'config.json')
	const hasConfig = existsSync(configPath)

	const routeHost = detectHost(runtimeOverride ? { overrideCli: runtimeOverride } : {})
	const host_capabilities = routeHost.capabilities
	const task_isolation = getTaskIsolationConfig(targetDir)

	const updateCheck = readUpdateCheckCache()
	let update_check = updateCheck ? { ...updateCheck } : null

	// FRAMEWORK-BUG-017 rung 1: in-band detection of workspace-ahead-of-
	// installed-kernel drift. Catches the self-host contributor case
	// where the SessionStart worker can't see the local same-PR fix yet.
	// Workspace wins when both signals fire (workspace is the source of
	// truth for self-host).
	const installedKernelVersion = readInstalledKernelVersion()
	const drift = detectSourceWorkspaceDrift(targetDir, installedKernelVersion)
	if (drift) {
		update_check = {
			update_available: true,
			installed_version: drift.installed_version,
			latest_version: drift.source_version,
			reason: 'workspace source ahead of installed kernel — run /apt:update --from-source',
		}
	}

	// ---------------- Deterministic branches (zero-behavior-change) ----------------
	// These fire regardless of host detection status — missing host is not
	// fatal for init/status/help/skill-passthrough.

	if (!taskDescription && !hasConfig) {
		return ok({
			status: 'ok',
			command: 'route',
			mode: 'init',
			spawn_agent: false,
			host_capabilities,
			task_isolation,
			update_check,
			reason: 'No .aperant/config.json found — first-time setup required',
		})
	}

	if (!taskDescription && hasConfig) {
		return ok({
			status: 'ok',
			command: 'route',
			mode: 'status',
			spawn_agent: false,
			host_capabilities,
			task_isolation,
			update_check,
			reason: 'No input provided — showing project status',
		})
	}

	if (taskDescription.toLowerCase() === 'help') {
		return ok({
			status: 'ok',
			command: 'route',
			mode: 'help',
			spawn_agent: false,
			host_capabilities,
			task_isolation,
			update_check,
			reason: 'Help requested',
		})
	}

	// Discover skills once — used for both the skill-passthrough lookup
	// and the classify envelope. Trust boundary enforced via
	// getTrustedSkillSources() which resolves the allowlisted roots for
	// this host + targetDir.
	const trustedSkillSources = getTrustedSkillSources(targetDir, routeHost.cli)
	const discovered = discoverSkills({
		targetDir,
		trustedSkillSources,
	})
	const index = skillIndex(discovered.skills)

	// ---------------- Skill passthrough ----------------
	const firstWord = taskDescription.split(/\s+/)[0].toLowerCase()
	const remainingArgs = taskDescription.split(/\s+/).slice(1).join(' ')

	// Carve-out: `review PR 42` / `review #7` / `review pull request 99` are
	// natural-language PR-review requests, NOT invocations of the task-level
	// apt:review skill. Without this guard the knownSlugs match on "review"
	// below silently spawns apt-reviewer (wrong agent) before the classify
	// envelope ever reaches the host LLM. The digit anchor keeps the regex
	// tight — "review pr-review usage docs" and "review src/foo.ts" still
	// passthrough to apt:review unchanged. See
	// src/__tests__/route-pr-review-skill.test.ts for the full contract.
	// The PR_REVIEW_SIGNAL regex itself is defined at module scope above.
	const isPrReviewFreetext = firstWord === 'review' && PR_REVIEW_SIGNAL.test(remainingArgs)

	if (!isPrReviewFreetext && index.knownSlugs.has(firstWord)) {
		const shouldSpawn = index.spawnsAgent(firstWord)
		const gate_preview = previewGatesFor(firstWord)
		const defaultTrack = index.defaultTrackOf(firstWord)
		return ok({
			status: 'ok',
			command: 'route',
			mode: 'skill-passthrough',
			skill: firstWord,
			skill_args: remainingArgs || null,
			spawn_agent: shouldSpawn,
			agent: shouldSpawn ? index.agentOf(firstWord) || `apt-${firstWord}` : null,
			task_context: index.taskContextOf(firstWord),
			...(defaultTrack ? { default_track: defaultTrack } : {}),
			host_capabilities,
			task_isolation,
			update_check,
			...(gate_preview ? { gate_preview } : {}),
			reason: `Direct skill invocation: apt:${firstWord}`,
		})
	}

	// ---------------- Flag-override classify ----------------
	// Explicit --quick / --deep / --debug short-circuit the LLM
	// classifier — the user already told us the track.
	let execution_mode = 'ask'
	if (autonomy_override === 3 || autonomy_override === 2) execution_mode = 'auto'
	else if (autonomy_override === 0 || autonomy_override === 1) execution_mode = 'step'
	if (executionModeOverride) execution_mode = executionModeOverride

	if (track_override) {
		const overrideResult = trackFromOverride(track_override)
		const track = overrideResult.track
		const primarySkill = overrideResult.primary_skill
		const spawnAgent = track !== 'QUICK' && track !== 'DEBUG'
		const agentName = spawnAgent ? index.agentOf(primarySkill) || 'apt-planner' : null
		const options = buildTrackOptions(track)
		const gate_preview = previewGatesFor(primarySkill)
		// FRAMEWORK-RFC-001 — flag-override path: track is known deterministically,
		// so we can resolve the spar gate string inline and emit it directly.
		// QUICK/DEBUG are Fast-Path-exempt — no inject. User-passed --spar-gates
		// wins over consent (no double-inject). Field omitted entirely when null
		// so the envelope stays byte-equivalent for users who never opted in.
		let spar_inject_gates = null
		if (sparEnabled && !userPassedExplicitSparGates) {
			if (track === 'STANDARD') spar_inject_gates = 'plan'
			else if (track === 'DEEP') spar_inject_gates = 'plan,execute'
			// QUICK / DEBUG → stays null
		}
		return ok({
			status: 'ok',
			command: 'route',
			mode: 'classify',
			classification_source: 'flag-override',
			spawn_agent: spawnAgent,
			agent: agentName,
			track,
			primary_skill: primarySkill,
			execution_mode,
			autonomy_override,
			options,
			task_description: taskDescription,
			needs_autonomy_prompt: execution_mode === 'ask',
			host_capabilities,
			task_isolation,
			update_check,
			...(gate_preview ? { gate_preview } : {}),
			...(spar_inject_gates ? { spar_inject_gates } : {}),
			classification: {
				source: 'flag-override',
				reason: `Explicit --${track_override.toLowerCase()} flag`,
			},
			reason: `Implementation task detected — ${track} track (flag-override)`,
		})
	}

	// ---------------- Unknown-host guard (spec §2.2) ----------------
	// Only fires when free-text is in flight AND detectHost couldn't
	// identify a runtime. The 14 recognized runtimes plus the
	// deterministic branches mean this path is a genuine error, not a
	// first-run surprise.
	if (routeHost.cli === 'unknown') {
		return exitWith(
			{
				status: 'error',
				command: 'route',
				error_code: 'unknown_host',
				reason: 'Detected no known AI CLI host. Run /apt from a supported runtime.',
				supported_hosts: [...SUPPORTED_HOSTS],
				hint: 'If you are in a supported runtime, run `apt-tools route --runtime <id>` to override detection (replace <id> with one of the supported_hosts values above). Then open a framework issue with the runtime name and CLI version — do NOT paste env vars publicly.',
			},
			1,
		)
	}

	// ---------------- Host-LLM classify (main path) ----------------
	// Build the envelope and hand it to the host model via apt/SKILL.md.
	// The host picks a skill from envelope.skills; existing dispatch
	// (skill-passthrough / agent-spawn) rejects unknown picks as a
	// natural backstop (D-03).
	let envelope
	try {
		envelope = buildEnvelope({
			skills: discovered.skills,
			scannedRoots: discovered.scanned_roots,
			hostCapabilities: host_capabilities,
			taskIsolation: task_isolation,
			updateCheck: update_check,
			deterministicContext: {
				task_description: taskDescription,
				autonomy_override,
				execution_mode,
				needs_autonomy_prompt: execution_mode === 'ask',
				classification: {
					source: 'host-model-deferred',
					reason:
						'Track and skill classification deferred to the host LLM; see classification_meta.trust_notice.',
				},
				// FRAMEWORK-RFC-001 — host model composes --spar-gates from these
				// signals + its track classification per apt/SKILL.md MODE: classify
				// Step 4. Field is absent when consent is off (envelope stays slim).
				...(sparEnabled
					? {
							spar_consent: {
								enabled: true,
								partner: sparPartner,
								user_passed_explicit_gates: userPassedExplicitSparGates,
							},
						}
					: {}),
			},
		})
	} catch (e) {
		// Over-budget envelope is a ship blocker — fail the build.
		return exitWith(
			{
				status: 'error',
				command: 'route',
				error_code: 'skill_description_too_long',
				reason: e instanceof Error ? e.message : String(e),
			},
			1,
		)
	}

	return ok(envelope)
}

/**
 * Build track-aware autonomy options. Extracted so both the flag-override
 * path (here) and the SKILL.md (inline) can present consistent options.
 */
export function buildTrackOptions(track) {
	const options = []
	if (track === 'QUICK') {
		options.push(
			{
				key: 'auto',
				label: 'Just do it',
				description: 'Straight to implementation, no planning',
				pipeline: ['quick', 'verify'],
			},
			{
				key: 'step',
				label: 'Step by step',
				description: "I'll show you what I'm doing as I go",
				pipeline: ['quick'],
			},
		)
	} else if (track === 'DEBUG') {
		options.push(
			{
				key: 'auto',
				label: 'Investigate',
				description: 'Systematic OBSERVE → HYPOTHESIZE → TEST → CONCLUDE',
				pipeline: ['debug'],
			},
			{
				key: 'step',
				label: 'Step by step',
				description: 'Check in at each debug phase',
				pipeline: ['debug'],
			},
		)
	} else if (track === 'STANDARD') {
		options.push(
			{
				key: 'auto',
				label: 'Full auto',
				description: 'Plan → execute → verify → review, stopping only for critical risks',
				pipeline: ['plan', 'execute', 'verify', 'review'],
			},
			{
				key: 'step',
				label: 'Step by step',
				description: 'Plan first, then ask at each stage',
				pipeline: ['plan'],
			},
			{
				key: 'plan-mode',
				label: 'Pause for approval — plan → approve → execute/verify/review',
				description: "I'll plan, wait for your approval, then build",
				pipeline: ['plan', 'execute', 'verify', 'review'],
			},
			{
				key: 'research',
				label: 'Research first',
				description: 'Discuss assumptions and gray areas before planning',
				pipeline: ['discuss', 'plan'],
			},
			{
				key: 'plan-only',
				label: 'Just plan',
				description: "Show me the plan, I'll decide from there",
				pipeline: ['plan'],
			},
		)
	} else {
		// DEEP
		options.push(
			{
				key: 'auto',
				label: 'Full auto',
				description: 'Discuss → plan → execute → verify → review',
				pipeline: ['discuss', 'plan', 'execute', 'verify', 'review'],
			},
			{
				key: 'step',
				label: 'Step by step',
				description: "I'll check in at each stage",
				pipeline: ['plan'],
			},
			{
				key: 'plan-mode',
				label: 'Pause for approval — plan → approve → execute/verify/review',
				description: "I'll discuss, plan, wait for your approval, then build",
				pipeline: ['discuss', 'plan', 'execute', 'verify', 'review'],
			},
			{
				key: 'research',
				label: 'Research first',
				description: '10-agent roundtable debate before planning',
				pipeline: ['roundtable', 'plan'],
			},
			{
				key: 'plan-only',
				label: 'Just plan',
				description: "Show me the plan, I'll decide from there",
				pipeline: ['plan'],
			},
		)
	}
	return options
}
