/**
 * SF Detection — Project state and ecosystem detection.
 *
 * Pure functions, zero UI dependencies, zero side effects.
 * Used by init-wizard.ts and guided-flow.ts to determine what onboarding
 * flow to show when entering a project directory.
 */
import {
	closeSync,
	existsSync,
	openSync,
	readdirSync,
	readFileSync,
	readSync,
	statSync,
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { sfRoot } from "./paths.js";
import { sfHome } from "./sf-home.js";

// ─── Project File Markers ───────────────────────────────────────────────────────
export const PROJECT_FILES = [
	"package.json",
	"Cargo.toml",
	"go.mod",
	"pyproject.toml",
	"setup.py",
	"Gemfile",
	"pom.xml",
	"build.gradle",
	"build.gradle.kts",
	"CMakeLists.txt",
	"Makefile",
	"composer.json",
	"pubspec.yaml",
	"Package.swift",
	"mix.exs",
	"deno.json",
	"deno.jsonc",
	// .NET
	".sln",
	".csproj",
	"Directory.Build.props",
	// Git submodules
	".gitmodules",
	// Xcode
	"project.yml",
	".xcodeproj",
	".xcworkspace",
	// Cloud platform config files
	"firebase.json",
	"cdk.json",
	"samconfig.toml",
	"serverless.yml",
	"serverless.yaml",
	"azure-pipelines.yml",
	// Database / ORM config files
	"prisma/schema.prisma",
	"supabase/config.toml",
	"drizzle.config.ts",
	"drizzle.config.js",
	"redis.conf",
	// React Native markers
	"metro.config.js",
	"metro.config.ts",
	"react-native.config.js",
	// Frontend framework config files
	"angular.json",
	"next.config.js",
	"next.config.ts",
	"next.config.mjs",
	"nuxt.config.ts",
	"nuxt.config.js",
	"svelte.config.js",
	"svelte.config.ts",
	// Vue CLI config files
	"vue.config.js",
	"vue.config.ts",
	// Frontend tooling
	"tailwind.config.js",
	"tailwind.config.ts",
	"tailwind.config.mjs",
	"tailwind.config.cjs",
	// Android project markers
	"app/build.gradle",
	"app/build.gradle.kts",
	// Container / DevOps config files
	"Dockerfile",
	"docker-compose.yml",
	"docker-compose.yaml",
	// Infrastructure as Code
	"main.tf",
	// Kubernetes / Helm markers
	"Chart.yaml",
	"kustomization.yaml",
	// CI/CD markers
	".github/workflows",
	// Blockchain / Web3 markers
	"hardhat.config.js",
	"hardhat.config.ts",
	"foundry.toml",
	// Data engineering markers
	"dbt_project.yml",
	"airflow.cfg",
	// Game engine markers
	"ProjectSettings/ProjectVersion.txt",
	"project.godot",
	// Python framework markers
	"manage.py",
	"requirements.txt",
];
/** File extensions that indicate SQLite databases in the project. */
const SQLITE_EXTENSIONS = [".sqlite", ".sqlite3", ".db"];
/** File extensions that indicate SQL usage (migrations, schemas, seeds). */
const SQL_EXTENSIONS = [".sql"];
/** File extensions that indicate .NET / C# projects. */
const _DOTNET_EXTENSIONS = [".csproj", ".sln", ".fsproj"];
/** File extensions that indicate Vue.js single-file components. */
const VUE_EXTENSIONS = [".vue"];
const LANGUAGE_MAP = {
	"package.json": "javascript/typescript",
	"Cargo.toml": "rust",
	"go.mod": "go",
	"pyproject.toml": "python",
	"setup.py": "python",
	Gemfile: "ruby",
	"pom.xml": "java",
	"build.gradle": "java/kotlin",
	"build.gradle.kts": "kotlin",
	"app/build.gradle": "java/kotlin",
	"app/build.gradle.kts": "kotlin",
	"CMakeLists.txt": "c/c++",
	"composer.json": "php",
	"pubspec.yaml": "dart/flutter",
	"Package.swift": "swift",
	"mix.exs": "elixir",
	"deno.json": "typescript/deno",
	"deno.jsonc": "typescript/deno",
	".sln": "dotnet",
	".csproj": "dotnet",
	"Directory.Build.props": "dotnet",
	"project.yml": "swift/xcode",
	".xcodeproj": "swift/xcode",
	".xcworkspace": "swift/xcode",
	Dockerfile: "docker",
	"manage.py": "python",
	"requirements.txt": "python",
};
const MONOREPO_MARKERS = [
	"lerna.json",
	"nx.json",
	"turbo.json",
	"pnpm-workspace.yaml",
];
const CI_MARKERS = [
	".github/workflows",
	".gitlab-ci.yml",
	"Jenkinsfile",
	".circleci",
	".travis.yml",
	"azure-pipelines.yml",
	"bitbucket-pipelines.yml",
];
const TEST_MARKERS = [
	"__tests__",
	"tests",
	"test",
	"spec",
	"jest.config.js",
	"jest.config.ts",
	"vitest.config.ts",
	"vitest.config.js",
	".mocharc.yml",
	"pytest.ini",
	"conftest.py",
	"phpunit.xml",
];
/** Directories skipped during bounded recursive project scans. */
const RECURSIVE_SCAN_IGNORED_DIRS = new Set([
	".git",
	".sf",
	".planning",
	".plans",
	".claude",
	".cursor",
	".vscode",
	"node_modules",
	// Python: virtualenvs, bytecode caches, tool caches, package metadata
	".venv",
	"venv",
	"__pycache__",
	".pytest_cache",
	".mypy_cache",
	".ruff_cache",
	".tox",
	".eggs",
	"htmlcov",
	// Build output / package output
	"dist",
	"build",
	"coverage",
	".next",
	".nuxt",
	"target",
	"vendor",
	".turbo",
	"Pods",
	"bin",
	"obj",
	".gradle",
	"DerivedData",
	"out",
]);
/** Project file markers that should ONLY be detected at the repo root.
 *
 * These markers signal "this is an X project at the root" — finding them in
 * nested subdirectories doesn't make the repo as a whole an X project, and
 * emitting bare commands like `cargo check` from root will fail when the only
 * Cargo.toml is in a subcrate.
 *
 * Suffix-matching via the recursive scan would over-detect; root-only is the
 * conservative choice. Verification command emitters that want to handle
 * nested-only layouts (e.g. cargo with nested crates) must scan explicitly. */
const ROOT_ONLY_PROJECT_FILES = new Set([
	".github/workflows",
	"package.json",
	"Cargo.toml",
	"go.mod",
	"pyproject.toml",
	"setup.py",
	"Gemfile",
	"pom.xml",
	"pubspec.yaml",
	"Package.swift",
	"mix.exs",
	"Makefile",
	"CMakeLists.txt",
	"build.gradle",
	"build.gradle.kts",
	"deno.json",
	"deno.jsonc",
]);
const MAX_RECURSIVE_SCAN_FILES = 2000;
const MAX_RECURSIVE_SCAN_DEPTH = 6;
// ─── Core Detection ─────────────────────────────────────────────────────────────
/**
 * Detect the full project state for a given directory.
 * This is the main entry point — calls all sub-detectors.
 */
export function detectProjectState(basePath) {
	const v1 = detectV1Planning(basePath);
	const v2 = detectV2Sf(basePath);
	const projectSignals = detectProjectSignals(basePath);
	const globalSetup = hasGlobalSetup();
	const firstEver = isFirstEverLaunch();
	let state;
	if (v2 && v2.milestoneCount > 0) {
		state = "v2-sf";
	} else if (v2 && v2.milestoneCount === 0) {
		state = "v2-sf-empty";
	} else if (v1) {
		state = "v1-planning";
	} else {
		state = "none";
	}
	return {
		state,
		isFirstEverLaunch: firstEver,
		hasGlobalSetup: globalSetup,
		v1: v1 ?? undefined,
		v2: v2 ?? undefined,
		projectSignals,
	};
}
// ─── V1 Planning Detection ──────────────────────────────────────────────────────
/**
 * Detect a v1 .planning/ directory with SF v1 markers.
 * Returns null if no .planning/ directory found.
 */
export function detectV1Planning(basePath) {
	const planningPath = join(basePath, ".planning");
	if (!existsSync(planningPath)) return null;
	try {
		const stat = statSync(planningPath);
		if (!stat.isDirectory()) return null;
	} catch {
		return null;
	}
	const hasRoadmap = existsSync(join(planningPath, "ROADMAP.md"));
	const phasesPath = join(planningPath, "phases");
	const hasPhasesDir = existsSync(phasesPath);
	let phaseCount = 0;
	if (hasPhasesDir) {
		try {
			const entries = readdirSync(phasesPath, { withFileTypes: true });
			phaseCount = entries.filter((e) => e.isDirectory()).length;
		} catch {
			// unreadable — report 0
		}
	}
	return {
		path: planningPath,
		hasPhasesDir,
		hasRoadmap,
		phaseCount,
	};
}
// ─── V2 SF Detection ──────────────────────────────────────────────────────────
function detectV2Sf(basePath) {
	const sfPath = sfRoot(basePath);
	if (!existsSync(sfPath)) return null;
	const hasPreferences = existsSync(join(sfPath, "preferences.yaml"));
	const hasContext = existsSync(join(sfPath, "CONTEXT.md"));
	let milestoneCount = 0;
	const milestonesPath = join(sfPath, "milestones");
	if (existsSync(milestonesPath)) {
		try {
			const entries = readdirSync(milestonesPath, { withFileTypes: true });
			milestoneCount = entries.filter((e) => e.isDirectory()).length;
		} catch {
			// unreadable — report 0
		}
	}
	return { milestoneCount, hasPreferences, hasContext };
}
// ─── Project Signals Detection ──────────────────────────────────────────────────
/**
 * Quick filesystem scan for project ecosystem markers.
 * Reads only file existence + minimal content (package.json for monorepo/scripts).
 */
export function detectProjectSignals(basePath) {
	const detectedFiles = [];
	let primaryLanguage;
	// Detect project files
	for (const file of PROJECT_FILES) {
		if (existsSync(join(basePath, file))) {
			detectedFiles.push(file);
			if (!primaryLanguage) {
				primaryLanguage = LANGUAGE_MAP[file];
			}
		}
	}
	// Bounded recursive scan for nested markers and dependency files.
	// This covers common brownfield layouts like src/App/App.csproj,
	// db/migrations/*.sql, src/components/*.vue, and services/api/pyproject.toml
	// without walking the entire repo or diving into heavyweight folders.
	const scannedFiles = scanProjectFiles(basePath);
	for (const file of PROJECT_FILES) {
		if (detectedFiles.includes(file) || ROOT_ONLY_PROJECT_FILES.has(file))
			continue;
		const hasMatch =
			file === "requirements.txt"
				? scannedFiles.some(isPythonRequirementsFile)
				: scannedFiles.some((scannedFile) =>
						matchesProjectFileMarker(scannedFile, file),
					);
		if (hasMatch) {
			pushUnique(detectedFiles, file);
			if (!primaryLanguage && LANGUAGE_MAP[file]) {
				primaryLanguage = LANGUAGE_MAP[file];
			}
		}
	}
	if (
		scannedFiles.some((file) =>
			SQLITE_EXTENSIONS.some((ext) => file.endsWith(ext)),
		)
	) {
		pushUnique(detectedFiles, "*.sqlite");
	}
	if (
		scannedFiles.some((file) =>
			SQL_EXTENSIONS.some((ext) => file.endsWith(ext)),
		)
	) {
		pushUnique(detectedFiles, "*.sql");
	}
	const hasCsproj = scannedFiles.some((file) => file.endsWith(".csproj"));
	const hasFsproj = scannedFiles.some((file) => file.endsWith(".fsproj"));
	const hasSln = scannedFiles.some((file) => file.endsWith(".sln"));
	if (hasCsproj) {
		pushUnique(detectedFiles, "*.csproj");
		if (!primaryLanguage) primaryLanguage = "csharp";
	}
	if (hasFsproj) {
		pushUnique(detectedFiles, "*.fsproj");
		if (!primaryLanguage) primaryLanguage = "fsharp";
	}
	if (hasSln) {
		pushUnique(detectedFiles, "*.sln");
		if (!primaryLanguage) primaryLanguage = "dotnet";
	}
	if (
		scannedFiles.some((file) =>
			VUE_EXTENSIONS.some((ext) => file.endsWith(ext)),
		)
	) {
		pushUnique(detectedFiles, "*.vue");
	}
	// Python framework detection — scan dependency files for framework-specific packages.
	// Adds synthetic markers (e.g. "dep:fastapi") so skill catalog matchFiles can reference them.
	const dependencyFiles = scannedFiles.filter(
		(file) => isPythonRequirementsFile(file) || file.endsWith("pyproject.toml"),
	);
	if (containsFastapiDependency(basePath, dependencyFiles)) {
		pushUnique(detectedFiles, "dep:fastapi");
	}
	const springBootBuildFiles = scannedFiles.filter(
		(file) =>
			file.endsWith("pom.xml") ||
			file.endsWith("build.gradle") ||
			file.endsWith("build.gradle.kts"),
	);
	const springBootVersionCatalogs = scannedFiles.filter((file) =>
		file.endsWith(".versions.toml"),
	);
	const springBootSettingsFiles = scannedFiles.filter(
		(file) =>
			file.endsWith("settings.gradle") || file.endsWith("settings.gradle.kts"),
	);
	if (
		containsSpringBootMarker(
			basePath,
			springBootBuildFiles,
			springBootVersionCatalogs,
			springBootSettingsFiles,
		)
	) {
		pushUnique(detectedFiles, "dep:spring-boot");
		if (!primaryLanguage) {
			primaryLanguage = "java/kotlin";
		}
	}
	// Git repo detection
	const isGitRepo = existsSync(join(basePath, ".git"));
	// Xcode platform detection — parse SDKROOT from project.pbxproj
	const xcodePlatforms = detectXcodePlatforms(basePath);
	// Set primaryLanguage to swift when an Xcode project is found but no
	// Package.swift was detected (CocoaPods or SPM-less projects).
	if (!primaryLanguage && xcodePlatforms.length > 0) {
		primaryLanguage = "swift";
	}
	// Monorepo detection
	let isMonorepo = false;
	for (const marker of MONOREPO_MARKERS) {
		if (existsSync(join(basePath, marker))) {
			isMonorepo = true;
			break;
		}
	}
	// Also check package.json workspaces
	if (!isMonorepo && detectedFiles.includes("package.json")) {
		isMonorepo = packageJsonHasWorkspaces(basePath);
	}
	// CI detection
	let hasCI = false;
	for (const marker of CI_MARKERS) {
		if (existsSync(join(basePath, marker))) {
			hasCI = true;
			break;
		}
	}
	// Test detection
	let hasTests = false;
	for (const marker of TEST_MARKERS) {
		if (existsSync(join(basePath, marker))) {
			hasTests = true;
			break;
		}
	}
	// Package manager detection
	const packageManager = detectPackageManager(basePath);
	// Verification commands
	const verificationCommands = detectVerificationCommands(
		basePath,
		detectedFiles,
		packageManager,
	);
	return {
		detectedFiles,
		isGitRepo,
		isMonorepo,
		primaryLanguage,
		xcodePlatforms,
		hasCI,
		hasTests,
		packageManager,
		verificationCommands,
	};
}
// ─── Xcode Platform Detection ───────────────────────────────────────────────────
/** Known SDKROOT values → canonical platform names. */
const SDKROOT_MAP = {
	iphoneos: "iphoneos",
	iphonesimulator: "iphoneos", // simulator builds still target iOS
	macosx: "macosx",
	watchos: "watchos",
	watchsimulator: "watchos",
	appletvos: "appletvos",
	appletvsimulator: "appletvos",
	xros: "xros",
	xrsimulator: "xros",
};
/** Regex for SUPPORTED_PLATFORMS — fallback when SDKROOT = auto (Xcode 15+). */
const SUPPORTED_PLATFORMS_RE = /SUPPORTED_PLATFORMS\s*=\s*"([^"]+)"/gi;
/** Read at most `maxBytes` from a file without loading the full file into memory. */
function readBounded(filePath, maxBytes) {
	const buf = Buffer.alloc(maxBytes);
	const fd = openSync(filePath, "r");
	try {
		const bytesRead = readSync(fd, buf, 0, maxBytes, 0);
		return buf.toString("utf-8", 0, bytesRead);
	} finally {
		closeSync(fd);
	}
}
/** Common subdirectories where .xcodeproj may live in monorepos / standard layouts. */
const XCODE_SUBDIRS = ["ios", "macos", "app", "apps"];
/**
 * Scan *.xcodeproj directories for project.pbxproj and extract SDKROOT values.
 * Returns deduplicated, canonical platform list (e.g. ["iphoneos"]).
 *
 * Reading the pbxproj is a lightweight regex scan — no full plist parsing needed.
 * We read at most 1 MB per file to keep detection fast.
 * Searches both the project root and common subdirectories (ios/, macos/, app/).
 */
