import { spawn, execSync } from 'node:child_process'
import { createConnection } from 'node:net'
import { resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import { hasPortConflict, resolveConflictPorts } from './ports'
import { collectShutdownPids } from './processes'
import { readBooleanEnvFlag, resolveEnvFile, writeBooleanEnvFlag } from './env'
import {
	findProjectRoot,
	isComposeContainerReady,
	isComposeUp,
	parseComposePs,
	pickAvailableKey,
	resolveConfig,
	type ComposeStatus,
	type DevProcess,
	type Link,
	type ScriptEntry,
} from './config'
import { createInterface } from 'node:readline/promises'
import { ensureConfig } from './init'
import { parseBranchList, parseGitPorcelain, statusIcon, type GitFileEntry } from './git-actions'
import {
	IS_WINDOWS,
	aggregateTreeStats,
	binaryExists,
	browserCommand,
	clipboardCommands,
	clipboardHint,
	findListeningPids,
	formatStats,
	killProcessTree,
	readPsStats,
	type ProcStats,
} from './platform'

const ROOT = findProjectRoot()
await ensureConfig(ROOT)
const config = await resolveConfig(ROOT)
applyTagFilter(config.processes)

function applyTagFilter(processes: DevProcess[]) {
	const only = (process.env.DEV_MENU_ONLY ?? '')
		.split(',')
		.map((tag) => tag.trim())
		.filter(Boolean)
	if (only.length === 0) return

	const known = new Set(processes.map((proc) => proc.tag))
	const unknown = only.filter((tag) => !known.has(tag))
	if (unknown.length > 0) {
		process.stderr.write(
			`  \x1b[31munknown tag(s): ${unknown.join(', ')}\x1b[0m\n` +
				`  \x1b[2mavailable:\x1b[0m ${[...known].join(', ')}\n`,
		)
		process.exit(1)
	}
	config.processes = processes.filter((proc) => only.includes(proc.tag))
}

const DEV_PORTS = config.processes
	.map((proc) => proc.port)
	.filter((port): port is number => typeof port === 'number')
const INNER_TAG_COLORS = config.innerTagColors
const CHROME_BINS = config.chromeBins
const AI_TOOLS = config.aiTools
const OPEN_TARGETS = buildOpenTargets(config.processes, config.links)

type Modal = { label: string; onKey: (key: string) => void }
type Toggle = { label: string; key: string; file: string; restart: boolean; on: boolean }

type Child = ReturnType<typeof spawn>

const MAX_AUTO_RESTARTS = 5
const STABLE_UPTIME_MS = 10000

type ProcState = 'starting' | 'ready' | 'crashed' | 'stopped'

let procs: Child[] = []
const procByTag = new Map<string, Child>()
const procStatus = new Map<string, ProcState>()
const procStats = new Map<string, ProcStats>()
let scriptProcs: Child[] = []
const restartAttempts = new Map<string, number>()
const restartTimers = new Map<string, ReturnType<typeof setTimeout>>()
let inGitStatus = false
let incognito = false
let aiMode = false
let activeModal: Modal | null = null

const toggles: Toggle[] = config.envToggles.map((toggle) => {
	const file = resolveEnvFile(ROOT, [toggle.file]) ?? resolve(ROOT, toggle.file)
	return {
		label: toggle.label,
		key: toggle.key,
		file,
		restart: toggle.restart ?? false,
		on: readBooleanEnvFlag(file, toggle.key),
	}
})

type OpenTarget = { url: string; label: string; key: string; port?: number }

function buildOpenTargets(processes: DevProcess[], links: Link[] = []): OpenTarget[] {
	const used = new Set<string>()
	const sources = [
		...processes.map((proc) => ({
			label: proc.tag,
			url: proc.url,
			port: proc.port,
			openKey: proc.openKey,
		})),
		...links,
	]

	const targets: OpenTarget[] = []
	for (const source of sources) {
		if (!source.url) continue
		const key = source.openKey && !used.has(source.openKey) ? source.openKey : undefined
		if (key) used.add(key)
		targets.push({
			url: source.url,
			label: source.label,
			port: source.port,
			key: key ?? pickAvailableKey(source.label, used, { avoid: new Set(), allowDigits: true }) ?? '?',
		})
	}
	return targets
}

function checkPort(port: number): Promise<boolean> {
	return new Promise((resolveCheck) => {
		const socket = createConnection({ port, host: '127.0.0.1' })
		function finish(ok: boolean) {
			socket.destroy()
			resolveCheck(ok)
		}
		socket.once('connect', () => finish(true))
		socket.once('error', () => finish(false))
		socket.setTimeout(500, () => finish(false))
	})
}

async function openWhenReady(target: OpenTarget) {
	if (!target.port || (await checkPort(target.port))) {
		openInBrowser(target.url)
		return
	}
	process.stdout.write(`  \x1b[2mwaiting for ${target.label} on :${target.port}…\x1b[0m\n`)
	for (let attempt = 0; attempt < 10; attempt++) {
		await new Promise((wake) => setTimeout(wake, 500))
		if (await checkPort(target.port)) {
			openInBrowser(target.url)
			return
		}
	}
	process.stdout.write(
		`  \x1b[33m${target.label} not responding on :${target.port} — opening anyway\x1b[0m\n`,
	)
	openInBrowser(target.url)
}

function openMenu() {
	if (OPEN_TARGETS.length === 0) {
		process.stdout.write('  \x1b[2mnothing to open\x1b[0m\n')
		return
	}
	if (OPEN_TARGETS.length === 1) {
		openWhenReady(OPEN_TARGETS[0])
		return
	}
	process.stdout.write(
		`  \x1b[2m[open]\x1b[0m` +
			OPEN_TARGETS.map((target) => `  ${kbd(target.key)} ${target.label}`).join('') +
			`  \x1b[2mEsc cancel\x1b[0m\n`,
	)
	pushModal({
		label: 'open',
		onKey: (k) => {
			activeModal = null
			const target = OPEN_TARGETS.find((entry) => entry.key === k)
			if (target) openWhenReady(target)
			else process.stdout.write(`  \x1b[2mopen cancelled (unknown: ${k})\x1b[0m\n`)
		},
	})
}

function toggleEnv(index: number) {
	const toggle = toggles[index]
	if (!toggle) return
	toggle.on = !toggle.on
	writeBooleanEnvFlag(toggle.file, toggle.key, toggle.on)
	process.stdout.write(
		`\n  \x1b[32m✔ ${toggle.label} ${toggle.on ? 'on' : 'off'}\x1b[0m` +
			`  \x1b[2m(${toggle.key}=${toggle.on})\x1b[0m\n`,
	)
	if (toggle.restart) restartAll()
	else refreshBars()
}

type GitLine = { text: string; icon?: string; color?: string; path?: string; action?: 'stage' | 'unstage' }
type GitViewMode = 'status' | 'output' | 'branches'
type GitBranchItem = { name: string; key: string; current: boolean }

let gitHeader = ''
let gitLines: GitLine[] = []
let gitScroll = 0
let gitBodyHeight = 0
let gitCursor = -1
let gitViewMode: GitViewMode = 'status'
let gitBusy = false
let gitPrompting = false
let gitNotice = ''
let gitBranches: GitBranchItem[] = []

let chromeActive = false

const MAX_ERRORS = 9
let errorRaw: string[] = []
let capturedErrors: string[][] = []
let errorFlushTimer: ReturnType<typeof setTimeout> | null = null

const ERROR_LINE_PATTERNS = config.errorPatterns.line
const ERROR_HEADER_PATTERNS = config.errorPatterns.header
const READY_PATTERNS = config.readyPatterns

function markReadyIfMatched(tag: string, line: string) {
	if (procStatus.get(tag) !== 'starting') return
	if (!READY_PATTERNS.some((pattern) => pattern.test(line))) return
	procStatus.set(tag, 'ready')
	refreshBars()
}

function isErrorLine(raw: string): boolean {
	return ERROR_LINE_PATTERNS.some((pattern) => pattern.test(raw))
}

function isErrorHeader(raw: string): boolean {
	return ERROR_HEADER_PATTERNS.some((pattern) => pattern.test(raw))
}

function splitErrors(lines: string[]): string[][] {
	const blocks: string[][] = []
	let cur: string[] = []
	for (const line of lines) {
		if (isErrorHeader(line) && cur.length > 0) {
			blocks.push(cur)
			cur = [line]
		} else {
			cur.push(line)
		}
	}
	if (cur.length > 0) blocks.push(cur)
	return blocks.filter((b) => b.length > 0)
}

function showCapturedErrors() {
	const n = capturedErrors.length
	if (n === 0) return
	const portHint = hasCapturedPortConflict() ? `  \x1b[2mK\x1b[0m clear ports + retry` : ''
	for (let i = 0; i < n; i++) {
		const block = capturedErrors[i]
		const label = `  \x1b[31m⚠ error #${i + 1}\x1b[0m`
		process.stdout.write(`\n${label}\n`)
		for (const line of block) {
			process.stdout.write(`  \x1b[2m│\x1b[0m ${colorizeErrors(colorizeInnerTags(line))}\n`)
		}
	}
	const indivHint =
		n > 1
			? `  \x1b[2mE+1\x1b[0m–\x1b[2m${Math.min(n, 9)}\x1b[0m individual`
			: `  \x1b[2mE+1\x1b[0m copy`
	process.stdout.write(
		`\n  \x1b[2mE+A\x1b[0m copy all` +
			`  \x1b[2mE+I\x1b[0m fix with AI` +
			indivHint +
			`  \x1b[2mE+C\x1b[0m clear` +
			portHint +
			'\n',
	)
}

function flushErrors() {
	if (errorRaw.length === 0) return
	const blocks = splitErrors(errorRaw)
	errorRaw = []
	capturedErrors = [...capturedErrors, ...blocks].slice(-MAX_ERRORS)
	showCapturedErrors()
}

function onErrorLine(raw: string) {
	errorRaw.push(raw)
	if (errorFlushTimer) clearTimeout(errorFlushTimer)
	errorFlushTimer = setTimeout(() => {
		errorFlushTimer = null
		flushErrors()
	}, 1000)
}

function copyToClipboard(text: string) {
	const cmds = clipboardCommands()
	function tryNext(list: typeof cmds) {
		if (list.length === 0) {
			process.stdout.write(`  \x1b[31mcould not copy: install ${clipboardHint()}\x1b[0m\n`)
			return
		}
		const { cmd, args } = list[0]
		const p = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] })
		p.stdin?.write(text)
		p.stdin?.end()
		p.on('error', () => tryNext(list.slice(1)))
		p.on('close', (code) => {
			if (code === 0) process.stdout.write('  \x1b[32mcopied\x1b[0m\n')
		})
	}
	tryNext(cmds)
}

