/**
 * host/detect.mjs — AI CLI host detection (C29).
 *
 * Detection maintenance: env-var mappings for each runtime below are the
 * authoritative signal. They must be re-verified against each CLI's current
 * public docs every quarter. See packages/framework/docs/intents.md
 * §Host-detection-maintenance for the process.
 *
 * For env vars that could not be confirmed from public docs at the time of
 * writing, we leave a TODO comment with the docs URL. These branches still
 * ship — we prefer best-effort coverage + structured unknown-host error
 * for misses over silently routing into the wrong runtime.
 */

import { existsSync as fsExistsSync } from 'node:fs'
import { delimiter as PATH_DELIMITER, join as pathJoin } from 'node:path'

/**
 * Canonical list of runtime identifiers accepted by detectHost's
 * `overrideCli` parameter AND by the route command's `--runtime <id>` flag.
 * This list MUST stay in sync with the env-based detection branches below
 * and with the `supported_hosts` array in the unknown-host error envelope
 * emitted by route.mjs — the flag's whole purpose is to round-trip back to
 * one of those identifiers when auto-detection fails.
 */
export const SUPPORTED_HOSTS = Object.freeze([
	'claude-code',
	'codex',
	'gemini-cli',
	'opencode',
	'pi',
	'cursor',
	'windsurf',
	'kilo',
	'copilot',
	'antigravity',
	'augment',
	'trae',
	'qwen',
	'cline',
	'codebuddy',
])

/**
 * Detect which AI CLI is currently running this script by checking environment
 * variables set by each runtime. Returns host info and capability matrix.
 *
 * Detection priority (older, well-validated runtimes win over newer opt-in
 * ones when env vars overlap): Claude Code > Codex > Gemini > OpenCode > Pi
 * > Cursor > Windsurf > Kilo > Copilot > Antigravity > Augment > Trae > Qwen
 * > Cline > Codebuddy > unknown.
 *
 * @param {object} [opts]
 * @param {string} [opts.overrideCli] — When provided AND one of SUPPORTED_HOSTS,
 *   the env-var detection chain is skipped and the result's `cli` is set to
 *   this value. Capabilities are recomputed for the override (e.g. passing
 *   'codex' when CLAUDECODE=1 is also set yields codex capabilities, not
 *   claude-code's). Aperant-terminal detection is still environment-derived
 *   and is NOT overridden — it describes the PTY wrapping, not the CLI host.
 *   Invalid values are ignored (the caller should validate before calling).
 */