function detectXcodePlatforms(basePath) {
	const platforms = new Set();
	// Directories to scan: project root + common subdirs
	const dirsToScan = [basePath];
	for (const sub of XCODE_SUBDIRS) {
		const subPath = join(basePath, sub);
		if (existsSync(subPath)) dirsToScan.push(subPath);
	}
	for (const dir of dirsToScan) {
		try {
			const entries = readdirSync(dir, { withFileTypes: true });
			for (const entry of entries) {
				if (!entry.isDirectory() || !entry.name.endsWith(".xcodeproj"))
					continue;
				const pbxprojPath = join(dir, entry.name, "project.pbxproj");
				try {
					const content = readBounded(pbxprojPath, 1024 * 1024);
					// Match SDKROOT = <value>; — both quoted and unquoted forms
					const sdkRe = /SDKROOT\s*=\s*"?([a-z]+)"?\s*;/gi;
					let m;
					let foundExplicit = false;
					while ((m = sdkRe.exec(content)) !== null) {
						const val = m[1].toLowerCase();
						if (val === "auto") continue; // handled below via SUPPORTED_PLATFORMS
						const canonical = SDKROOT_MAP[val];
						if (canonical) {
							platforms.add(canonical);
							foundExplicit = true;
						}
					}
					// Xcode 15+ defaults SDKROOT to "auto"; fall back to SUPPORTED_PLATFORMS
					if (!foundExplicit) {
						let sp;
						while ((sp = SUPPORTED_PLATFORMS_RE.exec(content)) !== null) {
							for (const tok of sp[1].split(/\s+/)) {
								const canonical = SDKROOT_MAP[tok.toLowerCase()];
								if (canonical) platforms.add(canonical);
							}
						}
						SUPPORTED_PLATFORMS_RE.lastIndex = 0;
					}
				} catch {
					// unreadable pbxproj — skip
				}
			}
		} catch {
			// unreadable directory
		}
	}
	return [...platforms];
}
// ─── Package Manager Detection ──────────────────────────────────────────────────
function detectPackageManager(basePath) {
	// No package.json at root → no JS/TS package manager. Avoid hallucinating
	// "npm" just because some downstream marker (lockfile-only repo, stale
	// artifact, etc.) happens to exist. Callers that expect a JS package
	// manager already gate on detectedFiles.includes("package.json").
	if (!existsSync(join(basePath, "package.json"))) return undefined;
	const declared = readPackageJsonPackageManager(basePath);
	if (declared) return declared;
	if (existsSync(join(basePath, "pnpm-lock.yaml"))) return "pnpm";
	if (existsSync(join(basePath, "yarn.lock"))) return "yarn";
	if (
		existsSync(join(basePath, "bun.lockb")) ||
		existsSync(join(basePath, "bun.lock"))
	)
		return "npm";
	if (existsSync(join(basePath, "package-lock.json"))) return "npm";
	return "npm";
}
function readPackageJsonPackageManager(basePath) {
	try {
		const raw = readFileSync(join(basePath, "package.json"), "utf-8");
		const pkg = JSON.parse(raw);
		if (typeof pkg.packageManager !== "string") return undefined;
		const name = pkg.packageManager.split("@")[0];
		if (name === "npm" || name === "pnpm" || name === "yarn") {
			return name;
		}
		if (name === "bun") return "npm";
		return undefined;
	} catch {
		return undefined;
	}
}
// ─── Verification Command Detection ─────────────────────────────────────────────
/**
 * Auto-detect verification commands from project files.
 * Returns commands in priority order (test first, then build, then lint).
 */
