/**
 * provider-quota-cache.test.mjs
 *
 * Tests that the quota fetcher loop:
 *   - Calls the right URL with Bearer auth per provider
 *   - Normalizes each vendor's JSON shape into the shared ProviderQuotaEntry
 *   - Writes to ~/.sf/provider-quota.json under the global SF_HOME
 *   - Honors TTL (no refetch when fresh)
 *   - Records per-provider errors without crashing the loop
 */
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, test } from "vitest";

import {
	getAllProviderQuotaEntries,
	getProviderQuotaState,
	QUOTA_CAPABLE_PROVIDER_IDS,
	runProviderQuotaRefreshIfStale,
} from "../provider-quota-cache.js";

// ─── Test isolation ──────────────────────────────────────────────────────────

const tmpDirs = [];
let originalSfHome;
let originalFetch;

beforeEach(() => {
	originalSfHome = process.env.SF_HOME;
	originalFetch = globalThis.fetch;
});

afterEach(() => {
	while (tmpDirs.length > 0) {
		rmSync(tmpDirs.pop(), { recursive: true, force: true });
	}
	if (originalSfHome === undefined) delete process.env.SF_HOME;
	else process.env.SF_HOME = originalSfHome;
	globalThis.fetch = originalFetch;
});

function tempSfHome() {
	const dir = mkdtempSync(join(tmpdir(), "sf-quota-cache-test-"));
	tmpDirs.push(dir);
	process.env.SF_HOME = dir;
	return dir;
}

/** Minimal auth shim: returns the requested key for any provider id. */
function makeAuth(keys) {
	return {
		getCredentialsForProvider(id) {
			return keys[id] ? [{ type: "api_key", key: keys[id] }] : [];
		},
	};
}

/** Stub fetch with a per-URL response map. Unmatched calls throw. */
function stubFetch(responses) {
	const calls = [];
	globalThis.fetch = async (url, options = {}) => {
		calls.push({ url: String(url), headers: options.headers ?? {} });
		const handler = responses[String(url)];
		if (!handler) throw new Error(`unexpected fetch: ${url}`);
		const body = typeof handler === "function" ? handler() : handler;
		return {
			ok: true,
			status: 200,
			json: async () => body,
		};
	};
	return calls;
}

// ─── Module surface ──────────────────────────────────────────────────────────

describe("QUOTA_CAPABLE_PROVIDER_IDS", () => {
	test("lists the five providers with introspection endpoints", () => {
		assert.deepEqual(
			[...QUOTA_CAPABLE_PROVIDER_IDS].sort(),
			[
				"google-gemini-cli",
				"kimi-coding",
				"minimax",
				"openrouter",
				"zai",
			].sort(),
		);
	});
});

// ─── kimi-coding ─────────────────────────────────────────────────────────────

describe("runProviderQuotaRefreshIfStale — kimi-coding", () => {
	test("hits /coding/v1/usages with Bearer auth and parses windows (real shape uses STRING numbers)", async () => {
		const home = tempSfHome();
		// Real API encodes numeric fields as strings; parser must coerce.
		const calls = stubFetch({
			"https://api.kimi.com/coding/v1/usages": {
				usage: { limit: "1000", used: "250", name: "Weekly" },
				limits: [
					{
						detail: { limit: "200", used: "80", name: "5h" },
						window: { duration: 5, timeUnit: "TIME_UNIT_MINUTE" },
					},
				],
			},
		});

		await runProviderQuotaRefreshIfStale(
			home,
			makeAuth({ "kimi-coding": "test-kimi" }),
		);

		const kimiCall = calls.find((c) => c.url.includes("kimi.com"));
		assert.ok(kimiCall, "should have called kimi.com");
		assert.equal(kimiCall.headers.Authorization, "Bearer test-kimi");

		const entry = getProviderQuotaState("kimi-coding");
		assert.ok(entry, "kimi-coding entry should exist");
		assert.equal(entry.ok, true);
		assert.equal(entry.windows.length, 2);
		assert.equal(entry.windows[0].label, "Weekly");
		assert.equal(entry.windows[0].used, 250);
		assert.equal(entry.windows[0].limit, 1000);
		assert.equal(entry.windows[0].usedFraction, 0.25);
		assert.equal(entry.windows[1].label, "5h");
		assert.equal(entry.windows[1].usedFraction, 0.4);
	});

	test("falls back from `used` to `limit - remaining` (string-encoded)", async () => {
		const home = tempSfHome();
		stubFetch({
			"https://api.kimi.com/coding/v1/usages": {
				usage: { limit: "1000", remaining: "600", name: "Weekly" },
			},
		});
		await runProviderQuotaRefreshIfStale(
			home,
			makeAuth({ "kimi-coding": "k" }),
		);
		const entry = getProviderQuotaState("kimi-coding");
		assert.equal(entry.windows[0].used, 400);
		assert.equal(entry.windows[0].usedFraction, 0.4);
	});
});

