/**
 * provider-catalog-discovery.test.mjs
 *
 * Regression tests for live-discovery entries in provider-catalog-config.js
 * and runtime behavior of model-catalog-cache.js.
 *
 * Sections:
 *   1. Provider catalog config — baseUrl / modelsPath / auth fields
 *   2. parseDiscoveredModels — OpenAI / Google format parsing
 *   3. refreshSfManagedProviders — iteration, auth headers, SDK cache writes
 *   4. DISCOVERABLE_PROVIDER_IDS completeness
 */
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, test, vi } from "vitest";
import {
	DISCOVERABLE_PROVIDER_IDS,
	getProviderCatalogConfig,
} from "../provider-catalog-config.js";

describe("opencode discovery config", () => {
	test("baseUrl points to /zen path", () => {
		const cfg = getProviderCatalogConfig("opencode");
		assert.ok(cfg, "opencode entry must exist");
		assert.equal(cfg.baseUrl, "https://opencode.ai/zen");
	});

	test("modelsPath is /v1/models", () => {
		const cfg = getProviderCatalogConfig("opencode");
		assert.equal(cfg.modelsPath, "/v1/models");
	});

	test("auth type is bearer", () => {
		const cfg = getProviderCatalogConfig("opencode");
		assert.equal(cfg.auth.type, "bearer");
	});

	test("is included in DISCOVERABLE_PROVIDER_IDS", () => {
		assert.ok(
			DISCOVERABLE_PROVIDER_IDS.includes("opencode"),
			"opencode should be discoverable",
		);
	});
});

describe("opencode-go discovery config", () => {
	test("baseUrl points to /zen/go path", () => {
		const cfg = getProviderCatalogConfig("opencode-go");
		assert.ok(cfg, "opencode-go entry must exist");
		assert.equal(cfg.baseUrl, "https://opencode.ai/zen/go");
	});

	test("modelsPath is /v1/models", () => {
		const cfg = getProviderCatalogConfig("opencode-go");
		assert.equal(cfg.modelsPath, "/v1/models");
	});

	test("auth type is bearer", () => {
		const cfg = getProviderCatalogConfig("opencode-go");
		assert.equal(cfg.auth.type, "bearer");
	});

	test("is included in DISCOVERABLE_PROVIDER_IDS", () => {
		assert.ok(
			DISCOVERABLE_PROVIDER_IDS.includes("opencode-go"),
			"opencode-go should be discoverable",
		);
	});

	test("baseUrl has /go suffix (distinct from opencode)", () => {
		const opencodeBase = getProviderCatalogConfig("opencode").baseUrl;
		const goBase = getProviderCatalogConfig("opencode-go").baseUrl;
		assert.ok(goBase.endsWith("/go"), "opencode-go baseUrl must end in /go");
		assert.notEqual(
			goBase,
			opencodeBase,
			"opencode-go baseUrl must differ from opencode",
		);
	});
});

describe("minimax discovery config", () => {
	test("baseUrl uses international api.minimax.io endpoint", () => {
		const cfg = getProviderCatalogConfig("minimax");
		assert.ok(cfg, "minimax entry must exist");
		assert.ok(
			cfg.baseUrl.includes("api.minimax.io"),
			`expected api.minimax.io, got ${cfg.baseUrl}`,
		);
		assert.ok(
			!cfg.baseUrl.includes("minimaxi.com"),
			"baseUrl must not use minimaxi.com (Chinese-network endpoint)",
		);
	});

	test("modelsPath is /v1/models", () => {
		const cfg = getProviderCatalogConfig("minimax");
		assert.equal(cfg.modelsPath, "/v1/models");
	});

	test("is included in DISCOVERABLE_PROVIDER_IDS", () => {
		assert.ok(
			DISCOVERABLE_PROVIDER_IDS.includes("minimax"),
			"minimax should be discoverable",
		);
	});
});

