/**
 * Google Fonts Loader
 *
 * Dynamically loads fonts from Google Fonts API with:
 * - Loading state tracking
 * - Duplicate prevention
 * - Callback notifications
 * - Font availability detection
 */

// Track loaded fonts to avoid duplicate requests
const loadedFonts = new Set<string>();

// Track fonts currently being loaded
const loadingFonts = new Map<string, Promise<boolean>>();

// Track loaded faces (family|weight) for URL/buffer paths where one
// family can have multiple weights registered independently.
const loadedFaces = new Set<string>();
const loadingFaces = new Map<string, Promise<boolean>>();

// Callbacks to notify when fonts are loaded
const loadCallbacks = new Set<(fonts: string[]) => void>();

// Callbacks to notify when a font fails to load. Adapters subscribe and
// forward to their `onError` prop so library consumers can route into their
// own error tracker (Sentry, Datadog, etc.) instead of filtering the console.
const errorCallbacks = new Set<(error: Error) => void>();

// Track overall loading state
let isLoadingAny = false;

// When false, the automatic Google Fonts fetch path is disabled. Embedders
// that must not egress (e.g. a CSP-locked review surface rendering privileged
// content from embedded font blobs, or any offline host) call
// setGoogleFontsEnabled(false) to suppress the redundant remote fetches at the
// source. Embedded faces (loadFontFromBuffer) and consumer-hosted faces
// (loadFontFromUrl / the `fonts` prop) are unaffected — only the implicit
// Google Fonts lookup in loadFont / loadFontWithMapping is gated.
let googleFontsEnabled = true;

// Families registered through this module's face loaders — raw buffers
// (DOCX-embedded fonts) and consumer URLs (the `fonts` prop). Both are
// routinely subsetted, so "this family renders" does NOT imply full glyph
// coverage. loadFontWithMapping keeps fetching the metric-compatible Google
// equivalent for these families as a glyph-coverage safety net; genuine
// system fonts skip it. Marked synchronously at registration start so an
// in-flight registration already counts as provenance.
const registeredFamilies = new Set<string>();

// Families the canvas probe confirmed as system-satisfied. Kept SEPARATE from
// loadedFonts on purpose: loadedFonts means "a fetch/registration happened"
// and short-circuits loadFont before its options are even looked at, so
// putting probe results there would make a later explicit-weights call a
// silent no-op. Negative probes are NOT cached — a host page's own
// @font-face can make a font appear at any time, and the re-probe is two
// measureText calls on a shared canvas.
const probeSatisfied = new Set<string>();

function reportFontError(error: unknown, context: string): void {
  // Wrap in a fresh Error rather than mutating the original — some Error
  // subclasses (DOMException, frozen objects) have a non-writable .message
  // and assigning to it throws, which would swallow the real load error.
  // Carry the original via `cause` so consumers can still inspect it.
  const origMessage = error instanceof Error ? error.message : String(error);
  const err = new Error(`[font] ${context}: ${origMessage}`, {
    cause: error,
  });

  if (errorCallbacks.size > 0) {
    for (const callback of errorCallbacks) {
      try {
        callback(err);
      } catch (subscriberError) {
        // A bad subscriber must not block the others — but don't silently eat
        // the bug. Surface in dev via console.error so the consumer can fix it.
        console.error('Font error subscriber threw:', subscriberError);
      }
    }
  } else {
    // No subscriber yet — fall back to console so the error is not silently
    // dropped during pre-mount or in non-adapter (headless / SSR) usage.
    console.warn(err.message);
  }
}

function faceKey(family: string, weight: number | string = 'normal'): string {
  return `${family.trim()}|${weight}`;
}

