/**
 * Benchmark-driven model selection.
 *
 * When `models.<unit>` is not set in preferences, this module picks the
 * best-scoring model from the allow-listed providers for each unit type.
 * Scoring is a weighted combination of published benchmarks
 * (`learning/data/model-benchmarks.json`) with per-unit-type profiles
 * that emphasise the dimensions that actually matter for that work:
 *   - plan-milestone / plan-slice    → reasoning-heavy (hle, aime, gpqa)
 *   - research-*                      → mixed (mmlu_pro, browse_comp, ...)
 *   - execute-task (heavy)            → coding (swe_bench, live_code_bench)
 *   - execute-task (light/standard)   → coding + instruction following
 *   - complete-* / execution_simple   → fast+correct (human_eval, ifeval)
 *   - gate-evaluate / validate-*      → reasoning + coding
 *
 * Missing benchmark scores are treated as 0 (model ranked last rather
 * than excluded) so freshly-launched models without benchmark data are
 * still dispatchable — they just don't displace an already-ranked peer.
 *
 * This is the inner primitive behind the "auto-benchmark" preference mode
 * users select by leaving `models.*` empty.
 */
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tierOrdinal } from "./complexity-classifier.js";
import { lookup } from "./model-registry.js";
import { getModelTier } from "./model-router.js";
import { getProviderQuotaState } from "./provider-quota-cache.js";

// ─── Quota-aware routing (phase 2) ────────────────────────────────────────────
// Bias dispatch toward under-used subscriptions ("spend the subs") and away
// from near-exhausted ones (avoid 429 walls). Multiplier is applied to the
// benchmark score before sort, so it only re-orders within the existing
// score → cost → coverage → preference ladder. Unknown quota state means
// neutral 1.0 — never punish a provider for having no public quota API.
//
// Curve, keyed on max(usedFraction) across all windows for the provider:
//   < 0.20 → 1.15  (boost: lots of headroom, prefer to use it)
//   < 0.50 → 1.00  (neutral)
//   < 0.70 → 0.92  (slight steer away)
//   < 0.90 → 0.50  (strong de-prioritize)
//   < 0.95 → 0.20  (near-exhaustion)
//   ≥ 0.95 → 0.05  (effectively skip; only used if no alternatives)
//
// The "max across windows" choice means kimi-coding's 5h-rolling window
// (typically tighter) binds the decision even when the weekly is fresh.
const QUOTA_BOOST = 1.15;
const QUOTA_NEUTRAL = 1.0;
const QUOTA_MILD = 0.92;
const QUOTA_STRONG_DEMOTE = 0.5;
const QUOTA_NEAR_EXHAUSTION = 0.2;
const QUOTA_SKIP = 0.05;

export function quotaHeadroomMultiplier(providerKey, getQuotaState) {
	const resolver = getQuotaState ?? getProviderQuotaState;
	let state;
	try {
		state = resolver(providerKey);
	} catch {
		return QUOTA_NEUTRAL;
	}
	if (
		!state ||
		!state.ok ||
		!Array.isArray(state.windows) ||
		state.windows.length === 0
	) {
		return QUOTA_NEUTRAL;
	}
	let maxUsed = 0;
	for (const w of state.windows) {
		if (
			typeof w?.usedFraction === "number" &&
			Number.isFinite(w.usedFraction)
		) {
			if (w.usedFraction > maxUsed) maxUsed = w.usedFraction;
		}
	}
	if (maxUsed < 0.2) return QUOTA_BOOST;
	if (maxUsed < 0.5) return QUOTA_NEUTRAL;
	if (maxUsed < 0.7) return QUOTA_MILD;
	if (maxUsed < 0.9) return QUOTA_STRONG_DEMOTE;
	if (maxUsed < 0.95) return QUOTA_NEAR_EXHAUSTION;
	return QUOTA_SKIP;
}

