/**
 * slice-routing-cache.js — per-slice sticky-model routing cache.
 *
 * Why: model routing is currently computed per-unit, so the executor can flip
 * between models within a single slice (M001-6377a4/S04 routed T01 to
 * gemini-3-flash-preview, then T02 to codestral-latest — the second was
 * unfit and refused the task, see ADR-0079). Once a model has successfully
 * completed work on a slice, prefer it for the slice's sibling units unless
 * a hard mismatch forces a switch.
 *
 * Contract:
 *   - Cache is small JSON keyed by sliceId. Each entry stores provider/id and
 *     timestamps so stale entries can be aged out.
 *   - Best-effort: read/write errors are swallowed; routing always has a
 *     fallback through the capability scorer.
 *   - Only successful outcomes (`continue` or `complete`) write to the cache.
 *     Refusal/blocker outcomes clear the entry so a failing model does not
 *     re-attach to the slice.
 *
 * Consumer: auto-model-selection.js reads before calling
 * resolveModelForComplexity; auto/phases-unit.js writes after a successful
 * checkpoint and clears on `executor-refused`.
 */
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
import { dirname, join } from "node:path";
import { atomicWriteSync } from "./atomic-write.js";
import { sfRuntimeRoot } from "./paths.js";

const CACHE_FILE = "slice-routing.json";
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

function cachePath(basePath) {
	return join(sfRuntimeRoot(basePath), CACHE_FILE);
}

/**
 * Extract the slice scope from a unit id.
 *
 * Supports the conventional SF unit-id grammar:
 *   - Execute task: "<milestoneId>/<sliceId>/<taskId>"        → "<milestoneId>/<sliceId>"
 *   - Plan / complete slice: "<milestoneId>/<sliceId>"        → "<milestoneId>/<sliceId>" (already a slice)
 *   - Milestone-level units: "<milestoneId>"                   → "<milestoneId>" (no slice scope)
 *
 * Returns null when the unit id is missing or unparseable.
 */
export function extractSliceScope(unitId) {
	if (!unitId || typeof unitId !== "string") return null;
	const parts = unitId.split("/").filter(Boolean);
	if (parts.length === 0) return null;
	if (parts.length === 1) return parts[0]; // milestone-only
	return `${parts[0]}/${parts[1]}`;
}

function readCache(basePath) {
	const path = cachePath(basePath);
	if (!existsSync(path)) return {};
	try {
		return JSON.parse(readFileSync(path, "utf-8"));
	} catch {
		return {};
	}
}

function writeCache(basePath, data) {
	const path = cachePath(basePath);
	try {
		mkdirSync(dirname(path), { recursive: true });
		atomicWriteSync(path, JSON.stringify(data, null, 2));
	} catch {
		// best-effort
	}
}

/**
 * Record the model that successfully handled a unit. The slice scope is
 * derived from the unit id. Subsequent units in the same slice will see this
 * as the sticky hint.
 */
export function recordSliceRouting(basePath, unitType, unitId, model) {
	if (!basePath || !model?.id) return;
	const sliceId = extractSliceScope(unitId);
	if (!sliceId) return;
	const data = readCache(basePath);
	data[sliceId] = {
		provider: String(model.provider ?? ""),
		id: String(model.id),
		ts: new Date().toISOString(),
		lastUnitType: String(unitType ?? ""),
		lastUnitId: String(unitId ?? ""),
	};
	writeCache(basePath, data);
}

/**
 * Look up the sticky model for the slice that contains this unit. Returns
 * null when there is no entry, when it's older than maxAgeMs, or when the
 * cache cannot be read.
 *
 * @param {string} basePath
 * @param {string} unitType
 * @param {string} unitId
 * @param {object} [options]
 * @param {number} [options.maxAgeMs=7d]
 * @returns {{ provider: string, id: string } | null}
 */
export function readStickyModelForUnit(
	basePath,
	_unitType,
	unitId,
	options = {},
) {
	if (!basePath) return null;
	const sliceId = extractSliceScope(unitId);
	if (!sliceId) return null;
	const data = readCache(basePath);
	const entry = data[sliceId];
	if (!entry?.id) return null;
	const maxAgeMs = Number(options.maxAgeMs ?? DEFAULT_MAX_AGE_MS);
	if (entry.ts) {
		const age = Date.now() - new Date(entry.ts).getTime();
		if (Number.isFinite(age) && age > maxAgeMs) return null;
	}
	return {
		provider: String(entry.provider ?? ""),
		id: String(entry.id),
	};
}

/**
 * Evict the sticky entry for the slice containing this unit. Called when the
 * model attached to the slice refuses or hits a hard mismatch, so the next
 * dispatch falls back to the capability scorer instead of re-pinning the
 * broken model.
 */
export function clearSliceRoutingForUnit(basePath, unitId) {
	if (!basePath) return;
	const sliceId = extractSliceScope(unitId);
	if (!sliceId) return;
	const data = readCache(basePath);
	if (!(sliceId in data)) return;
	delete data[sliceId];
	if (Object.keys(data).length === 0) {
		try {
			unlinkSync(cachePath(basePath));
		} catch {
			// best-effort
		}
		return;
	}
	writeCache(basePath, data);
}

/**
 * Test/debug only — read the entire cache. Production callers should use
 * readStickyModelForUnit instead.
 */
export function _readCacheForTests(basePath) {
	return readCache(basePath);
}