// "A genuine system font satisfies this family" — the decision behind every
// fetch skip. Self-excludes families registered through our face loaders
// (subsetted/partial faces — see registeredFamilies); system fonts that
// render are assumed weight-complete, since OS-bundled families ship full
// sets. The first positive probe also fires onFontsLoaded (microtask,
// matching the fetch path's async timing) — consumers gate ready-state UI
// on that callback, and before the skip existed the fetch guaranteed it.
function satisfiedBySystemFont(family: string): boolean {
  if (registeredFamilies.has(family)) {
    return false;
  }
  if (probeSatisfied.has(family)) {
    return true;
  }
  if (canRenderFont(family)) {
    probeSatisfied.add(family);
    queueMicrotask(() => notifyCallbacks([family]));
    return true;
  }
  return false;
}

// In-flight buffer/URL registrations for a family (loadingFaces is keyed
// `family|weight`).
function inFlightFacePromises(family: string): Promise<boolean>[] {
  const prefix = `${family}|`;
  const pending: Promise<boolean>[] = [];
  for (const [key, promise] of loadingFaces) {
    if (key.startsWith(prefix)) {
      pending.push(promise);
    }
  }
  return pending;
}

// Shared success bookkeeping for the buffer/URL face loaders. One place, so
// the two loaders cannot drift (the next probeSatisfied-style cache line
// added to one but not the other would skew buffer vs URL faces silently).
function markFaceLoaded(key: string, family: string): void {
  loadedFaces.add(key);
  loadedFonts.add(family);
  notifyCallbacks([family]);
}

/**
 * Generate Google Fonts CSS URL for a font family
 *
 * @param fontFamily - The font family name (e.g., "Roboto", "Open Sans")
 * @param weights - Font weights to load (default: 400, 700)
 * @param styles - Font styles to load (default: normal, italic)
 * @returns Google Fonts CSS URL
 */
function getGoogleFontsUrl(
  fontFamily: string,
  weights: number[] = [400, 700],
  styles: ('normal' | 'italic')[] = ['normal', 'italic']
): string {
  // Encode font family name for URL
  const encodedFamily = encodeURIComponent(fontFamily);

  // Build weight/style combinations
  // Format: ital,wght@0,400;0,700;1,400;1,700
  const combinations: string[] = [];

  for (const style of styles) {
    const italVal = style === 'italic' ? 1 : 0;
    for (const weight of weights) {
      combinations.push(`${italVal},${weight}`);
    }
  }

  // Sort and join
  combinations.sort();
  const spec = combinations.join(';');

  return `https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@${spec}&display=swap`;
}

/**
 * Load a font from Google Fonts
 *
 * @param fontFamily - The font family name to load
 * @param options - Optional configuration
 * @returns Promise resolving to true if font loaded successfully, false otherwise
 */
