import {
	DefaultResourceLoader,
	sortExtensionPaths,
} from "@singularity-forge/coding-agent";

if (process.env.SF_DEBUG_EXTENSIONS)
	process.stderr.write("[sf-debug] resource-loader.ts loaded\n");

import { createHash } from "node:crypto";
import {
	chmodSync,
	closeSync,
	copyFileSync,
	cpSync,
	existsSync,
	lstatSync,
	mkdirSync,
	openSync,
	readdirSync,
	readFileSync,
	readlinkSync,
	rmSync,
	statSync,
	symlinkSync,
	unlinkSync,
	writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, relative, resolve } from "node:path";
import { discoverExtensionEntryPaths } from "./extension-discovery.js";
import {
	ensureRegistryEntries,
	isExtensionEnabled,
	loadRegistry,
	readManifestFromEntryPath,
} from "./extension-registry.js";
import { getLogger } from "./logger.js";
import { compareSemver } from "./update-check.js";

// Resolve resources directory — prefer dist/resources/ (stable, set at build time)
// over src/resources/ (live working tree, changes with git branch).
//
// Why this matters: with `npm link`, src/resources/ points into the sf-run repo's
// working tree. Switching branches there changes src/resources/ for ALL projects
// that use sf — causing stale/broken extensions to be synced to ~/.sf/agent/.
// dist/resources/ is populated by the build step (`npm run copy-resources`) and
// reflects the built state, not the currently checked-out branch.
const packageRoot = resolve(import.meta.dirname, "..");
const distResources = join(packageRoot, "dist", "resources");
const srcResources = join(packageRoot, "src", "resources");
// Use dist/resources only if it has the full expected structure.
// A partial build (tsc without copy-resources) creates dist/resources/extensions/
// but not agents/ or skills/, causing initResources to sync from an incomplete source.
const resourcesDir =
	existsSync(distResources) && existsSync(join(distResources, "agents"))
		? distResources
		: srcResources;
const bundledExtensionsDir = join(resourcesDir, "extensions");
const log = getLogger("sf.core.resource-loader");

const resourceVersionManifestName = "managed-resources.json";

interface ManagedResourceManifest {
	sfVersion: string;
	syncedAt?: number;
	/** Content fingerprint of bundled resources — detects same-version content changes. */
	contentHash?: string;
	/**
	 * Root-level files installed in extensions/ by this SF version.
	 * Used on the next upgrade to detect and prune files that were removed or
	 * moved into a subdirectory, preventing orphaned non-extension files from
	 * causing extension load errors.
	 */
	installedExtensionRootFiles?: string[];
	/**
	 * Subdirectories installed in extensions/ by this SF version.
	 * Includes extension packages and shared support modules so a missing support
	 * module invalidates the manifest-gated startup skip.
	 */
	installedExtensionDirs?: string[];
}

export { discoverExtensionEntryPaths } from "./extension-discovery.js";

export function getExtensionKey(
	entryPath: string,
	extensionsDir: string,
): string {
	const relPath = relative(extensionsDir, entryPath);
	return relPath.split(/[\\/]/)[0].replace(/\.(?:ts|js)$/, "");
}

function getManagedResourceManifestPath(agentDir: string): string {
	return join(agentDir, resourceVersionManifestName);
}

function getBundledSfVersion(): string {
	// Prefer SF_VERSION env var (set once by loader.ts) to avoid re-reading package.json
	if (process.env.SF_VERSION && process.env.SF_VERSION !== "0.0.0") {
		return process.env.SF_VERSION;
	}
	try {
		const pkg = JSON.parse(
			readFileSync(join(packageRoot, "package.json"), "utf-8"),
		);
		return typeof pkg?.version === "string" ? pkg.version : "0.0.0";
	} catch {
		return "0.0.0";
	}
}

function writeManagedResourceManifest(agentDir: string): void {
	// Record root-level files and subdirectory names currently in the
	// bundled extensions source so that future upgrades can detect and prune any
	// that get removed or moved.
	let installedExtensionRootFiles: string[] = [];
	let installedExtensionDirs: string[] = [];
	try {
		if (existsSync(bundledExtensionsDir)) {
			const entries = readdirSync(bundledExtensionsDir, {
				withFileTypes: true,
			});
			installedExtensionRootFiles = entries
				.filter((e) => e.isFile())
				.map((e) => e.name);
			installedExtensionDirs = entries
				.filter((e) => e.isDirectory())
				.map((e) => e.name);
		}
	} catch {
		/* non-fatal */
	}

	const manifest: ManagedResourceManifest = {
		sfVersion: getBundledSfVersion(),
		syncedAt: Date.now(),
		contentHash: computeResourceFingerprint(),
		installedExtensionRootFiles,
		installedExtensionDirs,
	};
	writeFileSync(
		getManagedResourceManifestPath(agentDir),
		JSON.stringify(manifest),
	);
}

