/**
 * model-catalog-cache.js — live model-list discovery with disk cache.
 *
 * Fetches model lists from providers that publish a /v1/models (or equivalent)
 * endpoint. Results are cached globally to ~/.sf/model-catalog/<providerId>.json
 * (SF_HOME-aware) with a 6-hour TTL so the model picker and preference validation
 * see fresh model IDs without blocking the event loop. Catalog contents are
 * provider-level — identical across projects — so a single global cache avoids
 * the per-repo re-discovery cost.
 *
 * For providers that the SDK's built-in discovery adapter registry does NOT
 * cover with supportsDiscovery=true (opencode, opencode-go, kimi-coding, xiaomi),
 * results are ALSO written to ~/.sf/agent/discovery-cache.json so that
 * `sf --list-models --discover` includes them in the discovery output.
 *
 * Provider configuration (base URL, auth format, model filter patterns) comes
 * from provider-catalog-config.js — not hardcoded here.
 */
import {
	existsSync,
	mkdirSync,
	readFileSync,
	renameSync,
	writeFileSync,
} from "node:fs";
import { join } from "node:path";
import {
	DISCOVERABLE_PROVIDER_IDS,
	getProviderCatalogConfig,
	getProviderModelExcludePatterns,
} from "./provider-catalog-config.js";
import { sfHome } from "./sf-home.js";

/**
 * @typedef {{ id: string, cost?: { input: number, output: number, cacheRead: number, cacheWrite: number }, contextWindow?: number }} DiscoveredModelEntry
 */

const CATALOG_TTL_MS = 6 * 60 * 60 * 1000;

/**
 * Providers whose live model lists are already handled by the SDK's native
 * discoverModels() adapter (supportsDiscovery=true in model-discovery.js).
 * For these, the SF layer does NOT need to write to the SDK discovery cache —
 * --discover will populate it via the SDK adapter path.
 * All other DISCOVERABLE_PROVIDER_IDS are "SF-managed" and need SF to write
 * their results into discovery-cache.json so --list-models --discover sees them.
 */
const SDK_NATIVE_DISCOVERY_PROVIDERS = new Set([
	"ollama-cloud",
	"openrouter",
	"zai",
	"minimax",
	"mistral",
]);

const SDK_DISCOVERY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour — matches DISCOVERY_TTLS defaults

// Global catalog dir — identical across projects. `basePath` is accepted on
// public API for backward compat but ignored for path resolution.
function cacheDirPath(_basePath) {
	return join(sfHome(), "model-catalog");
}

function cacheFilePath(_basePath, providerId) {
	return join(cacheDirPath(), `${providerId}.json`);
}

/**
 * Read cached model entries for a provider. Returns null if cache is missing or stale.
 * Performs one-time migration from legacy string[] format to DiscoveredModelEntry[].
 *
 * @returns {DiscoveredModelEntry[] | null}
 */
function readCachedModelEntries(basePath, providerId) {
	try {
		const path = cacheFilePath(basePath, providerId);
		if (!existsSync(path)) return null;
		let entry = JSON.parse(readFileSync(path, "utf-8"));
		if (!entry?.fetchedAt || !Array.isArray(entry.modelIds)) return null;
		if (Date.now() - new Date(entry.fetchedAt).getTime() > CATALOG_TTL_MS)
			return null;
		// Migration: legacy cache stored string[] — convert to object[] in-place.
		if (entry.modelIds.length > 0 && typeof entry.modelIds[0] === "string") {
			entry = {
				...entry,
				modelIds: entry.modelIds.map((id) => ({ id })),
			};
			try {
				writeFileSync(
					cacheFilePath(basePath, providerId),
					JSON.stringify(entry),
					"utf-8",
				);
			} catch {
				// Migration write failure is non-fatal.
			}
		}
		return entry.modelIds;
	} catch {
		return null;
	}
}

/**
 * Read cached model IDs (strings only) for a provider. Returns null if cache is missing or stale.
 * Legacy helper for call sites that only need model ID strings.
 *
 * @returns {string[] | null}
 */
export function readCachedModelIds(basePath, providerId) {
	const entries = readCachedModelEntries(basePath, providerId);
	if (!entries) return null;
	return entries.map((e) => (typeof e === "string" ? e : e.id));
}

