import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import {
	apiKeyFromCredential,
	catalogListUrl,
	isAgentModel,
	isNativeOpenAIRoute,
	loadModels,
	normalizeApiBaseUrl,
	parseCatalogPage,
	pickCost,
	thinkingLevelMap,
	toExplabsModel,
} from "../src/catalog.ts";
import { rebaseCachedExplabsModels } from "../src/index.ts";
import type { CatalogModel, CatalogProvider } from "../src/types.ts";

const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), "fixtures");

async function readFixture(name: string): Promise<unknown> {
	return JSON.parse(await readFile(join(fixtureDir, name), "utf8")) as unknown;
}

describe("normalizeApiBaseUrl", () => {
	it("appends /v1 when missing", () => {
		assert.equal(normalizeApiBaseUrl("https://api.experientiallabs.ai"), "https://api.experientiallabs.ai/v1");
	});

	it("keeps an existing /v1 suffix", () => {
		assert.equal(
			normalizeApiBaseUrl("https://api.experientiallabs.ai/v1/"),
			"https://api.experientiallabs.ai/v1",
		);
	});
});

describe("rebaseCachedExplabsModels", () => {
	it("uses the current configured base URL for restored explabs models", () => {
		const stored = {
			checkedAt: 1,
			models: [
				{ provider: "explabs", id: "cached", baseUrl: "https://api.experientiallabs.ai/v1" },
				{ provider: "other", id: "other", baseUrl: "https://other.example/v1" },
			],
		} as never;

		const rebased = rebaseCachedExplabsModels(stored, "https://gateway.example/v1");

		assert.deepEqual(rebased?.models.map((model) => model.baseUrl), [
			"https://gateway.example/v1",
			"https://other.example/v1",
		]);
		assert.equal(stored.models[0].baseUrl, "https://api.experientiallabs.ai/v1");
	});

	it("leaves an absent cache absent", () => {
		assert.equal(rebaseCachedExplabsModels(undefined, "https://gateway.example/v1"), undefined);
	});
});

describe("catalogListUrl", () => {
	it("targets /api/models on the host, not /v1/models", () => {
		assert.equal(
			catalogListUrl("https://api.experientiallabs.ai/v1", 200, 200),
			"https://api.experientiallabs.ai/api/models?sort=preferred&supports=tools&limit=200&offset=200",
		);
	});

	it("keeps a self-hosted origin", () => {
		assert.equal(
			catalogListUrl("http://127.0.0.1:8000/v1", 0, 50),
			"http://127.0.0.1:8000/api/models?sort=preferred&supports=tools&limit=50&offset=0",
		);
	});
});

describe("catalog mapping", () => {
	it("maps a tool-capable reasoning model", async () => {
		const page = parseCatalogPage(await readFixture("catalog-page.json"));
		const row = page.models[0];
		assert.ok(row);
		const model = toExplabsModel(row.model, row.providers, "https://api.experientiallabs.ai/v1");
		assert.equal(model?.headers?.["User-Agent"]?.startsWith("pi-experientiallabs/"), true);
		assert.deepEqual({ ...model, headers: undefined }, {
			id: "claude-fable-5.1",
			name: "Claude Fable 5.1",
			api: "openai-responses",
			provider: "explabs",
			baseUrl: "https://api.experientiallabs.ai/v1",
			headers: undefined,
			reasoning: true,
			thinkingLevelMap: {
				off: null,
				minimal: null,
				low: "low",
				medium: "medium",
				high: "high",
				xhigh: "xhigh",
				max: "max",
			},
			input: ["text", "image"],
			cost: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 0 },
			contextWindow: 1_000_000,
			maxTokens: 128_000,
			compat: {
				supportsDeveloperRole: true,
				supportsStrictMode: true,
			},
			nativeOpenAIRoute: false,
		});
	});

	it("marks a native OpenAI first hop", () => {
		const providers: CatalogProvider[] = [
			{ id: "or-1", provider: "openrouter" },
			{ id: "oa-1", provider: "openai" },
		];
		assert.equal(isNativeOpenAIRoute(providers, ["oa-1", "or-1"]), true);
		assert.equal(isNativeOpenAIRoute(providers, ["or-1", "oa-1"]), false);
	});

	it("does not treat OpenAI-looking list order as native without a waterfall", () => {
		assert.equal(
			isNativeOpenAIRoute([{ provider: "openrouter" }, { provider: "openai" }]),
			false,
		);
		assert.equal(isNativeOpenAIRoute([{ provider: "openai" }]), true);
	});

	it("drops models that cannot drive a coding agent", async () => {
		const page = parseCatalogPage(await readFixture("catalog-page.json"));
		const slugs = page.models.map((row) => row.model.slug);
		assert.deepEqual(slugs, [
			"claude-fable-5.1",
			"ada",
			"claude-fable-5-batch",
			"stable-image-core",
		]);
		assert.deepEqual(
			page.models.map((row) => isAgentModel(row.model)),
			[true, false, false, false],
		);
	});

	it("prefers the cheapest host_managed price", () => {
		const providers: CatalogProvider[] = [
			{
				billing_source: "customer_managed",
				input_micro_usd_per_million: 1_000_000,
				output_micro_usd_per_million: 2_000_000,
				cached_input_micro_usd_per_million: null,
			},
			{
				billing_source: "host_managed",
				input_micro_usd_per_million: 10_000_000,
				output_micro_usd_per_million: 50_000_000,
				cached_input_micro_usd_per_million: 250_000,
			},
			{
				billing_source: "host_managed",
				input_micro_usd_per_million: 3_000_000,
				output_micro_usd_per_million: 15_000_000,
				cached_input_micro_usd_per_million: 300_000,
			},
		];
		assert.deepEqual(pickCost(providers), {
			input: 3,
			output: 15,
			cacheRead: 0.3,
			cacheWrite: 0,
		});
	});

	it("omits xhigh/max unless the catalog advertises them", () => {
		const model: CatalogModel = {
			slug: "gpt-5",
			input_modalities: ["text"],
			output_modalities: ["text"],
			supported_params: { tools: true, reasoning: true },
		};
		const map = thinkingLevelMap(model, [
			{ capabilities: { supported_reasoning_efforts: ["minimal", "low", "medium", "high"] } },
		]);
		assert.deepEqual(map, {
			off: null,
			minimal: "minimal",
			low: "low",
			medium: "medium",
			high: "high",
			xhigh: null,
			max: null,
		});
	});

	it("maps off to none when the catalog lists none", () => {
		const model: CatalogModel = {
			slug: "o3",
			input_modalities: ["text"],
			output_modalities: ["text"],
			supported_params: { tools: true, reasoning: true },
		};
		const map = thinkingLevelMap(model, [
			{ capabilities: { supported_reasoning_efforts: ["none", "low", "medium", "high"] } },
		]);
		assert.deepEqual(map, {
			off: "none",
			minimal: null,
			low: "low",
			medium: "medium",
			high: "high",
			xhigh: null,
			max: null,
		});
	});
});

