/**
 * NERF Repo Identity — external state directory primitives.
 *
 * Computes a stable per-repo identity hash, resolves the external
 * `~/.nerf/projects/<hash>/` state directory, and manages the
 * `<project>/.nerf → external` symlink.
 */
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
const nerfHome = process.env.NERF_HOME || join(homedir(), ".nerf");
function isRepoMeta(value) {
    if (!value || typeof value !== "object")
        return false;
    const v = value;
    return typeof v.version === "number"
        && typeof v.hash === "string"
        && typeof v.gitRoot === "string"
        && typeof v.remoteUrl === "string"
        && typeof v.createdAt === "string";
}
/**
 * Write (or refresh) repo metadata into the external state directory.
 * Called on open so metadata tracks repo path moves while keeping createdAt stable.
 * Non-fatal: a metadata write failure must never block project setup.
 */
function writeRepoMeta(externalPath, remoteUrl, gitRoot) {
    const metaPath = join(externalPath, "repo-meta.json");
    try {
        let createdAt = new Date().toISOString();
        let existing = null;
        if (existsSync(metaPath)) {
            try {
                const parsed = JSON.parse(readFileSync(metaPath, "utf-8"));
                if (isRepoMeta(parsed)) {
                    existing = parsed;
                    createdAt = parsed.createdAt;
                    // Fast path: nothing changed.
                    if (parsed.version === 1
                        && parsed.hash === basename(externalPath)
                        && parsed.gitRoot === gitRoot
                        && parsed.remoteUrl === remoteUrl) {
                        return;
                    }
                }
            }
            catch {
                // Fall through and rewrite invalid metadata.
            }
        }
        const meta = {
            version: 1,
            hash: basename(externalPath),
            gitRoot,
            remoteUrl,
            createdAt,
        };
        // Keep file format stable even when refreshing.
        writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf-8");
    }
    catch {
        // Non-fatal — metadata write failure should not block project setup
    }
}
/**
 * Read repo metadata from the external state directory.
 * Returns null if the file doesn't exist or can't be parsed.
 */
export function readRepoMeta(externalPath) {
    const metaPath = join(externalPath, "repo-meta.json");
    try {
        if (!existsSync(metaPath))
            return null;
        const raw = readFileSync(metaPath, "utf-8");
        const parsed = JSON.parse(raw);
        return isRepoMeta(parsed) ? parsed : null;
    }
    catch {
        return null;
    }
}
// ─── Inherited-Repo Detection ───────────────────────────────────────────────
/**
 * Check whether `basePath` is inheriting a parent directory's git repo
 * rather than being the git root itself.
 *
 * Returns true when ALL of:
 *   1. basePath is inside a git repo (git rev-parse succeeds)
 *   2. The resolved git root is a proper ancestor of basePath
 *   3. There is no *project* `.nerf` directory at the git root or any
 *      intermediate ancestor (the parent project has not been
 *      initialised with NERF)
 *
 * When true, the caller should run `git init` at basePath so that
 * `repoIdentity()` produces a hash unique to this directory, preventing
 * cross-project state leaks (#1639).
 *
 * When the git root already has a project `.nerf`, the directory is a
 * legitimate subdirectory of an existing NERF project — `cd src/ && /nerf`
 * should still load the parent project's milestones.
 */
export function isInheritedRepo(basePath) {
    try {
        const root = resolveGitRoot(basePath);
        const normalizedBase = canonicalizeExistingPath(basePath);
        const normalizedRoot = canonicalizeExistingPath(root);
        if (normalizedBase === normalizedRoot)
            return false; // basePath IS the root
        // The git root is a proper ancestor. Check whether it already has .nerf
        // (i.e. the parent project was initialised with NERF).
        if (isProjectNerf(join(root, ".nerf")) || isProjectNerf(join(root, ".nerf")))
            return false;
        // Walk up from basePath's parent to the git root checking for .nerf.
        // Start at dirname(normalizedBase), NOT normalizedBase itself — finding
        // .nerf at basePath means NERF state is set up for THIS project, which
        // says nothing about whether the git repo is inherited from an ancestor.
        let dir = dirname(normalizedBase);
        while (dir !== normalizedRoot && dir !== dirname(dir)) {
            if (isProjectNerf(join(dir, ".nerf")) || isProjectNerf(join(dir, ".nerf")))
                return false;
            dir = dirname(dir);
        }
        return true;
    }
    catch {
        return false;
    }
}
/**
 * Distinguish a *project* `.nerf` from the global `~/.nerf` state directory.
 *
 * A project `.nerf` is either:
 *   - A symlink to an external state directory (normal post-migration layout)
 *   - A legacy real directory that is NOT the global NERF home
 *
 * When the user's home directory is itself a git repo (e.g. dotfile managers),
 * `~/.nerf` exists but is the global state directory — not a project `.nerf`.
 * Treating it as a project `.nerf` would cause isInheritedRepo() to wrongly
 * conclude that subdirectories are part of the home "project" (#2393).
 */