/**
 * Return cached model entries (with pricing/contextWindow if available) for a provider.
 * Returns empty array if cache is missing, stale, or the provider hasn't been discovered yet.
 *
 * @returns {DiscoveredModelEntry[]}
 */
export function getCachedModelEntries(basePath, providerId) {
	return readCachedModelEntries(basePath, providerId) ?? [];
}

/**
 * Return cached model IDs as strings for a provider.
 * Returns empty array if cache is missing or stale.
 *
 * @returns {string[]}
 */
export function getCachedModelIds(basePath, providerId) {
	return readCachedModelIds(basePath, providerId) ?? [];
}

/**
 * @param {string} basePath
 * @param {string} providerId
 * @param {DiscoveredModelEntry[]} modelEntries
 */
function writeCacheEntry(basePath, providerId, modelEntries) {
	try {
		mkdirSync(cacheDirPath(basePath), { recursive: true });
		writeFileSync(
			cacheFilePath(basePath, providerId),
			JSON.stringify({
				fetchedAt: new Date().toISOString(),
				modelIds: modelEntries,
			}),
			"utf-8",
		);
	} catch {
		// Best-effort — never fail the caller.
	}
}

/**
 * Write fetched model entries for an SF-managed provider into the SDK discovery
 * cache (~/.sf/agent/discovery-cache.json) so that `--list-models --discover`
 * includes them.  Only called for providers not covered by the SDK's native
 * discovery adapters (i.e. not in SDK_NATIVE_DISCOVERY_PROVIDERS).
 *
 * Model objects include cost data (when available) so that
 * isModelAllowedByBuiltInProviderPolicy can evaluate zero-cost models correctly.
 *
 * @param {string} providerId
 * @param {DiscoveredModelEntry[]} modelEntries
 */
function writeSdkDiscoveryCacheEntry(providerId, modelEntries) {
	try {
		const cfg = getProviderCatalogConfig(providerId);
		if (!cfg) return;
		const cachePath = join(sfHome(), "agent", "discovery-cache.json");
		let cache = { version: 1, entries: {} };
		if (existsSync(cachePath)) {
			try {
				cache = JSON.parse(readFileSync(cachePath, "utf-8"));
				if (cache.version !== 1 || typeof cache.entries !== "object") {
					cache = { version: 1, entries: {} };
				}
			} catch {
				cache = { version: 1, entries: {} };
			}
		}
		const api =
			cfg.type === "anthropic" ? "anthropic-messages" : "openai-completions";
		const models = modelEntries.map((entry) => {
			const id = typeof entry === "string" ? entry : entry.id;
			const cost = typeof entry === "object" ? entry.cost : undefined;
			const contextWindow =
				typeof entry === "object" ? entry.contextWindow : undefined;
			return {
				id,
				name: id,
				provider: providerId,
				api,
				baseUrl: cfg.baseUrl,
				input: ["text"],
				...(cost !== undefined ? { cost } : {}),
				...(contextWindow !== undefined ? { contextWindow } : {}),
			};
		});
		cache.entries[providerId] = {
			models,
			fetchedAt: Date.now(),
			ttlMs: SDK_DISCOVERY_CACHE_TTL_MS,
		};
		const dir = join(sfHome(), "agent");
		mkdirSync(dir, { recursive: true });
		const tmp = cachePath + ".tmp";
		writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf-8");
		renameSync(tmp, cachePath);
	} catch {
		// Best-effort — never fail the caller.
	}
}

function buildAuthHeaders(cfg, apiKey) {
	switch (cfg.auth.type) {
		case "bearer":
			return { Authorization: `Bearer ${apiKey}` };
		case "x-api-key":
			return { [cfg.auth.header ?? "x-api-key"]: apiKey };
		case "query-param":
			return {};
		default:
			return {};
	}
}

function buildUrl(cfg, apiKey) {
	const base = cfg.baseUrl.replace(/\/$/, "");
	const path = cfg.modelsPath;
	if (cfg.auth.type === "query-param" && cfg.auth.param) {
		return `${base}${path}?${cfg.auth.param}=${encodeURIComponent(apiKey)}`;
	}
	return `${base}${path}`;
}

