/**
 * FontAsset — a user-provided font face stored once and reused across themes and
 * snippets (docs/chart-theming-scope.md §4 → User font upload).
 *
 * Portable core: record shape, factory, format detection, family-name
 * derivation, and variable-font (`fvar`) parsing. The raw bytes
 * live in `data` (an `ArrayBuffer`, stored verbatim by IndexedDB's structured
 * clone — `ArrayBuffer` is a platform global, not a DOM API, so this stays
 * browser-free). Registering the face as a `FontFace` and persisting it are
 * infrastructure concerns; this module never touches `document`/`indexedDB`.
 *
 * A font is referenced by its `family` — the CSS family name written into a
 * config's `font`/`*Font` slots — exactly as a dataset is referenced by name.
 * The family is unique across the font library (case-insensitive, via `naming`),
 * so a single family maps to a single uploaded face.
 *
 * `source` records provenance so later tiers reuse this one store: `file` is an
 * uploaded file; `google` (reserved) is a face fetched and cached from the
 * keyless Google Fonts catalog. Either way the bytes live in `data`, so export
 * and the SVG embed treat every FontAsset the same regardless of source.
 */

/** A FontAsset record's schema version (read-time migration target). */
export const CURRENT_FONT_VERSION = 1;

/** Supported font container formats, keyed by file extension. */
export type FontFormat = 'woff2' | 'woff' | 'ttf' | 'otf';

/**
 * One variation axis of a variable font (the OpenType `fvar` table), e.g.
 * `wght` (weight) or `wdth` (width). Stored on a FontAsset so registration can
 * declare the right `FontFace` ranges and the UI can flag the face as variable.
 */
export interface FontAxis {
  /** 4-char OpenType axis tag, e.g. `wght`, `wdth`, `opsz`. */
  tag: string;
  min: number;
  default: number;
  max: number;
}

/** Where an uploaded face came from. Only `file` ships today; `google` is reserved. */
export type FontSource = 'file' | 'google';

/**
 * Soft cap on a single uploaded face, in bytes. A Latin display woff2 is tens of
 * KB; this 10 MB ceiling admits a full TrueType (even a small CJK face) while
 * refusing a pathological upload that would bloat IndexedDB. Enforced at the
 * upload boundary (`services/fonts`), surfaced as a clear error — not silently here.
 */
export const MAX_FONT_BYTES = 10 * 1024 * 1024;

export interface FontAsset {
  /** Unique numeric identifier (IndexedDB key). */
  id: number;
  /** Record schema version, for read-time migration. */
  version: number;
  /** Unique, human-readable CSS family name — the key configs reference. */
  family: string;
  /** The raw font-file bytes (stored verbatim; registered as a `FontFace`). */
  data: ArrayBuffer;
  /** Container format, from the file extension. */
  format: FontFormat;
  /** Original file name, kept for display and provenance. */
  fileName: string;
  /** Provenance: an uploaded file, or (reserved) a cached Google face. */
  source: FontSource;
  /**
   * Variation axes if this is a variable font (parsed from `fvar` at upload),
   * else absent/empty for a static face. Drives the `FontFace` weight/width
   * ranges so one variable file serves the whole weight range.
   */
  axes?: FontAxis[];
  /** Byte length of `data`. */
  size: number;
  /** ISO timestamp — when first added. */
  created: string;
  /** ISO timestamp — when last changed (e.g. renamed). */
  modified: string;
}

/** Map each accepted file extension to its container format. */
const EXTENSION_FORMAT: Record<string, FontFormat> = {
  woff2: 'woff2',
  woff: 'woff',
  ttf: 'ttf',
  otf: 'otf',
};

/**
 * The container format for a file name, or `null` when the extension isn't a
 * supported font format. Extension-based on purpose: font MIME types are
 * inconsistent across browsers and OSes (`.ttf` arrives as `font/ttf`,
 * `application/x-font-ttf`, `application/octet-stream`, or `''`), so the
 * extension is the reliable signal.
 */
