/**
 * Tests for model-route-failure.js — generation guard and solver pinning (ADR-0079).
 *
 * Swarm B spec:
 *   1. Solver-pinned unit ("autonomous-solver") cannot fail over across
 *      canonical_id boundaries. The resolver must return undefined when all
 *      remaining routes belong to a different canonical model.
 *   2. Same-canonical multi-route failover works: two routes for the same
 *      canonical id (kimi-k2.6 and kimi-for-coding both map to kimi-k2.6).
 *   3. Cross-generation failover for non-solver units succeeds AND emits
 *      logGenerationDowngrade via logWarning.
 */

import { beforeEach, describe, expect, it, vi } from "vitest";

// ── Mock workflow-logger so we can assert on logWarning calls ──────────────
vi.mock("../workflow-logger.js", () => ({
	logWarning: vi.fn(),
	logError: vi.fn(),
}));

import {
	logGenerationDowngrade,
	resolveNextAvailableModelRoute,
} from "../model-route-failure.js";
import { logWarning } from "../workflow-logger.js";

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

/**
 * Minimal model shape that model-route-failure.js expects for availableModels.
 * provider + id must be real registry entries so canonicalIdFor() resolves them.
 */
function makeModel(provider: string, id: string) {
	return { provider, id, api: "openai-completions" as const };
}

beforeEach(() => {
	vi.clearAllMocks();
});

// ─────────────────────────────────────────────────────────────────────────────
// 1. Solver-pinning guard
// ─────────────────────────────────────────────────────────────────────────────

describe("solver-pinned failover (ADR-0079)", () => {
	it("returns undefined when the only available route has a different canonical_id than the failed solver route", () => {
		// Scenario: solver is running on kimi-coding/kimi-k2.6 (canonical: kimi-k2.6).
		// The only unfailed route is openrouter/moonshotai/kimi-k2.5 (canonical: kimi-k2.5).
		// Because these are different canonical ids, the solver-pinned guard must
		// reject the candidate and return undefined.
		const result = resolveNextAvailableModelRoute({
			current: makeModel("kimi-coding", "kimi-k2.6"),
			availableModels: [
				makeModel("kimi-coding", "kimi-k2.6"), // same as current — filtered
				makeModel("openrouter", "moonshotai/kimi-k2.5"), // canonical: kimi-k2.5 ≠ kimi-k2.6
			],
			failedRoutes: [
				{ provider: "kimi-coding", modelId: "kimi-k2.6", reason: "rate-limit" },
			],
			unitType: "autonomous-solver",
		});
		expect(result).toBeUndefined();
	});

	it("returns undefined when candidate is an unregistered route (null canonical) for solver-pinned unit", () => {
		// ollama-cloud/kimi-k2.5:cloud is not in the registry — canonicalIdFor
		// returns null. The guard treats null !== "kimi-k2.6" as a mismatch.
		const result = resolveNextAvailableModelRoute({
			current: makeModel("kimi-coding", "kimi-k2.6"),
			availableModels: [
				makeModel("ollama-cloud", "kimi-k2.5:cloud"), // not in registry
			],
			failedRoutes: [
				{ provider: "kimi-coding", modelId: "kimi-k2.6", reason: "server" },
			],
			unitType: "autonomous-solver",
		});
		expect(result).toBeUndefined();
	});

	it("does NOT emit logGenerationDowngrade for solver-pinned failover (guard rejects before logging)", () => {
		resolveNextAvailableModelRoute({
			current: makeModel("kimi-coding", "kimi-k2.6"),
			availableModels: [makeModel("openrouter", "moonshotai/kimi-k2.5")],
			failedRoutes: [
				{ provider: "kimi-coding", modelId: "kimi-k2.6", reason: "rate-limit" },
			],
			unitType: "autonomous-solver",
		});
		// The guard rejected before logging — no downgrade event should be emitted.
		expect(logWarning).not.toHaveBeenCalled();
	});
});

// ─────────────────────────────────────────────────────────────────────────────
// 2. Same-canonical multi-route failover
// ─────────────────────────────────────────────────────────────────────────────

describe("same-canonical multi-route failover", () => {
	it("succeeds when a second route shares the same canonical_id as the failed route", () => {
		// kimi-coding/kimi-for-coding maps to canonical kimi-k2.6 (same as
		// kimi-coding/kimi-k2.6). This is the standard same-canonical path.
		const result = resolveNextAvailableModelRoute({
			current: makeModel("kimi-coding", "kimi-k2.6"),
			availableModels: [
				makeModel("kimi-coding", "kimi-k2.6"), // same as current — filtered
				makeModel("kimi-coding", "kimi-for-coding"), // canonical: kimi-k2.6 ✓
			],
			failedRoutes: [
				{ provider: "kimi-coding", modelId: "kimi-k2.6", reason: "rate-limit" },
			],
			unitType: "autonomous-solver",
		});
		expect(result).toBeDefined();
		expect(result?.model.provider).toBe("kimi-coding");
		expect(result?.model.id).toBe("kimi-for-coding");
		expect(result?.source).toBe("available");
	});

	it("does not emit logGenerationDowngrade for same-canonical failover", () => {
		resolveNextAvailableModelRoute({
			current: makeModel("kimi-coding", "kimi-k2.6"),
			availableModels: [
				makeModel("kimi-coding", "kimi-k2.6"),
				makeModel("kimi-coding", "kimi-for-coding"),
			],
			failedRoutes: [
				{ provider: "kimi-coding", modelId: "kimi-k2.6", reason: "server" },
			],
			unitType: "execute-task",
		});
		expect(logWarning).not.toHaveBeenCalled();
	});

	it("same-canonical failover works even for solver-pinned unit type", () => {
		// Within the same canonical_id, solver pin does not block failover.
		const result = resolveNextAvailableModelRoute({
			current: makeModel("kimi-coding", "kimi-k2.6"),
			availableModels: [
				makeModel("kimi-coding", "kimi-k2.6"),
				makeModel("kimi-coding", "kimi-for-coding"),
			],
			failedRoutes: [
				{ provider: "kimi-coding", modelId: "kimi-k2.6", reason: "rate-limit" },
			],
			unitType: "autonomous-solver",
		});
		expect(result).toBeDefined();
		expect(result?.model.id).toBe("kimi-for-coding");
	});
});