/**
 * Parse raw /v1/models (or equivalent) API response into DiscoveredModelEntry[].
 * For OpenRouter, extracts pricing into the cost field.
 * For all other providers, returns {id}-only entries.
 *
 * Exported for testing.
 *
 * @param {{ type?: string, providerId?: string }} cfg
 * @param {unknown} json
 * @returns {DiscoveredModelEntry[] | null}
 */
export function parseDiscoveredModels(cfg, json) {
	let items;
	if (cfg.type === "google") {
		// Google: { models: [{name: "models/gemini-..."}] }
		items = Array.isArray(json?.models) ? json.models : null;
		if (!items) return null;
		return items
			.map((m) => {
				const id = (m.name ?? "").replace(/^models\//, "");
				return id ? { id } : null;
			})
			.filter(Boolean);
	}
	// OpenAI-compatible: { data: [{id}] } or { models: [{id}] }
	items = Array.isArray(json?.data)
		? json.data
		: Array.isArray(json?.models)
			? json.models
			: null;
	if (!items) return null;
	// For OpenRouter, extract per-model pricing from the response.
	const isOpenRouter = cfg.providerId === "openrouter";
	return items
		.map((m) => {
			const id = m.id ?? m.name;
			if (!id) return null;
			if (isOpenRouter && m.pricing) {
				return {
					id,
					cost: {
						input: parseFloat(m.pricing.prompt ?? "0") || 0,
						output: parseFloat(m.pricing.completion ?? "0") || 0,
						cacheRead: parseFloat(m.pricing.input_cache_read ?? "0") || 0,
						cacheWrite: parseFloat(m.pricing.input_cache_write ?? "0") || 0,
					},
					...(m.context_length != null
						? { contextWindow: m.context_length }
						: {}),
				};
			}
			return { id };
		})
		.filter(Boolean);
}

/**
 * @param {string} providerId
 * @param {DiscoveredModelEntry[]} modelEntries
 * @returns {DiscoveredModelEntry[]}
 */
function applyModelFilter(providerId, modelEntries) {
	const patterns = getProviderModelExcludePatterns(providerId);
	if (patterns.length === 0) return modelEntries;
	return modelEntries.filter((entry) => {
		const id = typeof entry === "string" ? entry : entry.id;
		return !patterns.some((re) => re.test(id));
	});
}

/**
 * Fetch the live model list for one provider and update the disk cache.
 * Returns the filtered model ID array on success, null on any error.
 *
 * For SF-managed providers (not in SDK_NATIVE_DISCOVERY_PROVIDERS), also
 * writes results into the SDK discovery cache so --list-models --discover
 * includes them.
 */
export async function refreshProviderCatalog(basePath, providerId, apiKey) {
	const cfg = getProviderCatalogConfig(providerId);
	if (!cfg?.modelsPath || !apiKey) return null;
	try {
		const url = buildUrl(cfg, apiKey);
		const res = await fetch(url, {
			headers: buildAuthHeaders(cfg, apiKey),
			signal: AbortSignal.timeout(10_000),
		});
		if (!res.ok) return null;
		const json = await res.json();
		// Pass providerId into parseDiscoveredModels so it can apply provider-specific
		// parsing (e.g. OpenRouter pricing extraction).
		const raw = parseDiscoveredModels({ ...cfg, providerId }, json);
		if (!raw) return null;
		const modelEntries = applyModelFilter(providerId, raw);
		writeCacheEntry(basePath, providerId, modelEntries);
		// Also populate the SDK discovery cache for providers that the SDK's
		// built-in discovery adapters do not cover (opencode, opencode-go,
		// kimi-coding, xiaomi, etc.). Without this, --list-models --discover
		// silently omits them because their SDK adapter has supportsDiscovery=false.
		if (!SDK_NATIVE_DISCOVERY_PROVIDERS.has(providerId)) {
			writeSdkDiscoveryCacheEntry(providerId, modelEntries);
		}
		return modelEntries.map((e) => e.id);
	} catch {
		return null;
	}
}

/**
 * Awaitable refresh for all discoverable providers with a key in auth.json
 * and a stale or absent cache. Per-provider failures are logged but never
 * surfaced as exceptions — the loop is best-effort.
 */
export async function runModelCatalogRefreshIfStale(basePath, auth) {
	for (const providerId of DISCOVERABLE_PROVIDER_IDS) {
		try {
			const creds = auth.getCredentialsForProvider(providerId);
			const apiKey = creds.find((c) => c.type === "api_key" && c.key)?.key;
			if (!apiKey) continue;
			if (readCachedModelIds(basePath, providerId) !== null) continue;
			const result = await refreshProviderCatalog(basePath, providerId, apiKey);
			if (result === null) {
				// Surface per-provider fetch failures so they don't silently disappear.
				process.stderr.write(
					`[model-catalog-cache] refresh failed for provider: ${providerId}\n`,
				);
			}
		} catch (err) {
			// Per-provider failures must not crash the refresh loop, but should be visible.
			process.stderr.write(
				`[model-catalog-cache] unexpected error for provider ${providerId}: ${err?.message ?? err}\n`,
			);
		}
	}
}

/**
 * Fire-and-forget background refresh. Wraps runModelCatalogRefreshIfStale in
 * setImmediate. Safe to call at session start — never throws, never blocks.
 */
export function scheduleModelCatalogRefresh(basePath, auth) {
	setImmediate(() => runModelCatalogRefreshIfStale(basePath, auth));
}

/**
 * Awaitable refresh of SF-managed providers — providers that DISCOVERABLE_PROVIDER_IDS
 * covers but the SDK's native discovery adapters (supportsDiscovery=true) do not.
 *
 * Called before `--list-models --discover` so that the SDK's discoverModels()
 * reads these providers from the (now-populated) discovery cache rather than
 * skipping them due to StaticDiscoveryAdapter.
 *
 * Only fetches providers whose runtime cache is stale or absent to avoid
 * unnecessary network calls on repeated --discover invocations.
 */
export async function refreshSfManagedProviders(basePath, auth) {
	for (const providerId of DISCOVERABLE_PROVIDER_IDS) {
		if (SDK_NATIVE_DISCOVERY_PROVIDERS.has(providerId)) continue;
		try {
			const creds = auth.getCredentialsForProvider(providerId);
			const apiKey = creds.find((c) => c.type === "api_key" && c.key)?.key;
			if (!apiKey) continue;
			const cachedIds = readCachedModelIds(basePath, providerId);
			if (cachedIds !== null) {
				// Runtime cache is fresh — no need to re-fetch. Still ensure the SDK
				// discovery cache has an entry for this provider (e.g. after --discover
				// deletes discovery-cache.json but the runtime cache survives).
				writeSdkDiscoveryCacheEntry(
					providerId,
					cachedIds.map((id) => ({ id })),
				);
				continue;
			}
			const result = await refreshProviderCatalog(basePath, providerId, apiKey);
			if (result === null) {
				process.stderr.write(
					`[model-catalog-cache] refresh failed for provider: ${providerId}\n`,
				);
			}
		} catch (err) {
			process.stderr.write(
				`[model-catalog-cache] unexpected error for provider ${providerId}: ${err?.message ?? err}\n`,
			);
		}
	}
}

/**
 * Return the union of SDK-static model IDs and live-discovered model IDs for a
 * provider. Hot path — reads only from disk cache, no network call.
 *
 * Consumer: model picker, preference validation for unknown model IDs.
 */
export function getKnownModelIds(basePath, providerId, sdkModelIds = []) {
	const cached = readCachedModelIds(basePath, providerId) ?? [];
	return [...new Set([...sdkModelIds, ...cached])];
}

/**
 * Look up the discovered cost for a given provider/modelId pair.
 * Uses the current working directory as basePath (appropriate for runtime calls
 * where basePath is not available on the call stack).
 *
 * Returns the cost object if the model was discovered with pricing data,
 * undefined if the model is unknown or has no pricing info in the cache.
 *
 * Consumer: isModelAllowedByBuiltInProviderPolicy — zero-cost openrouter models
 * that lack a `:free` suffix need their discovered cost to pass the policy check.
 *
 * @param {string} providerId
 * @param {string} modelId
 * @returns {{ input: number, output: number, cacheRead: number, cacheWrite: number } | undefined}
 */
export function lookupDiscoveredModelCost(providerId, modelId) {
	try {
		const entries = readCachedModelEntries(process.cwd(), providerId);
		if (!entries) return undefined;
		const entry = entries.find((e) => {
			const id = typeof e === "string" ? e : e.id;
			return id === modelId;
		});
		return typeof entry === "object" ? entry.cost : undefined;
	} catch {
		return undefined;
	}
}