// ─── Benchmark File Loader ───────────────────────────────────────────────────
let _benchmarksCache = null;
function loadBenchmarks() {
	if (_benchmarksCache) return _benchmarksCache;
	const here = import.meta.dirname;
	// Works for both .ts (dev) and .js (dist) since we copy the data file 1:1.
	const path = join(here, "learning", "data", "model-benchmarks.json");
	if (!existsSync(path)) {
		_benchmarksCache = {};
		return _benchmarksCache;
	}
	try {
		_benchmarksCache = JSON.parse(readFileSync(path, "utf-8"));
	} catch {
		_benchmarksCache = {};
	}
	return _benchmarksCache;
}
/** Testing: reset the in-memory benchmark cache. */
export function _resetBenchmarkCache() {
	_benchmarksCache = null;
}
const PROFILES = {
	// Planning in SF is agent-style decomposition work, not pure math
	// olympiad reasoning. Weight swe_bench (agent/coding reasoning) and
	// live_code_bench heavier; keep hle/gpqa for general capability.
	"plan-milestone": {
		weights: {
			swe_bench: 0.25,
			live_code_bench: 0.2,
			hle: 0.15,
			gpqa: 0.15,
			mmlu_pro: 0.15,
			aime_2026: 0.1,
		},
		label: "agent-planning",
	},
	"plan-slice": {
		weights: {
			swe_bench: 0.25,
			live_code_bench: 0.2,
			hle: 0.15,
			gpqa: 0.15,
			mmlu_pro: 0.15,
			aime_2026: 0.1,
		},
		label: "agent-planning",
	},
	"replan-slice": {
		weights: {
			hle: 0.25,
			gpqa: 0.2,
			swe_bench: 0.3,
			mmlu_pro: 0.15,
			instruction_following: 0.1,
		},
		label: "replanning",
	},
	discuss: {
		weights: {
			hle: 0.25,
			mmlu_pro: 0.25,
			gpqa: 0.2,
			instruction_following: 0.15,
			simple_qa: 0.15,
		},
		label: "discussion",
	},
	"discuss-milestone": {
		weights: {
			hle: 0.25,
			mmlu_pro: 0.25,
			gpqa: 0.2,
			instruction_following: 0.15,
			simple_qa: 0.15,
		},
		label: "discussion",
	},
	"discuss-slice": {
		weights: {
			hle: 0.25,
			mmlu_pro: 0.25,
			gpqa: 0.2,
			instruction_following: 0.15,
			simple_qa: 0.15,
		},
		label: "discussion",
	},
	"discuss-headless": {
		weights: {
			hle: 0.25,
			mmlu_pro: 0.25,
			gpqa: 0.2,
			instruction_following: 0.15,
			simple_qa: 0.15,
		},
		label: "discussion",
	},
	"research-milestone": {
		weights: {
			mmlu_pro: 0.25,
			hle: 0.2,
			human_eval: 0.2,
			browse_comp: 0.15,
			simple_qa: 0.1,
			gpqa: 0.1,
		},
		label: "research",
	},
	"research-slice": {
		weights: {
			mmlu_pro: 0.25,
			hle: 0.2,
			human_eval: 0.2,
			browse_comp: 0.15,
			simple_qa: 0.1,
			gpqa: 0.1,
		},
		label: "research",
	},
	"execute-task": {
		weights: {
			swe_bench: 0.35,
			swe_bench_verified: 0.25,
			live_code_bench: 0.2,
			human_eval: 0.15,
			instruction_following: 0.05,
		},
		label: "coding",
	},
	"reactive-execute": {
		weights: {
			swe_bench: 0.3,
			live_code_bench: 0.25,
			human_eval: 0.2,
			hle: 0.15,
			instruction_following: 0.1,
		},
		label: "coding",
	},
	"execute-task-simple": {
		weights: {
			human_eval: 0.4,
			instruction_following: 0.35,
			long_context_ruler: 0.25,
		},
		label: "fast+correct",
	},
	execution_simple: {
		weights: {
			human_eval: 0.4,
			instruction_following: 0.35,
			long_context_ruler: 0.25,
		},
		label: "fast+correct",
	},
	"complete-slice": {
		weights: {
			instruction_following: 0.4,
			human_eval: 0.35,
			long_context_ruler: 0.25,
		},
		label: "fast+correct",
	},
	"complete-milestone": {
		weights: {
			instruction_following: 0.4,
			human_eval: 0.35,
			long_context_ruler: 0.25,
		},
		label: "fast+correct",
	},
	"gate-evaluate": {
		weights: {
			swe_bench: 0.3,
			hle: 0.25,
			gpqa: 0.25,
			instruction_following: 0.2,
		},
		label: "review",
	},
	"validate-milestone": {
		weights: { hle: 0.3, gpqa: 0.25, mmlu_pro: 0.25, swe_bench: 0.2 },
		label: "validation",
	},
	subagent: {
		weights: {
			swe_bench: 0.3,
			live_code_bench: 0.25,
			human_eval: 0.25,
			hle: 0.2,
		},
		label: "subagent-default",
	},
	"run-uat": {
		weights: {
			human_eval: 0.45,
			instruction_following: 0.4,
			long_context_ruler: 0.15,
		},
		label: "uat",
	},
	"reassess-roadmap": {
		weights: {
			mmlu_pro: 0.3,
			hle: 0.25,
			gpqa: 0.25,
			browse_comp: 0.1,
			simple_qa: 0.1,
		},
		label: "reassessment",
	},
};
const MINIMUM_MODEL_TIER_BY_UNIT = {
	"complete-slice": "standard",
	"complete-milestone": "standard",
	"gate-evaluate": "standard",
	"run-uat": "standard",
	"validate-milestone": "standard",
};
// Fallback for unit types not in the table — treat as standard coding.
const DEFAULT_PROFILE = {
	swe_bench: 0.3,
	live_code_bench: 0.25,
	human_eval: 0.25,
	hle: 0.2,
};
function profileForUnitType(unitType) {
	const direct = PROFILES[unitType];
	if (direct) return direct;
	// hook/* units inherit DEFAULT_PROFILE
	return { weights: DEFAULT_PROFILE, label: `default(${unitType})` };
}
// ─── Scoring ─────────────────────────────────────────────────────────────────
/**
 * Match a provider+model pair to a benchmark record key. Benchmarks are
 * keyed by semantic model ID (e.g. "devstral-latest", "kimi-k2.5"), while registered
 * models may carry provider wire IDs or versioned suffixes
 * (`kimi-for-coding`, `devstral-2507`, `minimax-m2.7`). We try canonical id
 * from the model registry first, then exact match, then strip common
 * version/date suffixes, then try a family-level key (e.g. `mistral-large-2411` →
 * `mistral-large-latest`).
 */
