/**
 * SF Metrics — Token & Cost Tracking
 *
 * Accumulates per-unit usage data across autonomous mode sessions.
 * Data is extracted from session entries before each context wipe,
 * written to the SF SQLite database, and surfaced in the dashboard.
 *
 * Data flow:
 *   1. Before newSession() wipes context, snapshotUnitMetrics() scans
 *      session entries for AssistantMessage usage data
 *   2. The unit record is upserted to unit_metrics in the project DB
 *   3. The dashboard overlay and progress widget read from the in-memory ledger
 *   4. On crash recovery or fresh start, the ledger is loaded from the DB
 */
import { formatModelIdentity } from "./model-identity.js";
import {
	getAllUnitMetrics,
	getDatabase,
	getProjectStartedAt,
	pruneUnitMetrics,
	setProjectStartedAt,
	upsertUnitMetrics,
} from "./sf-db.js";
import { getAndClearSkills } from "./skill-telemetry.js";
import { parseUnitId } from "./unit-id.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js";

// Re-export from shared — import directly from format-utils to avoid pulling
// in the full barrel (mod.js → ui.js → @singularity-forge/tui) which breaks when loaded
// outside jiti's alias resolution (e.g. dynamic import in auto-loop reports).
export { formatTokenCount } from "@singularity-forge/coding-agent";

// ─── Learning Integration ─────────────────────────────────────────────────────
function formatAggregateModelIdentity(modelId) {
	const slashIdx = modelId.indexOf("/");
	if (slashIdx === -1) return formatModelIdentity({ id: modelId });
	return formatModelIdentity({
		provider: modelId.slice(0, slashIdx),
		id: modelId.slice(slashIdx + 1),
	});
}
/**
 * Record a unit outcome to the llm_task_outcomes table for Bayesian learning,
 * and also to model-learner for per-task-type performance tracking.
 *
 * Integration point for quick win #2: model-learner activates continuous
 * per-task-type model performance tracking for adaptive routing decisions.
 */
async function recordUnitOutcome(unit) {
	const db = getDatabase();
	if (!db) return;
	try {
		const { recordOutcome } = await import("./learning/outcome-recorder.mjs");
		const modelId = unit.model;
		const slashIdx = modelId.indexOf("/");
		// Unit metrics always carry full "provider/model" slugs upstream;
		// drop bare-id entries rather than guessing the provider.
		if (slashIdx === -1) return;
		const provider = modelId.slice(0, slashIdx);
		const outcome = {
			modelId,
			provider,
			unitType: unit.type,
			unitId: unit.id,
			succeeded: true, // metrics.json entry implies completion
			// retries omitted — UnitMetrics does not track retry count; defaults to 0 in outcome-recorder
			escalated: !!unit.modelDowngraded,
			verification_passed: null,
			blocker_discovered: false,
			duration_ms: unit.finishedAt - unit.startedAt,
			tokens_total: unit.tokens.total,
			cost_usd: unit.cost,
			recorded_at: unit.startedAt,
		};

		// Record to UOK llm_task_outcomes table
		recordOutcome(db, outcome);

		// Quick Win #2: Also record to model-learner for per-task-type tracking
		try {
			const { ModelLearner, registryReady } = await import(
				"./model-learner.js"
			);
			// Await registry load so canonicalIdFor is wired before the first
			// recordOutcome() call. Without this, the timing window between module
			// load and registry resolution causes all routes to land in _unmapped.
			await registryReady;
			const learner = new ModelLearner(basePath);
			learner.recordOutcome(unit.type, modelId, {
				success: true,
				timeout: false,
				tokensUsed: unit.tokens.total,
				costUsd: unit.cost,
			});
		} catch {
			/* model-learner integration is optional; never block outcome recording */
		}
	} catch {
		/* fire-and-forget */
	}
}
export function classifyUnitPhase(unitType) {
	switch (unitType) {
		case "research-milestone":
		case "research-slice":
			return "research";
		case "discuss-milestone":
		case "discuss-slice":
			return "discussion";
		case "plan-milestone":
		case "roadmap-meeting":
		case "plan-slice":
			return "planning";
		case "execute-task":
			return "execution";
		case "complete-slice":
			return "completion";
		case "reassess-roadmap":
			return "reassessment";
		default:
			return "execution";
	}
}
// ─── In-memory state ──────────────────────────────────────────────────────────
let ledger = null;
let basePath = "";
// ─── Public API ───────────────────────────────────────────────────────────────
/**
 * Initialize the metrics system for a given project.
 * Loads existing ledger from disk if present.
 */
