/**
 * The Hyper model catalog.
 *
 * Metadata comes from models.dev's `hyper` provider entry, which carries what a coding
 * agent needs and what Hyper's own endpoints only partly report: the reasoning effort
 * levels the deployment takes, vision support, context and output limits, and prices.
 * `GET /v1/models` - public, documented - supplies membership and this deployment's own
 * prices, and decides which models exist right now.
 *
 * `catalog.json` is that join, committed. The runtime loads the newest of it and the last
 * refresh cached in ~/.prime/agent/hyper-catalog.json, registers immediately, and
 * refreshes in the background. Parsing is strict: a shape we do not understand leaves the
 * previous catalog in place instead of registering models with wrong prices.
 */

import { readFileSync, writeFileSync } from "node:fs";
import type { OpenAICompletionsCompat } from "@earendil-works/pi-ai";
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
import { API_BASE_URL, cachePath } from "./hyper.js";
import { getJson } from "./http.js";

export const CATALOG_VERSION = 1;
export const CATALOG_SOURCE = "https://models.dev/api.json + GET /v1/models";

/** Prime Agent's thinking levels, ascending. Hyper's effort levels use the same names. */
export const PI_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;

/**
 * The level offered for a reasoning model that advertises no effort levels. Such a model
 * thinks by default and takes an effort without naming one, so the top level stands in for
 * "thinking on".
 */
export const UNADVERTISED_ON_LEVEL = "max";

export interface ModelCost {
	/** Dollars per million tokens, as Prime Agent prices models. */
	input: number;
	output: number;
	cacheRead: number;
	cacheWrite: number;
}

export interface ModelFacts {
	id: string;
	name: string;
	reasoning: boolean;
	vision: boolean;
	contextWindow: number;
	maxTokens: number;
	/** Hyper effort levels; empty when the model names none. */
	levels: string[];
	cost: ModelCost;
}

