/**
 * provider-quota-cache.js — fetch and cache subscription quota state per provider.
 *
 * For the five providers with a real quota introspection endpoint
 * (kimi-coding, openrouter, minimax, zai, google-gemini-cli) this module
 * fetches usage / remaining-quota data and caches it globally at
 * ~/.sf/provider-quota.json with a 15-minute TTL.
 *
 * The other allowed providers (mistral, ollama-cloud, opencode, opencode-go,
 * xiaomi) have no documented programmatic quota API today. They are simply
 * absent from the cache — consumers should treat "no entry" as "unknown" and
 * fall back to local-dispatch counters or accept routing without quota signal.
 *
 * Designed for two consumers:
 *   - sf headless usage: display quota for all providers at once.
 *   - routing layer (phase 2): bias dispatch toward under-used subs to
 *     maximize subscription utilization ("spend the subs").
 */
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { sfHome } from "./sf-home.js";

const QUOTA_TTL_MS = 15 * 60 * 1000;

/** Providers that have a documented quota endpoint we can call with an API key. */
export const QUOTA_CAPABLE_PROVIDER_IDS = [
	"kimi-coding",
	"openrouter",
	"minimax",
	"zai",
	"google-gemini-cli",
];

function quotaFilePath() {
	return join(sfHome(), "provider-quota.json");
}

/**
 * @typedef {object} ProviderQuotaWindow
 * @property {string} label — human-readable name ("Weekly", "5h rolling", "Monthly MCP", ...)
 * @property {number} used — tokens / requests / credits consumed
 * @property {number} limit — window cap
 * @property {number} [usedFraction] — derived used/limit, clamped 0..1
 * @property {string} [resetHint] — ISO timestamp or "N hours" depending on provider
 *
 * @typedef {object} ProviderQuotaEntry
 * @property {boolean} ok — true if the most recent fetch succeeded
 * @property {string} fetchedAt — ISO timestamp of the last successful fetch
 * @property {string} [error] — error message when ok=false
 * @property {ProviderQuotaWindow[]} windows — one or more rolling/period windows
 * @property {Record<string, unknown>} [raw] — raw API response for debugging
 */

function clampFraction(used, limit) {
	if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) {
		return undefined;
	}
	return Math.max(0, Math.min(1, used / limit));
}

/** Coerce a value that may be a number, numeric string, or undefined to a number. */
function toNum(v) {
	if (typeof v === "number" && Number.isFinite(v)) return v;
	if (typeof v === "string" && v.trim() !== "") {
		const n = Number(v);
		return Number.isFinite(n) ? n : undefined;
	}
	return undefined;
}

// ─── Per-provider fetchers ───────────────────────────────────────────────────

/**
 * Kimi For Coding — GET https://api.kimi.com/coding/v1/usages with Bearer auth.
 * Shape (from MoonshotAI/kimi-cli usage.py):
 *   { usage: {limit, used|remaining, name}, limits: [{detail:{...}, window:{duration, timeUnit}}] }
 */
async function fetchKimiCodingQuota(apiKey) {
	const res = await fetch("https://api.kimi.com/coding/v1/usages", {
		method: "GET",
		headers: { Authorization: `Bearer ${apiKey}` },
	});
	if (!res.ok) throw new Error(`HTTP ${res.status}`);
	const payload = await res.json();
	const windows = [];
	// Real API returns numeric fields as STRINGS ("limit": "100") and uses
	// `resetTime` (camelCase) for the reset hint. Coerce via toNum().
	const summary = payload?.usage;
	if (summary && typeof summary === "object") {
		const limit = toNum(summary.limit);
		let used = toNum(summary.used);
		if (used === undefined) {
			const remaining = toNum(summary.remaining);
			if (typeof remaining === "number" && typeof limit === "number") {
				used = limit - remaining;
			}
		}
		if (typeof used === "number" && typeof limit === "number") {
			windows.push({
				label: String(summary.name ?? "Weekly"),
				used,
				limit,
				usedFraction: clampFraction(used, limit),
				resetHint: summary.resetTime ? String(summary.resetTime) : undefined,
			});
		}
	}
	if (Array.isArray(payload?.limits)) {
		for (const item of payload.limits) {
			const detail = item?.detail ?? item;
			const window = item?.window;
			const limit = toNum(detail?.limit);
			let used = toNum(detail?.used);
			if (used === undefined) {
				const remaining = toNum(detail?.remaining);
				if (typeof remaining === "number" && typeof limit === "number") {
					used = limit - remaining;
				}
			}
			if (typeof used !== "number" || typeof limit !== "number") continue;
			const winLabel =
				window?.duration && window?.timeUnit
					? `${window.duration} ${String(window.timeUnit)
							.replace(/^TIME_UNIT_/, "")
							.toLowerCase()}`
					: undefined;
			windows.push({
				label: String(detail?.name ?? winLabel ?? "rolling window"),
				used,
				limit,
				usedFraction: clampFraction(used, limit),
				resetHint: detail?.resetTime ? String(detail.resetTime) : undefined,
			});
		}
	}
	return { windows, raw: payload };
}

