/**
 * `maxstack eval` / `dogfood` / `dev` / `demo` — wrappers over the web runtime
 * and the harness. `dev` runs the platform web app over a project's data dir
 * (the established data-dir mode — `MAXSTACK_DATA_DIR` grounds the admin +
 * workbench in the project spec); `demo` seeds sample data the same way.
 *
 * Both run against whichever runtime `resolveRuntime` finds: a maxstack checkout
 * (vite dev server, HMR, owned-slot hot loop) or the published
 * `maxstack-runtime` package (prebuilt react-router server + bundled seed —
 * no pnpm, no checkout). `eval`/`dogfood` are maxstack dev-loop tools and stay
 * checkout-only.
 */

import { spawn } from 'node:child_process'
import { watch } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { createServer } from 'node:net'
import { dirname, resolve } from 'node:path'
import { MANIFEST_FILENAME, parseManifest } from '@maxstack/core/ownership'
import { pathExists } from '../fsx.ts'
import { generateProject } from '../lib/generate.ts'
import { ensureMcpJson } from '../lib/mcp-config.ts'
import { loadProject, type Project } from '../lib/project.ts'
import { type Runtime, resolveRuntime } from '../lib/runtime.ts'
import { readRuntimeStamp, vendorRuntime } from './build.ts'

const require = createRequire(import.meta.url)

/** The harness `src/` dir, resolved via its package entry (`src/index.ts`).
 * Only resolvable inside a maxstack checkout — the harness is not published. */
function harnessSrcDir(): string {
	try {
		return dirname(require.resolve('@maxstack/harness'))
	} catch {
		throw new Error(
			'this command needs the maxstack checkout (the eval harness is not part of the published CLI)',
		)
	}
}

function spawnNode(
	script: string,
	args: string[],
	env: Record<string, string> = {},
	transformTypes = true,
): Promise<void> {
	return new Promise((res, reject) => {
		const child = spawn(
			process.execPath,
			[
				...(transformTypes ? ['--experimental-transform-types'] : []),
				script,
				...args,
			],
			{ stdio: 'inherit', env: { ...process.env, ...env } },
		)
		child.on('error', reject)
		child.on('close', (code) =>
			code === 0 ? res() : reject(new Error(`exited with code ${code}`)),
		)
	})
}

/** `maxstack eval [...args]` → the harness eval CLI (nightly benchmark run). */
export async function evalCommand(args: string[]): Promise<void> {
	await spawnNode(resolve(harnessSrcDir(), 'cli/eval.ts'), args)
}

/** `maxstack dogfood [...args]` → the harness dogfood scorer. */
export async function dogfoodCommand(args: string[]): Promise<void> {
	await spawnNode(resolve(harnessSrcDir(), 'cli/dogfood.ts'), args)
}

export interface DemoOptions {
	/** Port the running dev server is on, if any (default `PORT` env, then 3000). */
	port?: string
}

/**
 * Seed through a running `maxstack dev` server by POSTing its `/onboarding/seed`
 * route — the same server-side action the wizard's "Load demo data" button hits,
 * which runs `seedDemoData()` in the server's own process on its own db handle.
 *
 * Returns `true` when the running server handled the seed; `false` when nothing
 * is listening on the port (caller falls back to the in-process seed). Throws if
 * something *is* on the port but isn't a maxstack app, rather than silently
 * seeding into a second, invisible db view.
 */