export interface Catalog {
	version: number;
	generatedAt: string;
	models: ModelFacts[];
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
	typeof value === "object" && value !== null && !Array.isArray(value);

const at = (source: Record<string, unknown>, name: string): unknown =>
	Object.getOwnPropertyDescriptor(source, name)?.value;

function requireString(source: Record<string, unknown>, name: string, where: string): string {
	const value = at(source, name);
	if (typeof value !== "string" || value === "") throw new Error(`${where} has no string "${name}"`);
	return value;
}

function requireNumber(source: Record<string, unknown>, name: string, where: string): number {
	const value = at(source, name);
	if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${where} has no number "${name}"`);
	return value;
}

function requireBoolean(source: Record<string, unknown>, name: string, where: string): boolean {
	const value = at(source, name);
	if (typeof value !== "boolean") throw new Error(`${where} has no boolean "${name}"`);
	return value;
}

const optional = (source: Record<string, unknown>, name: string): unknown => at(source, name);

const optionalNumber = (source: Record<string, unknown>, name: string): number => {
	const value = at(source, name);
	return typeof value === "number" && Number.isFinite(value) ? value : 0;
};

const optionalBoolean = (source: Record<string, unknown>, name: string): boolean => at(source, name) === true;

const strings = (value: unknown, where: string, name: string): string[] => {
	if (value === undefined) return [];
	if (!Array.isArray(value)) throw new Error(`${where} has a non-array "${name}"`);
	return value.filter((entry): entry is string => typeof entry === "string");
};

/** `none`/`off` in Hyper's level list is the off switch, not a thinking depth. */
export const isOffLevel = (level: string): boolean => level === "none" || level === "off";

/** The effort levels a models.dev entry advertises. */
const devLevels = (model: Record<string, unknown>): string[] => {
	const options = optional(model, "reasoning_options");
	if (!Array.isArray(options)) return [];
	for (const option of options) {
		if (isRecord(option) && optional(option, "type") === "effort") return strings(optional(option, "values"), "models.dev", "values");
	}
	return [];
};

const devVision = (model: Record<string, unknown>): boolean => {
	const modalities = optional(model, "modalities");
	const input = isRecord(modalities) ? strings(optional(modalities, "input"), "models.dev", "input") : [];
	return optionalBoolean(model, "attachment") || input.includes("image");
};

/**
 * One model of `GET /v1/models`, enriched from models.dev. Prices and limits come from
 * whichever source states them; Hyper's own answer wins where the two disagree, since
 * those are the prices this key is billed at.
 */
function parseFacts(raw: unknown, index: number, dev: Record<string, unknown> | undefined): ModelFacts {
	const where = `Hyper /models model ${index}`;
	if (!isRecord(raw)) throw new Error(`${where} is not an object`);
	const id = requireString(raw, "id", where);
	const pricing = isRecord(optional(raw, "pricing")) ? (optional(raw, "pricing") as Record<string, unknown>) : {};
	const capabilities = isRecord(optional(raw, "capabilities")) ? (optional(raw, "capabilities") as Record<string, unknown>) : {};
	const reasoning = isRecord(optional(raw, "reasoning")) ? (optional(raw, "reasoning") as Record<string, unknown>) : undefined;
	const levels = reasoning ? strings(optional(reasoning, "effort_levels"), where, "effort_levels") : [];
	const devCost = dev && isRecord(optional(dev, "cost")) ? (optional(dev, "cost") as Record<string, unknown>) : undefined;
	const devLimit = dev && isRecord(optional(dev, "limit")) ? (optional(dev, "limit") as Record<string, unknown>) : undefined;
	// /v1/models reports effort levels as objects; keep the names.
	const namedLevels = levels
		.map((level) => (isRecord(level) ? optional(level, "value") : level))
		.filter((level): level is string => typeof level === "string");
	return {
		id,
		name: dev ? requireString(dev, "name", `models.dev ${id}`) : requireString(raw, "display_name", where),
		reasoning: dev ? requireBoolean(dev, "reasoning", `models.dev ${id}`) : reasoning !== undefined,
		vision: dev ? devVision(dev) : optionalBoolean(capabilities, "vision"),
		contextWindow: devLimit
			? (optionalNumber(devLimit, "context") || requireNumber(raw, "context_window", where))
			: requireNumber(raw, "context_window", where),
		maxTokens: devLimit
			? (optionalNumber(devLimit, "output") || requireNumber(raw, "max_output_tokens", where))
			: requireNumber(raw, "max_output_tokens", where),
		levels: dev ? devLevels(dev) : namedLevels,
		cost: {
			input: optionalNumber(pricing, "input") || (devCost ? optionalNumber(devCost, "input") : 0),
			output: optionalNumber(pricing, "output") || (devCost ? optionalNumber(devCost, "output") : 0),
			cacheRead: optionalNumber(pricing, "cache_hit") || (devCost ? optionalNumber(devCost, "cache_read") : 0),
			cacheWrite: optionalNumber(pricing, "cache_create") || (devCost ? optionalNumber(devCost, "cache_write") : 0),
		},
	};
}

export function parseFactsResponse(payload: unknown, devModels: Record<string, unknown>): ModelFacts[] {
	const models = isRecord(payload) ? at(payload, "data") : undefined;
	if (!Array.isArray(models) || models.length === 0) throw new Error("Hyper /v1/models returned no models");
	return models.map((model, index) => {
		const id = isRecord(model) ? optional(model, "id") : undefined;
		const dev = typeof id === "string" ? devModels[id] : undefined;
		return parseFacts(model, index, isRecord(dev) ? dev : undefined);
	});
}

/** models.dev's `hyper` provider entry. Its absence is not fatal: the live answer suffices. */
export async function fetchDevModels(): Promise<Record<string, unknown>> {
	try {
		const payload = await getJson("https://models.dev/api.json", undefined, 20_000);
		const hyper = isRecord(payload) ? optional(payload, "hyper") : undefined;
		const models = isRecord(hyper) ? optional(hyper, "models") : undefined;
		return isRecord(models) ? models : {};
	} catch {
		return {};
	}
}

export const fetchFacts = async (): Promise<ModelFacts[]> =>
	parseFactsResponse(await getJson(`${API_BASE_URL}/models`), await fetchDevModels());

/** One model row of a catalog file. */
function parseCatalogModel(raw: unknown, index: number): ModelFacts {
	const where = `Catalog model ${index}`;
	if (!isRecord(raw)) throw new Error(`${where} is not an object`);
	const cost = optional(raw, "cost");
	if (!isRecord(cost)) throw new Error(`${where} has no cost object`);
	return {
		id: requireString(raw, "id", where),
		name: requireString(raw, "name", where),
		reasoning: requireBoolean(raw, "reasoning", where),
		vision: requireBoolean(raw, "vision", where),
		contextWindow: requireNumber(raw, "contextWindow", where),
		maxTokens: requireNumber(raw, "maxTokens", where),
		levels: strings(optional(raw, "levels"), where, "levels"),
		cost: {
			input: requireNumber(cost, "input", `${where} cost`),
			output: requireNumber(cost, "output", `${where} cost`),
			cacheRead: requireNumber(cost, "cacheRead", `${where} cost`),
			cacheWrite: requireNumber(cost, "cacheWrite", `${where} cost`),
		},
	};
}

/** Reads a catalog file, or `undefined` when it is missing, unreadable, or from another version. */
export function readCatalog(path: string = cachePath()): Catalog | undefined {
	try {
		const payload: unknown = JSON.parse(readFileSync(path, "utf-8"));
		if (!isRecord(payload) || at(payload, "version") !== CATALOG_VERSION) return undefined;
		const raw = at(payload, "models");
		if (!Array.isArray(raw) || raw.length === 0) return undefined;
		const generatedAt = at(payload, "generatedAt");
		return {
			version: CATALOG_VERSION,
			generatedAt: typeof generatedAt === "string" ? generatedAt : "",
			models: raw.map(parseCatalogModel),
		};
	} catch {
		return undefined;
	}
}

/** Sorted by id: the endpoint's order is not stable, and registration order follows this. */
export const toCatalog = (models: readonly ModelFacts[]): Catalog => ({
	version: CATALOG_VERSION,
	generatedAt: new Date().toISOString(),
	models: [...models].sort((a, b) => a.id.localeCompare(b.id)),
});

export function writeCatalog(path: string, catalog: Catalog): void {
	writeFileSync(path, `${JSON.stringify(catalog, null, "\t")}\n`);
}

/**
 * The thinking controls Prime Agent should offer for one model.
 *
 * Only advertised levels are offered: Hyper accepts an unadvertised effort silently, so
 * offering levels it ignores would give the user two entries that send the same request.
 * A model that reasons without naming levels gets a single on state, and an off switch
 * only when Hyper advertises one (`none`), which is the shape that reaches
 * `reasoning_effort`.
 */
function thinkingLevelMap(model: ModelFacts): NonNullable<ProviderModelConfig["thinkingLevelMap"]> | undefined {
	if (!model.reasoning) return undefined;
	const advertised = new Set(model.levels);
	const offered = new Set([...advertised].filter((level) => !isOffLevel(level)));
	if (offered.size === 0) offered.add(UNADVERTISED_ON_LEVEL);
	const map: NonNullable<ProviderModelConfig["thinkingLevelMap"]> = {
		off: [...advertised].some(isOffLevel) ? "none" : null,
	};
	for (const level of PI_LEVELS) map[level] = offered.has(level) ? level : null;
	return map;
}

/** A Hyper model config, with the compat shape every Hyper model uses stated exactly. */
export type HyperProviderModel = ProviderModelConfig & { compat: OpenAICompletionsCompat };

export function toProviderModel(model: ModelFacts): HyperProviderModel {
	const levels = thinkingLevelMap(model);
	return {
		id: model.id,
		name: model.name,
		api: "openai-completions",
		reasoning: model.reasoning,
		input: model.vision ? ["text", "image"] : ["text"],
		cost: model.cost,
		contextWindow: model.contextWindow,
		maxTokens: model.maxTokens,
		...(levels ? { thinkingLevelMap: levels } : {}),
		compat: {
			// Hyper is a router in front of many upstreams: no response store, `max_tokens`,
			// and plain OpenAI reasoning controls.
			supportsStore: false,
			supportsReasoningEffort: model.reasoning,
			maxTokensField: "max_tokens",
			thinkingFormat: "openai",
		},
	};
}

export const toProviderModels = (models: readonly ModelFacts[]): HyperProviderModel[] =>
	models.map(toProviderModel);

export const sameModels = (a: readonly ProviderModelConfig[], b: readonly ProviderModelConfig[]): boolean =>
	JSON.stringify(a) === JSON.stringify(b);