function launchAI(combo: string, extraArgs: string[] = []) {
	const tool = AI_TOOLS[combo]
	if (!tool) return
	aiMode = true
	leaveChrome()
	process.stdout.write(
		`\x1b[2J\x1b[H\x1b[2m  launching ${tool.cmd} ${tool.args.join(' ')} — exit the tool to return\x1b[0m\n\n`,
	)
	process.stdin.setRawMode(false)
	process.stdin.pause()
	const ai = spawn(tool.cmd, [...tool.args, ...extraArgs], { stdio: 'inherit', env: process.env })
	ai.on('error', (e: Error) => {
		aiMode = false
		process.stdin.setRawMode(true)
		process.stdin.resume()
		enterChrome()
		process.stdout.write(`\x1b[31m  ${tool.cmd} not found: ${e.message}\x1b[0m\n`)
	})
	ai.on('exit', () => {
		aiMode = false
		process.stdout.write('\x1b[0m')
		process.stdin.setRawMode(true)
		process.stdin.resume()
		enterChrome()
		process.stdout.write(`\x1b[2m  returned from ${tool.label}\x1b[0m\n`)
	})
}

function launchAIWithErrors() {
	const combo = AI_TOOLS['CL'] ? 'CL' : Object.keys(AI_TOOLS)[0]
	if (!combo) {
		process.stdout.write('  \x1b[2mno AI tools configured\x1b[0m\n')
		return
	}
	const prompt =
		'My dev server printed these errors. Diagnose and fix them:\n\n' +
		capturedErrors.flat().join('\n')
	launchAI(combo, [prompt])
}

function findChrome(): string | null {
	for (const bin of CHROME_BINS) {
		if (binaryExists(bin)) return bin
	}
	return null
}

function openInBrowser(url: string) {
	if (incognito) {
		const chrome = findChrome()
		if (chrome) {
			spawn(chrome, ['--incognito', url], { detached: true, stdio: 'ignore' }).unref()
		} else {
			spawn('firefox', ['--private-window', url], { detached: true, stdio: 'ignore' }).unref()
		}
		incognito = false
		refreshBars()
		process.stdout.write('  \x1b[2mopened in private mode\x1b[0m\n')
		return
	}
	const { cmd, args } = browserCommand(url)
	spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref()
}

