/**
 * phases-io.mjs — per-phase roadmap JSON layout (R5).
 *
 * Legacy roadmap state lives in `.aperant/roadmap/{scope}/milestones.json` as
 * a single blob containing every milestone + every phase. Concurrent edits by
 * sibling worktrees conflict on a single file — git's 3-way merge can't
 * separate independent phase updates when they live in the same JSON array.
 *
 * R5 splits the state across one file per phase:
 *
 *   .aperant/roadmap/{scope}/phases/{phase-id}.json
 *
 * Each phase file carries its milestone_id + milestone_title so the reader can
 * reconstruct the full `{milestones: [...]}` shape without cross-referencing.
 * Two worktrees editing different phases now touch different files — git's
 * default 3-way merge resolves cleanly. Same-phase edits still conflict, but
 * that's a real conflict worth surfacing.
 *
 * Wave 2 (subtask 10): dual-write — `saveRoadmapForScope` writes BOTH the legacy
 * blob AND the per-phase files; `loadRoadmapForScope` prefers phases/ when any
 * file exists, else falls back to legacy (and triggers a one-way migration).
 * Wave 3 (subtask 11): reads become phases/-only once phases/ is populated.
 *
 * The First Principles Thinker's R5 dissent is preserved on purpose: splitting
 * state across N files loses the single-file invariant that "the whole roadmap
 * either parses or doesn't." A future schema validator closes that gap.
 */
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { atomicWriteJson } from '../util/atomic-write.mjs';
import { withFileLock } from '../util/fs-lock.mjs';
/**
 * Absolute path to the `phases/` directory for a scope.
 *
 *   phasesDir(root, 'framework') → `${root}/.aperant/roadmap/framework/phases`
 */
export function phasesDir(targetDir, scope) {
    return join(targetDir, '.aperant', 'roadmap', scope, 'phases');
}
/**
 * Absolute path to an individual phase file.
 */
export function phaseFilePath(targetDir, scope, phaseId) {
    return join(phasesDir(targetDir, scope), `${phaseId}.json`);
}
/**
 * Atomic-write a single phase file. Stamps milestone_id/milestone_title on
 * the phase so readers can reconstruct the milestone list without needing the
 * legacy blob.
 *
 * Under withFileLock so concurrent saveRoadmapForScope calls from two agents
 * can't interleave writes to the same phase file. projectRoot scopes the lock
 * directory so tests (and sibling worktrees) share a lock namespace with the
 * roadmap they're mutating.
 */
export function savePhaseFile(targetDir, scope, milestone, phase) {
    const dir = phasesDir(targetDir, scope);
    mkdirSync(dir, { recursive: true });
    const filePath = phaseFilePath(targetDir, scope, phase.id);
    // Flatten milestone identity onto the phase record. Keep every original
    // phase key (owner, status, description, blocked_by, deliverables,
    // completed_at, scope, etc.) so the reader round-trips losslessly.
    //
    // C12: intentionally DO NOT stamp milestone.seed_id onto the phase record.
    // seed_id is a milestone-level field owned by milestones.json (the legacy
    // blob). Copying it here would make hand-edits to milestones.json
    // invisible to the phases/-preferred reader — a drift check like "the
    // user deleted seed_id from milestones.json" would then fire 0 findings.
    // loadRoadmapForScope overlays seed_id from the legacy blob on top of the
    // phases/-reconstructed milestones (io.mjs), so a clean assign still
    // round-trips while a hand-edit still surfaces as drift.
    const record = {
        scope,
        milestone_id: milestone.id,
        milestone_title: milestone.title,
        milestone_goal: milestone.goal || '',
        ...phase,
    };
    withFileLock(filePath, () => {
        atomicWriteJson(filePath, record);
    }, { projectRoot: targetDir });
}
/**
 * Unlink a single phase file by id (C34c). Silent + idempotent: missing files,
 * permission quirks, and races where a sibling worktree already removed the
 * file must never crash the caller. The on-disk phases/ layout is the primary
 * source of truth for `loadPhasesAsRoadmap`, so stale files after a
 * delete-phase / status=deleted / delete-milestone flow would let the roadmap
 * resurrect deleted content on the next read. This helper closes that gap.
 *
 * Returns `true` when the file existed and was removed, `false` otherwise.
 */