export function readManagedResourceVersion(agentDir: string): string | null {
	try {
		const manifest = JSON.parse(
			readFileSync(getManagedResourceManifestPath(agentDir), "utf-8"),
		) as ManagedResourceManifest;
		return typeof manifest?.sfVersion === "string" ? manifest.sfVersion : null;
	} catch {
		return null;
	}
}

function readManagedResourceManifest(
	agentDir: string,
): ManagedResourceManifest | null {
	try {
		return JSON.parse(
			readFileSync(getManagedResourceManifestPath(agentDir), "utf-8"),
		) as ManagedResourceManifest;
	} catch {
		return null;
	}
}

/**
 * Computes a content fingerprint of a resources directory (defaults to the
 * bundled resourcesDir).
 *
 * Walks all files under `rootDir` and builds an aggregate fingerprint from
 * `${relativePath}:${sha256(contents)}` for each one. Content hashing is required
 * because same-byte-length prompt/resource edits must still invalidate the
 * installed resource cache.
 *
 * Cost is ~1-5ms even for large trees — negligible at startup.
 *
 * Exported for unit tests and for callers that want to check a different
 * directory (e.g. pre-install verification).
 */
export function computeResourceFingerprint(
	rootDir: string = resourcesDir,
): string {
	const entries: string[] = [];
	collectFileEntries(rootDir, rootDir, entries);
	entries.sort();
	return createHash("sha256")
		.update(entries.join("\n"))
		.digest("hex")
		.slice(0, 16);
}

function collectFileEntries(dir: string, root: string, out: string[]): void {
	if (!existsSync(dir)) return;
	for (const entry of readdirSync(dir, { withFileTypes: true })) {
		const fullPath = join(dir, entry.name);
		if (entry.isDirectory()) {
			collectFileEntries(fullPath, root, out);
		} else {
			const rel = relative(root, fullPath);
			let fingerprint: string;
			try {
				fingerprint = createHash("sha256")
					.update(readFileSync(fullPath))
					.digest("hex");
			} catch {
				// Unreadable file — fall back to a stable marker so the entry still
				// contributes to the aggregate hash and future reads will re-hash.
				fingerprint = "unreadable";
			}
			out.push(`${rel}:${fingerprint}`);
		}
	}
}

export function getNewerManagedResourceVersion(
	agentDir: string,
	currentVersion: string,
): string | null {
	const managedVersion = readManagedResourceVersion(agentDir);
	if (!managedVersion) {
		return null;
	}
	return compareSemver(managedVersion, currentVersion) > 0
		? managedVersion
		: null;
}

const RESOURCE_SYNC_LOCK_STALE_MS = 120_000;
const RESOURCE_SYNC_LOCK_TIMEOUT_MS = 60_000;
const RESOURCE_SYNC_LOCK_POLL_MS = 100;

function sleepSync(ms: number): void {
	const signal = new Int32Array(new SharedArrayBuffer(4));
	Atomics.wait(signal, 0, 0, ms);
}

function readLockPid(lockDir: string): number | null {
	try {
		const raw = readFileSync(join(lockDir, "owner"), "utf-8").trim();
		const pid = Number.parseInt(raw, 10);
		return Number.isFinite(pid) && pid > 0 ? pid : null;
	} catch {
		return null;
	}
}

function isProcessAlive(pid: number): boolean {
	try {
		process.kill(pid, 0);
		return true;
	} catch {
		return false;
	}
}

function removeStaleResourceSyncLock(lockDir: string): boolean {
	if (!existsSync(lockDir)) return true;

	const pid = readLockPid(lockDir);
	let oldEnough = false;
	try {
		oldEnough =
			Date.now() - statSync(lockDir).mtimeMs > RESOURCE_SYNC_LOCK_STALE_MS;
	} catch {
		oldEnough = true;
	}
	const ownerDead = pid !== null && !isProcessAlive(pid);
	if (!oldEnough && !ownerDead) return false;

	try {
		rmSync(lockDir, { recursive: true, force: true, maxRetries: 3 });
		return true;
	} catch {
		return false;
	}
}

/**
 * Serialise writes to the shared managed resource tree.
 *
 * Purpose: prevent parallel SF launches from deleting/copying the same
 * ~/.sf/agent/extensions subtree concurrently, which can surface as transient
 * ENOTEMPTY/ENOENT failures during startup.
 *
 * Consumer: initResources before it prunes and copies bundled resources.
 */
