/**
 * reflection.js — corpus assembler + prompt builder for the SF reflection layer.
 *
 * Purpose: address the architectural-defect filed as
 * "no-reflection-layer-over-self-feedback-corpus". SF detects symptoms and
 * triages individual entries but has no layer that reasons about the whole
 * self-feedback corpus to recognize recurring structural patterns. This
 * module is the data-assembly + prompt half of that layer; the LLM-call
 * half lives in the operator surface (headless-reflect.ts) and the future
 * autonomous unit handler (auto/phases-reflection.js).
 *
 * Output: a single Reflection corpus object that bundles every signal an
 * LLM would need to do meta-analysis (open + recent-resolved self-feedback,
 * recent commits, milestone state, validation files, prior reflection
 * report). The prompt builder renders that into a markdown brief the LLM
 * processes in one turn.
 */
import { execFileSync } from "node:child_process";
import {
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	statSync,
	writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { sfRoot } from "./paths.js";
import {
	getAllMilestones,
	getMilestoneSlices,
	getSliceTasks,
	isDbAvailable,
	listSelfFeedbackEntries,
} from "./sf-db.js";

const RECENT_RESOLVED_LOOKBACK_DAYS = 30;
const COMMIT_LOOKBACK = 50;

function sfReflectionDir(basePath) {
	return join(sfRoot(basePath), "reflection");
}

/**
 * Read recent commits via git log. Best-effort — returns [] on any failure.
 *
 * Why git log directly and not a wrapper: this module is part of SF's
 * runtime; it cannot depend on dev tooling. git is universally present in
 * any SF working tree (SF asserts this in safe-git checks).
 */
function readRecentCommits(basePath, limit) {
	try {
		const out = execFileSync(
			"git",
			[
				"log",
				`-${limit}`,
				"--pretty=format:%H%x09%an%x09%aI%x09%s%x00%b%x1e",
				"--no-merges",
			],
			{ cwd: basePath, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
		);
		return out
			.split("\x1e")
			.filter((s) => s.trim())
			.map((entry) => {
				const [headLine, body] = entry.split("\x00");
				const [sha, author, isoDate, subject] = (headLine ?? "").split("\x09");
				return {
					sha: (sha ?? "").trim(),
					author: (author ?? "").trim(),
					ts: (isoDate ?? "").trim(),
					subject: (subject ?? "").trim(),
					body: (body ?? "").trim(),
				};
			})
			.filter((c) => c.sha);
	} catch {
		return [];
	}
}

/**
 * Snapshot active milestone + slice + task state from the DB.
 */
function readMilestoneSnapshot() {
	if (!isDbAvailable()) return null;
	try {
		const milestones = getAllMilestones();
		return milestones.map((m) => {
			const slices = getMilestoneSlices(m.id) ?? [];
			return {
				id: m.id,
				title: m.title,
				status: m.status,
				slices: slices.map((s) => {
					const tasks = getSliceTasks(m.id, s.id) ?? [];
					return {
						id: s.id,
						status: s.status,
						tasks: tasks.map((t) => ({
							id: t.id,
							status: t.status,
						})),
					};
				}),
			};
		});
	} catch {
		return null;
	}
}

/**
 * Read every milestone's VALIDATION.md verdict + first ~1.5 KiB so the
 * reflection prompt can spot stale verdicts (the M001 case that motivated
 * this module: validation file said S02-S05 pending while the DB had them
 * complete — nothing was looking for that drift).
 */
function readMilestoneValidationFiles(basePath) {
	const milestonesDir = join(sfRoot(basePath), "milestones");
	if (!existsSync(milestonesDir)) return [];
	const out = [];
	try {
		for (const entry of readdirSync(milestonesDir)) {
			const dir = join(milestonesDir, entry);
			try {
				if (!statSync(dir).isDirectory()) continue;
			} catch {
				continue;
			}
			for (const fname of readdirSync(dir)) {
				if (!/-VALIDATION\.md$/.test(fname)) continue;
				try {
					const content = readFileSync(join(dir, fname), "utf-8");
					const verdictMatch = content.match(/^verdict:\s*(\S+)/m);
					out.push({
						milestoneId: entry,
						file: fname,
						verdict: verdictMatch ? verdictMatch[1] : null,
						excerpt: content.slice(0, 1500),
					});
				} catch {
					// best-effort
				}
			}
		}
	} catch {
		// best-effort
	}
	return out;
}

/**
 * Read the most recent reflection report so the next pass can avoid
 * re-stating the same patterns and can detect "what changed since last
 * reflection."
 */
function readPreviousReflectionReport(basePath) {
	const dir = sfReflectionDir(basePath);
	if (!existsSync(dir)) return null;
	try {
		const reports = readdirSync(dir)
			.filter((n) => n.endsWith("-report.md"))
			.sort()
			.reverse();
		if (reports.length === 0) return null;
		const latest = reports[0];
		return {
			file: latest,
			content: readFileSync(join(dir, latest), "utf-8"),
		};
	} catch {
		return null;
	}
}

/**
 * Assemble the full reflection corpus snapshot. All inputs the LLM would
 * need to do meta-analysis, in one struct. Returns null when prerequisites
 * are missing (DB not open) so callers downgrade gracefully.
 *
 * Consumer: buildReflectionPrompt + headless-reflect operator surface +
 * future auto/phases-reflection.js dispatcher handler.
 */
export function assembleReflectionCorpus(basePath = process.cwd()) {
	if (!isDbAvailable()) return null;
	const allEntries = listSelfFeedbackEntries();
	const forgeEntries = allEntries.filter((e) => e.repoIdentity === "forge");
	const cutoffMs =
		Date.now() - RECENT_RESOLVED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
	const open = forgeEntries.filter((e) => !e.resolvedAt);
	const recentResolved = forgeEntries.filter(
		(e) => e.resolvedAt && new Date(e.resolvedAt).getTime() >= cutoffMs,
	);
	return {
		generatedAt: new Date().toISOString(),
		basePath,
		lookbackDays: RECENT_RESOLVED_LOOKBACK_DAYS,
		openEntries: open,
		recentResolvedEntries: recentResolved,
		commits: readRecentCommits(basePath, COMMIT_LOOKBACK),
		milestones: readMilestoneSnapshot(),
		validations: readMilestoneValidationFiles(basePath),
		previousReport: readPreviousReflectionReport(basePath),
	};
}

function renderEntryBrief(entry) {
	let evidenceKind = null;
	if (entry.resolvedEvidence && typeof entry.resolvedEvidence === "object") {
		evidenceKind = entry.resolvedEvidence.kind ?? null;
	}
	const lines = [
		`### ${entry.id}  [${entry.severity}]  ${entry.kind}`,
		`- filed: ${entry.ts}`,
		entry.resolvedAt
			? `- resolved: ${entry.resolvedAt}  (evidence.kind=${evidenceKind ?? "(none)"})`
			: `- status: OPEN`,
		`- summary: ${(entry.summary ?? "").slice(0, 600)}`,
	];
	if (entry.suggestedFix) {
		lines.push(`- suggestedFix: ${entry.suggestedFix.slice(0, 400)}`);
	}
	return lines.filter(Boolean).join("\n");
}

function renderCommitBrief(commit) {
	const summary = commit.subject;
	const bodyLine = commit.body
		? ` — ${commit.body.split("\n")[0].slice(0, 200)}`
		: "";
	return `- ${commit.sha.slice(0, 9)}  ${commit.ts.slice(0, 10)}  ${summary}${bodyLine}`;
}

function renderMilestoneSnapshot(snapshot) {
	if (!snapshot || snapshot.length === 0) return "  (no milestone state)";
	return snapshot
		.map((m) => {
			const sliceLines = m.slices
				.map((s) => {
					const taskCounts = s.tasks.length;
					const doneTasks = s.tasks.filter(
						(t) => t.status === "complete" || t.status === "done",
					).length;
					return `    - ${s.id} [${s.status}]  tasks=${doneTasks}/${taskCounts}`;
				})
				.join("\n");
			return `  - ${m.id} [${m.status}]\n${sliceLines}`;
		})
		.join("\n");
}

function renderValidations(validations) {
	if (!validations || validations.length === 0)
		return "  (no validation files)";
	return validations
		.map((v) => `  - ${v.milestoneId}/${v.file}  verdict=${v.verdict ?? "?"}`)
		.join("\n");
}

/**
 * Render the corpus into a markdown brief the reflection LLM consumes in
 * one turn. The prompt template (prompts/reflection-pass.md) ends with the
 * placeholder {{corpus}} which gets replaced with this output.
 */
export function renderReflectionCorpusBrief(corpus) {
	const sections = [];
	sections.push(`## Corpus snapshot — generated ${corpus.generatedAt}`);
	sections.push(`Project: ${corpus.basePath}`);
	sections.push(`Lookback for resolved entries: ${corpus.lookbackDays} days`);
	sections.push("");

	sections.push(`## Open self-feedback entries (${corpus.openEntries.length})`);
	if (corpus.openEntries.length === 0) {
		sections.push("(none)");
	} else {
		for (const e of corpus.openEntries) sections.push(renderEntryBrief(e));
	}
	sections.push("");

	sections.push(
		`## Recently resolved self-feedback (${corpus.recentResolvedEntries.length}, last ${corpus.lookbackDays}d)`,
	);
	if (corpus.recentResolvedEntries.length === 0) {
		sections.push("(none)");
	} else {
		for (const e of corpus.recentResolvedEntries)
			sections.push(renderEntryBrief(e));
	}
	sections.push("");

	sections.push(`## Recent commits (${corpus.commits.length})`);
	if (corpus.commits.length === 0) {
		sections.push("(none — git log unavailable)");
	} else {
		for (const c of corpus.commits) sections.push(renderCommitBrief(c));
	}
	sections.push("");

	sections.push("## Milestone state");
	sections.push(renderMilestoneSnapshot(corpus.milestones));
	sections.push("");

	sections.push("## Milestone validation verdicts");
	sections.push(renderValidations(corpus.validations));
	sections.push("");

	sections.push("## Previous reflection report");
	if (!corpus.previousReport) {
		sections.push("(no prior report)");
	} else {
		sections.push(`File: ${corpus.previousReport.file}`);
		sections.push("```");
		sections.push(corpus.previousReport.content.slice(0, 4000));
		if (corpus.previousReport.content.length > 4000) {
			sections.push("[... truncated for brief; full file in .sf/reflection/]");
		}
		sections.push("```");
	}

	return sections.join("\n");
}

/**
 * Persist a reflection report to .sf/reflection/<timestamp>-report.md.
 * Returns the absolute path on success, null on failure (best-effort).
 *
 * Consumer: headless-reflect operator surface + future auto handler.
 */
export function writeReflectionReport(basePath, content) {
	try {
		const dir = sfReflectionDir(basePath);
		mkdirSync(dir, { recursive: true });
		const ts = new Date().toISOString().replace(/[:.]/g, "-");
		const path = join(dir, `${ts}-report.md`);
		writeFileSync(path, content, "utf-8");
		return path;
	} catch {
		return null;
	}
}

const REFLECTION_TERMINATOR = "REFLECTION_COMPLETE";

/**
 * Render the reflection-pass prompt template with a corpus brief.
 *
 * Routes through the canonical loadPrompt path so:
 *   - fragment composition ({{include:working-directory}}) resolves
 *   - variable validation catches missing values at render time
 *   - the gap-audit detector sees a real loadPrompt("reflection-pass")
 *     call site in extension source (sf-mp4warqc-y1u0b3 — orphan flag
 *     would otherwise fire because grep only scans EXTENSION_SRC).
 *
 * Consumer: headless-reflect operator surface; future autonomous-loop
 * reflection unit handler.
 */
export async function renderReflectionPrompt(corpus, options = {}) {
	const brief = renderReflectionCorpusBrief(corpus);
	const { loadPrompt } = await import("./prompt-loader.js");
	return loadPrompt("reflection-pass", {
		corpus: brief,
		workingDirectory: options.workingDirectory ?? process.cwd(),
	});
}

/**
 * Default provider/model used when --model is not supplied. This is the
 * single point of model defaulting — the rest of runReflection is
 * provider-agnostic and routes through @singularity-forge/ai's completeSimple.
 *
 * Today's default is google-gemini-cli/gemini-3-pro-preview because that's
 * what the operator's persistent AI Ultra session is wired to and reflection
 * is a reasoning-heavy task that benefits from the pro tier. Operators
 * override per-call via --model. A future change can let SF's
 * persisted-default-model resolution feed this in, eliminating the constant.
 */
const DEFAULT_REFLECTION_MODEL = "google-gemini-cli/gemini-3-pro-preview";

/**
 * Extract assistant text from an AssistantMessage. The response is an
 * array of TextContent | ThinkingContent | ToolCall | ... — we concatenate
 * all text parts so multi-part responses come through intact. Thinking and
 * tool-use parts are ignored — reflection produces text only by contract.
 */
function extractAssistantText(message) {
	const content = message?.content;
	if (!Array.isArray(content)) return "";
	const out = [];
	for (const part of content) {
		if (part && typeof part === "object" && part.type === "text") {
			if (typeof part.text === "string") out.push(part.text);
		}
	}
	return out.join("");
}

/**
 * Parse a "provider/modelId" string into [provider, modelId]. Returns null
 * when the input isn't in that shape. We require the explicit
 * provider/model form so reflection never accidentally picks a model from
 * the wrong provider (e.g. anthropic claude-sonnet-4-6 when the operator
 * meant google-gemini-cli/gemini-3-pro-preview).
 */
function parseModelString(input) {
	if (typeof input !== "string") return null;
	const slash = input.indexOf("/");
	if (slash <= 0 || slash === input.length - 1) return null;
	return [input.slice(0, slash), input.slice(slash + 1)];
}

/**
 * Resolve a "provider/modelId" string to a Model object via
 * @singularity-forge/ai's getModel. Returns null when the model isn't
 * registered.
 *
 * Why getModel and not MODELS directly: the static MODELS catalog is
 * imported into models.ts but not re-exported from the package's public
 * API. getModel is the canonical accessor and pulls from the runtime
 * model-registry which already merges generated MODELS + custom MODELS.
 */
async function resolveModel(providerModelString) {
	const parsed = parseModelString(providerModelString);
	if (!parsed) return null;
	const [provider, modelId] = parsed;
	const ai = await import("@singularity-forge/ai");
	if (typeof ai.getModel !== "function") return null;
	try {
		const model = ai.getModel(provider, modelId);
		return model ?? null;
	} catch {
		return null;
	}
}

/**
 * Run a reflection pass against the given prompt.
 *
 * Provider-agnostic: routes through @singularity-forge/ai's completeSimple,
 * which dispatches via the same provider registry SF uses for every other
 * LLM call (anthropic, openai, google-gemini-cli, openai-codex-responses,
 * mistral, etc.). The model is chosen by the operator's --model flag
 * (which arrives via options.model) and resolved against the @singularity-
 * forge/ai MODELS catalog. No subprocess, no hardcoded provider in the
 * call path.
 *
 * options.model — "provider/modelId" string (e.g. "google-gemini-cli/
 * gemini-3-pro-preview", "anthropic/claude-sonnet-4-6"). Defaults to
 * DEFAULT_REFLECTION_MODEL if unset.
 *
 * options.complete — dependency injection for tests. When supplied, it is
 * called instead of the imported completeSimple, with the same (model,
 * context, options) signature.
 *
 * Returns { ok, content?, cleanFinish?, error? }. Best-effort; never throws.
 *
 * Consumer: headless-reflect operator surface (--run flag).
 */
export async function runReflection(prompt, options = {}) {
	const modelString = options.model ?? DEFAULT_REFLECTION_MODEL;
	const timeoutMs = options.timeoutMs ?? 8 * 60 * 1000;

	let model;
	try {
		model = await resolveModel(modelString);
	} catch (err) {
		return {
			ok: false,
			error: `failed to load model catalog: ${err instanceof Error ? err.message : String(err)}`,
		};
	}
	if (!model) {
		return {
			ok: false,
			error: `unknown model "${modelString}" — expected "provider/modelId" with a model registered in @singularity-forge/ai MODELS`,
		};
	}

	// completeSimple takes a Context (systemPrompt + messages). The rendered
	// reflection template already contains both the system-style contract
	// (working-directory + reasoning protocol + output contract) and the
	// corpus brief, so we send it as a single user message rather than
	// splitting it — the prompt template is designed as one self-contained
	// brief for the LLM.
	const context = {
		systemPrompt: undefined,
		messages: [
			{
				role: "user",
				content: [{ type: "text", text: prompt }],
				timestamp: Date.now(),
			},
		],
	};

	const completeFn =
		options.complete ??
		(await (async () => {
			const ai = await import("@singularity-forge/ai");
			return ai.completeSimple;
		})());

	const callPromise = (async () => {
		try {
			const message = await completeFn(model, context, {});
			const content = extractAssistantText(message);
			return {
				ok: true,
				content,
				cleanFinish: content.includes(REFLECTION_TERMINATOR),
				provider: model.provider,
				modelId: model.id,
			};
		} catch (err) {
			return {
				ok: false,
				error: `provider call failed: ${err instanceof Error ? err.message : String(err)}`,
				provider: model.provider,
				modelId: model.id,
			};
		}
	})();

	const timeoutPromise = new Promise((resolve) => {
		setTimeout(() => {
			resolve({
				ok: false,
				error: `reflection call timed out after ${timeoutMs}ms`,
				provider: model.provider,
				modelId: model.id,
			});
		}, timeoutMs);
	});

	return Promise.race([callPromise, timeoutPromise]);
}

// Back-compat alias: callers wired against the old spawn-based name still
// work, but they pick up the new provider-agnostic implementation. New code
// should use runReflection directly.
export const runGeminiReflection = runReflection;