function detectVerificationCommands(basePath, detectedFiles, packageManager) {
	const commands = [];
	if (detectedFiles.includes("package.json")) {
		// Only synthesize a runner when there's actually a package.json. Without
		// one, "npm run X" is meaningless — and silently defaulting `pm` to "npm"
		// here would leak into commands emitted for repos that have no JS at all.
		const pm = packageManager ?? "npm";
		const run = pm === "npm" ? "npm run" : pm === "yarn" ? "yarn" : `${pm} run`;
		const scripts = readPackageJsonScripts(basePath);
		if (scripts) {
			// Typecheck first — fast, no worker processes
			if (scripts["typecheck:extensions"]) {
				commands.push(`${run} typecheck:extensions`);
			} else if (scripts.typecheck) {
				commands.push(`${run} typecheck`);
			} else if (scripts.tsc) {
				commands.push(`${run} tsc`);
			}
			// Build (compile check when no dedicated typecheck exists)
			if (scripts.build) {
				commands.push(`${run} build`);
			}
			// Lint
			if (scripts.lint) {
				commands.push(`${run} lint`);
			}
			// Prefer a light test target over the full suite.
			// npm test / yarn test can spawn many worker processes and saturate
			// CPUs (especially when paired with coverage or process isolation).
			// Use a *-light variant when present, otherwise fall back to npm test.
			if (scripts["test:sf-light"]) {
				commands.push(`${run} test:sf-light`);
			} else if (scripts["test:light"]) {
				commands.push(`${run} test:light`);
			} else if (
				scripts.test &&
				scripts.test !== 'echo "Error: no test specified" && exit 1'
			) {
				commands.push(pm === "npm" ? "npm test" : `${pm} test`);
			}
		}
	}
	// Cargo / Rust — three layouts:
	//   1. Root Cargo.toml with [workspace] → workspace root, bare cargo works.
	//   2. Root Cargo.toml without workspace → single crate, bare cargo works.
	//   3. No root Cargo.toml, only nested crates → emit per-crate bash loop so
	//      commands can run from repo root (mirrors the Go multi-module branch).
	const rootCargoPath = join(basePath, "Cargo.toml");
	const rootHasCargoToml = existsSync(rootCargoPath);
	if (rootHasCargoToml) {
		// Format check first — fastest, catches style drift before anything else runs.
		commands.push("cargo fmt --check");
		// Type-check without running tests (faster than test, catches most regressions).
		commands.push("cargo check");
		// Limit test threads so Rust tests don't saturate all CPUs.
		commands.push("cargo test -- --test-threads=2");
		commands.push("cargo clippy -- -D warnings");
	} else {
		const scanned = scanProjectFiles(basePath);
		const crateDirs = scanned
			.filter((f) => f.endsWith("/Cargo.toml"))
			.map((f) => f.slice(0, -"/Cargo.toml".length))
			.filter((d) => d.length > 0 && !d.includes(".."));
		if (crateDirs.length > 0) {
			const dirsArg = crateDirs.map((d) => `"${d}"`).join(" ");
			commands.push(
				`bash -c 'set -e; for d in ${dirsArg}; do (cd "$d" && cargo fmt --check); done'`,
			);
			commands.push(
				`bash -c 'set -e; for d in ${dirsArg}; do (cd "$d" && cargo check); done'`,
			);
			commands.push(
				`bash -c 'set -e; for d in ${dirsArg}; do (cd "$d" && cargo test -- --test-threads=2); done'`,
			);
			commands.push(
				`bash -c 'set -e; for d in ${dirsArg}; do (cd "$d" && cargo clippy -- -D warnings); done'`,
			);
		}
	}
	if (detectedFiles.includes("go.mod")) {
		// Limit parallelism: Go's default is GOMAXPROCS which can be very high.
		const rootHasGoMod = existsSync(join(basePath, "go.mod"));
		if (rootHasGoMod) {
			commands.push("go test -parallel 2 ./...");
			commands.push("go vet ./...");
		} else {
			// Multi-module repo (no root go.mod, only nested ones — common in
			// monorepos like dr-repo/{dr-agent,portal,gateway,...}). Find each
			// module dir and emit a per-module loop so commands work from the
			// repo root regardless of which modules exist.
			const scanned = scanProjectFiles(basePath);
			const moduleDirs = scanned
				.filter((f) => f.endsWith("/go.mod") || f === "go.mod")
				.map((f) => (f === "go.mod" ? "." : f.slice(0, -"/go.mod".length)))
				.filter((d) => d.length > 0 && !d.includes(".."));
			if (moduleDirs.length > 0) {
				const dirsArg = moduleDirs.map((d) => `"${d}"`).join(" ");
				commands.push(
					`bash -c 'set -e; for d in ${dirsArg}; do (cd "$d" && go vet ./...); done'`,
				);
				commands.push(
					`bash -c 'set -e; for d in ${dirsArg}; do (cd "$d" && go test -parallel 2 ./...); done'`,
				);
			}
		}
	}
	if (
		detectedFiles.includes("pyproject.toml") ||
		detectedFiles.includes("setup.py") ||
		detectedFiles.includes("requirements.txt")
	) {
		// Detect Python package manager. uv > poetry > pdm > raw.
		// The runner prefix changes which python gets invoked, so it matters that
		// commands match the project's actual env.
		const hasUvLock = existsSync(join(basePath, "uv.lock"));
		const hasPoetryLock = existsSync(join(basePath, "poetry.lock"));
		const hasPdmLock = existsSync(join(basePath, "pdm.lock"));
		const pyRunner = hasUvLock
			? "uv run"
			: hasPoetryLock
				? "poetry run"
				: hasPdmLock
					? "pdm run"
					: "";
		const prefix = pyRunner ? `${pyRunner} ` : "";
		// Lint first — ruff is fast and catches drift before slower checks run.
		const hasRuff =
			existsSync(join(basePath, "ruff.toml")) ||
			existsSync(join(basePath, ".ruff.toml")) ||
			pyprojectHasTool(basePath, "ruff");
		if (hasRuff) {
			commands.push(`${prefix}ruff check`);
		}
		// Type check — only emit if config exists (mypy or pyright).
		// Without config these tools error confusingly on first run; better to
		// skip than to emit a command that always fails.
		const hasMypy =
			existsSync(join(basePath, "mypy.ini")) ||
			existsSync(join(basePath, ".mypy.ini")) ||
			pyprojectHasTool(basePath, "mypy");
		const hasPyright =
			existsSync(join(basePath, "pyrightconfig.json")) ||
			pyprojectHasTool(basePath, "pyright");
		if (hasMypy) {
			commands.push(`${prefix}mypy .`);
		} else if (hasPyright) {
			commands.push(`${prefix}pyright`);
		}
		// Tests — single-process pytest by default; -x stops on first failure.
		commands.push(`${prefix}pytest -x`);
	}
	if (detectedFiles.includes("Gemfile")) {
		// Check for rspec vs minitest
		if (existsSync(join(basePath, "spec"))) {
			commands.push("bundle exec rspec");
		} else {
			commands.push("bundle exec rake test");
		}
	}
	if (detectedFiles.includes("Makefile")) {
		const makeTargets = readMakefileTargets(basePath);
		// Only emit `make test` if:
		//   1. A `test` target exists.
		//   2. No prior block already pushed a test command (defensive — e.g. a
		//      pytest/cargo/go/npm test was already emitted; recommending
		//      `make test` on top is redundant and can confuse users with
		//      conflicting verification paths).
		//   3. The `test` target isn't gated on a nix-only dependency such as
		//      `_verify_nix`. Such recipes fail outside a nix environment, so
		//      surfacing them as auto-detected verification breaks every run on
		//      machines without nix-shell.
		const alreadyHasTestCommand = commands.some((cmd) => isTestCommand(cmd));
		if (
			makeTargets.includes("test") &&
			!alreadyHasTestCommand &&
			isMakeTestTargetSafe(basePath)
		) {
			commands.push("make test");
		}
	}
	return commands;
}
/**
 * Heuristic check: does an emitted command appear to invoke a test runner?
 * Conservative — only matches patterns we actually emit elsewhere in this
 * file (pytest, cargo test, go test, npm/yarn/pnpm test, rspec, rake test).
 */