function findBenchmarkKey(modelId, benchmarks, provider) {
	// Use canonical id from registry when a provider is known.
	const resolved = provider ? lookup(provider, modelId) : null;
	const semantic = resolved?.canonical_id ?? modelId;
	if (semantic !== modelId && semantic in benchmarks) return semantic;
	if (modelId in benchmarks) return modelId;
	// Strip date-style suffixes: "devstral-medium-2507" → "devstral-medium"
	const noDate = modelId.replace(/-\d{4}$/, "");
	if (noDate !== modelId) {
		if (noDate in benchmarks) return noDate;
		// Many vendors only publish benchmarks for the "-latest" alias.
		// "devstral-medium-2507" → "devstral-medium" → try "devstral-medium-latest".
		const latestAlias = `${noDate}-latest`;
		if (latestAlias in benchmarks) return latestAlias;
	}
	// Also try "-latest" alias when the model ID ends with a version number
	// (e.g. "minimax-m2.7" → look up "minimax-m2.7-latest", "minimax-m2-latest").
	const versionStripped = modelId.replace(/-\d+(\.\d+)?$/, "");
	if (versionStripped !== modelId) {
		const latestKey = `${versionStripped}-latest`;
		if (latestKey in benchmarks) return latestKey;
		if (versionStripped in benchmarks) return versionStripped;
	}
	// Case-insensitive match — last resort for casing drift (MiniMax-M2 vs
	// minimax-m2). The catalog uses one convention, the benchmark file
	// another.
	const lower = modelId.toLowerCase();
	for (const key of Object.keys(benchmarks)) {
		if (key === "_meta") continue;
		if (key.toLowerCase() === lower) return key;
	}
	return null;
}
// Some benchmarks are practical equivalents — vendors publish one or the
// other but rarely both. Treat them as fungible: whichever is populated
// fills the profile slot. This prevents MiniMax (publishes
// swe_bench_verified=80) from being penalised vs z.ai GLM-5.1 (publishes
// swe_bench=78) on a weight that references only "swe_bench".
const DIMENSION_EQUIVALENTS = {
	swe_bench: ["swe_bench_verified"],
	swe_bench_verified: ["swe_bench"],
};
function readDimension(rec, dim) {
	const direct = rec[dim];
	if (typeof direct === "number" && Number.isFinite(direct)) return direct;
	const equivalents = DIMENSION_EQUIVALENTS[dim] ?? [];
	for (const alt of equivalents) {
		const v = rec[alt];
		if (typeof v === "number" && Number.isFinite(v)) return v;
	}
	return null;
}
function scoreCandidate(candidate, profile, benchmarks) {
	const key = findBenchmarkKey(candidate.id, benchmarks, candidate.provider);
	if (!key) return { score: 0, coverage: 0 };
	const rec = benchmarks[key];
	if (!rec || typeof rec !== "object") return { score: 0, coverage: 0 };
	let weightedSum = 0;
	let weightTotal = 0;
	let profileTotal = 0;
	let coverage = 0;
	for (const [dim, weight] of Object.entries(profile)) {
		profileTotal += weight;
		const v = readDimension(rec, dim);
		if (v !== null) {
			weightedSum += weight * v;
			weightTotal += weight;
			coverage++;
		}
	}
	if (weightTotal === 0) return { score: 0, coverage: 0 };
	// Normalise by populated weight so a model with 2 dimensions at 90 isn't
	// crushed by a peer with 5 mediocre ones… but moderate with a coverage
	// confidence multiplier so a 1-dimension specialist doesn't beat a
	// broadly-strong 4-dimension peer. Confidence = populated / total profile
	// weight; blend 50/50 with a flat floor so small coverage still scores.
	const normalized = weightedSum / weightTotal;
	const confidence = profileTotal > 0 ? weightTotal / profileTotal : 0;
	const confidenceMultiplier = 0.5 + 0.5 * confidence;
	return { score: normalized * confidenceMultiplier, coverage };
}
const COST_TIE_SCORE_WINDOW = 2;
function costBlendForUnitType(unitType) {
	if (
		unitType.startsWith("complete-") ||
		unitType === "run-uat" ||
		unitType === "execution_simple" ||
		unitType === "execute-task-simple"
	) {
		return { input: 0.55, output: 0.45 };
	}
	if (
		unitType.startsWith("plan-") ||
		unitType.startsWith("discuss-") ||
		unitType === "replan-slice" ||
		unitType === "gate-evaluate" ||
		unitType === "validate-milestone"
	) {
		return { input: 0.65, output: 0.35 };
	}
	return { input: 0.75, output: 0.25 };
}
function estimateCostPerMillion(candidate, unitType) {
	if (!candidate.cost) return Number.POSITIVE_INFINITY;
	const input = Number.isFinite(candidate.cost.input)
		? candidate.cost.input
		: Number.POSITIVE_INFINITY;
	const output = Number.isFinite(candidate.cost.output)
		? candidate.cost.output
		: Number.POSITIVE_INFINITY;
	const blend = costBlendForUnitType(unitType);
	return input * blend.input + output * blend.output;
}
function logScale(value, floor, ceiling) {
	if (!value || value <= 0) return 0;
	const clamped = Math.max(floor, Math.min(ceiling, value));
	return (
		(Math.log2(clamped) - Math.log2(floor)) /
		(Math.log2(ceiling) - Math.log2(floor))
	);
}
function capabilityTieBreakScore(candidate, unitType) {
	const isReasoningUnit =
		unitType.startsWith("plan-") ||
		unitType.startsWith("discuss-") ||
		unitType === "replan-slice" ||
		unitType === "gate-evaluate" ||
		unitType === "validate-milestone" ||
		unitType === "reassess-roadmap";
	const context = logScale(candidate.contextWindow, 8_192, 1_048_576);
	const output = logScale(candidate.maxTokens, 8_192, 131_072);
	let score = context * 35 + output * 25;
	if (candidate.reasoning) score += isReasoningUnit ? 25 : 8;
	if (candidate.input?.includes("image")) score += 4;
	if (candidate.capabilities?.supportsXhigh) score += isReasoningUnit ? 6 : 2;
	if (candidate.capabilities?.thinkingNoBudget) score += 3;
	return score;
}
// ─── Provider Diversity ──────────────────────────────────────────────────────
/**
 * Interleave picks across providers so the fallback chain doesn't collapse
 * into a single provider (if that provider goes 429, every fallback fails).
 * Takes the top-N from a sorted list but skips picks whose provider already
 * appears, until we exhaust the unique providers, then cycles back.
 */