export async function loadFont(
  fontFamily: string,
  options?: {
    weights?: number[];
    styles?: ('normal' | 'italic')[];
  }
): Promise<boolean> {
  // Skip font loading in non-browser environments (Node.js, SSR)
  if (typeof document === 'undefined') {
    return false;
  }

  // Normalize font family name
  const normalizedFamily = fontFamily.trim();

  // Already loaded?
  if (loadedFonts.has(normalizedFamily)) {
    return true;
  }

  // Currently loading? Return existing promise
  const existingLoad = loadingFonts.get(normalizedFamily);
  if (existingLoad) {
    return existingLoad;
  }

  // Already satisfied by a system font — fetching the Google copy would be a
  // redundant round-trip (and a CSP violation in no-egress embedders). Only
  // taken for the default face set: explicit options.weights/options.styles
  // always fetch, because the local probe can only see that *a* face renders,
  // never that a specific weight exists (CSS font matching falls back to the
  // nearest weight instead of failing). Deliberately NOT recorded in
  // loadedFonts — the "Already loaded?" check above ignores options, so
  // recording it there would turn a later explicit-weights call into a silent
  // no-op. Synchronous on purpose: an await here would let concurrent
  // loadFont calls slip past the loadingFonts dedupe above.
  if (!options && satisfiedBySystemFont(normalizedFamily)) {
    return true;
  }

  // A buffer/URL registration for this family may be mid-flight (its
  // waitForFontAvailable can take up to 3s). Snapshot here, in the same
  // synchronous frame as the dedupe checks above.
  const pendingFaces = inFlightFacePromises(normalizedFamily);

  // Remote disabled, not locally satisfied, and no in-flight registration
  // that could change the answer — statically false. Skip the promise
  // machinery instead of re-paying it on every loadDocumentFonts pass.
  if (!googleFontsEnabled && pendingFaces.length === 0) {
    return false;
  }

  // Create load promise
  const loadPromise = (async (): Promise<boolean> => {
    isLoadingAny = true;

    try {
      // Settle in-flight registrations and re-check, so we neither issue the
      // redundant fetch this path exists to skip nor report a spurious
      // failure under googleFontsEnabled=false. Skipped when nothing was in
      // flight — the synchronous checks above already gave the answer.
      if (pendingFaces.length > 0) {
        await Promise.all(pendingFaces);
        if (loadedFonts.has(normalizedFamily)) {
          return true;
        }
        if (!options && satisfiedBySystemFont(normalizedFamily)) {
          return true;
        }
      }

      // Remote fetch disabled (no-egress embedder). The font is not locally
      // available, so don't inject a Google Fonts <link> — report failure and
      // let the caller's CSS fallback stack render with what is available.
      if (!googleFontsEnabled) {
        return false;
      }

      // Generate Google Fonts URL
      const url = getGoogleFontsUrl(normalizedFamily, options?.weights, options?.styles);

      // Create link element
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = url;

      // Wait for load or error, with a 5s timeout. Clear the timer once
      // settled so no handle dangles past the load (keeps test runners and
      // watch cycles from waiting on dead timers).
      const loaded = await new Promise<boolean>((resolve) => {
        const timer = setTimeout(() => resolve(false), 5000);
        link.onload = () => {
          clearTimeout(timer);
          resolve(true);
        };
        link.onerror = () => {
          clearTimeout(timer);
          resolve(false);
        };

        // Append to head
        document.head.appendChild(link);
      });

      if (loaded) {
        // Wait a bit for the font to be available
        await waitForFontAvailable(normalizedFamily, 3000);

        loadedFonts.add(normalizedFamily);

        // Notify callbacks
        notifyCallbacks([normalizedFamily]);

        return true;
      }

      return false;
    } catch (error) {
      reportFontError(error, `failed to load "${normalizedFamily}"`);
      return false;
    } finally {
      loadingFonts.delete(normalizedFamily);

      // Check if still loading any fonts (Google or face-based)
      if (loadingFonts.size === 0 && loadingFaces.size === 0) {
        isLoadingAny = false;
      }
    }
  })();

  loadingFonts.set(normalizedFamily, loadPromise);
  return loadPromise;
}

/**
 * Load multiple fonts from Google Fonts
 *
 * @param families - Array of font family names to load
 * @param options - Optional configuration
 * @returns Promise resolving when all fonts are loaded (or failed)
 */
export async function loadFonts(
  families: string[],
  options?: {
    weights?: number[];
    styles?: ('normal' | 'italic')[];
  }
): Promise<void> {
  // Filter out already loaded fonts
  const toLoad = families.filter((family) => !loadedFonts.has(family.trim()));

  if (toLoad.length === 0) {
    return;
  }

  // Load all fonts in parallel
  await Promise.all(toLoad.map((family) => loadFont(family, options)));
}

/**
 * Check if a font is loaded
 *
 * @param fontFamily - The font family name to check
 * @returns true if the font is loaded, false otherwise
 */
export function isFontLoaded(fontFamily: string): boolean {
  // Probe-satisfied system fonts count as available: onFontsLoaded announces
  // them, so the read API must agree. (loadFont's internal dedupe reads
  // loadedFonts directly — the split only matters there, where a
  // probe-satisfied family must still honor explicit weight requests.)
  const family = fontFamily.trim();
  return loadedFonts.has(family) || probeSatisfied.has(family);
}