function kbd(key: string, active = false) {
	return active ? `\x1b[35;7m ${key} \x1b[0m` : `\x1b[7m ${key} \x1b[0m`
}

function hintLine() {
	const iKey = incognito
		? kbd('i', true) + ' \x1b[35mincognito\x1b[0m \x1b[2m(Esc cancel)\x1b[0m'
		: kbd('i') + ' incognito'
	const openHint = OPEN_TARGETS.length > 0 ? `  ${kbd('o')} open` : ''
	const optionsHint = toggles.length > 0 ? `  ${kbd('O')} options` : ''
	const scriptsHint = config.scripts.length > 0 ? `  ${kbd('x')} scripts` : ''
	return (
		openHint +
		`  ${kbd('r')} restart` +
		`  ${kbd('p')} stop/start` +
		`  ${kbd('k')} kill listeners` +
		`  ${kbd('s')} git status` +
		`  ${iKey}` +
		`  ${kbd('E')} errors` +
		`  ${kbd('A')} AI` +
		scriptsHint +
		optionsHint +
		`  ${kbd('q')} quit`
	)
}

function prefix(tag: string, color: string) {
	return `\x1b[${color}m[${tag}]\x1b[0m `
}

function colorizeInnerTags(line: string): string {
	return line.replace(/\[(\w+)\]/g, (match, tag) => {
		const color = INNER_TAG_COLORS[tag.toLowerCase()]
		return color ? `\x1b[${color}m${match}\x1b[0m` : match
	})
}

function colorizeErrors(line: string): string {
	if (/^\s{2,}at /.test(line)) {
		return `\x1b[2m\x1b[31m${line}\x1b[0m`
	}
	return line
		.replace(/(✘|✖|×)/g, '\x1b[31m$1\x1b[0m')
		.replace(/(⚠|▲)/g, '\x1b[33m$1\x1b[0m')
		.replace(/\b(errors?)\b/gi, '\x1b[31m$1\x1b[0m')
		.replace(/\b(warnings?|warn)\b/gi, '\x1b[33m$1\x1b[0m')
		.replace(/\b(failed?|failure|uncaught|exception|unhandled)\b/gi, '\x1b[31m$1\x1b[0m')
		.replace(/\b(\d{3})\b(?=\D|$)/g, (match, code) => {
			const n = Number(code)
			if (n >= 500) return `\x1b[31m${match}\x1b[0m`
			if (n >= 400) return `\x1b[33m${match}\x1b[0m`
			return match
		})
}

function emitLine(stream: { write(text: string): void }, tag: string, color: string, line: string) {
	if (inGitStatus || aiMode) return
	if (!line.trim()) return
	markReadyIfMatched(tag, line)
	if (isErrorLine(line)) onErrorLine(line)
	stream.write(prefix(tag, color) + colorizeErrors(colorizeInnerTags(line)) + '\n')
}

function makeLineHandler(onLine: (line: string) => void) {
	const decoder = new StringDecoder('utf8')
	let remainder = ''

	function push(buf: Buffer) {
		remainder += decoder.write(buf)
		const parts = remainder.split('\n')
		remainder = parts.pop() ?? ''
		for (const part of parts) onLine(part)
	}

	function flush() {
		remainder += decoder.end()
		if (remainder) {
			onLine(remainder)
			remainder = ''
		}
	}

	return { push, flush }
}

function spawnProc(tag: string, color: string, cmd: string, args: string[], cwd: string) {
	const proc = spawn(cmd, args, { cwd, stdio: 'pipe', shell: false, detached: !IS_WINDOWS })
	procStatus.set(tag, 'starting')

	const stableTimer = setTimeout(() => {
		if (procs.includes(proc)) restartAttempts.delete(tag)
	}, STABLE_UPTIME_MS)

	const outHandler = makeLineHandler((line) => emitLine(process.stdout, tag, color, line))
	const errHandler = makeLineHandler((line) => emitLine(process.stderr, tag, color, line))
	proc.stdout?.on('data', (buf: Buffer) => outHandler.push(buf))
	proc.stderr?.on('data', (buf: Buffer) => errHandler.push(buf))

	proc.on('exit', (code) => {
		outHandler.flush()
		errHandler.flush()
		clearTimeout(stableTimer)
		if (!procs.includes(proc)) return
		procStatus.set(tag, 'crashed')
		procStats.delete(tag)
		const reason = code != null && code !== 0 ? `exited with code ${code}` : 'stopped unexpectedly'
		process.stderr.write(prefix(tag, color) + `\x1b[31m${reason} — press r to restart\x1b[0m\n`)
		refreshBars()
		if (config.autoRestart) scheduleAutoRestart(tag)
	})

	return proc
}

function scheduleAutoRestart(tag: string) {
	const attempts = restartAttempts.get(tag) ?? 0
	if (attempts >= MAX_AUTO_RESTARTS) {
		process.stderr.write(
			`  \x1b[31mauto-restart gave up on ${tag} after ${MAX_AUTO_RESTARTS} tries — press r\x1b[0m\n`,
		)
		return
	}

	const delayMs = Math.min(8000, 500 * 2 ** attempts)
	restartAttempts.set(tag, attempts + 1)
	process.stdout.write(
		`  \x1b[2mauto-restarting ${tag} in ${Math.round(delayMs / 1000)}s (attempt ${attempts + 1}/${MAX_AUTO_RESTARTS})\x1b[0m\n`,
	)

	const timer = setTimeout(() => {
		restartTimers.delete(tag)
		restartProc(tag)
	}, delayMs)
	restartTimers.set(tag, timer)
}

const COMPOSE_POLL_INTERVAL_MS = 800
const COMPOSE_WATCH_TIMEOUT_MS = 120000
const composeWatchGen = new Map<string, number>()

function composeStatusColor(label: string): string {
	if (/unhealthy|exit|dead|error/.test(label)) return '31'
	if (/healthy|running|started/.test(label)) return '32'
	if (/starting|created|restart|waiting|paused/.test(label)) return '33'
	return '2'
}

function runComposePs(cwd: string, onResult: (containers: ComposeStatus[]) => void) {
	const ps = spawn('docker', ['compose', 'ps', '--format', 'json'], {
		cwd,
		stdio: ['ignore', 'pipe', 'ignore'],
	})
	let out = ''
	ps.stdout?.on('data', (buf: Buffer) => {
		out += buf.toString()
	})
	ps.on('error', () => onResult([]))
	ps.on('close', () => onResult(parseComposePs(out)))
}

