// src/lib/spec/audit/snapshot.ts — I/O layer: fault-tolerant fetch of a site's
// public surface (home + a few well-known paths). No browser, no DOM — the
// returned snapshot is parsed later by the pure check registry. Every fetch is
// wrapped so a network error / timeout yields an `{ ok: false }` record and
// NEVER throws, so a single dead URL can't break the whole audit.

import {
  assertPublicUrl,
  type DnsResolver,
  defaultResolver,
} from "./ssrf-guard";

const HTML_CAP = 512 * 1024; // ~512 KB of HTML is plenty for head/meta parsing.
const TIMEOUT_MS = 8000;
const MAX_REDIRECTS = 5;

/** The home document (parsed by foundations/seo/security checks). */
export type HomeResult = {
  ok: boolean;
  status: number;
  /** Response headers, keys lowercased. */
  headers: Record<string, string>;
  /** Response body (capped at ~512 KB). */
  html: string;
  /** `res.url` after redirects (used by the https check). */
  finalUrl: string;
};

/** A plain-text auxiliary resource (robots, sitemap, security.txt, llms.txt). */
export type TextResult = {
  ok: boolean;
  status: number;
  text: string;
};

export type SiteSnapshot = {
  /** The normalized base URL the snapshot was taken against. */
  baseUrl: string;
  home: HomeResult;
  robotsTxt: TextResult;
  sitemapXml: TextResult;
  securityTxt: TextResult;
  llmsTxt: TextResult;
};

type FetchImpl = typeof fetch;

/** Ensure a scheme; default to https:// when missing. Trims a trailing slash. */
export function normalizeBaseUrl(input: string): string {
  const trimmed = input.trim();
  const withScheme = /^https?:\/\//i.test(trimmed)
    ? trimmed
    : `https://${trimmed}`;
  return withScheme.replace(/\/+$/, "");
}

function emptyHome(): HomeResult {
  return { ok: false, status: 0, headers: {}, html: "", finalUrl: "" };
}

function emptyText(): TextResult {
  return { ok: false, status: 0, text: "" };
}

function lowerHeaders(headers: Headers): Record<string, string> {
  const out: Record<string, string> = {};
  headers.forEach((value, key) => {
    out[key.toLowerCase()] = value;
  });
  return out;
}

/**
 * SSRF-safe fetch. We follow redirects MANUALLY (`redirect: "manual"`) instead
 * of `"follow"` because `"follow"` would let a 3xx to an internal IP slip past
 * a one-shot pre-fetch check. Every hop — the initial URL and every `Location`
 * — is re-validated with `assertPublicUrl`, which also re-resolves DNS (basic
 * anti-rebinding). Throws (BlockedUrlError, too-many-redirects, or the
 * underlying fetch error) so the callers can map it to their empty result.
 */
async function safeFetch(
  url: string,
  fetchImpl: FetchImpl,
  resolver: DnsResolver,
): Promise<{ res: Response; finalUrl: string }> {
  let current = url;
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
    await assertPublicUrl(current, resolver);
    const res = await fetchImpl(current, {
      signal: AbortSignal.timeout(TIMEOUT_MS),
      redirect: "manual",
    });
    if (res.status >= 300 && res.status < 400) {
      const location = res.headers.get("location");
      if (!location) return { res, finalUrl: current };
      current = new URL(location, current).toString();
      continue;
    }
    return { res, finalUrl: current };
  }
  throw new Error("Too many redirects");
}

async function fetchHome(
  baseUrl: string,
  fetchImpl: FetchImpl,
  resolver: DnsResolver,
): Promise<HomeResult> {
  try {
    const { res, finalUrl } = await safeFetch(baseUrl, fetchImpl, resolver);
    const raw = await res.text();
    const html = raw.length > HTML_CAP ? raw.slice(0, HTML_CAP) : raw;
    return {
      ok: res.ok,
      status: res.status,
      headers: lowerHeaders(res.headers),
      html,
      finalUrl: res.url || finalUrl,
    };
  } catch {
    return emptyHome();
  }
}

async function fetchText(
  url: string,
  fetchImpl: FetchImpl,
  resolver: DnsResolver,
): Promise<TextResult> {
  try {
    const { res } = await safeFetch(url, fetchImpl, resolver);
    const text = await res.text();
    return {
      ok: res.ok,
      status: res.status,
      text: text.length > HTML_CAP ? text.slice(0, HTML_CAP) : text,
    };
  } catch {
    return emptyText();
  }
}

/**
 * Fetch the public surface of `baseUrl` for auditing. Tolerant to errors:
 * each request is independent and a failure becomes an `{ ok: false }` record.
 * `fetchImpl` is injectable so tests can pass a fake fetcher (no real network).
 */
export async function fetchSiteSnapshot(
  baseUrl: string,
  fetchImpl: FetchImpl = fetch,
  resolver: DnsResolver = defaultResolver,
): Promise<SiteSnapshot> {
  const base = normalizeBaseUrl(baseUrl);
  const [home, robotsTxt, sitemapXml, securityTxt, llmsTxt] = await Promise.all(
    [
      fetchHome(base, fetchImpl, resolver),
      fetchText(`${base}/robots.txt`, fetchImpl, resolver),
      fetchText(`${base}/sitemap.xml`, fetchImpl, resolver),
      fetchText(`${base}/.well-known/security.txt`, fetchImpl, resolver),
      fetchText(`${base}/llms.txt`, fetchImpl, resolver),
    ],
  );
  return { baseUrl: base, home, robotsTxt, sitemapXml, securityTxt, llmsTxt };
}
