/**
 * Alibaba Model Studio "token plan" provider for Prime Agent.
 *
 * `/login alibaba` stores a pasted API key in ~/.prime/agent/auth.json, and that is the
 * only place the key lives. ALIBABA_API_KEY is the sole fallback; when neither is present
 * the provider reports an unresolved key instead of sending a placeholder, so a missing
 * credential cannot be mistaken for an expired one.
 * ALIBABA_BASE_URL overrides the endpoint (e.g. another region).
 *
 * The model list is baked into models.json and regenerated by regen.ts, which
 * probes the live endpoint. Nothing here is fetched at startup except the
 * account's entitled ids, so capabilities never drift between runs.
 */

import type { Api } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

export const PROVIDER = "alibaba";
export const DEFAULT_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
export const CATALOG = join(fileURLToPath(new URL(".", import.meta.url)), "models.json");
const FETCH_TIMEOUT_MS = 10000;
const AUTH_FILE = join(homedir(), ".prime", "agent", "auth.json");

/**
 * What Prime Agent resolves when no credential is stored. `!`-prefixed values run through
 * the shell (bash -c), and an empty result is an unresolved key rather than an empty
 * string, which is what turns "no key" into a readable error instead of a 401.
 */
const API_KEY_FALLBACK = '!printf %s "$ALIBABA_API_KEY"';
const ENTITLEMENT_CACHE = join(homedir(), ".prime", "agent", "alibaba-entitlement.json");

export interface BakedModel {
	id: string;
	name: string;
	reasoning: boolean;
	input: ("text" | "image")[];
	contextWindow: number;
	maxTokens: number;
}

export interface BakedCatalog {
	generatedAt: string;
	baseUrl: string;
	models: BakedModel[];
	/** entitled ids the bake considered and refused */
	skipped: string[];
}

type Family = "qwen" | "deepseek" | "zai" | "other";

export const familyOf = (id: string): Family =>
	id.startsWith("qwen") ? "qwen" : id.startsWith("deepseek") ? "deepseek" : id.startsWith("glm") ? "zai" : "other";

/**
 * Per-family request quirks, mirroring what Prime's own catalog applies to the same
 * model families. Without the DeepSeek entry, replayed thinking blocks from another
 * provider land in the assistant text instead of `reasoning_content`, and the model
 * answers by writing its reasoning into the visible reply.
 */
const FAMILY_COMPAT: Record<Family, Record<string, unknown>> = {
	qwen: { thinkingFormat: "qwen", supportsReasoningEffort: false, cacheControlFormat: "anthropic" },
	deepseek: { thinkingFormat: "deepseek", requiresReasoningContentOnAssistantMessages: true },
	// glm depth is reasoning_effort, no enable_thinking: thinking is on by default here and
	// glm-5.3 rejects enable_thinking false outright
	zai: { supportsReasoningEffort: true },
	other: {},
};

/**
 * Model Studio's own levels, not the vendors' defaults.
 *
 * DeepSeek takes the full `reasoning_effort` ladder with high as its default. Qwen takes a
 * boolean `enable_thinking` only. GLM takes `reasoning_effort` too, with a value set that
 * varies by model, so the family entry is a superset and `MODEL_THINKING_LEVELS` narrows it.
 */
const THINKING_LEVELS: Record<Family, Record<string, string | null> | undefined> = {
	deepseek: { minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
	qwen: { minimal: null, low: null, medium: null, high: "high" },
	zai: { off: "none", minimal: "minimal", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
	other: undefined,
};

/** glm-5.3 takes only low/high/max, and 400s on every attempt to stop it thinking */
const MODEL_THINKING_LEVELS: Record<string, Record<string, string | null>> = {
	"glm-5.3": { off: null, minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: "max" },
};

const thinkingLevelsFor = (id: string): Record<string, string | null> | undefined =>
	MODEL_THINKING_LEVELS[id] ?? THINKING_LEVELS[familyOf(id)];

export const baseUrlFromEnv = (): string => (process.env.ALIBABA_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");

const readFile = (path: string): string | undefined => {
	try {
		return readFileSync(path, "utf-8");
	} catch {
		return undefined;
	}
};

/** Key saved by `/login alibaba`; the only place this provider reads a key from. */
const readStoredKey = (): string | undefined => {
	const raw = readFile(AUTH_FILE);
	if (!raw) return undefined;
	try {
		return (JSON.parse(raw) as Record<string, { key?: string }>)[PROVIDER]?.key;
	} catch {
		return undefined;
	}
};

export const resolveKey = (): string | undefined => readStoredKey() ?? process.env.ALIBABA_API_KEY;

export const loadCatalog = (): BakedCatalog => JSON.parse(readFileSync(CATALOG, "utf-8")) as BakedCatalog;

/** Model ids the account can actually call, straight from the endpoint. */
export async function accountModelIds(baseUrl: string, apiKey: string): Promise<string[]> {
	const response = await fetch(`${baseUrl}/models`, {
		headers: { Authorization: `Bearer ${apiKey}` },
		signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
	});
	if (!response.ok) throw new Error(`${baseUrl}/models ${response.status}`);
	const payload = (await response.json()) as { data?: Array<{ id: string }> };
	const ids = (payload.data ?? []).map((m) => m.id);
	if (ids.length === 0) throw new Error("no entitled models");
	return ids.sort();
}

/** Entitled ids, cached on disk: a slow or failed lookup reuses the last known list. */
async function entitledIds(baseUrl: string, apiKey: string | undefined): Promise<Set<string> | undefined> {
	const cached = (): Set<string> | undefined => {
		const raw = readFile(ENTITLEMENT_CACHE);
		if (!raw) return undefined;
		try {
			const ids = JSON.parse(raw) as string[];
			return ids.length > 0 ? new Set(ids) : undefined;
		} catch {
			return undefined;
		}
	};
	if (!apiKey) return cached();
	try {
		const ids = await accountModelIds(baseUrl, apiKey);
		writeFileSync(ENTITLEMENT_CACHE, `${JSON.stringify(ids, null, "\t")}\n`);
		return new Set(ids);
	} catch {
		return cached();
	}
}

export default async function (pi: ExtensionAPI) {
	const baseUrl = baseUrlFromEnv();
	const catalog = loadCatalog();
	const entitled = await entitledIds(baseUrl, resolveKey());

	const models = catalog.models
		.filter((m) => !entitled || entitled.has(m.id))
		.map((m) => ({
			...m,
			// The token plan bills a flat subscription, so per-token cost is zero.
			cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
			compat: {
				supportsDeveloperRole: false,
				maxTokensField: "max_tokens" as const,
				...FAMILY_COMPAT[familyOf(m.id)],
			},
			...(m.reasoning && thinkingLevelsFor(m.id) ? { thinkingLevelMap: thinkingLevelsFor(m.id) } : {}),
		}));

	// entitlement can only hide, never add, so an id gained after the last bake stays
	// invisible until regen.ts runs
	const known = new Set([...catalog.models.map((m) => m.id), ...catalog.skipped]);
	const unbaked = entitled === undefined ? [] : [...entitled].filter((id) => !known.has(id)).sort();
	pi.on("session_start", (_event, ctx) => {
		if (unbaked.length > 0) {
			ctx.ui.notify(`alibaba: entitled but not in models.json: ${unbaked.join(", ")} - run npx tsx regen.ts`, "warning");
		}
	});

	pi.registerProvider(PROVIDER, {
		name: "Alibaba Model Studio (token plan)",
		baseUrl,
		apiKey: API_KEY_FALLBACK,
		api: "openai-completions" as Api,
		models,
	});
}
