import { mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile, } from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import { randomUUID } from "node:crypto";
import { config } from "./config.js";
import { clearProjectOverrides as clearProjectSkillOverrides } from "./skill-overrides.js";
import { clearProjectPromptOverrides } from "./prompt-overrides.js";
import { clearProjectSystemPromptAddendum } from "./system-prompt-overrides.js";
import { clearProjectStdioTrust } from "./mcp/stdio-trust.js";
/**
 * Project ids are always `randomUUID()` output. Mirrors the same
 * regex used in session-registry's `sessionDirFor()` validator.
 * Codified here too because the cascade rm path builds a
 * filesystem destination from the id and should reject anything
 * that could escape `${SESSION_DIR}`.
 */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export class PathOutsideWorkspaceError extends Error {
    constructor(path) {
        super(`path outside workspace: ${path}`);
        this.name = "PathOutsideWorkspaceError";
    }
}
export class NotADirectoryError extends Error {
    constructor(path) {
        super(`not a directory: ${path}`);
        this.name = "NotADirectoryError";
    }
}
export class ProjectNotFoundError extends Error {
    constructor(id) {
        super(`project not found: ${id}`);
        this.name = "ProjectNotFoundError";
    }
}
export class InvalidNameError extends Error {
    constructor(message = "invalid name") {
        super(message);
        this.name = "InvalidNameError";
    }
}
export class InvalidDirectoryNameError extends Error {
    constructor(message = "invalid directory name") {
        super(message);
        this.name = "InvalidDirectoryNameError";
    }
}
export class DuplicatePathError extends Error {
    constructor(path) {
        super(`a project already points at: ${path}`);
        this.name = "DuplicatePathError";
    }
}
export class InvalidProjectOrderError extends Error {
    constructor(message = "invalid project order") {
        super(message);
        this.name = "InvalidProjectOrderError";
    }
}
const PROJECTS_FILE = () => join(config.forgeDataDir, "projects.json");
/** True iff `target` is the same path as `root` or strictly inside it. */
export function isInsideWorkspace(target, root = config.workspacePath) {
    const resolvedTarget = resolve(target);
    const resolvedRoot = resolve(root);
    if (resolvedTarget === resolvedRoot)
        return true;
    const rel = relative(resolvedRoot, resolvedTarget);
    return rel.length > 0 && !rel.startsWith("..") && !rel.startsWith(`..${sep}`);
}
async function ensureConfigDir() {
    await mkdir(config.forgeDataDir, { recursive: true });
}
/**
 * Run `fn` over each item with at most `limit` in flight at once. Order of
 * results matches the input order, including `undefined` inputs (the fn is
 * still invoked — the helper does not silently skip holes).
 *
 * Errors propagate via `Promise.all`: the first rejecting worker fails the
 * whole call. Wrap `fn` in your own try/catch if you need partial results.
 */
async function mapBounded(items, limit, fn) {
    const results = new Array(items.length);
    let next = 0;
    const worker = async () => {
        while (true) {
            const i = next++;
            if (i >= items.length)
                return;
            results[i] = await fn(items[i], i);
        }
    };
    const workers = [];
    for (let w = 0; w < Math.min(limit, items.length); w++)
        workers.push(worker());
    await Promise.all(workers);
    return results;
}
/**
 * Serialise all read-modify-write sequences over projects.json. Without this,
 * two concurrent POST /projects requests can read the same baseline and race
 * the rename(), losing one write. Single-process / single-tenant only — there
 * is no file lock; we don't need cross-process safety.
 */