function isTestCommand(command) {
	return (
		/\bpytest\b/.test(command) ||
		/\bcargo\s+test\b/.test(command) ||
		/\bgo\s+test\b/.test(command) ||
		/\b(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?test\b/.test(command) ||
		/\brspec\b/.test(command) ||
		/\brake\s+test\b/.test(command)
	);
}
/**
 * Inspect the Makefile to decide whether `make test` is safely runnable.
 * Returns false when the `test` target depends on a nix-only sentinel such
 * as `_verify_nix`, or when the recipe body references `nix-shell` / `nix `.
 *
 * Naive line-based scan — avoids pulling in a Make parser. Reads the file
 * directly so we can see both prerequisites (after `:` on the target line)
 * and recipe lines (TAB-indented lines following the target).
 */
function isMakeTestTargetSafe(basePath) {
	let raw;
	try {
		raw = readFileSync(join(basePath, "Makefile"), "utf-8");
	} catch {
		return false;
	}
	const lines = raw.split("\n");
	const testHeaderRe = /^test\s*:(.*)$/;
	for (let i = 0; i < lines.length; i++) {
		const headerMatch = lines[i].match(testHeaderRe);
		if (!headerMatch) continue;
		const prereqs = headerMatch[1].trim();
		if (/(^|\s)_verify_nix(\s|$)/.test(prereqs)) return false;
		if (/\bnix(-shell)?\b/.test(prereqs)) return false;
		// Walk the recipe body — TAB-indented lines until blank line or next
		// target declaration.
		for (let j = i + 1; j < lines.length; j++) {
			const line = lines[j];
			if (line.trim() === "") break;
			// New rule starts when a non-tab line contains `:` followed by
			// non-`=` (avoid matching variable assignments like `FOO := bar`).
			if (!line.startsWith("\t") && /^[A-Za-z0-9_.-]+\s*:[^=]/.test(line)) {
				break;
			}
			if (!line.startsWith("\t")) continue;
			if (/\bnix-shell\b/.test(line)) return false;
			if (/\bnix\s/.test(line)) return false;
		}
		return true;
	}
	return true;
}
// ─── Global Setup Detection ─────────────────────────────────────────────────────
/**
 * Check if global SF setup exists (has ~/.sf/ with preferences).
 */
export function hasGlobalSetup() {
	return existsSync(join(sfHome(), "preferences.yaml"));
}
/**
 * Check if this is the very first time SF has been used on this machine.
 * Returns true if ~/.sf/ doesn't exist or has no preferences or auth.
 */
export function isFirstEverLaunch() {
	if (!existsSync(sfHome())) return true;
	// If we have preferences, not first launch
	if (existsSync(join(sfHome(), "preferences.yaml"))) {
		return false;
	}
	// If we have auth.json, not first launch (onboarding.ts already ran)
	if (existsSync(join(sfHome(), "agent", "auth.json"))) return false;
	// Check legacy path too
	const legacyPath = join(homedir(), ".pi", "agent", "sf-preferences.md");
	if (existsSync(legacyPath)) return false;
	return true;
}
// ─── Helpers ────────────────────────────────────────────────────────────────────
function packageJsonHasWorkspaces(basePath) {
	try {
		const raw = readFileSync(join(basePath, "package.json"), "utf-8");
		const pkg = JSON.parse(raw);
		return (
			Array.isArray(pkg.workspaces) ||
			(pkg.workspaces && typeof pkg.workspaces === "object")
		);
	} catch {
		return false;
	}
}
function readPackageJsonScripts(basePath) {
	try {
		const raw = readFileSync(join(basePath, "package.json"), "utf-8");
		const pkg = JSON.parse(raw);
		return pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : null;
	} catch {
		return null;
	}
}
function readMakefileTargets(basePath) {
	try {
		const raw = readFileSync(join(basePath, "Makefile"), "utf-8");
		const targets = [];
		for (const line of raw.split("\n")) {
			const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):/);
			if (match) targets.push(match[1]);
		}
		return targets;
	} catch {
		return [];
	}
}
/**
 * Detect whether a Python tool is configured under [tool.<name>] in pyproject.toml.
 * Used by Python verification command detection so we only emit `mypy` / `pyright` /
 * `ruff` invocations for projects that actually configure those tools.
 *
 * Naive substring scan — avoids pulling in a TOML parser for a check this simple.
 * Matches `[tool.<name>]` AND `[tool.<name>.<sub>]` (e.g. `[tool.ruff.lint]`,
 * `[tool.ruff.format]`) since modern tools often only configure sub-sections.
 */
