/**
 * openai-codex-catalog.test.mjs — verify SF mirrors the codex CLI's own
 * ~/.codex/models_cache.json into the SF model-catalog cache, filtering by
 * visibility=list AND supported_in_api=true so phantom or hidden slugs do
 * not pollute SF's router decisions.
 */
import {
	mkdirSync,
	mkdtempSync,
	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 {
	readCodexAvailableModels,
	refreshOpenaiCodexCatalog,
	scheduleOpenaiCodexCatalogRefresh,
} from "../openai-codex-catalog.js";

const tmpDirs = [];
const ORIGINAL_CODEX_HOME = process.env.CODEX_HOME;
let originalSfHome;

beforeEach(() => {
	delete process.env.CODEX_HOME;
	originalSfHome = process.env.SF_HOME;
});

afterEach(() => {
	if (ORIGINAL_CODEX_HOME === undefined) {
		delete process.env.CODEX_HOME;
	} else {
		process.env.CODEX_HOME = ORIGINAL_CODEX_HOME;
	}
	if (originalSfHome === undefined) delete process.env.SF_HOME;
	else process.env.SF_HOME = originalSfHome;
	while (tmpDirs.length > 0) {
		const dir = tmpDirs.pop();
		if (dir) rmSync(dir, { recursive: true, force: true });
	}
});

function makeProject() {
	const dir = mkdtempSync(join(tmpdir(), "sf-codex-catalog-"));
	tmpDirs.push(dir);
	mkdirSync(join(dir, ".sf"), { recursive: true });
	// Catalog is global (~/.sf/model-catalog/) — pin SF_HOME at the temp dir
	// so each test's catalog reads/writes land in isolation.
	process.env.SF_HOME = dir;
	return dir;
}

function makeCodexHome(cache) {
	const dir = mkdtempSync(join(tmpdir(), "codex-home-"));
	tmpDirs.push(dir);
	if (cache !== null) {
		writeFileSync(join(dir, "models_cache.json"), JSON.stringify(cache));
	}
	process.env.CODEX_HOME = dir;
	return dir;
}

const REAL_SHAPE_CACHE = {
	fetched_at: "2026-05-14T00:00:00Z",
	etag: "abc",
	client_version: "0.128.0",
	models: [
		{ slug: "gpt-5.5", visibility: "list", supported_in_api: true },
		{ slug: "gpt-5.4", visibility: "list", supported_in_api: true },
		{ slug: "gpt-5.4-mini", visibility: "list", supported_in_api: true },
		{ slug: "gpt-5.3-codex", visibility: "list", supported_in_api: true },
		{
			slug: "gpt-5.3-codex-spark",
			visibility: "list",
			supported_in_api: false,
		},
		{ slug: "gpt-5.2", visibility: "list", supported_in_api: true },
		{ slug: "codex-auto-review", visibility: "hide", supported_in_api: true },
	],
};

describe("readCodexAvailableModels", () => {
	test("returns null when ~/.codex/models_cache.json is missing", async () => {
		makeCodexHome(null);
		expect(await readCodexAvailableModels()).toBe(null);
	});

	test("returns null on malformed cache", async () => {
		const dir = mkdtempSync(join(tmpdir(), "codex-bad-"));
		tmpDirs.push(dir);
		writeFileSync(join(dir, "models_cache.json"), "{not json");
		process.env.CODEX_HOME = dir;
		expect(await readCodexAvailableModels()).toBe(null);
	});

	test("filters by visibility=list AND supported_in_api=true", async () => {
		makeCodexHome(REAL_SHAPE_CACHE);
		const ids = await readCodexAvailableModels();
		expect(ids).toEqual([
			"gpt-5.5",
			"gpt-5.4",
			"gpt-5.4-mini",
			"gpt-5.3-codex",
			"gpt-5.2",
		]);
		// codex-auto-review is hidden; gpt-5.3-codex-spark is not supported_in_api → excluded
		expect(ids).not.toContain("codex-auto-review");
		expect(ids).not.toContain("gpt-5.3-codex-spark");
	});

	test("returns null when no models pass the filter", async () => {
		makeCodexHome({
			fetched_at: "2026-05-14T00:00:00Z",
			models: [
				{ slug: "internal-only", visibility: "hide", supported_in_api: true },
				{ slug: "preview", visibility: "list", supported_in_api: false },
			],
		});
		expect(await readCodexAvailableModels()).toBe(null);
	});

	test("ignores entries with missing/invalid slug", async () => {
		makeCodexHome({
			models: [
				{ visibility: "list", supported_in_api: true },
				{ slug: "", visibility: "list", supported_in_api: true },
				{ slug: "gpt-5.5", visibility: "list", supported_in_api: true },
			],
		});
		expect(await readCodexAvailableModels()).toEqual(["gpt-5.5"]);
	});
});

describe("refreshOpenaiCodexCatalog", () => {
	test("writes the SF cache from the codex CLI cache", async () => {
		const project = makeProject();
		makeCodexHome(REAL_SHAPE_CACHE);

		const result = await refreshOpenaiCodexCatalog(project);
		expect(result).toEqual([
			"gpt-5.5",
			"gpt-5.4",
			"gpt-5.4-mini",
			"gpt-5.3-codex",
			"gpt-5.2",
		]);

		const cachePath = join(project, "model-catalog", "openai-codex.json");
		const cache = JSON.parse(readFileSync(cachePath, "utf-8"));
		expect(cache.modelIds).toEqual(result);
		expect(typeof cache.fetchedAt).toBe("string");
		expect(new Date(cache.fetchedAt).toString()).not.toBe("Invalid Date");
	});

	test("returns null and writes nothing when codex cache is missing", async () => {
		const project = makeProject();
		makeCodexHome(null);
		expect(await refreshOpenaiCodexCatalog(project)).toBe(null);
	});
});

// Wait for the setImmediate + dynamic import + async refresh chain to
// complete — short polling rather than fixed delay so the test stays
// fast on CI.
async function waitForCacheWrite(cachePath, timeoutMs = 2000) {
	const deadline = Date.now() + timeoutMs;
	while (Date.now() < deadline) {
		try {
			const cache = JSON.parse(readFileSync(cachePath, "utf-8"));
			if (cache?.modelIds) return cache;
		} catch {
			// not yet
		}
		await new Promise((r) => setImmediate(r));
	}
	throw new Error(
		`SF cache file ${cachePath} not written within ${timeoutMs}ms`,
	);
}

describe("scheduleOpenaiCodexCatalogRefresh", () => {
	test("populates SF cache on first call", async () => {
		const project = makeProject();
		makeCodexHome(REAL_SHAPE_CACHE);

		scheduleOpenaiCodexCatalogRefresh(project);

		const cachePath = join(project, "model-catalog", "openai-codex.json");
		const cache = await waitForCacheWrite(cachePath);
		expect(cache.modelIds).toContain("gpt-5.5");
	});

	test("skips refresh when SF cache is fresh", async () => {
		const project = makeProject();
		makeCodexHome(REAL_SHAPE_CACHE);
		// Pre-seed a fresh cache with a deliberately different model list
		// to confirm the next call does NOT overwrite it.
		mkdirSync(join(project, "model-catalog"), { recursive: true });
		writeFileSync(
			join(project, "model-catalog", "openai-codex.json"),
			JSON.stringify({
				fetchedAt: new Date().toISOString(),
				modelIds: ["sentinel-only"],
			}),
		);

		scheduleOpenaiCodexCatalogRefresh(project);
		// Give any spurious setImmediate chain a chance to run.
		await new Promise((r) => setImmediate(r));
		await new Promise((r) => setImmediate(r));

		const cache = JSON.parse(
			readFileSync(
				join(project, "model-catalog", "openai-codex.json"),
				"utf-8",
			),
		);
		expect(cache.modelIds).toEqual(["sentinel-only"]);
	});
});