let projectsLock = Promise.resolve();
function withProjectsLock(fn) {
    const next = projectsLock.then(fn, fn);
    // Keep the chain alive but don't propagate failures into subsequent waiters.
    projectsLock = next.catch(() => undefined);
    return next;
}
export async function readProjects() {
    await ensureConfigDir();
    try {
        const raw = await readFile(PROJECTS_FILE(), "utf8");
        const parsed = JSON.parse(raw);
        if (!Array.isArray(parsed))
            return [];
        return parsed.filter(isProject);
    }
    catch (err) {
        if (err.code === "ENOENT")
            return [];
        throw err;
    }
}
function isProject(v) {
    if (typeof v !== "object" || v === null)
        return false;
    const r = v;
    return (typeof r.id === "string" &&
        typeof r.name === "string" &&
        typeof r.path === "string" &&
        typeof r.createdAt === "string");
}
async function writeProjects(projects) {
    await ensureConfigDir();
    const target = PROJECTS_FILE();
    const tmp = `${target}.${randomUUID()}.tmp`;
    await writeFile(tmp, JSON.stringify(projects, null, 2), "utf8");
    try {
        await rename(tmp, target);
    }
    catch (err) {
        // See atomicWriteJson in config-manager.ts: same tmp-file leak
        // cleanup. Without this, a rename failure (cross-fs, perms,
        // target locked on Windows) leaves the .tmp orphan.
        await unlink(tmp).catch(() => undefined);
        throw err;
    }
}
export async function createProject(name, path) {
    const trimmedName = name.trim();
    if (trimmedName.length === 0) {
        throw new InvalidNameError("project name cannot be empty");
    }
    const lexicalPath = resolve(path);
    // Realpath both sides before the inside-workspace check. The lexical
    // check alone accepts a symlink under WORKSPACE_PATH that points
    // OUTSIDE the realpath bound — e.g. `~/.huiyu-pi/workspace/external
    // -> /etc` — and registers `/external` (the symlink target) as a
    // legitimate project root. Subsequent file-manager ops would then
    // realpath-bound to the symlink target, NOT to WORKSPACE_PATH.
    // Catch here so a missing target throws NotADirectoryError below
    // rather than the more confusing realpath ENOENT.
    const realPath = await realpath(lexicalPath).catch(() => lexicalPath);
    const realWorkspaceRoot = await realpath(config.workspacePath).catch(() => config.workspacePath);
    if (!isInsideWorkspace(realPath, realWorkspaceRoot)) {
        throw new PathOutsideWorkspaceError(realPath);
    }
    // Persist the canonical (real) path so future operations are
    // consistent and `isInsideWorkspace` in file-manager and elsewhere
    // sees the same shape.
    const resolvedPath = realPath;
    const st = await stat(resolvedPath).catch(() => undefined);
    if (!st?.isDirectory()) {
        throw new NotADirectoryError(resolvedPath);
    }
    return withProjectsLock(async () => {
        const projects = await readProjects();
        if (projects.some((p) => p.path === resolvedPath)) {
            throw new DuplicatePathError(resolvedPath);
        }
        const project = {
            id: randomUUID(),
            name: trimmedName,
            path: resolvedPath,
            createdAt: new Date().toISOString(),
        };
        projects.push(project);
        await writeProjects(projects);
        return project;
    });
}
export async function renameProject(id, name) {
    const trimmed = name.trim();
    if (trimmed.length === 0) {
        throw new InvalidNameError("project name cannot be empty");
    }
    return withProjectsLock(async () => {
        const projects = await readProjects();
        const idx = projects.findIndex((p) => p.id === id);
        if (idx === -1)
            throw new ProjectNotFoundError(id);
        const existing = projects[idx];
        if (existing === undefined)
            throw new ProjectNotFoundError(id);
        const updated = { ...existing, name: trimmed };
        projects[idx] = updated;
        await writeProjects(projects);
        return updated;
    });
}
export async function reorderProjects(ids) {
    return withProjectsLock(async () => {
        const projects = await readProjects();
        if (ids.length !== projects.length) {
            throw new InvalidProjectOrderError("project order must include every project exactly once");
        }
        const byId = new Map(projects.map((p) => [p.id, p]));
        const seen = new Set();
        const next = [];
        for (const id of ids) {
            if (seen.has(id)) {
                throw new InvalidProjectOrderError("project order contains duplicate ids");
            }
            seen.add(id);
            const project = byId.get(id);
            if (project === undefined) {
                throw new InvalidProjectOrderError("project order contains unknown ids");
            }
            next.push(project);
        }
        await writeProjects(next);
        return next;
    });
}
const noopWarn = () => undefined;
export async function deleteProject(id, opts = {}) {
    const warn = opts.logWarn ?? noopWarn;
    let cascaded = false;
    await withProjectsLock(async () => {
        const projects = await readProjects();
        const next = projects.filter((p) => p.id !== id);
        if (next.length === projects.length)
            throw new ProjectNotFoundError(id);
        await writeProjects(next);
    });
    // Drop any per-project skill overrides for the deleted project.
    // Best-effort — a failure here doesn't undo the deletion (project
    // is already gone). The orphan would just be cosmetic in the UI's
    // cascade view; it's harmless to read but pointless to keep.
    await clearProjectSkillOverrides(id).catch((err) => {
        warn({ err, id }, "skill-overrides cleanup failed");
    });
    await clearProjectPromptOverrides(id).catch((err) => {
        warn({ err, id }, "prompt-overrides cleanup failed");
    });
    await clearProjectSystemPromptAddendum(id).catch((err) => {
        warn({ err, id }, "system-prompt-overrides cleanup failed");
    });
    await clearProjectStdioTrust(id).catch((err) => {
        warn({ err, id }, "mcp-stdio-trust cleanup failed");
    });
    // Always wipe the project's session directory in full — JSONLs
    // and the dir itself. Earlier versions made this opt-in via
    // `cascadeSessionDir: true` and a UI checkbox, but the default-off
    // behavior left a `<projectId>/` directory on disk that the UI had
    // no way to reach ever again. Even when individual sessions were
    // deleted before the project, the empty parent dir survived
    // (deleteColdSession only unlinks the JSONL, not the parent).
    //
    // v1.3.0 makes "project delete means gone" the contract: project
    // record + all session metadata + the parent dir are removed in
    // one shot. The user-facing confirmation about deleting N session
    // files happens at the UI layer (a required checkbox in the
    // delete dialog when sessions are present) — but the server-side
    // behavior is unconditional. Programmatic clients calling DELETE
    // /api/v1/projects/:id get the same atomic delete.
    //
    // The project's workspace folder (`${WORKSPACE_PATH}/<projectName>/`)
    // is still left alone — that's almost always real work the user
    // wants to keep.
    //
    // SAFETY: validate the id is UUID-shaped (the only shape
    // `createProject()` ever produces) BEFORE building the path.
    // `rm({ recursive: true, force: true })` is destructive enough
    // that any path-traversal in `id` would be catastrophic — a
    // hypothetical `id === ".."` would resolve to the parent of
    // `${SESSION_DIR}` and wipe it. Today the only id source is
    // `randomUUID()`, but the validator codifies that invariant
    // against any future code path that imports/restores ids from
    // the wire or a manually-edited projects.json.
    if (!UUID_RE.test(id)) {
        // Should be unreachable — the project record we just deleted
        // had this id, so it passed creation-time validation. Log and
        // skip the cleanup rather than rm something dangerous.
        warn({ id }, "refusing cascade rm for non-UUID id");
        return { cascaded };
    }
    const dir = join(config.sessionDir, id);
    try {
        await rm(dir, { recursive: true, force: true });
        cascaded = true;
    }
    catch (err) {
        // Don't fail the delete itself if cleanup fails — the project
        // record is gone, the session files are just orphaned. But DO
        // log so a permissions issue isn't silently invisible.
        warn({ err, dir }, "session-dir rm failed");
    }
    return { cascaded };
}
export async function getProject(id) {
    const projects = await readProjects();
    return projects.find((p) => p.id === id);
}
export async function browseDirectory(requested) {
    const target = resolve(requested ?? config.workspacePath);
    if (!isInsideWorkspace(target)) {
        throw new PathOutsideWorkspaceError(target);
    }
    const st = await stat(target).catch(() => undefined);
    if (!st?.isDirectory()) {
        throw new NotADirectoryError(target);
    }
    const dirents = await readdir(target, { withFileTypes: true });
    const dirEntries = dirents.filter((d) => d.isDirectory() && !d.name.startsWith("."));
    // Stat .git children with a bounded concurrency cap — unbounded Promise.all
    // could exhaust the libuv FD pool on a node_modules-shaped tree (closes the
    // Phase-10 deferred item). 16 concurrent stats is plenty for any realistic
    // workspace and well below the default ulimit on macOS/Linux.
    const entries = await mapBounded(dirEntries, 16, async (ent) => {
        const childPath = join(target, ent.name);
        const gitStat = await stat(join(childPath, ".git")).catch(() => undefined);
        return { name: ent.name, path: childPath, isGitRepo: gitStat !== undefined };
    });
    entries.sort((a, b) => a.name.localeCompare(b.name));
    const resolvedRoot = resolve(config.workspacePath);
    const parentPath = target === resolvedRoot ? undefined : dirname(target);
    return { path: target, parentPath, entries };
}
export async function createDirectory(parentPath, name) {
    const trimmed = name.trim();
    if (trimmed.length === 0 || trimmed.includes("/") || trimmed.includes("\\") || trimmed === "..") {
        throw new InvalidDirectoryNameError();
    }
    const parent = resolve(parentPath);
    if (!isInsideWorkspace(parent)) {
        throw new PathOutsideWorkspaceError(parent);
    }
    const target = join(parent, trimmed);
    if (!isInsideWorkspace(target)) {
        throw new PathOutsideWorkspaceError(target);
    }
    await mkdir(target, { recursive: false });
    return target;
}
//# sourceMappingURL=project-manager.js.map