/**
 * Remote-data fetch adapter (snapshot model — spec §05 → URL datasets).
 *
 * The ONLY place Astrolabe touches the network. Adding a URL dataset fetches the
 * resource once here; core then profiles the returned text exactly like inline
 * data (see dataset.ts). Keeping the fetch in infrastructure preserves the rule
 * that `src/core` stays pure and that the rest of the app never calls `fetch`
 * directly (AGENTS → Architecture).
 *
 * Failures are classified into a small set of machine-readable `reason`s and
 * thrown as `RemoteFetchError`; the user-facing wording (and the "paste inline
 * instead" fallback) lives in the UI layer, where it is council-reviewed — the
 * same split as `storage-errors`. A browser cross-origin block and an
 * offline/DNS failure are indistinguishable here — both reject with a `TypeError`
 * and no status — so they share the `network` reason and the UI hedges the cause.
 */

/** Why a remote fetch failed, in terms the UI maps to copy + a recovery path. */
export type RemoteFetchReason = 'network' | 'http' | 'empty' | 'timeout' | 'too-large';

/** A classified remote-fetch failure. `status` is set only for `http`. */
export class RemoteFetchError extends Error {
  readonly reason: RemoteFetchReason;
  readonly status?: number;

  constructor(reason: RemoteFetchReason, message: string, status?: number) {
    super(message);
    this.name = 'RemoteFetchError';
    this.reason = reason;
    this.status = status;
  }
}

/** A successful fetch: the raw body plus a weak format hint from the server. */
export interface RemoteData {
  /** The response body, ready for format detection + profiling. */
  text: string;
  /** The server's `Content-Type`, when provided — a tertiary format hint. */
  contentType: string | null;
}

/** Default ceiling on a single fetch before it is aborted as a timeout. */
const DEFAULT_TIMEOUT_MS = 30_000;

/**
 * Hard ceiling on a snapshotted remote body. A URL dataset is held whole in memory
 * while profiling and then stored whole in IndexedDB, so an unbounded download
 * (a multi-hundred-MB file) would jank or exhaust the tab. Checked against the
 * declared `Content-Length` before reading, and against the body as a backstop for
 * chunked responses that declare no length.
 */
export const MAX_REMOTE_BYTES = 50 * 1024 * 1024;

export interface FetchRemoteDataOptions {
  /** Abort the fetch after this many ms (default 30 s). */
  timeoutMs?: number;
  /** Reject a body larger than this many bytes (default {@link MAX_REMOTE_BYTES}). */
  maxBytes?: number;
}

function describeNetworkError(err: unknown): string {
  const reason = err instanceof Error ? err.message : String(err);
  return `Couldn't reach the URL (${reason}).`;
}

/** A byte count as a rounded MB string for diagnostic messages (e.g. `120 MB`). */
function megabytes(bytes: number): string {
  return `${Math.round(bytes / (1024 * 1024))} MB`;
}

/**
 * Fetch a remote dataset resource once and return its raw body. Throws
 * `RemoteFetchError` on any failure — the caller distinguishes recovery by
 * `reason`. The body is returned verbatim; format detection and profiling happen
 * downstream so this adapter stays free of core logic.
 */
export async function fetchRemoteData(
  url: string,
  options: FetchRemoteDataOptions = {},
): Promise<RemoteData> {
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const maxBytes = options.maxBytes ?? MAX_REMOTE_BYTES;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  let response: Response;
  try {
    response = await fetch(url, { redirect: 'follow', signal: controller.signal });
  } catch (err) {
    // A timeout aborts via the controller (AbortError); everything else here is a
    // CORS block, offline, DNS failure, or malformed URL — all opaque TypeErrors.
    if ((err as { name?: string }).name === 'AbortError') {
      throw new RemoteFetchError('timeout', `The URL took longer than ${timeoutMs} ms to respond.`);
    }
    throw new RemoteFetchError('network', describeNetworkError(err));
  } finally {
    clearTimeout(timer);
  }

  if (!response.ok) {
    throw new RemoteFetchError(
      'http',
      `The server responded ${response.status} ${response.statusText}.`.trim(),
      response.status,
    );
  }

  // Reject an oversized body before reading it, when the server declares the size —
  // so a huge download never lands in memory in the first place.
  const declared = Number(response.headers.get('content-length'));
  if (Number.isFinite(declared) && declared > maxBytes) {
    throw new RemoteFetchError('too-large', `The file is about ${megabytes(declared)}.`);
  }

  const text = await response.text();
  if (text.trim() === '') {
    throw new RemoteFetchError('empty', 'The URL returned no data.');
  }

  // Backstop for a chunked response that declared no Content-Length: the body is
  // already in memory, but this still keeps it out of IndexedDB. `text.length`
  // (UTF-16 units) slightly under-counts multibyte bytes — fine for a ceiling.
  if (text.length > maxBytes) {
    throw new RemoteFetchError('too-large', `The file is over ${megabytes(maxBytes)}.`);
  }

  return { text, contentType: response.headers.get('content-type') };
}