export function withResourceSyncLock<T>(
	agentDir: string,
	work: () => T,
	timeoutMs = RESOURCE_SYNC_LOCK_TIMEOUT_MS,
): T {
	const lockDir = join(agentDir, ".resource-sync.lock");
	const startedAt = Date.now();

	while (true) {
		try {
			mkdirSync(lockDir);
			writeFileSync(join(lockDir, "owner"), `${process.pid}\n`);
			break;
		} catch {
			removeStaleResourceSyncLock(lockDir);
			if (Date.now() - startedAt > timeoutMs) {
				throw new Error(
					`Timed out waiting for SF resource sync lock: ${lockDir}`,
				);
			}
			sleepSync(RESOURCE_SYNC_LOCK_POLL_MS);
		}
	}

	try {
		return work();
	} finally {
		try {
			rmSync(lockDir, { recursive: true, force: true, maxRetries: 3 });
		} catch {
			/* non-fatal: stale-lock cleanup on next launch handles this */
		}
	}
}

/**
 * Recursively makes all files and directories under dirPath owner-writable.
 *
 * Files copied from the Nix store inherit read-only modes (0444/0555).
 * Calling this before cpSync prevents overwrite failures on subsequent upgrades,
 * and calling it after ensures the next run can overwrite the copies too.
 *
 * Preserves existing permission bits (including executability) and only adds
 * owner-write (and for directories, owner-exec) without widening group/other
 * permissions.
 */
function makeTreeWritable(dirPath: string): void {
	if (!existsSync(dirPath)) return;

	// Use lstatSync to avoid following symlinks into immutable filesystems
	// (e.g., Nix store on NixOS/nix-darwin). Symlinks don't carry their own
	// permissions and their targets may be read-only by design (#1298).
	const stats = lstatSync(dirPath);
	if (stats.isSymbolicLink()) return;

	// Skip node_modules entirely — it's too large to crawl and should already
	// have correct permissions from the package manager or cpSync.
	if (basename(dirPath) === "node_modules") return;

	const isDir = stats.isDirectory();
	const currentMode = stats.mode & 0o777;

	// Ensure owner-write; for directories also ensure owner-exec so they remain traversable.
	let newMode = currentMode | 0o200;
	if (isDir) {
		newMode |= 0o100;
	}

	if (newMode !== currentMode) {
		try {
			chmodSync(dirPath, newMode);
		} catch {
			// Non-fatal — may fail on read-only filesystems or insufficient permissions
		}
	}

	if (isDir) {
		for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
			const entryPath = join(dirPath, entry.name);
			makeTreeWritable(entryPath);
		}
	}
}

/**
 * Syncs a single bundled resource directory into the agent directory.
 *
 * 1. Makes the destination writable (handles Nix store read-only copies).
 * 2. Removes destination subdirs that exist in source to clear stale files,
 *    while preserving user-created directories.
 * 3. Copies source into destination.
 * 4. Makes the result writable for the next upgrade cycle.
 */
export function syncResourceDir(srcDir: string, destDir: string): void {
	if (!existsSync(srcDir)) return;

	// In dev mode (syncing from src/resources/ in the monorepo), use a symlink
	// instead of cpSync. This makes fresh agentDir initialization (used by every
	// integration test) nearly instantaneous.
	const isDevSync = srcDir.includes(join("src", "resources"));
	if (isDevSync) {
		reconcileSymlink(destDir, srcDir);
		return;
	}

	if (existsSync(destDir) && lstatSync(destDir).isSymbolicLink()) {
		rmSync(destDir, { force: true });
	}

	makeTreeWritable(destDir);
	pruneStaleSiblingFiles(srcDir, destDir);
	for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
		if (entry.isDirectory()) {
			const target = join(destDir, entry.name);
			if (existsSync(target))
				rmSync(target, {
					recursive: true,
					force: true,
					maxRetries: 5,
					retryDelay: 50,
				});
		}
	}
	try {
		cpSync(srcDir, destDir, { recursive: true, force: true });
	} catch {
		// Fallback for Windows paths with non-ASCII characters where cpSync
		// fails with the \\?\ extended-length prefix (#1178).
		copyDirRecursive(srcDir, destDir);
	}
	makeTreeWritable(destDir);
}

function pruneStaleSiblingFiles(srcDir: string, destDir: string): void {
	if (!existsSync(destDir)) return;

	const sourceFiles = new Set(
		readdirSync(srcDir, { withFileTypes: true })
			.filter((entry) => entry.isFile())
			.map((entry) => entry.name),
	);

	for (const entry of readdirSync(destDir, { withFileTypes: true })) {
		if (!entry.isFile()) continue;
		if (sourceFiles.has(entry.name)) continue;

		const sourceJsName = entry.name.replace(/\.ts$/, ".js");
		const sourceTsName = entry.name.replace(/\.js$/, ".ts");
		if (sourceFiles.has(sourceJsName) || sourceFiles.has(sourceTsName)) {
			rmSync(join(destDir, entry.name), { force: true });
		}
	}
}

/**
 * Recursive directory copy using copyFileSync — workaround for cpSync failures
 * on Windows paths containing non-ASCII characters (#1178).
 */