function diversifyByProvider(sorted, maxPicks) {
	const picked = [];
	const seenProviders = new Set();
	const stragglers = [];
	for (const m of sorted) {
		if (picked.length >= maxPicks) break;
		if (!seenProviders.has(m.provider)) {
			picked.push(m.id);
			seenProviders.add(m.provider);
		} else {
			stragglers.push(m);
		}
	}
	// Top up from stragglers in score order if we ran out of unique providers.
	for (const s of stragglers) {
		if (picked.length >= maxPicks) break;
		picked.push(s.id);
	}
	return picked;
}
/**
 * Pick the best `provider/model-id` for a unit type from the candidate pool.
 * Returns null when no candidates are available.
 */
export function selectByBenchmarks(unitType, candidates, opts = {}) {
	if (candidates.length === 0) return null;
	const { weights, label } = profileForUnitType(unitType);
	const benchmarks = opts.benchmarks ?? loadBenchmarks();
	const maxEntries = opts.maxEntries ?? 4;
	const tierEligibleCandidates = filterByMinimumModelTier(unitType, candidates);
	// Build a provider-rank map. Listed providers get their index; unlisted
	// fall after all listed ones. Case-insensitive.
	const providerRank = new Map();
	const prefList = (opts.providerPreference ?? []).map((p) =>
		p.trim().toLowerCase(),
	);
	prefList.forEach((p, i) => {
		if (p && !providerRank.has(p)) providerRank.set(p, i);
	});
	const UNLISTED_RANK = 1_000_000;
	const rankOf = (prov) => providerRank.get(prov) ?? UNLISTED_RANK;
	const ranked = tierEligibleCandidates
		.map((c) => {
			const { score, coverage } = scoreCandidate(c, weights, benchmarks);
			const fullId = `${c.provider}/${c.id}`;
			const quotaMul = quotaHeadroomMultiplier(
				c.provider.toLowerCase(),
				opts.getQuotaState,
			);
			return {
				id: fullId,
				provider: c.provider.toLowerCase(),
				score: score * quotaMul,
				baseScore: score,
				quotaMultiplier: quotaMul,
				coverage,
				cost: estimateCostPerMillion(c, unitType),
				capabilitySignal: capabilityTieBreakScore(c, unitType),
			};
		})
		// Stable sort: higher score first, then higher coverage, then
		// cheaper near-ties, then metadata capability signal, then
		// provider_preference rank (lower = earlier = preferred), then
		// alphabetical for determinism. Cost only wins when benchmark scores are
		// close enough that the practical quality difference is noise.
		.sort((a, b) => {
			const scoreDiff = b.score - a.score;
			if (Math.abs(scoreDiff) > COST_TIE_SCORE_WINDOW) return scoreDiff;
			if (a.cost !== b.cost) return a.cost - b.cost;
			if (scoreDiff !== 0) return scoreDiff;
			if (b.coverage !== a.coverage) return b.coverage - a.coverage;
			if (b.capabilitySignal !== a.capabilitySignal) {
				return b.capabilitySignal - a.capabilitySignal;
			}
			const ra = rankOf(a.provider);
			const rb = rankOf(b.provider);
			if (ra !== rb) return ra - rb;
			return a.id.localeCompare(b.id);
		});
	const ids = diversifyByProvider(ranked, maxEntries);
	if (ids.length === 0) return null;
	const [primary, ...fallbacks] = ids;
	const scores = {};
	const costEstimates = {};
	const capabilitySignals = {};
	for (const r of ranked) {
		scores[r.id] = Math.round(r.score * 100) / 100;
		costEstimates[r.id] = Number.isFinite(r.cost)
			? Math.round(r.cost * 1000) / 1000
			: null;
		capabilitySignals[r.id] = Math.round(r.capabilitySignal * 100) / 100;
	}
	const topCoverage = ranked[0]?.coverage ?? 0;
	return {
		primary,
		fallbacks,
		scores,
		costEstimates,
		capabilitySignals,
		topCoverage,
		profile: label,
	};
}
function filterByMinimumModelTier(unitType, candidates) {
	const minimumTier = MINIMUM_MODEL_TIER_BY_UNIT[unitType];
	if (!minimumTier) return candidates;
	const minimum = tierOrdinal(minimumTier);
	const filtered = candidates.filter((candidate) => {
		return tierOrdinal(getModelTier(candidate.id)) >= minimum;
	});
	return filtered.length > 0 ? filtered : candidates;
}
