/**
 * upgrade-layout.mjs — C36 migration from the pre-C36 .aperant/ folder shape
 * to the normalized layout:
 *
 *   .aperant/contexts/*         → .aperant/context/installed/*   (durable-source)
 *   .aperant/context/*.md       → .aperant/context/notes/*.md    (local-private ledgers)
 *   .aperant/plans/             → removed (must be empty; error if not)
 *
 * Contract (mirrors upgrade-gitignore.mjs):
 *   - Deterministic: sibling files sorted; destination paths computed purely.
 *   - Idempotent: a second run finds no sources → status:'noop', moves:[].
 *   - Non-destructive: refuses to delete a non-empty plans/ (status:'conflict').
 *   - Atomic-per-file: each renameSync is individually atomic; on cross-device
 *     (EXDEV) we fall back to copyFileSync + unlinkSync. Aborts on first
 *     failure without attempting rollback — each successful rename is durable,
 *     so a re-run picks up whatever remains.
 *   - Collision-safe: every destination path is checked for pre-existence
 *     before any write; emits status:'conflict' if any destination exists.
 *
 * After the physical moves succeed, cascades to runGitignoreUpgrade so the
 * .aperant/.gitignore picks up the new allowlist (`!context/installed/**`).
 *
 * @internal
 */

import {
	copyFileSync,
	existsSync,
	mkdirSync,
	readdirSync,
	renameSync,
	rmdirSync,
	statSync,
	unlinkSync,
} from 'node:fs'
import { join, resolve } from 'node:path'
import { parseFlags } from '../util/args.mjs'
import { withFileLock } from '../util/fs-lock.mjs'
import { err, exitWith, ok } from '../util/result.mjs'
import { runGitignoreUpgrade } from './upgrade-gitignore.mjs'

/**
 * List plain files at the top level of `dir`. Returns [] if the directory
 * does not exist. Ignores subdirectories (they may be the new-shape
 * children we are migrating INTO).
 */
function listTopLevelFiles(dir) {
	if (!existsSync(dir)) return []
	const out = []
	for (const entry of readdirSync(dir)) {
		const full = join(dir, entry)
		try {
			if (statSync(full).isFile()) out.push(entry)
		} catch {
			/* skip unreadable entries */
		}
	}
	return out.sort()
}

/**
 * Plain-file count at `dir` (recursive). Used for the non-empty `plans/`
 * conflict check — we refuse to silently delete anything.
 */
function countFilesRecursive(dir) {
	if (!existsSync(dir)) return 0
	let n = 0
	for (const entry of readdirSync(dir)) {
		const full = join(dir, entry)
		try {
			const s = statSync(full)
			if (s.isFile()) n += 1
			else if (s.isDirectory()) n += countFilesRecursive(full)
		} catch {
			/* skip */
		}
	}
	return n
}

/**
 * Plan the migration without touching the disk. Pure function — returns the
 * set of moves, directory removals, and any conflicts detected up front.
 *
 * @returns {{
 *   moves: Array<{from: string, to: string, reason: string}>,
 *   removeDirs: string[],
 *   conflicts: Array<{kind: string, path: string, detail?: string}>,
 *   collisions: string[],
 * }}
 */
function planMigration(aperantDir) {
	const moves = []
	const removeDirs = []
	const conflicts = []
	const collisions = []

	// (1) .aperant/contexts/* → .aperant/context/installed/*
	const oldContextsDir = join(aperantDir, 'contexts')
	const newInstalledDir = join(aperantDir, 'context', 'installed')
	for (const f of listTopLevelFiles(oldContextsDir)) {
		const from = join(oldContextsDir, f)
		const to = join(newInstalledDir, f)
		if (existsSync(to)) collisions.push(to)
		moves.push({ from, to, reason: 'contexts→context/installed' })
	}
	if (existsSync(oldContextsDir)) removeDirs.push(oldContextsDir)

	// (2) .aperant/context/*.md (top-level files) → .aperant/context/notes/*.md
	//     Leave context/installed/ and context/notes/ subdirs untouched.
	const contextDir = join(aperantDir, 'context')
	const newNotesDir = join(contextDir, 'notes')
	for (const f of listTopLevelFiles(contextDir)) {
		const from = join(contextDir, f)
		const to = join(newNotesDir, f)
		if (existsSync(to)) collisions.push(to)
		moves.push({ from, to, reason: 'context/→context/notes' })
	}

	// (3) .aperant/plans/ — must be empty; refuse to delete otherwise.
	const plansDir = join(aperantDir, 'plans')
	if (existsSync(plansDir)) {
		const filesInPlans = countFilesRecursive(plansDir)
		if (filesInPlans > 0) {
			conflicts.push({
				kind: 'plans-non-empty',
				path: plansDir,
				detail: `${filesInPlans} file(s) present — relocate manually before re-running upgrade-layout`,
			})
		} else {
			removeDirs.push(plansDir)
		}
	}

	return { moves, removeDirs, conflicts, collisions }
}

/**
 * Rename a single file, falling back to copy+unlink on EXDEV (cross-device).
 */
function renameWithExdevFallback(from, to) {
	try {
		renameSync(from, to)
	} catch (e) {
		if (e && e.code === 'EXDEV') {
			copyFileSync(from, to)
			unlinkSync(from)
			return
		}
		throw e
	}
}

/**
 * Main entry. Wrapped in a file lock keyed on
 * `.aperant/config.lock-upgrade-layout` so concurrent callers serialize.
 *
 * @param {string} projectRoot
 * @param {{ dryRun?: boolean }} [opts]
 * @returns {{
 *   status: 'ok' | 'noop' | 'conflict',
 *   dryRun: boolean,
 *   moves: Array<{from: string, to: string, ok: boolean, error?: string}>,
 *   removedDirs: string[],
 *   conflicts: Array<{kind: string, path: string, detail?: string}>,
 *   collisions: string[],
 *   gitignoreResult: object | null,
 *   message: string,
 * }}
 */