function copyDirRecursive(src: string, dest: string): void {
	mkdirSync(dest, { recursive: true });
	for (const entry of readdirSync(src, { withFileTypes: true })) {
		const srcPath = join(src, entry.name);
		const destPath = join(dest, entry.name);
		if (entry.isDirectory()) {
			copyDirRecursive(srcPath, destPath);
		} else {
			copyFileSync(srcPath, destPath);
		}
	}
}

/**
 * Creates (or updates) a symlink at agentDir/node_modules pointing to SF's
 * own node_modules directory.
 *
 * Native ESM `import()` ignores NODE_PATH — it resolves packages by walking
 * up the directory tree from the importing file. Extension files synced to
 * ~/.sf/agent/extensions/ have no ancestor node_modules, so imports of
 * @singularity-forge/* packages fail. The symlink makes Node's standard resolution find
 * them without requiring every call site to use jiti.
 *
 * Layout differences by install method:
 * - Source/monorepo: packageRoot/node_modules has everything → simple symlink
 * - npm/bun global: deps hoisted to dirname(packageRoot), including @singularity-forge/* → simple symlink
 * - pnpm global: external deps hoisted, but @singularity-forge/* stays in packageRoot/node_modules
 *   → merged directory with symlinks from both roots (#3529, #3564)
 */
function ensureNodeModulesSymlink(agentDir: string): void {
	const agentNodeModules = join(agentDir, "node_modules");
	const internalNodeModules = join(packageRoot, "node_modules");
	const hoistedNodeModules = dirname(packageRoot);
	const isGlobalInstall = basename(hoistedNodeModules) === "node_modules";

	if (!isGlobalInstall) {
		// Source/monorepo: internal node_modules has everything
		reconcileSymlink(agentNodeModules, internalNodeModules);
		return;
	}

	// Global install: check if workspace scopes (@singularity-forge/*) are hoisted.
	// npm/bun hoist everything; pnpm keeps workspace packages internal.
	if (!hasMissingWorkspaceScopes(hoistedNodeModules, internalNodeModules)) {
		// Everything is hoisted — simple symlink to parent node_modules
		reconcileSymlink(agentNodeModules, hoistedNodeModules);
		return;
	}

	// pnpm-style layout: create a real directory merging both roots
	reconcileMergedNodeModules(
		agentNodeModules,
		hoistedNodeModules,
		internalNodeModules,
	);
}

/** Check if any @singularity-forge* scopes exist in internal but not in hoisted node_modules */
function hasMissingWorkspaceScopes(hoisted: string, internal: string): boolean {
	if (!existsSync(internal)) return false;
	try {
		for (const entry of readdirSync(internal, { withFileTypes: true })) {
			if (
				entry.isDirectory() &&
				entry.name.startsWith("@singularity-forge") &&
				!existsSync(join(hoisted, entry.name))
			) {
				return true;
			}
		}
	} catch {
		/* non-fatal */
	}
	return false;
}

/** Ensure a symlink at `link` points to `target`, fixing stale/wrong entries */
function reconcileSymlink(link: string, target: string): void {
	try {
		const stat = lstatSync(link);
		if (stat.isSymbolicLink()) {
			const existing = readlinkSync(link);
			if (existing === target && existsSync(link)) return; // correct and target exists
			unlinkSync(link);
		} else {
			// Real directory (or merged dir from previous pnpm fix) — remove it
			rmSync(link, { recursive: true, force: true });
		}
	} catch {
		// lstatSync throws if path doesn't exist — fine, we'll create below
	}

	try {
		symlinkSync(target, link, "junction");
	} catch (err) {
		log.warn(`Failed to symlink ${link} → ${target}`, { error: err });
	}
}

/**
 * Create a real node_modules directory containing symlinks from both the
 * hoisted root (external deps) and internal root (@singularity-forge/* workspace packages).
 * Used for pnpm global installs where @singularity-forge/* isn't hoisted.
 */