export function initMetrics(base) {
	basePath = base;
	ledger = loadLedger(base);
}
/**
 * Reset in-memory state. Called when autonomous mode stops.
 */
export function resetMetrics() {
	ledger = null;
	basePath = "";
}
/**
 * Snapshot usage metrics from the current session before it's wiped.
 * Scans session entries for AssistantMessage usage data.
 */
export function snapshotUnitMetrics(
	ctx,
	unitType,
	unitId,
	startedAt,
	model,
	opts,
) {
	if (!ledger) return null;
	const entries = ctx.sessionManager.getEntries();
	if (!entries || entries.length === 0) return null;
	const tokens = {
		input: 0,
		output: 0,
		cacheRead: 0,
		cacheWrite: 0,
		total: 0,
	};
	let cost = 0;
	let toolCalls = 0;
	let assistantMessages = 0;
	let userMessages = 0;
	for (const entry of entries) {
		if (entry.type !== "message") continue;
		const msg = entry.message;
		if (!msg) continue;
		if (msg.role === "assistant") {
			assistantMessages++;
			if (msg.usage) {
				tokens.input += msg.usage.input ?? 0;
				tokens.output += msg.usage.output ?? 0;
				tokens.cacheRead += msg.usage.cacheRead ?? 0;
				tokens.cacheWrite += msg.usage.cacheWrite ?? 0;
				tokens.total += msg.usage.totalTokens ?? 0;
				if (msg.usage.cost != null) {
					const c = msg.usage.cost;
					cost += typeof c === "number" ? c : (c.total ?? 0);
				}
			}
			// Count tool calls in this message.
			// Anthropic SDK uses snake_case `tool_use`; legacy SF paths sometimes
			// emit camelCase `toolCall`. Codex review 2026-05-17 + live evidence
			// from M048/S01 (agent self-reported ~66 calls; unit_metrics row had
			// tool_calls=2) showed the snake_case form was being undercounted by
			// ~30× — every Wiggums-class detector that reads tool_calls < N as a
			// stuck signature was broken. Accept both shapes.
			if (msg.content && Array.isArray(msg.content)) {
				for (const block of msg.content) {
					if (block?.type === "tool_use" || block?.type === "toolCall") {
						toolCalls++;
					}
				}
			}
		} else if (msg.role === "user") {
			userMessages++;
		}
	}
	const unit = {
		type: unitType,
		id: unitId,
		model,
		startedAt,
		finishedAt: Date.now(),
		...(opts?.autoSessionKey ? { autoSessionKey: opts.autoSessionKey } : {}),
		tokens,
		cost,
		toolCalls,
		assistantMessages,
		userMessages,
		apiRequests: assistantMessages, // each assistant message = one API request
		...(opts?.tier ? { tier: opts.tier } : {}),
		...(opts?.modelDowngraded !== undefined
			? { modelDowngraded: opts.modelDowngraded }
			: {}),
		...(opts?.contextWindowTokens !== undefined
			? { contextWindowTokens: opts.contextWindowTokens }
			: {}),
		...(opts?.truncationSections !== undefined
			? { truncationSections: opts.truncationSections }
			: {}),
		...(opts?.continueHereFired !== undefined
			? { continueHereFired: opts.continueHereFired }
			: {}),
		...(opts?.promptCharCount != null
			? { promptCharCount: opts.promptCharCount }
			: {}),
		...(opts?.baselineCharCount != null
			? { baselineCharCount: opts.baselineCharCount }
			: {}),
	};
	// Auto-capture skill telemetry (#599)
	const skills = getAndClearSkills();
	if (skills.length > 0) {
		unit.skills = skills;
	}
	// Compute cache hit rate
	if (tokens.cacheRead > 0 || tokens.input > 0) {
		const totalInput = tokens.cacheRead + tokens.input;
		unit.cacheHitRate =
			totalInput > 0 ? Math.round((tokens.cacheRead / totalInput) * 100) : 0;
	}
	// ── Idempotency guard ──────────────────────────────────────────────────
	// Prevent duplicate metrics entries when multiple callers snapshot the
	// same unit (e.g. idle-watchdog closeoutUnit + normal loop closeoutUnit).
	// A unit is considered a duplicate when type, id, AND startedAt all match
	// an existing entry. On duplicate, the existing entry is updated in-place
	// with the latest finishedAt and token counts instead of appending.
	const dupeIdx = ledger.units.findIndex(
		(u) =>
			u.type === unit.type &&
			u.id === unit.id &&
			u.startedAt === unit.startedAt,
	);
	if (dupeIdx >= 0) {
		ledger.units[dupeIdx] = unit;
	} else {
		ledger.units.push(unit);
	}
	saveLedger(basePath, ledger);
	// Background outcome recording for Bayesian learning
	recordUnitOutcome(unit).catch(() => {
		/* fire-and-forget */
	});
	// Also record to centralized metrics collector (Prometheus + SQLite)
	// Fire-and-forget: don't block the snapshot on metrics-central
	import("./metrics-central.js")
		.then(({ recordCost }) => {
			recordCost(
				unitId,
				model,
				tokens.input,
				tokens.output,
				cost,
				classifyUnitPhase(unitType),
			);
		})
		.catch(() => {
			// metrics-central is optional; never block snapshot
		});

	if (isAuditEnvelopeEnabled()) {
		emitUokAuditEvent(
			basePath,
			buildAuditEnvelope({
				traceId: opts?.traceId ?? `metrics:${unitType}:${unitId}`,
				turnId: opts?.turnId,
				causedBy: opts?.causedBy,
				category: "metrics",
				type: "unit-metrics-snapshot",
				payload: {
					unitType,
					unitId,
					model,
					tokens: unit.tokens,
					cost: unit.cost,
					toolCalls: unit.toolCalls,
				},
			}),
		);
	}
	return unit;
}
/**
 * Get the current ledger (read-only).
 */