/**
 * Enable or disable the editor's automatic Google Fonts lookup.
 *
 * Defaults to enabled. Set to `false` in no-egress embedders (strict CSP,
 * offline) so `loadFont` / `loadFontWithMapping` never inject a
 * `fonts.googleapis.com` stylesheet `<link>`. This gates ONLY the implicit
 * Google Fonts lookup — font URLs you register yourself (the `fonts` prop /
 * `loadFontFromUrl`) are still fetched by the browser, and embedded blobs
 * (`loadFontFromBuffer`) and system fonts still resolve.
 *
 * The flag is page-global (module-level), not per-editor: with multiple
 * editors on one page the last caller wins. Call it before loading documents;
 * disabling does not cancel fetches already in flight. A document font that
 * is neither local nor registered then resolves `loadFont` to `false`
 * silently (no `onFontError`) and renders via its CSS fallback stack.
 *
 * @see isGoogleFontsEnabled
 * @see loadFontFromBuffer
 * @see loadFontFromUrl
 *
 * @public
 */
export function setGoogleFontsEnabled(enabled: boolean): void {
  googleFontsEnabled = enabled;
}

/**
 * Whether the automatic Google Fonts lookup is currently enabled.
 *
 * @see setGoogleFontsEnabled
 *
 * @public
 */
export function isGoogleFontsEnabled(): boolean {
  return googleFontsEnabled;
}

/**
 * Check if any fonts are currently loading
 *
 * @returns true if any fonts are loading, false otherwise
 */
export function isLoading(): boolean {
  return isLoadingAny;
}

/**
 * Get list of all loaded fonts
 *
 * @returns Array of loaded font family names
 */
export function getLoadedFonts(): string[] {
  return Array.from(new Set([...loadedFonts, ...probeSatisfied]));
}

/**
 * Register a callback to be notified when fonts are loaded
 *
 * @param callback - Function to call when fonts are loaded
 * @returns Cleanup function to remove the callback
 */
export function onFontsLoaded(callback: (fonts: string[]) => void): () => void {
  loadCallbacks.add(callback);

  // Return cleanup function
  return () => {
    loadCallbacks.delete(callback);
  };
}

/**
 * Notify all registered callbacks
 */
function notifyCallbacks(fonts: string[]): void {
  for (const callback of loadCallbacks) {
    try {
      callback(fonts);
    } catch (error) {
      reportFontError(error, 'load callback threw');
    }
  }
}

/**
 * Register a callback to be notified when a font fails to load.
 *
 * Adapters subscribe and forward to their `onError` prop. Returns the unsub.
 *
 * @public
 */
export function onFontError(callback: (error: Error) => void): () => void {
  errorCallbacks.add(callback);
  return () => {
    errorCallbacks.delete(callback);
  };
}

/**
 * Wait for a font to be available using the CSS Font Loading API
 *
 * @param fontFamily - The font family to wait for
 * @param timeout - Maximum time to wait in milliseconds
 * @returns Promise resolving when font is available or timeout
 */
async function waitForFontAvailable(fontFamily: string, timeout: number): Promise<boolean> {
  // Use CSS Font Loading API if available
  if ('fonts' in document) {
    try {
      // Try to wait for the font
      const fontFace = `400 16px "${fontFamily}"`;
      await Promise.race([
        document.fonts.load(fontFace),
        new Promise((resolve) => setTimeout(resolve, timeout)),
      ]);

      return document.fonts.check(fontFace);
    } catch {
      // Fall through to fallback
    }
  }

  // Fallback: just wait a bit
  await new Promise((resolve) => setTimeout(resolve, 100));
  return true;
}

// Probe scratchpad. The probe runs once per family per document load, so a
// fresh canvas per call is pure waste — one shared context suffices, and the
// fallback-font widths ('72px sans-serif' over a fixed string) are session
// constants worth memoizing. Reset by __resetFontLoaderState (tests swap the
// global document, which would otherwise leave a stale context behind).
let probeContext: CanvasRenderingContext2D | null | undefined;
const fallbackWidthCache = new Map<string, number>();
const PROBE_TEXT = 'abcdefghijklmnopqrstuvwxyz0123456789';

