/**
 * commands/toolchain-detect.mjs — toolchain-detect command (C29 + C54).
 *
 * C54 extensions:
 *   - Accept `--path <subdir>` so callers can detect ecosystems at an
 *     arbitrary monorepo subdir (or worktree path). Default: project root.
 *     No change to existing call-sites that omit `--path`.
 *   - New top-level `ecosystems` array enumerating every detected ecosystem
 *     at `<path>` with { ecosystem, manifest, lockfile, lockfile_present,
 *     multi_lockfile_paths, warning? }. Consumers of worktree-install use
 *     this array; the existing `package_manager` / `toolchain` fields are
 *     preserved unchanged so back-compat is byte-identical.
 *   - Multi-lockfile detection: when >1 JS-ecosystem lockfile coexists at
 *     the target path (e.g. pnpm-lock.yaml + package-lock.json), populate
 *     `multi_lockfile_paths`. The manifest scanner (worktree-install R5)
 *     treats this as a hard error because mtime-based resolution is
 *     unreliable after a git checkout.
 *   - Yarn Berry: `.yarnrc.yml` with `nodeLinker: pnp` (or with any
 *     declared berry-style nodeLinker) ⇒ ecosystem `yarn-berry` rather
 *     than `yarn`, so the installer uses `yarn install --immutable`.
 *   - Bun lockfile migration: `bun.lock` (text, post-Jan 2025) and
 *     `bun.lockb` (legacy binary) are both detected; bun.lockb-only
 *     surfaces a non-fatal `warning` on the ecosystem entry.
 */

import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseFlags } from '../util/args.mjs'
import { err, ok } from '../util/result.mjs'

/**
 * JS-ecosystem lockfile candidates. Listed in preference order for
 * primary-package-manager fallback, but multi-lockfile detection compares
 * the full set.
 */
const JS_LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lock', 'bun.lockb']

function isBerryNodeLinker(yarnrcText) {
	// `nodeLinker: pnp` or `nodeLinker: pnpm` — both indicate Berry. We don't
	// parse YAML; a simple regex is sufficient because `.yarnrc.yml` is
	// hand-authored and tiny.
	if (!yarnrcText) return false
	return /^\s*nodeLinker\s*:\s*(pnp|pnpm)\s*$/m.test(yarnrcText)
}

/**
 * Detect every ecosystem present at `targetPath`.
 *
 * Returns an array of entries:
 *   {
 *     ecosystem: string,
 *     manifest: string,
 *     lockfile: string | null,
 *     lockfile_present: boolean,
 *     multi_lockfile_paths: string[] | null,
 *     warning?: string,
 *   }
 *
 * @param {string} targetPath absolute path to inspect
 * @returns {Array<object>}
 */
