// SF Memory Embeddings — LLM Gateway adapter
//
// Speaks the OpenAI-shaped /v1/embeddings and /v1/rerank protocols against
// a custom inference-fabric llm-gateway endpoint. Returns null when the
// gateway is not configured (env var unset / unreachable / catalog empty),
// so the consumer can fall through to keyword-only ranking without
// surfacing errors.
//
// Why a separate module: keeping gateway-specific HTTP, headers, and error
// shapes out of memory-embeddings.ts (which is provider-agnostic) means the
// embed-fn discovery surface stays clean and the gateway can be swapped or
// disabled without touching the consumer.
import { logWarning } from "./workflow-logger.js";

const DEFAULT_TIMEOUT_MS = 30_000;
// Throttle the "rerank worker offline" warning so per-query log spam doesn't
// drown out other diagnostics when SF_LLM_GATEWAY_RERANK_MODEL is set but no
// worker is online — the soft-degrade is expected in that state.
let lastRerankUnavailableLogAt = 0;
const RERANK_UNAVAILABLE_LOG_THROTTLE_MS = 60_000;
function logRerankUnavailable(msg) {
	const now = Date.now();
	if (now - lastRerankUnavailableLogAt < RERANK_UNAVAILABLE_LOG_THROTTLE_MS) {
		return;
	}
	lastRerankUnavailableLogAt = now;
	logWarning("memory-embeddings", msg);
}
const ENV_KEY = "SF_LLM_GATEWAY_KEY";
const ENV_URL = "SF_LLM_GATEWAY_URL";
const ENV_EMBED_MODEL = "SF_LLM_GATEWAY_EMBED_MODEL";
const ENV_RERANK_MODEL = "SF_LLM_GATEWAY_RERANK_MODEL";
const DEFAULT_EMBEDDING_MODEL = "Qwen/Qwen3-Embedding-4B";
const DEFAULT_RERANK_MODEL = "Qwen/Qwen3-Reranker-0.6B";
const KEY_ALIASES = [
	ENV_KEY,
	"LLM_GATEWAY_API_KEY",
	"LLM_GATEWAY_BEARER_KEY",
	"LLM_MUX_API_KEY",
];
const URL_ALIASES = [ENV_URL, "LLM_GATEWAY_BASE_URL", "LLM_MUX_BASE_URL"];
function firstEnvEntry(keys) {
	for (const key of keys) {
		const value = process.env[key]?.trim();
		if (value) return { key, value };
	}
	return null;
}
function firstEnvValue(keys) {
	return firstEnvEntry(keys)?.value ?? "";
}
/** Read gateway config from env. Returns null when SF_LLM_GATEWAY_KEY is
 *  missing — the gateway path is opt-in and silently absent otherwise. */
export function loadGatewayConfigFromEnv() {
	const keyEntry = firstEnvEntry(KEY_ALIASES);
	if (!keyEntry) return null;
	const urlEntry = firstEnvEntry(URL_ALIASES);
	const url = urlEntry?.value || "https://llm-gateway.centralcloud.com/v1";
	const embeddingModel =
		firstEnvValue([ENV_EMBED_MODEL]) || DEFAULT_EMBEDDING_MODEL;
	const rerankModel = firstEnvValue([ENV_RERANK_MODEL]) || DEFAULT_RERANK_MODEL;
	return {
		url,
		apiKey: keyEntry.value,
		keySource: keyEntry.key,
		urlSource: urlEntry?.key ?? "default",
		embeddingModel,
		rerankModel,
	};
}
/** Build an EmbedFn that posts to <url>/embeddings with Bearer auth.
 *  Returns Float32Array[] in the same order as the input. Throws on HTTP
 *  errors so the caller (embedMemories) logs and counts as zero. */
export function createGatewayEmbedFn(config) {
	return async (texts) => {
		if (texts.length === 0) return [];
		const controller = new AbortController();
		const timeout = setTimeout(
			() => controller.abort(),
			config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
		);
		try {
			const res = await fetch(`${config.url}/embeddings`, {
				method: "POST",
				headers: {
					Authorization: `Bearer ${config.apiKey}`,
					"Content-Type": "application/json",
				},
				body: JSON.stringify({
					model: config.embeddingModel,
					input: texts,
				}),
				signal: controller.signal,
			});
			if (!res.ok) {
				const body = await res.text().catch(() => "");
				throw new Error(
					`llm-gateway /embeddings ${res.status}: ${body.slice(0, 200)}`,
				);
			}
			const json = await res.json();
			if (!Array.isArray(json.data)) {
				throw new Error("llm-gateway /embeddings: missing data array");
			}
			// Sort by index to handle out-of-order responses defensively.
			const sorted = [...json.data].sort((a, b) => a.index - b.index);
			return sorted.map((d) => Float32Array.from(d.embedding));
		} finally {
			clearTimeout(timeout);
		}
	};
}
/** Score candidates against a query via <url>/rerank. Returns null when no
 *  rerank model is configured OR the gateway has no rerank worker online —
 *  callers should treat null as "skip the rerank pass". */
export async function rerankCandidates(config, query, candidates) {
	if (!config.rerankModel) return null;
	if (candidates.length === 0) return [];
	const controller = new AbortController();
	const timeout = setTimeout(
		() => controller.abort(),
		config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
	);
	try {
		const res = await fetch(`${config.url}/rerank`, {
			method: "POST",
			headers: {
				Authorization: `Bearer ${config.apiKey}`,
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				model: config.rerankModel,
				query,
				documents: candidates.map((c) => c.text),
			}),
			signal: controller.signal,
		});
		if (res.status === 503 || res.status === 404) {
			logRerankUnavailable(
				`llm-gateway /rerank unavailable (${res.status}); falling back to non-reranked results`,
			);
			return null;
		}
		// Read once — the gateway sometimes returns 200 with a plain-text body
		// like "no worker with rerank capability is available", so we can't
		// branch on res.ok before peeking at the body.
		const bodyText = await res.text().catch(() => "");
		if (/no worker.*rerank/i.test(bodyText)) {
			logRerankUnavailable(
				"llm-gateway /rerank: no worker capability available",
			);
			return null;
		}
		if (!res.ok) {
			throw new Error(
				`llm-gateway /rerank ${res.status}: ${bodyText.slice(0, 200)}`,
			);
		}
		let json;
		try {
			json = JSON.parse(bodyText);
		} catch {
			throw new Error(
				`llm-gateway /rerank: malformed JSON response (${bodyText.slice(0, 200)})`,
			);
		}
		if (!Array.isArray(json.results)) {
			throw new Error("llm-gateway /rerank: missing results array");
		}
		return json.results.map((r) => ({
			id: candidates[r.index]?.id ?? String(r.index),
			score: r.relevance_score,
		}));
	} finally {
		clearTimeout(timeout);
	}
}
