import {
	existsSync,
	readdirSync,
	readFileSync,
	renameSync,
	rmSync,
	statSync,
} from "node:fs";
import { join } from "node:path";
import { getErrorMessage } from "./error-utils.js";
import { migrateHierarchyToDb } from "./md-importer.js";
import { parseRoadmap } from "./parsers.js";
import { milestonesDir, resolveMilestoneFile } from "./paths.js";
import {
	_getAdapter,
	getAllMilestones,
	getMilestoneSlices,
	isDbAvailable,
	openDatabase,
} from "./sf-db.js";
import { deriveState } from "./state.js";
import {
	summarizeParityHealth,
	writeParityReport,
} from "./uok/parity-report.js";
import { readEvents } from "./workflow-events.js";
import { renderAllProjections } from "./workflow-projections.js";

const LEGACY_MILESTONE_DIR_RE = /^(M\d+)-.+$/;
const LEGACY_SLICE_DIR_RE = /^(S\d+)-.+$/;
function canonicalMilestonePrefix(id) {
	return id.match(/^([A-Z]\d{3})/)?.[1] ?? id;
}
function findDbMilestoneForDiskEntry(diskMilestoneId, milestones) {
	const canonicalId = canonicalMilestonePrefix(diskMilestoneId);
	return (
		milestones.find((m) => m.id === diskMilestoneId) ??
		milestones.find((m) => canonicalMilestonePrefix(m.id) === canonicalId) ??
		null
	);
}
function detectMissingPlanningRowsFromProjection(basePath) {
	if (!isDbAvailable()) return [];
	const msDir = milestonesDir(basePath);
	if (!existsSync(msDir)) return [];
	const issues = [];
	const milestones = getAllMilestones();
	for (const entry of readdirSync(msDir)) {
		const fullPath = join(msDir, entry);
		try {
			if (!statSync(fullPath).isDirectory()) continue;
		} catch {
			continue;
		}
		const roadmapPath = resolveMilestoneFile(basePath, entry, "ROADMAP");
		if (!roadmapPath || !existsSync(roadmapPath)) continue;
		let roadmap;
		try {
			roadmap = parseRoadmap(readFileSync(roadmapPath, "utf-8"));
		} catch {
			continue;
		}
		if (!roadmap?.slices || roadmap.slices.length === 0) continue;
		const dbMilestone = findDbMilestoneForDiskEntry(entry, milestones);
		if (dbMilestone && getMilestoneSlices(dbMilestone.id).length > 0) continue;
		issues.push({
			severity: "warning",
			code: "db_missing_planning_rows_from_projection",
			scope: "milestone",
			unitId: dbMilestone?.id ?? entry,
			message: `${entry}-ROADMAP.md contains ${roadmap.slices.length} slice(s), but the DB has no executable slice rows for ${dbMilestone?.id ?? entry}. Run doctor --fix to upgrade the .sf planning artifact into DB state.`,
			file: roadmapPath,
			fixable: true,
		});
	}
	return issues;
}

function projectionDriftIssues(basePath, milestoneId) {
	const issues = [];
	const roadmapJsonPath = join(
		basePath,
		".sf",
		"milestones",
		milestoneId,
		`${milestoneId}-ROADMAP.json`,
	);
	if (roadmapJsonPath && existsSync(roadmapJsonPath)) {
		try {
			const projected = JSON.parse(readFileSync(roadmapJsonPath, "utf-8"));
			const dbSlices = getMilestoneSlices(milestoneId).map((slice) => ({
				id: slice.id,
				status: slice.status ?? "",
				sequence: Number(slice.sequence ?? 0),
			}));
			const projectedSlices = Array.isArray(projected?.slices)
				? projected.slices.map((slice) => ({
						id: String(slice?.id ?? slice?.sliceId ?? ""),
						status: String(slice?.status ?? ""),
						sequence: Number(slice?.sequence ?? 0),
					}))
				: [];
			if (JSON.stringify(dbSlices) !== JSON.stringify(projectedSlices)) {
				issues.push({
					severity: "warning",
					code: "db_projection_roadmap_drift",
					scope: "milestone",
					unitId: milestoneId,
					message: `${milestoneId}-ROADMAP.json differs from DB slice order/status. Projection is export/recovery only; DB remains authoritative.`,
					file: roadmapJsonPath,
					fixable: false,
				});
			}
		} catch (err) {
			issues.push({
				severity: "warning",
				code: "db_projection_roadmap_invalid",
				scope: "milestone",
				unitId: milestoneId,
				message: `${milestoneId}-ROADMAP.json could not be parsed: ${getErrorMessage(err)}. Projection is not executable runtime state.`,
				file: roadmapJsonPath,
				fixable: false,
			});
		}
	}
	// db_projection_validation_drift check removed (#db-first-validation):
	// VALIDATION.md is now a generated projection from assessments table.
	// Drift is structurally impossible — the file is always regenerated from DB
	// by handleValidateMilestone. No drift check needed.
	return issues;
}

