/**
 * HTTP transport shared by both engines: JSON POST/GET with a per-attempt timeout,
 * bounded retries on 408/429/5xx and network failures, and `retry-after` support.
 */

import { JevError, messageOf } from "./errors.ts";

const RETRYABLE_STATUS: Record<number, true> = {
  408: true,
  425: true,
  429: true,
  500: true,
  502: true,
  503: true,
  504: true,
};
const MAX_BACKOFF_MS = 4_000;
const BASE_BACKOFF_MS = 400;
const MAX_RETRY_AFTER_MS = 30_000;

export interface HttpRequestOptions {
  url: string;
  method: "GET" | "POST";
  headers?: Record<string, string>;
  body?: unknown;
  timeoutMs: number;
  /** Retries after the first attempt; total attempts = maxRetries + 1. */
  maxRetries: number;
  signal?: AbortSignal;
}

export interface HttpResponse {
  status: number;
  headers: Headers;
  text: string;
  attempts: number;
}

export async function requestWithRetry(options: HttpRequestOptions): Promise<HttpResponse> {
  const payload = options.body === undefined ? undefined : JSON.stringify(options.body);
  let attempt = 0;
  let waitMs = 0;
  for (;;) {
    if (waitMs > 0) await sleep(waitMs, options.signal);
    attempt += 1;
    let response: Response;
    try {
      response = await fetchWithTimeout(options, payload);
    } catch (error) {
      if (options.signal?.aborted) throw new JevError(`${options.url} request cancelled.`);
      if (attempt > options.maxRetries) {
        throw new JevError(`Cannot reach ${options.url}: ${messageOf(error)}`, { cause: error });
      }
      waitMs = backoffMs(attempt);
      continue;
    }
    let text: string;
    try {
      text = await response.text();
    } catch (error) {
      throw new JevError(`Failed reading the response from ${options.url}: ${messageOf(error)}`, {
        status: response.status,
      });
    }
    if (response.ok) return { status: response.status, headers: response.headers, text, attempts: attempt };
    const failure = new JevError(httpErrorMessage(response, text), { status: response.status, body: text });
    if (RETRYABLE_STATUS[response.status] !== true || attempt > options.maxRetries) throw failure;
    waitMs = retryAfterMs(response.headers) ?? backoffMs(attempt);
  }
}

export function parseJsonBody(text: string, url: string): unknown {
  try {
    return JSON.parse(text) as unknown;
  } catch (error) {
    throw new JevError(`${url} returned invalid JSON: ${messageOf(error)} (body: ${snippet(text)})`);
  }
}

async function fetchWithTimeout(options: HttpRequestOptions, payload: string | undefined): Promise<Response> {
  if (options.signal?.aborted) throw new JevError(`${options.url} request cancelled.`);
  const controller = new AbortController();
  let timedOut = false;
  const timer = setTimeout(() => {
    timedOut = true;
    controller.abort();
  }, options.timeoutMs);
  const onAbort = () => controller.abort();
  options.signal?.addEventListener("abort", onAbort, { once: true });
  try {
    return await fetch(options.url, {
      method: options.method,
      headers: {
        accept: "application/json",
        ...(payload === undefined ? {} : { "content-type": "application/json" }),
        ...options.headers,
      },
      body: payload,
      signal: controller.signal,
    });
  } catch (error) {
    if (timedOut) {
      throw new JevError(`${options.url} timed out after ${options.timeoutMs} ms.`, { cause: error });
    }
    throw error;
  } finally {
    clearTimeout(timer);
    options.signal?.removeEventListener("abort", onAbort);
  }
}

/** Prefer the provider's own error field over a raw body dump. */
function httpErrorMessage(response: Response, text: string): string {
  const status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
  let detail: string | undefined;
  try {
    const parsed = JSON.parse(text) as unknown;
    if (parsed !== null && typeof parsed === "object") {
      const record = parsed as Record<string, unknown>;
      const error = record.error;
      if (typeof error === "string") detail = error;
      else if (error !== null && typeof error === "object") {
        const inner = (error as Record<string, unknown>).message;
        if (typeof inner === "string") detail = inner;
      }
      if (detail === undefined && typeof record.message === "string") detail = record.message;
      if (detail === undefined && typeof record.detail === "string") detail = record.detail;
    }
  } catch {
    // Non-JSON error bodies fall through to the raw snippet.
  }
  return `${status}: ${detail ?? snippet(text)}`;
}

function snippet(text: string): string {
  const collapsed = text.replace(/\s+/g, " ").trim();
  return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed;
}

function retryAfterMs(headers: Headers): number | undefined {
  const header = headers.get("retry-after");
  if (!header) return undefined;
  const seconds = Number(header.trim());
  const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now();
  if (!Number.isFinite(ms) || ms <= 0) return undefined;
  return Math.min(ms, MAX_RETRY_AFTER_MS);
}

function backoffMs(attempt: number): number {
  return Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** (attempt - 1)) + Math.floor(Math.random() * 200);
}

function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
  if (signal?.aborted) return Promise.resolve();
  const { promise, resolve } = Promise.withResolvers<void>();
  const timer = setTimeout(finish, ms);
  function finish() {
    clearTimeout(timer);
    signal?.removeEventListener("abort", finish);
    resolve();
  }
  signal?.addEventListener("abort", finish, { once: true });
  return promise;
}