function reconcileMergedNodeModules(
	agentNodeModules: string,
	hoisted: string,
	internal: string,
): void {
	// Fast path: if already merged for this packageRoot + same directory contents, skip.
	// The fingerprint includes entry names from both roots so `pnpm add/remove` triggers rebuild.
	const marker = join(agentNodeModules, ".sf-merged");
	const fingerprint = mergedFingerprint(hoisted, internal);
	try {
		if (
			existsSync(marker) &&
			readFileSync(marker, "utf-8").trim() === fingerprint
		)
			return;
	} catch {
		/* rebuild */
	}

	// Remove any existing symlink or stale merged directory
	try {
		const stat = lstatSync(agentNodeModules);
		if (stat.isSymbolicLink()) {
			unlinkSync(agentNodeModules);
		} else {
			rmSync(agentNodeModules, { recursive: true, force: true });
		}
	} catch {
		/* doesn't exist */
	}

	mkdirSync(agentNodeModules, { recursive: true });

	let linkedCount = 0;

	// Symlink entries from the hoisted node_modules (external deps)
	try {
		for (const entry of readdirSync(hoisted, { withFileTypes: true })) {
			// Skip the sf-run package itself and dotfiles
			if (entry.name === basename(packageRoot)) continue;
			if (entry.name.startsWith(".")) continue;
			try {
				symlinkSync(
					join(hoisted, entry.name),
					join(agentNodeModules, entry.name),
					"junction",
				);
				linkedCount++;
			} catch {
				/* skip individual */
			}
		}
	} catch (err) {
		log.warn(`Failed to read hoisted node_modules at ${hoisted}`, {
			error: err,
		});
	}

	// Overlay internal node_modules entries that weren't hoisted.
	// This covers @singularity-forge/* workspace packages AND optional deps like
	// @anthropic-ai/claude-agent-sdk that npm keeps internal.
	try {
		for (const entry of readdirSync(internal, { withFileTypes: true })) {
			if (entry.name.startsWith(".")) continue;
			const link = join(agentNodeModules, entry.name);
			// Replace hoisted symlink with internal version (internal takes precedence)
			try {
				lstatSync(link);
				unlinkSync(link);
			} catch {
				/* didn't exist — will create below */
			}
			try {
				symlinkSync(join(internal, entry.name), link, "junction");
				linkedCount++;
			} catch {
				/* skip individual */
			}
		}
	} catch (err) {
		log.warn(`Failed to read internal node_modules at ${internal}`, {
			error: err,
		});
	}

	// Only stamp marker if we actually linked something — avoids caching a broken state
	if (linkedCount > 0) {
		try {
			writeFileSync(marker, fingerprint);
		} catch {
			/* non-fatal */
		}
	}
}

/** Build a cache fingerprint from packageRoot + sorted entry names of both directories */
function mergedFingerprint(hoisted: string, internal: string): string {
	try {
		const h = readdirSync(hoisted).sort().join(",");
		const i = readdirSync(internal).sort().join(",");
		return `${packageRoot}\n${h}\n${i}`;
	} catch {
		return packageRoot; // fallback: at least invalidate on version change
	}
}

/**
 * Verify that all files recorded in the manifest still exist on disk.
 * If any file is missing, the manifest is stale and a full resync is needed.
 * This catches cases where files were deleted manually or by git operations
 * after the manifest was written (#sf-moody5qi-8gbwp2).
 */
function verifyManifestFilesExist(
	manifest: ManagedResourceManifest,
	agentDir: string,
): boolean {
	const extensionsDir = join(agentDir, "extensions");
	// Check root files
	if (manifest.installedExtensionRootFiles) {
		for (const file of manifest.installedExtensionRootFiles) {
			if (!existsSync(join(extensionsDir, file))) {
				return false;
			}
		}
	}
	// Check subdirectory extensions
	if (manifest.installedExtensionDirs) {
		for (const dir of manifest.installedExtensionDirs) {
			if (!existsSync(join(extensionsDir, dir))) {
				return false;
			}
		}
	}
	try {
		if (existsSync(bundledExtensionsDir)) {
			for (const entry of readdirSync(bundledExtensionsDir, {
				withFileTypes: true,
			})) {
				if (entry.isFile() && !existsSync(join(extensionsDir, entry.name))) {
					return false;
				}
				if (
					entry.isDirectory() &&
					!existsSync(join(extensionsDir, entry.name))
				) {
					return false;
				}
			}
		}
	} catch {
		return false;
	}
	return true;
}

/**
 * Prune root-level extension files that were installed by a previous SF version
 * but have since been removed or relocated to a subdirectory.
 *
 * Two strategies:
 * 1. Manifest-based (preferred): the manifest records which root files were installed
 *    last time; any that are no longer in the current bundle are deleted.
 * 2. Known-stale fallback: for upgrades from versions before manifest tracking,
 *    explicitly delete files known to have been moved (e.g. env-utils.js → sf/).
 */