describe("kimi-coding discovery config", () => {
	test("baseUrl is the Moonshot AI coding endpoint", () => {
		const cfg = getProviderCatalogConfig("kimi-coding");
		assert.ok(cfg, "kimi-coding entry must exist");
		assert.equal(cfg.baseUrl, "https://api.kimi.com/coding");
	});

	test("modelsPath is /v1/models", () => {
		const cfg = getProviderCatalogConfig("kimi-coding");
		assert.equal(cfg.modelsPath, "/v1/models");
	});

	test("auth type is bearer", () => {
		const cfg = getProviderCatalogConfig("kimi-coding");
		assert.equal(cfg.auth.type, "bearer");
	});

	test("is included in DISCOVERABLE_PROVIDER_IDS", () => {
		assert.ok(
			DISCOVERABLE_PROVIDER_IDS.includes("kimi-coding"),
			"kimi-coding should be discoverable",
		);
	});
});

describe("zai discovery config", () => {
	test("baseUrl is the Z.AI coding plan endpoint", () => {
		const cfg = getProviderCatalogConfig("zai");
		assert.ok(cfg, "zai entry must exist");
		assert.equal(cfg.baseUrl, "https://api.z.ai/api/coding/paas");
	});

	test("modelsPath is /v4/models", () => {
		const cfg = getProviderCatalogConfig("zai");
		assert.equal(cfg.modelsPath, "/v4/models");
	});

	test("auth type is bearer", () => {
		const cfg = getProviderCatalogConfig("zai");
		assert.equal(cfg.auth.type, "bearer");
	});

	test("is included in DISCOVERABLE_PROVIDER_IDS", () => {
		assert.ok(
			DISCOVERABLE_PROVIDER_IDS.includes("zai"),
			"zai should be discoverable",
		);
	});
});

describe("ollama-cloud discovery config", () => {
	test("baseUrl is ollama.com", () => {
		const cfg = getProviderCatalogConfig("ollama-cloud");
		assert.ok(cfg, "ollama-cloud entry must exist");
		assert.equal(cfg.baseUrl, "https://ollama.com");
	});

	test("modelsPath is /v1/models", () => {
		const cfg = getProviderCatalogConfig("ollama-cloud");
		assert.equal(cfg.modelsPath, "/v1/models");
	});

	test("auth type is bearer", () => {
		const cfg = getProviderCatalogConfig("ollama-cloud");
		assert.equal(cfg.auth.type, "bearer");
	});

	test("is included in DISCOVERABLE_PROVIDER_IDS", () => {
		assert.ok(
			DISCOVERABLE_PROVIDER_IDS.includes("ollama-cloud"),
			"ollama-cloud should be discoverable",
		);
	});
});

describe("xiaomi discovery config", () => {
	test("baseUrl is the Xiaomi MiMo international endpoint", () => {
		const cfg = getProviderCatalogConfig("xiaomi");
		assert.ok(cfg, "xiaomi entry must exist");
		assert.equal(cfg.baseUrl, "https://token-plan-ams.xiaomimimo.com");
	});

	test("modelsPath is /v1/models (OpenAI-shaped discovery)", () => {
		const cfg = getProviderCatalogConfig("xiaomi");
		assert.equal(cfg.modelsPath, "/v1/models");
	});

	test("auth type is x-api-key", () => {
		const cfg = getProviderCatalogConfig("xiaomi");
		assert.equal(cfg.auth.type, "x-api-key");
		assert.equal(cfg.auth.header, "x-api-key");
	});

	test("is included in DISCOVERABLE_PROVIDER_IDS", () => {
		assert.ok(
			DISCOVERABLE_PROVIDER_IDS.includes("xiaomi"),
			"xiaomi should be discoverable",
		);
	});
});

