/**
 * seeds/validate.mjs — mutual-consistency audit between seeds.json and the
 * per-scope milestones.json files (C12, AC6).
 *
 * The `seed_id` field on a milestone and the milestone ref inside
 * `seeds.json.seeds[].milestones[]` must agree. Drift is possible when:
 *
 *   - a user hand-edits milestones.json and sets/clears seed_id without
 *     running `apt-tools seed assign/unassign`
 *   - a partial-write failure leaves one side stale (extremely rare given the
 *     atomic writers, but the audit makes it observable)
 *
 * validateSeeds is a PURE function: takes the seeds file and a map of
 * `{ scope → roadmap }` and returns a structured drift report. The CLI wires
 * I/O around it (see commands/seed.mjs `validate`).
 */
/**
 * @typedef {{ id: string, milestones?: Array<{scope: string, milestone_id: string}> }} SeedRec
 * @typedef {{ id: string, seed_id?: string | null, scope?: string }} MilestoneRec
 * @typedef {{ milestones?: MilestoneRec[] }} RoadmapRec
 */
/**
 * Inspect seeds ↔ milestones coherence.
 *
 * Drift categories:
 *   - orphan-seed-ref:   seeds.json points at (scope, milestone_id) that
 *                        does NOT exist in any loaded roadmap.
 *   - missing-seed-id:   seeds.json lists (scope, milestone_id), but the
 *                        milestone on disk has a null/missing seed_id.
 *   - wrong-seed-id:     milestone on disk has seed_id=X, but seeds.json
 *                        lists it under a different seed Y. (Or vice versa.)
 *   - dangling-seed-id:  milestone has seed_id=X but X is not listed in any
 *                        seed's milestones[] array (or X is not a known seed).
 *   - duplicate-membership: same (scope, milestone_id) appears in >1 seed
 *                        (seed membership is one-to-many — a milestone can
 *                        belong to AT MOST one seed).
 *
 * Returns:
 *   {
 *     ok: boolean,         // true when drift.length === 0
 *     drift: Array<{ kind, seed_id, scope, milestone_id, expected, actual }>
 *   }
 *
 * @param {{seeds?: SeedRec[]} | null} seedsFile
 * @param {Map<string, RoadmapRec | null> | Record<string, RoadmapRec | null>} roadmapsByScope
 */
export function validateSeeds(seedsFile, roadmapsByScope) {
    const drift = [];
    const seeds = (seedsFile && Array.isArray(seedsFile.seeds) ? seedsFile.seeds : []) || [];
    // Normalize roadmapsByScope to a Map for uniform access.
    const roadmaps = roadmapsByScope instanceof Map
        ? roadmapsByScope
        : new Map(Object.entries(roadmapsByScope || {}));
    // Build (scope, milestone_id) → seed_id(s) membership map from seeds.json.
    // Duplicate-membership drift is flagged when >1 seed references the same
    // milestone.
    const seedMembership = new Map(); // key = `${scope}\u0000${milestone_id}` → Set<seed_id>
    for (const seed of seeds) {
        for (const ref of seed.milestones || []) {
            if (!ref || !ref.scope || !ref.milestone_id)
                continue;
            const key = `${ref.scope}\u0000${ref.milestone_id}`;
            if (!seedMembership.has(key))
                seedMembership.set(key, new Set());
            seedMembership.get(key).add(seed.id);
        }
    }
    // Pass 1: seeds → milestones. Every (scope, milestone_id) in seeds.json
    // must resolve to a real milestone, and that milestone's seed_id must
    // agree with the seed that claims it.
    for (const seed of seeds) {
        for (const ref of seed.milestones || []) {
            if (!ref || !ref.scope || !ref.milestone_id)
                continue;
            const rm = roadmaps.get(ref.scope);
            const m = rm && Array.isArray(rm.milestones)
                ? rm.milestones.find((x) => x && x.id === ref.milestone_id)
                : null;
            if (!m) {
                drift.push({
                    kind: 'orphan-seed-ref',
                    seed_id: seed.id,
                    scope: ref.scope,
                    milestone_id: ref.milestone_id,
                    expected: `milestone "${ref.milestone_id}" in scope "${ref.scope}"`,
                    actual: 'milestone not found',
                });
                continue;
            }
            const onDisk = m.seed_id ?? null;
            if (onDisk == null) {
                drift.push({
                    kind: 'missing-seed-id',
                    seed_id: seed.id,
                    scope: ref.scope,
                    milestone_id: ref.milestone_id,
                    expected: seed.id,
                    actual: null,
                });
            }
            else if (onDisk !== seed.id) {
                drift.push({
                    kind: 'wrong-seed-id',
                    seed_id: seed.id,
                    scope: ref.scope,
                    milestone_id: ref.milestone_id,
                    expected: seed.id,
                    actual: onDisk,
                });
            }
        }
    }
    // Pass 2: milestones → seeds. Every milestone with a seed_id must be
    // claimed by a seed (and exactly one seed).
    const seedsById = new Map(seeds.map((s) => [s.id, s]));
    for (const [scope, rm] of roadmaps.entries()) {
        if (!rm || !Array.isArray(rm.milestones))
            continue;
        for (const m of rm.milestones) {
            if (!m || !m.id)
                continue;
            const sid = m.seed_id ?? null;
            if (sid == null)
                continue;
            const claimingSeed = seedsById.get(sid);
            const key = `${scope}\u0000${m.id}`;
            const membership = seedMembership.get(key);
            if (!claimingSeed) {
                drift.push({
                    kind: 'dangling-seed-id',
                    seed_id: sid,
                    scope,
                    milestone_id: m.id,
                    expected: 'seed to exist',
                    actual: `unknown seed "${sid}"`,
                });
                continue;
            }
            if (!membership || !membership.has(sid)) {
                drift.push({
                    kind: 'dangling-seed-id',
                    seed_id: sid,
                    scope,
                    milestone_id: m.id,
                    expected: `${sid} to list this milestone in its milestones[]`,
                    actual: 'not listed',
                });
            }
        }
    }
    // Pass 3: duplicate-membership — any key with >1 seed id is a violation.
    for (const [key, set] of seedMembership.entries()) {
        if (set.size > 1) {
            const [scope, milestoneId] = key.split('\u0000');
            drift.push({
                kind: 'duplicate-membership',
                seed_id: Array.from(set).sort().join(','),
                scope,
                milestone_id: milestoneId,
                expected: '1 seed',
                actual: `${set.size} seeds: ${Array.from(set).sort().join(', ')}`,
            });
        }
    }
    // Stable output order for deterministic test assertions.
    drift.sort((a, b) => {
        if (a.kind !== b.kind)
            return a.kind.localeCompare(b.kind);
        if (a.seed_id !== b.seed_id)
            return String(a.seed_id).localeCompare(String(b.seed_id));
        if (a.scope !== b.scope)
            return String(a.scope).localeCompare(String(b.scope));
        return String(a.milestone_id).localeCompare(String(b.milestone_id));
    });
    return { ok: drift.length === 0, drift };
}
//# sourceMappingURL=validate.mjs.map