/**
 * Low-level fetch helpers for the rpki_query_service HTTP API.
 *
 * All JSON endpoints share the envelope shape:
 *   { "data": ..., "page": { "nextCursor": string | null, "limit": number } | null,
 *     "meta": { "runId": string, "schemaVersion": number } }
 * Errors are `{ "error": string }` with a non-2xx status.
 */

export class ApiError extends Error {
  readonly status: number;

  constructor(status: number, message: string) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

export type QueryParams = Record<
  string,
  string | number | boolean | null | undefined
>;

/** Build a URL with a query string, skipping empty values. */
export function withQuery(path: string, params?: QueryParams): string {
  if (!params) return path;
  const search = new URLSearchParams();
  for (const [key, value] of Object.entries(params)) {
    if (value === undefined || value === null || value === "") continue;
    search.set(key, String(value));
  }
  const qs = search.toString();
  return qs ? `${path}?${qs}` : path;
}

async function parseErrorBody(res: Response): Promise<string> {
  try {
    const body: unknown = await res.json();
    if (
      body !== null &&
      typeof body === "object" &&
      "error" in body &&
      typeof (body as { error: unknown }).error === "string"
    ) {
      return (body as { error: string }).error;
    }
  } catch {
    // fall through to status text
  }
  return `HTTP ${res.status} ${res.statusText}`.trim();
}

/** Default timeout for every API request — hung streams must not spin forever. */
const REQUEST_TIMEOUT_MS = 30_000;

/** Combine the caller's abort signal (if any) with the default timeout. */
function requestSignal(signal?: AbortSignal | null): AbortSignal {
  const signals = [signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter(
    (s): s is AbortSignal => s != null,
  );
  return AbortSignal.any(signals);
}

/** Fetch JSON from the API, throwing ApiError on non-2xx. */
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
  let res: Response;
  try {
    res = await fetch(path, { ...init, signal: requestSignal(init?.signal) });
  } catch (err) {
    throw new ApiError(0, err instanceof Error ? err.message : "network error");
  }
  if (!res.ok) {
    throw new ApiError(res.status, await parseErrorBody(res));
  }
  return (await res.json()) as Promise<T>;
}

/** Fetch binary content (raw object bytes, export tarballs). */
export async function apiFetchBlob(path: string): Promise<Blob> {
  let res: Response;
  try {
    res = await fetch(path, { signal: requestSignal() });
  } catch (err) {
    throw new ApiError(0, err instanceof Error ? err.message : "network error");
  }
  if (!res.ok) {
    throw new ApiError(res.status, await parseErrorBody(res));
  }
  return res.blob();
}

/** POST a JSON body, returning the parsed JSON response. */
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
  return apiFetch<T>(path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body ?? {}),
  });
}

/** Trigger a browser download for a blob. */
export function saveBlob(blob: Blob, filename: string): void {
  const url = URL.createObjectURL(blob);
  const anchor = document.createElement("a");
  anchor.href = url;
  anchor.download = filename;
  document.body.appendChild(anchor);
  anchor.click();
  anchor.remove();
  URL.revokeObjectURL(url);
}