export function detectHost(opts = {}) {
	const env = process.env
	const { overrideCli } = opts

	let cli = 'unknown'
	let agentTeams = false

	// Explicit override wins over the env-based chain. The route command
	// validates `overrideCli` against SUPPORTED_HOSTS before calling us, so
	// an unknown value here is a programmer error — we silently ignore it
	// rather than throwing, since detectHost is on the hot path.
	if (overrideCli && SUPPORTED_HOSTS.includes(overrideCli)) {
		cli = overrideCli
		// agent_teams is a Claude-Code-only capability tied to an env flag.
		// When the override is claude-code, we still honor the flag so the
		// caller gets accurate capabilities; when the override is any other
		// runtime, agent_teams stays false regardless.
		if (cli === 'claude-code') {
			agentTeams = env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1'
		}
	} else if (
		env.CLAUDECODE === '1' ||
		env.CLAUDE_CODE_ENTRYPOINT ||
		env.CLAUDE_CODE_SSE_PORT ||
		env.CLAUDE_CONFIG_DIR
	) {
		cli = 'claude-code'
		agentTeams = env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1'
	} else if (env.CODEX_HOME || env.CODEX_THREAD_ID) {
		cli = 'codex'
	} else if (env.GEMINI_CONFIG_DIR || env.GEMINI_SESSION_ID) {
		cli = 'gemini-cli'
	} else if (env.OPENCODE_CONFIG_DIR || env.OPENCODE_CONFIG || env.OPENCODE_SESSION_ID) {
		cli = 'opencode'
	} else if (env.PI_CODING_AGENT === 'true') {
		// Pi (@mariozechner/pi-coding-agent) sets PI_CODING_AGENT="true" at
		// CLI startup — verified against v0.68.0 dist/cli.js line 9:
		//   process.env.PI_CODING_AGENT = "true";
		// It does NOT set PI_CONFIG_DIR or PI_SESSION_ID (those names appeared
		// in the draft spec §9 risk #1 but proved to not actually exist —
		// Pi only reads env vars like PI_OFFLINE / PI_TELEMETRY for user config).
		// PI_CODING_AGENT is the single authoritative detection signal.
		cli = 'pi'
	} else if (env.CURSOR_TRACE_ID || env.CURSOR_CONFIG_DIR) {
		// Cursor exposes CURSOR_TRACE_ID inside its agent/chat runtime
		// (observed in Cursor v1.x). CURSOR_CONFIG_DIR is the canonical
		// user-config override variable.
		// TODO(router-0.3.0): re-verify against https://docs.cursor.com/
		//   once the agent-API docs stabilize. Cursor's 2.x cycle may add or
		//   rename vars; Q3-2026 maintenance sweep.
		cli = 'cursor'
	} else if (env.WINDSURF_CONFIG_DIR || env.WINDSURF_SESSION_ID) {
		// Windsurf (Codeium IDE) — WINDSURF_CONFIG_DIR is the config-override
		// variable; WINDSURF_SESSION_ID appears in the editor's agent runtime.
		// TODO(router-0.3.0): confirm against https://docs.codeium.com/windsurf
		//   — vars may shift as Windsurf consolidates under the Codeium
		//   branding. Q3-2026 maintenance sweep.
		cli = 'windsurf'
	} else if (env.KILO_CONFIG_DIR || env.KILO_SESSION_ID) {
		// Kilo Code CLI — config + session env vars, same shape as Codex/Gemini.
		// TODO(router-0.3.0): confirm against https://kilocode.ai/docs once the
		//   CLI docs surface env-var references. Q3-2026 maintenance sweep.
		cli = 'kilo'
	} else if (
		env.GITHUB_COPILOT_CLI_SESSION ||
		env.COPILOT_CLI_SESSION ||
		env.GITHUB_COPILOT_TOKEN
	) {
		// GitHub Copilot CLI (gh-copilot extension). GITHUB_COPILOT_TOKEN is
		// the auth marker; *_CLI_SESSION variants are the runtime markers
		// observed in gh-copilot's extension process.
		// TODO(router-0.3.0): re-verify against
		//   https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line
		//   as Copilot's CLI surface is still evolving. Q3-2026 sweep.
		cli = 'copilot'
	} else if (env.ANTIGRAVITY_CONFIG_DIR || env.ANTIGRAVITY_SESSION_ID) {
		// Google Antigravity agent runtime.
		// TODO(router-0.3.0): verify against Antigravity's public docs once
		//   the preview exits and runtime env-vars are documented. Q3-2026 sweep.
		cli = 'antigravity'
	} else if (env.AUGMENT_CONFIG_DIR || env.AUGMENT_SESSION_ID) {
		// Augment Code agent runtime.
		// TODO(router-0.3.0): confirm against https://www.augmentcode.com/
		//   docs once a stable CLI surface lands. Q3-2026 sweep.
		cli = 'augment'
	} else if (env.TRAE_CONFIG_DIR || env.TRAE_SESSION_ID) {
		// Trae AI IDE agent runtime.
		// TODO(router-0.3.0): confirm against https://www.trae.ai/docs
		//   once CLI runtime env-vars are published. Q3-2026 sweep.
		cli = 'trae'
	} else if (env.QWEN_CONFIG_DIR || env.QWEN_SESSION_ID) {
		// Qwen Coder agent runtime.
		// TODO(router-0.3.0): confirm against
		//   https://qwenlm.github.io/ as the CLI surface evolves. Q3-2026 sweep.
		cli = 'qwen'
	} else if (env.CLINE_CONFIG_DIR || env.CLINE_SESSION_ID) {
		// Cline (VSCode extension → CLI). Historically "Claude Dev"; env
		// naming follows the current Cline branding.
		// TODO(router-0.3.0): confirm against https://docs.cline.bot/ once
		//   the headless CLI surface is stable. Q3-2026 sweep.
		cli = 'cline'
	} else if (env.CODEBUDDY_CONFIG_DIR || env.CODEBUDDY_SESSION_ID) {
		// CodeBuddy (Tencent Cloud's code agent).
		// TODO(router-0.3.0): confirm against CodeBuddy's public docs once
		//   the CLI surface stabilizes. Q3-2026 sweep.
		cli = 'codebuddy'
	}

	const isClaudeCode = cli === 'claude-code'
	const isCodex = cli === 'codex'

	// Structured prompt tools: Claude Code (AskUserQuestion), Gemini CLI (ask_user), OpenCode (question).
	// Codex CLI has no native equivalent — falls back to numbered text prompts.
	// Pi has dialog UI in interactive mode but no Claude-Code-style structured
	// prompt TOOL — and the headless RPC path auto-cancels all dialogs (spec §4.3).
	//
	// The 10 new runtimes (cursor, windsurf, kilo, copilot, antigravity, augment,
	// trae, qwen, cline, codebuddy) default to structured_prompts=false — none
	// has a confirmed Claude-Code-style AskUserQuestion primitive at the time
	// of writing. Flip to true once any of them ships one + confirm with a test.
	const structuredPrompts = isClaudeCode || cli === 'gemini-cli' || cli === 'opencode'

	// Aperant terminal detection — separate from `cli`. The framework can be
	// running inside an Aperant-spawned PTY regardless of which CLI host
	// (claude-code, codex, gemini-cli, opencode, pi, unknown) is at the prompt.
	// Both env vars must be present: TERM_PROGRAM identifies the parent
	// terminal program, APERANT_IPC_SOCKET is the absolute path to the
	// Electron main process's Unix domain socket for nested-spawn RPC.
	const aperantSocket = env.APERANT_IPC_SOCKET ?? null
	const isAperantTerminal = env.TERM_PROGRAM === 'Aperant' && !!aperantSocket

	const capabilities = {
		sub_agents: isClaudeCode || isCodex,
		agent_teams: isClaudeCode && agentTeams,
		worktrees: isClaudeCode,
		background_agents: isClaudeCode || isCodex,
		structured_prompts: structuredPrompts,
		// D-03 (post-ship CI watcher) — true for hosts that ship a native
		// recurring-task primitive the watcher can delegate to (CC's CronCreate,
		// Pi's pi-schedule-prompt). False elsewhere; on those hosts the user
		// re-invokes `/apt:watch-ci --tick --pr N` manually.
		cron: isClaudeCode || cli === 'pi',
		// True when the framework can ask Electron to spawn a sibling Aperant
		// terminal (with its own worktree + CLI) over the Unix-domain IPC socket.
		// Requires TERM_PROGRAM=Aperant + APERANT_IPC_SOCKET to be set by the
		// PTY launcher (NodePtyProvider + AperantIpcServer).
		terminal_spawn: isAperantTerminal,
	}

	return {
		cli,
		version: null, // Version detection requires shelling out — skipped for ~50ms target
		capabilities,
		aperant_terminal: isAperantTerminal,
		aperant_socket: aperantSocket,
		partner_transports: detectPartnerTransports({ env }),
	}
}

