import { DefaultResourceLoader } from '@nerf/pi-coding-agent';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { compareSemver } from './update-check.js';
import { discoverExtensionEntryPaths } from './extension-discovery.js';
import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled, ensureRegistryEntries } from './extension-registry.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 NERF repo's
// working tree. Switching branches there changes src/resources/ for ALL projects
// that use nerf — causing stale/broken extensions to be synced to ~/.nerf/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(dirname(fileURLToPath(import.meta.url)), '..');
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 resourceVersionManifestName = 'managed-resources.json';
export { discoverExtensionEntryPaths } from './extension-discovery.js';
export function getExtensionKey(entryPath, extensionsDir) {
    const relPath = relative(extensionsDir, entryPath);
    return relPath.split(/[\\/]/)[0].replace(/\.(?:ts|js)$/, '');
}
function getManagedResourceManifestPath(agentDir) {
    return join(agentDir, resourceVersionManifestName);
}
function getBundledNerfVersion() {
    // Prefer NERF_VERSION env var (set once by loader.ts) to avoid re-reading package.json
    if (process.env.NERF_VERSION && process.env.NERF_VERSION !== '0.0.0') {
        return process.env.NERF_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) {
    // Record root-level files and subdirectory extension names currently in the
    // bundled extensions source so that future upgrades can detect and prune any
    // that get removed or moved.
    let installedExtensionRootFiles = [];
    let installedExtensionDirs = [];
    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())
                .filter(e => {
                // Only track directories that are actual extensions (contain index.js or index.ts)
                const dirPath = join(bundledExtensionsDir, e.name);
                return existsSync(join(dirPath, 'index.js')) || existsSync(join(dirPath, 'index.ts'));
            })
                .map(e => e.name);
        }
    }
    catch { /* non-fatal */ }
    const manifest = {
        nerfVersion: getBundledNerfVersion(),
        syncedAt: Date.now(),
        contentHash: computeResourceFingerprint(),
        installedExtensionRootFiles,
        installedExtensionDirs,
    };
    writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest));
}
export function readManagedResourceVersion(agentDir) {
    try {
        const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8'));
        return typeof manifest?.nerfVersion === 'string' ? manifest.nerfVersion : null;
    }
    catch {
        return null;
    }
}
function readManagedResourceManifest(agentDir) {
    try {
        return JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8'));
    }
    catch {
        return null;
    }
}
/**
 * Computes a lightweight content fingerprint of the bundled resources directory.
 *
 * Walks all files under resourcesDir and hashes their relative paths + sizes.
 * This catches same-version content changes (npm link dev workflow, hotfixes
 * within a release) without the cost of reading every file's contents.
 *
 * ~1ms for a typical resources tree (~100 files) — just stat calls, no reads.
 */
function computeResourceFingerprint() {
    const entries = [];
    collectFileEntries(resourcesDir, resourcesDir, entries);
    entries.sort();
    return createHash('sha256').update(entries.join('\n')).digest('hex').slice(0, 16);
}
function collectFileEntries(dir, root, out) {
    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);
            const size = statSync(fullPath).size;
            out.push(`${rel}:${size}`);
        }
    }
}
export function getNewerManagedResourceVersion(agentDir, currentVersion) {
    const managedVersion = readManagedResourceVersion(agentDir);
    if (!managedVersion) {
        return null;
    }
    return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null;
}
/**
 * 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) {
    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;
    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.
 */