function legacyBareId(name, pattern) {
	const match = name.match(pattern);
	return match?.[1] ?? null;
}

function normalizeLegacyDirectory(
	parentDir,
	entry,
	pattern,
	unitPrefix,
	issues,
	fixesApplied,
	shouldFix,
) {
	const bareId = legacyBareId(entry, pattern);
	if (!bareId) return;
	const sourcePath = join(parentDir, entry);
	const targetPath = join(parentDir, bareId);
	const conflict = existsSync(targetPath);
	if (conflict) return;
	issues.push({
		severity: "info",
		code: "legacy_plan_slug_directory",
		scope: "project",
		unitId: `${unitPrefix}/${entry}`,
		message: `Legacy plan directory ${sourcePath} should be renamed to bare ID directory ${targetPath}.`,
		file: sourcePath,
		fixable: true,
	});
	if (!shouldFix?.("legacy_plan_slug_directory")) return;
	try {
		renameSync(sourcePath, targetPath);
		fixesApplied.push(
			`renamed legacy plan directory ${sourcePath} -> ${targetPath}`,
		);
	} catch {
		fixesApplied.push(
			`failed to rename legacy plan directory ${sourcePath} -> ${targetPath}`,
		);
	}
}

export function normalizeLegacyPlanSlugDirectories(
	basePath,
	issues,
	fixesApplied,
	shouldFix,
) {
	const msDir = milestonesDir(basePath);
	if (!existsSync(msDir)) return;
	for (const milestoneEntry of readdirSync(msDir, { withFileTypes: true })) {
		if (!milestoneEntry.isDirectory()) continue;
		const milestonePath = join(msDir, milestoneEntry.name);
		const slicesPath = join(milestonePath, "slices");
		if (existsSync(slicesPath)) {
			for (const sliceEntry of readdirSync(slicesPath, {
				withFileTypes: true,
			})) {
				if (!sliceEntry.isDirectory()) continue;
				normalizeLegacyDirectory(
					slicesPath,
					sliceEntry.name,
					LEGACY_SLICE_DIR_RE,
					milestoneEntry.name,
					issues,
					fixesApplied,
					shouldFix,
				);
			}
		}
		normalizeLegacyDirectory(
			msDir,
			milestoneEntry.name,
			LEGACY_MILESTONE_DIR_RE,
			"milestone",
			issues,
			fixesApplied,
			shouldFix,
		);
	}
}
/**
 * Check SF engine health: database constraints, projection drift, and corruption.
 *
 * Verifies orphaned tasks/slices, duplicate IDs, and missing task summaries.
 * Re-renders stale markdown projections when event log is newer than cached files.
 * Non-fatal: issues are reported but never auto-fixed.
 *
 * Purpose: keep autonomous SF from treating structurally invalid DB planning
 * state as executable work.
 *
 * Consumer: sf doctor and headless triage before dispatching autonomous units.
 */