function getProbeContext(): CanvasRenderingContext2D | null {
  if (probeContext === undefined) {
    probeContext = document.createElement('canvas').getContext('2d');
    if (probeContext) {
      probeContext.textBaseline = 'top';
    }
  }
  return probeContext;
}

/**
 * Check if a font is available on the system using canvas measurement
 *
 * Compares text width with the target font vs a known fallback font (and the
 * opposite fallback, for names that collide with the browser defaults). If
 * the widths differ, the font is available. Reuses one shared canvas; the
 * fallback widths are session constants and memoized.
 *
 * @param fontFamily - The font family name to check
 * @param fallbackFont - Fallback font to compare against
 * @returns true if font is available, false otherwise
 */
export function canRenderFont(fontFamily: string, fallbackFont: string = 'sans-serif'): boolean {
  // Skip if we're not in a browser
  if (typeof document === 'undefined') {
    return false;
  }

  const ctx = getProbeContext();
  if (!ctx) {
    return false;
  }

  const measure = (font: string): number => {
    ctx.font = font;
    return ctx.measureText(PROBE_TEXT).width;
  };

  // If widths differ, the custom font (not the fallback) was used.
  const differsFrom = (fallback: string): boolean => {
    let fallbackWidth = fallbackWidthCache.get(fallback);
    if (fallbackWidth === undefined) {
      fallbackWidth = measure(`72px ${fallback}`);
      fallbackWidthCache.set(fallback, fallbackWidth);
    }
    return measure(`72px "${fontFamily}", ${fallback}`) !== fallbackWidth;
  };

  // Check with the primary fallback, then the opposite one (handles the edge
  // case where the font name matches the browser's default sans-serif/serif).
  return (
    differsFrom(fallbackFont) || differsFrom(fallbackFont === 'sans-serif' ? 'serif' : 'sans-serif')
  );
}

/**
 * Load a font from a raw buffer (e.g., embedded in DOCX)
 *
 * Call before loading the document when possible: registration marks the
 * family as ours, which is what keeps the metric-compatible Google fallback
 * in play for subsetted faces during the document's font resolution pass.
 * Late registration still self-heals on the next pass. Under
 * `setGoogleFontsEnabled(false)` that fallback fetch is suppressed and
 * glyphs missing from the registered faces render via the CSS stack.
 *
 * @param fontFamily - The font family name
 * @param buffer - Font file buffer (TTF, OTF, WOFF, WOFF2)
 * @param options - Font options
 * @returns Promise resolving when font is loaded
 */
export async function loadFontFromBuffer(
  fontFamily: string,
  buffer: ArrayBuffer,
  options?: {
    weight?: number | string;
  }
): Promise<boolean> {
  if (typeof document === 'undefined') return false;

  const normalizedFamily = fontFamily.trim();
  const key = faceKey(normalizedFamily, options?.weight);

  // Provenance: mark before the async work so an in-flight registration
  // already counts as registered (see loadFontWithMapping). Marking a family
  // that then fails to load only means we keep fetching its Google
  // equivalent — the safe direction.
  registeredFamilies.add(normalizedFamily);

  // Face-keyed dedupe so multiple weights of the same family register
  // independently and a prior URL/Google load of the family does not skip
  // this face.
  if (loadedFaces.has(key)) return true;
  const existing = loadingFaces.get(key);
  if (existing) return existing;

  const loadPromise = (async (): Promise<boolean> => {
    isLoadingAny = true;
    try {
      const blob = new Blob([buffer], { type: 'font/ttf' });
      const url = URL.createObjectURL(blob);

      const style = document.createElement('style');
      style.textContent = `
      @font-face {
        font-family: "${normalizedFamily}";
        src: url(${url}) format('truetype');
        font-weight: ${options?.weight ?? 'normal'};
        font-display: swap;
      }
    `;
      document.head.appendChild(style);

      await waitForFontAvailable(normalizedFamily, 3000);

      markFaceLoaded(key, normalizedFamily);

      return true;
    } catch (error) {
      reportFontError(error, `failed to load "${normalizedFamily}" from buffer`);
      return false;
    } finally {
      loadingFaces.delete(key);
      if (loadingFonts.size === 0 && loadingFaces.size === 0) {
        isLoadingAny = false;
      }
    }
  })();

  loadingFaces.set(key, loadPromise);
  return loadPromise;
}