export function getLedger() {
	return ledger;
}
function emptyTokens() {
	return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
}
function addTokens(a, b) {
	return {
		input: a.input + b.input,
		output: a.output + b.output,
		cacheRead: a.cacheRead + b.cacheRead,
		cacheWrite: a.cacheWrite + b.cacheWrite,
		total: a.total + b.total,
	};
}
export function aggregateByPhase(units) {
	const map = new Map();
	for (const u of units) {
		const phase = classifyUnitPhase(u.type);
		let agg = map.get(phase);
		if (!agg) {
			agg = { phase, units: 0, tokens: emptyTokens(), cost: 0, duration: 0 };
			map.set(phase, agg);
		}
		agg.units++;
		agg.tokens = addTokens(agg.tokens, u.tokens);
		agg.cost += u.cost;
		agg.duration += u.finishedAt - u.startedAt;
	}
	// Return in a stable order
	const order = [
		"research",
		"discussion",
		"planning",
		"execution",
		"completion",
		"reassessment",
	];
	return order.map((p) => map.get(p)).filter((a) => !!a);
}
export function aggregateBySlice(units) {
	const map = new Map();
	for (const u of units) {
		const { milestone, slice } = parseUnitId(u.id);
		const sliceId = slice ? `${milestone}/${slice}` : milestone;
		let agg = map.get(sliceId);
		if (!agg) {
			agg = { sliceId, units: 0, tokens: emptyTokens(), cost: 0, duration: 0 };
			map.set(sliceId, agg);
		}
		agg.units++;
		agg.tokens = addTokens(agg.tokens, u.tokens);
		agg.cost += u.cost;
		agg.duration += u.finishedAt - u.startedAt;
	}
	return Array.from(map.values()).sort((a, b) =>
		a.sliceId.localeCompare(b.sliceId),
	);
}
export function aggregateByModel(units) {
	const map = new Map();
	for (const u of units) {
		const modelIdentity = formatAggregateModelIdentity(u.model);
		let agg = map.get(modelIdentity);
		if (!agg) {
			agg = { model: modelIdentity, units: 0, tokens: emptyTokens(), cost: 0 };
			map.set(modelIdentity, agg);
		}
		agg.units++;
		agg.tokens = addTokens(agg.tokens, u.tokens);
		agg.cost += u.cost;
		if (
			u.contextWindowTokens !== undefined &&
			agg.contextWindowTokens === undefined
		) {
			agg.contextWindowTokens = u.contextWindowTokens;
		}
	}
	return Array.from(map.values()).sort((a, b) => b.cost - a.cost);
}
export function getProjectTotals(units) {
	const totals = {
		units: units.length,
		tokens: emptyTokens(),
		cost: 0,
		duration: 0,
		toolCalls: 0,
		assistantMessages: 0,
		userMessages: 0,
		apiRequests: 0,
		totalTruncationSections: 0,
		continueHereFiredCount: 0,
	};
	for (const u of units) {
		totals.tokens = addTokens(totals.tokens, u.tokens);
		totals.cost += u.cost;
		totals.duration += u.finishedAt - u.startedAt;
		totals.toolCalls += u.toolCalls;
		totals.assistantMessages += u.assistantMessages;
		totals.userMessages += u.userMessages;
		totals.apiRequests += u.apiRequests ?? u.assistantMessages; // fallback for pre-existing data
		totals.totalTruncationSections += u.truncationSections ?? 0;
		if (u.continueHereFired) totals.continueHereFiredCount++;
	}
	return totals;
}
export function aggregateByTier(units) {
	const map = new Map();
	for (const u of units) {
		const tier = u.tier ?? "unknown";
		let agg = map.get(tier);
		if (!agg) {
			agg = { tier, units: 0, tokens: emptyTokens(), cost: 0, downgraded: 0 };
			map.set(tier, agg);
		}
		agg.units++;
		agg.tokens = addTokens(agg.tokens, u.tokens);
		agg.cost += u.cost;
		if (u.modelDowngraded) agg.downgraded++;
	}
	const order = ["light", "standard", "heavy", "unknown"];
	return order.map((t) => map.get(t)).filter((a) => !!a);
}
/**
 * Format a summary of savings from dynamic routing.
 * Returns empty string if no units were downgraded.
 */