async function seedViaRunningServer(port: string): Promise<boolean> {
	// `maxstack dev` now pins IPv4 loopback (`apps/web/vite.config.ts` sets
	// `server.host = '127.0.0.1'`), so `127.0.0.1` is tried first and normally
	// hits. `[::1]` stays as a fallback for a hand-run `pnpm dev` (unpinned host,
	// which commonly lands on IPv6 `::1` only): missing it there would ECONNREFUSE
	// and wrongly fall back to the in-process seed, reintroducing the very
	// concurrent-handle split this routes around (issue #100).
	for (const host of ['127.0.0.1', '[::1]']) {
		const res = await postSeed(`http://${host}:${port}/onboarding/seed`)
		if (res === 'refused') continue
		// The action awaits `seedDemoData()` before it responds, on the server's
		// own store handle — so any response here means the rows are already
		// committed and visible to the next `/api/<entity>` read (issue #99). A
		// JSON body (newer runtime) tells us exactly what landed; an older runtime
		// still redirects, surfacing as an opaque redirect (status 0) with
		// `redirect: 'manual'`. Accept any non-error status either way.
		if (res.status === 0 || (res.status >= 200 && res.status < 400)) {
			console.log(await seedSummary(res, port))
			return true
		}
		throw new Error(
			`something is listening on port ${port} but isn't a maxstack dev server ` +
				`(POST /onboarding/seed → ${res.status}). Stop it, or pass --port <n>.`,
		)
	}
	// Nothing on either loopback family: no dev server, seed in-process.
	return false
}

/** Build the success line from the seed action's response. A newer runtime
 * returns `{ seeded, resources }` as JSON, so we can name what committed (or say
 * nothing was seeded because data already existed); an older runtime redirects
 * with no body, so we fall back to the generic confirmation. */
export async function seedSummary(
	res: Response,
	port: string,
): Promise<string> {
	const via = `via the running dev server (port ${port})`
	if (!res.headers.get('content-type')?.includes('application/json')) {
		return `✓ demo data loaded ${via}`
	}
	try {
		const result = (await res.json()) as {
			seeded?: boolean
			resources?: string[]
		}
		if (result.seeded === false) {
			return `· nothing to seed ${via} — resources already have data`
		}
		const named = result.resources?.length
			? ` (${result.resources.join(', ')})`
			: ''
		return `✓ demo data loaded ${via}${named}`
	} catch {
		// Malformed body from something claiming JSON — the rows still committed
		// (the action awaited the seed), so report success without the detail.
		return `✓ demo data loaded ${via}`
	}
}

/** POST the seed route on one host. Returns the response, or the sentinel
 * `'refused'` when the connection was refused (nothing listening there). */
async function postSeed(url: string): Promise<Response | 'refused'> {
	try {
		return await fetch(url, {
			method: 'POST',
			headers: {
				'content-type': 'application/x-www-form-urlencoded',
				// Ask for the seed result as JSON so we report only what committed,
				// instead of treating an opaque redirect as "probably worked".
				accept: 'application/json',
			},
			body: 'redirectTo=/',
			redirect: 'manual',
		})
	} catch (err) {
		// Connection refused → nothing on this host. Re-throw anything else (a
		// mid-flight reset, a DNS failure) as a real error worth surfacing.
		if ((err as { cause?: { code?: string } }).cause?.code === 'ECONNREFUSED') {
			return 'refused'
		}
		throw err
	}
}

/**
 * `maxstack demo [dir]` → load sample data into the project's data dir
 * (issue #60 / task 63), headlessly. When a `maxstack dev` server is running it
 * routes the seed through that server (see `seedViaRunningServer`); otherwise it
 * spawns `apps/web/scripts/seed-demo.ts` the same way `dev` spawns the web app
 * itself (`MAXSTACK_DATA_DIR` grounds it in the project's spec + store), running
 * the exact `seedDemoData()` the onboarding wizard's "Load demo data" button and
 * the empty-state CTA call — one seed mechanism, reachable from the UI and CLI.
 */
