import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { defaultLogger } from "../logger.js";

import { FONT_ALIAS_MAP } from "@hyperframes/core/fonts/aliases";
import {
  locateSystemFontVariants,
  SYSTEM_FONT_SIZE_LIMIT,
} from "@hyperframes/core/fonts/system-locator";
import { parseHTML } from "linkedom";
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
import { fontToDataUri } from "./fontCompression.js";

type FontFaceSpec = {
  weight: string;
  style?: "normal" | "italic";
};

type CanonicalFontSpec = {
  packageName: string;
  faces: FontFaceSpec[];
};

/**
 * Family names that resolve to a host-OS font (or a CSS generic that the
 * browser substitutes with a host-OS font). Exported so plan-time validators
 * can reject them as primary families in distributed renders.
 *
 * Lower-cased — call `normalizeFamilyName` on declared values before lookup.
 */
export const GENERIC_FAMILIES: ReadonlySet<string> = new Set([
  "sans-serif",
  "serif",
  "monospace",
  "cursive",
  "fantasy",
  "system-ui",
  "ui-sans-serif",
  "ui-serif",
  "ui-monospace",
  "emoji",
  "math",
  "fangsong",
  "-apple-system",
  "blinkmacsystemfont",
]);

/**
 * Parse a single `font-family` value (e.g. `"Inter", -apple-system,
 * sans-serif`) into a list of unquoted family names in declaration order.
 * Whitespace and surrounding `"…"` / `'…'` quotes are stripped; case is
 * preserved. Pass each name through `normalizeFamilyName` for case-
 * insensitive comparisons.
 */
export function parseFontFamilyValue(value: string): string[] {
  return value
    .split(",")
    .map((piece) => piece.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim())
    .filter((piece) => piece.length > 0);
}

/** Surfaces font-family is declared on in served HTML. */
export type FontFamilySurface = "font-family" | "data-font-family";

/**
 * Iterate every font-family declaration in a compiled HTML document. Yields
 * each declaration's surface (CSS property vs HTML attribute), raw value,
 * and the parsed family list. Used by both the @font-face injector and the
 * plan-time validator so they read the same surface area.
 */
export function* iterateFontFamilyDeclarations(
  html: string,
): Generator<{ surface: FontFamilySurface; declaration: string; families: string[] }, void, void> {
  const sources: ReadonlyArray<readonly [RegExp, FontFamilySurface]> = [
    [/font-family\s*:\s*([^;}{]+)[;}]?/gi, "font-family"],
    [/data-font-family=["']([^"']+)["']/gi, "data-font-family"],
  ];
  for (const [regex, surface] of sources) {
    for (const match of html.matchAll(regex)) {
      const declaration = match[1] ?? "";
      yield { surface, declaration, families: parseFontFamilyValue(declaration) };
    }
  }
}

const CANONICAL_FONTS: Record<string, CanonicalFontSpec> = {
  inter: {
    packageName: "@fontsource/inter",
    faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
  },
  montserrat: {
    packageName: "@fontsource/montserrat",
    faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
  },
  outfit: {
    packageName: "@fontsource/outfit",
    faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
  },
  nunito: {
    packageName: "@fontsource/nunito",
    faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
  },
  oswald: {
    packageName: "@fontsource/oswald",
    faces: [{ weight: "400" }, { weight: "700" }],
  },
  "league-gothic": {
    packageName: "@fontsource/league-gothic",
    faces: [{ weight: "400" }],
  },
  "archivo-black": {
    packageName: "@fontsource/archivo-black",
    faces: [{ weight: "400" }],
  },
  "space-mono": {
    packageName: "@fontsource/space-mono",
    faces: [{ weight: "400" }, { weight: "700" }],
  },
  "ibm-plex-mono": {
    packageName: "@fontsource/ibm-plex-mono",
    faces: [{ weight: "400" }, { weight: "700" }],
  },
  "jetbrains-mono": {
    packageName: "@fontsource/jetbrains-mono",
    faces: [{ weight: "400" }, { weight: "700" }],
  },
  "eb-garamond": {
    packageName: "@fontsource/eb-garamond",
    faces: [{ weight: "400" }, { weight: "700" }],
  },
  "playfair-display": {
    packageName: "@fontsource/playfair-display",
    faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
  },
  "source-code-pro": {
    packageName: "@fontsource/source-code-pro",
    faces: [{ weight: "400" }, { weight: "700" }],
  },
  "noto-sans-jp": {
    packageName: "@fontsource/noto-sans-jp",
    faces: [{ weight: "400" }, { weight: "700" }],
  },
  roboto: {
    packageName: "@fontsource/roboto",
    faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
  },
  "open-sans": {
    packageName: "@fontsource/open-sans",
    faces: [{ weight: "400" }, { weight: "700" }],
  },
  lato: {
    packageName: "@fontsource/lato",
    faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
  },
  poppins: {
    packageName: "@fontsource/poppins",
    faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
  },
};

