// cwd-to-absolute.ts — One-shot migration of legacy <cwd>/data/projects/{id}/
// trees into the new absolute home (~/.planu/data/projects/{id}/) introduced
// in v3.3.0 (SPEC-1007).
//
// Idempotent and safe to call before every read/write entry point. Uses three
// short-circuits in order:
//   1. In-memory Set<projectId> — same session, repeat calls return immediately.
//   2. Sentinel file `<absolute>/.migrated-from-cwd` — across sessions.
//   3. Walk-up of `cwd` ancestors (max 8) for `knowledge.json` anchor.
//
// On rename we attempt the atomic `fs.rename`. When the source and destination
// live on different filesystems (`EXDEV`), we fall back to `fs.cp` + verify +
// `rm -rf legacy`. Verify failures leave the legacy tree intact.
//
// Conflict semantics: if the absolute path already holds populated data and
// the legacy tree also has populated data, we never auto-merge — the legacy
// tree is renamed to `<legacy>.legacy-<ISO_TS>` and a warning is logged.
import { stat, mkdir, rename, cp, rm, writeFile, open } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname, isAbsolute } from 'node:path';
import { projectDataDir } from '../base-store.js';
const SENTINEL_FILE = '.migrated-from-cwd';
// LOCK_FILE is appended to absolute path as a sibling (e.g. `${absolute}.migration.lock`)
// to avoid creating `absolute` itself before the rename can run.
const LOCK_FILE = '.migration.lock';
const MAX_WALK_DEPTH = 8;
const ANCHOR_FILE = 'knowledge.json';
const LEGACY_SUBPATH = 'data/projects';
/**
 * Per-session set of projectIds already migrated. Cleared by tests via
 * `clearMigratedThisSession()` so each test gets a fresh slate.
 */
const migratedThisSession = new Set();
/** Test-only: reset the in-memory short-circuit set. */
export function clearMigratedThisSession() {
    migratedThisSession.clear();
}
async function pathExists(p) {
    try {
        await stat(p);
        return true;
    }
    catch {
        return false;
    }
}
async function fileExists(p) {
    try {
        const s = await stat(p);
        return s.isFile();
    }
    catch {
        return false;
    }
}
/**
 * Walk upward from `startDir` looking for `<dir>/data/projects/<id>/knowledge.json`.
 * Returns the matched legacy data dir (`<dir>/data/projects/<id>`), or null.
 */
async function walkUpForLegacy(startDir, projectId) {
    if (!isAbsolute(startDir)) {
        return null;
    }
    let current = startDir;
    for (let i = 0; i < MAX_WALK_DEPTH; i++) {
        const candidate = join(current, LEGACY_SUBPATH, projectId);
        if (await fileExists(join(candidate, ANCHOR_FILE))) {
            return candidate;
        }
        const parent = dirname(current);
        if (parent === current) {
            return null;
        }
        current = parent;
    }
    return null;
}
async function writeSentinel(absolute) {
    await mkdir(absolute, { recursive: true });
    await writeFile(join(absolute, SENTINEL_FILE), new Date().toISOString(), 'utf-8');
}
/**
 * Acquire an exclusive lock by opening LOCK_FILE with O_CREAT|O_EXCL. The lock
 * is a sibling file `<absolute>.migration.lock`, so we never create `<absolute>`
 * itself prematurely (rename(legacy, absolute) must operate on a non-existing
 * destination). Returns a release function. If the lock is already held, polls
 * every 100ms up to 5s.
 */
async function acquireLock(absolute) {
    const lockPath = `${absolute}${LOCK_FILE}`;
    await mkdir(dirname(absolute), { recursive: true });
    const deadline = Date.now() + 5000;
    while (Date.now() < deadline) {
        try {
            const fh = await open(lockPath, 'wx');
            await fh.close();
            return async () => {
                try {
                    await rm(lockPath, { force: true });
                }
                catch {
                    // Best-effort
                }
            };
        }
        catch (err) {
            if (err instanceof Error &&
                'code' in err &&
                err.code === 'EEXIST') {
                await new Promise((r) => setTimeout(r, 100));
                continue;
            }
            throw err;
        }
    }
    // Timed out — assume the other writer crashed; force-release and retry once.
    try {
        await rm(lockPath, { force: true });
    }
    catch {
        // ignore
    }
    const fh = await open(lockPath, 'wx');
    await fh.close();
    return async () => {
        try {
            await rm(lockPath, { force: true });
        }
        catch {
            // ignore
        }
    };
}
function isExdevError(err) {
    return err instanceof Error && 'code' in err && err.code === 'EXDEV';
}
/**
 * Move a legacy `<cwd>/data/projects/<id>/` tree into the absolute home for
 * `projectId`. Idempotent across calls and processes.
 *
 * Test isolation: when PLANU_PROJECT_DATA_DIR is assigned to a fresh tmp dir
 * (as in `tests/setup.ts`), this migration walks `cwd` from the test's
 * directory and finds nothing — returns `noop` immediately.
 */