function writeComposeStatus(tag: string, color: string, text: string) {
	if (inGitStatus || aiMode) return
	process.stdout.write(prefix(tag, color) + text + '\n')
}

function watchComposeStatus(tag: string, color: string, cwd: string) {
	const gen = (composeWatchGen.get(tag) ?? 0) + 1
	composeWatchGen.set(tag, gen)
	const lastLabel = new Map<string, string>()
	const startedAt = Date.now()

	function isCurrent() {
		return composeWatchGen.get(tag) === gen
	}

	function poll() {
		if (!isCurrent()) return
		runComposePs(cwd, (containers) => {
			if (!isCurrent()) return

			let ready = 0
			for (const container of containers) {
				const label = container.health || container.state || 'unknown'
				if (lastLabel.get(container.service) !== label) {
					lastLabel.set(container.service, label)
					writeComposeStatus(
						tag,
						color,
						`\x1b[2m${container.service}\x1b[0m \x1b[${composeStatusColor(label)}m${label}\x1b[0m`,
					)
				}
				if (isComposeContainerReady(container)) ready++
			}

			if (containers.length > 0 && ready === containers.length) {
				composeWatchGen.set(tag, gen + 1)
				if (procStatus.get(tag) === 'starting') procStatus.set(tag, 'ready')
				writeComposeStatus(
					tag,
					color,
					`\x1b[32mall services ready (${ready}/${containers.length})\x1b[0m`,
				)
				refreshBars()
				return
			}
			if (Date.now() - startedAt > COMPOSE_WATCH_TIMEOUT_MS) return
			setTimeout(poll, COMPOSE_POLL_INTERVAL_MS)
		})
	}

	poll()
}

function startAll() {
	for (const proc of config.processes) {
		const child = spawnProc(proc.tag, proc.color, proc.cmd, proc.args, resolve(ROOT, proc.cwd))
		procs.push(child)
		procByTag.set(proc.tag, child)
		if (isComposeUp(proc)) watchComposeStatus(proc.tag, proc.color, resolve(ROOT, proc.cwd))
	}
}

function killChild(child: Child) {
	if (child.pid == null) return
	killProcessTree(child.pid)
}

function killAll() {
	for (const timer of restartTimers.values()) clearTimeout(timer)
	restartTimers.clear()
	for (const p of procs) killChild(p)
	for (const p of scriptProcs) killChild(p)
	procs = []
	scriptProcs = []
	procByTag.clear()
	procStats.clear()
}

function restartAll() {
	process.stdout.write('\n\x1b[33mrestarting...\x1b[0m\n\n')
	restartAttempts.clear()
	killAll()
	startAll()
	showHints()
}

function restartProc(tag: string, manual = false) {
	const definition = config.processes.find((proc) => proc.tag === tag)
	if (!definition) return

	const pending = restartTimers.get(tag)
	if (pending) {
		clearTimeout(pending)
		restartTimers.delete(tag)
	}
	if (manual) restartAttempts.delete(tag)

	const existing = procByTag.get(tag)
	if (existing) {
		const index = procs.indexOf(existing)
		if (index >= 0) procs.splice(index, 1)
		killChild(existing)
	}

	process.stdout.write(`\n\x1b[33mrestarting ${tag}...\x1b[0m\n\n`)
	const child = spawnProc(
		definition.tag,
		definition.color,
		definition.cmd,
		definition.args,
		resolve(ROOT, definition.cwd),
	)
	procs.push(child)
	procByTag.set(tag, child)
	if (isComposeUp(definition)) watchComposeStatus(definition.tag, definition.color, resolve(ROOT, definition.cwd))
	showHints()
}

function restartMenu() {
	if (config.processes.length <= 1) {
		restartAll()
		return
	}

	const used = new Set<string>(['a'])
	const items = config.processes.map((proc) => ({
		tag: proc.tag,
		key: pickAvailableKey(proc.tag, used, { avoid: new Set(), allowDigits: true }) ?? '?',
	}))

	process.stdout.write(
		`  \x1b[2m[restart]\x1b[0m` +
			`  ${kbd('a')} all` +
			items.map((item) => `  ${kbd(item.key)} ${item.tag}`).join('') +
			`  \x1b[2mEsc cancel\x1b[0m\n`,
	)
	pushModal({
		label: 'restart',
		onKey: (k) => {
			activeModal = null
			if (k === 'a') {
				restartAll()
				return
			}
			const item = items.find((entry) => entry.key === k)
			if (item) restartProc(item.tag, true)
			else process.stdout.write(`  \x1b[2mrestart cancelled (unknown: ${k})\x1b[0m\n`)
		},
	})
}

function stopProc(tag: string) {
	const child = procByTag.get(tag)
	if (!child) return

	const pending = restartTimers.get(tag)
	if (pending) {
		clearTimeout(pending)
		restartTimers.delete(tag)
	}
	restartAttempts.delete(tag)

	const index = procs.indexOf(child)
	if (index >= 0) procs.splice(index, 1)
	procByTag.delete(tag)
	killChild(child)
	procStatus.set(tag, 'stopped')
	procStats.delete(tag)
	process.stdout.write(`\n  \x1b[33m■ ${tag} stopped\x1b[0m \x1b[2m— press p to start it again\x1b[0m\n`)
	refreshBars()
}

function startProc(tag: string) {
	const definition = config.processes.find((proc) => proc.tag === tag)
	if (!definition || procByTag.has(tag)) return

	process.stdout.write(`\n  \x1b[32m▶ starting ${tag}...\x1b[0m\n\n`)
	const child = spawnProc(
		definition.tag,
		definition.color,
		definition.cmd,
		definition.args,
		resolve(ROOT, definition.cwd),
	)
	procs.push(child)
	procByTag.set(tag, child)
	if (isComposeUp(definition)) watchComposeStatus(definition.tag, definition.color, resolve(ROOT, definition.cwd))
	refreshBars()
}

