/**
 * benchmark-coverage.js — audit which catalog models have static benchmark data.
 *
 * Reads the global model-catalog (~/.sf/model-catalog/<provider>.json) and the
 * static benchmark file (learning/data/model-benchmarks.json), applies the
 * same user policy filters used by the dispatcher (isProviderAllowedByLists +
 * isProviderModelAllowed), and writes ~/.sf/benchmark-coverage.json with the
 * set of dispatchable models that have NO published benchmark numbers.
 *
 * Use: surface the gap so the static benchmark file can be kept current
 * without guessing. The audit never blocks — it's a fire-and-forget at
 * session_start (and, later, on the daemon maintenance timer).
 */
import {
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import {
	isProviderAllowedByLists,
	isProviderModelAllowed,
} from "./preferences-models.js";
import { sfHome } from "./sf-home.js";

const BENCHMARK_DATA_REL = ["learning", "data", "model-benchmarks.json"];

function catalogDir() {
	return join(sfHome(), "model-catalog");
}

function coverageFilePath() {
	return join(sfHome(), "benchmark-coverage.json");
}

function benchmarkDataPath() {
	return join(import.meta.dirname, ...BENCHMARK_DATA_REL);
}

/**
 * Build a lowercase set of model ids that have any published benchmark data.
 * Returns an empty Set when the file is missing or malformed.
 */
function loadBenchmarkKeys() {
	const path = benchmarkDataPath();
	if (!existsSync(path)) return new Set();
	try {
		const data = JSON.parse(readFileSync(path, "utf-8"));
		return new Set(
			Object.keys(data)
				.filter((k) => k !== "_meta")
				.map((k) => k.toLowerCase()),
		);
	} catch {
		return new Set();
	}
}

/**
 * Read every <provider>.json file under ~/.sf/model-catalog/ and flatten into
 * { provider, id, cost? } entries. Returns [] if the catalog dir is missing.
 */
function loadCatalogEntries() {
	const dir = catalogDir();
	if (!existsSync(dir)) return [];
	const out = [];
	for (const file of readdirSync(dir)) {
		if (!file.endsWith(".json")) continue;
		const provider = file.slice(0, -".json".length);
		let parsed;
		try {
			parsed = JSON.parse(readFileSync(join(dir, file), "utf-8"));
		} catch {
			continue;
		}
		const entries = Array.isArray(parsed?.modelIds) ? parsed.modelIds : [];
		for (const raw of entries) {
			const id = typeof raw === "string" ? raw : raw?.id;
			if (typeof id !== "string" || id.length === 0) continue;
			out.push({
				provider,
				id,
				cost: typeof raw === "object" ? raw?.cost : undefined,
				contextWindow: typeof raw === "object" ? raw?.contextWindow : undefined,
			});
		}
	}
	return out;
}

/**
 * Reduce an arbitrary route id to the bare model id used in the benchmark
 * file. Lowercases, strips `<provider>/` prefix (openrouter style) and any
 * `:free` suffix.
 */
export function normalizeForBenchmarkLookup(modelId) {
	let key = modelId.toLowerCase();
	const slash = key.indexOf("/");
	if (slash !== -1) key = key.slice(slash + 1);
	if (key.endsWith(":free")) key = key.slice(0, -":free".length);
	return key;
}

/**
 * #R050: produce a list of fallback lookup keys for a model id. Each variant
 * carries a `kind` flag — "exact" for date/version aliases (e.g.
 * "mistral-medium-2505" → "mistral-medium-latest" — same model line, different
 * release tag) versus "approx" for tier proxies (e.g. "X-flash-lite" → "X-flash"
 * — DIFFERENT model size, scores not equivalent).
 *
 * The audit uses "exact" matches to count toward full coverage; "approx" matches
 * are reported as `approximateCoverage` so operators know a proxy is being used
 * rather than a real benchmark.
 */
export function benchmarkLookupVariants(modelId) {
	const primary = normalizeForBenchmarkLookup(modelId);
	const variants = [{ key: primary, kind: "exact" }];
	// Date/version suffix strip is "exact" — same model line, different release tag
	const dateStripped = primary.replace(/-\d{4,}(-\d{2,})?$/, "");
	if (dateStripped !== primary) {
		variants.push({ key: dateStripped, kind: "exact" });
	}
	// "-latest" alias is "exact" — points to the same line's current release
	if (!primary.endsWith("-latest")) {
		variants.push({ key: `${primary}-latest`, kind: "exact" });
	}
	// Tier strips are "approx" — flash-lite vs flash are DIFFERENT models,
	// different parameters, different scores. Proxy at best.
	if (primary.endsWith("-lite")) {
		variants.push({ key: primary.slice(0, -"-lite".length), kind: "approx" });
	}
	if (primary.endsWith("-flash-lite")) {
		variants.push({
			key: primary.replace(/-flash-lite$/, "-flash"),
			kind: "approx",
		});
	}
	if (primary.endsWith("-flash-lite-preview")) {
		variants.push({
			key: primary.replace(/-flash-lite-preview$/, "-flash"),
			kind: "approx",
		});
		variants.push({
			key: primary.replace(/-flash-lite-preview$/, "-flash-preview"),
			kind: "approx",
		});
	}
	// Dedup by key, preserving the first (most-preferred) kind
	const seen = new Set();
	const out = [];
	for (const v of variants) {
		if (seen.has(v.key)) continue;
		seen.add(v.key);
		out.push(v);
	}
	return out;
}

/**
 * Compute coverage stats for the user's dispatchable model set.
 *
 * `prefs` is the merged preferences object; we read allowed_providers,
 * blocked_providers, provider_model_allow, provider_model_block.
 */
export function computeBenchmarkCoverage(prefs) {
	const benchmarkKeys = loadBenchmarkKeys();
	const catalog = loadCatalogEntries();
	const allowed = prefs?.allowed_providers;
	const blocked = prefs?.blocked_providers;
	const providerModelAllow = prefs?.provider_model_allow;
	const providerModelBlock = prefs?.provider_model_block;

	const covered = [];
	const uncovered = [];

	for (const entry of catalog) {
		if (!isProviderAllowedByLists(entry.provider, allowed, blocked)) continue;
		const modelObj = {
			cost: entry.cost,
			contextWindow: entry.contextWindow,
		};
		if (
			!isProviderModelAllowed(
				entry.provider,
				entry.id,
				providerModelAllow,
				providerModelBlock,
				modelObj,
			)
		) {
			continue;
		}
		// #R050: try each variant in order. Exact matches count as full coverage;
		// approx matches (tier proxies like flash-lite→flash) are tracked
		// separately so operators see when a real benchmark is still missing.
		const variants = benchmarkLookupVariants(entry.id);
		const matched = variants.find((v) => benchmarkKeys.has(v.key));
		if (!matched) {
			uncovered.push({ provider: entry.provider, id: entry.id });
		} else if (matched.kind === "exact") {
			covered.push({
				provider: entry.provider,
				id: entry.id,
				...(matched.key !== variants[0].key ? { matchedVia: matched.key } : {}),
			});
		} else {
			// approx — proxy match; still counts as uncovered for honest reporting,
			// but the audit lists the proxy so operators can decide to add a real entry.
			uncovered.push({
				provider: entry.provider,
				id: entry.id,
				approximatedBy: matched.key,
				note: "covered via approx proxy — different model tier; real benchmark would be more accurate",
			});
		}
	}

	const total = covered.length + uncovered.length;
	return {
		covered,
		uncovered,
		summary: {
			total,
			coveredCount: covered.length,
			uncoveredCount: uncovered.length,
			coverageRatio: total === 0 ? 0 : covered.length / total,
		},
	};
}

/**
 * Write the coverage report to ~/.sf/benchmark-coverage.json. Best-effort —
 * never throws on disk failure.
 */
export function writeBenchmarkCoverage(coverage) {
	const path = coverageFilePath();
	try {
		mkdirSync(dirname(path), { recursive: true });
		const payload = {
			schemaVersion: 1,
			generated: new Date().toISOString(),
			summary: coverage.summary,
			uncovered: coverage.uncovered,
		};
		writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
	} catch {
		// Best-effort — never fail the caller.
	}
}

/**
 * Compare a freshly computed coverage report against the previously written
 * one. Returns true when the uncovered set has changed (additions OR removals).
 * Used to gate change-driven notifications.
 */
export function detectCoverageChange(coverage) {
	const path = coverageFilePath();
	if (!existsSync(path)) return true;
	let prev;
	try {
		prev = JSON.parse(readFileSync(path, "utf-8"));
	} catch {
		return true;
	}
	const prevIds = new Set(
		(Array.isArray(prev?.uncovered) ? prev.uncovered : []).map(
			(m) => `${m.provider}/${m.id}`,
		),
	);
	const currIds = new Set(
		coverage.uncovered.map((m) => `${m.provider}/${m.id}`),
	);
	if (prevIds.size !== currIds.size) return true;
	for (const id of currIds) {
		if (!prevIds.has(id)) return true;
	}
	return false;
}

/**
 * Fire-and-forget audit. Writes the coverage report and, if the uncovered set
 * has changed since the previous run, invokes the optional `notify` callback
 * with a one-line summary. Caller supplies notify so this module stays free of
 * UI dependencies.
 *
 *   scheduleBenchmarkCoverageAudit(prefs, (msg) => ctx.ui?.notify?.(msg, "info"))
 */
export function scheduleBenchmarkCoverageAudit(prefs, notify) {
	setImmediate(() => {
		try {
			const coverage = computeBenchmarkCoverage(prefs);
			const changed = detectCoverageChange(coverage);
			writeBenchmarkCoverage(coverage);
			if (
				changed &&
				coverage.uncovered.length > 0 &&
				typeof notify === "function"
			) {
				const sample = coverage.uncovered
					.slice(0, 3)
					.map((m) => `${m.provider}/${m.id}`)
					.join(", ");
				const more =
					coverage.uncovered.length > 3
						? ` (+${coverage.uncovered.length - 3} more)`
						: "";
				notify(
					`Benchmark coverage: ${coverage.summary.coveredCount}/${coverage.summary.total} models have static scores. Uncovered: ${sample}${more}. See ~/.sf/benchmark-coverage.json`,
				);
			}
		} catch {
			// Audit failures must never block session start.
		}
	});
}