describe("DISCOVERABLE_PROVIDER_IDS completeness", () => {
	test("includes all 4 newly added providers (kimi-coding, zai, ollama-cloud, xiaomi)", () => {
		const ids = DISCOVERABLE_PROVIDER_IDS;
		assert.ok(ids.includes("kimi-coding"), "missing kimi-coding");
		assert.ok(ids.includes("zai"), "missing zai");
		assert.ok(ids.includes("ollama-cloud"), "missing ollama-cloud");
		assert.ok(ids.includes("xiaomi"), "missing xiaomi");
	});

	test("includes pre-existing providers (opencode, opencode-go, minimax)", () => {
		const ids = DISCOVERABLE_PROVIDER_IDS;
		assert.ok(ids.includes("opencode"), "missing opencode");
		assert.ok(ids.includes("opencode-go"), "missing opencode-go");
		assert.ok(ids.includes("minimax"), "missing minimax");
	});

	test("every discoverable provider has non-null modelsPath and baseUrl", () => {
		for (const id of DISCOVERABLE_PROVIDER_IDS) {
			const cfg = getProviderCatalogConfig(id);
			assert.ok(cfg, `no catalog config for ${id}`);
			assert.ok(
				cfg.modelsPath,
				`${id}.modelsPath is null/empty — should not be in DISCOVERABLE_PROVIDER_IDS`,
			);
			assert.ok(cfg.baseUrl, `${id}.baseUrl is null/empty`);
		}
	});
});

// ─── parseDiscoveredModels ────────────────────────────────────────────────────

const { parseDiscoveredModels } = await import("../model-catalog-cache.js");

describe("parseDiscoveredModels — OpenAI format", () => {
	test("parses { data: [{id}] } response", () => {
		const cfg = { type: "openai" };
		const json = { data: [{ id: "model-a" }, { id: "model-b" }] };
		const result = parseDiscoveredModels(cfg, json);
		assert.deepEqual(result, [{ id: "model-a" }, { id: "model-b" }]);
	});

	test("parses { models: [{id}] } fallback format", () => {
		const cfg = { type: "openai" };
		const json = { models: [{ id: "model-x" }] };
		const result = parseDiscoveredModels(cfg, json);
		assert.deepEqual(result, [{ id: "model-x" }]);
	});

	test("returns null for empty/invalid response", () => {
		const cfg = { type: "openai" };
		assert.equal(parseDiscoveredModels(cfg, null), null);
		assert.equal(parseDiscoveredModels(cfg, {}), null);
		assert.equal(parseDiscoveredModels(cfg, { items: [] }), null);
	});

	test("uses 'name' field as fallback when 'id' is absent", () => {
		const cfg = { type: "openai" };
		// m.id ?? m.name — so { name: "fallback-name" } becomes id: "fallback-name"
		const json = { data: [{ id: "explicit-id" }, { name: "fallback-name" }] };
		const result = parseDiscoveredModels(cfg, json);
		assert.deepEqual(result, [{ id: "explicit-id" }, { id: "fallback-name" }]);
	});

	test("filters out entries with neither id nor name", () => {
		const cfg = { type: "openai" };
		const json = { data: [{ id: "valid" }, {}] };
		const result = parseDiscoveredModels(cfg, json);
		assert.deepEqual(result, [{ id: "valid" }]);
	});
});

describe("parseDiscoveredModels — Google format", () => {
	test("parses { models: [{name: 'models/gemini-...'}] } and strips 'models/' prefix", () => {
		const cfg = { type: "google" };
		const json = {
			models: [
				{ name: "models/gemini-2.0-flash" },
				{ name: "models/gemini-1.5-pro" },
			],
		};
		const result = parseDiscoveredModels(cfg, json);
		assert.deepEqual(result, [
			{ id: "gemini-2.0-flash" },
			{ id: "gemini-1.5-pro" },
		]);
	});

	test("filters out entries with empty name after stripping prefix", () => {
		const cfg = { type: "google" };
		const json = {
			models: [{ name: "models/gemini-2.0-flash" }, { name: "" }, {}],
		};
		const result = parseDiscoveredModels(cfg, json);
		assert.deepEqual(result, [{ id: "gemini-2.0-flash" }]);
	});

	test("returns null when models array is absent", () => {
		const cfg = { type: "google" };
		assert.equal(parseDiscoveredModels(cfg, { data: [] }), null);
	});
});

