/**
 * io.mjs — roadmap read/write/lookup helpers (C29, R5).
 *
 * R5 (subtask 10): dual-write transition.
 *   - saveRoadmapForScope writes BOTH the legacy milestones.json AND per-phase
 *     files under .aperant/roadmap/{scope}/phases/{phase-id}.json. This keeps
 *     rollback possible until Wave 3 removes the legacy write.
 *   - loadRoadmapForScope prefers phases/ when any phase file exists. If only
 *     the legacy blob is present, parse it AND trigger a one-way migration
 *     (savePhaseFile for every phase, legacy file preserved on disk).
 *
 * Subtask 11 tightens the read to NEVER fall back once phases/ exists, closing
 * the window where a stale milestones.json could mask a fresh phase update.
 */

import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { withFileLock } from '../util/fs-lock.mjs'
import { listRoadmapScopes, roadmapScopePaths } from './paths.mjs'
import {
	hasPhaseFiles,
	loadPhasesAsRoadmap,
	migrateRoadmapToPhasesDir,
	savePhaseFile,
} from './phases-io.mjs'
import { renderRoadmapMd } from './render.mjs'

/**
 * Load the roadmap for a scope.
 *
 * Read preference:
 *   1. If phases/ has at least one JSON file, reconstruct from there and
 *      return. Never consult the legacy blob in this case (subtask 11
 *      hardens this, subtask 10 already prefers phases/ when available).
 *   2. Else if legacy milestones.json exists, parse it, auto-migrate each
 *      phase into phases/ so the next reader hits the fast path, then return
 *      the parsed legacy blob.
 *   3. Else return null.
 */
export function loadRoadmapForScope(targetDir, scope) {
	if (hasPhaseFiles(targetDir, scope)) {
		const reconstructed = loadPhasesAsRoadmap(targetDir, scope)
		// C12 overlay: milestone-level fields (today: seed_id) live only in
		// the legacy milestones.json blob, not in phases/{id}.json. During
		// Wave 2 dual-write, the legacy blob is kept in lockstep with phases/,
		// so we overlay those fields on top of the phases/-reconstructed
		// output. This preserves two invariants simultaneously:
		//   1. A clean `seed assign` round-trips: seeds.json ↔ reconstructed
		//      roadmap agree (seed_id present on both sides).
		//   2. A hand-edit that strips seed_id from milestones.json shows up
		//      as drift — the overlay simply finds no value to overlay, so
		//      validate sees the milestone without seed_id and flags
		//      `missing-seed-id`.
		// After Wave 3 removes the legacy blob, seed_id will need to live on
		// either phases/ records or a per-milestone sidecar; this overlay is
		// a transition helper, not a permanent fixture.
		if (reconstructed) overlayLegacyMilestoneFields(targetDir, scope, reconstructed)
		return reconstructed
	}
	const { jsonPath } = roadmapScopePaths(targetDir, scope)
	if (!existsSync(jsonPath)) return null
	const legacy = JSON.parse(readFileSync(jsonPath, 'utf-8'))
	// Fire-and-forget migration. Non-destructive: leaves milestones.json on
	// disk. Best-effort — if the migration throws (permissions, disk full),
	// we still return the parsed legacy blob so the caller isn't blocked.
	try {
		migrateRoadmapToPhasesDir(targetDir, scope)
	} catch {
		/* migration retries next load */
	}
	return legacy
}

/**
 * Merge milestone-level fields from the legacy milestones.json blob onto a
 * phases/-reconstructed roadmap. Today only `seed_id` participates — the
 * phases/ layout doesn't own milestone metadata, so we copy from the legacy
 * blob when it exists. Missing blob ⇒ no overlay, no error.
 *
 * Also surfaces empty-phases milestones. The phases/-reconstruction drops
 * milestones that own zero phase files, because the reconstruction is built
 * by grouping phase files by `milestone_id`. Freshly-added milestones (via
 * `roadmap add-milestone`) start life with `phases: []`, so they exist in
 * the legacy blob but never in the phases/ layout until the first phase is
 * added. Without this augmentation, `findMilestoneScopes` (and any caller
 * of `loadRoadmapForScope`) can't see a just-added milestone, and
 * `add-phase --milestone <new-id>` fails with "not found in scope …".
 * The legacy blob stays authoritative for the milestone *set*; the
 * phases/ layout stays authoritative for phase *data*.
 */
