import { assertSafeUrl } from './ssrf.ts';

const VERSION = '0.1.0';
const MAX_BYTES = 5 * 1024 * 1024;
const TIMEOUT_MS = 10000;
const MAX_REDIRECTS = 5;

export type UrlGuard = (url: string) => Promise<void>;

export interface FetchOptions {
  etag?: string;
  lastModified?: string;
}

export interface FetchErrorOptions {
  status?: number;
  retryAfter?: string | null;
}

export interface FetchNotModified {
  status: 304;
  url: string;
  etag?: string;
  lastModified?: string;
}

export interface FetchSuccess {
  status: number;
  url: string;
  body: string;
  etag: string | null;
  lastModified: string | null;
  contentType: string;
}

export type FetchResult = FetchNotModified | FetchSuccess;

export function isNotModified(res: FetchResult): res is FetchNotModified {
  return res.status === 304;
}

export class FetchError extends Error {
  status?: number;
  retryAfter?: string | null;

  constructor(message: string, { status, retryAfter }: FetchErrorOptions = {}) {
    super(message);
    this.name = 'FetchError';
    this.status = status;
    this.retryAfter = retryAfter;
  }
}

async function readBody(res: Response): Promise<string> {
  const reader = res.body!.getReader();
  const chunks: Uint8Array[] = [];
  let total = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > MAX_BYTES) {
      await reader.cancel();
      throw new FetchError('response too large');
    }
    chunks.push(value);
  }
  const buffer = new Uint8Array(total);
  let offset = 0;
  for (const c of chunks) {
    buffer.set(c, offset);
    offset += c.byteLength;
  }
  return new TextDecoder().decode(buffer);
}

export async function fetchFeed(
  url: string,
  options: FetchOptions = {},
  guard: UrlGuard = assertSafeUrl,
): Promise<FetchResult> {
  let current = url;
  let redirects = 0;
  while (true) {
    await guard(current);
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
    let res: Response;
    try {
      const headers: Record<string, string> = {
        'User-Agent': `truf/${VERSION}`,
        Accept:
          'application/rss+xml, application/atom+xml, application/json, application/xml, text/xml, */*',
      };
      if (options.etag) headers['If-None-Match'] = options.etag;
      if (options.lastModified) headers['If-Modified-Since'] = options.lastModified;
      res = await fetch(current, { headers, redirect: 'manual', signal: controller.signal });
    } catch (e) {
      clearTimeout(timer);
      throw new FetchError(`network error: ${(e as Error).message}`);
    }
    clearTimeout(timer);

    if (res.status === 304) {
      return { status: 304, url: current, etag: options.etag, lastModified: options.lastModified };
    }
    if (res.status >= 300 && res.status < 400) {
      const loc = res.headers.get('location');
      if (!loc) throw new FetchError('redirect without location');
      redirects += 1;
      if (redirects > MAX_REDIRECTS) throw new FetchError('too many redirects');
      current = new URL(loc, current).toString();
      continue;
    }

    if (!res.ok) {
      throw new FetchError(`HTTP ${res.status}`, {
        status: res.status,
        retryAfter: res.headers.get('retry-after'),
      });
    }

    const cl = res.headers.get('content-length');
    if (cl && Number(cl) > MAX_BYTES) throw new FetchError('response too large');

    const body = await readBody(res);
    return {
      status: res.status,
      url: current,
      body,
      etag: res.headers.get('etag'),
      lastModified: res.headers.get('last-modified'),
      contentType: res.headers.get('content-type') || '',
    };
  }
}