function pruneRemovedBundledExtensions(
	manifest: ManagedResourceManifest | null,
	agentDir: string,
): void {
	const extensionsDir = join(agentDir, "extensions");
	if (!existsSync(extensionsDir)) return;

	// Current bundled root-level files (what the new version provides)
	const currentSourceFiles = new Set<string>();
	// Current bundled subdirectory extensions
	const currentSourceDirs = new Set<string>();
	try {
		if (existsSync(bundledExtensionsDir)) {
			for (const e of readdirSync(bundledExtensionsDir, {
				withFileTypes: true,
			})) {
				if (e.isFile()) currentSourceFiles.add(e.name);
				if (e.isDirectory()) currentSourceDirs.add(e.name);
			}
		}
	} catch {
		/* non-fatal */
	}

	const removeFileIfStale = (fileName: string) => {
		if (currentSourceFiles.has(fileName)) return; // still in bundle, not stale
		const stale = join(extensionsDir, fileName);
		try {
			if (existsSync(stale)) rmSync(stale, { force: true });
		} catch {
			/* non-fatal */
		}
	};

	const removeDirIfStale = (dirName: string) => {
		if (currentSourceDirs.has(dirName)) return; // still in bundle, not stale
		const stale = join(extensionsDir, dirName);
		try {
			if (existsSync(stale)) rmSync(stale, { recursive: true, force: true });
		} catch {
			/* non-fatal */
		}
	};

	if (manifest?.installedExtensionRootFiles) {
		// Manifest-based: remove previously-installed root files that are no longer bundled
		for (const prevFile of manifest.installedExtensionRootFiles) {
			removeFileIfStale(prevFile);
		}
	}

	if (manifest?.installedExtensionDirs) {
		// Manifest-based: remove previously-installed subdirectory extensions that are no longer bundled
		for (const prevDir of manifest.installedExtensionDirs) {
			removeDirIfStale(prevDir);
		}
	}

	// Sweep-based: also remove any installed extension subdirectory not in the current bundle,
	// even if it was never tracked in the manifest (e.g. installed by a pre-manifest version).
	try {
		if (existsSync(extensionsDir)) {
			for (const e of readdirSync(extensionsDir, { withFileTypes: true })) {
				if (e.isDirectory()) removeDirIfStale(e.name);
			}
		}
	} catch {
		/* non-fatal */
	}

	// Always remove known stale files regardless of manifest state.
	// These were installed by pre-manifest versions so they may not appear in
	// installedExtensionRootFiles even when a manifest exists.
	// env-utils.js was moved from extensions/ root → sf/ in v2.39.x (#1634)
	removeFileIfStale("env-utils.js");
}

/**
 * Syncs all bundled resources to agentDir (~/.sf/agent/) on every launch.
 *
 * - extensions/ → ~/.sf/agent/extensions/   (overwrite when version changes)
 * - agents/     → ~/.sf/agent/agents/        (overwrite when version changes)
 * - SF-WORKFLOW.md → ~/.sf/agent/SF-WORKFLOW.md (fallback for env var miss)
 *
 * Skills are NOT synced here. They are installed by the user via the
 * skills.sh CLI (`npx skills add <repo>`) into ~/.agents/skills/ — the
 * industry-standard Agent Skills ecosystem directory.
 *
 * Skips the copy when the managed-resources.json version matches the current
 * SF version, avoiding ~128ms of synchronous cpSync on every startup.
 * After `npm update -g @glittercowboy/sf`, versions will differ and the
 * copy runs once to land the new resources.
 *
 * Inspectable: `ls ~/.sf/agent/extensions/`
 */
export function initResources(agentDir: string): void {
	mkdirSync(agentDir, { recursive: true });
	withResourceSyncLock(agentDir, () => initResourcesUnlocked(agentDir));
}

function initResourcesUnlocked(agentDir: string): void {
	const currentVersion = getBundledSfVersion();
	const manifest = readManagedResourceManifest(agentDir);
	const extensionsDir = join(agentDir, "extensions");

	// Always prune root-level extension files that were removed from the bundle.
	// This is cheap (a few existence checks + at most one rmSync) and must run
	// unconditionally so that stale files left by a previous version are cleaned
	// up even when the version/hash match causes the full sync to be skipped.
	pruneRemovedBundledExtensions(manifest, agentDir);
	pruneStaleSiblingFiles(bundledExtensionsDir, extensionsDir);

	// Ensure ~/.sf/agent/node_modules symlinks to SF's node_modules on EVERY
	// launch, not just during resource syncs. A stale/broken symlink makes ALL
	// extensions fail to resolve @singularity-forge/* packages, rendering SF non-functional.
	ensureNodeModulesSymlink(agentDir);

	// Migrate legacy skills on every launch (not gated by manifest) so that
	// partial-failure retries don't wait for a version bump.
	migrateSkillsToEcosystemDir(agentDir);

	// Skip the full copy when both version AND content fingerprint match.
	// Version-only checks miss same-version content changes (npm link dev workflow,
	// hotfixes within a release). The content hash catches those at ~1ms cost.
	if (manifest && manifest.sfVersion === currentVersion) {
		// Version matches — check content fingerprint for same-version staleness.
		const currentHash = computeResourceFingerprint();
		const hasStaleExtensionFiles = hasStaleCompiledExtensionSiblings(
			extensionsDir,
			bundledExtensionsDir,
		);
		// Also verify files listed in manifest actually exist on disk (#sf-moody5qi-8gbwp2)
		const manifestFilesExist = verifyManifestFilesExist(manifest, agentDir);
		if (
			manifest.contentHash &&
			manifest.contentHash === currentHash &&
			!hasStaleExtensionFiles &&
			manifestFilesExist
		) {
			return;
		}
	}

	// Sync bundled resources — overwrite so updates land on next launch.

	syncResourceDir(bundledExtensionsDir, join(agentDir, "extensions"));
	syncResourceDir(join(resourcesDir, "agents"), join(agentDir, "agents"));
	// Skills are no longer force-synced here. Users install skills via the
	// skills.sh CLI (`npx skills add <repo>`) into ~/.agents/skills/ which
	// is the industry-standard Agent Skills ecosystem directory.
	//
	// Migration from the legacy ~/.sf/agent/skills/ directory is handled
	// above the manifest check so it runs on every launch (including retries
	// after partial copy failures).

	const workflowSrc = join(resourcesDir, "SF-WORKFLOW.md");
	if (existsSync(workflowSrc)) {
		try {
			copyFileSync(workflowSrc, join(agentDir, "SF-WORKFLOW.md"));
		} catch {
			/* non-fatal */
		}
	}

	// Ensure all newly copied files are owner-writable so the next run can
	// overwrite them (covers extensions, agents, and skills in one walk).
	// Fast now because makeTreeWritable skips node_modules recursion (#3471).
	makeTreeWritable(agentDir);

	writeManagedResourceManifest(agentDir);
	ensureRegistryEntries(join(agentDir, "extensions"));
}