export async function demoCommand(
	dir: string | undefined,
	opts: DemoOptions = {},
): Promise<void> {
	const project = await loadProject(dir ?? '.')
	const dataDir = resolve(project.root, project.config.dataDir)

	// Issue #100: if `maxstack dev` is running, it holds an exclusive on-disk
	// pglite handle (documented in `apps/web/app/sprout.server.ts`). A second
	// process opening the same db seeds into a private view the server never
	// sees, so those rows 404 on the live API even though the server created its
	// own rows fine. Route the seed *through* the running server instead — it
	// runs the exact same `seedDemoData()` in its own process, so seeded rows are
	// indistinguishable from API-created rows and delete through the same route.
	const port = opts.port ?? process.env.PORT ?? '3000'
	if (await seedViaRunningServer(port)) return

	// No dev server on the port: it's safe to open the db ourselves and seed
	// in-process (a single handle, no concurrent reader).
	const runtime = await resolveRuntime(project.root)
	const env = {
		MAXSTACK_DATA_DIR: dataDir,
		...(project.config.backend === 'postgres' && process.env.DATABASE_URL
			? { DATABASE_URL: process.env.DATABASE_URL }
			: {}),
	}
	if (runtime.mode === 'package') {
		// The runtime package ships the seed pre-bundled (plain node, no vite).
		await spawnNode(runtime.seedScript, [], env, false)
		return
	}
	await spawnNode(
		resolve(runtime.root, 'apps/web/scripts/seed-demo.ts'),
		[],
		env,
	)
}

/**
 * `maxstack dev [dir]` → run the platform web app over the project's data dir,
 * with three ergonomic guarantees the bare dev server didn't give:
 *
 *   - **self-heal + banner (issue #22):** write `.mcp.json` if it's missing (so
 *     older/hand-made projects get MCP auto-discovery too) and print a one-line
 *     "MCP tools live as `mcp__maxstack__*`" banner.
 *   - **auto-gen (issue #19):** regenerate the app tree on boot, then watch
 *     `spec.json` and regenerate on every change. This makes the "writable on
 *     the very next request — no gen ceremony" promise literally true: an
 *     accepted op lands, the on-disk `app/` tree follows automatically, no
 *     explicit `maxstack gen`.
 *   - **owned-slot hot loop (issue #41):** pass `MAXSTACK_PROJECT_APP_DIR` so
 *     the web app's `ownedSlotDevPlugin` (a vite plugin) can regenerate the
 *     owned-code manifest from the project's real app dir, live — previously
 *     `dev` only ever imported the committed empty `owned.generated.tsx`
 *     stub, so filled slot/ejected-route files never rendered until you ran
 *     `maxstack build`.
 *
 * Every mode serves on the same default port (3000, `--port`/`PORT` to
 * change), so the scaffolded `.mcp.json` default URL is right out of the box.
 *
 * From npm, `--owned` closes the owned-code gap without a checkout: it vendors
 * the runtime source snapshot under `.maxstack/runtime/` (once), installs it,
 * and runs *its* vite dev server — the same HMR + owned-slot hot loop as
 * checkout dev, pointed at the project's real app dir.
 */
export interface DevOptions {
	/** Run the vendored runtime's vite dev server so owned code executes. */
	owned?: boolean
	/** Port to serve on (default `PORT` env, then 3000). */
	port?: string
}

export async function devCommand(
	dir: string | undefined,
	opts: DevOptions = {},
): Promise<void> {
	const project = await loadProject(dir ?? '.')
	const dataDir = resolve(project.root, project.config.dataDir)
	const runtime = await resolveRuntime(project.root)
	const port = opts.port ?? process.env.PORT ?? '3000'

	// One canonical port: the app AND the MCP endpoint both live here, and
	// `.mcp.json` hard-points at it. If it's taken, fail before the dev server can
	// silently drift to another port (vite auto-increments) and strand `.mcp.json`
	// pointing at the wrong one.
	if (await portInUse(Number(port))) {
		throw new Error(
			`port ${port} is already in use.\n` +
				`  maxstack dev serves the app and the MCP endpoint on this one port, and\n` +
				`  .mcp.json points here — a silent fallback to another port would break it.\n` +
				`  Stop whatever is on ${port}, or run with --port <n> (and set\n` +
				`  MAXSTACK_MCP_URL=http://localhost:<n>/mcp so Claude Code finds the server).`,
		)
	}

	if (await ensureMcpJson(project.root)) {
		console.log('· wrote .mcp.json (MCP auto-discovery for Claude Code)')
	}
	console.log('MCP tools live as `mcp__maxstack__*` while this server runs.')

	// Keep the app tree in sync with the spec, automatically (issue #19).
	await regen(project, 'initial')
	const stop = watchSpec(project)

	console.log(
		`starting the maxstack web app over ${project.config.dataDir}/ (${project.config.backend})…`,
	)
	try {
		if (runtime.mode !== 'package') {
			// Checkout dev already runs owned code (the hot loop) — `--owned` is moot.
			await devFromCheckout(project, runtime, dataDir, port)
		} else if (opts.owned) {
			await devFromVendored(project, runtime, dataDir, port)
		} else {
			await devFromPackage(project, runtime, dataDir, port)
		}
	} finally {
		stop()
	}
}

