/**
 * Continuous Model Learning — track per-task-type model performance and
 * adaptively route to better-performing models.
 *
 * Purpose: Make model selection data-driven and adaptive instead of static.
 * When a model consistently fails on certain task types, demote it. When a new
 * model succeeds where the incumbent fails, promote it.
 *
 * Consumer: auto-dispatch.ts outcome logging, model-router.ts selection logic,
 * benchmark-selector.ts display.
 */

import {
	appendFileSync,
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	renameSync,
	writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";

const MODEL_FAILURE_LOG_SCHEMA_VERSION = 1;

/**
 * Reference to canonicalIdFor from model-registry.
 *
 * Default: null (all routes go to _unmapped).
 * Override in tests via setRegistryResolver() to inject a stub.
 * In production, model-registry.js injects itself at module load via
 * the import side-effect at the bottom of this file (lazy dynamic import).
 */
let _canonicalIdForFn = null;

/**
 * Resolve a route key (provider/wire-id) to a canonical id using the model
 * registry. Falls back gracefully when the registry is unavailable (e.g. in
 * tests that don't load the full @singularity-forge/ai package).
 *
 * Returns null when the route is not mappable (routes to _unmapped).
 */
function tryCanonicalIdFor(routeKey) {
	if (_canonicalIdForFn === null) return null;
	try {
		return _canonicalIdForFn(routeKey);
	} catch {
		return null;
	}
}

/**
 * Allow tests and the module itself to inject a canonicalIdFor implementation.
 * In production, model-registry.js is imported lazily and wires itself here.
 * In tests, call this before constructing ModelPerformanceTracker:
 *   setRegistryResolver((rk) => rk === "kimi-coding/kimi-k2.6" ? "kimi-k2.6" : null)
 */
export function setRegistryResolver(fn) {
	_canonicalIdForFn = fn;
}

// Wire the registry lazily so model-learner.js can be imported independently
// of @singularity-forge/ai (e.g. in tests that don't load the full AI package).
// The fire-and-forget import populates _canonicalIdForFn when the registry
// resolves. Export `registryReady` so callers can await it before the first
// recordOutcome() call to avoid the timing window where _canonicalIdForFn is
// still null (which routes everything to _unmapped).
export const registryReady = import("./model-registry.js")
	.then((mod) => {
		if (
			_canonicalIdForFn === null &&
			typeof mod?.canonicalIdFor === "function"
		) {
			_canonicalIdForFn = mod.canonicalIdFor;
		}
	})
	.catch(() => {
		// Registry unavailable (tests, stripped builds, etc.) — routes go to _unmapped.
	});

/**
 * Detect whether a unit-type blob in the performance file uses the OLD
 * flat format ({ "provider/wire-id": { successes, failures, ... } })
 * vs the NEW canonical format ({ "canonical-id": { aggregate, by_route } }).
 *
 * Detection rule: if ANY key in the object has a canonical-shaped entry
 * (i.e. has a nested `aggregate` object), the blob is already new-format.
 * Otherwise it's old-format.
 */
function isOldFormat(unitTypeBlob) {
	if (!unitTypeBlob || typeof unitTypeBlob !== "object") return false;
	for (const val of Object.values(unitTypeBlob)) {
		if (val && typeof val === "object" && "aggregate" in val) {
			return false; // new-format entry found
		}
	}
	return true;
}

/**
 * Migrate a single unit-type blob from old flat format to new canonical format.
 * Returns the migrated blob.
 */
function migrateUnitTypeBlob(oldBlob) {
	const newBlob = {};
	for (const [routeKey, stats] of Object.entries(oldBlob)) {
		if (!stats || typeof stats !== "object") continue;
		const canonicalId = tryCanonicalIdFor(routeKey);
		const bucket = canonicalId ?? "_unmapped";
		if (!newBlob[bucket]) {
			if (bucket === "_unmapped") {
				newBlob["_unmapped"] = { by_route: {} };
			} else {
				newBlob[bucket] = {
					aggregate: {
						successes: 0,
						failures: 0,
						timeouts: 0,
						totalTokens: 0,
						totalCost: 0,
						lastUsed: stats.lastUsed ?? new Date().toISOString(),
					},
					by_route: {},
				};
			}
		}
		const routeEntry = {
			successes: stats.successes ?? 0,
			failures: stats.failures ?? 0,
			timeouts: stats.timeouts ?? 0,
			totalTokens: stats.totalTokens ?? 0,
			totalCost: stats.totalCost ?? 0,
			lastUsed: stats.lastUsed ?? new Date().toISOString(),
		};
		if (bucket === "_unmapped") {
			newBlob["_unmapped"].by_route[routeKey] = routeEntry;
		} else {
			newBlob[bucket].by_route[routeKey] = routeEntry;
			// Recompute aggregate as sum of by_route
			recomputeAggregate(newBlob[bucket]);
		}
	}
	return newBlob;
}

/**
 * Recompute the `aggregate` object for a canonical entry as the sum of all
 * by_route entries. This maintains the invariant:
 *   aggregate.successes === sum(by_route[*].successes)
 */
function recomputeAggregate(canonicalEntry) {
	const agg = {
		successes: 0,
		failures: 0,
		timeouts: 0,
		totalTokens: 0,
		totalCost: 0,
		lastUsed: "",
	};
	for (const r of Object.values(canonicalEntry.by_route)) {
		agg.successes += r.successes ?? 0;
		agg.failures += r.failures ?? 0;
		agg.timeouts += r.timeouts ?? 0;
		agg.totalTokens += r.totalTokens ?? 0;
		agg.totalCost += r.totalCost ?? 0;
		if (!agg.lastUsed || (r.lastUsed && r.lastUsed > agg.lastUsed)) {
			agg.lastUsed = r.lastUsed;
		}
	}
	canonicalEntry.aggregate = agg;
}

/**
 * Return a zero-valued route stats entry.
 */
function emptyRouteStats(timestamp) {
	return {
		successes: 0,
		failures: 0,
		timeouts: 0,
		totalTokens: 0,
		totalCost: 0,
		lastUsed: timestamp,
	};
}

/**
 * Per-task-type model performance tracker.
 *
 * New schema (v2 — canonical-keyed):
 * {
 *   "<unit-type>": {
 *     "<canonical-id>": {
 *       "aggregate":  { successes, failures, timeouts, totalTokens, totalCost, lastUsed },
 *       "by_route":   { "<provider/wire-model>": { successes, failures, ... } }
 *     },
 *     "_unmapped": {
 *       "by_route": { "<route>": { ... } }
 *     }
 *   }
 * }
 *
 * Old schema (v1 — fused route key as top-level key) is migrated on boot.
 */
class ModelPerformanceTracker {
	constructor(basePath) {
		this.basePath = basePath;
		this.storagePath = basePath
			? join(basePath, ".sf", "model-performance.json")
			: null;
		this.data = this._load();
	}

	_load() {
		if (!this.storagePath) {
			return {};
		}
		if (!existsSync(this.storagePath)) {
			return {};
		}
		try {
			const content = readFileSync(this.storagePath, "utf-8");
			const parsed = JSON.parse(content);
			return this._migrateIfNeeded(parsed);
		} catch {
			return {};
		}
	}

	/**
	 * Detect and migrate old-format data in-place. Writes backup + new file on
	 * migration. Idempotent: if backup already exists, skip.
	 */
	_migrateIfNeeded(parsed) {
		// Check if any unit-type blob is still in old format
		let needsMigration = false;
		for (const unitTypeBlob of Object.values(parsed)) {
			if (
				typeof unitTypeBlob === "object" &&
				unitTypeBlob !== null &&
				isOldFormat(unitTypeBlob)
			) {
				needsMigration = true;
				break;
			}
		}
		if (!needsMigration) return parsed;

		// Write backup (idempotent — only if backup doesn't already exist)
		const backupPath = this.storagePath + ".pre-canonical-backup";
		if (!existsSync(backupPath)) {
			try {
				writeFileSync(backupPath, JSON.stringify(parsed, null, 2), "utf-8");
			} catch {
				// Non-fatal: backup failure should not block migration
			}
		}

		// Migrate each unit type
		const migrated = {};
		for (const [unitType, unitTypeBlob] of Object.entries(parsed)) {
			if (typeof unitTypeBlob !== "object" || unitTypeBlob === null) {
				migrated[unitType] = unitTypeBlob;
				continue;
			}
			if (isOldFormat(unitTypeBlob)) {
				migrated[unitType] = migrateUnitTypeBlob(unitTypeBlob);
			} else {
				migrated[unitType] = unitTypeBlob;
			}
		}

		// Write migrated data back to disk
		try {
			const dir = dirname(this.storagePath);
			if (!existsSync(dir)) {
				mkdirSync(dir, { recursive: true });
			}
			writeFileSync(
				this.storagePath,
				JSON.stringify(migrated, null, 2),
				"utf-8",
			);
		} catch {
			// Non-fatal
		}

		return migrated;
	}

	_save() {
		if (!this.storagePath) {
			return;
		}
		try {
			const dir = dirname(this.storagePath);
			if (!existsSync(dir)) {
				mkdirSync(dir, { recursive: true });
			}
			writeFileSync(
				this.storagePath,
				JSON.stringify(this.data, null, 2),
				"utf-8",
			);
		} catch (err) {
			console.error("Failed to save model performance data:", err);
		}
	}

	/**
	 * Record outcome for a route key on a specific task type.
	 *
	 * @param taskType - e.g. "execute-task"
	 * @param routeKey - format: "provider/wire-model" (e.g. "kimi-coding/kimi-k2.6")
	 *                   OR a bare model id for backward-compat (no slash = treated as routeKey)
	 */
	recordOutcome(
		taskType,
		routeKey,
		outcomeOrSuccess,
		timeoutArg = false,
		tokensUsedArg = 0,
		costUsdArg = 0,
	) {
		const outcome =
			typeof outcomeOrSuccess === "object" && outcomeOrSuccess !== null
				? outcomeOrSuccess
				: {
						success: Boolean(outcomeOrSuccess),
						timeout: timeoutArg,
						tokensUsed: tokensUsedArg,
						costUsd: costUsdArg,
					};
		const {
			success,
			timeout = false,
			tokensUsed = 0,
			costUsd = 0,
			timestamp = new Date().toISOString(),
		} = outcome;

		if (!this.data[taskType]) {
			this.data[taskType] = {};
		}

		// Resolve canonical id. Routes with no slash are legacy bare model ids
		// — treat them as their own route key, try registry first.
		const canonicalId = tryCanonicalIdFor(routeKey);

		if (canonicalId === null) {
			// Route not in registry → write to _unmapped
			if (!this.data[taskType]["_unmapped"]) {
				this.data[taskType]["_unmapped"] = { by_route: {} };
			}
			const unmapped = this.data[taskType]["_unmapped"];
			if (!unmapped.by_route[routeKey]) {
				unmapped.by_route[routeKey] = emptyRouteStats(timestamp);
			}
			const rs = unmapped.by_route[routeKey];
			this._applyOutcomeToStats(
				rs,
				success,
				timeout,
				tokensUsed,
				costUsd,
				timestamp,
			);
		} else {
			// Known route → write to by_route + recompute aggregate
			if (!this.data[taskType][canonicalId]) {
				this.data[taskType][canonicalId] = {
					aggregate: emptyRouteStats(timestamp),
					by_route: {},
				};
			}
			const canonicalEntry = this.data[taskType][canonicalId];
			if (!canonicalEntry.by_route[routeKey]) {
				canonicalEntry.by_route[routeKey] = emptyRouteStats(timestamp);
			}
			const rs = canonicalEntry.by_route[routeKey];
			this._applyOutcomeToStats(
				rs,
				success,
				timeout,
				tokensUsed,
				costUsd,
				timestamp,
			);
			recomputeAggregate(canonicalEntry);
		}

		this._save();
	}

	/**
	 * Apply a single outcome event to a stats object in-place.
	 */
	_applyOutcomeToStats(
		stats,
		success,
		timeout,
		tokensUsed,
		costUsd,
		timestamp,
	) {
		if (success) {
			stats.successes += 1;
		} else if (timeout) {
			stats.timeouts += 1;
			stats.failures += 1;
		} else {
			stats.failures += 1;
		}
		stats.totalTokens += tokensUsed;
		stats.totalCost += costUsd;
		stats.lastUsed = timestamp;
	}

	/**
	 * Get performance stats for a task type and model.
	 *
	 * When routeMode=false (default): looks up aggregate stats for a canonical id.
	 * When routeMode=true: looks up by_route stats for a specific routeKey.
	 *
	 * Backward-compat fallback: if the id is not found as a canonical, also checks
	 * _unmapped.by_route and all by_route maps — supports bare model ids used in
	 * tests and legacy callers that don't have the registry wired.
	 *
	 * @param taskType - e.g. "execute-task"
	 * @param canonicalOrRouteKey - canonical id (aggregate) or routeKey (by-route)
	 * @param routeMode - when true, returns by_route stats
	 */
	getStats(taskType, canonicalOrRouteKey, routeMode = false) {
		const unitBlob = this.data[taskType];
		if (!unitBlob) return null;

		if (routeMode) {
			// Explicit by-route lookup: scan all canonical entries and _unmapped
			return this.getRouteStats(taskType, canonicalOrRouteKey);
		}

		// Aggregate mode: look up by canonical id first
		const entry = unitBlob[canonicalOrRouteKey];
		if (entry?.aggregate) {
			const agg = entry.aggregate;
			const total = agg.successes + agg.failures;
			return {
				...agg,
				total,
				successRate: total > 0 ? agg.successes / total : 0,
			};
		}

		// Backward-compat fallback: look in by_route maps (for bare IDs and unmapped routes)
		// This supports old tests that use bare model IDs without a registry resolver.
		for (const [key, val] of Object.entries(unitBlob)) {
			if (key === "_unmapped") {
				if (val?.by_route?.[canonicalOrRouteKey]) {
					const rs = val.by_route[canonicalOrRouteKey];
					const total = rs.successes + rs.failures;
					return {
						...rs,
						total,
						successRate: total > 0 ? rs.successes / total : 0,
					};
				}
			} else if (val?.by_route?.[canonicalOrRouteKey]) {
				const rs = val.by_route[canonicalOrRouteKey];
				const total = rs.successes + rs.failures;
				return {
					...rs,
					total,
					successRate: total > 0 ? rs.successes / total : 0,
				};
			}
		}
		return null;
	}

	/**
	 * Get stats for a specific route (by_route lookup across all canonical entries).
	 */
	getRouteStats(taskType, routeKey) {
		const unitBlob = this.data[taskType];
		if (!unitBlob) return null;
		for (const [key, val] of Object.entries(unitBlob)) {
			if (key === "_unmapped") {
				if (val?.by_route?.[routeKey]) return val.by_route[routeKey];
			} else if (val?.by_route?.[routeKey]) {
				return val.by_route[routeKey];
			}
		}
		return null;
	}

	/**
	 * Get all models for a task type, ranked by success rate.
	 *
	 * Primary mode: iterates canonical ids using aggregate stats.
	 * Backward-compat fallback: if no canonical entries exist (no registry
	 * wired), iterates _unmapped.by_route entries instead so legacy tests
	 * that use bare model IDs still work.
	 */
	getRankedModels(taskType, minSamples = 1) {
		if (!this.data[taskType]) return [];

		const models = [];
		let hasCanonical = false;

		for (const [key, entry] of Object.entries(this.data[taskType])) {
			if (key === "_unmapped") continue;
			// New format: entry has aggregate + by_route
			const agg = entry?.aggregate;
			if (!agg) continue;
			hasCanonical = true;
			const total = agg.successes + agg.failures;
			if (total < minSamples) continue;
			const successRate = total > 0 ? agg.successes / total : 0;
			models.push({
				modelId: key, // canonical id
				successRate,
				attempts: total,
				tokens: agg.totalTokens ?? 0,
				cost: agg.totalCost ?? 0,
				latestAttempt: agg.lastUsed,
			});
		}

		// Backward-compat: when no canonical entries exist (registry not wired),
		// fall back to _unmapped.by_route so bare-ID tests still get rankings.
		if (!hasCanonical) {
			const unmapped = this.data[taskType]["_unmapped"];
			if (unmapped?.by_route) {
				for (const [routeKey, rs] of Object.entries(unmapped.by_route)) {
					if (!rs) continue;
					const total = (rs.successes ?? 0) + (rs.failures ?? 0);
					if (total < minSamples) continue;
					const successRate = total > 0 ? rs.successes / total : 0;
					models.push({
						modelId: routeKey,
						successRate,
						attempts: total,
						tokens: rs.totalTokens ?? 0,
						cost: rs.totalCost ?? 0,
						latestAttempt: rs.lastUsed,
					});
				}
			}
		}

		return models.sort((a, b) => b.successRate - a.successRate);
	}

	/**
	 * Check if a model should be demoted (fails >50% on this task type).
	 * Accepts a canonical id (aggregate demotion) or routeKey (route-level).
	 */
	shouldDemote(taskType, modelId, thresholdFailureRate = 0.5) {
		// Try aggregate lookup first (canonical id)
		const stats = this.getStats(taskType, modelId);
		if (!stats) return false;

		const failureRate = 1 - stats.successRate;
		const totalAttempts = stats.successes + stats.failures;

		return failureRate > thresholdFailureRate && totalAttempts >= 4;
	}

	/**
	 * Get candidates for A/B testing (new model vs incumbent).
	 * Returns: { incumbent, challengers: [] }
	 */
	getABTestCandidates(taskType, minSamples = 1, lowRiskFraction = 0.1) {
		const incumbentCandidates = this.getRankedModels(
			taskType,
			Math.max(3, minSamples),
		);
		const allRanked = this.getRankedModels(taskType, 1);
		if (incumbentCandidates.length < 1 || allRanked.length < 2) return null;

		const incumbent = incumbentCandidates[0].modelId;
		const challengers = allRanked
			.filter((candidate) => candidate.modelId !== incumbent)
			.slice(0, 2);
		if (challengers.length === 0) return null;

		return {
			incumbent,
			challengers,
			testBudget: Math.max(1, Math.ceil(1 / lowRiskFraction)), // E.g., 10 tasks
		};
	}

	/**
	 * Track A/B test results and decide on promotion/demotion.
	 */
	analyzeABTest(_taskType, results) {
		// results: { incumbentWins, challengerWins, incumbentAvgLatency, challengerAvgLatency }
		const { incumbentWins, challengerWins } = results;
		const total = incumbentWins + challengerWins;

		if (total < 5) {
			return { recommendation: "inconclusive", reason: "insufficient samples" };
		}

		const challengerSuccessRate = challengerWins / total;
		const incumbentSuccessRate = incumbentWins / total;

		if (challengerSuccessRate > incumbentSuccessRate + 0.1) {
			return {
				recommendation: "promote",
				reason: `challenger ${challengerSuccessRate.toFixed(2)} vs incumbent ${incumbentSuccessRate.toFixed(2)}`,
			};
		}

		return {
			recommendation: "continue",
			reason: "incumbent still ahead",
		};
	}
}

/**
 * Failure Analyzer — categorize and log why models failed.
 *
 * Purpose: Understand failure patterns (timeout, quality, cost) to inform
 * promotion/demotion decisions.
 */
class FailureAnalyzer {
	constructor(basePath) {
		this.basePath = basePath;
		this.logsPath = basePath
			? join(basePath, ".sf", "model-failure-log.jsonl")
			: null;
		this.entries = [];
	}

	logFailure(
		taskType,
		modelId,
		failureOrReason,
		timeoutArg = false,
		contextArg = {},
	) {
		const failure =
			typeof failureOrReason === "object" && failureOrReason !== null
				? failureOrReason
				: {
						reason: failureOrReason,
						timeout: timeoutArg,
						context: contextArg,
					};
		const {
			reason = "unknown",
			timeout = false,
			tokensUsed = 0,
			context = {},
			timestamp = new Date().toISOString(),
		} = failure;

		const entry = {
			schemaVersion: MODEL_FAILURE_LOG_SCHEMA_VERSION,
			timestamp,
			taskType,
			modelId,
			reason,
			timeout,
			tokensUsed,
			context,
		};

		this.entries.push(entry);
		if (!this.logsPath) {
			return;
		}
		try {
			const dir = dirname(this.logsPath);
			if (!existsSync(dir)) {
				mkdirSync(dir, { recursive: true });
			}
			appendFileSync(this.logsPath, JSON.stringify(entry) + "\n", "utf-8");
		} catch (err) {
			console.error("Failed to log model failure:", err);
		}
	}

	/**
	 * Get failure summary for a model on a task type.
	 * Returns: { reasons: { [reason]: count }, patterns: [...] }
	 */
	getFailureSummary(taskType, modelId) {
		if (!this.logsPath) {
			return this._summarizeEntries(taskType, modelId, this.entries);
		}
		if (!existsSync(this.logsPath)) {
			return this._summarizeEntries(taskType, modelId, this.entries);
		}

		try {
			const content = readFileSync(this.logsPath, "utf-8");
			const lines = content.trim() ? content.trim().split("\n") : [];
			const entries = [
				...this.entries,
				...lines
					.map((line) => normalizeModelFailureEntry(JSON.parse(line)))
					.filter(Boolean),
			];
			return this._summarizeEntries(taskType, modelId, entries);
		} catch {
			return this._summarizeEntries(taskType, modelId, this.entries);
		}
	}

	_summarizeEntries(taskType, modelId, entries) {
		const reasons = {};
		const failures = [];

		for (const entry of entries) {
			if (entry.taskType !== taskType || entry.modelId !== modelId) continue;
			reasons[entry.reason] = (reasons[entry.reason] || 0) + 1;
			failures.push(entry);
		}

		return {
			failureCount: failures.length,
			reasons,
			patterns: this._detectPatterns(failures),
		};
	}

	_detectPatterns(failures) {
		// Analyze failure distribution to detect systematic issues
		const timeoutCount = failures.filter((f) => f.timeout).length;
		const qualityCount = failures.filter(
			(f) => f.reason === "quality_check_failed",
		).length;
		const patterns = [];

		if (timeoutCount / Math.max(failures.length, 1) > 0.5) {
			patterns.push("timeout_prone");
		}
		if (qualityCount / Math.max(failures.length, 1) > 0.5) {
			patterns.push("quality_issues");
		}

		return patterns;
	}
}

function normalizeModelFailureEntry(entry) {
	if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
	const schemaVersion = entry.schemaVersion ?? MODEL_FAILURE_LOG_SCHEMA_VERSION;
	if (schemaVersion !== MODEL_FAILURE_LOG_SCHEMA_VERSION) return null;
	return {
		...entry,
		schemaVersion,
	};
}

/**
 * Main API: Integrate model learning into dispatch workflow.
 *
 * Usage in auto-dispatch.ts:
 * ```
 * const learner = new ModelLearner(projectPath);
 * learner.recordOutcome("execute-task", modelUsed, {
 *   success: taskSucceeded,
 *   timeout: taskTimedOut,
 *   tokensUsed: totalTokens,
 *   costUsd: modelCost,
 * });
 * ```
 */
export class ModelLearner {
	constructor(basePath) {
		this.basePath = basePath;
		this.tracker = new ModelPerformanceTracker(basePath);
		this.analyzer = new FailureAnalyzer(basePath);
	}

	/**
	 * Record an outcome for a model on a task.
	 */
	recordOutcome(taskType, modelId, outcome) {
		this.tracker.recordOutcome(taskType, modelId, outcome);
	}

	/**
	 * Log failure details for analysis.
	 */
	logFailure(taskType, modelId, failure) {
		this.analyzer.logFailure(taskType, modelId, failure);
	}

	/**
	 * Get ranked models for a task type (for intelligent routing).
	 */
	getRankedModels(taskType, minSamples = 1) {
		return this.tracker.getRankedModels(taskType, minSamples);
	}

	/**
	 * Decide whether to demote a model.
	 */
	shouldDemote(taskType, modelId, failureThreshold = 0.5) {
		return this.tracker.shouldDemote(taskType, modelId, failureThreshold);
	}

	/**
	 * Get A/B test candidates (for hypothesis testing).
	 */
	getABTestCandidates(taskType, minSamples = 1) {
		return this.tracker.getABTestCandidates(taskType, minSamples);
	}

	/**
	 * Analyze A/B test results.
	 */
	analyzeABTest(taskType, results) {
		return this.tracker.analyzeABTest(taskType, results);
	}

	/**
	 * Get failure analysis for a model.
	 */
	getFailureAnalysis(taskType, modelId) {
		return this.analyzer.getFailureSummary(taskType, modelId);
	}

	getFailureSummary(taskType, modelId) {
		return this.analyzer.getFailureSummary(taskType, modelId);
	}
}

export { FailureAnalyzer, ModelPerformanceTracker };

export default {
	ModelLearner,
	ModelPerformanceTracker,
	FailureAnalyzer,
};

/**
 * Opt-in backfill: re-aggregate model-performance.json from journal events.
 *
 * Reads all `model_auto_resolved` events (which carry provider + model) from
 * `.sf/journal/*.jsonl`, joins them with `unit_completed` events on traceId to
 * get success/failure signal, then writes a fresh model-performance.json keyed
 * by canonical ids. The old file is timestamped-renamed as a backup.
 *
 * Only call this explicitly — never at session start — so operators are not
 * surprised by rewritten data.
 *
 * @param {string} basePath - project root (same as passed to ModelLearner)
 * @returns {{ written: number, unmapped: number, backupPath: string | null }}
 */
export async function backfillModelPerformanceFromJournal(basePath) {
	// Ensure registry is loaded so canonicalIdFor works.
	await registryReady;

	const journalDir = join(basePath, ".sf", "journal");
	const perfPath = join(basePath, ".sf", "model-performance.json");

	if (!existsSync(journalDir)) {
		return { written: 0, unmapped: 0, backupPath: null };
	}

	// ── Phase 1: Collect unit_completed outcomes keyed by traceId ────────────
	// Shape: Map<traceId, { status: "completed" | "failed", tokens: number, cost: number }>
	const outcomeByTrace = new Map();
	// ── Phase 2: Collect model_auto_resolved events ───────────────────────────
	// Shape: Array<{ traceId, unitType, routeKey }>
	const dispatches = [];

	const files = readdirSync(journalDir)
		.filter((f) => f.endsWith(".jsonl"))
		.sort();

	for (const file of files) {
		const content = readFileSync(join(journalDir, file), "utf-8");
		for (const line of content.split("\n")) {
			if (!line.trim()) continue;
			let event;
			try {
				event = JSON.parse(line);
			} catch {
				continue;
			}
			const et = event.eventType;
			const d = event.data;
			if (!d) continue;

			if (et === "model_auto_resolved") {
				const inner = d.data ?? d;
				const provider = inner.resolvedProvider;
				const model = inner.resolvedModel;
				const unitType = inner.unitType ?? d.unitType;
				const traceId = inner.traceId ?? d.traceId ?? event.traceId;
				if (provider && model && unitType && traceId) {
					dispatches.push({
						traceId,
						unitType,
						routeKey: `${provider}/${model}`,
					});
				}
			} else if (et === "unit_completed") {
				const inner = d.data ?? d;
				const traceId = inner.traceId ?? d.traceId ?? event.traceId;
				const status = inner.status ?? "completed";
				const tokens = inner.tokens ?? 0;
				const cost = inner.cost_usd ?? inner.cost ?? 0;
				if (traceId) {
					// last-write wins (multiple units per trace is fine)
					outcomeByTrace.set(traceId, {
						status,
						tokens: typeof tokens === "number" ? tokens : (tokens.total ?? 0),
						cost,
					});
				}
			}
		}
	}

	// ── Phase 3: Build aggregated performance data ────────────────────────────
	// Shape: { [taskType]: { [canonicalId]: { aggregate: {...}, by_route: {...} } } }
	const newData = {};
	let written = 0;
	let unmapped = 0;

	for (const { traceId, unitType, routeKey } of dispatches) {
		const outcome = outcomeByTrace.get(traceId);
		const success = outcome ? outcome.status === "completed" : true; // assume success if not found
		const tokensUsed = outcome?.tokens ?? 0;
		const costUsd = outcome?.cost ?? 0;
		const timestamp = new Date().toISOString();

		if (!newData[unitType]) newData[unitType] = {};

		const canonicalId = tryCanonicalIdFor(routeKey);
		if (canonicalId === null) {
			unmapped++;
			if (!newData[unitType]["_unmapped"]) {
				newData[unitType]["_unmapped"] = { by_route: {} };
			}
			const unmappedEntry = newData[unitType]["_unmapped"];
			if (!unmappedEntry.by_route[routeKey]) {
				unmappedEntry.by_route[routeKey] = emptyRouteStats(timestamp);
			}
			const rs = unmappedEntry.by_route[routeKey];
			if (success) rs.successes++;
			else rs.failures++;
			rs.totalTokens += tokensUsed;
			rs.totalCost += costUsd;
			rs.lastUsed = timestamp;
		} else {
			written++;
			if (!newData[unitType][canonicalId]) {
				newData[unitType][canonicalId] = {
					aggregate: emptyRouteStats(timestamp),
					by_route: {},
				};
			}
			const canonicalEntry = newData[unitType][canonicalId];
			if (!canonicalEntry.by_route[routeKey]) {
				canonicalEntry.by_route[routeKey] = emptyRouteStats(timestamp);
			}
			const rs = canonicalEntry.by_route[routeKey];
			if (success) rs.successes++;
			else rs.failures++;
			rs.totalTokens += tokensUsed;
			rs.totalCost += costUsd;
			rs.lastUsed = timestamp;
			recomputeAggregate(canonicalEntry);
		}
	}

	// ── Phase 4: Backup old file and write new one ────────────────────────────
	let backupPath = null;
	if (existsSync(perfPath)) {
		backupPath = `${perfPath}.bak.${Date.now()}`;
		renameSync(perfPath, backupPath);
	}
	writeFileSync(perfPath, JSON.stringify(newData, null, 2), "utf-8");

	return { written, unmapped, backupPath };
}