/**
 * OpenRouter — GET https://openrouter.ai/api/v1/credits with Bearer auth.
 * Returns { data: { total_credits, total_usage } } where both are USD floats.
 *
 * Important: SF's built-in policy (preferences-models.js:123-131
 * isModelAllowedByBuiltInProviderPolicy) restricts OpenRouter routes to
 * `:free` + zero-cost models for ALL SF users, no opt-in needed. Routing
 * through SF therefore never consumes credits — the credits balance is
 * historical noise, not a signal for the quota-aware router.
 *
 * The window IS reported (so `sf headless usage` can display credits state
 * for user awareness) but `usedFraction` is deliberately left undefined so
 * benchmark-selector's quotaHeadroomMultiplier treats OpenRouter as "no
 * quota signal" (neutral 1.0) rather than incorrectly demoting at the
 * credits high-water mark.
 */
async function fetchOpenrouterQuota(apiKey) {
	const res = await fetch("https://openrouter.ai/api/v1/credits", {
		method: "GET",
		headers: { Authorization: `Bearer ${apiKey}` },
	});
	if (!res.ok) throw new Error(`HTTP ${res.status}`);
	const payload = await res.json();
	const totalCredits = Number(payload?.data?.total_credits ?? 0);
	const totalUsage = Number(payload?.data?.total_usage ?? 0);
	const remaining = totalCredits - totalUsage;
	return {
		windows: [
			{
				label: "credits (USD, info only — SF routes :free)",
				used: totalUsage,
				limit: totalCredits || totalUsage, // avoid divide-by-zero
				// usedFraction intentionally omitted; see comment above.
				resetHint:
					remaining >= 0 ? `${remaining.toFixed(2)} USD remaining` : undefined,
			},
		],
		raw: payload,
	};
}

/**
 * MiniMax — GET https://api.minimax.io/v1/token_plan/remains with Bearer auth.
 * Real shape: { model_remains: [{ model_name, current_interval_total_count,
 * current_interval_usage_count, current_weekly_total_count,
 * current_weekly_usage_count, end_time, weekly_end_time, ...}] }.
 * One entry per model family. Emit one window per model that has a non-zero
 * interval cap; weekly windows are also surfaced when configured.
 */