function stopStartMenu() {
	const used = new Set<string>()
	const items = config.processes.map((proc) => ({
		tag: proc.tag,
		running: procByTag.has(proc.tag),
		key: pickAvailableKey(proc.tag, used, { avoid: new Set(), allowDigits: true }) ?? '?',
	}))
	if (items.length === 0) {
		process.stdout.write('  \x1b[2mno processes\x1b[0m\n')
		return
	}

	process.stdout.write(
		`  \x1b[2m[processes]\x1b[0m` +
			items
				.map(
					(item) =>
						`  ${kbd(item.key)} ${item.running ? 'stop' : '\x1b[32mstart\x1b[0m'} ${item.tag}`,
				)
				.join('') +
			`  \x1b[2mEsc cancel\x1b[0m\n`,
	)
	pushModal({
		label: 'processes',
		onKey: (k) => {
			activeModal = null
			const item = items.find((entry) => entry.key === k)
			if (!item) {
				process.stdout.write(`  \x1b[2mprocesses cancelled (unknown: ${k})\x1b[0m\n`)
				return
			}
			if (item.running) stopProc(item.tag)
			else startProc(item.tag)
		},
	})
}

function runScript(script: ScriptEntry) {
	const tag = `run:${script.label}`
	const color = '35'
	process.stdout.write(`\n  \x1b[35m▶ ${script.label}\x1b[0m \x1b[2m${script.cmd} ${(script.args ?? []).join(' ')}\x1b[0m\n\n`)

	const child = spawn(script.cmd, script.args ?? [], {
		cwd: resolve(ROOT, script.cwd ?? '.'),
		stdio: 'pipe',
		shell: false,
		detached: !IS_WINDOWS,
	})
	scriptProcs.push(child)

	const outHandler = makeLineHandler((line) => emitLine(process.stdout, tag, color, line))
	const errHandler = makeLineHandler((line) => emitLine(process.stderr, tag, color, line))
	child.stdout?.on('data', (buf: Buffer) => outHandler.push(buf))
	child.stderr?.on('data', (buf: Buffer) => errHandler.push(buf))

	child.on('error', (e: Error) => {
		process.stderr.write(`  \x1b[31m${script.cmd} failed to start: ${e.message}\x1b[0m\n`)
	})
	child.on('exit', (code) => {
		outHandler.flush()
		errHandler.flush()
		const index = scriptProcs.indexOf(child)
		if (index >= 0) scriptProcs.splice(index, 1)
		if (inGitStatus || aiMode) return
		process.stdout.write(
			code === 0
				? `  \x1b[32m✔ ${script.label} done\x1b[0m\n`
				: `  \x1b[31m✖ ${script.label} exited with code ${code}\x1b[0m\n`,
		)
	})
}

function scriptsMenu() {
	if (config.scripts.length === 0) {
		process.stdout.write(
			'  \x1b[2mno scripts configured — add scripts: [{ label, cmd, args }] to dev-menu.config.ts\x1b[0m\n',
		)
		return
	}

	const used = new Set<string>()
	const items = config.scripts.map((script) => ({
		script,
		key:
			script.key && !used.has(script.key)
				? (used.add(script.key), script.key)
				: (pickAvailableKey(script.label, used, { avoid: new Set(), allowDigits: true }) ?? '?'),
	}))

	process.stdout.write(
		`  \x1b[2m[scripts]\x1b[0m` +
			items.map((item) => `  ${kbd(item.key)} ${item.script.label}`).join('') +
			`  \x1b[2mEsc cancel\x1b[0m\n`,
	)
	pushModal({
		label: 'scripts',
		onKey: (k) => {
			activeModal = null
			const item = items.find((entry) => entry.key === k)
			if (item) runScript(item.script)
			else process.stdout.write(`  \x1b[2mscripts cancelled (unknown: ${k})\x1b[0m\n`)
		},
	})
}

function hasCapturedPortConflict() {
	return hasPortConflict(capturedErrors.flat())
}

function collectConflictPorts() {
	return resolveConflictPorts(capturedErrors.flat(), DEV_PORTS)
}

function readProcessTable() {
	try {
		return execSync('ps -eo pid=,ppid=,command=', {
			encoding: 'utf8',
			stdio: ['ignore', 'pipe', 'ignore'],
		})
	} catch {
		return ''
	}
}

function stopPid(pid: number) {
	try {
		process.kill(pid, 'SIGTERM')
	} catch {}
}

function forceStopPid(pid: number) {
	try {
		process.kill(pid, 0)
		process.kill(pid, 'SIGKILL')
	} catch {}
}

function clearPorts(ports: number[]) {
	const listenerPids = new Set<number>()
	for (const port of ports) {
		for (const pid of findListeningPids(port)) {
			if (pid !== process.pid) listenerPids.add(pid)
		}
	}

	if (IS_WINDOWS) {
		for (const pid of listenerPids) killProcessTree(pid)
		return listenerPids.size
	}

	const pids = collectShutdownPids([...listenerPids], readProcessTable(), process.pid)
	for (const pid of pids) stopPid(pid)
	if (pids.length > 0) sleepSync(250)
	for (const pid of pids) forceStopPid(pid)
	return pids.length
}

function sleepSync(ms: number) {
	Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
}

function clearPortsAndRestart() {
	const ports = collectConflictPorts()
	process.stdout.write(
		`\n\x1b[33mkilling external listeners on ports ${ports.join(', ')} and retrying...\x1b[0m\n\n`,
	)
	killAll()
	const stopped = clearPorts(ports)
	if (stopped === 0) {
		process.stdout.write('  \x1b[2mno external listeners found\x1b[0m\n\n')
	}
	startAll()
	showHints()
}

function showHints() {
	if (chromeActive) {
		drawChromeBars()
		return
	}
	process.stdout.write('\n' + hintLine() + '\n\n')
}

function statusDot(state: ProcState | undefined): string {
	if (state === 'crashed') return '\x1b[31m●\x1b[0m'
	if (state === 'stopped') return '\x1b[2m○\x1b[0m'
	if (state === 'starting') return '\x1b[33m●\x1b[0m'
	return '\x1b[32m●\x1b[0m'
}

function statusBar(): string {
	const mode = incognito ? '   \x1b[35mincognito\x1b[0m' : ''
	const procsBar = config.processes
		.map((proc) => {
			const dot = statusDot(procStatus.get(proc.tag))
			const url = proc.url ? ` \x1b[36m${proc.url}\x1b[0m` : ''
			const stats = procStats.get(proc.tag)
			const load = stats ? ` \x1b[2m${formatStats(stats)}\x1b[0m` : ''
			return `   ${dot} \x1b[2m${proc.tag}\x1b[0m${url}${load}`
		})
		.join('')
	const activeToggles = toggles
		.filter((toggle) => toggle.on)
		.map((toggle) => `   \x1b[33m⚠ ${toggle.label} on\x1b[0m`)
		.join('')
	return `  \x1b[2mdev\x1b[0m` + procsBar + activeToggles + mode
}

