/**
 * install/runtime-migrate.mjs — at-install cleanup of legacy install
 * trees the framework no longer owns (FRAMEWORK-BUG-021).
 *
 * Codex shuffled install roots twice:
 *   - Pre-0.7: installed at `.agents/skills/` (commit 1a8dd8a8).
 *   - v0.7/v0.8: moved to `.codex/agents/` + `.codex/commands/` — a
 *     misdirect, since Codex CLI 0.132.0 doesn't discover skills there.
 *   - v0.8.2 (this fix): moved back to `.agents/skills/<folder>/SKILL.md`
 *     (Codex's documented surface). The v0.7/v0.8 `.codex/` markdown
 *     tree is now the legacy target — `LEGACY_INSTALL_PATHS.codex` was
 *     re-pointed accordingly.
 *
 * `runLegacyCleanup` runs once per `apt-tools init` when codex is in the
 * requested runtime set. It is conservative by default:
 *
 *   - Only files listed in `LEGACY_INSTALL_PATHS.codex.framework_files`
 *     are removed. User-added skill directories (`my-skill/`, …) are
 *     preserved. If anything beyond the known framework set remains,
 *     `retained[]` lists those paths.
 *   - A present-and-current `apt-file-manifest.json` is treated as the
 *     authoritative "this is a managed install, leave it alone" signal
 *     — but ONLY when the legacy entry has `honor_manifest: true` (the
 *     default). When `honor_manifest: false` (the current codex entry,
 *     because `.codex/` is known-dead), the manifest is removed alongside
 *     framework files.
 *   - The caller can suppress cleanup entirely via `--keep-legacy` at the
 *     init.mjs site; the function itself is dumb about flags.
 */

import { existsSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { LEGACY_INSTALL_PATHS } from './legacy-paths.mjs'
import { MANIFEST_FILENAME } from './manifest.mjs'

/**
 * @typedef {Object} LegacyCodexDetection
 * @property {boolean} present              True when the legacy install_root
 *                                          (currently `.codex/`) exists.
 * @property {string[]} frameworkFiles      Subset of `framework_files` that
 *                                          actually exist on disk (relative
 *                                          to install_root).
 * @property {string[]} userFiles           Everything else under
 *                                          install_root (relative to
 *                                          install_root). May include both
 *                                          files and directories. Drives the
 *                                          `retained[]` warning.
 * @property {boolean} hasAptVersion        Whether the legacy `.apt-version`
 *                                          stamp is present.
 * @property {boolean} hasManifest          Whether an
 *                                          `apt-file-manifest.json` is
 *                                          present at install_root. When the
 *                                          legacy entry has
 *                                          `honor_manifest: true` (default)
 *                                          this signals a managed install
 *                                          and cleanup no-ops; when
 *                                          `honor_manifest: false` the
 *                                          manifest is treated as orphan
 *                                          and removed alongside framework
 *                                          files.
 */

const LEGACY = LEGACY_INSTALL_PATHS.codex

/**
 * Recursively enumerate every file path under `dir`, returning paths
 * relative to `dir`. Used to compute the "user files" set (anything not
 * in the framework_files list).
 *
 * Returns []  when the dir doesn't exist. Swallows per-entry errors so a
 * single unreadable symlink doesn't abort detection.
 *
 * @param {string} dir
 * @returns {string[]}
 */
function walkRelative(dir) {
	/** @type {string[]} */
	const out = []
	if (!existsSync(dir)) return out
	/** @param {string} current @param {string} rel */
	const visit = (current, rel) => {
		let entries
		try {
			entries = readdirSync(current, { withFileTypes: true })
		} catch {
			return
		}
		for (const entry of entries) {
			const childPath = join(current, entry.name)
			const childRel = rel ? `${rel}/${entry.name}` : entry.name
			if (entry.isDirectory()) {
				visit(childPath, childRel)
			} else if (entry.isFile()) {
				out.push(childRel)
			}
			// Symlinks / other entry types are intentionally ignored — the
			// framework only ever writes regular files.
		}
	}
	visit(dir, '')
	return out
}

/**
 * Probe the codex legacy install tree at `<targetDir>/<LEGACY.install_root>`
 * (currently `.codex/` after the v0.8.2 registry inversion — the v0.7/v0.8
 * misdirect topology).
 *
 * @param {string} targetDir
 * @returns {LegacyCodexDetection}
 */
export function detectLegacyCodexTree(targetDir) {
	const installRoot = join(targetDir, LEGACY.install_root)
	if (!existsSync(installRoot)) {
		return {
			present: false,
			frameworkFiles: [],
			userFiles: [],
			hasAptVersion: false,
			hasManifest: false,
		}
	}

	const onDisk = walkRelative(installRoot)
	const frameworkSet = new Set(LEGACY.framework_files)
	const frameworkFiles = onDisk.filter((p) => frameworkSet.has(p))
	const userFiles = onDisk.filter((p) => !frameworkSet.has(p) && p !== MANIFEST_FILENAME)

	return {
		present: true,
		frameworkFiles,
		userFiles,
		hasAptVersion: onDisk.includes('.apt-version'),
		hasManifest: onDisk.includes(MANIFEST_FILENAME),
	}
}

/**
 * Try to remove a directory if it is empty. Swallows ENOTEMPTY / ENOENT
 * so the caller can blindly sweep candidate dirs in any order.
 *
 * @param {string} dir
 * @returns {boolean} true iff the dir was actually removed.
 */
function rmEmptyDir(dir) {
	if (!existsSync(dir)) return false
	try {
		rmdirSync(dir)
		return true
	} catch {
		return false
	}
}

/**
 * Clean up the legacy install tree at `<targetDir>/<LEGACY.install_root>`
 * if it looks orphaned. Post v0.8.2 the codex legacy root is `.codex/`
 * with framework files at `agents/<file>.md` + `commands/<file>.md` +
 * `apt-file-manifest.json`.
 *
 * Branches:
 *   1. `present: false` OR `frameworkFiles.length === 0` → no-op,
 *      `{ ran: false, reason: 'no-legacy' }`.
 *   2. `hasManifest: true` AND `LEGACY.honor_manifest !== false`
 *      (default true) → managed install, no-op,
 *      `{ ran: false, reason: 'managed' }`. When `honor_manifest: false`
 *      (known-dead surface like the v0.7/v0.8 codex misdirect) the
 *      manifest is treated as orphan and removed alongside framework
 *      files.
 *   3. Otherwise: per-file `unlinkSync` each framework-owned file;
 *      `rmdirSync` any subdirectory left empty after the unlinks (e.g.
 *      `.codex/agents/`, `.codex/commands/`); sweep the install_root
 *      itself only if it is fully empty after the per-file pass; sweep
 *      the install_root's parent only if it too became empty (relevant
 *      for legacy topologies two levels deep, e.g. the pre-0.7
 *      `.agents/skills/` codex layout).
 *
 * Returns `{ ran: true, removed, retained, rmdirs }` on the cleanup
 * branch:
 *   - `removed`  — relative paths under `install_root` that were unlinked.
 *   - `retained` — relative paths under `install_root` that were NOT
 *     removed (user-added skills / files not in the legacy set, e.g.
 *     `.codex/config.toml`).
 *   - `rmdirs`   — absolute paths of directories that were removed,
 *     in removal order.
 *
 * Atomicity: per-file unlink is non-atomic. A partial-failure scenario
 * (process killed mid-loop) is tolerable — the next `init` run will
 * re-detect the remaining framework files and finish the sweep.
 *
 * @param {{ targetDir: string, dryRun?: boolean }} args
 * @returns {
 *   | { ran: false, reason: 'no-legacy' | 'managed' }
 *   | { ran: true, removed: string[], retained: string[], rmdirs: string[] }
 * }
 */
export function runLegacyCleanup({ targetDir, dryRun = false }) {
	const detection = detectLegacyCodexTree(targetDir)

	if (!detection.present || detection.frameworkFiles.length === 0) {
		return { ran: false, reason: 'no-legacy' }
	}
	// `honor_manifest: false` means the legacy install_root is a known-dead
	// surface (the v0.7/v0.8 codex misdirect). A manifest sitting there is
	// by definition stale — fall through and remove it alongside framework
	// files. `honor_manifest: true` (default) preserves the managed-install
	// safety for legacy roots that could legitimately re-host a managed
	// install.
	const honorManifest = LEGACY.honor_manifest !== false
	if (detection.hasManifest && honorManifest) {
		return { ran: false, reason: 'managed' }
	}

	const installRoot = join(targetDir, LEGACY.install_root)
	const removed = []
	const rmdirs = []

	if (dryRun) {
		// Dry-run: report what WOULD be removed without touching the filesystem.
		// `retained` is the user-file set from the pre-walk (no sweep occurred).
		// `rmdirs` is the set of apt-* subdirs that would become empty after removal.
		const wouldRemove = detection.frameworkFiles
		const retained = detection.userFiles
		const candidateDirs = new Set()
		for (const rel of wouldRemove) {
			let parent = dirname(rel)
			while (parent !== '.' && parent !== '') {
				candidateDirs.add(parent)
				parent = dirname(parent)
			}
		}
		const wouldRmdirs = [...candidateDirs]
			.sort((a, b) => b.length - a.length)
			.map((rel) => join(installRoot, rel))
		return { ran: true, dryRun: true, removed: wouldRemove, retained, rmdirs: wouldRmdirs }
	}

	for (const rel of detection.frameworkFiles) {
		const abs = join(installRoot, rel)
		try {
			unlinkSync(abs)
			removed.push(rel)
		} catch {
			/* best-effort: leave a partial sweep; next run finishes it */
		}
	}

	// Sweep any now-empty `apt-*/` subdirs the unlinks left behind.
	// Walk parent dirs of every removed file in reverse-depth order so
	// nested `apt-pr-review/prompts/` empties before `apt-pr-review/`.
	const candidateDirs = new Set()
	for (const rel of removed) {
		let parent = dirname(rel)
		while (parent !== '.' && parent !== '') {
			candidateDirs.add(parent)
			parent = dirname(parent)
		}
	}
	const sortedDirs = [...candidateDirs].sort((a, b) => b.length - a.length)
	for (const rel of sortedDirs) {
		const abs = join(installRoot, rel)
		if (rmEmptyDir(abs)) rmdirs.push(abs)
	}

	// Sweep the legacy install_root only if fully empty after the per-file
	// pass — preserves any user-added content under it (e.g. `.codex/config.toml`
	// when LEGACY.install_root is `.codex/`).
	if (rmEmptyDir(installRoot)) {
		rmdirs.push(installRoot)
		// Sweep the install_root's parent only if it became empty too
		// (relevant for legacy topologies whose install_root sits two
		// levels deep, e.g. the pre-0.7 `.agents/skills/` codex layout).
		// No-op when install_root is a top-level dir like `.codex/` —
		// rmEmptyDir refuses to remove a non-empty project root.
		const agentsRoot = dirname(installRoot)
		if (rmEmptyDir(agentsRoot)) rmdirs.push(agentsRoot)
	}

	// Use the pre-walk's userFiles as `retained` — the post-walk only existed
	// to defensively re-scan, but pre-walk already captured user files accurately.
	// (PRF-002: avoid redundant detectLegacyCodexTree call.)
	const retained = detection.userFiles

	return { ran: true, removed, retained, rmdirs }
}