function guessFontFormat(src: string): string {
  const url = src.split('?')[0].split('#')[0].toLowerCase();
  if (url.endsWith('.woff2')) return 'woff2';
  if (url.endsWith('.woff')) return 'woff';
  if (url.endsWith('.otf')) return 'opentype';
  return 'truetype';
}

/**
 * Load a font face from a URL (woff2, woff, ttf, otf).
 *
 * Injects an `@font-face` rule pointing at the URL. Multiple weights of the
 * same family can be registered independently. Families registered here are
 * treated as potentially subsetted: the editor still fetches their
 * metric-compatible Google equivalent as a glyph-coverage fallback, unless
 * disabled via `setGoogleFontsEnabled(false)`.
 *
 * @param fontFamily - CSS font-family name to expose
 * @param src - URL to the font file
 * @param options - Optional weight
 * @returns Promise resolving to true if the face became available
 *
 * @public
 */
export async function loadFontFromUrl(
  fontFamily: string,
  src: string,
  options?: {
    weight?: number | string;
  }
): Promise<boolean> {
  if (typeof document === 'undefined') return false;

  // Reject URLs containing HTML-breaking characters. The loader writes src
  // into a <style> element's textContent — safe for client rendering, but
  // a serialized document.head (SSR, devtools snapshot) would terminate the
  // style block early on </style>. < and > are never valid in a URL anyway.
  if (/[<>]/.test(src)) {
    reportFontError(
      new Error(`invalid src URL for "${fontFamily}": contains '<' or '>'`),
      'rejected src'
    );
    return false;
  }

  const normalizedFamily = fontFamily.trim();
  const key = faceKey(normalizedFamily, options?.weight);

  // Provenance: subsetted-face safety net — see registeredFamilies.
  registeredFamilies.add(normalizedFamily);

  if (loadedFaces.has(key)) return true;
  const existing = loadingFaces.get(key);
  if (existing) return existing;

  const loadPromise = (async (): Promise<boolean> => {
    isLoadingAny = true;
    try {
      const style = document.createElement('style');
      style.textContent = `
      @font-face {
        font-family: "${normalizedFamily}";
        src: url(${JSON.stringify(src)}) format('${guessFontFormat(src)}');
        font-weight: ${options?.weight ?? 'normal'};
        font-display: swap;
      }
    `;
      document.head.appendChild(style);

      await waitForFontAvailable(normalizedFamily, 3000);

      markFaceLoaded(key, normalizedFamily);

      return true;
    } catch (error) {
      reportFontError(error, `failed to load "${normalizedFamily}" from ${src}`);
      return false;
    } finally {
      loadingFaces.delete(key);
      if (loadingFonts.size === 0 && loadingFaces.size === 0) {
        isLoadingAny = false;
      }
    }
  })();

  loadingFaces.set(key, loadPromise);
  return loadPromise;
}

/**
 * Declarative description of a single font face to register with the editor.
 *
 * Each entry injects one `@font-face` rule pointing at a URL. Multiple
 * entries can share `family` to register distinct weights as separate faces.
 *
 * For Google Fonts, call `loadFont(family)` directly — the `fonts` prop is
 * for fonts the consumer hosts themselves. For raw bytes already in memory
 * (DOCX-embedded fonts, user uploads), call `loadFontFromBuffer(family, buf)`.
 *
 * @public
 */
