import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Model } from "@singularity-forge/ai";
import { afterEach, beforeEach, describe, it } from "vitest";
import type { ModelRegistry } from "../core/model-registry.js";
import { listModels } from "./list-models.js";

/**
 * Path to a guaranteed-missing file — used to prevent tests from picking up
 * the developer's real ~/.sf/agent/discovery-cache.json and thus seeing
 * unexpected models in output.
 */
const NO_CACHE = join(tmpdir(), `no-discovery-cache-${process.pid}.json`);

const model = (provider: string, id: string): Model<any> => ({
	id,
	name: id,
	api: "openai-completions",
	provider,
	baseUrl: "https://example.invalid",
	reasoning: false,
	input: ["text"],
	cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
	contextWindow: 128000,
	maxTokens: 16000,
});

/** Minimal no-op ModelRegistry stub used by discovery-cache tests */
const staticRegistry = (staticModels: Model<any>[] = []): ModelRegistry =>
	({
		discoverModels: async () => [],
		getAvailable: () => staticModels,
		getDiscoveredModels: () => [],
		isDiscovered: () => false,
	}) as unknown as ModelRegistry;

let originalLog: typeof console.log;
let output: string[];

beforeEach(() => {
	originalLog = console.log;
	output = [];
	console.log = (...args: unknown[]) => {
		output.push(args.join(" "));
	};
});

afterEach(() => {
	console.log = originalLog;
});

describe("listModels", () => {
	it("exact live provider search uses discovery and replaces static rows", async () => {
		const registry = {
			discoverModels: async (providers?: string[]) => {
				assert.deepEqual(providers, ["zai"]);
				return [
					{
						provider: "zai",
						models: [{ id: "glm-5.1" }],
						fetchedAt: Date.now(),
					},
				];
			},
			getAvailable: () => [
				model("zai", "glm-4.5-air"),
				model("zai", "glm-5.1"),
			],
			getDiscoveredModels: () => [model("zai", "glm-5.1")],
			isDiscovered: (m: Model<any>) =>
				m.provider === "zai" && m.id === "glm-5.1",
		} as unknown as ModelRegistry;

		await listModels(registry, {
			searchPattern: "zai",
			_discoveryCacheFilePath: NO_CACHE,
		});

		const rendered = output.join("\n");
		assert.match(rendered, /glm-5\.1/);
		assert.doesNotMatch(rendered, /glm-4\.5-air/);
	});

	it("discovery errors hide stale static rows for attempted live providers", async () => {
		const registry = {
			discoverModels: async () => [
				{
					provider: "zai",
					models: [],
					fetchedAt: Date.now(),
					error: "401 Unauthorized",
				},
			],
			getAvailable: () => [
				model("zai", "glm-5.1"),
				model("minimax", "MiniMax-M2.7"),
			],
			getDiscoveredModels: () => [],
			isDiscovered: () => false,
		} as unknown as ModelRegistry;

		await listModels(registry, {
			discover: true,
			_discoveryCacheFilePath: NO_CACHE,
		});

		const rendered = output.join("\n");
		assert.doesNotMatch(rendered, /zai/);
		assert.match(rendered, /MiniMax-M2\.7/);
	});
});

describe("listModels – discovery cache merge", () => {
	/** Write a minimal discovery-cache.json to a temp file and return its path */
	function writeTempCache(
		entries: Record<string, { models: object[] }>,
	): string {
		const path = join(
			tmpdir(),
			`discovery-cache-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
		);
		writeFileSync(path, JSON.stringify({ version: 1, entries }), "utf-8");
		return path;
	}

	it("includes opencode models from discovery cache when provider is absent from static catalog", async () => {
		const cachePath = writeTempCache({
			opencode: {
				models: [
					{
						id: "big-pickle",
						name: "big-pickle",
						provider: "opencode",
						api: "openai-completions",
						baseUrl: "https://opencode.ai/zen",
						input: ["text"],
					},
					{
						id: "gpt-5-nano",
						name: "gpt-5-nano",
						provider: "opencode",
						api: "openai-completions",
						baseUrl: "https://opencode.ai/zen",
						input: ["text"],
					},
				],
			},
		});

		await listModels(staticRegistry(), {
			discover: true,
			_discoveryCacheFilePath: cachePath,
		});

		const rendered = output.join("\n");
		assert.match(rendered, /opencode/);
		assert.match(rendered, /big-pickle/);
		assert.match(rendered, /gpt-5-nano/);
	});

	it("dedupes opencode-go: 9 static + 15 in cache → 15 unique rows (static wins for overlap)", async () => {
		// 9 static models
		const staticModels = Array.from({ length: 9 }, (_, i) =>
			model("opencode-go", `static-model-${i}`),
		);
		// Discovery cache has 15 models: 9 overlap with static, 6 unique
		const cacheModels = [
			...Array.from({ length: 9 }, (_, i) => ({ id: `static-model-${i}` })),
			...Array.from({ length: 6 }, (_, i) => ({ id: `live-only-${i}` })),
		];
		const cachePath = writeTempCache({
			"opencode-go": { models: cacheModels },
		});

		await listModels(staticRegistry(staticModels), {
			discover: true,
			_discoveryCacheFilePath: cachePath,
		});

		const rendered = output.join("\n");
		// Static entries should be present
		assert.match(rendered, /static-model-0/);
		// Live-only entries should be present
		assert.match(rendered, /live-only-0/);
		// Total opencode-go rows = 15 (9 static + 6 new from cache)
		const lines = rendered.split("\n").filter((l) => l.includes("opencode-go"));
		assert.equal(
			lines.length,
			15,
			`expected 15 opencode-go rows, got ${lines.length}`,
		);
	});

	it("gracefully handles missing discovery cache file — falls back to static only", async () => {
		const nonExistentPath = join(tmpdir(), `no-such-file-${Date.now()}.json`);

		await listModels(staticRegistry([model("anthropic", "claude-opus-4")]), {
			discover: true,
			_discoveryCacheFilePath: nonExistentPath,
		});

		const rendered = output.join("\n");
		assert.match(rendered, /claude-opus-4/);
	});

	it("gracefully handles malformed discovery cache JSON — falls back to static only", async () => {
		const path = join(tmpdir(), `bad-cache-${Date.now()}.json`);
		writeFileSync(path, "{ this is not valid json ::::", "utf-8");

		await listModels(staticRegistry([model("anthropic", "claude-sonnet-4")]), {
			discover: true,
			_discoveryCacheFilePath: path,
		});

		const rendered = output.join("\n");
		assert.match(rendered, /claude-sonnet-4/);
	});

	it("synthesized cache entries are marked [discovered] in output", async () => {
		const cachePath = writeTempCache({
			"kimi-coding": {
				models: [
					{
						id: "kimi-for-coding",
						provider: "kimi-coding",
						api: "openai-completions",
						input: ["text"],
					},
				],
			},
		});

		await listModels(staticRegistry(), {
			discover: true,
			_discoveryCacheFilePath: cachePath,
		});

		const rendered = output.join("\n");
		const kimiLine = rendered
			.split("\n")
			.find((l) => l.includes("kimi-for-coding"));
		assert.ok(kimiLine, "kimi-for-coding should appear in output");
		assert.match(
			kimiLine,
			/\[discovered\]/,
			"cache entry should be marked [discovered]",
		);
	});
});