function isProjectNerf(nerfPath) {
    if (!existsSync(nerfPath))
        return false;
    try {
        const stat = lstatSync(nerfPath);
        // Symlinks are always project .nerf (created by ensureNerfSymlink).
        if (stat.isSymbolicLink())
            return true;
        // For real directories, check that this isn't the global NERF home.
        // Recompute nerfHome dynamically so env overrides (NERF_HOME) are
        // picked up at call time, not just at module load time.
        if (stat.isDirectory()) {
            const currentNerfHome = process.env.NERF_HOME || join(homedir(), ".nerf");
            const normalizedNerfPath = canonicalizeExistingPath(nerfPath);
            const normalizedNerfHome = canonicalizeExistingPath(currentNerfHome);
            if (normalizedNerfPath === normalizedNerfHome)
                return false;
            return true;
        }
    }
    catch {
        // lstat failed — treat as no .nerf present
    }
    return false;
}
// ─── Repo Identity ──────────────────────────────────────────────────────────
/**
 * Get the git remote URL for "origin", or "" if no remote is configured.
 * Uses `git config` rather than `git remote get-url` for broader compat.
 */
function getRemoteUrl(basePath) {
    try {
        return execFileSync("git", ["config", "--get", "remote.origin.url"], {
            cwd: basePath,
            encoding: "utf-8",
            stdio: ["ignore", "pipe", "ignore"],
            timeout: 5_000,
        }).trim();
    }
    catch {
        return "";
    }
}
/**
 * Resolve the git toplevel (real root) for the given path.
 * For worktrees this returns the main repo root, not the worktree path.
 */
function canonicalizeExistingPath(path) {
    try {
        // Use native realpath on Windows to resolve 8.3 short paths (e.g. RUNNER~1)
        return process.platform === "win32" ? realpathSync.native(path) : realpathSync(path);
    }
    catch {
        return resolve(path);
    }
}
function resolveGitCommonDir(basePath) {
    try {
        return execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
            cwd: basePath,
            encoding: "utf-8",
            stdio: ["ignore", "pipe", "ignore"],
            timeout: 5_000,
        }).trim();
    }
    catch {
        const raw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
            cwd: basePath,
            encoding: "utf-8",
            stdio: ["ignore", "pipe", "ignore"],
            timeout: 5_000,
        }).trim();
        return resolve(basePath, raw);
    }
}
function resolveGitRoot(basePath) {
    try {
        const commonDir = resolveGitCommonDir(basePath);
        const normalizedCommonDir = commonDir.replaceAll("\\", "/");
        // Normal repo or worktree with shared common dir pointing at <repo>/.git.
        if (normalizedCommonDir.endsWith("/.git")) {
            return canonicalizeExistingPath(resolve(commonDir, ".."));
        }
        // Some git setups may still expose <repo>/.git/worktrees/<name>.
        const worktreeMarker = "/.git/worktrees/";
        if (normalizedCommonDir.includes(worktreeMarker)) {
            return canonicalizeExistingPath(resolve(commonDir, "..", ".."));
        }
        // Fallback for unusual layouts.
        return canonicalizeExistingPath(execFileSync("git", ["rev-parse", "--show-toplevel"], {
            cwd: basePath,
            encoding: "utf-8",
            stdio: ["ignore", "pipe", "ignore"],
            timeout: 5_000,
        }).trim());
    }
    catch {
        return resolve(basePath);
    }
}
/**
 * Validate a NERF_PROJECT_ID value.
 *
 * Must contain only alphanumeric characters, hyphens, and underscores.
 * Call this once at startup so the user gets immediate feedback on bad values.
 */