/** Env every dev server variant needs: the data dir grounds the runtime in the
 * project, the app dir feeds the owned-slot hot loop (issue #41). */
function devEnv(project: Project, dataDir: string): Record<string, string> {
	return {
		MAXSTACK_DATA_DIR: dataDir,
		// Owned-slot dev hot loop (issue #41): the web app's vite plugin
		// (`ownedSlotDevPlugin`) regenerates the owned-code manifest from
		// this dir instead of the checked-in empty stub.
		MAXSTACK_PROJECT_APP_DIR: project.appPath,
		...(project.config.backend === 'postgres' && process.env.DATABASE_URL
			? { DATABASE_URL: process.env.DATABASE_URL }
			: {}),
	}
}

/** Whether `port` is already bound on localhost (so dev can fail before drifting).
 *
 * Probes BOTH stacks. Our own dev server now pins IPv4 loopback
 * (`apps/web/vite.config.ts` sets `server.host = '127.0.0.1'`), so the `127.0.0.1`
 * probe alone catches a maxstack server we started. But a *foreign* squatter can
 * hold the port on EITHER family — an IPv6-only listener would slip past an
 * IPv4-only probe and let dev proceed only to die on vite's strictPort bind, so
 * the `::1` probe stays as belt-and-suspenders. A collision on either stack means
 * the port isn't safely ours. */
function portInUse(port: number): Promise<boolean> {
	return Promise.all(
		['127.0.0.1', '::1'].map((host) => hostPortInUse(host, port)),
	).then((results) => results.some(Boolean))
}

/** Whether `port` is bound on one specific host. A host family that isn't available
 * (e.g. IPv6 disabled) counts as free on that stack, not an error. */
function hostPortInUse(host: string, port: number): Promise<boolean> {
	return new Promise((res) => {
		const probe = createServer()
		probe.once('error', (err: NodeJS.ErrnoException) =>
			res(err.code === 'EADDRINUSE' || err.code === 'EACCES'),
		)
		probe.once('listening', () => probe.close(() => res(false)))
		probe.listen(port, host)
	})
}

/** Spawn `pnpm run dev --port <port>` in a workspace's `apps/web`. The web vite
 * config sets `server.strictPort`, so vite binds exactly `port` or fails instead
 * of silently auto-incrementing off it (which would strand `.mcp.json`). */
function spawnWebDev(
	webDir: string,
	port: string,
	env: Record<string, string>,
): Promise<void> {
	return new Promise<void>((res, reject) => {
		const child = spawn('pnpm', ['run', 'dev', '--port', port], {
			cwd: webDir,
			stdio: 'inherit',
			env: { ...process.env, ...env },
		})
		child.on('error', reject)
		child.on('close', (code) =>
			code === 0
				? res()
				: reject(new Error(`web dev exited with code ${code}`)),
		)
	})
}

/** Checkout dev: vite dev server in `apps/web` — HMR + the owned-slot hot loop. */
function devFromCheckout(
	project: Project,
	runtime: Extract<Runtime, { mode: 'checkout' }>,
	dataDir: string,
	port: string,
): Promise<void> {
	console.log(
		`· maxstack checkout — vite dev server on http://localhost:${port} (HMR + owned code)`,
	)
	return spawnWebDev(
		resolve(runtime.root, 'apps/web'),
		port,
		devEnv(project, dataDir),
	)
}