// ─────────────────────────────────────────────────────────────────────────────
// 3. Cross-generation failover for non-solver units
// ─────────────────────────────────────────────────────────────────────────────

describe("cross-generation failover (executor layer)", () => {
	it("succeeds and emits logGenerationDowngrade when crossing generation boundaries", () => {
		// kimi-k2.6 (gen: k2.6) → kimi-k2.5 via openrouter (gen: k2.5).
		// These are different generations, so the downgrade event must fire.
		const result = resolveNextAvailableModelRoute({
			current: makeModel("kimi-coding", "kimi-k2.6"),
			availableModels: [makeModel("openrouter", "moonshotai/kimi-k2.5")],
			failedRoutes: [
				{ provider: "kimi-coding", modelId: "kimi-k2.6", reason: "server" },
			],
			unitType: "execute-task",
		});
		expect(result).toBeDefined();
		expect(result?.model.provider).toBe("openrouter");
		// logGenerationDowngrade should have been called
		expect(logWarning).toHaveBeenCalledWith(
			"model-route-failure",
			"generation-downgrade",
			expect.objectContaining({
				from: "kimi-k2.6",
				to: "kimi-k2.5",
				unitType: "execute-task",
				sameGeneration: false,
			}),
		);
	});

	it("emits logGenerationDowngrade with the correct unitType from args", () => {
		resolveNextAvailableModelRoute({
			current: makeModel("kimi-coding", "kimi-k2.6"),
			availableModels: [makeModel("openrouter", "moonshotai/kimi-k2.5")],
			failedRoutes: [
				{ provider: "kimi-coding", modelId: "kimi-k2.6", reason: "rate-limit" },
			],
			unitType: "plan-slice",
		});
		expect(logWarning).toHaveBeenCalledWith(
			"model-route-failure",
			"generation-downgrade",
			expect.objectContaining({ unitType: "plan-slice" }),
		);
	});

	it("does not emit logGenerationDowngrade when no current route is set", () => {
		// When current is undefined, canonicalIdFor returns null and no generation
		// check can be performed — no downgrade event should fire.
		const result = resolveNextAvailableModelRoute({
			current: undefined,
			availableModels: [makeModel("openrouter", "moonshotai/kimi-k2.5")],
			failedRoutes: [],
			unitType: "execute-task",
		});
		expect(result).toBeDefined();
		expect(logWarning).not.toHaveBeenCalled();
	});

	it("does not emit logGenerationDowngrade when both routes share the same generation", () => {
		// claude-sonnet-4 and claude-sonnet-4-5 both have generation "sonnet-4".
		resolveNextAvailableModelRoute({
			current: makeModel("anthropic", "claude-sonnet-4-20250514"),
			availableModels: [makeModel("anthropic", "claude-sonnet-4-5-20250929")],
			failedRoutes: [
				{
					provider: "anthropic",
					modelId: "claude-sonnet-4-20250514",
					reason: "rate-limit",
				},
			],
			unitType: "execute-task",
		});
		expect(logWarning).not.toHaveBeenCalled();
	});
});

// ─────────────────────────────────────────────────────────────────────────────
// 4. logGenerationDowngrade helper
// ─────────────────────────────────────────────────────────────────────────────

describe("logGenerationDowngrade helper", () => {
	it("calls logWarning with structured generation-downgrade payload", () => {
		logGenerationDowngrade(
			"kimi-k2.6",
			"kimi-k2.5",
			"execute-task",
			"test reason",
		);
		expect(logWarning).toHaveBeenCalledWith(
			"model-route-failure",
			"generation-downgrade",
			{
				from: "kimi-k2.6",
				to: "kimi-k2.5",
				unitType: "execute-task",
				reason: "test reason",
				sameGeneration: false,
			},
		);
	});

	it("uses default reason when none is supplied", () => {
		logGenerationDowngrade("kimi-k2.6", "kimi-k2.5", "plan-slice", undefined);
		expect(logWarning).toHaveBeenCalledWith(
			"model-route-failure",
			"generation-downgrade",
			expect.objectContaining({ reason: "cross-generation failover" }),
		);
	});
});