describe("parseDiscoveredModels — OpenRouter pricing extraction", () => {
	test("extracts cost object from pricing field", () => {
		const cfg = { type: "openai", providerId: "openrouter" };
		const json = {
			data: [
				{
					id: "openai/gpt-4o",
					pricing: {
						prompt: "0.000005",
						completion: "0.000015",
						input_cache_read: "0.0000025",
						input_cache_write: "0.000005",
					},
					context_length: 128000,
				},
			],
		};
		const result = parseDiscoveredModels(cfg, json);
		assert.equal(result.length, 1);
		assert.equal(result[0].id, "openai/gpt-4o");
		assert.ok(result[0].cost, "should have cost field");
		assert.equal(result[0].cost.input, 0.000005);
		assert.equal(result[0].cost.output, 0.000015);
		assert.equal(result[0].contextWindow, 128000);
	});
});

// ─── refreshSfManagedProviders — auth headers and SDK cache writes ────────────

describe("refreshSfManagedProviders — auth header and iteration", () => {
	let tmp;
	let originalSfHome;
	let originalFetch;

	/** Captured fetch calls: [{ url, headers }] */
	const fetchCalls = [];

	beforeEach(() => {
		fetchCalls.length = 0;
		// Temp dirs: basePath (runtime model-catalog) and SF_HOME (SDK discovery cache)
		tmp = mkdtempSync(join(tmpdir(), "sf-discovery-test-"));
		// Override SF_HOME so writeSdkDiscoveryCacheEntry writes to our temp dir.
		originalSfHome = process.env.SF_HOME;
		process.env.SF_HOME = tmp;
		// Intercept fetch.
		originalFetch = globalThis.fetch;
		globalThis.fetch = async (url, options = {}) => {
			fetchCalls.push({ url, headers: { ...(options.headers ?? {}) } });
			// Return a minimal valid model list for any URL.
			return {
				ok: true,
				json: async () => ({ data: [{ id: "stub-model" }] }),
			};
		};
	});

	afterEach(() => {
		if (tmp) rmSync(tmp, { recursive: true, force: true });
		process.env.SF_HOME = originalSfHome;
		globalThis.fetch = originalFetch;
	});

	test("uses x-api-key header for xiaomi (not Bearer)", async () => {
		const { refreshSfManagedProviders } = await import(
			"../model-catalog-cache.js"
		);

		// Provide a key only for xiaomi so we don't accidentally test other providers.
		const auth = {
			getCredentialsForProvider(id) {
				if (id === "xiaomi")
					return [{ type: "api_key", key: "test-key-xiaomi" }];
				return [];
			},
		};

		await refreshSfManagedProviders(tmp, auth);

		const xiaomiCall = fetchCalls.find((c) => c.url.includes("xiaomimimo.com"));
		assert.ok(xiaomiCall, "should have fetched xiaomi models endpoint");
		assert.equal(
			xiaomiCall.headers["x-api-key"],
			"test-key-xiaomi",
			"xiaomi must use x-api-key header",
		);
		assert.equal(
			xiaomiCall.headers["Authorization"],
			undefined,
			"xiaomi must NOT use Authorization/Bearer header",
		);
	});

	test("uses Bearer header for opencode (not x-api-key)", async () => {
		const { refreshSfManagedProviders } = await import(
			"../model-catalog-cache.js"
		);

		const auth = {
			getCredentialsForProvider(id) {
				if (id === "opencode")
					return [{ type: "api_key", key: "test-key-opencode" }];
				return [];
			},
		};

		await refreshSfManagedProviders(tmp, auth);

		const opencodeCall = fetchCalls.find(
			(c) => c.url.includes("opencode.ai/zen") && !c.url.includes("/go"),
		);
		assert.ok(opencodeCall, "should have fetched opencode models endpoint");
		assert.equal(
			opencodeCall.headers["Authorization"],
			"Bearer test-key-opencode",
			"opencode must use Bearer auth",
		);
		assert.equal(
			opencodeCall.headers["x-api-key"],
			undefined,
			"opencode must NOT use x-api-key header",
		);
	});

	test("skips providers with no API key", async () => {
		const { refreshSfManagedProviders } = await import(
			"../model-catalog-cache.js"
		);

		// No keys at all.
		const auth = { getCredentialsForProvider: () => [] };

		await refreshSfManagedProviders(tmp, auth);

		assert.equal(
			fetchCalls.length,
			0,
			"should make no fetch calls when no keys are configured",
		);
	});

	test("writes to SDK discovery cache when runtime cache is absent", async () => {
		const { refreshSfManagedProviders } = await import(
			"../model-catalog-cache.js"
		);

		const auth = {
			getCredentialsForProvider(id) {
				if (id === "opencode") return [{ type: "api_key", key: "test-key-oc" }];
				return [];
			},
		};

		await refreshSfManagedProviders(tmp, auth);

		const cachePath = join(tmp, "agent", "discovery-cache.json");
		assert.ok(existsSync(cachePath), "discovery-cache.json should be created");

		const cache = JSON.parse(readFileSync(cachePath, "utf-8"));
		assert.equal(cache.version, 1);
		assert.ok(
			cache.entries["opencode"],
			"opencode should have an entry in the SDK discovery cache",
		);
		assert.ok(
			Array.isArray(cache.entries["opencode"].models),
			"opencode entry should have a models array",
		);
	});

	test("iterates all SF-managed providers that have keys (kimi-coding and opencode-go)", async () => {
		const { refreshSfManagedProviders } = await import(
			"../model-catalog-cache.js"
		);

		const auth = {
			getCredentialsForProvider(id) {
				if (id === "kimi-coding") return [{ type: "api_key", key: "key-kimi" }];
				if (id === "opencode-go")
					return [{ type: "api_key", key: "key-oc-go" }];
				return [];
			},
		};

		await refreshSfManagedProviders(tmp, auth);

		const kimiCall = fetchCalls.find((c) => c.url.includes("api.kimi.com"));
		assert.ok(kimiCall, "should have fetched kimi-coding models endpoint");

		const goCall = fetchCalls.find((c) => c.url.includes("opencode.ai/zen/go"));
		assert.ok(goCall, "should have fetched opencode-go models endpoint");

		// Both should be in the SDK discovery cache.
		const cachePath = join(tmp, "agent", "discovery-cache.json");
		assert.ok(existsSync(cachePath));
		const cache = JSON.parse(readFileSync(cachePath, "utf-8"));
		assert.ok(
			cache.entries["kimi-coding"],
			"kimi-coding should be in SDK discovery cache",
		);
		assert.ok(
			cache.entries["opencode-go"],
			"opencode-go should be in SDK discovery cache",
		);
	});

	test("writes runtime-cached data to SDK discovery cache without re-fetching", async () => {
		const { refreshSfManagedProviders } = await import(
			"../model-catalog-cache.js"
		);
		const { writeFileSync, mkdirSync } = await import("node:fs");

		// Pre-populate the global catalog for opencode to simulate a cache hit.
		// SF_HOME is pinned to `tmp` in beforeEach, so the global catalog dir
		// resolves to join(tmp, "model-catalog").
		const cacheDir = join(tmp, "model-catalog");
		mkdirSync(cacheDir, { recursive: true });
		writeFileSync(
			join(cacheDir, "opencode.json"),
			JSON.stringify({
				fetchedAt: new Date().toISOString(),
				modelIds: [{ id: "cached-model-1" }, { id: "cached-model-2" }],
			}),
			"utf-8",
		);

		const auth = {
			getCredentialsForProvider(id) {
				if (id === "opencode") return [{ type: "api_key", key: "test-key-oc" }];
				return [];
			},
		};

		await refreshSfManagedProviders(tmp, auth);

		// Should NOT have made a network call for opencode (cache was fresh).
		const opencodeCall = fetchCalls.find(
			(c) => c.url.includes("opencode.ai/zen") && !c.url.includes("/go"),
		);
		assert.equal(
			opencodeCall,
			undefined,
			"should NOT re-fetch opencode when runtime cache is fresh",
		);

		// But should still have written to the SDK discovery cache from cached data.
		const cachePath = join(tmp, "agent", "discovery-cache.json");
		assert.ok(
			existsSync(cachePath),
			"SDK discovery cache should be written even on cache hit",
		);
		const cache = JSON.parse(readFileSync(cachePath, "utf-8"));
		assert.ok(
			cache.entries["opencode"],
			"opencode should be in SDK discovery cache",
		);
		assert.ok(
			cache.entries["opencode"].models.length > 0,
			"SDK discovery cache should include cached model data",
		);
	});
});