export function validateProjectId(id) {
    return /^[a-zA-Z0-9_-]+$/.test(id);
}
/**
 * Compute a stable identity for a repository.
 *
 * If `NERF_PROJECT_ID` is set, returns it directly (validation is expected
 * to have already happened at startup via `validateProjectId`).
 *
 * Otherwise returns SHA-256 of `${remoteUrl}\n${resolvedRoot}`, truncated
 * to 12 hex chars. Deterministic: same repo always produces the same hash
 * regardless of which worktree the caller is inside.
 */
export function repoIdentity(basePath) {
    const projectId = process.env.NERF_PROJECT_ID;
    if (projectId) {
        return projectId;
    }
    const remoteUrl = getRemoteUrl(basePath);
    const root = resolveGitRoot(basePath);
    const input = `${remoteUrl}\n${root}`;
    return createHash("sha256").update(input).digest("hex").slice(0, 12);
}
// ─── External State Directory ───────────────────────────────────────────────
/**
 * Compute the external NERF state directory for a repository.
 *
 * Returns `$NERF_STATE_DIR/projects/<hash>` if `NERF_STATE_DIR` is set,
 * otherwise `~/.nerf/projects/<hash>`.
 */
export function externalNerfRoot(basePath) {
    const base = process.env.NERF_STATE_DIR || nerfHome;
    return join(base, "projects", repoIdentity(basePath));
}
/**
 * Resolve the root directory that stores project-scoped external state.
 * Honors NERF_STATE_DIR override before falling back to NERF_HOME.
 */
export function externalProjectsRoot() {
    const base = process.env.NERF_STATE_DIR || nerfHome;
    return join(base, "projects");
}
// ─── Numbered Variant Cleanup ────────────────────────────────────────────────
/**
 * macOS collision pattern: `.nerf 2`, `.nerf 3`, `.nerf 4`, etc.
 * (Also cleans legacy `.nerf 2`, `.nerf 3`, etc.)
 *
 * When `symlinkSync` (or Finder) tries to create `.nerf` but a real directory
 * already exists at that path, macOS APFS silently renames the new entry to
 * `.nerf 2`, then `.nerf 3`, and so on. These numbered variants confuse the
 * system because the canonical `.nerf` path no longer resolves to the external
 * state directory, making tracked planning files appear deleted.
 *
 * This helper scans the project root for entries matching `.nerf <digits>` or
 * `.nerf <digits>` and removes them. It is called early in `ensureNerfSymlink()`
 * so that the canonical `.nerf` path is always the one in use.
 */
const NERF_NUMBERED_VARIANT_RE = /^\.(?:nerf) \d+$/;
export function cleanNumberedNerfVariants(projectPath) {
    const removed = [];
    try {
        const entries = readdirSync(projectPath);
        for (const entry of entries) {
            if (NERF_NUMBERED_VARIANT_RE.test(entry)) {
                const fullPath = join(projectPath, entry);
                try {
                    rmSync(fullPath, { recursive: true, force: true });
                    removed.push(entry);
                }
                catch {
                    // Best-effort: if removal fails (e.g. permissions), continue with next
                }
            }
        }
    }
    catch {
        // Non-fatal: readdir failure should not block symlink creation
    }
    return removed;
}
// ─── Symlink Management ─────────────────────────────────────────────────────
/**
 * Ensure the `<project>/.nerf` symlink points to the external state directory.
 *
 * 1. Clean up any macOS numbered collision variants (`.nerf 2`, `.nerf 3`, etc.)
 * 2. mkdir -p the external dir
 * 3. If `<project>/.nerf` doesn't exist → create symlink
 * 4. If `<project>/.nerf` is already the correct symlink → no-op
 * 5. If `<project>/.nerf` is a real directory → return as-is (migration handles later)
 *
 * Returns the resolved external path.
 */
