// Deep planning mode — Artifact validator entry point.
//
// Validates PROJECT.md, REQUIREMENTS.md, and per-milestone ROADMAP.md
// against the contract spec in .planning/phases/11-deep-planning-mode/11-CONTRACTS.md.
// Used by deep-mode dispatch rules to gate stage completion and by light mode
// auto-start to catch malformed artifacts early.
import { existsSync, readFileSync } from "node:fs";
import { parseProject, parseRequirements, parseRoadmap } from "./parsers.js";

const REQUIRED_PROJECT_SECTIONS = [
	"What This Is",
	"Core Value",
	"Current State",
	"Architecture / Key Patterns",
	"Capability Contract",
	"Milestone Sequence",
];
const REQUIRED_REQUIREMENTS_SECTIONS = [
	"Active",
	"Validated",
	"Deferred",
	"Out of Scope",
	"Traceability",
	"Coverage Summary",
];
// Roadmap section requirements:
//   - "Slices" (legacy H3 format) OR "Slice Overview" (table format
//     emitted by workflow-projections.ts) — at least one must be present.
//   - "Definition of Done" — always required.
// Defensive parsing accepts both shapes; the validator does the same.
const REQUIRED_ROADMAP_SECTIONS = ["Definition of Done"];
const ROADMAP_SLICE_SECTIONS = ["Slices", "Slice Overview"];
const ALLOWED_REQUIREMENT_CLASSES = new Set([
	"core-capability",
	"primary-user-loop",
	"launchability",
	"continuity",
	"failure-visibility",
	"integration",
	"quality-attribute",
	"operability",
	"admin/support",
	"compliance/security",
	"differentiator",
	"constraint",
	"anti-feature",
]);
const STATUS_TO_SECTION = {
	active: "Active",
	validated: "Validated",
	deferred: "Deferred",
	"out-of-scope": "Out of Scope",
};
function loadFile(path) {
	if (!existsSync(path)) return null;
	try {
		return readFileSync(path, "utf-8");
	} catch {
		return null;
	}
}
function err(code, message, location) {
	return location ? { code, message, location } : { code, message };
}
// ─── PROJECT.md ─────────────────────────────────────────────────────────
function validateProjectContent(content) {
	const errors = [];
	const warnings = [];
	const parsed = parseProject(content);
	for (const required of REQUIRED_PROJECT_SECTIONS) {
		if (!(required in parsed.sections)) {
			errors.push(
				err(
					"missing-section",
					`Missing required section "## ${required}"`,
					required,
				),
			);
		}
	}
	for (const sectionName of parsed.sectionsWithTokens) {
		errors.push(
			err(
				"template-token",
				`Section "${sectionName}" contains unsubstituted {{...}} template tokens`,
				sectionName,
			),
		);
	}
	for (const required of REQUIRED_PROJECT_SECTIONS) {
		const body = parsed.sections[required];
		if (body !== undefined && body.trim() === "") {
			errors.push(
				err("empty-section", `Section "## ${required}" is empty`, required),
			);
		}
	}
	if (
		parsed.milestones.length === 0 &&
		"Milestone Sequence" in parsed.sections
	) {
		errors.push(
			err(
				"no-milestones",
				"Milestone Sequence has no entries",
				"Milestone Sequence",
			),
		);
	}
	const seen = new Set();
	let prevNum = 0;
	for (const m of parsed.milestones) {
		if (seen.has(m.id)) {
			errors.push(
				err(
					"duplicate-milestone",
					`Duplicate milestone ID ${m.id}`,
					"Milestone Sequence",
				),
			);
		}
		seen.add(m.id);
		const num = parseInt(m.id.slice(1), 10);
		if (num !== prevNum + 1) {
			warnings.push(
				err(
					"non-monotonic-milestone",
					`Milestone ${m.id} is not monotonically numbered (expected M${String(prevNum + 1).padStart(3, "0")})`,
					"Milestone Sequence",
				),
			);
		}
		prevNum = num;
		if (!m.title || !m.oneLiner) {
			errors.push(
				err(
					"incomplete-milestone",
					`Milestone ${m.id} is missing title or one-liner`,
					"Milestone Sequence",
				),
			);
		}
	}
	const capabilityBody = parsed.sections["Capability Contract"] ?? "";
	if (capabilityBody && !capabilityBody.includes("REQUIREMENTS.md")) {
		warnings.push(
			err(
				"missing-requirements-ref",
				"Capability Contract section should reference .sf/REQUIREMENTS.md",
				"Capability Contract",
			),
		);
	}
	return { ok: errors.length === 0, errors, warnings };
}
// ─── REQUIREMENTS.md ────────────────────────────────────────────────────
function parseSliceList(raw) {
	// e.g. "M001/S02, M002/S03" or "—" or "none"
	if (!raw) return [];
	const trimmed = raw.trim();
	if (
		!trimmed ||
		trimmed === "—" ||
		trimmed === "-" ||
		trimmed.toLowerCase() === "none"
	)
		return [];
	return trimmed
		.split(/[,\s]+/)
		.map((s) => s.trim())
		.filter(Boolean);
}
function validateRequirementsContent(
	content,
	projectContent,
	roadmapsByMilestone,
) {
	const errors = [];
	const warnings = [];
	const parsed = parseRequirements(content);
	for (const required of REQUIRED_REQUIREMENTS_SECTIONS) {
		if (!(required in parsed.sections)) {
			errors.push(
				err(
					"missing-section",
					`Missing required section "## ${required}"`,
					required,
				),
			);
		}
	}
	for (const sectionName of Object.keys(parsed.sections)) {
		const body = parsed.sections[sectionName];
		if (/\{\{[^}]+\}\}/.test(body)) {
			errors.push(
				err(
					"template-token",
					`Section "${sectionName}" contains unsubstituted {{...}} template tokens`,
					sectionName,
				),
			);
		}
	}
	const seenIds = new Set();
	let prevNum = 0;
	for (const r of parsed.requirements) {
		if (seenIds.has(r.id)) {
			errors.push(
				err("duplicate-requirement", `Duplicate requirement ID ${r.id}`, r.id),
			);
		}
		seenIds.add(r.id);
		const num = parseInt(r.id.slice(1), 10);
		if (num <= prevNum) {
			warnings.push(
				err(
					"non-monotonic-requirement",
					`Requirement ${r.id} is not monotonically numbered`,
					r.id,
				),
			);
		}
		prevNum = num;
		validateRequirementShape(r, errors, warnings);
	}
	const milestoneIds = projectContent
		? new Set(parseProject(projectContent).milestones.map((m) => m.id))
		: new Set(Array.from(roadmapsByMilestone.keys()));
	const canValidateMilestones =
		projectContent !== null || roadmapsByMilestone.size > 0;
	/**
	 * Validate one "M###/S##" reference (or partial). Pushes an error if
	 * the milestone is known to be missing; pushes an error if a roadmap is loaded
	 * for the milestone and the slice half is missing.
	 */
	const checkRef = (requirementId, ref, field) => {
		// Tolerate the documented "none yet" / "none" sentinels for primaryOwner.
		if (field === "primaryOwner" && /^(none yet|none)$/.test(ref)) return;
		// "M###" alone (no slash) is allowed for primaryOwner shape; still want
		// to check milestone existence when project/roadmap context is available.
		const milestoneOnly = ref.match(/^(M\d{3})$/);
		if (milestoneOnly) {
			if (canValidateMilestones && !milestoneIds.has(milestoneOnly[1])) {
				errors.push(
					err(
						"dangling-owner",
						`Requirement ${requirementId} ${field} references non-existent milestone ${milestoneOnly[1]}`,
						requirementId,
					),
				);
			}
			return;
		}
		const m = ref.match(/^(M\d{3})\/(S\d{2}|none yet)$/);
		if (!m) {
			warnings.push(
				err(
					"malformed-slice-ref",
					`Requirement ${requirementId} ${field} value "${ref}" does not match expected M###/S## format`,
					requirementId,
				),
			);
			return;
		}
		const [, milestoneId, sliceHalf] = m;
		if (canValidateMilestones && !milestoneIds.has(milestoneId)) {
			errors.push(
				err(
					"dangling-owner",
					`Requirement ${requirementId} ${field} references non-existent milestone ${milestoneId}`,
					requirementId,
				),
			);
			return;
		}
		// Slice-half cross-ref: only enforced when we have a roadmap for the milestone.
		if (sliceHalf === "none yet") return;
		const roadmap = roadmapsByMilestone.get(milestoneId);
		if (!roadmap) return;
		const sliceExists = roadmap.slices.some((s) => s.id === sliceHalf);
		if (!sliceExists) {
			errors.push(
				err(
					"dangling-slice-ref",
					`Requirement ${requirementId} ${field} references slice ${milestoneId}/${sliceHalf} which does not exist in that milestone's roadmap`,
					requirementId,
				),
			);
		}
	};
	for (const r of parsed.requirements) {
		// primaryOwner: single reference.
		if (r.primaryOwner) checkRef(r.id, r.primaryOwner, "primaryOwner");
		// supportingSlices: comma/space-separated list.
		for (const ref of parseSliceList(r.supportingSlices)) {
			checkRef(r.id, ref, "supportingSlices");
		}
	}
	const sectionCounts = {
		Active: 0,
		Validated: 0,
		Deferred: 0,
		"Out of Scope": 0,
	};
	for (const r of parsed.requirements)
		sectionCounts[r.parentSection] = (sectionCounts[r.parentSection] ?? 0) + 1;
	const expectedActive = sectionCounts.Active;
	const reportedActive = parsed.coverageSummary["Active requirements"];
	if (
		reportedActive !== undefined &&
		parseInt(reportedActive, 10) !== expectedActive
	) {
		warnings.push(
			err(
				"coverage-mismatch",
				`Coverage Summary says Active=${reportedActive} but ${expectedActive} entries found in ## Active`,
				"Coverage Summary",
			),
		);
	}
	return { ok: errors.length === 0, errors, warnings };
}
function validateRequirementShape(r, errors, warnings) {
	const required = [
		"class",
		"status",
		"description",
		"whyItMatters",
		"source",
		"primaryOwner",
		"validation",
	];
	for (const field of required) {
		if (!r[field] || r[field].trim() === "") {
			errors.push(
				err(
					"missing-field",
					`Requirement ${r.id} is missing field "${field}"`,
					r.id,
				),
			);
		}
	}
	if (r.class && !ALLOWED_REQUIREMENT_CLASSES.has(r.class)) {
		errors.push(
			err(
				"invalid-class",
				`Requirement ${r.id} has invalid class "${r.class}"`,
				r.id,
			),
		);
	}
	const expectedSection = STATUS_TO_SECTION[r.status];
	if (expectedSection && expectedSection !== r.parentSection) {
		errors.push(
			err(
				"status-section-mismatch",
				`Requirement ${r.id} has Status "${r.status}" but lives under "## ${r.parentSection}" (expected "## ${expectedSection}")`,
				r.id,
			),
		);
	}
	if (
		r.primaryOwner &&
		!/^(M\d{3}(\/(S\d{2}|none yet))?|none yet|none)$/.test(r.primaryOwner)
	) {
		warnings.push(
			err(
				"malformed-owner",
				`Requirement ${r.id} owner "${r.primaryOwner}" does not match expected formats (M### | M###/S## | M###/none yet | none yet | none)`,
				r.id,
			),
		);
	}
}
// ─── ROADMAP.md ─────────────────────────────────────────────────────────
function validateRoadmapContent(
	content,
	requirementsContent,
	currentMilestoneId = null,
) {
	const errors = [];
	const warnings = [];
	const parsed = parseRoadmap(content);
	for (const required of REQUIRED_ROADMAP_SECTIONS) {
		if (!(required in parsed.sections)) {
			errors.push(
				err(
					"missing-section",
					`Missing required section "## ${required}"`,
					required,
				),
			);
		}
	}
	// Slice section: accept either "## Slices" or "## Slice Overview".
	const hasSliceSection = ROADMAP_SLICE_SECTIONS.some(
		(name) => name in parsed.sections,
	);
	if (!hasSliceSection) {
		errors.push(
			err(
				"missing-section",
				`Missing slice section — expected "## Slices" or "## Slice Overview"`,
			),
		);
	}
	for (const sectionName of Object.keys(parsed.sections)) {
		const body = parsed.sections[sectionName];
		if (/\{\{[^}]+\}\}/.test(body)) {
			errors.push(
				err(
					"template-token",
					`Section "${sectionName}" contains unsubstituted {{...}} template tokens`,
					sectionName,
				),
			);
		}
	}
	if (parsed.slices.length === 0 && hasSliceSection) {
		const sliceSection =
			ROADMAP_SLICE_SECTIONS.find((name) => name in parsed.sections) ??
			"Slices";
		errors.push(
			err("no-slices", `${sliceSection} section has no entries`, sliceSection),
		);
	}
	// I5: surface malformed Depends tokens (e.g. "S99;" or "S01-S03") that the
	// parser dropped from the dependency graph. Warning, not error — the rest
	// of the graph is still usable.
	for (const m of parsed.malformedDepends) {
		warnings.push(
			err(
				"malformed-depends",
				`Slice ${m.sliceId} has malformed Depends value(s) that were dropped from the graph: ${m.values.join(", ")}`,
				m.sliceId,
			),
		);
	}
	if (
		parsed.definitionOfDone.length === 0 &&
		"Definition of Done" in parsed.sections
	) {
		errors.push(
			err(
				"no-definition-of-done",
				"Definition of Done has no items",
				"Definition of Done",
			),
		);
	}
	const seenIds = new Set();
	let prevNum = 0;
	for (const s of parsed.slices) {
		if (seenIds.has(s.id)) {
			errors.push(err("duplicate-slice", `Duplicate slice ID ${s.id}`, s.id));
		}
		seenIds.add(s.id);
		const num = parseInt(s.id.slice(1), 10);
		if (num !== prevNum + 1) {
			warnings.push(
				err(
					"non-monotonic-slice",
					`Slice ${s.id} is not monotonically numbered (expected S${String(prevNum + 1).padStart(2, "0")})`,
					s.id,
				),
			);
		}
		prevNum = num;
		if (!s.risk || !s.demo) {
			errors.push(
				err(
					"missing-slice-field",
					`Slice ${s.id} is missing required field (risk and demo are required)`,
					s.id,
				),
			);
		}
	}
	// Depends graph: dangling refs + cycle detection
	const sliceIds = new Set(parsed.slices.map((s) => s.id));
	for (const s of parsed.slices) {
		for (const dep of s.depends) {
			if (!sliceIds.has(dep)) {
				errors.push(
					err(
						"dangling-dependency",
						`Slice ${s.id} depends on non-existent slice ${dep}`,
						s.id,
					),
				);
			}
		}
	}
	if (hasCycle(parsed.slices)) {
		errors.push(
			err("circular-dependency", "Slice depends graph contains a cycle"),
		);
	}
	if (requirementsContent) {
		const reqs = parseRequirements(requirementsContent);
		for (const s of parsed.slices) {
			const ownsAnyRequirement = reqs.requirements.some((r) => {
				if (r.parentSection !== "Active") return false;
				const m = r.primaryOwner.match(/^(M\d{3})\/(S\d{2})$/);
				if (!m) return false;
				if (currentMilestoneId !== null && m[1] !== currentMilestoneId)
					return false;
				return m[2] === s.id;
			});
			if (!ownsAnyRequirement) {
				warnings.push(
					err(
						"orphan-slice",
						`Slice ${s.id} owns no Active requirements`,
						s.id,
					),
				);
			}
		}
	}
	return { ok: errors.length === 0, errors, warnings };
}
function hasCycle(slices) {
	const map = new Map(slices.map((s) => [s.id, s.depends]));
	const visiting = new Set();
	const visited = new Set();
	function dfs(id) {
		if (visiting.has(id)) return true;
		if (visited.has(id)) return false;
		visiting.add(id);
		for (const dep of map.get(id) ?? []) {
			if (dfs(dep)) return true;
		}
		visiting.delete(id);
		visited.add(id);
		return false;
	}
	for (const s of slices) {
		if (dfs(s.id)) return true;
	}
	return false;
}
// ─── Entry point ────────────────────────────────────────────────────────
export function validateArtifact(filePath, kind, opts = {}) {
	const content = loadFile(filePath);
	if (content === null) {
		return {
			ok: false,
			errors: [
				err("file-missing", `Artifact file not found: ${filePath}`, filePath),
			],
			warnings: [],
		};
	}
	switch (kind) {
		case "project":
			return validateProjectContent(content);
		case "requirements": {
			const projectContent = opts.crossRefs?.projectPath
				? loadFile(opts.crossRefs.projectPath)
				: null;
			const roadmapsByMilestone = new Map();
			const roadmapPaths = opts.crossRefs?.roadmapPaths ?? {};
			for (const [mid, path] of Object.entries(roadmapPaths)) {
				const c = loadFile(path);
				if (c) roadmapsByMilestone.set(mid, parseRoadmap(c));
			}
			return validateRequirementsContent(
				content,
				projectContent,
				roadmapsByMilestone,
			);
		}
		case "roadmap":
			return validateRoadmapContent(
				content,
				opts.crossRefs?.requirementsPath
					? loadFile(opts.crossRefs.requirementsPath)
					: null,
				opts.milestoneId ??
					filePath.match(/(?:^|[\\/])(M\d{3})(?:[\\/]|-)/)?.[1] ??
					null,
			);
	}
}