/**
 * `dev --owned` from npm: run the *vendored* runtime's vite dev server, so
 * owned code (filled slots, ejected routes) executes with the same HMR hot
 * loop a maxstack checkout gets — no checkout needed. The vendored tree
 * (`.maxstack/runtime/`, cloned from the runtime package's source snapshot) is
 * reused across runs — the hot loop reads the project's *real* app dir live,
 * so a stale mirror inside the tree doesn't matter — and installed once with
 * pnpm (the one extra tool this path needs).
 */
async function devFromVendored(
	project: Project,
	runtime: Extract<Runtime, { mode: 'package' }>,
	dataDir: string,
	port: string,
): Promise<void> {
	const runtimeDir = resolve(project.root, '.maxstack', 'runtime')
	const webDir = resolve(runtimeDir, 'apps/web')

	// Reuse the vendored tree only when it exists AND was cloned by THIS maxstack
	// version. A tree left by an older release can't read a newer spec format —
	// reusing it 404s every page (and older runtimes even seed a fixture spec into
	// the project) — so a version-stamp mismatch forces a fresh vendor. `vendorRuntime`
	// wipes the whole tree (node_modules included), so the install step below re-runs.
	const stamp = await readRuntimeStamp(runtimeDir)
	const vendored = await pathExists(resolve(webDir, 'package.json'))
	if (!vendored || stamp !== runtime.version) {
		console.log(
			vendored
				? `· maxstack changed (runtime ${stamp ?? 'unstamped'} → ${runtime.version}); re-vendoring .maxstack/runtime/…`
				: '· vendoring the runtime source into .maxstack/runtime/ (one-time)…',
		)
		await vendorRuntime(project, runtime.root, runtime.version)
	} else {
		console.log(`· reusing .maxstack/runtime/ (runtime ${runtime.version})`)
	}

	if (!(await pathExists(resolve(runtimeDir, 'node_modules')))) {
		console.log('· installing the vendored runtime (pnpm, one-time)…')
		await new Promise<void>((res, reject) => {
			const child = spawn('pnpm', ['install', '--frozen-lockfile'], {
				cwd: runtimeDir,
				stdio: 'inherit',
				env: { ...process.env },
			})
			child.on('error', (err: NodeJS.ErrnoException) =>
				reject(
					err.code === 'ENOENT'
						? new Error(
								'`maxstack dev --owned` needs pnpm to install the vendored runtime — `npm install -g pnpm` (or `corepack enable`), then re-run.',
							)
						: err,
				),
			)
			child.on('close', (code) =>
				code === 0 ? res() : reject(new Error(`pnpm install exited ${code}`)),
			)
		})
	}

	console.log(
		`· vendored runtime dev server — http://localhost:${port} (owned code + HMR)`,
	)
	return spawnWebDev(webDir, port, devEnv(project, dataDir))
}

/**
 * Package dev: run the prebuilt react-router server shipped in
 * `maxstack-runtime` — no pnpm, no vite, no checkout. The runtime is a spec
 * interpreter that composes the app from `spec.json` at request time, so spec
 * changes show on the next request; the trade-off is that *owned code* (filled
 * slots, ejected routes) is compiled in at build time and the shipped server
 * carries the empty stub — point users at `dev --owned` (live) or
 * `maxstack build` (deployable image) for that.
 */
async function devFromPackage(
	project: Project,
	runtime: Extract<Runtime, { mode: 'package' }>,
	dataDir: string,
	port: string,
): Promise<void> {
	const serveBin = resolve(
		dirname(
			createRequire(resolve(runtime.pkgDir, 'package.json')).resolve(
				'@react-router/serve/package.json',
			),
		),
		'bin.cjs',
	)
	const owned = await countOwnedModules(project)
	console.log(
		`· prebuilt runtime (maxstack-runtime ${runtime.version}) — http://localhost:${port}` +
			(owned > 0
				? `\n· ⚠ ${owned} owned module(s) (filled slots / ejected routes) do NOT run in this server —` +
					'\n  re-run with `maxstack dev --owned` to serve them live, or `maxstack build`' +
					'\n  to compile them into a deployable image.'
				: '\n· owned code (filled slots / ejected routes) does not run in this server;' +
					'\n  use `maxstack dev --owned` (live) or `maxstack build` (image) when you fill one in.'),
	)
	return new Promise<void>((res, reject) => {
		const child = spawn(
			process.execPath,
			[serveBin, runtime.serverIndex],
			packageServeSpawnOptions(runtime, dataDir, port, project.config.backend),
		)
		child.on('error', reject)
		child.on('close', (code) =>
			code === 0
				? res()
				: reject(new Error(`web dev exited with code ${code}`)),
		)
	})
}