function syncResourceDir(srcDir, destDir) {
    if (!existsSync(srcDir))
        return;
    // Clean up any leftover .tmp directory from a prior interrupted sync.
    const tmpDir = destDir + '.tmp';
    if (existsSync(tmpDir))
        rmSync(tmpDir, { recursive: true, force: true });
    // Copy to a temporary sibling directory first, then atomically rename.
    // This prevents a partially-synced extension directory persisting across
    // restarts if the process is interrupted between rmSync and cpSync.
    // CWE-367: Race Condition / non-atomic file write prevention.
    try {
        mkdirSync(tmpDir, { recursive: true });
        try {
            cpSync(srcDir, tmpDir, { recursive: true, force: true });
        }
        catch {
            // Fallback for Windows paths with non-ASCII characters (#1178).
            copyDirRecursive(srcDir, tmpDir);
        }
        makeTreeWritable(tmpDir);
        // Prune stale sibling files from the eventual destination before swap.
        // This must operate on destDir content relative to srcDir — done inline.
        pruneStaleSiblingFiles(srcDir, destDir);
        // Atomic swap: remove old dest, rename tmp into place.
        makeTreeWritable(destDir);
        if (existsSync(destDir))
            rmSync(destDir, { recursive: true, force: true });
        renameSync(tmpDir, destDir);
    }
    catch (err) {
        // Clean up tmp on failure; leave destDir untouched.
        if (existsSync(tmpDir)) {
            try {
                rmSync(tmpDir, { recursive: true, force: true });
            }
            catch { /* best effort */ }
        }
        throw err;
    }
}
function pruneStaleSiblingFiles(srcDir, destDir) {
    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, dest) {
    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 NERF'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
 * ~/.nerf/agent/extensions/ have no ancestor node_modules, so imports of
 * @nerf/* packages fail. The symlink makes Node's standard resolution find
 * them without requiring every call site to use jiti.
 */
function ensureNodeModulesSymlink(agentDir) {
    const agentNodeModules = join(agentDir, 'node_modules');
    const nerfNodeModules = join(packageRoot, 'node_modules');
    try {
        const stat = lstatSync(agentNodeModules);
        if (stat.isSymbolicLink()) {
            const existing = readlinkSync(agentNodeModules);
            // Symlink exists — verify it points to the correct, existing target
            if (existing === nerfNodeModules && existsSync(agentNodeModules))
                return; // correct and target exists
            // Stale or wrong target — remove and recreate
            unlinkSync(agentNodeModules);
        }
        else {
            // Real directory (not a symlink) is blocking — remove it
            rmSync(agentNodeModules, { recursive: true, force: true });
        }
    }
    catch {
        // lstatSync throws if path doesn't exist — that's fine, we'll create below
    }
    try {
        symlinkSync(nerfNodeModules, agentNodeModules, 'junction');
    }
    catch (err) {
        // This failure makes NERF non-functional — extensions can't resolve @nerf/* packages.
        // Throw so the caller gets a clear error rather than mysterious MODULE_NOT_FOUND later.
        const detail = err instanceof Error ? err.message : String(err);
        throw new Error(`[nerf] Fatal: failed to create required symlink ${agentNodeModules} → ${nerfNodeModules}.\n` +
            `Cause: ${detail}\n` +
            `Fix: ensure ${nerfNodeModules} exists (run 'npm install' in the NERF project root) ` +
            `and that the parent directory ${agentNodeModules.replace(/[^/\\]+$/, '')} is writable.`);
    }
}
/**
 * Prune root-level extension files that were installed by a previous NERF 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 → nerf/).
 */
function pruneRemovedBundledExtensions(manifest, agentDir) {
    const extensionsDir = join(agentDir, 'extensions');
    if (!existsSync(extensionsDir))
        return;
    // Current bundled root-level files (what the new version provides)
    const currentSourceFiles = new Set();
    // Current bundled subdirectory extensions
    const currentSourceDirs = new Set();
    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) => {
        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) => {
        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);
        }
    }
    // 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 → nerf/ in v2.39.x (#1634)
    removeFileIfStale('env-utils.js');
}
/**
 * Syncs all bundled resources to agentDir (~/.nerf/agent/) on every launch.
 *
 * - extensions/ → ~/.nerf/agent/extensions/   (overwrite when version changes)
 * - agents/     → ~/.nerf/agent/agents/        (overwrite when version changes)
 * - NERF-WORKFLOW.md → ~/.nerf/agent/NERF-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
 * NERF version, avoiding ~128ms of synchronous cpSync on every startup.
 * After `npm update -g @glittercowboy/nerf`, versions will differ and the
 * copy runs once to land the new resources.
 *
 * Inspectable: `ls ~/.nerf/agent/extensions/`
 */
export function initResources(agentDir) {
    mkdirSync(agentDir, { recursive: true });
    const currentVersion = getBundledNerfVersion();
    const manifest = readManagedResourceManifest(agentDir);
    // 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);
    // Ensure ~/.nerf/agent/node_modules symlinks to NERF's node_modules on EVERY
    // launch, not just during resource syncs. A stale/broken symlink makes ALL
    // extensions fail to resolve @nerf/* packages, rendering NERF 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.nerfVersion === currentVersion) {
        // Version matches — check content fingerprint for same-version staleness.
        const currentHash = computeResourceFingerprint();
        const hasStaleExtensionFiles = hasStaleCompiledExtensionSiblings(join(agentDir, 'extensions'));
        if (manifest.contentHash && manifest.contentHash === currentHash && !hasStaleExtensionFiles) {
            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 ~/.nerf/agent/skills/ directory is handled
    // above the manifest check so it runs on every launch (including retries
    // after partial copy failures).
    // Sync NERF-WORKFLOW.md to agentDir as a fallback for when NERF_WORKFLOW_PATH
    // env var is not set (e.g. fork/dev builds, alternative entry points).
    const workflowSrc = join(resourcesDir, 'NERF-WORKFLOW.md');
    if (existsSync(workflowSrc)) {
        try {
            copyFileSync(workflowSrc, join(agentDir, 'NERF-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).
    makeTreeWritable(agentDir);
    writeManagedResourceManifest(agentDir);
    ensureRegistryEntries(join(agentDir, 'extensions'));
}
// ─── Legacy Skill Migration ──────────────────────────────────────────────────────
/**
 * One-time migration: copy user-customized skills from the old
 * ~/.nerf/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 NERF 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) {
    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 NERF processes start simultaneously.
    let markerFd;
    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 (~/.nerf/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) {
    if (!existsSync(extensionsDir))
        return false;
    for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
        if (!entry.isFile() || !entry.name.endsWith('.ts'))
            continue;
        const jsName = entry.name.replace(/\.ts$/, '.js');
        if (existsSync(join(extensionsDir, jsName))) {
            return true;
        }
    }
    return false;
}
/**
 * Constructs a DefaultResourceLoader that loads extensions from both
 * ~/.nerf/agent/extensions/ (NERF'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 = null;
function getBundledExtensionKeys() {
    if (!_bundledExtensionKeys) {
        _bundledExtensionKeys = new Set(discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) => getExtensionKey(entryPath, bundledExtensionsDir)));
    }
    return _bundledExtensionKeys;
}
export function buildResourceLoader(agentDir) {
    const registry = loadRegistry();
    const piAgentDir = join(homedir(), '.pi', 'agent');
    const piExtensionsDir = join(piAgentDir, 'extensions');
    const bundledKeys = getBundledExtensionKeys();
    const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir)
        .filter((entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir)))
        .filter((entryPath) => {
        const manifest = readManifestFromEntryPath(entryPath);
        if (!manifest)
            return true;
        return isExtensionEnabled(registry, manifest.id);
    });
    return new DefaultResourceLoader({
        agentDir,
        additionalExtensionPaths: piExtensionPaths,
        bundledExtensionNames: bundledKeys,
    });
}