export function detectFontFormat(fileName: string): FontFormat | null {
  const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
  return EXTENSION_FORMAT[ext] ?? null;
}

/**
 * A clean default family name derived from a file name: drop the extension,
 * turn `-`/`_` separators into spaces, collapse whitespace. `Inter-Regular.ttf`
 * → `Inter Regular`. A starting point the user can rename; never authoritative.
 */
export function familyNameFromFileName(fileName: string): string {
  const base = fileName.replace(/\.[^.]+$/, '');
  const cleaned = base.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
  return cleaned || 'Custom font';
}

export interface CreateFontAssetOptions {
  /** The CSS family name (uniqueness enforced upstream — see `naming`). */
  family: string;
  /** The raw font-file bytes. */
  data: ArrayBuffer;
  /** Container format. */
  format: FontFormat;
  /** Original file name. */
  fileName: string;
  /** Provenance; defaults to `file`. */
  source?: FontSource;
  /** Variation axes (parsed from `fvar`) when the file is a variable font. */
  axes?: FontAxis[];
  /** Clock injection for deterministic tests; defaults to the current time. */
  now?: Date;
  /** Id injection for deterministic tests; defaults to `Date.now()`. */
  id?: number;
}

/**
 * Build a FontAsset: stamps version/timestamps and computes `size`. The default
 * id is provisional — the store's id authority reassigns it on insert (same
 * contract as `createDataset`/`createCustomTheme`).
 */
export function createFontAsset(options: CreateFontAssetOptions): FontAsset {
  const iso = (options.now ?? new Date()).toISOString();
  return {
    id: options.id ?? Date.now(),
    version: CURRENT_FONT_VERSION,
    family: options.family,
    data: options.data,
    format: options.format,
    fileName: options.fileName,
    source: options.source ?? 'file',
    ...(options.axes && options.axes.length > 0 ? { axes: options.axes } : {}),
    size: options.data.byteLength,
    created: iso,
    modified: iso,
  };
}

/**
 * The CSS font stack written into a config for an uploaded family: the quoted
 * family plus a generic fallback. The upload tier doesn't know a face's category
 * (serif/sans/mono), so `sans-serif` is the neutral last resort if the face
 * itself fails to load. Mirrors the roster's stacks in `THEME_FONT_OPTIONS`.
 */
export function fontFamilyStack(family: string): string {
  return `"${family}", sans-serif`;
}

/**
 * The primary family of a CSS font stack — the first comma-segment with any
 * surrounding quotes stripped. `'"My Brand", sans-serif'` → `My Brand`;
 * `'system-ui, sans-serif'` → `system-ui`. Used to match a config's font slot
 * (which holds a whole stack — see `fontFamilyStack`/`THEME_FONT_OPTIONS`) back
 * to a `FontAsset.family`, so only uploaded faces (never the roster/system
 * stacks) are embedded on export.
 */