export interface FontDefinition {
  /**
   * CSS `font-family` name to expose. Match the family name your documents
   * reference; the browser uses this to look up glyphs when text is rendered.
   */
  family: string;
  /**
   * URL to the font file (woff2, woff, ttf, or otf). The loader injects an
   * `@font-face` rule and lets the browser fetch on demand.
   */
  src: string;
  /**
   * CSS `font-weight` for this face. Defaults to `'normal'` (≈400). Pass a
   * number (`400`, `700`) or a CSS keyword (`'bold'`). Required when one
   * `family` registers multiple weights as separate entries.
   */
  weight?: number | string;
}

/**
 * Register a list of custom font faces. Used by the `fonts` prop on
 * `<DocxEditor>` (React + Vue). Idempotent — safe to call on every render.
 *
 * @public
 */
export async function loadFontDefinitions(
  defs: ReadonlyArray<FontDefinition> | undefined
): Promise<void> {
  if (!defs || defs.length === 0) return;
  await Promise.all(
    defs.map((def) => loadFontFromUrl(def.family, def.src, { weight: def.weight }))
  );
}

/**
 * Mapping from common Office/system fonts to Google Fonts equivalents
 *
 * Google Fonts doesn't have exact matches for many Microsoft fonts,
 * but these are close alternatives that work well for document rendering.
 */
export const FONT_MAPPING: Record<string, string> = {
  // Microsoft Office fonts → Google Fonts equivalents
  Calibri: 'Carlito',
  Cambria: 'Caladea',
  Arial: 'Arimo',
  'Times New Roman': 'Tinos',
  'Courier New': 'Cousine',
  Garamond: 'EB Garamond',
  'Book Antiqua': 'EB Garamond',
  Georgia: 'Tinos',
  Verdana: 'Open Sans',
  Tahoma: 'Open Sans',
  'Trebuchet MS': 'Source Sans Pro',
  'Century Gothic': 'Poppins',
  'Franklin Gothic': 'Libre Franklin',
  Palatino: 'EB Garamond',
  'Palatino Linotype': 'EB Garamond',
  'Lucida Sans': 'Open Sans',
  'Segoe UI': 'Open Sans',
  Impact: 'Anton',
  'Comic Sans MS': 'Comic Neue',
  Consolas: 'Inconsolata',
  'Lucida Console': 'Inconsolata',
  Monaco: 'Fira Code',
};

/**
 * Get the Google Fonts equivalent for a font name
 *
 * @param fontName - The original font name from the document
 * @returns The Google Fonts equivalent, or the original name if no mapping exists
 */
export function getGoogleFontEquivalent(fontName: string): string {
  const trimmed = fontName.trim();
  return FONT_MAPPING[trimmed] || trimmed;
}

/**
 * Load a font, automatically mapping to Google Fonts equivalent if needed.
 * If the font needs mapping, also creates a CSS alias so the original font
 * name works in stylesheets.
 *
 * @param fontFamily - The font family name (may be an Office font)
 * @returns Promise resolving to true if font loaded
 */
export async function loadFontWithMapping(fontFamily: string): Promise<boolean> {
  const trimmed = fontFamily.trim();
  const googleFont = getGoogleFontEquivalent(trimmed);

  // Load the Google Font under its own name (no aliasing).
  // The font resolver provides CSS fallback stacks that list both the
  // original DOCX font and the Google equivalent, so the browser will
  // use whichever is available without @font-face aliasing that would
  // hijack Canvas measurements.
  if (googleFont !== trimmed) {
    // A genuine system-installed original has full glyph coverage, so the
    // metric-approximate Google equivalent would never be consulted by the
    // fallback stack — skip its fetch. Registered (subsetted) families keep
    // it as the glyph-coverage safety net — see registeredFamilies. The
    // isFontLoaded leg covers "equivalent already fetched on an earlier
    // pass"; satisfiedBySystemFont self-excludes registered families.
    if (
      (!registeredFamilies.has(trimmed) && isFontLoaded(trimmed)) ||
      satisfiedBySystemFont(trimmed)
    ) {
      return true;
    }
    const result = await loadFont(googleFont);
    if (result) {
      loadedFonts.add(trimmed);
    }
    return result;
  }

  // No mapping needed, load directly
  return loadFont(googleFont);
}