describe("runProviderQuotaRefreshIfStale — zai envelope error", () => {
	test("success:false response surfaces vendor msg as an error entry", async () => {
		const home = tempSfHome();
		stubFetch({
			"https://api.z.ai/api/monitor/usage/quota/limit": {
				code: 500,
				msg: "current user does not have a coding plan",
				success: false,
			},
		});
		await runProviderQuotaRefreshIfStale(home, makeAuth({ zai: "test-zai" }));
		const all = getAllProviderQuotaEntries();
		assert.equal(all["zai"].ok, false);
		assert.match(all["zai"].error, /does not have a coding plan/);
	});
});

// ─── openrouter ──────────────────────────────────────────────────────────────

describe("runProviderQuotaRefreshIfStale — openrouter", () => {
	test("hits /api/v1/credits with Bearer auth", async () => {
		const home = tempSfHome();
		const calls = stubFetch({
			"https://openrouter.ai/api/v1/credits": {
				data: { total_credits: 10, total_usage: 2.5 },
			},
		});

		await runProviderQuotaRefreshIfStale(
			home,
			makeAuth({ openrouter: "test-or" }),
		);

		const orCall = calls.find((c) => c.url.includes("openrouter.ai"));
		assert.ok(orCall);
		assert.equal(orCall.headers.Authorization, "Bearer test-or");

		const entry = getProviderQuotaState("openrouter");
		assert.equal(entry.ok, true);
		assert.equal(entry.windows[0].used, 2.5);
		assert.equal(entry.windows[0].limit, 10);
		// usedFraction is intentionally omitted for openrouter — credits are
		// informational only (SF routes :free models without billing). The openrouter
		// window exists for user awareness, not routing constraints.
	});
});

// ─── minimax ─────────────────────────────────────────────────────────────────

describe("runProviderQuotaRefreshIfStale — minimax", () => {
	test("hits /v1/token_plan/remains and emits one window per model_remains entry", async () => {
		const home = tempSfHome();
		stubFetch({
			"https://api.minimax.io/v1/token_plan/remains": {
				model_remains: [
					{
						model_name: "MiniMax-M*",
						current_interval_total_count: 1000,
						current_interval_usage_count: 250,
						current_weekly_total_count: 0,
						current_weekly_usage_count: 0,
						end_time: 1778961600000,
					},
				],
			},
		});

		await runProviderQuotaRefreshIfStale(
			home,
			makeAuth({ minimax: "test-mm" }),
		);

		const entry = getProviderQuotaState("minimax");
		assert.equal(entry.ok, true);
		assert.equal(entry.windows.length, 1);
		assert.equal(entry.windows[0].label, "MiniMax-M* (5h)");
		assert.equal(entry.windows[0].used, 250);
		assert.equal(entry.windows[0].limit, 1000);
		assert.equal(entry.windows[0].usedFraction, 0.25);
		assert.ok(entry.windows[0].resetHint);
	});
});

// ─── zai ─────────────────────────────────────────────────────────────────────