// FONT_ALIASES derives from the shared alias map in @hyperframes/core.
// The cast is safe: every value in FONT_ALIAS_MAP is a valid CANONICAL_FONTS key.
export const FONT_ALIASES = FONT_ALIAS_MAP as Record<string, keyof typeof CANONICAL_FONTS>;

export { FONT_ALIAS_KEYS } from "@hyperframes/core/fonts/aliases";

function normalizeFamilyName(family: string): string {
  return family
    .trim()
    .replace(/^['"]|['"]$/g, "")
    .trim()
    .toLowerCase();
}

function fontDataUri(
  packageName: string,
  weight: string,
  style: "normal" | "italic" = "normal",
): string {
  const key = `${packageName}:${weight}:${style}`;
  const uri = EMBEDDED_FONT_DATA.get(key);
  if (!uri) {
    throw new Error(
      `No embedded font data for ${key}. Regenerate with: tsx scripts/generate-font-data.ts`,
    );
  }
  return uri;
}

function extractExistingFontFaces(html: string): Set<string> {
  const families = new Set<string>();
  const fontFaceRegex = /@font-face\s*\{[\s\S]*?font-family\s*:\s*([^;]+);[\s\S]*?\}/gi;
  for (const match of html.matchAll(fontFaceRegex)) {
    const raw = match[1] || "";
    const normalized = normalizeFamilyName(raw);
    if (normalized) {
      families.add(normalized);
    }
  }
  return families;
}

function extractRequestedFontFamilies(html: string): Map<string, string> {
  const requested = new Map<string, string>();
  for (const { families } of iterateFontFamilyDeclarations(html)) {
    for (const originalCase of families) {
      const normalized = originalCase.toLowerCase();
      if (!normalized || GENERIC_FAMILIES.has(normalized)) continue;
      if (!requested.has(normalized)) requested.set(normalized, originalCase);
    }
  }
  return requested;
}

function buildFontFaceRule(
  familyName: string,
  src: string,
  weight: string,
  style: string,
  unicodeRange?: string,
): string {
  return [
    "@font-face {",
    `  font-family: "${familyName}";`,
    `  src: url("${src}") format("woff2");`,
    `  font-style: ${style};`,
    `  font-weight: ${weight};`,
    "  font-display: block;",
    // Preserve the subset's unicode-range so the browser selects the right
    // per-codepoint subset (matching Google Fonts' own CSS semantics).
    ...(unicodeRange ? [`  unicode-range: ${unicodeRange};`] : []),
    "}",
  ].join("\n");
}

async function buildFontFaceCss(
  requestedFamilies: Map<string, string>,
  options: InternalFontFetchOptions,
): Promise<{
  css: string;
  unresolved: string[];
}> {
  const rules: string[] = [];
  const unresolved: string[] = [];

  for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
    // Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces,
    // then fetch from Google Fonts to fill any weights not in the bundle.
    const canonicalKey = FONT_ALIASES[normalizedFamily];
    if (canonicalKey) {
      const canonical = CANONICAL_FONTS[canonicalKey];
      if (!canonical) continue;

      const coveredWeights = new Set<string>();
      for (const face of canonical.faces) {
        const style = face.style || "normal";
        const src = fontDataUri(canonical.packageName, face.weight, style);
        rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style));
        coveredWeights.add(`${face.weight}:${style}`);
      }

      // Fetch all weights from Google Fonts and add any that aren't
      // already covered by the embedded bundle. This ensures that
      // compositions requesting e.g. wght@200 get that weight even
      // if the bundle only ships 400/700/900.
      const googleFaces = await fetchGoogleFont(originalCaseFamily, options);
      for (const face of googleFaces) {
        // A weight covered by the embedded bundle is already full-coverage —
        // skip it. For weights the bundle lacks, add EVERY subset face (a
        // weight has one face per unicode-range subset), not just the first.
        if (coveredWeights.has(`${face.weight}:${face.style}`)) continue;
        rules.push(
          buildFontFaceRule(
            originalCaseFamily,
            face.dataUri,
            face.weight,
            face.style,
            face.unicodeRange,
          ),
        );
      }
      continue;
    }

    // Path 2: fetch from Google Fonts (with local cache)
    const googleFaces = await fetchGoogleFont(originalCaseFamily, options);
    if (googleFaces.length > 0) {
      for (const face of googleFaces) {
        rules.push(
          buildFontFaceRule(
            originalCaseFamily,
            face.dataUri,
            face.weight,
            face.style,
            face.unicodeRange,
          ),
        );
      }
      continue;
    }

    // Path 3: locate font on the local filesystem, compress, and embed.
    if (options.allowSystemFontCapture) {
      const variants = locateSystemFontVariants(originalCaseFamily);
      if (variants.length > 0) {
        let totalBytes = 0;
        for (const variant of variants) {
          const fontBuffer = readFileSync(variant.path);
          totalBytes += fontBuffer.length;
          const dataUri = await fontToDataUri(fontBuffer, variant.format);
          rules.push(buildFontFaceRule(originalCaseFamily, dataUri, variant.weight, variant.style));
        }
        if (totalBytes > SYSTEM_FONT_SIZE_LIMIT) {
          defaultLogger.warn(
            `[Compiler] System font "${originalCaseFamily}" is large (${(totalBytes / 1024 / 1024).toFixed(1)} MB total across ${variants.length} variant(s)) — embedding anyway. Consider font subsetting for production.`,
          );
        }
        defaultLogger.info(
          `[Compiler] Embedded system font "${originalCaseFamily}" — ${variants.length} variant(s), ${(totalBytes / 1024).toFixed(0)} KB total`,
        );
        continue;
      }
    }

    // No path resolved
    unresolved.push(originalCaseFamily);
  }

  return {
    css: rules.join("\n\n").trim(),
    unresolved: unresolved.sort(),
  };
}

