/**
 * Post-unit file change validator for autonomous mode safety harness.
 * Compares actual file changes against the task plan's expected output files.
 *
 * Uses tasks.expected_output (DB column, populated from per-task ## Expected Output)
 * and tasks.files (from slice PLAN.md - Files: subline) as the expected set.
 * Compares against `git diff-tree --root --no-commit-id -r --name-only HEAD` after auto-commit.
 * Using diff-tree --root handles initial commits, shallow clones, and merge commits correctly
 * (Bug — git diff HEAD~1 failed on initial commits). Deferred-commit
 * flows can instead validate the staged diff before the commit is created.
 *
 * Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
 */
import { execFileSync } from "node:child_process";
import { normalizePlannedFileReference } from "../files.js";
import { logWarning } from "../workflow-logger.js";
// ─── Public API ─────────────────────────────────────────────────────────────
/**
 * Validate file changes after auto-commit for an execute-task unit.
 * Returns null if task data is unavailable or DB is not loaded.
 *
 * @param basePath - Working directory (worktree or project root)
 * @param expectedOutput - JSON array from tasks.expected_output DB column
 * @param plannedFiles - JSON array from tasks.files DB column
 */
export function validateFileChanges(
	basePath,
	expectedOutput,
	plannedFiles,
	options = {},
) {
	const allExpected = new Set([...expectedOutput, ...plannedFiles]);
	// If no expected files were planned, skip validation
	if (allExpected.size === 0) return null;
	const source = options.source ?? "last-commit";
	const actualFiles =
		source === "staged"
			? getChangedFilesFromStagedDiff(basePath)
			: getChangedFilesFromLastCommit(basePath);
	if (!actualFiles) return null;
	// Filter out .sf/ internal files — only validate project source files
	const projectFiles = actualFiles.filter(
		(f) => !f.startsWith(".sf/") && !f.startsWith(".sf\\"),
	);
	const baselineFiles = new Set(
		(options.baselineFiles ?? []).map(normalizeProjectPath),
	);
	const validationFiles = projectFiles
		.map(normalizeProjectPath)
		.filter((f) => !baselineFiles.has(f));
	const ignoredBaselineFiles = projectFiles
		.map(normalizeProjectPath)
		.filter((f) => baselineFiles.has(f));
	// Normalize expected paths (strip leading ./ or /)
	const normalizedExpected = new Set(
		[...allExpected].map((f) =>
			normalizeProjectPath(normalizePlannedFileReference(f)),
		),
	);
	// Compute symmetric difference
	const unexpectedFiles = validationFiles.filter(
		(f) => !normalizedExpected.has(f),
	);
	const missingFiles = [...normalizedExpected].filter(
		(f) => !validationFiles.includes(f),
	);
	const violations = [];
	for (const f of unexpectedFiles) {
		violations.push({
			severity: "warning",
			file: f,
			reason: "Modified but not in task plan's expected output",
		});
	}
	for (const f of missingFiles) {
		violations.push({
			severity: "info",
			file: f,
			reason: "Listed in task plan but not modified",
		});
	}
	return {
		expectedFiles: [...normalizedExpected],
		actualFiles: validationFiles,
		unexpectedFiles,
		missingFiles,
		ignoredBaselineFiles,
		violations,
	};
}
/**
 * Capture the dirty-file baseline at unit start. Post-unit validation uses this
 * to avoid warning on files that were already dirty before the task ran.
 */
export function getDirtyFiles(basePath) {
	try {
		const result = execFileSync(
			"git",
			["status", "--porcelain=v1", "--untracked-files=all"],
			{ cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
		).trim();
		if (!result) return [];
		return result
			.split("\n")
			.map((line) => line.slice(3).trim())
			.map((file) => {
				const renamed = file.split(" -> ");
				return renamed[renamed.length - 1] ?? file;
			})
			.filter(Boolean)
			.map(normalizeProjectPath);
	} catch (e) {
		logWarning(
			"safety",
			`git status failed in file-change-validator: ${e.message}`,
		);
		return [];
	}
}
/**
 * Check staged files for .sf/ paths and emit a high-severity warning if any are found.
 * This is the last-line-of-defense layer — it catches .sf/ paths that somehow
 * reached the git staging area despite the nativeAddPaths and stageExplicitIncludePaths
 * barriers. It fires via the pre-commit hook in auto-post-unit.js.
 *
 * Returns { hasSfPaths, sfPaths } where sfPaths is the list of staged .sf/ paths.
 */
export function validateStagedFileChanges(basePath, _stagedPathsOverride) {
	const stagedPaths =
		_stagedPathsOverride !== undefined
			? _stagedPathsOverride
			: getChangedFilesFromStagedDiff(basePath);
	if (!stagedPaths) return { hasSfPaths: false, sfPaths: [] };
	const sfPaths = stagedPaths.filter(
		(p) => p.replace(/\\/g, "/").split("/")[0] === ".sf",
	);
	if (sfPaths.length > 0) {
		logWarning(
			"safety",
			`High severity: staged path(s) with '.sf/' first segment detected: ${sfPaths.join(", ")}. ` +
				`SF planning state must not be committed to the repo. Unstage with: git restore --staged .sf/`,
		);
	}
	return { hasSfPaths: sfPaths.length > 0, sfPaths };
}
// ─── Internals ──────────────────────────────────────────────────────────────
function normalizeProjectPath(file) {
	return file.replace(/^\.\//, "").replace(/^\//, "");
}
function getChangedFilesFromLastCommit(basePath) {
	try {
		const result = execFileSync(
			"git",
			["diff-tree", "--root", "--no-commit-id", "-r", "--name-only", "HEAD"],
			{ cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
		).trim();
		return result ? result.split("\n").filter(Boolean) : [];
	} catch (e) {
		logWarning(
			"safety",
			`git diff failed in file-change-validator: ${e.message}`,
		);
		return null;
	}
}
function getChangedFilesFromStagedDiff(basePath) {
	try {
		const result = execFileSync("git", ["diff", "--name-only", "--cached"], {
			cwd: basePath,
			stdio: ["ignore", "pipe", "pipe"],
			encoding: "utf-8",
		}).trim();
		return result
			? result.split("\n").filter(Boolean).map(normalizeProjectPath)
			: [];
	} catch (e) {
		logWarning(
			"safety",
			`git staged diff failed in file-change-validator: ${e.message}`,
		);
		return null;
	}
}