describe("runProviderQuotaRefreshIfStale — zai", () => {
	test("uses raw key auth (no Bearer prefix) and parses {type, usage, currentValue}", async () => {
		const home = tempSfHome();
		const calls = stubFetch({
			"https://api.z.ai/api/monitor/usage/quota/limit": {
				code: 200,
				msg: "ok",
				success: true,
				data: {
					limits: [
						{
							type: "TOKENS_LIMIT",
							usage: 5000,
							currentValue: 1500,
							percentage: 30,
							nextResetTime: 1779004800000,
						},
						{
							type: "TIME_LIMIT",
							usage: 100,
							currentValue: 70,
							percentage: 70,
						},
					],
				},
			},
		});

		await runProviderQuotaRefreshIfStale(home, makeAuth({ zai: "test-zai" }));

		const zaiCall = calls.find((c) => c.url.includes("z.ai"));
		assert.ok(zaiCall);
		assert.equal(
			zaiCall.headers.Authorization,
			"test-zai",
			"zai auth header must be the raw key, NO Bearer prefix",
		);

		const entry = getProviderQuotaState("zai");
		assert.equal(entry.ok, true);
		assert.equal(entry.windows.length, 2);
		assert.equal(entry.windows[0].label, "5h tokens");
		assert.equal(entry.windows[0].usedFraction, 0.3);
		assert.equal(entry.windows[1].label, "MCP monthly");
		assert.equal(entry.windows[1].usedFraction, 0.7);
	});
});

// ─── TTL behavior ────────────────────────────────────────────────────────────

describe("TTL", () => {
	test("second refresh within TTL is a no-op (does not re-fetch)", async () => {
		const home = tempSfHome();
		const calls = stubFetch({
			"https://api.minimax.io/v1/token_plan/remains": {
				remaining_tokens: 100,
				total_tokens: 200,
			},
		});

		await runProviderQuotaRefreshIfStale(home, makeAuth({ minimax: "k" }));
		assert.equal(calls.length, 1);

		await runProviderQuotaRefreshIfStale(home, makeAuth({ minimax: "k" }));
		assert.equal(
			calls.length,
			1,
			"second refresh within TTL should reuse cache",
		);
	});

	test("getProviderQuotaState returns null when there is no entry", () => {
		tempSfHome();
		assert.equal(getProviderQuotaState("kimi-coding"), null);
	});
});

// ─── Error handling ──────────────────────────────────────────────────────────

describe("error handling", () => {
	test("missing API key is recorded as error, doesn't crash other providers", async () => {
		const home = tempSfHome();
		stubFetch({
			"https://openrouter.ai/api/v1/credits": {
				data: { total_credits: 5, total_usage: 1 },
			},
		});

		// Only provide openrouter key; kimi/minimax/zai should record errors.
		await runProviderQuotaRefreshIfStale(home, makeAuth({ openrouter: "or" }));

		const all = getAllProviderQuotaEntries();
		assert.equal(all["openrouter"].ok, true);
		assert.equal(all["kimi-coding"].ok, false);
		assert.match(all["kimi-coding"].error, /no api key configured/);
		assert.equal(all["minimax"].ok, false);
		assert.equal(all["zai"].ok, false);
	});

	test("fetch failure recorded as error, doesn't crash loop", async () => {
		const home = tempSfHome();
		globalThis.fetch = async () => {
			throw new Error("network down");
		};
		await runProviderQuotaRefreshIfStale(
			home,
			makeAuth({ "kimi-coding": "k", openrouter: "o", minimax: "m", zai: "z" }),
		);
		const all = getAllProviderQuotaEntries();
		for (const pid of ["kimi-coding", "openrouter", "minimax", "zai"]) {
			assert.equal(all[pid].ok, false, `${pid} should be marked failed`);
			assert.match(all[pid].error, /network down/);
		}
	});
});

// ─── Cache file format ───────────────────────────────────────────────────────

describe("cache file", () => {
	test("writes ~/.sf/provider-quota.json with schemaVersion 1", async () => {
		const home = tempSfHome();
		stubFetch({
			"https://openrouter.ai/api/v1/credits": {
				data: { total_credits: 5, total_usage: 1 },
			},
		});
		await runProviderQuotaRefreshIfStale(home, makeAuth({ openrouter: "k" }));
		const path = join(home, "provider-quota.json");
		assert.ok(existsSync(path));
		const parsed = JSON.parse(readFileSync(path, "utf-8"));
		assert.equal(parsed.schemaVersion, 1);
		assert.ok(parsed.providers.openrouter);
		assert.equal(parsed.providers.openrouter.ok, true);
	});
});