export function detectEcosystems(targetPath) {
	const ecosystems = []

	// --- JS ecosystems (share package.json, distinguished by lockfile) ---
	if (existsSync(join(targetPath, 'package.json'))) {
		const presentJsLockfiles = JS_LOCKFILES.filter((lock) => existsSync(join(targetPath, lock)))
		const multiLockfilePaths = presentJsLockfiles.length > 1 ? presentJsLockfiles : null

		// Resolve primary JS ecosystem by lockfile precedence: pnpm > yarn > bun > npm.
		// When a lockfile is missing entirely we still register npm as the ecosystem,
		// because `npm ci` would fail on no lockfile but the installer needs to know
		// the ecosystem exists. Consumers decide whether to proceed.
		let jsEcosystem = 'npm'
		let jsLockfile = null
		let jsWarning
		if (existsSync(join(targetPath, 'pnpm-lock.yaml'))) {
			jsEcosystem = 'pnpm'
			jsLockfile = 'pnpm-lock.yaml'
		} else if (existsSync(join(targetPath, 'yarn.lock'))) {
			// Yarn Berry detection: `.yarnrc.yml` with `nodeLinker: pnp`
			// (or `pnpm`) flags Berry.
			let yarnrcText = null
			try {
				if (existsSync(join(targetPath, '.yarnrc.yml'))) {
					yarnrcText = readFileSync(join(targetPath, '.yarnrc.yml'), 'utf-8')
				}
			} catch {
				/* unreadable */
			}
			if (isBerryNodeLinker(yarnrcText)) {
				jsEcosystem = 'yarn-berry'
			} else {
				jsEcosystem = 'yarn'
			}
			jsLockfile = 'yarn.lock'
		} else if (existsSync(join(targetPath, 'bun.lock'))) {
			jsEcosystem = 'bun'
			jsLockfile = 'bun.lock'
		} else if (existsSync(join(targetPath, 'bun.lockb'))) {
			jsEcosystem = 'bun'
			jsLockfile = 'bun.lockb'
			jsWarning =
				'bun.lockb is the legacy binary format; consider migrating to bun.lock (text, post-Jan 2025)'
		} else if (existsSync(join(targetPath, 'package-lock.json'))) {
			jsEcosystem = 'npm'
			jsLockfile = 'package-lock.json'
		}
		const entry = {
			ecosystem: jsEcosystem,
			manifest: 'package.json',
			lockfile: jsLockfile,
			lockfile_present: jsLockfile !== null,
			multi_lockfile_paths: multiLockfilePaths,
		}
		if (jsWarning) entry.warning = jsWarning
		ecosystems.push(entry)
	}

	// --- Python ---
	if (existsSync(join(targetPath, 'pyproject.toml'))) {
		let pyproject = ''
		try {
			pyproject = readFileSync(join(targetPath, 'pyproject.toml'), 'utf-8')
		} catch {
			/* unreadable */
		}
		let pyEco = null
		let pyLock = null
		if (existsSync(join(targetPath, 'uv.lock'))) {
			pyEco = 'uv'
			pyLock = 'uv.lock'
		} else if (pyproject && /\[tool\.uv\]/.test(pyproject)) {
			pyEco = 'uv'
			pyLock = null
		} else if (existsSync(join(targetPath, 'poetry.lock'))) {
			pyEco = 'poetry'
			pyLock = 'poetry.lock'
		} else if (pyproject && /\[tool\.poetry\]/.test(pyproject)) {
			pyEco = 'poetry'
			pyLock = null
		}
		if (pyEco) {
			ecosystems.push({
				ecosystem: pyEco,
				manifest: 'pyproject.toml',
				lockfile: pyLock,
				lockfile_present: pyLock !== null,
				multi_lockfile_paths: null,
			})
		}
	}

	// --- Python: pip-venv fallback (requirements.txt without pyproject.toml) ---
	if (
		!existsSync(join(targetPath, 'pyproject.toml')) &&
		existsSync(join(targetPath, 'requirements.txt'))
	) {
		ecosystems.push({
			ecosystem: 'pip-venv',
			manifest: 'requirements.txt',
			lockfile: null,
			lockfile_present: false,
			multi_lockfile_paths: null,
		})
	}

	// --- Rust / cargo ---
	if (existsSync(join(targetPath, 'Cargo.toml'))) {
		ecosystems.push({
			ecosystem: 'cargo',
			manifest: 'Cargo.toml',
			lockfile: existsSync(join(targetPath, 'Cargo.lock')) ? 'Cargo.lock' : null,
			lockfile_present: existsSync(join(targetPath, 'Cargo.lock')),
			multi_lockfile_paths: null,
		})
	}

	// --- Go ---
	if (existsSync(join(targetPath, 'go.mod'))) {
		ecosystems.push({
			ecosystem: 'go',
			manifest: 'go.mod',
			lockfile: existsSync(join(targetPath, 'go.sum')) ? 'go.sum' : null,
			lockfile_present: existsSync(join(targetPath, 'go.sum')),
			multi_lockfile_paths: null,
		})
	}

	// --- Ruby / bundler ---
	if (existsSync(join(targetPath, 'Gemfile'))) {
		ecosystems.push({
			ecosystem: 'bundler',
			manifest: 'Gemfile',
			lockfile: existsSync(join(targetPath, 'Gemfile.lock')) ? 'Gemfile.lock' : null,
			lockfile_present: existsSync(join(targetPath, 'Gemfile.lock')),
			multi_lockfile_paths: null,
		})
	}

	// --- PHP / composer ---
	if (existsSync(join(targetPath, 'composer.json'))) {
		ecosystems.push({
			ecosystem: 'composer',
			manifest: 'composer.json',
			lockfile: existsSync(join(targetPath, 'composer.lock')) ? 'composer.lock' : null,
			lockfile_present: existsSync(join(targetPath, 'composer.lock')),
			multi_lockfile_paths: null,
		})
	}

	// --- Gradle ---
	const gradleManifest = existsSync(join(targetPath, 'build.gradle.kts'))
		? 'build.gradle.kts'
		: existsSync(join(targetPath, 'build.gradle'))
			? 'build.gradle'
			: null
	if (gradleManifest) {
		ecosystems.push({
			ecosystem: 'gradle',
			manifest: gradleManifest,
			lockfile: null, // Gradle uses per-config .lockfile files we don't track individually
			lockfile_present: false,
			multi_lockfile_paths: null,
		})
	}

	return ecosystems
}

