/**
 * Swarm C — canonical-keyed model performance metrics tests.
 *
 * Tests:
 *  1. Migration round-trip: old-format file → boot loader → new file + backup exists.
 *  2. Aggregate invariant: aggregate.successes === sum(by_route[*].successes).
 *  3. _unmapped bucket: unknown route key lands in _unmapped, not dropped.
 *  4. Reading: sensible defaults (null) for a never-seen canonical id.
 *  5. Migration idempotency: running migration twice does not corrupt data.
 *  6. Two routes same canonical: aggregate sums correctly.
 */

import {
	existsSync,
	mkdirSync,
	readFileSync,
	rmSync,
	writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
	ModelLearner,
	ModelPerformanceTracker,
	setRegistryResolver,
} from "../model-learner.js";

// ── Stub registry ──────────────────────────────────────────────────────────────
// Inject a lightweight registry resolver that maps two test routes to the same
// canonical id, leaving all other routes unmapped. This avoids loading
// @singularity-forge/ai in tests.
//
// Route table:
//   "kimi-coding/kimi-k2.6"      → "kimi-k2.6"
//   "openrouter/moonshotai/kimi-k2.6" → "kimi-k2.6"
//   "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6"
//   everything else               → null  (→ _unmapped)
function makeStubResolver(table: Record<string, string | null> = {}) {
	const defaultTable: Record<string, string | null> = {
		"kimi-coding/kimi-k2.6": "kimi-k2.6",
		"openrouter/moonshotai/kimi-k2.6": "kimi-k2.6",
		"anthropic/claude-sonnet-4-6": "claude-sonnet-4-6",
	};
	const merged = { ...defaultTable, ...table };
	return (routeKey: string): string | null => merged[routeKey] ?? null;
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function sfDir(base: string) {
	return join(base, ".sf");
}

function perfFile(base: string) {
	return join(base, ".sf", "model-performance.json");
}

function backupFile(base: string) {
	return join(base, ".sf", "model-performance.json.pre-canonical-backup");
}

function readPerf(base: string) {
	return JSON.parse(readFileSync(perfFile(base), "utf-8"));
}

function writeOldPerf(base: string, data: object) {
	mkdirSync(sfDir(base), { recursive: true });
	writeFileSync(perfFile(base), JSON.stringify(data, null, 2), "utf-8");
}

// ── Test suite ────────────────────────────────────────────────────────────────

describe("model-learner canonical schema (Swarm C)", () => {
	let tmpDir: string;

	beforeEach(() => {
		tmpDir = join(
			tmpdir(),
			`test-ml-canonical-${Date.now()}-${Math.random().toString(36).slice(2)}`,
		);
		mkdirSync(tmpDir, { recursive: true });
		// Wire stub resolver before each test
		setRegistryResolver(makeStubResolver());
	});

	afterEach(() => {
		rmSync(tmpDir, { recursive: true, force: true });
		// Reset resolver to null so other test suites are unaffected
		setRegistryResolver(null as unknown as (rk: string) => string | null);
	});

	// ── Test 1: Migration round-trip ────────────────────────────────────────

	describe("migration round-trip", () => {
		test("migrates old flat format to canonical schema on load", () => {
			// Write old-format file
			writeOldPerf(tmpDir, {
				"execute-task": {
					"kimi-coding/kimi-k2.6": {
						successes: 5,
						failures: 1,
						timeouts: 0,
						totalTokens: 10000,
						totalCost: 0.5,
						lastUsed: "2026-05-01T12:00:00Z",
						successRate: 0.833,
					},
					"anthropic/claude-sonnet-4-6": {
						successes: 3,
						failures: 0,
						timeouts: 0,
						totalTokens: 6000,
						totalCost: 0.3,
						lastUsed: "2026-05-02T12:00:00Z",
						successRate: 1.0,
					},
				},
			});

			// Boot tracker — migration happens on _load()
			const tracker = new ModelPerformanceTracker(tmpDir);
			// Migration triggers on disk read

			// Verify backup was created
			expect(existsSync(backupFile(tmpDir))).toBe(true);

			// Verify new file has canonical schema
			const data = readPerf(tmpDir);
			const execBlob = data["execute-task"];

			// kimi-coding/kimi-k2.6 → canonical "kimi-k2.6"
			expect(execBlob["kimi-k2.6"]).toBeDefined();
			expect(execBlob["kimi-k2.6"].aggregate).toBeDefined();
			expect(execBlob["kimi-k2.6"].by_route).toBeDefined();
			expect(
				execBlob["kimi-k2.6"].by_route["kimi-coding/kimi-k2.6"],
			).toBeDefined();
			expect(execBlob["kimi-k2.6"].aggregate.successes).toBe(5);
			expect(execBlob["kimi-k2.6"].aggregate.failures).toBe(1);

			// anthropic/claude-sonnet-4-6 → canonical "claude-sonnet-4-6"
			expect(execBlob["claude-sonnet-4-6"]).toBeDefined();
			expect(execBlob["claude-sonnet-4-6"].aggregate.successes).toBe(3);

			// Verify tracker in-memory state is also migrated
			const stats = tracker.getStats("execute-task", "kimi-k2.6");
			expect(stats).not.toBeNull();
			expect(stats!.successes).toBe(5);
		});

		test("by_route entries are preserved after migration", () => {
			writeOldPerf(tmpDir, {
				"execute-task": {
					"kimi-coding/kimi-k2.6": {
						successes: 10,
						failures: 2,
						timeouts: 1,
						totalTokens: 50000,
						totalCost: 2.5,
						lastUsed: "2026-05-10T00:00:00Z",
					},
				},
			});

			new ModelPerformanceTracker(tmpDir); // triggers migration

			const data = readPerf(tmpDir);
			const routeEntry =
				data["execute-task"]["kimi-k2.6"].by_route["kimi-coding/kimi-k2.6"];
			expect(routeEntry).toBeDefined();
			expect(routeEntry.successes).toBe(10);
			expect(routeEntry.failures).toBe(2);
			expect(routeEntry.timeouts).toBe(1);
			expect(routeEntry.totalTokens).toBe(50000);
		});

		test("migration is idempotent — running twice produces identical result", () => {
			writeOldPerf(tmpDir, {
				"execute-task": {
					"kimi-coding/kimi-k2.6": {
						successes: 7,
						failures: 1,
						timeouts: 0,
						totalTokens: 20000,
						totalCost: 1.0,
						lastUsed: "2026-05-05T12:00:00Z",
					},
				},
			});

			new ModelPerformanceTracker(tmpDir); // first migration
			const dataAfterFirst = readPerf(tmpDir);

			new ModelPerformanceTracker(tmpDir); // second load — should not re-migrate
			const dataAfterSecond = readPerf(tmpDir);

			expect(dataAfterSecond).toEqual(dataAfterFirst);
		});

		test("backup is written only once (idempotent)", () => {
			writeOldPerf(tmpDir, {
				"execute-task": {
					"kimi-coding/kimi-k2.6": {
						successes: 3,
						failures: 0,
						timeouts: 0,
						totalTokens: 5000,
						totalCost: 0.2,
						lastUsed: "2026-05-06T00:00:00Z",
					},
				},
			});

			new ModelPerformanceTracker(tmpDir); // writes backup
			const backupContent1 = readFileSync(backupFile(tmpDir), "utf-8");

			// Sanity: first ctor produced a real backup, not the sentinel we
			// are about to write — otherwise the overwrite-detection below is
			// vacuous.
			expect(backupContent1).not.toBe('{"sentinel":true}');

			// Overwrite the backup to detect if it gets re-written
			writeFileSync(backupFile(tmpDir), '{"sentinel":true}', "utf-8");

			new ModelPerformanceTracker(tmpDir); // should NOT overwrite backup
			const backupContent2 = readFileSync(backupFile(tmpDir), "utf-8");

			// If sentinel is still there, backup was not overwritten
			expect(backupContent2).toBe('{"sentinel":true}');
		});
	});

	// ── Test 2: Aggregate invariant ─────────────────────────────────────────

	describe("aggregate invariant", () => {
		test("aggregate.successes === sum(by_route[*].successes) after writes to two routes", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);

			// Route 1: kimi-coding/kimi-k2.6 → canonical kimi-k2.6
			tracker.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
				success: true,
				timeout: false,
				tokensUsed: 1000,
				costUsd: 0.05,
			});
			tracker.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
				success: true,
				timeout: false,
				tokensUsed: 1200,
				costUsd: 0.06,
			});
			tracker.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
				success: false,
				timeout: false,
				tokensUsed: 800,
				costUsd: 0.04,
			});

			// Route 2: openrouter/moonshotai/kimi-k2.6 → same canonical kimi-k2.6
			tracker.recordOutcome("execute-task", "openrouter/moonshotai/kimi-k2.6", {
				success: true,
				timeout: false,
				tokensUsed: 2000,
				costUsd: 0.1,
			});
			tracker.recordOutcome("execute-task", "openrouter/moonshotai/kimi-k2.6", {
				success: false,
				timeout: true,
				tokensUsed: 0,
				costUsd: 0,
			});

			const data = readPerf(tmpDir);
			const canonicalEntry = data["execute-task"]["kimi-k2.6"];
			const agg = canonicalEntry.aggregate;
			const byRoute = canonicalEntry.by_route;

			// Compute expected sums from by_route
			const routeSuccesses = Object.values(byRoute).reduce(
				(sum: number, r: any) => sum + (r.successes ?? 0),
				0,
			);
			const routeFailures = Object.values(byRoute).reduce(
				(sum: number, r: any) => sum + (r.failures ?? 0),
				0,
			);
			const routeTimeouts = Object.values(byRoute).reduce(
				(sum: number, r: any) => sum + (r.timeouts ?? 0),
				0,
			);

			expect(agg.successes).toBe(routeSuccesses);
			expect(agg.failures).toBe(routeFailures);
			expect(agg.timeouts).toBe(routeTimeouts);

			// Concrete values: 3 successes from route1, 1 success from route2 = 4 total
			expect(agg.successes).toBe(3);
			// Failures: 1 from route1 (non-timeout), 1 from route2 (timeout) = 2 total
			expect(agg.failures).toBe(2);
			// Timeouts: 1 from route2
			expect(agg.timeouts).toBe(1);
		});

		test("aggregate is recalculated correctly after each write", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);

			tracker.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
				success: true,
				timeout: false,
				tokensUsed: 100,
				costUsd: 0.01,
			});
			let data = readPerf(tmpDir);
			expect(data["execute-task"]["kimi-k2.6"].aggregate.successes).toBe(1);

			tracker.recordOutcome("execute-task", "openrouter/moonshotai/kimi-k2.6", {
				success: true,
				timeout: false,
				tokensUsed: 200,
				costUsd: 0.02,
			});
			data = readPerf(tmpDir);
			expect(data["execute-task"]["kimi-k2.6"].aggregate.successes).toBe(2);

			tracker.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
				success: false,
				timeout: false,
				tokensUsed: 50,
				costUsd: 0.005,
			});
			data = readPerf(tmpDir);
			expect(data["execute-task"]["kimi-k2.6"].aggregate.successes).toBe(2);
			expect(data["execute-task"]["kimi-k2.6"].aggregate.failures).toBe(1);
		});
	});

	// ── Test 3: _unmapped bucket ───────────────────────────────────────────

	describe("_unmapped bucket", () => {
		test("unknown route key lands in _unmapped, not dropped", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);

			tracker.recordOutcome("execute-task", "foo-provider/bar-model", {
				success: true,
				timeout: false,
				tokensUsed: 500,
				costUsd: 0.02,
			});

			const data = readPerf(tmpDir);
			const unmapped = data["execute-task"]["_unmapped"];
			expect(unmapped).toBeDefined();
			expect(unmapped.by_route["foo-provider/bar-model"]).toBeDefined();
			expect(unmapped.by_route["foo-provider/bar-model"].successes).toBe(1);
		});

		test("_unmapped entry does NOT appear in getRankedModels when canonical entries exist", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);

			// Known route → canonical
			tracker.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
				success: true,
				timeout: false,
				tokensUsed: 1000,
				costUsd: 0.05,
			});
			// Unknown route → _unmapped
			tracker.recordOutcome("execute-task", "foo-provider/bar-model", {
				success: true,
				timeout: false,
				tokensUsed: 500,
				costUsd: 0.02,
			});

			const ranked = tracker.getRankedModels("execute-task", 0);
			const modelIds = ranked.map((r) => r.modelId);
			expect(modelIds).toContain("kimi-k2.6");
			expect(modelIds).not.toContain("_unmapped");
			expect(modelIds).not.toContain("foo-provider/bar-model");
		});

		test("_unmapped preserves multiple unknown routes independently", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);

			tracker.recordOutcome("execute-task", "unknown-a/model-x", {
				success: true,
				timeout: false,
				tokensUsed: 100,
				costUsd: 0.01,
			});
			tracker.recordOutcome("execute-task", "unknown-b/model-y", {
				success: false,
				timeout: false,
				tokensUsed: 50,
				costUsd: 0.005,
			});

			const data = readPerf(tmpDir);
			const unmapped = data["execute-task"]["_unmapped"];
			expect(unmapped.by_route["unknown-a/model-x"].successes).toBe(1);
			expect(unmapped.by_route["unknown-b/model-y"].failures).toBe(1);
		});
	});

	// ── Test 4: Reading sensible defaults ──────────────────────────────────

	describe("reading never-seen canonical ids", () => {
		test("getStats returns null for a never-seen canonical id", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);
			expect(tracker.getStats("execute-task", "kimi-k2.6")).toBeNull();
		});

		test("getStats returns null for a never-seen task type", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);
			tracker.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
				success: true,
				timeout: false,
				tokensUsed: 100,
				costUsd: 0.01,
			});
			expect(tracker.getStats("plan-slice", "kimi-k2.6")).toBeNull();
		});

		test("getRouteStats returns null for a never-seen route", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);
			expect(
				tracker.getRouteStats("execute-task", "kimi-coding/kimi-k2.6"),
			).toBeNull();
		});

		test("getRankedModels returns empty array for unknown task type", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);
			expect(tracker.getRankedModels("nonexistent-type")).toEqual([]);
		});

		test("shouldDemote returns false for a never-seen canonical id", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);
			expect(tracker.shouldDemote("execute-task", "kimi-k2.6")).toBe(false);
		});
	});

	// ── Test 5: ModelLearner integration ───────────────────────────────────

	describe("ModelLearner canonical integration", () => {
		test("recordOutcome + getRankedModels uses canonical ids", () => {
			const learner = new ModelLearner(tmpDir);

			// Record 5 successes via route 1
			for (let i = 0; i < 5; i++) {
				learner.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
					success: true,
					timeout: false,
					tokensUsed: 1000,
					costUsd: 0.05,
				});
			}
			// Record 1 failure via route 2 (same canonical)
			learner.recordOutcome("execute-task", "openrouter/moonshotai/kimi-k2.6", {
				success: false,
				timeout: false,
				tokensUsed: 500,
				costUsd: 0.025,
			});

			const ranked = learner.getRankedModels("execute-task");
			expect(ranked.length).toBeGreaterThan(0);
			// Canonical id should appear in ranked list
			const kimiEntry = ranked.find((r) => r.modelId === "kimi-k2.6");
			expect(kimiEntry).toBeDefined();
			expect(kimiEntry!.attempts).toBe(6); // 5 + 1
			// Success rate: 5/6
			expect(kimiEntry!.successRate).toBeCloseTo(5 / 6, 3);
		});

		test("migration round-trip preserves by_route data (full lifecycle)", () => {
			// Step 1: write old-format file
			writeOldPerf(tmpDir, {
				"execute-task": {
					"kimi-coding/kimi-k2.6": {
						successes: 8,
						failures: 2,
						timeouts: 0,
						totalTokens: 40000,
						totalCost: 2.0,
						lastUsed: "2026-04-01T00:00:00Z",
					},
					"openrouter/moonshotai/kimi-k2.6": {
						successes: 3,
						failures: 1,
						timeouts: 0,
						totalTokens: 15000,
						totalCost: 0.75,
						lastUsed: "2026-04-02T00:00:00Z",
					},
				},
			});

			// Step 2: boot learner (triggers migration)
			const learner = new ModelLearner(tmpDir);

			// Step 3: verify backup exists
			expect(existsSync(backupFile(tmpDir))).toBe(true);

			// Step 4: verify new file structure
			const data = readPerf(tmpDir);
			const kimiEntry = data["execute-task"]["kimi-k2.6"];
			expect(kimiEntry).toBeDefined();
			expect(kimiEntry.aggregate.successes).toBe(11); // 8 + 3
			expect(kimiEntry.aggregate.failures).toBe(3); // 2 + 1
			expect(kimiEntry.by_route["kimi-coding/kimi-k2.6"].successes).toBe(8);
			expect(
				kimiEntry.by_route["openrouter/moonshotai/kimi-k2.6"].successes,
			).toBe(3);

			// Step 5: verify aggregate invariant
			const agg = kimiEntry.aggregate;
			const routeSum = Object.values(kimiEntry.by_route).reduce(
				(sum: number, r: any) => sum + (r.successes ?? 0),
				0,
			);
			expect(agg.successes).toBe(routeSum);

			// Step 6: verify in-memory reads via getRankedModels
			const ranked = learner.getRankedModels("execute-task");
			const kimiRanked = ranked.find((r) => r.modelId === "kimi-k2.6");
			expect(kimiRanked).toBeDefined();
			expect(kimiRanked!.attempts).toBe(14); // 11 + 3
		});

		test("per-route health can be queried independently of aggregate", () => {
			const tracker = new ModelPerformanceTracker(tmpDir);

			// Route 1: healthy
			for (let i = 0; i < 9; i++) {
				tracker.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
					success: true,
					timeout: false,
					tokensUsed: 1000,
					costUsd: 0.05,
				});
			}
			tracker.recordOutcome("execute-task", "kimi-coding/kimi-k2.6", {
				success: false,
				timeout: false,
				tokensUsed: 1000,
				costUsd: 0.05,
			});

			// Route 2: failing
			for (let i = 0; i < 3; i++) {
				tracker.recordOutcome(
					"execute-task",
					"openrouter/moonshotai/kimi-k2.6",
					{
						success: false,
						timeout: false,
						tokensUsed: 500,
						costUsd: 0.025,
					},
				);
			}
			tracker.recordOutcome("execute-task", "openrouter/moonshotai/kimi-k2.6", {
				success: true,
				timeout: false,
				tokensUsed: 500,
				costUsd: 0.025,
			});

			// Aggregate: 10 successes, 4 failures = 71% success rate
			const agg = tracker.getStats("execute-task", "kimi-k2.6");
			expect(agg).not.toBeNull();
			expect(agg!.successes).toBe(10);
			expect(agg!.failures).toBe(4);

			// Per-route: kimi-coding is healthy, openrouter is failing
			const route1 = tracker.getRouteStats(
				"execute-task",
				"kimi-coding/kimi-k2.6",
			);
			expect(route1).not.toBeNull();
			expect(route1!.successes).toBe(9);
			expect(route1!.failures).toBe(1);

			const route2 = tracker.getRouteStats(
				"execute-task",
				"openrouter/moonshotai/kimi-k2.6",
			);
			expect(route2).not.toBeNull();
			expect(route2!.successes).toBe(1);
			expect(route2!.failures).toBe(3);
		});
	});
});