export function runLayoutUpgrade(projectRoot, opts = {}) {
	const dryRun = opts.dryRun === true
	const targetDir = resolve(projectRoot)
	const aperantDir = join(targetDir, '.aperant')
	const lockPath = join(aperantDir, 'config.lock-upgrade-layout')

	// Ensure the lock file's parent exists even on a freshly-cloned repo that
	// has no .aperant/ yet — withFileLock would otherwise reject on mkdir.
	if (!existsSync(aperantDir)) {
		return {
			status: 'noop',
			dryRun,
			moves: [],
			removedDirs: [],
			conflicts: [],
			collisions: [],
			gitignoreResult: null,
			message: 'No .aperant/ directory — nothing to migrate.',
		}
	}

	return withFileLock(
		lockPath,
		() => {
			const plan = planMigration(aperantDir)

			// Conflict: non-empty plans/. Refuse to proceed.
			if (plan.conflicts.length > 0) {
				return {
					status: 'conflict',
					dryRun,
					moves: [],
					removedDirs: [],
					conflicts: plan.conflicts,
					collisions: plan.collisions,
					gitignoreResult: null,
					message:
						'Migration blocked: non-empty plans/ directory. Relocate its contents manually before re-running.',
				}
			}

			// Collision: a destination file already exists. Refuse to clobber.
			if (plan.collisions.length > 0) {
				return {
					status: 'conflict',
					dryRun,
					moves: [],
					removedDirs: [],
					conflicts: [],
					collisions: plan.collisions,
					gitignoreResult: null,
					message: 'Migration blocked: destination path(s) already exist.',
				}
			}

			// Nothing to do — idempotent second run.
			if (plan.moves.length === 0 && plan.removeDirs.length === 0) {
				const gi = dryRun ? null : runGitignoreUpgrade(targetDir)
				return {
					status: 'noop',
					dryRun,
					moves: [],
					removedDirs: [],
					conflicts: [],
					collisions: [],
					gitignoreResult: gi,
					message: 'No legacy paths detected — layout already at C36 shape.',
				}
			}

			// Dry-run: report the plan and exit.
			if (dryRun) {
				return {
					status: 'ok',
					dryRun: true,
					moves: plan.moves.map((m) => ({ from: m.from, to: m.to, ok: false })),
					removedDirs: plan.removeDirs,
					conflicts: [],
					collisions: [],
					gitignoreResult: null,
					message: `Dry run: would move ${plan.moves.length} file(s) and remove ${plan.removeDirs.length} empty dir(s).`,
				}
			}

			// Apply: mkdir parents, rename each file, then rmdir empty legacy dirs.
			const performedMoves = []
			let failingMove = null
			try {
				for (const m of plan.moves) {
					failingMove = m
					const destParent = join(m.to, '..')
					mkdirSync(destParent, { recursive: true })
					renameWithExdevFallback(m.from, m.to)
					performedMoves.push({ from: m.from, to: m.to, ok: true })
					failingMove = null
				}
			} catch (e) {
				return {
					status: 'conflict',
					dryRun,
					moves: performedMoves.concat([
						{
							from: failingMove?.from ?? 'unknown',
							to: failingMove?.to ?? 'unknown',
							ok: false,
							error: String(e?.message ?? e),
						},
					]),
					removedDirs: [],
					conflicts: [],
					collisions: [],
					gitignoreResult: null,
					message:
						'Migration aborted mid-apply. Each completed rename is atomic; re-run upgrade-layout to resume.',
				}
			}

			const removedDirs = []
			for (const d of plan.removeDirs) {
				try {
					// Only remove if now empty (post-move) — preserves safety for
					// any stray subdirs we did not explicitly migrate.
					if (existsSync(d) && readdirSync(d).length === 0) {
						rmdirSync(d)
						removedDirs.push(d)
					}
				} catch {
					/* non-fatal; user can clean up by hand */
				}
			}

			// Cascade: rewrite .aperant/.gitignore to the new allowlist.
			const gitignoreResult = runGitignoreUpgrade(targetDir)

			return {
				status: 'ok',
				dryRun: false,
				moves: performedMoves,
				removedDirs,
				conflicts: [],
				collisions: [],
				gitignoreResult,
				message: `Migrated ${performedMoves.length} file(s); removed ${removedDirs.length} empty legacy dir(s).`,
			}
		},
		{ projectRoot: targetDir },
	)
}

export { listTopLevelFiles as _listTopLevelFilesForTests, planMigration as _planMigrationForTests }

/**
 * CLI entry wrapper mirroring cmdConfigUpgradeGitignore's shape.
 */
export function cmdConfigUpgradeLayout(projectDir, extraArgs = []) {
	if (!projectDir) {
		return err('Usage: apt-tools config upgrade-layout <project-dir> [--dry-run]')
	}
	const targetDir = resolve(projectDir)
	const flags = parseFlags(extraArgs)
	const dryRun = flags.has('dry-run')
	const result = runLayoutUpgrade(targetDir, { dryRun })
	const envelope = {
		status: result.status,
		command: 'config-upgrade-layout',
		projectDir: targetDir,
		dryRun: result.dryRun,
		moves: result.moves,
		removedDirs: result.removedDirs,
		conflicts: result.conflicts,
		collisions: result.collisions,
		gitignoreResult: result.gitignoreResult,
		message: result.message,
	}
	return result.status === 'conflict' ? exitWith(envelope, 2) : ok(envelope)
}