function pyprojectHasTool(basePath, toolName) {
	try {
		const raw = readFileSync(join(basePath, "pyproject.toml"), "utf-8");
		const exactHeader = `[tool.${toolName}]`;
		const subHeader = `[tool.${toolName}.`;
		for (const line of raw.split("\n")) {
			const trimmed = line.trim();
			if (trimmed.startsWith(exactHeader) || trimmed.startsWith(subHeader)) {
				return true;
			}
		}
		return false;
	} catch {
		return false;
	}
}
function pushUnique(arr, value) {
	if (!arr.includes(value)) arr.push(value);
}
function matchesProjectFileMarker(scannedFile, marker) {
	const normalized = scannedFile.replaceAll("\\", "/");
	return normalized === marker || normalized.endsWith(`/${marker}`);
}
function isPythonRequirementsFile(relativePath) {
	const normalized = relativePath.replaceAll("\\", "/");
	const basename = normalized.slice(normalized.lastIndexOf("/") + 1);
	return (
		basename === "requirements.txt" ||
		basename === "requirements.in" ||
		/^requirements([-.].+)?\.(txt|in)$/i.test(basename) ||
		/(^|\/)requirements\/.+\.(txt|in)$/i.test(normalized)
	);
}
function containsFastapiDependency(basePath, relativePaths) {
	for (const relativePath of relativePaths) {
		try {
			const raw = readBounded(join(basePath, relativePath), 64 * 1024);
			const content = extractDependencyContent(relativePath, raw);
			if (isPythonRequirementsFile(relativePath)) {
				for (const line of content.split("\n")) {
					if (extractRequirementName(line) === "fastapi") return true;
				}
				continue;
			}
			if (relativePath.endsWith("pyproject.toml")) {
				if (containsFastapiInPyproject(content)) return true;
			}
		} catch {
			// unreadable file — continue scanning other candidate files
		}
	}
	return false;
}
function containsSpringBootMarker(
	basePath,
	buildFiles,
	versionCatalogFiles,
	settingsFiles,
) {
	const usedPluginAliases = new Set();
	const usedLibraryAliases = new Set();
	const catalogAccessors = resolveVersionCatalogAccessors(
		basePath,
		versionCatalogFiles,
		settingsFiles,
	);
	for (const relativePath of buildFiles) {
		try {
			const raw = readBounded(join(basePath, relativePath), 64 * 1024);
			const content = stripDependencyComments(relativePath, raw);
			if (containsDirectSpringBootReference(relativePath, content)) {
				return true;
			}
			const normalized = content.toLowerCase();
			let match;
			for (const accessor of catalogAccessors) {
				const aliasRe = new RegExp(
					`alias\\(\\s*${accessor}\\.plugins\\.([a-z0-9_.-]+)\\s*\\)`,
					"gi",
				);
				while ((match = aliasRe.exec(normalized)) !== null) {
					usedPluginAliases.add(normalizePluginAlias(match[1]));
				}
				const libraryAliasRe = new RegExp(
					`\\b${accessor}\\.((?!plugins\\b)[a-z0-9_.-]+)`,
					"gi",
				);
				while ((match = libraryAliasRe.exec(normalized)) !== null) {
					usedLibraryAliases.add(normalizePluginAlias(match[1]));
				}
			}
		} catch {
			// unreadable build file — continue scanning others
		}
	}
	if (usedPluginAliases.size === 0 && usedLibraryAliases.size === 0) {
		return false;
	}
	if (versionCatalogFiles.length === 0) {
		return false;
	}
	const springBootAliases = new Set();
	const springBootLibraries = new Set();
	const pendingSpringBootBundles = [];
	for (const relativePath of versionCatalogFiles) {
		try {
			const raw = readBounded(join(basePath, relativePath), 64 * 1024);
			const content = stripDependencyComments(relativePath, raw);
			const aliasRe =
				/^\s*([A-Za-z0-9_.-]+)\s*=\s*\{[^\n}]*\bid\s*=\s*["']org\.springframework\.boot["'][^\n}]*\}/gm;
			let match;
			while ((match = aliasRe.exec(content)) !== null) {
				springBootAliases.add(normalizePluginAlias(match[1]));
			}
			const libraryRe =
				/^\s*([A-Za-z0-9_.-]+)\s*=\s*\{[^\n}]*\b(module\s*=\s*["']org\.springframework\.boot:[^"']+["']|group\s*=\s*["']org\.springframework\.boot["'][^\n}]*\bname\s*=\s*["']spring-boot[^"']*["'])[^\n}]*\}/gm;
			while ((match = libraryRe.exec(content)) !== null) {
				springBootLibraries.add(normalizePluginAlias(match[1]));
			}
			const bundleRe = /^\s*([A-Za-z0-9_.-]+)\s*=\s*\[([\s\S]*?)\]/gm;
			while ((match = bundleRe.exec(content)) !== null) {
				pendingSpringBootBundles.push({
					bundleAlias: normalizePluginAlias(`bundles.${match[1]}`),
					referencedAliases: match[2]
						.split(",")
						.map((part) => normalizePluginAlias(part.replace(/["'\s]/g, "")))
						.filter(Boolean),
				});
			}
		} catch {
			// unreadable version catalog — continue scanning others
		}
	}
	const springBootBundles = new Set();
	for (const pendingBundle of pendingSpringBootBundles) {
		if (
			pendingBundle.referencedAliases.some((alias) =>
				springBootLibraries.has(alias),
			)
		) {
			springBootBundles.add(pendingBundle.bundleAlias);
		}
	}
	for (const alias of usedPluginAliases) {
		if (springBootAliases.has(alias)) return true;
	}
	for (const alias of usedLibraryAliases) {
		if (springBootLibraries.has(alias) || springBootBundles.has(alias))
			return true;
	}
	return false;
}
function stripDependencyComments(relativePath, content) {
	if (relativePath.endsWith("requirements.txt")) {
		return content.replace(/(^|\s)#.*$/gm, "");
	}
	if (relativePath.endsWith("pyproject.toml")) {
		return content.replace(/(^|\s)#.*$/gm, "");
	}
	if (relativePath.endsWith(".versions.toml")) {
		return content.replace(/(^|\s)#.*$/gm, "");
	}
	if (
		relativePath.endsWith("settings.gradle") ||
		relativePath.endsWith("settings.gradle.kts")
	) {
		return content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
	}
	if (relativePath.endsWith("pom.xml")) {
		return content.replace(/<!--[\s\S]*?-->/g, "");
	}
	if (
		relativePath.endsWith("build.gradle") ||
		relativePath.endsWith("build.gradle.kts")
	) {
		return content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
	}
	return content;
}
function extractDependencyContent(relativePath, content) {
	const stripped = stripDependencyComments(relativePath, content);
	if (relativePath.endsWith("pyproject.toml")) {
		return extractPyprojectDependencySections(stripped);
	}
	return stripped;
}
function extractRequirementName(spec) {
	const trimmed = spec.trim().replace(/^["']|["']$/g, "");
	if (!trimmed) return null;
	const match = trimmed.match(
		/^([A-Za-z0-9_.-]+)(?:\[[^\]]+\])?(?=\s*(?:@|[<>=!~;]|$))/,
	);
	if (!match) return null;
	return normalizePackageName(match[1]);
}
function containsFastapiInPyproject(content) {
	for (const line of content.split("\n")) {
		const keyMatch = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
		if (keyMatch) {
			const key = normalizePackageName(keyMatch[1]);
			if (key === "fastapi") {
				return true;
			}
			if (key !== "dependencies") {
				continue;
			}
		}
		const quotedSpecRe = /["']([^"']+)["']/g;
		let match;
		while ((match = quotedSpecRe.exec(line)) !== null) {
			if (extractRequirementName(match[1]) === "fastapi") {
				return true;
			}
		}
	}
	return false;
}
function containsDirectSpringBootReference(relativePath, content) {
	if (relativePath.endsWith("pom.xml")) {
		return /<groupId>\s*org\.springframework\.boot\s*<\/groupId>/i.test(
			content,
		);
	}
	if (
		relativePath.endsWith("build.gradle") ||
		relativePath.endsWith("build.gradle.kts")
	) {
		return /(id\s*\(?\s*["']org\.springframework\.boot["']|apply\s*\(?\s*plugin\s*[:=]\s*["']org\.springframework\.boot["']|(?:implementation|api|compileOnly|runtimeOnly|testImplementation|annotationProcessor|kapt)\s*\(?\s*["'][^"']*org\.springframework\.boot:[^"']*spring-boot[^"']*["'])/i.test(
			content,
		);
	}
	return false;
}
function extractPyprojectDependencySections(content) {
	const lines = content.split("\n");
	const collected = [];
	let section = "";
	let collectingProjectDeps = false;
	let collectingOptionalDeps = false;
	let bracketDepth = 0;
	for (const line of lines) {
		const trimmed = line.trim();
		if (collectingProjectDeps) {
			collected.push(line);
			bracketDepth += countChar(line, "[") - countChar(line, "]");
			if (bracketDepth <= 0) {
				collectingProjectDeps = false;
			}
			continue;
		}
		if (collectingOptionalDeps) {
			collected.push(line);
			bracketDepth += countChar(line, "[") - countChar(line, "]");
			if (bracketDepth <= 0) {
				collectingOptionalDeps = false;
			}
			continue;
		}
		const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
		if (sectionMatch) {
			section = sectionMatch[1].trim();
			continue;
		}
		if (section === "project" && /^dependencies\s*=\s*\[/.test(trimmed)) {
			collected.push(line);
			bracketDepth = countChar(line, "[") - countChar(line, "]");
			collectingProjectDeps = bracketDepth > 0;
			continue;
		}
		if (
			section === "project.optional-dependencies" ||
			section === "tool.poetry.dependencies"
		) {
			if (section === "project.optional-dependencies") {
				const equalsIndex = line.indexOf("=");
				if (equalsIndex !== -1) {
					const value = line.slice(equalsIndex + 1);
					collected.push(value);
					bracketDepth = countChar(value, "[") - countChar(value, "]");
					collectingOptionalDeps = bracketDepth > 0;
				}
			} else {
				collected.push(line);
			}
		}
	}
	return collected.join("\n");
}
function countChar(text, char) {
	return [...text].filter((c) => c === char).length;
}
function normalizePackageName(name) {
	return name.toLowerCase().replace(/[_.]/g, "-");
}
function normalizePluginAlias(alias) {
	return alias.toLowerCase().replace(/[-_]/g, ".");
}
function versionCatalogAccessorName(relativePath) {
	const normalized = relativePath.replaceAll("\\", "/");
	const basename = normalized.slice(normalized.lastIndexOf("/") + 1);
	return basename.replace(/\.versions\.toml$/i, "").toLowerCase();
}
function resolveVersionCatalogAccessors(
	basePath,
	versionCatalogFiles,
	settingsFiles,
) {
	const accessors = new Set(
		versionCatalogFiles.map(versionCatalogAccessorName).filter(Boolean),
	);
	if (versionCatalogFiles.length === 0 || settingsFiles.length === 0) {
		return accessors;
	}
	for (const settingsFile of settingsFiles) {
		try {
			const raw = readBounded(join(basePath, settingsFile), 64 * 1024);
			const content = stripDependencyComments(settingsFile, raw);
			const createRe =
				/create\(\s*["']([A-Za-z0-9_]+)["']\s*\)\s*\{[\s\S]*?([A-Za-z0-9_.-]+\.versions\.toml)["']?\s*\)\s*\)/g;
			let match;
			while ((match = createRe.exec(content)) !== null) {
				const accessor = match[1].toLowerCase();
				const catalogBasename = match[2].replaceAll("\\", "/").split("/").pop();
				if (
					versionCatalogFiles.some((file) => {
						const normalized = file.replaceAll("\\", "/");
						return (
							normalized === catalogBasename ||
							normalized.endsWith(`/${catalogBasename}`)
						);
					})
				) {
					accessors.add(accessor);
				}
			}
		} catch {
			// unreadable settings file — ignore
		}
	}
	return accessors;
}
export function scanProjectFiles(basePath) {
	const files = [];
	const queue = [{ path: basePath, depth: 0 }];
	while (queue.length > 0 && files.length < MAX_RECURSIVE_SCAN_FILES) {
		const current = queue.shift();
		let entries;
		try {
			entries = readdirSync(current.path, {
				withFileTypes: true,
				encoding: "utf8",
			});
		} catch {
			continue;
		}
		for (const entry of entries) {
			const entryPath = join(current.path, entry.name);
			const relativePath = entryPath.slice(basePath.length + 1);
			if (entry.isDirectory()) {
				if (
					current.depth < MAX_RECURSIVE_SCAN_DEPTH &&
					!RECURSIVE_SCAN_IGNORED_DIRS.has(entry.name)
				) {
					queue.push({ path: entryPath, depth: current.depth + 1 });
				}
				continue;
			}
			if (!entry.isFile()) continue;
			files.push(relativePath);
			if (files.length >= MAX_RECURSIVE_SCAN_FILES) break;
		}
	}
	return files;
}