function warnUnresolvedFonts(unresolved: string[]): void {
  const mapped = Object.entries(FONT_ALIASES)
    .reduce<string[]>((acc, [alias, canonical]) => {
      const display = alias === canonical ? alias : `${alias} → ${canonical}`;
      if (!acc.includes(display)) acc.push(display);
      return acc;
    }, [])
    .sort();
  defaultLogger.warn(
    `[Compiler] No deterministic font mapping for: ${unresolved.join(", ")}\n` +
      `  Mapped fonts: ${mapped.join(", ")}\n` +
      `  To fix, pick one:\n` +
      `    1. Use a mapped font name instead (see list above)\n` +
      `    2. Add a @font-face block in your HTML with a local or hosted font file\n` +
      `    3. Install the font locally on the render machine (Docker: add to Dockerfile)\n` +
      `    4. Add an alias to FONT_ALIAS_MAP in packages/core/src/fonts/aliases.ts (for contributors)\n` +
      `  Docs: https://hyperframes.heygen.com/docs/fonts`,
  );
}

// ---------------------------------------------------------------------------
// Google Fonts on-demand fetch + local cache
// ---------------------------------------------------------------------------

// On AWS Lambda `$HOME` resolves to a `/home/sbx_*` tree that's
// read-only; only `/tmp` is writable. Route the cache there when
// running inside Lambda, and honor `HYPERFRAMES_FONT_CACHE_DIR` as
// an explicit override for any environment.
function resolveFontCacheRoot(): string {
  return (
    process.env.HYPERFRAMES_FONT_CACHE_DIR ??
    (process.env.AWS_LAMBDA_FUNCTION_NAME
      ? join(tmpdir(), "hyperframes", "fonts")
      : join(homedir(), ".cache", "hyperframes", "fonts"))
  );
}
const GOOGLE_FONTS_CACHE_DIR = resolveFontCacheRoot();

