/**
 * headless-uok-status.ts — `sf headless status uok`
 *
 * Purpose: expose UOK gate health (recent outcomes, circuit breaker states,
 * failure streaks) via the headless CLI so operators can inspect autonomous
 * reliability without digging through DB or log files.
 *
 * Consumer: headless.ts when command === "status" && commandArgs[0] === "uok"
 */

import { existsSync } from "node:fs";
import { join } from "node:path";
import { createJiti } from "@mariozechner/jiti";
import { resolveBundledSourceResource } from "./bundled-resource-path.js";
import { getSfEnv } from "./env.js";

const jiti = createJiti(import.meta.filename, {
	interopDefault: true,
	debug: false,
});

const agentExtensionsDir = join(getSfEnv().agentDir, "extensions", "sf");
const useAgentDir = existsSync(join(agentExtensionsDir, "state.js"));

function sfExtensionPath(moduleName: string): string {
	if (useAgentDir) return join(agentExtensionsDir, `${moduleName}.js`);
	const tsPath = resolveBundledSourceResource(
		import.meta.url,
		"extensions",
		"sf",
		`${moduleName}.ts`,
	);
	if (existsSync(tsPath)) return tsPath;
	return resolveBundledSourceResource(
		import.meta.url,
		"extensions",
		"sf",
		`${moduleName}.js`,
	);
}

/**
 * Coverage classification for a gate in the status uok view.
 *
 * Slice 1 (UOK control-plane plan, 2026-05-14) introduces this field so
 * operators can distinguish gates that should be paying attention to from
 * those that don't yet have the UOK run context. Each value's contract:
 *
 *   - "ok"         Gate has schema-v2 run context AND recent runs in the
 *                  window. Healthy.
 *   - "stale"      Gate has prior runs but nothing in the last 24h.
 *                  Suggests something stopped exercising it.
 *   - "incomplete" Gate has schema-v2 records but is missing required
 *                  run-context fields (surface / runControl / permissionProfile /
 *                  traceId). Used when future slices start writing
 *                  schema-v2 rows; never assigned to legacy rows.
 *   - "missing"   Gate is configured/expected but has zero recent runs.
 *                  Requires a configured-gate registry to detect; future
 *                  slice work, not slice 1.
 *   - "legacy"     Gate row predates schema-v2 run context. NOT a warning —
 *                  operators are not paged for the rich history of pre-v2
 *                  records. Future slices migrate these as the writer
 *                  paths emit complete run context.
 *
 * Slice 1 only populates "ok" / "stale" / "legacy". "incomplete" and
 * "missing" wait for the schema-v2 writer adapter (slice 2) and the
 * configured-gate registry (later).
 */
export type GateCoverageStatus =
	| "ok"
	| "stale"
	| "incomplete"
	| "missing"
	| "legacy";

export interface GateHealthEntry {
	id: string;
	scope: string;
	total: number;
	pass: number;
	fail: number;
	/**
	 * Manual-attention outcomes (codex audit follow-up). Previously
	 * counted by getGateRunStats but dropped from the JSON/table output,
	 * which inflated `total` without an operator-visible bucket. Now
	 * surfaced as its own column so manual-attention gates are
	 * distinguishable from pass/fail in the at-a-glance view.
	 */
	manualAttention: number;
	retry: number;
	lastEvaluatedAt: string | null;
	circuitBreaker: string;
	failureStreak: number;
	/** Coverage classification (schema v2+). */
	coverageStatus: GateCoverageStatus;
}

export interface UokStatusResult {
	exitCode: number;
	gates: GateHealthEntry[];
}

/**
 * A row is "legacy" when it lacks the schema-v2 UOK run context that the writer
 * adapter (slice 2 of the UOK control-plane plan) populates. Surface is
 * the canonical indicator: NULL → legacy, set → schema-v2 row that
 * should be classified ok/stale/incomplete based on the other fields.
 *
 * Schema v66 added the columns (surface, run_control, permission_profile,
 * trace_id, parent_trace); pre-v66 rows have NULL for all of them.
 *
 * "incomplete" fires when surface is set but one of run_control,
 * permission_profile, or trace_id is missing — caller didn't populate the
 * full required set, and the classifier surfaces that so operators can
 * find the buggy writer.
 */
interface GateRunContextRow {
	surface: string | null;
	runControl: string | null;
	permissionProfile: string | null;
	traceId: string | null;
}

function hasSchemaV2RunContext(context: GateRunContextRow): boolean {
	return typeof context.surface === "string" && context.surface.length > 0;
}

function isSchemaV2RunContextComplete(context: GateRunContextRow): boolean {
	return (
		typeof context.surface === "string" &&
		context.surface.length > 0 &&
		typeof context.runControl === "string" &&
		context.runControl.length > 0 &&
		typeof context.permissionProfile === "string" &&
		context.permissionProfile.length > 0 &&
		typeof context.traceId === "string" &&
		context.traceId.length > 0
	);
}

const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;

