import { clearParseCache } from "../files.js";
import { renderRoadmapFromDb } from "../markdown-renderer.js";
import { hasStructuredVisionAlignmentMeeting } from "../milestone-quality.js";
import { appendScheduleSpecs } from "../schedule/schedule-milestone.js";
import {
	getMilestone,
	getMilestoneSlices,
	getSlice,
	insertMilestone,
	insertMilestoneEvidence,
	insertSlice,
	transaction,
	upsertMilestonePlanning,
	upsertSlicePlanning,
} from "../sf-db.js";
import { invalidateStateCache } from "../state.js";
import { isClosedStatus } from "../status-guards.js";
import {
	isNonEmptyString,
	normalizePlanningText,
	normalizePlanningTextArray,
} from "../validation.js";
import { logWarning } from "../workflow-logger.js";
import { scaffoldMilestoneSlices } from "../workflow-templates.js";
import { runPostMutationHook } from "./tool-helpers.js";

function validateRiskEntries(value) {
	if (!Array.isArray(value)) {
		throw new Error("keyRisks must be an array");
	}
	return value.map((entry, index) => {
		if (!entry || typeof entry !== "object") {
			throw new Error(`keyRisks[${index}] must be an object`);
		}
		const risk = entry.risk;
		const whyItMatters = entry.whyItMatters;
		if (!isNonEmptyString(risk) || !isNonEmptyString(whyItMatters)) {
			throw new Error(
				`keyRisks[${index}] must include non-empty risk and whyItMatters`,
			);
		}
		return {
			risk: normalizePlanningText(risk, `keyRisks[${index}].risk`),
			whyItMatters: normalizePlanningText(
				whyItMatters,
				`keyRisks[${index}].whyItMatters`,
			),
		};
	});
}
function validateProofStrategy(value) {
	if (!Array.isArray(value)) {
		throw new Error("proofStrategy must be an array");
	}
	return value.map((entry, index) => {
		if (!entry || typeof entry !== "object") {
			throw new Error(`proofStrategy[${index}] must be an object`);
		}
		const riskOrUnknown = entry.riskOrUnknown;
		const retireIn = entry.retireIn;
		const whatWillBeProven = entry.whatWillBeProven;
		if (
			!isNonEmptyString(riskOrUnknown) ||
			!isNonEmptyString(retireIn) ||
			!isNonEmptyString(whatWillBeProven)
		) {
			throw new Error(
				`proofStrategy[${index}] must include non-empty riskOrUnknown, retireIn, and whatWillBeProven`,
			);
		}
		return {
			riskOrUnknown: normalizePlanningText(
				riskOrUnknown,
				`proofStrategy[${index}].riskOrUnknown`,
			),
			retireIn: normalizePlanningText(
				retireIn,
				`proofStrategy[${index}].retireIn`,
			),
			whatWillBeProven: normalizePlanningText(
				whatWillBeProven,
				`proofStrategy[${index}].whatWillBeProven`,
			),
		};
	});
}
const PRODUCT_RESEARCH_TEXT_FIELDS = [
	"purpose",
	"consumer",
	"contract",
	"failureBoundary",
	"evidence",
	"nonGoals",
	"invariants",
	"assumptions",
	"category",
	"targetJob",
];
const PRODUCT_RESEARCH_ARRAY_FIELDS = [
	"comparables",
	"tableStakes",
	"differentiators",
	"antiPatterns",
];
function validateProductResearch(value) {
	if (!value || typeof value !== "object" || Array.isArray(value)) {
		throw new Error("productResearch must be an object");
	}
	const normalized = {};
	for (const field of PRODUCT_RESEARCH_TEXT_FIELDS) {
		if (!isNonEmptyString(value[field])) {
			throw new Error(`productResearch.${field} must be a non-empty string`);
		}
		normalized[field] = normalizePlanningText(
			value[field],
			`productResearch.${field}`,
		);
	}
	for (const field of PRODUCT_RESEARCH_ARRAY_FIELDS) {
		const items = value[field];
		if (
			!Array.isArray(items) ||
			items.some((item) => !isNonEmptyString(item))
		) {
			throw new Error(
				`productResearch.${field} must be an array of non-empty strings`,
			);
		}
		normalized[field] = items.map((item, index) =>
			normalizePlanningText(item, `productResearch.${field}[${index}]`),
		);
	}
	return normalized;
}
// ADR-0000 P2 (schema v69): normalize text for fragment containment comparison.
// Collapses runs of whitespace, trims, and lowercases so trivial formatting
// differences (newlines, double spaces, capitalization) don't cause false
// rejections. The fragment must still be a literal substring of the vision
// after the same normalization is applied.
function normalizeForFragmentMatch(value) {
	return String(value ?? "")
		.replace(/\s+/g, " ")
		.trim()
		.toLowerCase();
}