// ─── Legacy Skill Migration ──────────────────────────────────────────────────────

/**
 * One-time migration: copy user-customized skills from the old
 * ~/.sf/agent/skills/ directory into ~/.agents/skills/.
 *
 * The migration is conservative:
 *  - Only skill directories containing a SKILL.md are considered.
 *  - Copies, does not move — the old directory stays intact so downgrading
 *    to a pre-migration SF version still works.
 *  - Collision-safe — if a skill name already exists in the target, the
 *    existing ecosystem skill wins (user may have already installed a newer
 *    version via skills.sh).
 *  - Writes a `.migrated-to-agents` marker inside the legacy directory so
 *    the migration runs at most once.
 */
function migrateSkillsToEcosystemDir(agentDir: string): void {
	const legacyDir = join(agentDir, "skills");
	const markerPath = join(legacyDir, ".migrated-to-agents");

	// Already migrated or no legacy dir — nothing to do
	if (!existsSync(legacyDir)) return;

	// Atomic marker check — 'wx' fails if file already exists, preventing races
	// when two SF processes start simultaneously.
	let markerFd: number;
	try {
		markerFd = openSync(markerPath, "wx");
	} catch {
		return; // marker already exists (another process won the race, or already migrated)
	}

	try {
		const ecosystemDir = join(homedir(), ".agents", "skills");
		mkdirSync(ecosystemDir, { recursive: true });

		const entries = readdirSync(legacyDir, { withFileTypes: true });
		let migrated = 0;
		let candidates = 0;
		for (const entry of entries) {
			// Handle both real directories and symlinks pointing to directories
			const isDir = entry.isDirectory();
			const isSymlink = entry.isSymbolicLink();
			if (!isDir && !isSymlink) continue;

			const sourcePath = join(legacyDir, entry.name);

			// For symlinks, verify the target is a directory
			if (isSymlink) {
				try {
					const stat = statSync(sourcePath);
					if (!stat.isDirectory()) continue;
				} catch {
					continue; // broken symlink — skip
				}
			}

			const skillMd = join(sourcePath, "SKILL.md");
			if (!existsSync(skillMd)) continue;

			const target = join(ecosystemDir, entry.name);
			if (existsSync(target)) continue; // ecosystem version wins

			candidates++;
			try {
				if (isSymlink) {
					// Recreate the symlink in the ecosystem directory using an absolute
					// target. Relative symlinks would resolve from the new parent dir
					// (~/.agents/skills/) instead of the original (~/.sf/agent/skills/),
					// pointing to the wrong location.
					const rawTarget = readlinkSync(sourcePath);
					const absTarget = resolve(dirname(sourcePath), rawTarget);
					symlinkSync(absTarget, target);
				} else {
					cpSync(sourcePath, target, { recursive: true });
				}
				migrated++;
			} catch {
				// non-fatal — skip this skill
			}
		}

		// If any skills failed to copy, remove the marker so migration retries
		// on the next launch.  This keeps the legacy dir as fallback until every
		// skill has been successfully migrated.
		if (migrated < candidates) {
			try {
				closeSync(markerFd);
				markerFd = -1;
			} catch {
				/* non-fatal */
			}
			try {
				unlinkSync(markerPath);
			} catch {
				/* non-fatal */
			}
			return;
		}

		// Write migration info to the marker
		try {
			writeFileSync(
				markerFd,
				`Migrated ${migrated} skill(s) to ${ecosystemDir} on ${new Date().toISOString()}\n`,
			);
		} catch {
			/* non-fatal */
		}
	} catch {
		// can't create ecosystem dir or read legacy dir — close fd first (required on Windows
		// where unlinkSync fails on open handles), then remove marker so we retry next launch
		try {
			closeSync(markerFd);
			markerFd = -1;
		} catch {
			/* non-fatal */
		}
		try {
			unlinkSync(markerPath);
		} catch {
			/* non-fatal */
		}
	} finally {
		if (markerFd !== -1) {
			try {
				closeSync(markerFd);
			} catch {
				/* non-fatal */
			}
		}
	}
}