function drawChromeBars() {
	const rows = process.stdout.rows || 24
	const cols = process.stdout.columns || 80
	process.stdout.write(
		'\x1b7' +
			`\x1b[1;1H\x1b[2K` +
			truncateAnsi(statusBar(), cols) +
			`\x1b[${rows};1H\x1b[2K` +
			truncateAnsi(hintLine(), cols) +
			'\x1b8',
	)
}

function enterChrome() {
	const rows = process.stdout.rows || 24
	chromeActive = true
	process.stdout.write(`\x1b[?25h\x1b[2J\x1b[2;${rows - 1}r\x1b[${rows - 1};1H`)
	drawChromeBars()
}

function resyncChrome() {
	const rows = process.stdout.rows || 24
	process.stdout.write(`\x1b[2;${rows - 1}r\x1b[${rows - 1};1H`)
	drawChromeBars()
}

function leaveChrome() {
	chromeActive = false
	process.stdout.write('\x1b[r\x1b[?25h')
}

function refreshBars() {
	if (chromeActive) drawChromeBars()
}

const STATS_POLL_INTERVAL_MS = 2000

function pollProcStats() {
	if (!chromeActive || inGitStatus || aiMode) return
	const table = readPsStats()
	if (!table) return
	let changed = false
	for (const [tag, child] of procByTag) {
		if (child.pid == null) continue
		const state = procStatus.get(tag)
		if (state === 'crashed' || state === 'stopped') continue
		procStats.set(tag, aggregateTreeStats(table, child.pid))
		changed = true
	}
	if (changed) refreshBars()
}

const statsTimer = setInterval(pollProcStats, STATS_POLL_INTERVAL_MS)
statsTimer.unref?.()

