// SF Extension — Verification Gate
// Pure functions for discovering and running verification commands.
// Discovery order (D003): preference → task plan verify → package.json scripts.
// First non-empty source wins.
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { rewriteCommandWithRtk } from "../shared/rtk.js";
import { DEFAULT_COMMAND_TIMEOUT_MS } from "./constants.js";

/** Maximum bytes of stdout/stderr to retain per command (10 KB). */
const MAX_OUTPUT_BYTES = 10 * 1024;
/** Truncate a string to maxBytes, appending a marker if truncated. */
function truncate(value, maxBytes) {
	if (!value) return "";
	if (Buffer.byteLength(value, "utf-8") <= maxBytes) return value;
	// Slice conservatively then trim to last full character
	const buf = Buffer.from(value, "utf-8").subarray(0, maxBytes);
	return buf.toString("utf-8") + "\n…[truncated]";
}
/** Package.json script keys to probe, in order. */
const PACKAGE_SCRIPT_KEYS = ["typecheck", "lint", "test"];

function extractForLoopCdDirs(command) {
	const match = command.match(
		/for\s+d\s+in\s+(.+?);\s*do\s*\(\s*cd\s+["']?\$d["']?/s,
	);
	if (!match) return null;
	const segment = match[1];
	const dirs = [];
	const quoted = segment.matchAll(/"([^"]+)"|'([^']+)'/g);
	for (const m of quoted) {
		const value = (m[1] ?? m[2] ?? "").trim();
		if (value) dirs.push(value);
	}
	return dirs.length > 0 ? dirs : null;
}

/**
 * Return true when a verification command obviously belongs to another repo.
 *
 * Purpose: prevent DB/runtime preferences copied from a different checkout from
 * blocking autonomous mode with irrelevant commands. Commands that enumerate
 * directories and then `cd "$d"` are repo-specific; if any listed directory is
 * absent in the active project, the whole command set is stale and discovery
 * should fall through to project-local signals.
 *
 * Consumer: discoverCommands before preference/task-plan commands win.
 */
export function hasMissingProjectPathReferences(cwd, command) {
	const dirs = extractForLoopCdDirs(command);
	if (!dirs) return false;
	return dirs.some((dir) => !existsSync(join(cwd, dir)));
}

function isBroadMultiProjectCargoCommand(command) {
	const dirs = extractForLoopCdDirs(command);
	if (!dirs || dirs.length < 2) return false;
	return /\bcargo\s+(fmt|check|test|clippy)\b/.test(command);
}

function hasMakefileVerifyTarget(cwd) {
	const makefilePath = join(cwd, "Makefile");
	if (!existsSync(makefilePath)) return false;
	try {
		return /^verify[-\w]*:/m.test(readFileSync(makefilePath, "utf-8"));
	} catch {
		return false;
	}
}

function hasProjectLocalVerificationSignal(cwd, taskPlanVerify) {
	if (taskPlanVerify?.trim()) return true;
	if (hasMakefileVerifyTarget(cwd)) return true;
	return (
		existsSync(join(cwd, "package.json")) ||
		existsSync(join(cwd, "pyproject.toml"))
	);
}

function looksLikeStalePreferenceSet(cwd, commands, taskPlanVerify) {
	if (!hasProjectLocalVerificationSignal(cwd, taskPlanVerify)) return false;
	return commands.some((cmd) => isBroadMultiProjectCargoCommand(cmd));
}

function normalizeConfiguredCommands(cwd, commands, taskPlanVerify = "") {
	const filtered = (commands ?? []).map((c) => c.trim()).filter(Boolean);
	if (filtered.length === 0) return { commands: [], stale: false };
	const stale =
		filtered.some((cmd) => hasMissingProjectPathReferences(cwd, cmd)) ||
		looksLikeStalePreferenceSet(cwd, filtered, taskPlanVerify);
	return { commands: stale ? [] : filtered, stale };
}

function discoverPackageJsonCommands(cwd) {
	const pkgPath = join(cwd, "package.json");
	if (!existsSync(pkgPath)) return [];
	try {
		const raw = readFileSync(pkgPath, "utf-8");
		const pkg = JSON.parse(raw);
		if (
			pkg &&
			typeof pkg === "object" &&
			pkg.scripts &&
			typeof pkg.scripts === "object"
		) {
			const commands = [];
			for (const key of PACKAGE_SCRIPT_KEYS) {
				if (typeof pkg.scripts[key] === "string") {
					commands.push(`npm run ${key}`);
				}
			}
			return commands;
		}
	} catch {
		// Malformed package.json — fall through.
	}
	return [];
}

function discoverPythonCommands(cwd) {
	if (!existsSync(join(cwd, "pyproject.toml"))) return [];
	return ["python -m pytest -q"];
}

/**
 * Discover verification commands using the first-non-empty-wins strategy (D003):
 *   1. Explicit preference commands
 *   2. Task plan verify field (split on &&)
 *   3. package.json scripts (typecheck, lint, test)
 *   4. None found
 */
export function discoverCommands(options) {
	// 1. Preference commands
	if (options.preferenceCommands && options.preferenceCommands.length > 0) {
		const configured = normalizeConfiguredCommands(
			options.cwd,
			options.preferenceCommands,
			options.taskPlanVerify,
		);
		if (configured.commands.length > 0) {
			return { commands: configured.commands, source: "preference" };
		}
	}
	// 2. Task plan verify field (commands are untrusted — sanitize)
	if (options.taskPlanVerify && options.taskPlanVerify.trim()) {
		const rawCommands = options.taskPlanVerify
			.split("&&")
			.map((c) => c.trim())
			.filter(Boolean);
		const configured = normalizeConfiguredCommands(options.cwd, rawCommands);
		const commands = configured.commands.filter(
			(c) => sanitizeCommand(c) !== null,
		);
		if (commands.length > 0) {
			return { commands, source: "task-plan" };
		}
	}
	// 3. package.json scripts
	const packageCommands = discoverPackageJsonCommands(options.cwd);
	if (packageCommands.length > 0) {
		return { commands: packageCommands, source: "package-json" };
	}
	const pythonCommands = discoverPythonCommands(options.cwd);
	if (pythonCommands.length > 0) {
		return { commands: pythonCommands, source: "python-project" };
	}
	// 4. Nothing found
	return { commands: [], source: "none" };
}
// ─── Failure Context Formatting ──────────────────────────────────────────────
/** Maximum chars of stderr to include per failed check in failure context. */
const MAX_STDERR_PER_CHECK = 2_000;
/** Maximum total chars for the combined failure context output. */
const MAX_FAILURE_CONTEXT_CHARS = 10_000;
/**
 * Format failed verification checks into a prompt-injectable text block.
 *
 * Each failed check gets a heading with the command name and exit code,
 * followed by a truncated stderr excerpt. Individual stderr is capped to
 * 2 000 chars; total output is capped to 10 000 chars.
 *
 * Returns an empty string when all checks pass or the checks array is empty.
 */
export function formatFailureContext(result) {
	const failures = result.checks.filter((c) => c.exitCode !== 0);
	if (failures.length === 0) return "";
	const blocks = [];
	// Give each failing check a fair share of the total budget so that
	// diagnostics from later checks are not silently cut when the first
	// check alone would exceed MAX_FAILURE_CONTEXT_CHARS.
	const perCheckBudget = Math.floor(
		MAX_FAILURE_CONTEXT_CHARS / failures.length,
	);
	for (const check of failures) {
		let stderr = check.stderr ?? "";
		const cap = Math.min(MAX_STDERR_PER_CHECK, perCheckBudget);
		if (stderr.length > cap) {
			stderr = stderr.slice(0, cap) + "\n…[truncated]";
		}
		blocks.push(
			`### ❌ \`${check.command}\` (exit code ${check.exitCode})\n\`\`\`stderr\n${stderr}\n\`\`\``,
		);
	}
	const body = blocks.join("\n\n");
	const header = "## Verification Failures\n\n";
	return header + body;
}
// ─── Gate Execution ─────────────────────────────────────────────────────────
/** Characters that indicate shell injection when found in a command string. */
const SHELL_INJECTION_PATTERN = /[;|`]|\$\(/;
/**
 * Known executable first-tokens that are safe to run.
 * Lowercase commands, common build/test tools, and npm/yarn/pnpm invocations.
 */
const KNOWN_COMMAND_PREFIXES = new Set([
	"npm",
	"npx",
	"yarn",
	"pnpm",
	"deno",
	"node",
	"ts-node",
	"tsx",
	"tsc",
	"sh",
	"bash",
	"zsh",
	"echo",
	"cat",
	"ls",
	"test",
	"true",
	"false",
	"pwd",
	"env",
	"make",
	"cargo",
	"go",
	"python",
	"python3",
	"pip",
	"pip3",
	"ruby",
	"gem",
	"bundle",
	"rake",
	"java",
	"javac",
	"mvn",
	"gradle",
	"docker",
	"docker-compose",
	"git",
	"gh",
	"eslint",
	"prettier",
	"vitest",
	"jest",
	"mocha",
	"pytest",
	"phpunit",
	"curl",
	"wget",
	"grep",
	"find",
	"diff",
	"wc",
	"sort",
	"head",
	"tail",
]);
/**
 * Heuristic check: does this string look like an executable shell command
 * rather than a prose description?
 *
 * Returns true when the string appears to be a command. Returns false
 * for English prose (e.g. "Document exists, contains all 5 scale names").
 *
 * Heuristics (any true → command-like):
 *   1. First token is a known command prefix
 *   2. First token starts with `.` or `/` (path-like)
 *   3. Any token starts with `-` (flag-like)
 *   4. First token contains no uppercase letters (commands are lowercase)
 *      AND first token does not end with a comma or colon (prose punctuation)
 *
 * Heuristics (any true → prose-like):
 *   1. First token starts with an uppercase letter and the string has 4+ words
 *   2. String contains commas followed by spaces (prose clause structure)
 *   3. First token is an English prose article/conjunction (a, an, the, …)
 *      and the string has 2 or more words — short prose fragments otherwise
 *      look like commands (e.g. "the verify step").
 *   4. String has fewer than 2 tokens AND the single token is not a known
 *      command prefix and does not start with a path character — single
 *      non-command words are prose, not commands.
 *   5. First token is a single lowercase letter (a, i) followed by 2+ words —
 *      short prose sentences like "a quick test" or "it works now".
 */
export function isLikelyCommand(cmd) {
	const trimmed = cmd.trim();
	if (!trimmed) return false;
	const tokens = trimmed.split(/\s+/);
	const firstToken = tokens[0];
	// Known command prefix → definitely a command
	if (KNOWN_COMMAND_PREFIXES.has(firstToken)) return true;
	// Path-like first token → command
	if (
		firstToken.startsWith("/") ||
		firstToken.startsWith("./") ||
		firstToken.startsWith("../")
	)
		return true;
	// Has flag-like tokens → command
	if (tokens.some((t) => t.startsWith("-"))) return true;
	// Prose-article first token with 2+ words → prose
	const PROSE_ARTICLES = new Set([
		"a",
		"an",
		"the",
		"this",
		"that",
		"these",
		"those",
		"it",
		"its",
	]);
	if (PROSE_ARTICLES.has(firstToken.toLowerCase()) && tokens.length >= 2)
		return false;
	// Single-letter first token (a, i) followed by 2+ words → prose
	// e.g. "a quick test", "it works now"
	if (/^[a-z]$/.test(firstToken.toLowerCase()) && tokens.length >= 3)
		return false;
	// String has fewer than 2 tokens AND first token is not a known command
	// prefix AND does not start with a path character → prose
	if (
		tokens.length < 2 &&
		!KNOWN_COMMAND_PREFIXES.has(firstToken) &&
		!firstToken.startsWith("/") &&
		!firstToken.startsWith("./") &&
		!firstToken.startsWith("../")
	)
		return false;
	// Single token that is not a known command prefix or path: treat as prose
	// only when it is a plain English-looking word (all alphabetic, no hyphens
	// or underscores) with an uppercase first letter — e.g. "Document",
	// "Build", "Verify". Single lowercase tokens (e.g. "custom-verify",
	// "mycheck") look like custom scripts and are kept as command-like.
	if (tokens.length === 1 && /^[A-Z][a-zA-Z]+$/.test(firstToken)) return false;
	// First token starts with uppercase + 4 or more words → prose
	if (/^[A-Z]/.test(firstToken) && tokens.length >= 4) return false;
	// Contains comma-space patterns (prose clause separators) → prose
	if (/,\s/.test(trimmed) && tokens.length >= 4) return false;
	// First token has uppercase letters and no path separators → prose
	if (/[A-Z]/.test(firstToken) && !firstToken.includes("/")) return false;
	return true;
}
/**
 * Validate a command string for obvious shell injection patterns.
 * Returns the command unchanged if safe, or null if suspicious.
 */
function sanitizeCommand(cmd) {
	if (SHELL_INJECTION_PATTERN.test(cmd)) return null;
	if (!isLikelyCommand(cmd)) return null;
	return cmd;
}
/**
 * Run the verification gate: discover commands, execute each via spawnSync,
 * and return a structured result.
 *
 * - All commands run sequentially regardless of individual pass/fail.
 * - `passed` is true when every command exits 0; `skipped` is true when no commands are discovered.
 * - stdout/stderr per command are truncated to 10 KB.
 */
export function runVerificationGate(options) {
	const timestamp = Date.now();
	const { commands, source } = discoverCommands({
		preferenceCommands: options.preferenceCommands,
		taskPlanVerify: options.taskPlanVerify,
		cwd: options.cwd,
	});
	if (commands.length === 0) {
		return {
			passed: false,
			skipped: true,
			checks: [],
			discoverySource: source,
			timestamp,
		};
	}
	const checks = [];
	for (const command of commands) {
		const start = Date.now();
		const rewrittenCommand = rewriteCommandWithRtk(command);
		// Pass the command string as an argument to the shell explicitly
		// to avoid Node.js DEP0190 (spawnSync with shell: true and no args).
		const shellBin = process.platform === "win32" ? "cmd" : "sh";
		const shellArgs =
			process.platform === "win32"
				? ["/c", rewrittenCommand]
				: ["-c", rewrittenCommand];
		const result = spawnSync(shellBin, shellArgs, {
			cwd: options.cwd,
			stdio: "pipe",
			encoding: "utf-8",
			timeout: options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
			// SIGKILL ensures the shell wrapper exits promptly when the timeout
			// fires — SIGTERM (the default) can be caught/ignored by npm/node
			// grandchildren, leaving spawnSync blocked well past the deadline.
			killSignal: "SIGKILL",
		});
		const durationMs = Date.now() - start;
		let exitCode;
		let stderr;
		if (result.error && result.status === null) {
			// Command not found or spawn failure before the child produced a status.
			exitCode = 127;
			stderr = truncate(
				(result.stderr || "") + "\n" + result.error.message,
				MAX_OUTPUT_BYTES,
			);
		} else {
			// Some sandboxed runtimes report an EPERM error even when the command
			// executed and returned a status. Prefer the observed child status.
			exitCode = result.status ?? 1;
			stderr = truncate(result.stderr, MAX_OUTPUT_BYTES);
		}
		checks.push({
			command,
			exitCode,
			stdout: truncate(result.stdout, MAX_OUTPUT_BYTES),
			stderr,
			durationMs,
		});
	}
	return {
		passed: checks.every((c) => c.exitCode === 0),
		checks,
		discoverySource: source,
		timestamp,
	};
}
// ─── Runtime Error Capture ──────────────────────────────────────────────────
/** Maximum characters of browser console text to retain per entry. */
const MAX_BROWSER_TEXT_CHARS = 500;
/** Fatal signals that indicate a crash regardless of other status fields. */
const FATAL_SIGNALS = new Set(["SIGABRT", "SIGSEGV", "SIGBUS"]);
/**
 * Scan bg-shell processes and browser console logs for runtime errors.
 *
 * Severity classification follows D004:
 *   - bg-shell status "crashed" → blocking crash
 *   - bg-shell !alive && exitCode !== 0 && exitCode !== null → blocking crash
 *   - bg-shell signal SIGABRT/SIGSEGV/SIGBUS → blocking crash
 *   - Browser console error with "Unhandled"/"UnhandledRejection" → blocking crash
 *   - Browser console error (general) → non-blocking error
 *   - Browser console warning with deprecation text → non-blocking warning
 *   - bg-shell alive process with recentErrors → non-blocking error
 *
 * Returns RuntimeError[] — empty when both sources are unavailable.
 */
export async function captureRuntimeErrors(options) {
	const errors = [];
	// ── bg-shell scan ─────────────────────────────────────────────────────
	try {
		let processes;
		if (options?.getProcesses) {
			processes = options.getProcesses();
		} else {
			const mod = await import("../bg-shell/process-manager.js");
			processes = mod.processes;
		}
		for (const [id, raw] of processes) {
			const proc = raw;
			const name = proc.label || proc.id || id;
			// Check for fatal signal first (applies regardless of alive/status)
			if (proc.signal && FATAL_SIGNALS.has(proc.signal)) {
				errors.push({
					source: "bg-shell",
					severity: "crash",
					message: buildBgShellMessage(
						name,
						proc.exitCode,
						proc.signal,
						proc.recentErrors,
					),
					blocking: true,
				});
				continue;
			}
			// Crashed status
			if (proc.status === "crashed") {
				errors.push({
					source: "bg-shell",
					severity: "crash",
					message: buildBgShellMessage(
						name,
						proc.exitCode,
						proc.signal,
						proc.recentErrors,
					),
					blocking: true,
				});
				continue;
			}
			// Non-zero exit on dead process
			if (
				!proc.alive &&
				proc.exitCode !== 0 &&
				proc.exitCode !== null &&
				proc.exitCode !== undefined
			) {
				errors.push({
					source: "bg-shell",
					severity: "crash",
					message: buildBgShellMessage(
						name,
						proc.exitCode,
						proc.signal,
						proc.recentErrors,
					),
					blocking: true,
				});
				continue;
			}
			// Alive process with recent errors — non-blocking
			if (proc.alive && proc.recentErrors && proc.recentErrors.length > 0) {
				const snippet = proc.recentErrors.slice(0, 3).join("; ");
				errors.push({
					source: "bg-shell",
					severity: "error",
					message: `[${name}] recent errors: ${snippet}`,
					blocking: false,
				});
			}
		}
	} catch {
		// bg-shell not available — skip silently
	}
	// ── browser console scan ──────────────────────────────────────────────
	try {
		let logs;
		if (options?.getConsoleLogs) {
			logs = options.getConsoleLogs();
		} else {
			const mod = await import("../browser-tools/state.js");
			logs = mod.getConsoleLogs();
		}
		for (const entry of logs) {
			const text =
				entry.text.length > MAX_BROWSER_TEXT_CHARS
					? entry.text.slice(0, MAX_BROWSER_TEXT_CHARS) + "…[truncated]"
					: entry.text;
			if (entry.type === "error") {
				// Unhandled rejection / unhandled error → blocking crash
				if (/unhandled/i.test(entry.text)) {
					errors.push({
						source: "browser",
						severity: "crash",
						message: text,
						blocking: true,
					});
				} else {
					// General console.error → non-blocking error
					errors.push({
						source: "browser",
						severity: "error",
						message: text,
						blocking: false,
					});
				}
			} else if (entry.type === "warning" && /deprecated/i.test(entry.text)) {
				// Deprecation warning → non-blocking warning
				errors.push({
					source: "browser",
					severity: "warning",
					message: text,
					blocking: false,
				});
			}
			// Non-deprecation warnings are intentionally ignored
		}
	} catch {
		// browser-tools not available — skip silently
	}
	return errors;
}
/** Build a human-readable message for a bg-shell process error. */
function buildBgShellMessage(name, exitCode, signal, recentErrors) {
	const parts = [`[${name}]`];
	if (signal) parts.push(`signal=${signal}`);
	if (exitCode !== null && exitCode !== undefined)
		parts.push(`exitCode=${exitCode}`);
	if (recentErrors && recentErrors.length > 0) {
		const snippet = recentErrors.slice(0, 3).join("; ");
		parts.push(`errors: ${snippet}`);
	}
	return parts.join(" ");
}
// ─── Dependency Audit ───────────────────────────────────────────────────────
/** Top-level dependency files that trigger an audit when changed. */
const DEPENDENCY_FILES = new Set([
	"package.json",
	"package-lock.json",
	"pnpm-lock.yaml",
	"yarn.lock",
	"bun.lockb",
]);
/**
 * Default gitDiff: runs `git diff --name-only HEAD` and returns file paths.
 * Returns empty array on any failure (non-git dir, git not found, etc.).
 */
function defaultGitDiff(cwd) {
	try {
		const result = spawnSync("git", ["diff", "--name-only", "HEAD"], {
			cwd,
			encoding: "utf-8",
			timeout: 10_000,
		});
		if (result.status !== 0 || !result.stdout) return [];
		return result.stdout.trim().split("\n").filter(Boolean);
	} catch {
		return [];
	}
}
/**
 * Default npmAudit: runs `npm audit --audit-level=moderate --json`.
 * Returns { stdout, exitCode }. Non-zero exit is expected when vulnerabilities exist.
 */
function defaultNpmAudit(cwd) {
	const result = spawnSync(
		"npm",
		["audit", "--audit-level=moderate", "--json"],
		{
			cwd,
			encoding: "utf-8",
			timeout: 60_000,
		},
	);
	return {
		stdout: result.stdout ?? "",
		exitCode: result.status ?? 1,
	};
}
/**
 * Detect dependency file changes and run npm audit if changes are found.
 *
 * - Calls gitDiff to get changed files, checks if any are top-level dependency files
 * - If no dependency files changed, returns []
 * - Runs npmAudit and parses JSON output into AuditWarning[]
 * - Never throws — all errors return []
 * - Non-zero npm audit exit code is expected (vulnerabilities found), not an error
 */
export function runDependencyAudit(cwd, options) {
	try {
		const gitDiff = options?.gitDiff ?? defaultGitDiff;
		const npmAudit = options?.npmAudit ?? defaultNpmAudit;
		// Get changed files and check for top-level dependency file matches
		const changedFiles = gitDiff(cwd);
		const hasDependencyChange = changedFiles.some((filePath) => {
			const name = basename(filePath);
			// Only match top-level files: the path must equal just the filename
			// (no directory separators) to be considered top-level
			return DEPENDENCY_FILES.has(name) && filePath === name;
		});
		if (!hasDependencyChange) return [];
		// Run npm audit
		const auditResult = npmAudit(cwd);
		// Parse JSON output — npm audit exits non-zero when vulnerabilities exist
		let parsed;
		try {
			parsed = JSON.parse(auditResult.stdout);
		} catch {
			return [];
		}
		// Extract vulnerabilities from the parsed output
		const vulnerabilities = parsed.vulnerabilities;
		if (!vulnerabilities || typeof vulnerabilities !== "object") return [];
		const warnings = [];
		for (const [name, raw] of Object.entries(vulnerabilities)) {
			const vuln = raw;
			if (!vuln || typeof vuln !== "object") continue;
			const severity = vuln.severity;
			if (
				severity !== "low" &&
				severity !== "moderate" &&
				severity !== "high" &&
				severity !== "critical"
			) {
				continue;
			}
			// Find the first `via` entry that's an object (not a string reference)
			let title = name;
			let url = "";
			if (Array.isArray(vuln.via)) {
				for (const entry of vuln.via) {
					if (entry && typeof entry === "object" && !Array.isArray(entry)) {
						const obj = entry;
						if (obj.title) title = obj.title;
						if (obj.url) url = obj.url;
						break;
					}
				}
			}
			warnings.push({
				name,
				severity: severity,
				title,
				url,
				fixAvailable: vuln.fixAvailable === true,
			});
		}
		return warnings;
	} catch {
		return [];
	}
}