/**
 * Partner-transport ladder used by `apt:spar` (FRAMEWORK-BUG-024).
 *
 * For each partner (codex / claude / gemini) we resolve a 3-rung ladder in
 * preference order — MCP first (cleanest integration), then plugin shim
 * (codex only), then raw CLI on $PATH. The first rung with `detected: true`
 * wins; the spar skill's gate fails only when all three rungs report
 * `detected: false`.
 *
 * Detection is best-effort and side-effect-free w/r/t the FS — only
 * `existsSync` reads. No spawn, no network. The MCP rung uses
 * `detected: "runtime-host-only"` when the framework process cannot probe
 * the host's deferred-tool registry (the common case — MCP availability
 * is host-internal), signalling the SKILL body to self-check at invocation
 * time rather than fail the gate.
 *
 * Pure function w/r/t injected `env` + `existsSync` so tests can mock both
 * without monkey-patching `process.env` or `node:fs`.
 *
 * @param {object} [opts]
 * @param {NodeJS.ProcessEnv} [opts.env] — defaults to `process.env`
 * @param {(p: string) => boolean} [opts.existsSync] — defaults to `node:fs` existsSync
 */
export function detectPartnerTransports(opts = {}) {
	const env = opts.env ?? process.env
	const exists = opts.existsSync ?? fsExistsSync
	const pathEntries = (env.PATH ?? '').split(PATH_DELIMITER).filter(Boolean)

	return {
		codex: [
			detectMcpRung('codex', env),
			detectPluginShim('codex', env, exists),
			detectRawCli('codex', pathEntries, exists),
		],
		claude: [
			detectMcpRung('claude', env),
			// No plugin shim exists for Claude today — kept as `detected: false`
			// for ladder-shape symmetry so consumers can index by rung uniformly.
			{ rung: 'plugin', detected: false, reason: 'no plugin shim for claude' },
			detectRawCli('claude', pathEntries, exists),
		],
		gemini: [
			detectMcpRung('gemini', env),
			{ rung: 'plugin', detected: false, reason: 'no plugin shim for gemini' },
			detectRawCli('gemini', pathEntries, exists),
		],
	}
}