function classifyCoverage(
	entry: Omit<GateHealthEntry, "coverageStatus">,
	context: GateRunContextRow,
): GateCoverageStatus {
	if (!hasSchemaV2RunContext(context)) return "legacy";
	if (!isSchemaV2RunContextComplete(context)) return "incomplete";
	if (entry.total === 0) {
		// Has run context but no runs in window. If we ever saw a run, it's
		// stale; otherwise it's never run (caller will mark "missing" when
		// a configured-gate registry confirms it was expected). For slice
		// 1+2, no registry exists, so the safer default is "stale".
		return "stale";
	}
	const last = entry.lastEvaluatedAt ? Date.parse(entry.lastEvaluatedAt) : null;
	if (last !== null && Date.now() - last > STALE_THRESHOLD_MS) {
		return "stale";
	}
	return "ok";
}

function passRate(entry: GateHealthEntry): string {
	if (entry.total === 0) return "—";
	return `${Math.round((entry.pass / entry.total) * 100)}%`;
}

function cbIcon(state: string): string {
	if (state === "open") return "🔴";
	if (state === "half-open") return "🟡";
	return "🟢";
}

function coverageIcon(status: GateCoverageStatus): string {
	switch (status) {
		case "ok":
			return "✓ ok";
		case "stale":
			return "⚠ stale";
		case "incomplete":
			return "⚠ incomplete";
		case "missing":
			return "⚠ missing";
		case "legacy":
			return "· legacy";
	}
}

function formatTable(gates: GateHealthEntry[]): string {
	if (gates.length === 0) {
		return "No gate run data found in the last 24h.\n";
	}
	const header =
		"| Gate | Scope | Coverage | Pass% | Pass | Fail | Manual | Retry | CB | Streak | Last Evaluated |\n" +
		"|------|-------|----------|-------|------|------|--------|-------|----|--------|----------------|\n";
	const rows = gates
		.map((g) => {
			const last = g.lastEvaluatedAt
				? new Date(g.lastEvaluatedAt)
						.toISOString()
						.replace("T", " ")
						.slice(0, 19)
				: "never";
			return `| ${g.id} | ${g.scope} | ${coverageIcon(g.coverageStatus)} | ${passRate(g)} | ${g.pass} | ${g.fail} | ${g.manualAttention} | ${g.retry} | ${cbIcon(g.circuitBreaker)} ${g.circuitBreaker} | ${g.failureStreak} | ${last} |`;
		})
		.join("\n");
	return `${header}${rows}\n`;
}

