/**
 * The one place this service talks to somebody else's server.
 *
 * Every outbound call is bounded and every failure is a returned `null` rather
 * than a thrown exception, because the callers are all route handlers whose
 * contract is that they answer. An upstream that has gone away must degrade the
 * body, never the response.
 */

const DEFAULT_TIMEOUT_MS = 6000;

/**
 * The largest upstream body this service will read.
 *
 * "Every outbound call is bounded" was true of the *time* and not of the size:
 * a bare `res.json()` reads whatever arrives, and what arrives is chosen by
 * somebody else's server. An ADS-B endpoint answering with 200,000 aircraft was
 * measured at 14.9 MB, parsed into an array this process then held and served
 * — cached, publicly — to every anonymous caller for the length of the TTL.
 *
 * Four megabytes is roughly two orders of magnitude above any honest answer
 * from the four upstreams here (a busy adsb.lol circle is tens of kilobytes, an
 * NWS observation is under ten) and well under anything that would trouble the
 * heap. It bounds the damage; `flights/adsb.ts` caps the row count, which
 * bounds what is kept.
 */
const MAX_BODY_BYTES = 4 * 1024 * 1024;

export interface GetTextOptions {
  headers?: Record<string, string>;
  timeoutMs?: number;
  /** Body-size ceiling in bytes. Defaults to `MAX_BODY_BYTES`. */
  maxBytes?: number;
  /** Sent as `Accept` when set. Omitted entirely when not. */
  accept?: string;
}

export type GetJsonOptions = Omit<GetTextOptions, "accept">;

/**
 * `null` on any failure at all — transport, status, an oversized body, or one
 * that will not parse. The caller decides what a missing answer means; nothing
 * here does.
 */
export async function getJson<T>(url: string, opts: GetJsonOptions = {}): Promise<T | null> {
  const text = await getText(url, { accept: "application/json", ...opts });
  if (text === null) return null;
  try {
    return JSON.parse(text) as T;
  } catch {
    return null;
  }
}

/**
 * The same bounded fetch, stopping one step earlier.
 *
 * Not every upstream speaks JSON. CelesTrak serves element sets as fixed-width
 * text, and the choice was between this and a second copy of the size-bounding
 * that `getJson` already had — which is the copy that gets a fix applied to one
 * of it. So the ceiling, the timeout, the streaming cancel and the
 * failure-is-`null` contract live here once, and `getJson` is this plus a parse.
 *
 * `accept` defaults to nothing rather than to `text/plain`, because a server
 * that content-negotiates should be free to send its own idea of the right type;
 * `getJson` passes the header it actually needs.
 */
export async function getText(url: string, opts: GetTextOptions = {}): Promise<string | null> {
  const maxBytes = opts.maxBytes ?? MAX_BODY_BYTES;
  try {
    const res = await fetch(url, {
      headers: { ...(opts.accept === undefined ? {} : { accept: opts.accept }), ...opts.headers },
      signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
    });
    if (!res.ok) return null;

    /**
     * The header first, because it is free and it is the one that stops the
     * transfer before it happens. It is only advisory — a chunked response
     * sends none — so the body is counted as it streams as well, and the
     * `cancel()` closes the socket on a server that lied or did not say.
     */
    const declared = Number(res.headers.get("content-length"));
    if (Number.isFinite(declared) && declared > maxBytes) {
      await res.body?.cancel();
      return null;
    }

    return await readBounded(res, maxBytes);
  } catch {
    return null;
  }
}

/** The body as text, or `null` the moment it goes over `maxBytes`. */
async function readBounded(res: Response, maxBytes: number): Promise<string | null> {
  const body = res.body;
  // Undici always gives a stream; a test double or a `fetch` polyfill may not,
  // and falling back to `res.text()` there is still bounded by the header check
  // above and by the timeout.
  if (!body) {
    const text = await res.text();
    return text.length > maxBytes ? null : text;
  }
  const reader = body.getReader();
  const decoder = new TextDecoder();
  let size = 0;
  let out = "";
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    size += value.byteLength;
    if (size > maxBytes) {
      await reader.cancel();
      return null;
    }
    out += decoder.decode(value, { stream: true });
  }
  return out + decoder.decode();
}

/**
 * A User-Agent that identifies this software and the operator running it.
 *
 * NWS and MET Norway both ask for a contact and are entitled to block a caller
 * who sends a generic agent. This is also why an empty contact demotes the
 * source in `config.ts` rather than being papered over here with a fake address.
 */
export function userAgent(contact: string): string {
  return `tera-api (+https://github.com/lumbridge-public/tera; ${contact})`;
}