export function ensureNerfSymlink(projectPath) {
    const externalPath = externalNerfRoot(projectPath);
    const localNerf = join(projectPath, ".nerf");
    const inWorktree = isInsideWorktree(projectPath);
    // Guard: Never create a symlink at ~/.nerf — that's the user-level NERF home,
    // not a project .nerf. This can happen if resolveProjectRoot() or
    // escapeStaleWorktree() returned ~ as the project root (#1676).
    const localNerfNormalized = localNerf.replaceAll("\\", "/");
    const nerfHomePath = nerfHome.replaceAll("\\", "/");
    if (localNerfNormalized === nerfHomePath) {
        return localNerf;
    }
    // Guard: If projectPath is a plain subdirectory (not a worktree) of a git
    // repo that already has a .nerf at the git root, do not create a duplicate
    // symlink in the subdirectory — that causes `.nerf 2` collision variants on
    // macOS (#2380). Worktrees are excluded because they legitimately need their
    // own .nerf symlink pointing at the shared external state dir.
    if (!inWorktree) {
        try {
            const gitRoot = resolveGitRoot(projectPath);
            const normalizedProject = canonicalizeExistingPath(projectPath);
            const normalizedRoot = canonicalizeExistingPath(gitRoot);
            if (normalizedProject !== normalizedRoot) {
                const rootNerf = join(gitRoot, ".nerf");
                if (existsSync(rootNerf)) {
                    try {
                        const rootStat = lstatSync(rootNerf);
                        if (rootStat.isSymbolicLink() || rootStat.isDirectory()) {
                            return rootStat.isSymbolicLink() ? realpathSync(rootNerf) : rootNerf;
                        }
                    }
                    catch {
                        // Fall through to normal logic if we can't stat root .nerf
                    }
                }
            }
        }
        catch {
            // If git root detection fails, fall through to normal logic
        }
    }
    // Clean up macOS numbered collision variants (.nerf 2, .nerf 3, etc.) before
    // any existence checks — otherwise they accumulate and confuse state (#2205).
    cleanNumberedNerfVariants(projectPath);
    // Ensure external directory exists
    mkdirSync(externalPath, { recursive: true });
    // Write repo metadata once so cleanup commands can identify this directory later.
    writeRepoMeta(externalPath, getRemoteUrl(projectPath), resolveGitRoot(projectPath));
    const replaceWithSymlink = () => {
        rmSync(localNerf, { recursive: true, force: true });
        symlinkSync(externalPath, localNerf, "junction");
        return externalPath;
    };
    // Use lstatSync (not existsSync) here: existsSync returns false for broken
    // symlinks, which causes symlinkSync to throw EEXIST and leaves the project
    // in a permanently broken state (#broken-symlink-eexist).
    const lstat = lstatSync(localNerf, { throwIfNoEntry: false });
    if (!lstat) {
        // Nothing exists yet — create symlink
        symlinkSync(externalPath, localNerf, "junction");
        return externalPath;
    }
    try {
        if (lstat.isSymbolicLink()) {
            // Already a symlink — verify it points to the right place.
            // If the target is gone (broken symlink), realpathSync throws; treat
            // that as a stale link and replace it unconditionally.
            let target;
            try {
                target = realpathSync(localNerf);
            }
            catch {
                // Broken symlink — replace with a fresh one pointing at externalPath
                return replaceWithSymlink();
            }
            if (target === externalPath) {
                return externalPath; // correct symlink, no-op
            }
            // In a worktree, mismatched symlinks are always stale. Heal them so
            // the worktree points at the same external state dir as the main repo.
            if (inWorktree) {
                return replaceWithSymlink();
            }
            // Outside worktrees, preserve custom overrides or legacy symlinks.
            return target;
        }
        if (lstat.isDirectory()) {
            // Real directory in the main repo — migration will handle this later.
            // In worktrees, keep the directory in place and let syncNerfStateToWorktree
            // refresh its contents. Replacing a git-tracked .nerf directory with a
            // symlink makes git think tracked planning files were deleted.
            return localNerf;
        }
    }
    catch {
        // Unexpected error inspecting lstat result
    }
    return localNerf;
}
// ─── Worktree Detection ─────────────────────────────────────────────────────
/**
 * Check if the given directory is a git worktree (not the main repo).
 *
 * Git worktrees have a `.git` *file* (not directory) containing a
 * `gitdir:` pointer. This is git's native worktree indicator — no
 * string marker parsing needed.
 */
export function isInsideWorktree(cwd) {
    const gitPath = join(cwd, ".git");
    try {
        const stat = lstatSync(gitPath);
        if (!stat.isFile())
            return false;
        const content = readFileSync(gitPath, "utf-8").trim();
        return content.startsWith("gitdir:");
    }
    catch {
        return false;
    }
}