export async function migrateLegacyDataDir(projectId, cwd = process.cwd()) {
    // Reject obviously invalid input — projectDataDir already validates, but we
    // want a typed result rather than a thrown error reaching callers.
    if (!projectId || projectId.includes('..') || projectId.includes('/')) {
        return {
            status: 'error',
            legacyPath: null,
            absolutePath: '',
            reason: `Invalid projectId: "${projectId}"`,
        };
    }
    const absolute = projectDataDir(projectId);
    // 1. In-memory short-circuit.
    if (migratedThisSession.has(projectId)) {
        return { status: 'noop', legacyPath: null, absolutePath: absolute };
    }
    // 2. Sentinel short-circuit (cross-session).
    if (existsSync(join(absolute, SENTINEL_FILE))) {
        migratedThisSession.add(projectId);
        return { status: 'noop', legacyPath: null, absolutePath: absolute };
    }
    // 3. Walk up from cwd looking for legacy.
    let legacyPath;
    try {
        legacyPath = await walkUpForLegacy(cwd, projectId);
    }
    catch (err) {
        return {
            status: 'error',
            legacyPath: null,
            absolutePath: absolute,
            reason: `Walk-up failed: ${err instanceof Error ? err.message : String(err)}`,
        };
    }
    // No legacy → first-time install. Mark with sentinel so we never re-walk.
    if (legacyPath === null) {
        try {
            await writeSentinel(absolute);
        }
        catch {
            // Best-effort: the absence of a sentinel just means we re-walk next call.
        }
        migratedThisSession.add(projectId);
        return { status: 'noop', legacyPath: null, absolutePath: absolute };
    }
    // 4. Acquire lock and re-check sentinel inside the critical section.
    let release;
    try {
        release = await acquireLock(absolute);
    }
    catch (err) {
        return {
            status: 'error',
            legacyPath,
            absolutePath: absolute,
            reason: `Lock acquisition failed: ${err instanceof Error ? err.message : String(err)}`,
        };
    }
    try {
        if (existsSync(join(absolute, SENTINEL_FILE))) {
            migratedThisSession.add(projectId);
            return { status: 'noop', legacyPath, absolutePath: absolute };
        }
        const absoluteHasContent = await fileExists(join(absolute, ANCHOR_FILE));
        if (absoluteHasContent) {
            return await runConflictBranch(projectId, legacyPath, absolute);
        }
        return await runHappyPath(projectId, legacyPath, absolute);
    }
    finally {
        await release();
    }
}
async function runConflictBranch(projectId, legacyPath, absolute) {
    const ts = new Date().toISOString().replace(/[:.]/g, '-');
    const renamed = `${legacyPath}.legacy-${ts}`;
    try {
        await rename(legacyPath, renamed);
    }
    catch (err) {
        return {
            status: 'error',
            legacyPath,
            absolutePath: absolute,
            reason: `Conflict rename failed: ${err instanceof Error ? err.message : String(err)}`,
        };
    }
    console.warn(`[Planu] Migration conflict: legacy ${legacyPath} renamed to ${renamed}; absolute path retained as canonical.`);
    await writeSentinel(absolute);
    migratedThisSession.add(projectId);
    return {
        status: 'conflict',
        legacyPath,
        absolutePath: absolute,
        reason: `Both legacy and absolute had populated content; legacy renamed to ${renamed}`,
    };
}
async function runHappyPath(projectId, legacyPath, absolute) {
    await mkdir(dirname(absolute), { recursive: true });
    try {
        await rename(legacyPath, absolute);
    }
    catch (err) {
        if (!isExdevError(err)) {
            return {
                status: 'error',
                legacyPath,
                absolutePath: absolute,
                reason: `Rename failed: ${err instanceof Error ? err.message : String(err)}`,
            };
        }
        await cp(legacyPath, absolute, { recursive: true, errorOnExist: false });
        if (!(await fileExists(join(absolute, ANCHOR_FILE)))) {
            return {
                status: 'error',
                legacyPath,
                absolutePath: absolute,
                reason: 'Cross-filesystem copy verification failed; legacy tree preserved',
            };
        }
        await rm(legacyPath, { recursive: true, force: true });
    }
    await writeSentinel(absolute);
    migratedThisSession.add(projectId);
    return { status: 'migrated', legacyPath, absolutePath: absolute };
}
// Re-export constants used by callers and tests.
export const MIGRATION_INTERNALS = Object.freeze({
    SENTINEL_FILE,
    LOCK_FILE,
    MAX_WALK_DEPTH,
    ANCHOR_FILE,
    LEGACY_SUBPATH,
});
// pathExists is an internal helper; export only when needed in tests.
export const __test = { pathExists };
//# sourceMappingURL=cwd-to-absolute.js.map