export async function handleUokStatus(
	basePath: string,
	opts: { json?: boolean } = {},
): Promise<UokStatusResult> {
	let gates: GateHealthEntry[] = [];

	try {
		const autoStartModule = (await jiti.import(
			sfExtensionPath("auto-start"),
			{},
		)) as any;
		await autoStartModule.openProjectDbIfPresent(basePath);

		// Load sf-db-gates directly. (Earlier versions had a workaround
		// comment here about a "missing getDistinctGateIds import in
		// gate-runner.js"; that import exists today — see
		// gate-runner.js:5. Bypassing UokGateRunner.getHealthSummary is
		// still appropriate here because the headless status surface
		// wants raw gate-id discovery without requiring the registry to
		// be populated by a session.)
		const gatesDbModule = (await jiti.import(
			sfExtensionPath("sf-db/sf-db-gates"),
			{},
		)) as any;

		const gateIds: string[] = gatesDbModule.getDistinctGateIds();

		// Fetch scope, last-evaluated, and schema-v2 run context for each
		// gate. Slice 3a (UOK control-plane plan): run context can land in
		// trace events from headless flows that have no quality_gates
		// parent row (e.g. triage --apply gates, which are flow-scoped
		// rather than slice-scoped). Trace events win when present; the
		// quality_gates DB is consulted as a fallback for slice-scoped
		// rows that may have rotated out of the trace window.
		const sfDbModule = (await jiti.import(sfExtensionPath("sf-db"), {})) as any;
		const traceWriterModule = (await jiti.import(
			sfExtensionPath("uok/trace-writer"),
		)) as {
			readTraceEvents: (
				basePath: string,
				type: string,
				windowHours?: number,
			) => Array<Record<string, unknown>>;
		};
		// Read the gate-run trace events once and bucket by gateId so
		// per-gate getGateMeta calls are O(1) lookup instead of repeated
		// linear scans of every event.
		const traceEvents = (() => {
			try {
				return (
					traceWriterModule.readTraceEvents?.(basePath, "gate_run", 24 * 30) ??
					[]
				);
			} catch {
				return [];
			}
		})();
		const latestTraceContextByGate = new Map<
			string,
			{
				scope?: string;
				evaluatedAt?: string;
				surface?: string;
				runControl?: string;
				permissionProfile?: string;
				traceId?: string;
			}
		>();
		for (const ev of traceEvents) {
			const gateId =
				typeof ev.gateId === "string" ? (ev.gateId as string) : null;
			if (!gateId) continue;
			const evaluatedAt =
				typeof ev.evaluatedAt === "string"
					? (ev.evaluatedAt as string)
					: typeof ev.ts === "string"
						? (ev.ts as string)
						: undefined;
			const prev = latestTraceContextByGate.get(gateId);
			if (
				prev &&
				prev.evaluatedAt &&
				evaluatedAt &&
				prev.evaluatedAt >= evaluatedAt
			) {
				continue;
			}
			latestTraceContextByGate.set(gateId, {
				scope: typeof ev.scope === "string" ? (ev.scope as string) : undefined,
				evaluatedAt,
				surface:
					typeof ev.surface === "string" ? (ev.surface as string) : undefined,
				runControl:
					typeof ev.runControl === "string"
						? (ev.runControl as string)
						: undefined,
				permissionProfile:
					typeof ev.permissionProfile === "string"
						? (ev.permissionProfile as string)
						: undefined,
				traceId:
					typeof ev.traceId === "string" ? (ev.traceId as string) : undefined,
			});
		}
		interface GateRunContextQuery {
			scope: string;
			lastEvaluatedAt: string | null;
			surface: string | null;
			runControl: string | null;
			permissionProfile: string | null;
			traceId: string | null;
		}
		const getGateRunContext = (id: string): GateRunContextQuery => {
			const empty: GateRunContextQuery = {
				scope: "unknown",
				lastEvaluatedAt: null,
				surface: null,
				runControl: null,
				permissionProfile: null,
				traceId: null,
			};
			const trace = latestTraceContextByGate.get(id);
			let dbContext: GateRunContextQuery | null = null;
			try {
				const db = sfDbModule._getAdapter?.() ?? null;
				if (db) {
					const row = db
						.prepare(
							`SELECT scope, evaluated_at, surface, run_control,
							        permission_profile, trace_id
							 FROM quality_gates
							 WHERE gate_id = ?
							 ORDER BY evaluated_at IS NULL, evaluated_at DESC
							 LIMIT 1`,
						)
						.get(id);
					if (row) {
						dbContext = {
							scope: row.scope ?? "unknown",
							lastEvaluatedAt: row.evaluated_at ?? null,
							surface: row.surface ?? null,
							runControl: row.run_control ?? null,
							permissionProfile: row.permission_profile ?? null,
							traceId: row.trace_id ?? null,
						};
					}
				}
			} catch {
				// DB read failure leaves dbContext null; trace can still win.
			}
			// Precedence: pick whichever source has the later evaluatedAt
			// (codex review 2026-05-14 follow-up on slice 3a). Tie-breaker:
			// prefer trace because flow-scoped gates with no quality_gates
			// FK row are trace-only. When neither has context, return empty.
			const traceTs = trace?.evaluatedAt ?? null;
			const dbTs = dbContext?.lastEvaluatedAt ?? null;
			const traceWins =
				trace &&
				(dbContext === null ||
					(traceTs !== null && dbTs !== null && traceTs >= dbTs) ||
					(traceTs !== null && dbTs === null) ||
					(traceTs === null && dbTs === null));
			if (traceWins && trace) {
				return {
					scope: trace.scope ?? dbContext?.scope ?? "unknown",
					lastEvaluatedAt: traceTs,
					surface: trace.surface ?? null,
					runControl: trace.runControl ?? null,
					permissionProfile: trace.permissionProfile ?? null,
					traceId: trace.traceId ?? null,
				};
			}
			return dbContext ?? empty;
		};

		gates = gateIds.map((id: string) => {
			const stats = gatesDbModule.getGateRunStats(id, 24);
			const cb = gatesDbModule.getGateCircuitBreaker(id);
			const runContext = getGateRunContext(id);
			const base = {
				id,
				scope: runContext.scope,
				total: stats.total ?? 0,
				pass: stats.pass ?? 0,
				fail: stats.fail ?? 0,
				manualAttention: stats.manualAttention ?? 0,
				retry: stats.retry ?? 0,
				// prefer stats window result; fall back to quality_gates last entry
				lastEvaluatedAt: stats.lastEvaluatedAt ?? runContext.lastEvaluatedAt,
				circuitBreaker: cb?.state ?? "closed",
				failureStreak: cb?.failureStreak ?? 0,
			};
			const coverageStatus = classifyCoverage(base, runContext);
			return { ...base, coverageStatus } satisfies GateHealthEntry;
		});
	} catch (err) {
		const msg = err instanceof Error ? err.message : String(err);
		process.stderr.write(`[headless] uok-status: ${msg}\n`);
		return { exitCode: 1, gates: [] };
	}

	if (opts.json) {
		// schemaVersion bumped 1 → 2 for the addition of coverageStatus.
		// Adding a field is non-breaking for consumers that pin to v1's
		// shape; the bump signals the new field is part of the contract.
		process.stdout.write(
			`${JSON.stringify({ schemaVersion: 2, gates }, null, 2)}\n`,
		);
	} else {
		process.stdout.write("\nUOK Gate Health (last 24h)\n\n");
		process.stdout.write(formatTable(gates));
	}

	return { exitCode: 0, gates };
}