export function formatTierSavings(units) {
	const downgraded = units.filter((u) => u.modelDowngraded);
	if (downgraded.length === 0) return "";
	const downgradedCost = downgraded.reduce((sum, u) => sum + u.cost, 0);
	const totalUnits = units.filter((u) => u.tier).length;
	const pct =
		totalUnits > 0 ? Math.round((downgraded.length / totalUnits) * 100) : 0;
	return `Dynamic routing: ${downgraded.length}/${totalUnits} units downgraded (${pct}%), cost: ${formatCost(downgradedCost)}`;
}
/**
 * Compute aggregate cache hit rate across all units.
 * Returns percentage 0-100.
 */
export function aggregateCacheHitRate() {
	if (!ledger || ledger.units.length === 0) return 0;
	let totalInput = 0;
	let totalCacheRead = 0;
	for (const unit of ledger.units) {
		totalInput += unit.tokens.input;
		totalCacheRead += unit.tokens.cacheRead;
	}
	const total = totalInput + totalCacheRead;
	return total > 0 ? Math.round((totalCacheRead / total) * 100) : 0;
}
// ─── Formatting helpers ───────────────────────────────────────────────────────
export function formatCost(cost) {
	const n = Number(cost) || 0;
	if (n < 0.01) return `$${n.toFixed(4)}`;
	if (n < 1) return `$${n.toFixed(3)}`;
	return `$${n.toFixed(2)}`;
}
// ─── Budget Prediction ────────────────────────────────────────────────────────
/**
 * Calculate average cost per unit type from completed units.
 * Returns a Map from unit type to average cost in USD.
 */
export function getAverageCostPerUnitType(units) {
	const sums = new Map();
	for (const u of units) {
		const entry = sums.get(u.type) ?? { total: 0, count: 0 };
		entry.total += u.cost;
		entry.count += 1;
		sums.set(u.type, entry);
	}
	const avgs = new Map();
	for (const [type, { total, count }] of sums) {
		avgs.set(type, total / count);
	}
	return avgs;
}
/**
 * Estimate remaining cost given average costs and remaining unit counts.
 * @param avgCosts - Average cost per unit type
 * @param remainingUnits - Array of unit types still to dispatch
 * @param fallbackAvg - Fallback average if unit type not seen before
 * @returns Estimated remaining cost in USD
 */