function visibleLen(value: string): number {
	return value.replace(/\x1b\[[0-9;]*m/g, '').length
}

function truncateAnsi(value: string, width: number): string {
	if (visibleLen(value) <= width) return value
	let out = ''
	let count = 0
	let i = 0
	while (i < value.length && count < width - 1) {
		if (value[i] === '\x1b') {
			const match = value.slice(i).match(/^\x1b\[[0-9;]*m/)
			if (match) {
				out += match[0]
				i += match[0].length
				continue
			}
		}
		out += value[i]
		count++
		i++
	}
	return out + '…\x1b[0m'
}

function runGit(args: string[]): Promise<{ code: number; out: string }> {
	return new Promise((done) => {
		const child = spawn('git', ['-C', ROOT, ...args], { stdio: ['ignore', 'pipe', 'pipe'] })
		let out = ''
		child.stdout?.on('data', (buf: Buffer) => {
			out += buf.toString()
		})
		child.stderr?.on('data', (buf: Buffer) => {
			out += buf.toString()
		})
		child.on('error', (e: Error) => done({ code: 127, out: e.message }))
		child.on('close', (code) => done({ code: code ?? 0, out }))
	})
}

function gitSection(
	lines: GitLine[],
	title: string,
	color: string,
	entries: GitFileEntry[],
	action: 'stage' | 'unstage',
) {
	if (entries.length === 0) return
	lines.push({ text: '' })
	lines.push({ text: `\x1b[${color};1m  ${title}\x1b[0m` })
	for (const entry of entries) {
		lines.push({
			text: entry.path,
			icon: statusIcon(entry.code, action === 'unstage'),
			color,
			path: entry.path,
			action,
		})
	}
}

function selectableIndexes(): number[] {
	return gitLines.map((line, index) => (line.path ? index : -1)).filter((index) => index >= 0)
}

function buildGitStatusView(raw: string) {
	const sections = parseGitPorcelain(raw)
	gitHeader = `\x1b[35;1m  ${sections.branch || 'Git status'}\x1b[0m`
	gitViewMode = 'status'

	const lines: GitLine[] = []
	gitSection(lines, 'conflicts — resolve, then stage', '31', sections.conflicts, 'stage')
	gitSection(lines, 'staged', '32', sections.staged, 'unstage')
	gitSection(lines, 'changes', '33', sections.unstaged, 'stage')
	gitSection(lines, 'untracked', '2', sections.untracked, 'stage')
	if (lines.length === 0) {
		lines.push({ text: '' }, { text: '\x1b[2m  Working tree clean\x1b[0m' })
	}
	gitLines = lines

	const selectable = selectableIndexes()
	if (selectable.length === 0) gitCursor = -1
	else if (gitCursor < 0) gitCursor = selectable[0]
	else {
		gitCursor = selectable.reduce(
			(best, index) => (Math.abs(index - gitCursor) < Math.abs(best - gitCursor) ? index : best),
			selectable[0],
		)
	}
}

function renderGitLine(line: GitLine, selected: boolean): string {
	if (!line.path) return line.text
	if (selected) return `  \x1b[7m ${line.icon} ${line.text} \x1b[0m`
	return `    \x1b[${line.color}m${line.icon}\x1b[0m ${line.text}`
}

function gitFooter(): string {
	const notice = gitNotice ? `   \x1b[33m${gitNotice}\x1b[0m` : ''
	if (gitBusy) return `  \x1b[33m${gitNotice || 'working…'}\x1b[0m`
	if (gitViewMode === 'output') return `  ${kbd('any key')} back${notice}`
	if (gitViewMode === 'branches') return `  press a key to checkout  ${kbd('Esc')} back${notice}`

	const shown = Math.min(gitLines.length, gitBodyHeight)
	const first = gitLines.length === 0 ? 0 : gitScroll + 1
	const position = `\x1b[2m${first}-${gitScroll + shown}/${gitLines.length}\x1b[0m`
	const stage = gitCursor >= 0 ? `  ${kbd('␣')} ${gitLines[gitCursor]?.action === 'unstage' ? 'unstage' : 'stage'}` : ''
	return (
		stage +
		`  ${kbd('a')} all  ${kbd('u')} none  ${kbd('c')} commit  ${kbd('P')} push  ${kbd('U')} pull  ` +
		`${kbd('B')} branch  ${kbd('Esc')} back   ${position}${notice}`
	)
}

function renderGitStatus() {
	const rows = process.stdout.rows || 24
	const cols = process.stdout.columns || 80
	gitBodyHeight = Math.max(1, rows - 3)

	if (gitCursor >= 0) {
		if (gitCursor < gitScroll) gitScroll = gitCursor
		if (gitCursor >= gitScroll + gitBodyHeight) gitScroll = gitCursor - gitBodyHeight + 1
	}
	const maxScroll = Math.max(0, gitLines.length - gitBodyHeight)
	if (gitScroll > maxScroll) gitScroll = maxScroll
	if (gitScroll < 0) gitScroll = 0

	const out: string[] = ['\x1b[?25l\x1b[2J\x1b[H', truncateAnsi(gitHeader, cols)]
	for (let i = 0; i < gitBodyHeight; i++) {
		const index = gitScroll + i
		const line = gitLines[index]
		out.push(line ? truncateAnsi(renderGitLine(line, index === gitCursor), cols) : '')
	}

	process.stdout.write(out.join('\n') + `\x1b[${rows};1H\x1b[2K` + truncateAnsi(gitFooter(), cols))
}

async function refreshGitStatus() {
	const res = await runGit(['status', '--porcelain=v1', '--branch'])
	if (!inGitStatus) return
	if (res.code !== 0) {
		gitHeader = '\x1b[35;1m  Git status\x1b[0m'
		gitViewMode = 'status'
		gitLines = res.out.split('\n').map((text) => ({ text: `  ${text}` }))
		gitCursor = -1
	} else {
		buildGitStatusView(res.out)
	}
	renderGitStatus()
}

function showGitStatus() {
	inGitStatus = true
	gitScroll = 0
	gitCursor = -1
	gitNotice = ''
	gitViewMode = 'status'
	leaveChrome()
	refreshGitStatus()
}

function moveGitCursor(delta: number) {
	const selectable = selectableIndexes()
	if (selectable.length === 0) {
		gitScroll += delta
		renderGitStatus()
		return
	}
	const at = selectable.indexOf(gitCursor)
	const next = Math.min(selectable.length - 1, Math.max(0, (at < 0 ? 0 : at) + delta))
	gitCursor = selectable[next]
	renderGitStatus()
}

async function gitQuickAction(args: string[], successNotice: string) {
	const res = await runGit(args)
	gitNotice = res.code === 0 ? successNotice : (res.out.trim().split('\n')[0] ?? 'git failed')
	await refreshGitStatus()
}

function toggleStageAtCursor() {
	const line = gitLines[gitCursor]
	if (!line?.path || !line.action) return
	const args =
		line.action === 'unstage'
			? ['restore', '--staged', '--', line.path]
			: ['add', '--', line.path]
	gitQuickAction(args, line.action === 'unstage' ? `unstaged ${line.path}` : `staged ${line.path}`)
}

function showGitOutput(title: string, res: { code: number; out: string }) {
	gitViewMode = 'output'
	gitHeader =
		`\x1b[35;1m  ${title}\x1b[0m` + (res.code === 0 ? ' \x1b[32m✔\x1b[0m' : ` \x1b[31m✖ exit ${res.code}\x1b[0m`)
	const body = res.out.trim() ? res.out.trimEnd().split('\n') : ['(no output)']
	gitLines = [{ text: '' }, ...body.map((text) => ({ text: `  ${text}` }))]
	gitCursor = -1
	gitScroll = 0
	gitNotice = ''
	renderGitStatus()
}

async function gitLongAction(label: string, args: string[]) {
	gitBusy = true
	gitNotice = `${label}…`
	renderGitStatus()
	const res = await runGit(args)
	gitBusy = false
	if (!inGitStatus) return
	showGitOutput(`git ${args.join(' ')}`, res)
}

async function promptLine(question: string): Promise<string> {
	gitPrompting = true
	const rows = process.stdout.rows || 24
	process.stdout.write(`\x1b[${rows};1H\x1b[2K\x1b[?25h`)
	process.stdin.setRawMode(false)
	const rl = createInterface({ input: process.stdin, output: process.stdout })
	try {
		return (await rl.question(question)).trim()
	} finally {
		rl.close()
		process.stdin.setRawMode(true)
		process.stdin.resume()
		gitPrompting = false
	}
}

async function gitCommit() {
	const stagedCount = gitLines.filter((line) => line.action === 'unstage').length
	if (stagedCount === 0) {
		gitNotice = 'nothing staged — stage files with space or a first'
		renderGitStatus()
		return
	}
	const message = await promptLine(`  commit message (${stagedCount} staged): `)
	if (!message) {
		gitNotice = 'commit cancelled'
		await refreshGitStatus()
		return
	}
	gitBusy = true
	gitNotice = 'committing…'
	renderGitStatus()
	const res = await runGit(['commit', '-m', message])
	gitBusy = false
	showGitOutput('git commit', res)
}

async function gitBranchMenu() {
	const res = await runGit(['branch'])
	if (res.code !== 0) {
		showGitOutput('git branch', res)
		return
	}
	const used = new Set<string>()
	gitBranches = parseBranchList(res.out).map((branch) => ({
		...branch,
		key: pickAvailableKey(branch.name, used, { avoid: new Set(['q']), allowDigits: true }) ?? '?',
	}))
	if (gitBranches.length === 0) {
		gitNotice = 'no local branches'
		renderGitStatus()
		return
	}
	gitViewMode = 'branches'
	gitHeader = '\x1b[35;1m  checkout branch\x1b[0m'
	gitLines = [
		{ text: '' },
		...gitBranches.map((branch) => ({
			text:
				`    ${kbd(branch.key)} ${branch.current ? '\x1b[32m' : ''}${branch.name}` +
				`${branch.current ? ' (current)\x1b[0m' : ''}`,
		})),
	]
	gitCursor = -1
	gitScroll = 0
	renderGitStatus()
}

function handleGitBranchKey(key: string) {
	if (key === '\x1b') {
		gitViewMode = 'status'
		refreshGitStatus()
		return
	}
	const branch = gitBranches.find((entry) => entry.key === key)
	if (!branch) return
	if (branch.current) {
		gitNotice = `already on ${branch.name}`
		renderGitStatus()
		return
	}
	gitViewMode = 'status'
	gitLongAction(`checking out ${branch.name}`, ['checkout', branch.name])
}

function exitGitStatus() {
	inGitStatus = false
	enterChrome()
}

function pushModal(modal: Modal) {
	activeModal = modal
}

function handleKey(buf: Buffer) {
	const key = buf.toString()

	if (gitPrompting) return

	if (inGitStatus) {
		if (key === '\x03') {
			process.stdout.write('\x1b[?25h')
			killAll()
			process.exit(0)
		}
		if (gitBusy) return
		if (gitViewMode === 'output') {
			gitViewMode = 'status'
			refreshGitStatus()
			return
		}
		if (gitViewMode === 'branches') {
			handleGitBranchKey(key)
			return
		}
		switch (key) {
			case '\x1b':
			case 'q':
				exitGitStatus()
				break
			case '\x1b[A':
			case 'k':
				moveGitCursor(-1)
				break
			case '\x1b[B':
			case 'j':
				moveGitCursor(1)
				break
			case '\x1b[5~':
			case 'b':
				moveGitCursor(-gitBodyHeight)
				break
			case '\x1b[6~':
			case 'f':
				moveGitCursor(gitBodyHeight)
				break
			case 'g':
				moveGitCursor(-gitLines.length)
				break
			case 'G':
				moveGitCursor(gitLines.length)
				break
			case ' ':
				toggleStageAtCursor()
				break
			case 'a':
				gitQuickAction(['add', '-A'], 'staged everything')
				break
			case 'u':
				gitQuickAction(['reset', '-q'], 'unstaged everything')
				break
			case 'c':
				gitCommit()
				break
			case 'P':
				gitLongAction('pushing', ['push'])
				break
			case 'U':
				gitLongAction('pulling', ['pull'])
				break
			case 'B':
				gitBranchMenu()
				break
		}
		return
	}

	if (activeModal) {
		if (key === '\x1b') {
			const label = activeModal.label
			activeModal = null
			process.stdout.write(`  \x1b[2m${label} cancelled\x1b[0m\n`)
			return
		}
		activeModal.onKey(key)
		return
	}

	if (key === '\x1b') {
		if (incognito) {
			incognito = false
			refreshBars()
			process.stdout.write('  \x1b[2mincognito cancelled\x1b[0m\n')
		}
		return
	}

	switch (key) {
		case 'o':
			openMenu()
			break
		case 'E':
			if (capturedErrors.length === 0) {
				process.stdout.write('  \x1b[2mno errors captured yet\x1b[0m\n')
			} else {
				const n = capturedErrors.length
				process.stdout.write(
					`  \x1b[2m[E+…]\x1b[0m` +
						`  ${kbd('A')} copy all` +
						`  ${kbd('I')} fix with AI` +
						Array.from(
							{ length: n },
							(_, i) => `  ${kbd(String(i + 1))} #${i + 1}`,
						).join('') +
						(hasCapturedPortConflict() ? `  ${kbd('K')} kill listeners + retry` : '') +
						`  ${kbd('C')} clear  \x1b[2mEsc cancel\x1b[0m\n`,
				)
				pushModal({
					label: 'errors',
					onKey: (k) => {
						activeModal = null
						if (k === 'A') {
							copyToClipboard(capturedErrors.flat().join('\n'))
						} else if (k === 'I') {
							launchAIWithErrors()
						} else if (k === 'K') {
							clearPortsAndRestart()
						} else if (k === 'C') {
							capturedErrors = []
							process.stdout.write('  \x1b[2merrors cleared\x1b[0m\n')
						} else if (/^[1-9]$/.test(k)) {
							const block = capturedErrors[Number(k) - 1]
							if (block) copyToClipboard(block.join('\n'))
							else process.stdout.write(`  \x1b[2mno error #${k}\x1b[0m\n`)
						} else {
							process.stdout.write(
								`  \x1b[2merrors cancelled (unknown: E+${k})\x1b[0m\n`,
							)
						}
					},
				})
			}
			break
		case 'A':
			process.stdout.write(
				`  \x1b[2m[AI]\x1b[0m` +
					`  ${kbd('C')}+${kbd('O')} codex --yolo` +
					`  ${kbd('C')}+${kbd('L')} claude` +
					`  ${kbd('O')}+${kbd('C')} opencode` +
					`  \x1b[2mEsc cancel\x1b[0m\n`,
			)
			pushModal({
				label: 'AI',
				onKey: (k) => {
					if (k === 'C' || k === 'O') {
						const first = k
						pushModal({
							label: 'AI',
							onKey: (k2) => {
								activeModal = null
								const combo = first + k2.toUpperCase()
								if (AI_TOOLS[combo]) launchAI(combo)
								else
									process.stdout.write(
										`  \x1b[2mAI cancelled (unknown: ${combo})\x1b[0m\n`,
									)
							},
						})
					} else {
						activeModal = null
						process.stdout.write(`  \x1b[2mAI cancelled\x1b[0m\n`)
					}
				},
			})
			break
		case 'O': {
			if (toggles.length === 0) {
				process.stdout.write('  \x1b[2mno options configured\x1b[0m\n')
				break
			}
			process.stdout.write(
				`  \x1b[2m[options]\x1b[0m` +
					toggles
						.map(
							(toggle, i) =>
								`  ${kbd(String(i + 1))} ${toggle.label} \x1b[2m(now ${toggle.on ? 'on' : 'off'})\x1b[0m`,
						)
						.join('') +
					`  \x1b[2mEsc cancel\x1b[0m\n`,
			)
			pushModal({
				label: 'options',
				onKey: (k) => {
					activeModal = null
					if (/^[1-9]$/.test(k) && toggles[Number(k) - 1]) toggleEnv(Number(k) - 1)
					else
						process.stdout.write(
							`  \x1b[2moptions cancelled (unknown: ${k})\x1b[0m\n`,
						)
				},
			})
			break
		}
		case 'k':
			clearPortsAndRestart()
			break
		case 'i': {
			incognito = true
			refreshBars()
			const openKeys = OPEN_TARGETS.map((target) => target.key).join('/')
			const nextHint = openKeys ? `next ${openKeys}` : 'next open'
			process.stdout.write(
				`  \x1b[35mincognito on\x1b[0m \x1b[2m— ${nextHint} opens private  (Esc to cancel)\x1b[0m\n`,
			)
			break
		}
		case 'r':
			restartMenu()
			break
		case 'p':
			stopStartMenu()
			break
		case 'x':
			scriptsMenu()
			break
		case 's':
			showGitStatus()
			break
		case 'q':
		case '\x03':
			leaveChrome()
			killAll()
			process.stdout.write('\n')
			process.exit(0)
	}
}

process.stdin.setRawMode(true)
process.stdin.resume()
process.stdin.on('data', handleKey)

process.stdout.on('resize', () => {
	if (inGitStatus) renderGitStatus()
	else if (chromeActive) resyncChrome()
})

process.on('SIGINT', () => {
	if (aiMode) return
	leaveChrome()
	killAll()
	process.exit(0)
})
process.on('SIGTERM', () => {
	leaveChrome()
	killAll()
	process.exit(0)
})

startAll()
enterChrome()