export function hasStaleCompiledExtensionSiblings(
	extensionsDir: string,
	sourceDir: string = bundledExtensionsDir,
): boolean {
	if (!existsSync(extensionsDir)) return false;
	const sourceFiles = existsSync(sourceDir)
		? new Set(
				readdirSync(sourceDir, { withFileTypes: true })
					.filter((entry) => entry.isFile())
					.map((entry) => entry.name),
			)
		: new Set<string>();
	for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
		if (!entry.isFile()) continue;
		if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".js")) continue;

		const siblingName = entry.name.endsWith(".ts")
			? entry.name.replace(/\.ts$/, ".js")
			: entry.name.replace(/\.js$/, ".ts");

		if (!existsSync(join(extensionsDir, siblingName))) continue;
		if (sourceFiles.has(entry.name) && sourceFiles.has(siblingName)) continue;
		if (sourceFiles.has(entry.name) || sourceFiles.has(siblingName)) {
			return true;
		}
	}
	return false;
}

/**
 * Constructs a DefaultResourceLoader that loads extensions from both
 * ~/.sf/agent/extensions/ (SF's default) and ~/.pi/agent/extensions/ (pi's default).
 * This allows users to use extensions from either location.
 */
// Cache bundled extension keys at module load — avoids re-scanning the extensions
// directory in buildResourceLoader() (already scanned by loader.ts for env var).
let _bundledExtensionKeys: Set<string> | null = null;
function getBundledExtensionKeys(): Set<string> {
	if (!_bundledExtensionKeys) {
		_bundledExtensionKeys = new Set(
			discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) =>
				getExtensionKey(entryPath, bundledExtensionsDir),
			),
		);
	}
	return _bundledExtensionKeys;
}

/**
 * Optional overrides passed through to DefaultResourceLoader. Print mode
 * needs these — it used to construct DefaultResourceLoader directly, which
 * bypassed buildResourceLoader's extensionPathsTransform (= the SF registry
 * filter) and let disabled bundled extensions like `ollama` leak through and
 * conflict with community replacements such as `@0xkobold/pi-ollama`.
 */
export interface BuildResourceLoaderOptions {
	additionalExtensionPaths?: string[];
	appendSystemPrompt?: string;
}

export function buildResourceLoader(
	agentDir: string,
	options: BuildResourceLoaderOptions = {},
): DefaultResourceLoader {
	const registry = loadRegistry();
	const piAgentDir = join(homedir(), ".pi", "agent");
	const piExtensionsDir = join(piAgentDir, "extensions");
	const piLegacyExtensionsDir = join(homedir(), ".pi", "extensions");
	const bundledKeys = getBundledExtensionKeys();

	const discoverPiExtensions = (dir: string): string[] =>
		discoverExtensionEntryPaths(dir)
			.filter((entryPath) => !bundledKeys.has(getExtensionKey(entryPath, dir)))
			.filter((entryPath) => {
				const manifest = readManifestFromEntryPath(entryPath);
				if (!manifest) return true;
				return isExtensionEnabled(registry, manifest.id);
			});

	const piExtensionPaths = [
		...discoverPiExtensions(piExtensionsDir),
		...discoverPiExtensions(piLegacyExtensionsDir),
	];

	// Print-mode callers pass their own additional extension paths (e.g. --extension
	// flags). Non-print mode uses the implicit pi-extensions discovery above.
	const additionalExtensionPaths =
		options.additionalExtensionPaths &&
		options.additionalExtensionPaths.length > 0
			? options.additionalExtensionPaths
			: piExtensionPaths;

	return new DefaultResourceLoader({
		agentDir,
		additionalExtensionPaths,
		appendSystemPrompt: options.appendSystemPrompt,
		bundledExtensionKeys: bundledKeys,
		extensionPathsTransform: (paths: string[]) => {
			// Filter community + bundled extensions through the SF registry so
			// explicitly-disabled entries (e.g. bundled `ollama` superseded by
			// `@0xkobold/pi-ollama`) never reach the runtime and trigger command
			// conflicts.
			const filteredPaths = paths.filter((entryPath) => {
				const manifest = readManifestFromEntryPath(entryPath);
				if (!manifest) return true; // no manifest = always load
				return isExtensionEnabled(registry, manifest.id);
			});

			// Sort in topological dependency order
			const { sortedPaths, warnings } = sortExtensionPaths(filteredPaths);

			return {
				paths: sortedPaths,
				diagnostics: warnings.map((w) => w.message),
			};
		},
	} as ConstructorParameters<typeof DefaultResourceLoader>[0]);
}