export function cmdToolchainDetect(projectDir, extraArgs = []) {
	if (!projectDir) return err('Usage: apt-tools toolchain-detect <project-dir> [--path <subdir>]')
	const targetDir = resolve(projectDir)

	if (!existsSync(targetDir)) return err(`Directory does not exist: ${targetDir}`)

	const flags = parseFlags(extraArgs)
	// C54 — --path scopes ecosystem detection to an arbitrary subdir. The
	// legacy package_manager / toolchain fields continue to target this path
	// so existing callers keep working regardless of where they point.
	const pathFlag = flags.get('path')
	const scopePath = pathFlag
		? resolve(targetDir, typeof pathFlag === 'string' ? pathFlag : '.')
		: targetDir

	if (!existsSync(scopePath)) return err(`Path does not exist: ${scopePath}`)

	// Detect package manager at scopePath (back-compat: preserves prior
	// single-PM detection for callers that omit --path).
	let pm = 'npm'
	let pmExec = 'npx'
	if (existsSync(join(scopePath, 'pnpm-lock.yaml'))) {
		pm = 'pnpm'
		pmExec = 'pnpm exec'
	} else if (existsSync(join(scopePath, 'yarn.lock'))) {
		pm = 'yarn'
		pmExec = 'yarn'
	} else if (existsSync(join(scopePath, 'bun.lockb'))) {
		pm = 'bun'
		pmExec = 'bunx'
	}

	const pmRun = pm === 'npm' ? 'npm run' : `${pm} run`

	// Read package.json if present
	let packageJson = null
	const pkgPath = join(scopePath, 'package.json')
	if (existsSync(pkgPath)) {
		try {
			packageJson = JSON.parse(readFileSync(pkgPath, 'utf-8'))
		} catch {
			/* malformed */
		}
	}
	const scripts = packageJson?.scripts ?? {}

	// FRAMEWORK-BUG-006 (Part B) — turbo cache-bypass detection.
	// A turbo-backed verify command can replay a STALE CACHE HIT and report
	// PASS while a broken import sits on disk (sighted on PR #244). When the
	// project is turbo-backed we additionally emit a `no_cache_command`
	// variant that appends turbo's `--force` flag so pr-review (and any
	// consumer) can run a cache-bypassing verification. This is ADDITIVE —
	// the existing `command` strings are never altered (ID-03).
	const hasTurboConfig = existsSync(join(scopePath, 'turbo.json'))
	// A package script "resolves through turbo" when its script body invokes
	// the turbo CLI (e.g. "turbo run typecheck"). package.json scripts are
	// hand-authored and tiny; a substring match on the `turbo` token is
	// sufficient and avoids parsing shell.
	function scriptIsTurboBacked(scriptName) {
		const body = scripts[scriptName]
		return typeof body === 'string' && /\bturbo\b/.test(body)
	}
	// Append --force to a turbo-backed package script so it reaches the
	// underlying `turbo run …`. Only used when the command is known to be
	// turbo-backed, so --force is always meaningful here. npm does NOT
	// forward trailing args to the script unless they follow a `--`
	// separator (`npm run typecheck --force` swallows --force as an npm flag),
	// so insert `--` for npm. pnpm/yarn/bun forward trailing args directly.
	function withTurboForce(command) {
		return pm === 'npm' ? `${command} -- --force` : `${command} --force`
	}

	// Read pyproject.toml as string if present
	let pyproject = null
	const pyprojectPath = join(scopePath, 'pyproject.toml')
	if (existsSync(pyprojectPath)) {
		try {
			pyproject = readFileSync(pyprojectPath, 'utf-8')
		} catch {
			/* unreadable */
		}
	}

	// Helper: check if any file matching a pattern exists
	function hasFile(...names) {
		return names.some((n) => existsSync(join(scopePath, n)))
	}

	function hasFilePrefix(prefix) {
		try {
			return readdirSync(scopePath).some((f) => f.startsWith(prefix))
		} catch {
			return false
		}
	}

	// --- Linter ---
	let linter = null
	if (hasFile('biome.json', 'biome.jsonc')) {
		linter = { tool: 'biome', command: `${pmExec} biome ci .` }
	} else if (hasFilePrefix('eslint.config') || hasFilePrefix('.eslintrc')) {
		linter = { tool: 'eslint', command: `${pmExec} eslint .` }
	} else if (pyproject && pyproject.includes('[tool.ruff]')) {
		linter = { tool: 'ruff', command: 'ruff check .' }
	} else if (scripts.lint) {
		linter = { tool: 'package-script', command: `${pmRun} lint` }
	}

	// --- Formatter ---
	let formatter = null
	if (hasFile('biome.json', 'biome.jsonc')) {
		formatter = { tool: 'biome', command: `${pmExec} biome format --write .` }
	} else if (hasFilePrefix('.prettierrc') || hasFilePrefix('prettier.config')) {
		formatter = { tool: 'prettier', command: `${pmExec} prettier --write .` }
	} else if (scripts.format) {
		formatter = { tool: 'package-script', command: `${pmRun} format` }
	}

	// --- Typecheck ---
	let typecheck = null
	if (hasFile('tsconfig.json')) {
		typecheck = { tool: 'tsc', command: `${pmExec} tsc --noEmit` }
	} else if (scripts.typecheck) {
		typecheck = { tool: 'package-script', command: `${pmRun} typecheck` }
	} else if (scripts['type-check']) {
		typecheck = { tool: 'package-script', command: `${pmRun} type-check` }
	} else if (pyproject && pyproject.includes('[tool.mypy]')) {
		typecheck = { tool: 'mypy', command: 'mypy .' }
	}
	// Additive turbo cache-bypass variant (FRAMEWORK-BUG-006). When the
	// project is turbo-backed AND has a turbo-backed typecheck script, the
	// plain command (often `tsc --noEmit` when tsconfig.json wins above) can
	// be masked by a replayed turbo cache PASS. The no_cache_command routes
	// through the turbo-backed script with `--force` so pr-review's post-fix
	// typecheck cannot be fooled by a stale cache.
	if (typecheck && hasTurboConfig) {
		if (scriptIsTurboBacked('typecheck')) {
			typecheck.no_cache_command = withTurboForce(`${pmRun} typecheck`)
		} else if (scriptIsTurboBacked('type-check')) {
			typecheck.no_cache_command = withTurboForce(`${pmRun} type-check`)
		}
	}

	// --- Test ---
	let test = null
	if (hasFilePrefix('vitest.config')) {
		test = { tool: 'vitest', command: `${pmExec} vitest run` }
	} else if (hasFilePrefix('vite.config')) {
		// Check if vite config has test section
		const viteConfigs = readdirSync(scopePath).filter((f) => f.startsWith('vite.config'))
		let hasTestSection = false
		for (const vc of viteConfigs) {
			try {
				const content = readFileSync(join(scopePath, vc), 'utf-8')
				if (content.includes('test:') || content.includes('test :')) {
					hasTestSection = true
					break
				}
			} catch {
				/* unreadable */
			}
		}
		if (hasTestSection) {
			test = { tool: 'vitest', command: `${pmExec} vitest run` }
		}
	}
	if (!test && (hasFilePrefix('jest.config') || packageJson?.jest)) {
		test = { tool: 'jest', command: `${pmExec} jest` }
	}
	if (!test && pyproject && pyproject.includes('[tool.pytest]')) {
		test = { tool: 'pytest', command: 'pytest' }
	}
	if (!test && scripts.test) {
		test = { tool: 'package-script', command: `${pmRun} test` }
	}
	// Additive turbo cache-bypass variant for tests (FRAMEWORK-BUG-006), same
	// rationale as typecheck above — only when turbo backs the test script.
	if (test && hasTurboConfig && scriptIsTurboBacked('test')) {
		test.no_cache_command = withTurboForce(`${pmRun} test`)
	}

	// --- Build ---
	let build = null
	if (hasFile('turbo.json')) {
		build = { tool: 'turbo', command: `${pmExec} turbo build` }
	} else if (scripts.build) {
		build = { tool: 'package-script', command: `${pmRun} build` }
	}

	// C54 — multi-ecosystem detection at scopePath. The top-level
	// `ecosystems` array is additive; legacy callers that only read
	// `package_manager` / `toolchain` are untouched.
	const ecosystems = detectEcosystems(scopePath)

	return ok({
		status: 'ok',
		command: 'toolchain-detect',
		package_manager: pm,
		toolchain: {
			linter,
			formatter,
			typecheck,
			test,
			build,
		},
		ecosystems,
		path: scopePath,
	})
}