/**
 * MCP rung — detected if the host exposes its deferred-tool manifest via
 * `CLAUDE_MCP_DEFERRED_TOOLS` (a JSON array of tool names) AND a matching
 * `mcp__<partner>-mcp__*` entry is present. When the env var is absent the
 * framework cannot observe the host's tool registry from outside, so we
 * return `detected: "runtime-host-only"` — the SKILL body interprets this
 * as "try it; if the host has the tool, use it; otherwise fall through".
 */
function detectMcpRung(partner, env) {
	const toolPrefix = mcpToolPrefix(partner)
	const manifest = env.CLAUDE_MCP_DEFERRED_TOOLS
	if (manifest) {
		try {
			const tools = JSON.parse(manifest)
			if (
				Array.isArray(tools) &&
				tools.some((t) => typeof t === 'string' && t.startsWith(toolPrefix))
			) {
				return { rung: 'mcp', detected: true, invocation: `${toolPrefix}*` }
			}
			return {
				rung: 'mcp',
				detected: false,
				reason: `no ${toolPrefix}* tool in CLAUDE_MCP_DEFERRED_TOOLS`,
			}
		} catch {
			// Malformed manifest — fall through to runtime-deferred
		}
	}
	return { rung: 'mcp', detected: 'runtime-host-only', invocation: `${toolPrefix}*` }
}

function mcpToolPrefix(partner) {
	if (partner === 'codex') return 'mcp__codex-mcp__'
	if (partner === 'claude') return 'mcp__claude-code-mcp__'
	if (partner === 'gemini') return 'mcp__gemini-mcp__'
	return `mcp__${partner}-mcp__`
}

/**
 * Plugin-shim rung — Claude Code plugins drop helper scripts under
 * `${CLAUDE_PLUGIN_ROOT}/scripts/<partner>-companion.mjs`. Only codex
 * ships a companion today; the caller passes `'codex'` to probe.
 */
function detectPluginShim(partner, env, exists) {
	const root = env.CLAUDE_PLUGIN_ROOT
	if (!root) {
		return { rung: 'plugin', detected: false, reason: 'CLAUDE_PLUGIN_ROOT unset' }
	}
	const script = pathJoin(root, 'scripts', `${partner}-companion.mjs`)
	if (!exists(script)) {
		return { rung: 'plugin', detected: false, reason: `${script} not found` }
	}
	return { rung: 'plugin', detected: true, invocation: `node "${script}" task` }
}

/**
 * Raw-CLI rung — probe pre-split $PATH entries for the partner binary using
 * existsSync (no spawn). Accepts a pre-split array so detectPartnerTransports
 * can parse $PATH once and share it across all partner probes.
 *
 * The binary name matches the partner id for all three currently-supported
 * partners (codex / claude / gemini).
 */
function detectRawCli(partner, pathEntries, exists) {
	const bin = partner
	for (const dir of pathEntries) {
		const candidate = pathJoin(dir, bin)
		if (exists(candidate)) {
			return { rung: 'cli', detected: true, invocation: rawCliInvocation(partner) }
		}
	}
	return { rung: 'cli', detected: false, reason: `${bin} not found on $PATH` }
}

function rawCliInvocation(partner) {
	if (partner === 'codex') return 'codex exec'
	if (partner === 'claude') return 'claude -p'
	if (partner === 'gemini') return 'gemini'
	return partner
}