async function fetchMinimaxQuota(apiKey) {
	const res = await fetch("https://api.minimax.io/v1/token_plan/remains", {
		method: "GET",
		headers: { Authorization: `Bearer ${apiKey}` },
	});
	if (!res.ok) throw new Error(`HTTP ${res.status}`);
	const payload = await res.json();
	const windows = [];
	const remains = Array.isArray(payload?.model_remains)
		? payload.model_remains
		: [];
	for (const entry of remains) {
		if (!entry || typeof entry !== "object") continue;
		const intervalTotal = toNum(entry.current_interval_total_count);
		const intervalUsed = toNum(entry.current_interval_usage_count);
		if (
			typeof intervalTotal === "number" &&
			typeof intervalUsed === "number" &&
			intervalTotal > 0
		) {
			windows.push({
				label: `${entry.model_name ?? "model"} (5h)`,
				used: intervalUsed,
				limit: intervalTotal,
				usedFraction: clampFraction(intervalUsed, intervalTotal),
				resetHint:
					typeof entry.end_time === "number"
						? new Date(entry.end_time).toISOString()
						: undefined,
			});
		}
		const weeklyTotal = toNum(entry.current_weekly_total_count);
		const weeklyUsed = toNum(entry.current_weekly_usage_count);
		if (
			typeof weeklyTotal === "number" &&
			typeof weeklyUsed === "number" &&
			weeklyTotal > 0
		) {
			windows.push({
				label: `${entry.model_name ?? "model"} (weekly)`,
				used: weeklyUsed,
				limit: weeklyTotal,
				usedFraction: clampFraction(weeklyUsed, weeklyTotal),
				resetHint:
					typeof entry.weekly_end_time === "number"
						? new Date(entry.weekly_end_time).toISOString()
						: undefined,
			});
		}
	}
	return { windows, raw: payload };
}

/**
 * Z.AI — GET https://api.z.ai/api/monitor/usage/quota/limit.
 *
 * Two non-obvious things about this endpoint (cross-referenced with
 * vbgate/opencode-mystatus reference impl):
 *
 *   1. Auth header is the RAW api key with NO `Bearer ` prefix. Using
 *      `Authorization: Bearer <key>` causes the server to treat the call
 *      as unauthenticated and respond with success:false / "user does not
 *      have a coding plan" even when the user is actively on the plan.
 *
 *   2. Real response shape:
 *        { code: 200, msg, success: true,
 *          data: { limits: [
 *            { type: "TOKENS_LIMIT" | "TIME_LIMIT",
 *              usage: <total>, currentValue: <used>,
 *              percentage: <0-100>, nextResetTime?: <ms epoch>
 *            }, ...
 *          ] } }
 */
async function fetchZaiQuota(apiKey) {
	const res = await fetch("https://api.z.ai/api/monitor/usage/quota/limit", {
		method: "GET",
		headers: {
			Authorization: apiKey,
			"Content-Type": "application/json",
			"User-Agent": "SingularityForge-Quota/1.0",
		},
	});
	if (!res.ok) throw new Error(`HTTP ${res.status}`);
	const payload = await res.json();
	if (payload?.success === false || payload?.code !== 200) {
		throw new Error(
			`z.ai: ${payload?.msg ?? "unknown error"} (code ${payload?.code ?? "?"})`,
		);
	}
	const windows = [];
	const buckets = Array.isArray(payload?.data?.limits)
		? payload.data.limits
		: [];
	for (const bucket of buckets) {
		if (!bucket || typeof bucket !== "object") continue;
		const limit = toNum(bucket.usage);
		const used = toNum(bucket.currentValue);
		if (typeof used !== "number" || typeof limit !== "number") continue;
		const label =
			bucket.type === "TOKENS_LIMIT"
				? "5h tokens"
				: bucket.type === "TIME_LIMIT"
					? "MCP monthly"
					: String(bucket.type ?? "quota");
		windows.push({
			label,
			used,
			limit,
			usedFraction: clampFraction(used, limit),
			resetHint:
				typeof bucket.nextResetTime === "number"
					? new Date(bucket.nextResetTime).toISOString()
					: undefined,
		});
	}
	return { windows, raw: payload };
}

/**
 * Google Gemini CLI — reuses snapshotGeminiCliAccount from the dedicated
 * provider package. Maps the per-model quota windows into the same shape as
 * the other providers' entries.
 */
async function fetchGeminiCliQuota(basePath) {
	const { snapshotGeminiCliAccount } = await import(
		"@singularity-forge/google-gemini-cli-provider"
	);
	const snapshot = await snapshotGeminiCliAccount(basePath);
	if (!snapshot) throw new Error("no snapshot — gemini-cli not signed in");
	const windows = (snapshot.models ?? []).map((m) => ({
		label: m.modelId,
		used: 1, // expressed as fraction directly
		limit: 1,
		usedFraction:
			typeof m.usedFraction === "number" ? m.usedFraction : undefined,
		resetHint: m.resetTime ? String(m.resetTime) : undefined,
	}));
	return { windows, raw: snapshot };
}

