/**
 * local-llm consumer helpers for TypeScript/Node (≥18), Deno and Bun — no dependencies.
 * Copy this file into a project (it is small on purpose).
 *
 *   import OpenAI from "openai";
 *   import { oauthFetch } from "./llm-client";
 *
 *   const llm = new OpenAI({
 *     baseURL: process.env.LLM_BASE_URL,              // https://llm.andreaskra.com/v1
 *     apiKey: "unused",                               // oauthFetch sets the real Authorization header
 *     fetch: oauthFetch({
 *       tokenUrl: process.env.LLM_TOKEN_URL!,         // https://llm.andreaskra.com/oauth/token
 *       clientId: process.env.LLM_CLIENT_ID!,
 *       clientSecret: process.env.LLM_CLIENT_SECRET!,
 *     }),
 *   });
 *
 * OAuth 2.1 client_credentials: the token is cached, fetched again at 80% of its lifetime,
 * and a 401 drops it and retries the request once.
 */

export interface ClientCredentialsOptions {
  tokenUrl: string;
  clientId: string;
  clientSecret: string;
  scope?: string;
  /** Fraction of the token lifetime after which a new one is fetched (default 0.8). */
  refreshRatio?: number;
  /** The fetch to wrap (default: globalThis.fetch). */
  fetch?: typeof fetch;
}

export class TokenError extends Error {}

export function clientCredentials(opts: ClientCredentialsOptions) {
  const base = opts.fetch ?? globalThis.fetch;
  const ratio = opts.refreshRatio ?? 0.8;
  let cached: { token: string; refreshAt: number } | null = null;
  let inflight: Promise<string> | null = null;

  async function fetchToken(): Promise<string> {
    const body = new URLSearchParams({ grant_type: "client_credentials" });
    if (opts.scope) body.set("scope", opts.scope);
    const basic = btoa(`${encodeURIComponent(opts.clientId)}:${encodeURIComponent(opts.clientSecret)}`);
    const res = await base(opts.tokenUrl, {
      method: "POST",
      headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
      body,
    });
    if (!res.ok) throw new TokenError(`token endpoint answered ${res.status}: ${await res.text()}`);
    const json = (await res.json()) as { access_token: string; expires_in?: number };
    cached = { token: json.access_token, refreshAt: Date.now() + (json.expires_in ?? 300) * 1000 * ratio };
    return json.access_token;
  }

  return {
    async token(force = false): Promise<string> {
      if (!force && cached && Date.now() < cached.refreshAt) return cached.token;
      // One refresh at a time, however many requests are waiting for it.
      inflight ??= fetchToken().finally(() => {
        inflight = null;
      });
      return inflight;
    },
    invalidate() {
      cached = null;
    },
  };
}

/** A fetch that authenticates every request — pass it as `fetch` to openai-node. */
export function oauthFetch(opts: ClientCredentialsOptions): typeof fetch {
  const base = opts.fetch ?? globalThis.fetch;
  const creds = clientCredentials(opts);

  const authed = async (input: RequestInfo | URL, init: RequestInit | undefined, force: boolean) => {
    const headers = new Headers(input instanceof Request ? input.headers : undefined);
    new Headers(init?.headers).forEach((v, k) => headers.set(k, v));
    headers.set("Authorization", `Bearer ${await creds.token(force)}`);
    return base(input, { ...init, headers });
  };

  return (async (input: RequestInfo | URL, init?: RequestInit) => {
    const res = await authed(input, init, false);
    // Retry once with a fresh token; a streamed body can't be replayed, so only when it is not one.
    const replayable = !(init?.body instanceof ReadableStream);
    if (res.status === 401 && replayable) {
      creds.invalidate();
      return authed(input, init, true);
    }
    return res;
  }) as typeof fetch;
}

export class WebhookVerificationError extends Error {}

/**
 * Verify an `LLM-Signature: t=<unix>,v1=<hex hmac>` webhook and return the parsed event.
 * Pass the RAW body (before any JSON parsing) and LLM_WEBHOOK_SECRET.
 */
export async function verifyWebhook(
  payload: string | Uint8Array,
  header: string | null | undefined,
  secret: string,
  toleranceS = 300,
): Promise<{ id: string; type: string; created: number; data: Record<string, unknown> }> {
  if (!header) throw new WebhookVerificationError("missing LLM-Signature header");
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!Number.isFinite(t)) throw new WebhookVerificationError("signature header has no valid timestamp");
  if (Math.abs(Date.now() / 1000 - t) > toleranceS) throw new WebhookVerificationError("signature timestamp outside tolerance");

  const enc = new TextEncoder();
  const body = typeof payload === "string" ? enc.encode(payload) : payload;
  const signed = new Uint8Array([...enc.encode(`${t}.`), ...body]);
  const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, signed));
  const expected = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
  const equal = (a: string, b: string) => {
    if (a.length !== b.length) return false;
    let diff = 0;
    for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
    return diff === 0;
  };
  if (!sigs.some((s) => equal(s, expected))) throw new WebhookVerificationError("signature mismatch");
  return JSON.parse(new TextDecoder().decode(body));
}