export function deletePhaseFile(targetDir, scope, phaseId) {
    if (!phaseId)
        return false;
    const filePath = phaseFilePath(targetDir, scope, phaseId);
    if (!existsSync(filePath))
        return false;
    try {
        rmSync(filePath, { force: true });
        return true;
    }
    catch {
        return false;
    }
}
/**
 * True when `phases/` exists and contains at least one `.json` phase record.
 * Used as the read-preference gate: once phases/ is populated, reads switch
 * to the per-phase layout.
 */
export function hasPhaseFiles(targetDir, scope) {
    const dir = phasesDir(targetDir, scope);
    if (!existsSync(dir))
        return false;
    try {
        for (const name of readdirSync(dir)) {
            if (name.endsWith('.json'))
                return true;
        }
    }
    catch {
        /* dir removed between exists + readdir */
    }
    return false;
}
/**
 * Reconstruct the legacy `{milestones: [...]}` shape from the per-phase files.
 *
 * Phases are grouped by `milestone_id`. Milestone order follows first-seen
 * order in the sorted phase-file listing — we sort phase filenames so the
 * output is deterministic for a given directory state. Milestone title/goal
 * come from whichever phase was seen first for that milestone; callers that
 * want authoritative milestone metadata should still rely on saveRoadmapForScope
 * keeping the legacy blob in sync during Wave 2.
 *
 * Returns `null` when no phases/ directory exists, or an empty
 * `{milestones:[]}` shape when the directory exists but has no phase files.
 */
export function loadPhasesAsRoadmap(targetDir, scope) {
    const dir = phasesDir(targetDir, scope);
    if (!existsSync(dir))
        return null;
    const files = readdirSync(dir)
        .filter((f) => f.endsWith('.json'))
        .sort();
    const milestoneMap = new Map();
    const milestoneOrder = [];
    for (const f of files) {
        let rec;
        try {
            rec = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
        }
        catch {
            // Skip malformed files rather than poisoning the whole load. A future
            // validator can surface corruption explicitly.
            continue;
        }
        const msId = rec.milestone_id;
        if (!msId)
            continue;
        if (!milestoneMap.has(msId)) {
            const milestoneRec = {
                id: msId,
                title: rec.milestone_title || msId,
                goal: rec.milestone_goal || '',
                scope,
                phases: [],
            };
            milestoneMap.set(msId, milestoneRec);
            milestoneOrder.push(msId);
        }
        // Strip the milestone-identity fields before handing the phase back to
        // callers — they belong to the parent milestone now, not the phase.
        const { scope: _s, milestone_id: _mid, milestone_title: _mt, milestone_goal: _mg, ...phase } = rec;
        milestoneMap.get(msId).phases.push(phase);
    }
    return {
        version: 1,
        scope,
        milestones: milestoneOrder.map((id) => milestoneMap.get(id)),
    };
}
/**
 * One-way migration: read legacy `milestones.json`, write each phase to
 * `phases/{phase-id}.json`. Never deletes the legacy file — Wave 2 keeps it
 * alive as a rollback lane. Idempotent: if `phases/` already has files for the
 * phases we'd write, re-running is a no-op (savePhaseFile overwrites with the
 * same bytes modulo timestamps, which is acceptable during the transition).
 *
 * Returns the number of phase files written, or 0 when the legacy file is
 * absent / empty / malformed.
 */
export function migrateRoadmapToPhasesDir(targetDir, scope) {
    const legacyPath = join(targetDir, '.aperant', 'roadmap', scope, 'milestones.json');
    if (!existsSync(legacyPath))
        return 0;
    let legacy;
    try {
        legacy = JSON.parse(readFileSync(legacyPath, 'utf-8'));
    }
    catch {
        return 0;
    }
    if (!legacy || !Array.isArray(legacy.milestones))
        return 0;
    let written = 0;
    for (const milestone of legacy.milestones) {
        if (!milestone || !Array.isArray(milestone.phases))
            continue;
        for (const phase of milestone.phases) {
            if (!phase || !phase.id)
                continue;
            savePhaseFile(targetDir, scope, milestone, phase);
            written++;
        }
    }
    return written;
}
//# sourceMappingURL=phases-io.mjs.map