const FETCHERS = {
	"kimi-coding": fetchKimiCodingQuota,
	openrouter: fetchOpenrouterQuota,
	minimax: fetchMinimaxQuota,
	zai: fetchZaiQuota,
};

// ─── Cache I/O ───────────────────────────────────────────────────────────────

function loadCache() {
	const path = quotaFilePath();
	if (!existsSync(path)) return { schemaVersion: 1, providers: {} };
	try {
		const parsed = JSON.parse(readFileSync(path, "utf-8"));
		if (parsed?.schemaVersion === 1 && parsed?.providers) return parsed;
		return { schemaVersion: 1, providers: {} };
	} catch {
		return { schemaVersion: 1, providers: {} };
	}
}

function saveCache(cache) {
	const path = quotaFilePath();
	try {
		mkdirSync(dirname(path), { recursive: true });
		writeFileSync(path, `${JSON.stringify(cache, null, 2)}\n`, "utf-8");
	} catch {
		// Best-effort.
	}
}

function isFresh(entry) {
	if (!entry?.fetchedAt) return false;
	return Date.now() - new Date(entry.fetchedAt).getTime() <= QUOTA_TTL_MS;
}

// ─── Public API ──────────────────────────────────────────────────────────────

/**
 * Read the cached quota entry for a provider.
 * Returns null if no entry exists or the entry is older than QUOTA_TTL_MS.
 *
 * @param {string} providerId
 * @returns {ProviderQuotaEntry | null}
 */
export function getProviderQuotaState(providerId) {
	const cache = loadCache();
	const entry = cache.providers?.[providerId];
	if (!entry) return null;
	if (!isFresh(entry)) return null;
	return entry;
}

/**
 * Read all cached quota entries, fresh or stale, for display purposes.
 * @returns {Record<string, ProviderQuotaEntry>}
 */
export function getAllProviderQuotaEntries() {
	return loadCache().providers ?? {};
}

/**
 * Awaitable refresh for all quota-capable providers that have a key in `auth`.
 * Skips fresh entries (within TTL). Per-provider failures are recorded as
 * { ok: false, error } but never thrown.
 *
 * @param {string} basePath — passed through to gemini-cli snapshot helper
 * @param {{ getCredentialsForProvider: (id: string) => Array<{type: string, key?: string}> }} auth
 */
export async function runProviderQuotaRefreshIfStale(basePath, auth) {
	const cache = loadCache();
	cache.providers = cache.providers ?? {};
	for (const providerId of QUOTA_CAPABLE_PROVIDER_IDS) {
		if (isFresh(cache.providers[providerId])) continue;
		const now = new Date().toISOString();
		try {
			let result;
			if (providerId === "google-gemini-cli") {
				result = await fetchGeminiCliQuota(basePath);
			} else {
				const fetcher = FETCHERS[providerId];
				if (!fetcher) continue;
				const creds = auth?.getCredentialsForProvider?.(providerId) ?? [];
				const apiKey = creds.find((c) => c.type === "api_key" && c.key)?.key;
				if (!apiKey) {
					cache.providers[providerId] = {
						ok: false,
						fetchedAt: now,
						error: "no api key configured",
						windows: [],
					};
					continue;
				}
				result = await fetcher(apiKey);
			}
			cache.providers[providerId] = {
				ok: true,
				fetchedAt: now,
				windows: result.windows ?? [],
				raw: result.raw,
			};
		} catch (err) {
			cache.providers[providerId] = {
				ok: false,
				fetchedAt: now,
				error: err instanceof Error ? err.message : String(err),
				windows: [],
			};
		}
	}
	saveCache(cache);
}

/**
 * Fire-and-forget background refresh — safe to call at session_start.
 * Never throws, never blocks.
 */
export function scheduleProviderQuotaRefresh(basePath, auth) {
	setImmediate(() => runProviderQuotaRefreshIfStale(basePath, auth));
}