/**
 * Spawn options for the prebuilt-runtime react-router server.
 *
 * The critical bit is `cwd: runtime.pkgDir` (issue #94): react-router-serve
 * serves static assets via `express.static(build.assetsBuildDirectory)`, and
 * `assetsBuildDirectory` is a *relative* path (`build/client`) resolved against
 * `process.cwd()`. With no cwd the child inherits the user's project dir, so the
 * static handler looks under `<project>/build/client/assets/…` — which doesn't
 * exist — and serves every `/assets/*` as 404 (unstyled, un-hydrated app). SSR
 * survives only because `serverIndex` is an absolute path. Pinning cwd to the
 * runtime package dir (which contains `build/`) fixes both.
 */
export function packageServeSpawnOptions(
	runtime: Extract<Runtime, { mode: 'package' }>,
	dataDir: string,
	port: string,
	backend: Project['config']['backend'],
): { stdio: 'inherit'; cwd: string; env: NodeJS.ProcessEnv } {
	return {
		stdio: 'inherit',
		cwd: runtime.pkgDir,
		env: {
			...process.env,
			NODE_ENV: 'production',
			PORT: port,
			MAXSTACK_DATA_DIR: dataDir,
			...(backend === 'postgres' && process.env.DATABASE_URL
				? { DATABASE_URL: process.env.DATABASE_URL }
				: {}),
		},
	}
}

/** How many owned modules (filled slots + ejected routes) the project carries —
 * the same count `vendorRuntime` compiles in. Zero when there's no manifest. */
async function countOwnedModules(project: Project): Promise<number> {
	try {
		const manifest = parseManifest(
			await readFile(resolve(project.appPath, MANIFEST_FILENAME), 'utf8'),
		)
		return manifest.entries.filter(
			(e) => e.slotFile || e.ownership === 'ejected',
		).length
	} catch {
		return 0
	}
}

/** Regenerate the app tree, logging a compact one-liner (never throwing out of
 * the watcher — a bad in-progress edit shouldn't kill the dev server). */
async function regen(project: Project, label: string): Promise<void> {
	try {
		const { writes, artifacts } = await generateProject(project)
		const changed = writes.filter(
			(w) => w.action !== 'unchanged' && w.action !== 'skipped-user-owned',
		).length
		console.log(
			`· gen (${label}): ${changed} changed · ${writes.length} routes · ${artifacts.length} artifacts`,
		)
	} catch (err) {
		console.warn(`· gen (${label}) failed: ${(err as Error).message}`)
	}
}

/**
 * Watch the project's `spec/` directory for changes and regenerate, debounced.
 * Returns a stopper.
 *
 * We watch the spec *directory*, not individual files: the store saves each
 * layer file atomically (write `<file>.tmp` then rename over it), and a
 * file-level `fs.watch` goes dead the moment the inode it's holding is replaced.
 * A non-recursive directory watch survives those renames and sees every layer
 * file's write; the generator's own output lands under `app/`, never here, so it
 * can't feed back into the watch.
 */
function watchSpec(project: Project): () => void {
	let timer: NodeJS.Timeout | null = null
	let watcher: ReturnType<typeof watch> | null = null
	try {
		watcher = watch(project.specDir, () => {
			if (timer) clearTimeout(timer)
			// Debounce: an atomic save fires many events (a tmp + rename per layer
			// file) in a burst; collapse them into one regen.
			timer = setTimeout(() => void regen(project, 'spec change'), 150)
		})
	} catch {
		// Watching is best-effort — an unusual FS just means manual `gen` still works.
	}
	return () => {
		if (timer) clearTimeout(timer)
		watcher?.close()
	}
}
