/**
 * List available models with optional fuzzy search and discovery support
 */

import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Api, Model } from "@singularity-forge/ai";
import { fuzzyFilter } from "@singularity-forge/tui/fuzzy";
import { getDiscoverableProviders } from "../core/model-discovery.js";
import type { ModelRegistry } from "../core/model-registry.js";

/**
 * Read ~/.sf/agent/discovery-cache.json and return synthesized Model objects
 * for entries not already present in `existingKeys` (set of "provider/id" strings).
 * Entries from the cache are marked with `_fromDiscoveryCache: true` via the
 * badge field in the row builder below.
 *
 * Gracefully returns [] on missing file, malformed JSON, or unexpected shape.
 */
export interface SynthesizedModel extends Model<Api> {
	/** True when this model came from the discovery cache, not the static catalog */
	_fromDiscoveryCache: boolean;
}

function readDiscoveryCacheModels(
	existingKeys: Set<string>,
	cacheFilePath?: string,
): SynthesizedModel[] {
	try {
		const path =
			cacheFilePath ?? join(homedir(), ".sf", "agent", "discovery-cache.json");
		const raw = readFileSync(path, "utf-8");
		const data = JSON.parse(raw) as {
			version?: number;
			entries?: Record<
				string,
				{
					models?: Array<{
						id: string;
						name?: string;
						contextWindow?: number;
						maxTokens?: number;
						reasoning?: boolean;
						input?: ("text" | "image")[];
						cost?: {
							input: number;
							output: number;
							cacheRead: number;
							cacheWrite: number;
						};
					}>;
				}
			>;
		};
		if (data.version !== 1 || typeof data.entries !== "object") return [];

		const synthesized: SynthesizedModel[] = [];
		for (const [provider, entry] of Object.entries(data.entries)) {
			if (!Array.isArray(entry?.models)) continue;
			for (const m of entry.models) {
				if (!m?.id) continue;
				const key = `${provider}/${m.id}`;
				if (existingKeys.has(key)) continue;
				synthesized.push({
					provider,
					id: m.id,
					name: m.name ?? m.id,
					api: "openai-completions",
					baseUrl: "",
					contextWindow: m.contextWindow ?? 0,
					maxTokens: m.maxTokens ?? 0,
					cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
					reasoning: m.reasoning ?? false,
					input: m.input ?? ["text"],
					_fromDiscoveryCache: true,
				});
			}
		}
		return synthesized;
	} catch {
		return [];
	}
}

export interface ListModelsOptions {
	/** Include discovered models in output */
	discover?: boolean;
	/** Search pattern for fuzzy filtering */
	searchPattern?: string;
	/**
	 * Optional predicate run per model. If provided, only models passing the
	 * filter are listed. Used by SF to honour allowed_providers /
	 * blocked_providers and provider_model_allow / provider_model_block from
	 * preferences.
	 */
	modelFilter?: (model: Model<Api>) => boolean;
	/**
	 * Override path to discovery-cache.json. Defaults to
	 * ~/.sf/agent/discovery-cache.json. Intended for testing only.
	 */
	_discoveryCacheFilePath?: string;
}

/**
 * Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M")
 */
function formatTokenCount(count: number): string {
	if (count >= 1_000_000) {
		const millions = count / 1_000_000;
		return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
	}
	if (count >= 1_000) {
		const thousands = count / 1_000;
		return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
	}
	return count.toString();
}

/**
 * Discover models from provider APIs and print results.
 */
export async function discoverAndPrintModels(
	modelRegistry: ModelRegistry,
	provider?: string,
): Promise<void> {
	const providers = provider ? [provider] : undefined;

	console.log("Discovering models...");
	const results = await modelRegistry.discoverModels(providers);

	for (const result of results) {
		if (result.error) {
			console.log(`  ${result.provider}: error - ${result.error}`);
		} else {
			console.log(`  ${result.provider}: ${result.models.length} models found`);
		}
	}
}

/**
 * List available models, optionally filtered by search pattern.
 * Accepts either a string (backward compat) or ListModelsOptions.
 */