export function primaryFamilyName(stack: string): string {
  const first = stack.split(',')[0]?.trim() ?? '';
  return first.replace(/^["']|["']$/g, '').trim();
}

// --- Serialization & data URIs (base64) ------------------------------------
//
// `btoa`/`atob` are platform globals (HTML/WHATWG, present in browsers and Node
// alike — same standing as `crypto.randomUUID`/`ArrayBuffer` used elsewhere in
// core), so font bytes serialize without reaching for a DOM API. The chunked
// `fromCharCode` keeps a multi-MB face under the argument-count limit.

/** Container-format → CSS `@font-face` MIME type, for an embedded `src` URL. */
const FONT_MIME: Record<FontFormat, string> = {
  woff2: 'font/woff2',
  woff: 'font/woff',
  ttf: 'font/ttf',
  otf: 'font/otf',
};

/** Base64-encode raw font bytes (32 KB chunks to stay under the spread limit). */
function bytesToBase64(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  const CHUNK = 0x8000;
  for (let i = 0; i < bytes.length; i += CHUNK) {
    binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
  }
  return btoa(binary);
}

/** Decode base64 back to raw font bytes. Throws on malformed input (caller guards). */
function base64ToBytes(base64: string): ArrayBuffer {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes.buffer;
}

/** A `data:` URL embedding a face's bytes — the `src` for an `@font-face` rule. */
export function fontDataUri(asset: FontAsset): string {
  return `data:${FONT_MIME[asset.format]};base64,${bytesToBase64(asset.data)}`;
}

/**
 * A FontAsset as it travels in the §08 export envelope (JSON): identical to the
 * stored record except `data` is base64 (an `ArrayBuffer` can't be JSON-encoded).
 */
export interface SerializedFontAsset extends Omit<FontAsset, 'data'> {
  /** Base64-encoded font bytes. */
  data: string;
}

/** Whether a value is one of the supported container formats. */
function isFontFormat(value: unknown): value is FontFormat {
  return value === 'woff2' || value === 'woff' || value === 'ttf' || value === 'otf';
}

/** Encode a stored FontAsset for the export envelope (base64 its bytes). */
export function serializeFontAsset(font: FontAsset): SerializedFontAsset {
  const { data, ...rest } = font;
  return { ...rest, data: bytesToBase64(data) };
}

/**
 * Decode one envelope font record back onto the current `FontAsset` shape, or
 * `null` when it's unusable (missing/empty `family` or `data`, or `data` that
 * isn't valid base64) — the importer skips a `null` rather than abort. Like
 * `normalizeCustomTheme` this is gap-filling, not foreign-shape mapping: fonts
 * only travel inside Astrolabe envelopes. `size` is re-derived from the decoded
 * bytes (authoritative), `version` stamped current, and `id` left provisional
 * for the store's id authority to reassign.
 */
export function deserializeFontAsset(raw: unknown, opts: { now?: Date } = {}): FontAsset | null {
  if (typeof raw !== 'object' || raw === null) return null;
  const r = raw as Record<string, unknown>;

  const family = typeof r.family === 'string' && r.family.trim() !== '' ? r.family : null;
  const encoded = typeof r.data === 'string' && r.data !== '' ? r.data : null;
  if (family === null || encoded === null) return null;

  let data: ArrayBuffer;
  try {
    data = base64ToBytes(encoded);
  } catch {
    return null;
  }
  if (data.byteLength === 0) return null;

  const fileName = typeof r.fileName === 'string' ? r.fileName : family;
  const format = isFontFormat(r.format) ? r.format : (detectFontFormat(fileName) ?? 'ttf');
  const axes = Array.isArray(r.axes) ? (r.axes as FontAxis[]) : undefined;
  const created = typeof r.created === 'string' && r.created !== '' ? r.created : null;
  const modified = typeof r.modified === 'string' && r.modified !== '' ? r.modified : null;

  // Build the base for id/version/size/format/axes, then overlay the stored
  // timestamps *verbatim* — never round-tripped through `new Date()`, so a corrupt
  // non-ISO `created` can't throw and abort the import (mirrors normalizeCustomTheme).
  const base = createFontAsset({
    family,
    data,
    format,
    fileName,
    source: r.source === 'google' ? 'google' : 'file',
    ...(axes && axes.length > 0 ? { axes } : {}),
    now: opts.now,
  });
  return {
    ...base,
    created: created ?? base.created,
    modified: modified ?? created ?? base.modified,
  };
}

// --- Variable-font (fvar) parsing ------------------------------------------

const SFNT_TTF = 0x00010000; // TrueType outlines
const SFNT_OTTO = 0x4f54544f; // 'OTTO' — CFF (OpenType) outlines
const SFNT_TRUE = 0x74727565; // 'true' — legacy TrueType

/** Read a 4-byte tag as ASCII. */
function readTag(view: DataView, offset: number): string {
  return String.fromCharCode(
    view.getUint8(offset),
    view.getUint8(offset + 1),
    view.getUint8(offset + 2),
    view.getUint8(offset + 3),
  );
}

/** OpenType 16.16 fixed-point (big-endian) → number. */
function readFixed(view: DataView, offset: number): number {
  return view.getInt32(offset) / 65536;
}

/**
 * Parse a font's variation axes from its OpenType `fvar` table, returning `[]`
 * for a static font (or one we can't read).
 *
 * Only **uncompressed SFNT** (`ttf`/`otf`) is read directly. `woff`/`woff2` wrap
 * the table data in zlib/brotli compression we don't unpack, so those uploads
 * are treated as static (registered pinned) — a deliberate, safe fallback rather
 * than a failure. A malformed file likewise degrades to `[]`, not a throw: axis
 * detection is an enhancement, and the face still registers and renders.
 */
export function parseFontAxes(data: ArrayBuffer, format: FontFormat): FontAxis[] {
  if (format !== 'ttf' && format !== 'otf') return [];
  try {
    if (data.byteLength < 12) return [];
    const view = new DataView(data);
    const sfnt = view.getUint32(0);
    if (sfnt !== SFNT_TTF && sfnt !== SFNT_OTTO && sfnt !== SFNT_TRUE) return [];

    const numTables = view.getUint16(4);
    let fvar = -1;
    for (let i = 0; i < numTables; i++) {
      const rec = 12 + i * 16; // table directory: 16-byte records after the 12-byte header
      if (readTag(view, rec) === 'fvar') {
        fvar = view.getUint32(rec + 8); // record: tag, checksum, offset, length
        break;
      }
    }
    if (fvar < 0) return [];

    // fvar header: version(4) axesArrayOffset(2) reserved(2) axisCount(2)
    //              axisSize(2) instanceCount(2) instanceSize(2)
    const axesArrayOffset = view.getUint16(fvar + 4);
    const axisCount = view.getUint16(fvar + 8);
    const axisSize = view.getUint16(fvar + 10);
    const base = fvar + axesArrayOffset;

    const axes: FontAxis[] = [];
    for (let i = 0; i < axisCount; i++) {
      const a = base + i * axisSize; // axis record: tag(4) min(Fixed) default(Fixed) max(Fixed) flags(2) nameID(2)
      axes.push({
        tag: readTag(view, a),
        min: readFixed(view, a + 4),
        default: readFixed(view, a + 8),
        max: readFixed(view, a + 12),
      });
    }
    return axes;
  } catch {
    return [];
  }
}

/** Whether a face carries variation axes (i.e. it's a variable font). */
export function isVariableFont(axes: FontAxis[] | undefined): boolean {
  return (axes?.length ?? 0) > 0;
}

/**
 * `FontFace` descriptor ranges for a variable font so the browser resolves a
 * requested weight/width against the file's axes — one variable file then serves
 * the whole weight range, driven by the Type panel's weight controls. Empty for
 * a static font.
 *
 * Only `wght`→`weight` and `wdth`→`stretch` are mapped: those flow through the
 * CSS font shorthand Vega builds for canvas/SVG text, so they actually take
 * effect at render. Other axes (optical size, grade, custom) have no text-render
 * hook in Vega and would only pin at the registered default, so we don't pretend
 * to expose them. Declaring the `wdth` range also defaults the face to normal
 * width (100%) instead of a variable font's possibly-condensed default instance.
 */
export function variableFontDescriptors(axes: FontAxis[] | undefined): {
  weight?: string;
  stretch?: string;
} {
  const out: { weight?: string; stretch?: string } = {};
  const wght = axes?.find((a) => a.tag === 'wght');
  if (wght) out.weight = `${wght.min} ${wght.max}`;
  const wdth = axes?.find((a) => a.tag === 'wdth');
  if (wdth) out.stretch = `${wdth.min}% ${wdth.max}%`;
  return out;
}