export function predictRemainingCost(avgCosts, remainingUnits, fallbackAvg) {
	// If no averages available, use overall average as fallback
	const allAvgs = [...avgCosts.values()];
	const overallAvg =
		fallbackAvg ??
		(allAvgs.length > 0
			? allAvgs.reduce((a, b) => a + b, 0) / allAvgs.length
			: 0);
	let total = 0;
	for (const unitType of remainingUnits) {
		total += avgCosts.get(unitType) ?? overallAvg;
	}
	return total;
}
/**
 * Compute a projected remaining cost based on completed slice averages.
 *
 * Filters to slice-level entries (sliceId contains "/") to exclude bare milestone
 * aggregates from the average. Returns [] when fewer than 2 slice-level entries
 * exist (insufficient data for a reliable projection).
 *
 * If `budgetCeiling` is provided and `totalCost >= budgetCeiling`, a warning line
 * is appended to the result.
 */
export function formatCostProjection(
	completedSlices,
	remainingCount,
	budgetCeiling,
) {
	const sliceLevel = completedSlices.filter((s) => s.sliceId.includes("/"));
	if (sliceLevel.length < 2) return [];
	const totalCost = sliceLevel.reduce((sum, s) => sum + s.cost, 0);
	const avgCost = totalCost / sliceLevel.length;
	const projected = avgCost * remainingCount;
	const projLine = `Projected remaining: ${formatCost(projected)} (${formatCost(avgCost)}/slice avg × ${remainingCount} remaining)`;
	const result = [projLine];
	if (budgetCeiling !== undefined && totalCost >= budgetCeiling) {
		result.push(
			`Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)})`,
		);
	}
	return result;
}
// ─── Disk I/O ─────────────────────────────────────────────────────────────────
function defaultLedger() {
	return { version: 1, projectStartedAt: Date.now(), units: [] };
}
/**
 * Prune the metrics ledger to at most `keepCount` most-recent unit entries.
 *
 * Called by the doctor when the ledger exceeds the bloat threshold.
 * Keeps the newest entries and discards the oldest.
 * Preserves `projectStartedAt`.
 *
 * Updates both the DB and the in-memory ledger if it is loaded,
 * so the current session sees the pruned state immediately.
 *
 * @returns the number of entries removed, or 0 if no pruning was needed.
 */
export function pruneMetricsLedger(base, keepCount) {
	const db = getDatabase();
	const disk = loadLedgerFromDisk(base);
	if (!disk || disk.units.length <= keepCount) return 0;
	const removed = disk.units.length - keepCount;
	if (db) {
		pruneUnitMetrics(db, keepCount);
	}
	if (ledger) {
		ledger.units = ledger.units.slice(-keepCount);
	}
	return removed;
}
/**
 * Load ledger from DB without initializing in-memory state.
 * Used by history/export commands outside of autonomous mode.
 */
export function loadLedgerFromDisk(_base) {
	const db = getDatabase();
	if (!db) return null;
	try {
		const units = getAllUnitMetrics(db);
		const projectStartedAt = getProjectStartedAt(db) ?? Date.now();
		return { version: 1, projectStartedAt, units };
	} catch {
		return null;
	}
}
function loadLedger(_base) {
	const db = getDatabase();
	if (!db) return defaultLedger();
	try {
		const rawUnits = getAllUnitMetrics(db);
		const units = deduplicateUnits(rawUnits);
		const projectStartedAt = getProjectStartedAt(db) ?? Date.now();
		return { version: 1, projectStartedAt, units };
	} catch {
		return defaultLedger();
	}
}
/**
 * Collapse duplicate entries with the same (type, id, startedAt) triple.
 * Keeps the entry with the highest finishedAt (the most complete snapshot).
 *
 * This is a defensive measure against idle-watchdog race conditions that can
 * produce duplicate entries despite the in-memory idempotency guard
 * in snapshotUnitMetrics(). See #1943.
 */
function deduplicateUnits(units) {
	const map = new Map();
	for (const u of units) {
		const key = `${u.type}\0${u.id}\0${u.startedAt}`;
		const existing = map.get(key);
		if (!existing || u.finishedAt > existing.finishedAt) {
			map.set(key, u);
		}
	}
	return Array.from(map.values());
}
function saveLedger(_base, data) {
	const db = getDatabase();
	if (!db) return;
	try {
		for (const unit of data.units) {
			upsertUnitMetrics(db, unit);
		}
		setProjectStartedAt(db, data.projectStartedAt);
	} catch {
		/* non-fatal — DB writes are best-effort during autonomous mode */
	}
}