function validateSlices(value, { vision } = {}) {
	if (!Array.isArray(value) || value.length === 0) {
		throw new Error("slices must be a non-empty array");
	}
	const normalizedVision =
		typeof vision === "string" ? normalizeForFragmentMatch(vision) : "";
	const seen = new Set();
	return value.map((entry, index) => {
		if (!entry || typeof entry !== "object") {
			throw new Error(`slices[${index}] must be an object`);
		}
		const obj = entry;
		const sliceId = obj.sliceId;
		const title = obj.title;
		const risk = obj.risk;
		const depends = obj.depends;
		const demo = obj.demo;
		const goal = obj.goal;
		const successCriteria = obj.successCriteria;
		const proofLevel = obj.proofLevel;
		const integrationClosure = obj.integrationClosure;
		const observabilityImpact = obj.observabilityImpact;
		const isSketchRaw = obj.isSketch;
		const sketchScopeRaw = obj.sketchScope;
		const tracesVisionFragmentRaw = obj.tracesVisionFragment;
		// SF ADR-011: preserve 3-valued isSketch semantics (true/false/absent).
		// Absent must round-trip as undefined so DB upsert ON CONFLICT preserves
		// any existing is_sketch row state rather than silently overwriting.
		const isSketch =
			isSketchRaw === true ? true : isSketchRaw === false ? false : undefined;
		if (!isNonEmptyString(sliceId))
			throw new Error(`slices[${index}].sliceId must be a non-empty string`);
		if (seen.has(sliceId))
			throw new Error(`slices[${index}].sliceId must be unique`);
		seen.add(sliceId);
		if (!isNonEmptyString(title))
			throw new Error(`slices[${index}].title must be a non-empty string`);
		if (!isNonEmptyString(risk))
			throw new Error(`slices[${index}].risk must be a non-empty string`);
		if (
			!Array.isArray(depends) ||
			depends.some((item) => !isNonEmptyString(item))
		) {
			throw new Error(
				`slices[${index}].depends must be an array of non-empty strings`,
			);
		}
		if (!isNonEmptyString(demo))
			throw new Error(`slices[${index}].demo must be a non-empty string`);
		if (!isNonEmptyString(goal))
			throw new Error(`slices[${index}].goal must be a non-empty string`);
		// ADR-0000 P2 (schema v69): every new slice must carry a literal
		// fragment of milestone.vision so validate-milestone has structured
		// grounds for assessment without re-reading the vision via the LLM.
		if (!isNonEmptyString(tracesVisionFragmentRaw)) {
			throw new Error(
				`slices[${index}].tracesVisionFragment must be a non-empty string — emit the literal clause from milestone.vision this slice serves (ADR-0000)`,
			);
		}
		const fragmentNormalized = normalizeForFragmentMatch(
			tracesVisionFragmentRaw,
		);
		if (!fragmentNormalized) {
			throw new Error(
				`slices[${index}].tracesVisionFragment must be a non-empty string`,
			);
		}
		if (!normalizedVision.includes(fragmentNormalized)) {
			throw new Error(
				`slices[${index}].tracesVisionFragment must appear verbatim in milestone.vision (ADR-0000 P2); got ${JSON.stringify(tracesVisionFragmentRaw)}`,
			);
		}
		// SF ADR-011: sketch slices defer the heavyweight planning fields to
		// refine-slice. Non-sketch slices must populate them up front.
		if (isSketch === true) {
			if (!isNonEmptyString(sketchScopeRaw)) {
				throw new Error(
					`slices[${index}].sketchScope must be a non-empty string when isSketch is true`,
				);
			}
		} else {
			if (!isNonEmptyString(successCriteria))
				throw new Error(
					`slices[${index}].successCriteria must be a non-empty string`,
				);
			if (!isNonEmptyString(proofLevel))
				throw new Error(
					`slices[${index}].proofLevel must be a non-empty string`,
				);
			if (!isNonEmptyString(integrationClosure))
				throw new Error(
					`slices[${index}].integrationClosure must be a non-empty string`,
				);
			if (!isNonEmptyString(observabilityImpact))
				throw new Error(
					`slices[${index}].observabilityImpact must be a non-empty string`,
				);
		}
		return {
			sliceId: normalizePlanningText(sliceId, `slices[${index}].sliceId`),
			title: normalizePlanningText(title, `slices[${index}].title`),
			risk: normalizePlanningText(risk, `slices[${index}].risk`),
			depends: depends.map((item, depIndex) =>
				normalizePlanningText(item, `slices[${index}].depends[${depIndex}]`),
			),
			demo: normalizePlanningText(demo, `slices[${index}].demo`),
			goal: normalizePlanningText(goal, `slices[${index}].goal`),
			successCriteria: isNonEmptyString(successCriteria)
				? normalizePlanningText(
						successCriteria,
						`slices[${index}].successCriteria`,
					)
				: "",
			proofLevel: isNonEmptyString(proofLevel)
				? normalizePlanningText(proofLevel, `slices[${index}].proofLevel`)
				: "",
			integrationClosure: isNonEmptyString(integrationClosure)
				? normalizePlanningText(
						integrationClosure,
						`slices[${index}].integrationClosure`,
					)
				: "",
			observabilityImpact: isNonEmptyString(observabilityImpact)
				? normalizePlanningText(
						observabilityImpact,
						`slices[${index}].observabilityImpact`,
					)
				: "",
			isSketch,
			// Only carry sketch scope through if caller explicitly provided it —
			// preserves ON CONFLICT semantics for re-plans that omit the field.
			sketchScope:
				sketchScopeRaw === undefined
					? undefined
					: isNonEmptyString(sketchScopeRaw)
						? normalizePlanningText(
								sketchScopeRaw,
								`slices[${index}].sketchScope`,
							)
						: "",
			// ADR-0000 P2 (schema v69): validated, normalized vision trace clause.
			tracesVisionFragment: normalizePlanningText(
				tracesVisionFragmentRaw,
				`slices[${index}].tracesVisionFragment`,
			),
		};
	});
}
function normalizeVisionMeeting(meeting) {
	return {
		trigger: normalizePlanningText(meeting.trigger, "visionMeeting.trigger"),
		pm: normalizePlanningText(meeting.pm, "visionMeeting.pm"),
		userAdvocate: normalizePlanningText(
			meeting.userAdvocate,
			"visionMeeting.userAdvocate",
		),
		customerPanel: normalizePlanningText(
			meeting.customerPanel,
			"visionMeeting.customerPanel",
		),
		business: normalizePlanningText(meeting.business, "visionMeeting.business"),
		researcher: normalizePlanningText(
			meeting.researcher,
			"visionMeeting.researcher",
		),
		deliveryLead: normalizePlanningText(
			meeting.deliveryLead,
			"visionMeeting.deliveryLead",
		),
		partner: normalizePlanningText(meeting.partner, "visionMeeting.partner"),
		combatant: normalizePlanningText(
			meeting.combatant,
			"visionMeeting.combatant",
		),
		architect: normalizePlanningText(
			meeting.architect,
			"visionMeeting.architect",
		),
		moderator: normalizePlanningText(
			meeting.moderator,
			"visionMeeting.moderator",
		),
		weightedSynthesis: normalizePlanningText(
			meeting.weightedSynthesis,
			"visionMeeting.weightedSynthesis",
		),
		confidenceByArea: normalizePlanningText(
			meeting.confidenceByArea,
			"visionMeeting.confidenceByArea",
		),
		recommendedRoute: meeting.recommendedRoute,
	};
}
function validateParams(params) {
	if (!isNonEmptyString(params?.milestoneId))
		throw new Error("milestoneId is required");
	if (!isNonEmptyString(params?.title)) throw new Error("title is required");
	if (!isNonEmptyString(params?.vision)) throw new Error("vision is required");
	let slicesInput = params.slices;
	if (
		(!Array.isArray(slicesInput) || slicesInput.length === 0) &&
		params.templateId
	) {
		const scaffolded = scaffoldMilestoneSlices(params.templateId);
		if (!scaffolded) {
			throw new Error(`unknown milestone template: ${params.templateId}`);
		}
		slicesInput = scaffolded;
	}
	return {
		...params,
		milestoneId: normalizePlanningText(params.milestoneId, "milestoneId"),
		title: normalizePlanningText(params.title, "title"),
		vision: normalizePlanningText(params.vision, "vision"),
		dependsOn: params.dependsOn
			? normalizePlanningTextArray(params.dependsOn, "dependsOn")
			: [],
		// Apply defaults for optional enrichment fields (#2771)
		successCriteria: params.successCriteria
			? normalizePlanningTextArray(params.successCriteria, "successCriteria")
			: [],
		keyRisks: params.keyRisks ? validateRiskEntries(params.keyRisks) : [],
		proofStrategy: params.proofStrategy
			? validateProofStrategy(params.proofStrategy)
			: [],
		verificationContract: normalizePlanningText(
			params.verificationContract ?? "",
			"verificationContract",
		),
		verificationIntegration: normalizePlanningText(
			params.verificationIntegration ?? "",
			"verificationIntegration",
		),
		verificationOperational: normalizePlanningText(
			params.verificationOperational ?? "",
			"verificationOperational",
		),
		verificationUat: normalizePlanningText(
			params.verificationUat ?? "",
			"verificationUat",
		),
		definitionOfDone: params.definitionOfDone
			? normalizePlanningTextArray(params.definitionOfDone, "definitionOfDone")
			: [],
		requirementCoverage: normalizePlanningText(
			params.requirementCoverage ?? "Not provided.",
			"requirementCoverage",
		),
		boundaryMapMarkdown: normalizePlanningText(
			params.boundaryMapMarkdown ?? "Not provided.",
			"boundaryMapMarkdown",
		),
		productResearch: params.productResearch
			? validateProductResearch(params.productResearch)
			: undefined,
		visionMeeting: hasStructuredVisionAlignmentMeeting(params.visionMeeting)
			? normalizeVisionMeeting(params.visionMeeting)
			: undefined,
		// ADR-0000 P2 (schema v69): pass normalized vision so each slice's
		// tracesVisionFragment can be checked for verbatim containment.
		slices: validateSlices(slicesInput, {
			vision: normalizePlanningText(params.vision, "vision"),
		}),
	};
}
export async function handlePlanMilestone(rawParams, basePath) {
	let params;
	try {
		params = validateParams(rawParams);
	} catch (err) {
		return { error: `validation failed: ${err.message}` };
	}
	const slices = params.slices ?? [];
	// ── Guards + DB writes inside a single transaction (prevents TOCTOU) ───
	// Guards must be inside the transaction so the state they check cannot
	// change between the read and the write (#2723).
	let guardError = null;
	try {
		transaction(() => {
			const existingMilestone = getMilestone(params.milestoneId);
			if (existingMilestone && isClosedStatus(existingMilestone.status)) {
				guardError = `cannot re-plan milestone ${params.milestoneId}: it is already complete`;
				return;
			}
			// Guard: refuse to re-plan a milestone that would drop completed slices (#2960).
			// Allow re-planning when all completed slices are still present in the
			// incoming plan — their status is preserved below (#2558). Block only when
			// the new plan omits a completed slice, which could shadow completed work.
			const existingSlices = getMilestoneSlices(params.milestoneId);
			const completedSlices = existingSlices.filter((s) =>
				isClosedStatus(s.status),
			);
			if (completedSlices.length > 0) {
				const incomingSliceIds = new Set(slices.map((s) => s.sliceId));
				const droppedCompleted = completedSlices.filter(
					(s) => !incomingSliceIds.has(s.id),
				);
				if (droppedCompleted.length > 0) {
					guardError = `cannot re-plan milestone ${params.milestoneId}: ${droppedCompleted.length} completed slice(s) would be dropped (${droppedCompleted.map((s) => s.id).join(", ")}). Use reassess_roadmap to modify the roadmap.`;
					return;
				}
			}
			// Validate depends_on: all dependencies must exist and be complete
			if (params.dependsOn && params.dependsOn.length > 0) {
				for (const depId of params.dependsOn) {
					const dep = getMilestone(depId);
					if (!dep) {
						guardError = `depends_on references unknown milestone: ${depId}`;
						return;
					}
					if (!isClosedStatus(dep.status)) {
						guardError = `depends_on milestone ${depId} is not yet complete (status: ${dep.status})`;
						return;
					}
				}
			}
			insertMilestone({
				id: params.milestoneId,
				title: params.title,
				status: params.status ?? "active",
				depends_on: params.dependsOn ?? [],
			});
			upsertMilestonePlanning(params.milestoneId, {
				title: params.title,
				status: params.status ?? "active",
				vision: params.vision,
				successCriteria: params.successCriteria,
				keyRisks: params.keyRisks,
				proofStrategy: params.proofStrategy,
				verificationContract: params.verificationContract,
				verificationIntegration: params.verificationIntegration,
				verificationOperational: params.verificationOperational,
				verificationUat: params.verificationUat,
				definitionOfDone: params.definitionOfDone,
				requirementCoverage: params.requirementCoverage,
				boundaryMapMarkdown: params.boundaryMapMarkdown,
				productResearch: params.productResearch,
				visionMeeting: params.visionMeeting,
			});
			for (let i = 0; i < slices.length; i++) {
				const slice = slices[i];
				// Preserve completed/done status on re-plan (#2558).
				// Without this, a re-plan after milestone transition would reset
				// already-completed slices back to "pending".
				const existing = getSlice(params.milestoneId, slice.sliceId);
				const status =
					existing &&
					(existing.status === "complete" || existing.status === "done")
						? existing.status
						: "pending";
				insertSlice({
					id: slice.sliceId,
					milestoneId: params.milestoneId,
					title: slice.title,
					status,
					risk: slice.risk,
					depends: slice.depends,
					demo: slice.demo,
					sequence: i + 1, // Preserve agent-ordered sequence (#3356)
					isSketch: slice.isSketch,
					sketchScope: slice.sketchScope,
					// ADR-0000 P2 (schema v69): persist validated vision-trace fragment.
					tracesVisionFragment: slice.tracesVisionFragment,
				});
				// SF ADR-011: sketches defer planning fields to refine-slice — only
				// upsert when we actually have content to write.
				if (slice.isSketch !== true) {
					upsertSlicePlanning(params.milestoneId, slice.sliceId, {
						goal: slice.goal,
						successCriteria: slice.successCriteria,
						proofLevel: slice.proofLevel,
						integrationClosure: slice.integrationClosure,
						observabilityImpact: slice.observabilityImpact,
						// ADR-0000 P2 (schema v69): keep upsert path in sync with insert.
						tracesVisionFragment: slice.tracesVisionFragment,
					});
				}
			}
			// Record evidence: milestone planning
			const milestoneEvidenceContent = JSON.stringify({
				title: params.title,
				vision: params.vision ?? "",
				successCriteria: params.successCriteria ?? "",
				keyRisks: params.keyRisks ?? "",
				proofStrategy: params.proofStrategy ?? "",
				verificationContract: params.verificationContract ?? "",
				boundaryMapMarkdown: params.boundaryMapMarkdown ?? "",
				productResearch: params.productResearch ?? null,
				slices:
					slices.map((s) => ({ sliceId: s.sliceId, title: s.title })) ?? [],
			});
			insertMilestoneEvidence(
				params.milestoneId,
				"plan_milestone",
				milestoneEvidenceContent,
				"plan-milestone",
				"agent",
			);
		});
	} catch (err) {
		return { error: `db write failed: ${err.message}` };
	}
	if (guardError) {
		return { error: guardError };
	}
	let roadmapPath;
	try {
		const renderResult = await renderRoadmapFromDb(
			basePath,
			params.milestoneId,
		);
		roadmapPath = renderResult.roadmapPath;
	} catch (renderErr) {
		logWarning(
			"tool",
			`plan_milestone — render failed (DB rows preserved for debugging): ${renderErr.message}`,
		);
		invalidateStateCache();
		return { error: `render failed: ${renderErr.message}` };
	}
	invalidateStateCache();
	clearParseCache();
	// ── Schedule follow-ups (creation-time) ───────────────────────────────
	if (params.schedule && params.schedule.length > 0) {
		try {
			const creationSpecs = params.schedule.filter(
				(s) => s && s.in && !s.on_complete,
			);
			appendScheduleSpecs(basePath, creationSpecs, params.milestoneId);
		} catch {
			// Non-fatal — schedule errors must not fail milestone creation.
		}
	}
	// ── Post-mutation hook: projections, manifest, event log ───────────────
	await runPostMutationHook(
		basePath,
		params.milestoneId,
		{
			cmd: "plan-milestone",
			params: { milestoneId: params.milestoneId },
			ts: new Date().toISOString(),
			actor: "agent",
			actor_name: params.actorName,
			trigger_reason: params.triggerReason,
		},
		{ caller: "plan-milestone" },
	);
	return {
		milestoneId: params.milestoneId,
		title: params.title,
		sliceCount: params.slices?.length ?? 0,
		firstSliceId: params.slices?.[0]?.sliceId,
		firstSliceTitle: params.slices?.[0]?.title,
		roadmapPath,
	};
}