/**
 * Test-only: reset every piece of module-level loader state. fontLoader is a
 * module singleton, and `bun test` runs all files in one process — without a
 * reset, family names cached by one test file leak into every later one.
 *
 * Not re-exported from the package entry; import from this module directly.
 *
 * @internal
 */
export function __resetFontLoaderState(): void {
  loadedFonts.clear();
  loadingFonts.clear();
  loadedFaces.clear();
  loadingFaces.clear();
  loadCallbacks.clear();
  errorCallbacks.clear();
  registeredFamilies.clear();
  probeSatisfied.clear();
  fallbackWidthCache.clear();
  probeContext = undefined;
  googleFontsEnabled = true;
  isLoadingAny = false;
}

/**
 * Load multiple fonts with automatic mapping to Google Fonts equivalents
 *
 * @param families - Array of font family names
 * @returns Promise resolving when all fonts are loaded
 */
export async function loadFontsWithMapping(families: string[]): Promise<void> {
  // Remove duplicates
  const uniqueFonts = [...new Set(families.map((f) => f.trim()))];
  // Load each font with mapping (creates aliases for Office → Google font mappings)
  await Promise.all(uniqueFonts.map((family) => loadFontWithMapping(family)));
}

/**
 * Preload a list of common document fonts
 *
 * This preloads fonts commonly used in DOCX documents that have
 * Google Fonts equivalents.
 */
export async function preloadCommonFonts(): Promise<void> {
  const commonFonts = [
    'Carlito', // Calibri equivalent
    'Caladea', // Cambria equivalent
    'Arimo', // Arial equivalent
    'Tinos', // Times New Roman equivalent
    'Cousine', // Courier New equivalent
    'EB Garamond', // Garamond equivalent
  ];

  await loadFonts(commonFonts);
}

/**
 * Extract all font families used in a document
 *
 * Uses loose typing to handle any document-like structure.
 *
 * @param document - The parsed document
 * @returns Set of unique font family names
 */
export function extractFontsFromDocument(document: unknown): Set<string> {
  const fonts = new Set<string>();

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const doc = document as any;
  if (!doc?.package) return fonts;

  // Extract from document content
  const content = doc.package?.document?.content;
  if (Array.isArray(content)) {
    for (const paragraph of content) {
      if (paragraph?.type === 'paragraph' && Array.isArray(paragraph.content)) {
        for (const run of paragraph.content) {
          if (run?.type === 'run' && run.formatting?.fontFamily) {
            const { ascii, hAnsi } = run.formatting.fontFamily;
            if (ascii) fonts.add(ascii);
            if (hAnsi && hAnsi !== ascii) fonts.add(hAnsi);
          }
        }
      }
    }
  }

  // Extract from styles
  const styles = doc.package?.styles?.styles;
  if (Array.isArray(styles)) {
    for (const style of styles) {
      if (style?.runProperties?.fontFamily) {
        const { ascii, hAnsi } = style.runProperties.fontFamily;
        if (ascii) fonts.add(ascii);
        if (hAnsi && hAnsi !== ascii) fonts.add(hAnsi);
      }
    }
  }

  return fonts;
}

/**
 * Extract fonts from a document and load them from Google Fonts
 *
 * @param document - The parsed document
 * @returns Promise resolving when fonts are loaded
 */
export async function loadDocumentFonts(document: unknown): Promise<void> {
  const fonts = extractFontsFromDocument(document);

  if (fonts.size === 0) {
    return;
  }

  // Loading document fonts
  await loadFontsWithMapping(Array.from(fonts));
}