export async function listModels(
	modelRegistry: ModelRegistry,
	optionsOrSearch?: string | ListModelsOptions,
): Promise<void> {
	const options: ListModelsOptions =
		typeof optionsOrSearch === "string"
			? { searchPattern: optionsOrSearch }
			: (optionsOrSearch ?? {});

	const exactDiscoveryProvider = resolveExactDiscoveryProvider(
		options.searchPattern,
	);
	const shouldDiscover =
		options.discover || exactDiscoveryProvider !== undefined;
	const discoveryResults = shouldDiscover
		? await modelRegistry.discoverModels(
				exactDiscoveryProvider ? [exactDiscoveryProvider] : undefined,
			)
		: [];
	const discoveredProviders = new Set(discoveryResults.map((r) => r.provider));

	// Live-listed providers must not fall back to stale static catalog rows once a
	// discovery pass was attempted. If the provider returns 401/429/empty, it
	// contributes zero rows to this diagnostic output.
	const baseModels = shouldDiscover
		? [
				...modelRegistry
					.getAvailable()
					.filter((m) => !discoveredProviders.has(m.provider)),
				...modelRegistry
					.getDiscoveredModels()
					.filter((m) => discoveredProviders.has(m.provider)),
			]
		: modelRegistry.getAvailable();

	// Merge entries from the discovery cache that aren't already in baseModels.
	// This covers SF-managed providers (opencode, opencode-go, kimi-coding, …)
	// whose SDK adapters have supportsDiscovery=false and are therefore skipped
	// by modelRegistry.discoverModels(). The cache was populated by
	// refreshSfManagedProviders() before this call (in src/cli.ts).
	// Static catalog entries win over synthesized cache entries (dedup by
	// "provider/id" — existing key = already in baseModels → skip).
	const existingKeys = new Set(baseModels.map((m) => `${m.provider}/${m.id}`));
	const cacheModels = readDiscoveryCacheModels(
		existingKeys,
		options._discoveryCacheFilePath,
	);
	const models: (Model<Api> | SynthesizedModel)[] = [
		...baseModels,
		...cacheModels,
	];

	if (models.length === 0) {
		console.log("No models available. Set API keys in environment variables.");
		return;
	}

	// Apply fuzzy filter if search pattern provided
	let filteredModels: (Model<Api> | SynthesizedModel)[] = exactDiscoveryProvider
		? models.filter((m) => m.provider.toLowerCase() === exactDiscoveryProvider)
		: models;
	if (options.searchPattern && !exactDiscoveryProvider) {
		filteredModels = fuzzyFilter(
			models,
			options.searchPattern,
			(m) => `${m.provider} ${m.id}`,
		);
	}

	if (options.modelFilter) {
		filteredModels = filteredModels.filter((m) => options.modelFilter!(m));
	}

	if (filteredModels.length === 0) {
		console.log(`No models matching "${options.searchPattern}"`);
		return;
	}

	// Sort by model name descending (newest first), then provider, then id
	filteredModels.sort((a, b) => {
		const nameCmp = b.name.localeCompare(a.name);
		if (nameCmp !== 0) return nameCmp;
		const providerCmp = a.provider.localeCompare(b.provider);
		if (providerCmp !== 0) return providerCmp;
		return a.id.localeCompare(b.id);
	});

	// Calculate column widths
	const rows = filteredModels.map((m) => {
		const isCacheEntry = "_fromDiscoveryCache" in m && m._fromDiscoveryCache;
		const isDiscovered =
			isCacheEntry || (options.discover && modelRegistry.isDiscovered(m));
		return {
			provider: m.provider,
			model: m.id,
			name: m.name,
			context: formatTokenCount(m.contextWindow),
			maxOut: formatTokenCount(m.maxTokens),
			thinking: m.reasoning ? "yes" : "no",
			images: m.input.includes("image") ? "yes" : "no",
			badge: isDiscovered ? "[discovered]" : "",
		};
	});

	const headers = {
		provider: "provider",
		model: "model",
		name: "name",
		context: "context",
		maxOut: "max-out",
		thinking: "thinking",
		images: "images",
		badge: "",
	};

	const widths = {
		provider: Math.max(
			headers.provider.length,
			...rows.map((r) => r.provider.length),
		),
		model: Math.max(headers.model.length, ...rows.map((r) => r.model.length)),
		name: Math.max(headers.name.length, ...rows.map((r) => r.name.length)),
		context: Math.max(
			headers.context.length,
			...rows.map((r) => r.context.length),
		),
		maxOut: Math.max(
			headers.maxOut.length,
			...rows.map((r) => r.maxOut.length),
		),
		thinking: Math.max(
			headers.thinking.length,
			...rows.map((r) => r.thinking.length),
		),
		images: Math.max(
			headers.images.length,
			...rows.map((r) => r.images.length),
		),
	};

	// Print header
	const headerLine = [
		headers.provider.padEnd(widths.provider),
		headers.model.padEnd(widths.model),
		headers.name.padEnd(widths.name),
		headers.context.padEnd(widths.context),
		headers.maxOut.padEnd(widths.maxOut),
		headers.thinking.padEnd(widths.thinking),
		headers.images.padEnd(widths.images),
	].join("  ");
	console.log(headerLine);

	// Print rows
	for (const row of rows) {
		const line = [
			row.provider.padEnd(widths.provider),
			row.model.padEnd(widths.model),
			row.name.padEnd(widths.name),
			row.context.padEnd(widths.context),
			row.maxOut.padEnd(widths.maxOut),
			row.thinking.padEnd(widths.thinking),
			row.images.padEnd(widths.images),
			row.badge,
		]
			.join("  ")
			.trimEnd();
		console.log(line);
	}
}

function resolveExactDiscoveryProvider(
	searchPattern?: string,
): string | undefined {
	const query = searchPattern?.trim().toLowerCase();
	if (!query) return undefined;
	return getDiscoverableProviders().find(
		(provider) => provider.toLowerCase() === query,
	);
}