export async function checkEngineHealth(
	basePath,
	issues,
	fixesApplied,
	shouldFix,
) {
	const dbPath = join(basePath, ".sf", "sf.db");
	if (!isDbAvailable() && existsSync(dbPath)) {
		try {
			openDatabase(dbPath);
		} catch {
			// Report below. Doctor must not throw just because the engine DB is
			// locked, corrupt, or unavailable in this process.
		}
	}
	if (!isDbAvailable() && existsSync(dbPath)) {
		issues.push({
			severity: "warning",
			code: "db_unavailable",
			scope: "project",
			unitId: "project",
			message:
				"Database unavailable — using filesystem state derivation (degraded mode). State queries may be slower and less reliable.",
			file: ".sf/sf.db",
			fixable: false,
		});
	}
	try {
		normalizeLegacyPlanSlugDirectories(
			basePath,
			issues,
			fixesApplied,
			shouldFix,
		);
	} catch {
		// Non-fatal — legacy directory normalization must never block doctor.
	}
	try {
		const parityReport = writeParityReport(basePath);
		const parityHealth = summarizeParityHealth(parityReport);
		if (!parityHealth.ok) {
			issues.push({
				severity: "warning",
				code: "uok_parity_current_issue",
				scope: "project",
				unitId: "project",
				message:
					`UOK parity has current issues: ${parityHealth.current.criticalMismatches} critical mismatch(es), ` +
					`${parityHealth.current.missingExitEvents} missing exit(s), ${parityHealth.current.errorEvents} error event(s).`,
				file: ".sf/runtime/uok-parity-report.json",
				fixable: false,
			});
		} else if (
			parityHealth.historical.criticalMismatches > 0 ||
			parityHealth.historical.legacyMissingExitEvents > 0 ||
			parityHealth.historical.errorEvents > 0 ||
			parityHealth.historical.unmatchedRuns > 0
		) {
			issues.push({
				severity: "info",
				code: "uok_parity_historical_drift",
				scope: "project",
				unitId: "project",
				message:
					`UOK parity is clean now; historical drift remains: ` +
					`${parityHealth.historical.criticalMismatches} old mismatch(es), ` +
					`${parityHealth.historical.legacyMissingExitEvents} legacy missing exit(s), ` +
					`${parityHealth.historical.errorEvents} old error event(s), ` +
					`${parityHealth.historical.unmatchedRuns} stale unmatched run(s).`,
				file: ".sf/runtime/uok-parity-report.json",
				fixable: false,
			});
		}
	} catch {
		// Non-fatal — UOK parity diagnostics must never block doctor.
	}
	// ── DB constraint violation detection (full doctor only, not pre-dispatch per D-10) ──
	try {
		if (isDbAvailable()) {
			const adapter = _getAdapter();
			// a. Orphaned tasks (task.slice_id points to non-existent slice)
			try {
				const orphanedTasks = adapter
					.prepare(`SELECT t.id, t.slice_id, t.milestone_id
             FROM tasks t
             LEFT JOIN slices s ON t.milestone_id = s.milestone_id AND t.slice_id = s.id
             WHERE s.id IS NULL`)
					.all();
				for (const row of orphanedTasks) {
					issues.push({
						severity: "error",
						code: "db_orphaned_task",
						scope: "task",
						unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`,
						message: `Task ${row.id} references slice ${row.slice_id} in milestone ${row.milestone_id} but no such slice exists in the database`,
						fixable: false,
					});
				}
			} catch {
				// Non-fatal — orphaned task check failed
			}
			// b. Orphaned slices (slice.milestone_id points to non-existent milestone)
			try {
				const orphanedSlices = adapter
					.prepare(`SELECT s.id, s.milestone_id
             FROM slices s
             LEFT JOIN milestones m ON s.milestone_id = m.id
             WHERE m.id IS NULL`)
					.all();
				for (const row of orphanedSlices) {
					issues.push({
						severity: "error",
						code: "db_orphaned_slice",
						scope: "slice",
						unitId: `${row.milestone_id}/${row.id}`,
						message: `Slice ${row.id} references milestone ${row.milestone_id} but no such milestone exists in the database`,
						fixable: false,
					});
				}
			} catch {
				// Non-fatal — orphaned slice check failed
			}
			// c. Tasks marked complete without summaries
			try {
				const doneTasks = adapter
					.prepare(`SELECT id, slice_id, milestone_id FROM tasks
             WHERE status = 'done' AND (summary IS NULL OR summary = '')`)
					.all();
				for (const row of doneTasks) {
					issues.push({
						severity: "warning",
						code: "db_done_task_no_summary",
						scope: "task",
						unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`,
						message: `Task ${row.id} is marked done but has no summary in the database`,
						fixable: false,
					});
				}
			} catch {
				// Non-fatal — done-task-no-summary check failed
			}
			// c2. Milestones without vision (purpose gate per ADR-0000)
			try {
				const missingVision = adapter
					.prepare(
						`SELECT id FROM milestones WHERE vision IS NULL OR TRIM(vision) = ''`,
					)
					.all();
				for (const row of missingVision) {
					issues.push({
						severity: "error",
						code: "db_milestone_missing_vision",
						scope: "milestone",
						unitId: row.id,
						message: `Milestone ${row.id} has no vision (purpose). Every milestone must define its purpose per ADR-0000.`,
						fixable: false,
					});
				}
			} catch {
				// Non-fatal
			}
			// c3. Slices without goal (purpose gate per ADR-0000)
			try {
				const missingGoal = adapter
					.prepare(
						`SELECT milestone_id, id FROM slices WHERE goal IS NULL OR TRIM(goal) = ''`,
					)
					.all();
				for (const row of missingGoal) {
					issues.push({
						severity: "error",
						code: "db_slice_missing_goal",
						scope: "slice",
						unitId: `${row.milestone_id}/${row.id}`,
						message: `Slice ${row.milestone_id}/${row.id} has no goal. Every slice must define its purpose per ADR-0000.`,
						fixable: false,
					});
				}
			} catch {
				// Non-fatal
			}
			// d. Duplicate entity IDs (safety check)
			try {
				const dupMilestones = adapter
					.prepare(
						"SELECT id, COUNT(*) as cnt FROM milestones GROUP BY id HAVING cnt > 1",
					)
					.all();
				for (const row of dupMilestones) {
					issues.push({
						severity: "error",
						code: "db_duplicate_id",
						scope: "milestone",
						unitId: row.id,
						message: `Duplicate milestone ID "${row.id}" appears ${row.cnt} times in the database`,
						fixable: false,
					});
				}
				const dupSlices = adapter
					.prepare(
						"SELECT id, milestone_id, COUNT(*) as cnt FROM slices GROUP BY id, milestone_id HAVING cnt > 1",
					)
					.all();
				for (const row of dupSlices) {
					issues.push({
						severity: "error",
						code: "db_duplicate_id",
						scope: "slice",
						unitId: `${row.milestone_id}/${row.id}`,
						message: `Duplicate slice ID "${row.id}" in milestone ${row.milestone_id} appears ${row.cnt} times`,
						fixable: false,
					});
				}
				const dupTasks = adapter
					.prepare(
						"SELECT id, slice_id, milestone_id, COUNT(*) as cnt FROM tasks GROUP BY id, slice_id, milestone_id HAVING cnt > 1",
					)
					.all();
				for (const row of dupTasks) {
					issues.push({
						severity: "error",
						code: "db_duplicate_id",
						scope: "task",
						unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`,
						message: `Duplicate task ID "${row.id}" in slice ${row.slice_id} appears ${row.cnt} times`,
						fixable: false,
					});
				}
			} catch {
				// Non-fatal — duplicate ID check failed
			}
			// e. DB/projection drift. Projections are useful for humans, exports,
			// and recovery, but runtime must not treat them as executable state when
			// the DB is available.
			try {
				const missingPlanningRows =
					detectMissingPlanningRowsFromProjection(basePath);
				issues.push(...missingPlanningRows);
				if (
					missingPlanningRows.length > 0 &&
					shouldFix?.("db_missing_planning_rows_from_projection")
				) {
					const counts = migrateHierarchyToDb(basePath);
					fixesApplied.push(
						`upgraded .sf planning artifacts into DB (${counts.milestones}M/${counts.slices}S/${counts.tasks}T)`,
					);
				}
				for (const milestone of getAllMilestones()) {
					issues.push(...projectionDriftIssues(basePath, milestone.id));
				}
			} catch {
				// Non-fatal — projection drift checks must not block doctor.
			}
		}
	} catch {
		// Non-fatal — DB constraint checks failed entirely
	}
	// ── Orphaned milestone directories ─────────────────────────────────────
	// Detect .sf/milestones/* directories that have no corresponding DB row.
	// These are leftover from manual cleanup, failed deletions, or DB resets.
	// When DB is available, DB is authoritative. When DB is unavailable,
	// fall back to filesystem-derived registry (roadmap-based discovery).
	try {
		const msDir = milestonesDir(basePath);
		if (existsSync(msDir)) {
			const validMilestoneIds = new Set();
			const validMilestonePrefixes = new Set();
			if (isDbAvailable()) {
				// DB-authoritative: only DB rows count as valid
				for (const m of getAllMilestones()) {
					validMilestoneIds.add(m.id);
					validMilestonePrefixes.add(canonicalMilestonePrefix(m.id));
				}
			} else {
				// No DB: fall back to filesystem registry
				const state = await deriveState(basePath);
				for (const m of state.registry) {
					validMilestoneIds.add(m.id);
					validMilestonePrefixes.add(canonicalMilestonePrefix(m.id));
				}
			}
			for (const entry of readdirSync(msDir)) {
				const fullPath = join(msDir, entry);
				try {
					if (!statSync(fullPath).isDirectory()) continue;
				} catch {
					continue;
				}
				// Extract milestone ID from directory name (handles M001, M001-r5jzab, etc.)
				const milestoneId = entry.split("-")[0];
				if (!milestoneId) continue;
				if (
					!validMilestoneIds.has(milestoneId) &&
					!validMilestoneIds.has(entry) &&
					!validMilestonePrefixes.has(canonicalMilestonePrefix(entry))
				) {
					issues.push({
						severity: "warning",
						code: "orphaned_milestone_directory",
						scope: "project",
						unitId: entry,
						message: `Milestone directory ${fullPath} exists on disk but has no corresponding database entry or roadmap. It may be leftover from manual cleanup or a DB reset.`,
						fixable: true,
					});
					if (shouldFix?.("orphaned_milestone_directory")) {
						try {
							rmSync(fullPath, { recursive: true, force: true });
							fixesApplied.push(
								`removed orphaned milestone directory ${fullPath}`,
							);
						} catch {
							fixesApplied.push(
								`failed to remove orphaned milestone directory ${fullPath}`,
							);
						}
					}
				}
			}
		}
	} catch {
		// Non-fatal — orphaned milestone directory check failed
	}
	// ── Projection drift detection ──────────────────────────────────────────
	// If the DB is available, check whether markdown projections are stale
	// relative to the event log and re-render them.
	try {
		if (isDbAvailable()) {
			const eventLogPath = join(basePath, ".sf", "event-log.jsonl");
			const events = readEvents(eventLogPath);
			if (events.length > 0) {
				const lastEventTs = new Date(events[events.length - 1].ts).getTime();
				const state = await deriveState(basePath);
				for (const milestone of state.registry) {
					if (milestone.status === "complete") continue;
					const roadmapPath = resolveMilestoneFile(
						basePath,
						milestone.id,
						"ROADMAP",
					);
					if (!roadmapPath || !existsSync(roadmapPath)) {
						try {
							await renderAllProjections(basePath, milestone.id);
							fixesApplied.push(
								`re-rendered missing projections for ${milestone.id}`,
							);
						} catch {
							// Non-fatal — projection re-render failed
						}
						continue;
					}
					const projectionMtime = statSync(roadmapPath).mtimeMs;
					if (lastEventTs > projectionMtime) {
						try {
							await renderAllProjections(basePath, milestone.id);
							fixesApplied.push(
								`re-rendered stale projections for ${milestone.id}`,
							);
						} catch {
							// Non-fatal — projection re-render failed
						}
					}
				}
			}
		}
	} catch {
		// Non-fatal — projection drift check must never block doctor
	}
	// ── Spawn-worker silent failure check (R015) ──────────────────────────────
	// Detect parallel-orchestrator workers that transitioned to 'failed' state
	// during the current run. Reads the orchestrator's in-memory state rather
	// than querying the DB — the orchestrator owns this state and keeps it
	// current via refreshWorkerStatuses.
	// Uses dynamic import to avoid circular dependency (doctor-engine-checks
	// is imported by parallel-orchestrator via the doctor.js orchestrator).
	try {
		const { getOrchestratorState, readWorkerStderr } = await import(
			"./parallel-orchestrator.js"
		).catch(() => ({ getOrchestratorState: () => null, readWorkerStderr: () => "" }));
		const orchestratorState = getOrchestratorState();
		if (orchestratorState) {
			for (const worker of orchestratorState.workers.values()) {
				if (worker.state !== "failed") continue;
				const stderrExcerpt = readWorkerStderr(basePath, worker.milestoneId);
				issues.push({
					severity: "high",
					code: "spawn_worker_silent_failure",
					scope: "milestone",
					unitId: worker.milestoneId,
					message: `Worker for milestone ${worker.milestoneId} transitioned to 'failed' state after silent spawn failure (worker was alive for ${Date.now() - worker.startedAt}ms without producing output). Check .sf/parallel/${worker.milestoneId}.stderr.log for details.`,
					file: `.sf/parallel/${worker.milestoneId}.stderr.log`,
					fixable: false,
					// Structured metadata for downstream consumers (e.g. self-feedback triage)
					milestoneId: worker.milestoneId,
					spawnPid: worker.pid,
					elapsedMsSinceSpawn: Date.now() - worker.startedAt,
					stderrExcerpt:
						stderrExcerpt.length > 500
							? stderrExcerpt.slice(-500)
							: stderrExcerpt,
					retryCount: worker.retryCount ?? 0,
				});
			}
		}
	} catch {
		// Non-fatal — spawn failure check must never block doctor.
	}
}