describe("loadModels", () => {
	it("paginates and sends the API key", async () => {
		const requests: string[] = [];
		const headers: string[] = [];
		const fetchImpl: typeof fetch = async (input, init) => {
			const url = String(input);
			requests.push(url);
			headers.push(new Headers(init?.headers).get("authorization") ?? "");
			const offset = new URL(url).searchParams.get("offset");
			if (offset === "0") {
				return new Response(
					JSON.stringify({
						total: 3,
						limit: 2,
						offset: 0,
						models: [
							{
								model: {
									slug: "claude-fable-5.1",
									display_name: "Claude Fable 5.1",
									status: "active",
									context_window: 1000000,
									max_output_tokens: 128000,
									input_modalities: ["text", "image"],
									output_modalities: ["text"],
									supported_params: { tools: true, reasoning: true, structured_outputs: true },
								},
								providers: [],
							},
							{
								model: {
									slug: "ada",
									display_name: "Ada",
									status: "active",
									input_modalities: ["text"],
									output_modalities: ["text"],
									supported_params: { tools: false },
								},
								providers: [],
							},
						],
					}),
					{ status: 200, headers: { "content-type": "application/json" } },
				);
			}
			return new Response(
				JSON.stringify({
					total: 3,
					limit: 2,
					offset: 2,
					models: [
						{
							model: {
								slug: "gpt-5.6-sol",
								display_name: "GPT-5.6 Sol",
								status: "active",
								context_window: 400000,
								max_output_tokens: 128000,
								input_modalities: ["text"],
								output_modalities: ["text"],
								supported_params: { tools: true, reasoning: true },
							},
							providers: [],
						},
					],
				}),
				{ status: 200, headers: { "content-type": "application/json" } },
			);
		};

		const models = await loadModels({
			baseUrl: "https://api.experientiallabs.ai/v1",
			apiKey: "xpl_test",
			fetch: fetchImpl,
			pageSize: 2,
		});

		assert.deepEqual(
			models.map((model) => model.id),
			["claude-fable-5.1", "gpt-5.6-sol"],
		);
		assert.equal(requests.length, 2);
		assert.equal(headers[0], "Bearer xpl_test");
	});

	it("throws when every row is filtered out so Pi can keep the last cache", async () => {
		const fetchImpl: typeof fetch = async () =>
			new Response(
				JSON.stringify({
					total: 1,
					limit: 1,
					offset: 0,
					models: [
						{
							model: {
								slug: "ada",
								status: "active",
								input_modalities: ["text"],
								output_modalities: ["text"],
								supported_params: { tools: false },
							},
							providers: [],
						},
					],
				}),
				{ status: 200, headers: { "content-type": "application/json" } },
			);
		await assert.rejects(
			() =>
				loadModels({
					baseUrl: "https://api.experientiallabs.ai/v1",
					fetch: fetchImpl,
				}),
			/returned no models/,
		);
	});
});

describe("apiKeyFromCredential", () => {
	it("reads stored API keys", () => {
		assert.equal(apiKeyFromCredential({ type: "api_key", key: "xpl_abc" }), "xpl_abc");
	});
});

describe("loadModels errors", () => {
	it("throws on HTTP failure so Pi can keep the last cached catalog", async () => {
		const fetchImpl: typeof fetch = async () =>
			new Response("nope", { status: 502, statusText: "Bad Gateway" });
		await assert.rejects(
			() =>
				loadModels({
					baseUrl: "https://api.experientiallabs.ai/v1",
					fetch: fetchImpl,
				}),
			/catalog failed: HTTP 502/,
		);
	});
});