// Chrome UA triggers woff2 responses from Google Fonts CSS API
const WOFF2_USER_AGENT =
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";

function fontSlug(familyName: string): string {
  return familyName
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

function fontCacheDir(slug: string): string {
  const dir = join(GOOGLE_FONTS_CACHE_DIR, slug);
  if (!existsSync(dir)) {
    mkdirSync(dir, { recursive: true });
  }
  return dir;
}

// A short, stable discriminator for a single subset's woff2. Google Fonts'
// css2 API returns one @font-face per (weight × unicode-range subset) — e.g.
// `vietnamese`, `latin-ext`, and `latin` faces for the SAME weight, each with
// a distinct woff2 URL and glyph set. Keying the cache by weight+style alone
// collides every subset onto one filename, so only the first subset in the
// CSS gets downloaded and the rest read it back. Derive the cache key from the
// (subset-unique, version-stable) woff2 URL so each subset is cached on its own.
function subsetToken(woff2Url: string): string {
  return createHash("sha1").update(woff2Url).digest("hex").slice(0, 12);
}

function cachedWoff2Path(slug: string, weight: string, style: string, subset: string): string {
  return join(fontCacheDir(slug), `${weight}-${style}-${subset}.woff2`);
}

type GoogleFontFace = {
  weight: string;
  style: string;
  dataUri: string;
  unicodeRange?: string;
};

/**
 * Typed code classifying a font-fetch failure as non-retryable for
 * distributed workflow adapters — a missing Google Fonts entry will not heal
 * on retry.
 */
export const FONT_FETCH_FAILED = "FONT_FETCH_FAILED";

/**
 * Typed error thrown by {@link injectDeterministicFontFaces} when
 * `failClosedFontFetch === true` and an external font fetch fails. The
 * default (swallow + warn) preserves the in-process behavior.
 */
export class FontFetchError extends Error {
  readonly code: typeof FONT_FETCH_FAILED = FONT_FETCH_FAILED;
  readonly familyName: string;
  readonly url: string;
  readonly cause?: unknown;

  constructor(familyName: string, url: string, message: string, cause?: unknown) {
    super(message);
    this.name = "FontFetchError";
    this.familyName = familyName;
    this.url = url;
    this.cause = cause;
  }
}

/** Internal threading of the failClosed flag + fetch override through callers. */
interface InternalFontFetchOptions {
  failClosedFontFetch: boolean;
  fetchImpl: typeof fetch;
  allowSystemFontCapture: boolean;
}

/**
 * Build a typed FontFetchError describing why a Google Fonts request failed.
 * Centralizes the message wording so all four call sites (CSS/woff2 ×
 * HTTP-error/exception) stay phrased identically.
 */
function fontFetchError(
  familyName: string,
  url: string,
  what: "Google Fonts CSS" | `Google Fonts woff2 (${string}/${string})`,
  cause: { status: number } | { error: unknown },
): FontFetchError {
  const reason =
    "status" in cause
      ? `returned HTTP ${cause.status}`
      : `failed: ${(cause.error as Error).message}`;
  const message =
    `[deterministicFonts] ${what} fetch for ${JSON.stringify(familyName)} ${reason}. ` +
    `Distributed renders require deterministic fonts; system-font fallback would produce ` +
    `non-byte-identical output.`;
  return new FontFetchError(familyName, url, message, "error" in cause ? cause.error : undefined);
}

/**
 * Ensure one subset's woff2 is cached on disk (downloading if absent) and
 * return it as a `data:` URI. Returns `null` when the woff2 isn't served
 * (4xx) so the caller skips that face. Throws {@link FontFetchError} on
 * transient (5xx / network) failures when `failClosedFontFetch` is set.
 */
async function ensureWoff2DataUri(
  cachePath: string,
  woff2Url: string,
  familyName: string,
  weight: string,
  style: string,
  options: InternalFontFetchOptions,
): Promise<string | null> {
  try {
    return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
  } catch {
    // Not cached yet — fall through to fetch.
  }

  const woff2What = `Google Fonts woff2 (${weight}/${style})` as const;
  try {
    const fontRes = await options.fetchImpl(woff2Url);
    if (!fontRes.ok) {
      if (fontRes.status >= 500 && options.failClosedFontFetch) {
        throw fontFetchError(familyName, woff2Url, woff2What, { status: fontRes.status });
      }
      return null;
    }
    // wx = O_CREAT|O_EXCL: atomic create, rejects symlinks, fails with
    // EEXIST if a concurrent call cached it between our read and write.
    writeFileSync(cachePath, Buffer.from(await fontRes.arrayBuffer()), { flag: "wx", mode: 0o644 });
  } catch (err) {
    if (err instanceof FontFetchError) throw err;
    if ((err as NodeJS.ErrnoException).code === "EEXIST") {
      // Concurrent call wrote it — read their result below.
    } else if (options.failClosedFontFetch) {
      throw fontFetchError(familyName, woff2Url, woff2What, { error: err });
    } else {
      return null;
    }
  }
  return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
}

async function fetchGoogleFont(
  familyName: string,
  options: InternalFontFetchOptions,
): Promise<GoogleFontFace[]> {
  const slug = fontSlug(familyName);
  const encodedFamily = encodeURIComponent(familyName);
  const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700`;

  let cssText: string;
  try {
    const res = await options.fetchImpl(url, {
      headers: { "User-Agent": WOFF2_USER_AGENT },
    });
    if (!res.ok) {
      // 4xx is a *deterministic* answer from Google Fonts that this
      // family is not served (e.g. HTTP 400 for "Segoe UI", "Arial",
      // "Futura" — names absent from Google's catalog) or is misnamed.
      // The render falls back to embedded faces / the composition's
      // font-family chain; we return [] in both modes. 5xx (and other
      // transient upstream failures) could return faces on retry, which
      // would break the byte-identical-retry contract distributed
      // renders rely on — those still fail closed when requested.
      if (res.status >= 500 && options.failClosedFontFetch) {
        throw fontFetchError(familyName, url, "Google Fonts CSS", { status: res.status });
      }
      return [];
    }
    cssText = await res.text();
  } catch (err) {
    // Rethrow typed error untouched. Network / DNS / fetch-throws are
    // non-deterministic infrastructure failures — wrapped when failClosed
    // is on, swallowed otherwise.
    if (err instanceof FontFetchError) throw err;
    if (options.failClosedFontFetch) {
      throw fontFetchError(familyName, url, "Google Fonts CSS", { error: err });
    }
    return [];
  }

  // Parse @font-face blocks from the CSS response. The optional trailing
  // capture grabs each face's `unicode-range` (Google emits it after `src`)
  // so the injected face only claims the codepoints the subset actually
  // covers — without it the face would advertise full coverage it lacks.
  const faceRegex =
    /@font-face\s*\{[^}]*font-style:\s*(normal|italic)[^}]*font-weight:\s*(\d+)[^}]*src:\s*url\(([^)]+)\)\s*format\(['"]woff2['"]\)(?:[^}]*?unicode-range:\s*([^;}]+))?[^}]*\}/gi;

  const faces: GoogleFontFace[] = [];

  for (const match of cssText.matchAll(faceRegex)) {
    const style = match[1] || "normal";
    const weight = match[2] || "400";
    const woff2Url = match[3] || "";
    const unicodeRange = match[4]?.trim() || undefined;

    if (!woff2Url) continue;

    const cachePath = cachedWoff2Path(slug, weight, style, subsetToken(woff2Url));
    const dataUri = await ensureWoff2DataUri(
      cachePath,
      woff2Url,
      familyName,
      weight,
      style,
      options,
    );
    if (dataUri) faces.push({ weight, style, dataUri, unicodeRange });
  }

  if (faces.length > 0) {
    defaultLogger.info(
      `[Compiler] Fetched ${faces.length} font face(s) for "${familyName}" from Google Fonts (cached to ${fontCacheDir(slug)})`,
    );
  }

  return faces;
}

// ---------------------------------------------------------------------------

/**
 * Options for {@link injectDeterministicFontFaces}.
 */
export interface InjectDeterministicFontFacesOptions {
  /**
   * When `true`, any external font fetch failure (Google Fonts CSS or
   * woff2) throws {@link FontFetchError} with code `FONT_FETCH_FAILED`.
   *
   * Default `false`: failed fetches are silently swallowed; the composition
   * falls back to system fonts via `warnUnresolvedFonts`. This preserves the
   * in-process behavior.
   *
   * Distributed callers pass `true` so font availability is part of the
   * planDir's content-addressed hash and fetch failures surface as typed
   * non-retryable errors.
   */
  failClosedFontFetch?: boolean;
  /**
   * Injectable `fetch` implementation. Defaults to the global `fetch`.
   * Tests pass a stub to simulate fetch failures without going over the
   * network.
   */
  fetchImpl?: typeof fetch;
  /**
   * When `true` (default for local renders), fonts that aren't resolved by
   * the bundled alias map or Google Fonts are located on the local filesystem,
   * compressed to woff2, and embedded as data URIs. Set to `false` for
   * distributed/Lambda renders where the host filesystem is not guaranteed
   * to contain the same fonts as the authoring machine.
   */
  allowSystemFontCapture?: boolean;
}

export async function injectDeterministicFontFaces(
  html: string,
  options: InjectDeterministicFontFacesOptions = {},
): Promise<string> {
  const failClosedFontFetch = options.failClosedFontFetch === true;
  const fetchImpl = options.fetchImpl ?? fetch;
  const allowSystemFontCapture = options.allowSystemFontCapture !== false;
  const fetchOptions: InternalFontFetchOptions = {
    failClosedFontFetch,
    fetchImpl,
    allowSystemFontCapture,
  };

  const existingFaces = extractExistingFontFaces(html);
  const requestedFamilies = extractRequestedFontFamilies(html);
  const pendingFamilies = new Map<string, string>();

  for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
    if (!existingFaces.has(normalizedFamily)) {
      pendingFamilies.set(normalizedFamily, originalCaseFamily);
    }
  }

  if (pendingFamilies.size === 0) {
    return html;
  }

  const { css, unresolved } = await buildFontFaceCss(pendingFamilies, fetchOptions);
  if (unresolved.length > 0 && options.failClosedFontFetch) {
    throw new FontFetchError(
      unresolved.join(", "),
      "",
      `[Compiler] Unresolved fonts in fail-closed mode: ${unresolved.join(", ")}. ` +
        `Distributed renders require all fonts to be resolvable.`,
    );
  }
  if (!css) {
    if (unresolved.length > 0) {
      warnUnresolvedFonts(unresolved);
    }
    return html;
  }

  const { document } = parseHTML(html);
  const head = document.querySelector("head");
  if (!head) {
    return html;
  }

  const styleEl = document.createElement("style");
  styleEl.setAttribute("data-hyperframes-deterministic-fonts", "true");
  styleEl.textContent = css;
  head.insertBefore(styleEl, head.firstChild);

  defaultLogger.info(
    `[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`,
  );
  if (unresolved.length > 0) {
    warnUnresolvedFonts(unresolved);
  }

  return document.toString();
}