function overlayLegacyMilestoneFields(targetDir, scope, reconstructed) {
	const { jsonPath } = roadmapScopePaths(targetDir, scope)
	if (!existsSync(jsonPath)) return
	let legacy
	try {
		legacy = JSON.parse(readFileSync(jsonPath, 'utf-8'))
	} catch {
		return
	}
	if (!legacy || !Array.isArray(legacy.milestones)) return
	const legacyById = new Map(legacy.milestones.map((m) => [m.id, m]))
	const reconstructedIds = new Set(
		(reconstructed.milestones || []).map((m) => m && m.id).filter(Boolean),
	)
	for (const ms of reconstructed.milestones || []) {
		const legacyMs = legacyById.get(ms.id)
		if (!legacyMs) continue
		if (Object.hasOwn(legacyMs, 'seed_id')) {
			ms.seed_id = legacyMs.seed_id
		}
		// C33 — milestone-level `status` is a derived rollup value that also
		// lives only in the legacy blob (phases/ per-file records describe
		// phases, not milestones). Overlay so the phases/-preferred read path
		// returns the cached rollup, and writers see the current value for
		// change-detection.
		if (Object.hasOwn(legacyMs, 'status')) {
			ms.status = legacyMs.status
		}
	}
	// Append empty-phases milestones that the phases/ reconstruction missed.
	// We only append when the legacy record itself has zero phases — a legacy
	// milestone with phases that are absent from phases/ is data drift the
	// operator should resolve, not a new blank milestone we should resurrect.
	if (!reconstructed.milestones) reconstructed.milestones = []
	for (const legacyMs of legacy.milestones) {
		if (!legacyMs || !legacyMs.id) continue
		if (reconstructedIds.has(legacyMs.id)) continue
		const legacyPhases = Array.isArray(legacyMs.phases) ? legacyMs.phases : []
		if (legacyPhases.length > 0) continue
		reconstructed.milestones.push({
			id: legacyMs.id,
			title: legacyMs.title || legacyMs.id,
			goal: legacyMs.goal || '',
			scope: legacyMs.scope || scope,
			phases: [],
			...(Object.hasOwn(legacyMs, 'seed_id') ? { seed_id: legacyMs.seed_id } : {}),
			...(Object.hasOwn(legacyMs, 'status') ? { status: legacyMs.status } : {}),
		})
	}
}

/**
 * Save the roadmap for a scope (dual-write).
 *
 *   - Writes legacy milestones.json + ROADMAP.md atomically under a single
 *     file lock (same behavior as before R5).
 *   - Iterates milestones + phases and calls savePhaseFile for each, so the
 *     per-phase layout stays in sync with the blob.
 *
 * The two writes are NOT atomic with each other (they hold separate locks),
 * but both use atomic tmp+rename so neither can produce a torn file. During
 * Wave 2 a partial failure is tolerable: the next load will prefer whichever
 * layout completed (phases/ wins if any file landed), and saveRoadmapForScope
 * will re-sync both on the next save.
 */
export function saveRoadmapForScope(targetDir, scope, roadmap) {
	const { scopeDir, jsonPath, mdPath } = roadmapScopePaths(targetDir, scope)
	roadmap.scope = scope
	roadmap.updated_at = new Date().toISOString()
	mkdirSync(scopeDir, { recursive: true })
	withFileLock(
		jsonPath,
		() => {
			writeFileSync(jsonPath, JSON.stringify(roadmap, null, 2) + '\n', 'utf-8')
			writeFileSync(mdPath, renderRoadmapMd(roadmap), 'utf-8')
		},
		{ projectRoot: targetDir },
	)
	// Per-phase dual-write. Each savePhaseFile takes its own file lock, so
	// concurrent callers never corrupt a phase record.
	for (const milestone of roadmap.milestones || []) {
		if (!milestone || !Array.isArray(milestone.phases)) continue
		for (const phase of milestone.phases) {
			if (!phase || !phase.id) continue
			savePhaseFile(targetDir, scope, milestone, phase)
		}
	}
}

export function findMilestoneScopeGlobal(targetDir, milestoneId) {
	const hits = []
	for (const scope of listRoadmapScopes(targetDir)) {
		const rm = loadRoadmapForScope(targetDir, scope)
		if (rm && (rm.milestones || []).some((m) => m.id === milestoneId)) hits.push(scope)
	}
	if (hits.length === 0) return null
	if (hits.length > 1) return null // ambiguous — drain treats as no-op
	return hits[0]
}
