import type { OcrPage } from "../../statements/ocr/ocr.provider";

/**
 * What one parsed policy page yields. All fields are nullable because each
 * provider prints a different subset (GMX's certificate has no premium
 * breakdown, only insured amounts; GMX's receipt page would carry the
 * premium), and the matcher + the review queue both work better with
 * "field was read" vs "field was not" rather than guessing.
 */
export interface ParsedPolicy {
  /** "GMX" | "ANA"; the dispatcher lives on `detectPolicyProvider`. */
  provider: string;
  /**
   * The `PolicyType.name` this document's product corresponds to — "AUTO",
   * "LICENCIAS", "MULT". A **name**, not an id: the parser is a pure function
   * over text and must not reach for the database, so the confirm step
   * resolves it (and leaves the field null if no such row exists).
   *
   * Null when the layout cannot tell. Guessing is worse than null here — the
   * type drives which renewal report a policy appears on.
   */
  policyTypeName: string | null;
  policyNumber: string | null;
  insuredName: string | null;
  additionalInsured: string | null;
  /** The "Broker" line on GMX — mapped onto `Policy.agentName`. */
  agentName: string | null;
  legalAddress: string | null;
  zip: string | null;
  policyFrom: Date | null;
  policyTo: Date | null;
  /** Signature/issue date — `Policy.policyDate`. */
  policyDate: Date | null;
  /** "MXN" | "USD" | …, derived from the printed currency word. */
  currency: string | null;
  netPremium: number | null;
  policyFee: number | null;
  brokerFee: number | null;
  /**
   * IVA, off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
   * premium at all, so there is no tax on it to read either.
   *
   * The adjacent `LOCAL TAX` cell is deliberately NOT folded in here. It is a
   * separate levy with no column of its own, and summing the two would report
   * an IVA figure that no longer divides back to a rate — the whole point of
   * storing it. It prints 0.00 on every policy seen so far and is surfaced as
   * a note when it is not.
   */
  tax: number | null;
  total: number | null;
  /** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */
  premiumPayment: string | null;
  /**
   * GMX prints per-coverage rows in a table: Building / Contents /
   * Earthquake / … with insured amount, deductible, loss participation.
   * Preserved verbatim so a missing premium receipt still leaves the
   * coverages auditable on the Policy row.
   */
  coverages: ParsedCoverage[];
  /**
   * Printed term length in days. GMX prints "Term 12 months" and the office
   * keys the default 365; ANA's tourist book sells 3- and 4-day policies and
   * prints the count in its own `DAYS` cell, so leaving `Policy
   * .coveragePeriodDays` at its 365 default would overstate a weekend policy
   * by a year.
   */
  coveragePeriodDays: number | null;
  /**
   * Vehicles listed on the policy face. Empty on the property lines (GMX
   * Hogar) and on ANA's driver's policy, which insures a person rather than
   * a car.
   */
  vehicles: ParsedVehicle[];
  /**
   * Named drivers. ANA's `DRIVER´S POLICY FOR AUTOMOBILE` prints up to five
   * slots and carries no vehicle at all — the drivers ARE the risk.
   */
  drivers: ParsedDriver[];
  /** Human-readable trail of what was read, surfaced in the review queue. */
  notes: string[];
}

export interface ParsedCoverage {
  /** "Building", "Contents", "Debris removal Building", "Earthquake…". */
  risk: string;
  insuredAmount: number | null;
  deductible: string | null;
  lossParticipation: string | null;
  /**
   * The per-coverage premium, when the layout prints one. GMX never does;
   * ANA prints a `PREMIUM` column for the add-on sections (legal aid,
   * roadside assistance, catastrophic liability), where the printed figure
   * is what the coverage COST, not what it pays out. Recording it as
   * `insuredAmount` would read as a $40 sum insured on the review screen.
   */
  premium?: number | null;
}

/** One row of ANA's `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table. */
export interface ParsedVehicle {
  /** "VEHICLE" | "TRAILER" | "TOWING" — the printed item slot. */
  item: string;
  modelYear: string | null;
  make: string | null;
  /** The printed BODY cell ("PACIFICA", "GENESIS SEDAN"). */
  bodyType: string | null;
  vinNumber: string | null;
  licensePlate: string | null;
}

export interface ParsedDriver {
  fullName: string;
  licenseNumber: string | null;
  address: string | null;
  phone: string | null;
  email: string | null;
}

// --- shared helpers ---------------------------------------------------------

const DIGIT_CONFUSIONS: Record<string, string> = {
  O: "0", o: "0", D: "0", I: "1", l: "1", "|": "1", S: "5", B: "8",
};

/**
 * Tesseract confuses these glyphs inside numeric runs with some regularity.
 * Same map and same caveat as the statement parser: ONLY apply to fields
 * known to be digits, never to free text.
 */
function toDigits(s: string | null | undefined): string {
  if (!s) return "";
  return s
    .split("")
    .map((c) => DIGIT_CONFUSIONS[c] ?? c)
    .join("")
    .replace(/\D/g, "");
}

/**
 * Parse a printed amount, treating `,` and `.` by position rather than by
 * assumption. Same algorithm as the statement parser — kept here so the
 * policy module is self-contained, since importing from `../../statements`
 * would couple two unrelated domains through a helper.
 */
function money(s: string | null | undefined): number | null {
  if (!s) return null;
  const cleaned = s.replace(/[\s$]/g, "");

  let m = cleaned.match(/^(\d{1,3}(?:[.,]\d{3})+)([.,]\d{1,2})?$/);
  if (m) {
    const whole = m[1].replace(/[.,]/g, "");
    const cents = m[2] ? m[2].slice(1) : "";
    return Number(cents ? `${whole}.${cents.padEnd(2, "0")}` : whole);
  }

  m = cleaned.match(/^(\d+)[.,](\d{2})$/);
  if (m) return Number(`${m[1]}.${m[2]}`);

  const n = Number(cleaned.replace(/[,.]/g, ""));
  return Number.isFinite(n) ? n : null;
}

function firstMatch(text: string, patterns: RegExp[]): string | null {
  for (const p of patterns) {
    const m = text.match(p);
    if (m?.[1]) return m[1].trim();
  }
  return null;
}

function allMatches(text: string, pattern: RegExp): string[] {
  const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
  const out: string[] = [];
  let m: RegExpExecArray | null;
  while ((m = re.exec(text)) !== null) {
    if (m[1]) out.push(m[1]);
    if (m.index === re.lastIndex) re.lastIndex++;
  }
  return out;
}

/**
 * The printed policy number, as a shape rather than as fixed widths.
 *
 * Both GMX document families print the same five dash-separated groups, but
 * NOT with the same widths: the English caratula reads
 * `007-037-07005947-0000-02` and the Spanish PVL especificación reads
 * `07-037-07006957-00000-01` — 2 digits in the first group and 5 in the
 * fourth. Pinning the widths (as the first version of this parser did) reads
 * one family and silently returns null on the other, which the matcher then
 * reports as "no se pudo leer el número de póliza" on a page where the number
 * is printed ten times.
 *
 * The OCR confusion class is allowed inside the groups but NOT normalised
 * here — `normalizePolicyNumber` does that, so the raw read stays available.
 */
const POLICY_NUMBER_SHAPE =
  /\b([0-9OIlSBD]{2,4}[-\s][0-9OIlSBD]{3}[-\s][0-9OIlSBD]{6,10}[-\s][0-9OIlSBD]{4,6}[-\s][0-9OIlSBD]{2})\b/;

/**
 * Strip layout whitespace and fold the Tesseract digit confusions, keeping the
 * dashes: they are part of the number as the office keys it, and
 * `PolicyMatcherService` matches `Policy.policyNumber` exactly.
 */
function normalizePolicyNumber(raw: string | null | undefined): string | null {
  if (!raw) return null;
  const out = raw
    .replace(/\s+/g, "")
    .split("")
    .map((c) => (c === "-" ? c : (DIGIT_CONFUSIONS[c] ?? c)))
    .join("");
  return out || null;
}

const MONTHS: Record<string, number> = {
  ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5,
  JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11,
};

/**
 * DD/MM/YYYY (GMX) and the dash-separated ISO variants. Two-digit years are
 * windowed: < 50 → 20YY, ≥ 50 → 19YY, matching what a 1950-2049 window
 * expects from a paper document.
 */
function parseDate(raw: string | null | undefined): Date | null {
  if (!raw) return null;
  const s = raw.trim();

  let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
  if (m) return utc(+m[3], +m[2] - 1, +m[1]);

  m = s.match(/^(\d{1,2})[-\s/]([A-Z]{3})[-\s/](\d{2,4})$/i);
  if (m && MONTHS[m[2].toUpperCase()] !== undefined) {
    const yr = +m[3];
    const y = m[3].length === 2 ? (yr < 50 ? 2000 + yr : 1900 + yr) : yr;
    return utc(y, MONTHS[m[2].toUpperCase()], +m[1]);
  }

  m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
  if (m) return utc(+m[1], +m[2] - 1, +m[3]);

  // "July 23, 2026" — the signature date on the GMX certificate.
  m = s.match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/);
  if (m) {
    const MONTH_NAMES: Record<string, number> = {
      january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
      july: 6, august: 7, september: 8, october: 9, november: 10, december: 11,
    };
    const mo = MONTH_NAMES[m[1].toLowerCase()];
    if (mo !== undefined) return utc(+m[3], mo, +m[2]);
  }

  return null;
}

function utc(y: number, mo: number, d: number): Date | null {
  const dt = new Date(Date.UTC(y, mo, d));
  return Number.isNaN(dt.getTime()) ? null : dt;
}

/** Map the printed currency word onto an ISO code. */
function currencyCode(raw: string | null | undefined): string | null {
  if (!raw) return null;
  const s = raw.trim().toUpperCase();
  if (s.startsWith("PESO") || s === "MXN" || s.includes("NACIONAL")) return "MXN";
  if (s.startsWith("DOLAR") || s === "USD" || s.includes("DOLLAR")) return "USD";
  if (s === "EUR" || s.includes("EURO")) return "EUR";
  return null;
}

// --- provider detection -----------------------------------------------------

/**
 * Brand first, layout as a fallback. Same ordering rule as the statement
 * parser: a brand wordmark is the cheapest, most reliable discriminator, and
 * a layout rule that runs first can wrongly claim a page that happens to
 * carry the same shape string (the statement parser's lesson with CFE vs
 * GAS on "PERIODO FACTURADO").
 */
const BRAND: [string, RegExp][] = [
  ["GMX", /\bGMX\b|Grupo\s*Mexicano\s*de\s*Seguros|gmx\.com\.mx|JUNTOS\s*EL\s*RIESGO\s*ES\s*MENOR/i],
  [
    "ANA",
    /A\.?N\.?A\.?\s*COMPA[ÑN][IÍ]A\s*DE\s*SEGUROS|anaseguros\.com\.mx|auto-insurance-ana\.com|une@anaseguros/i,
  ],
];

const LAYOUT: [string, RegExp][] = [
  [
    "GMX",
    /Multiple\s*Policy|IMPUESTO\s*PREDIAL[\s\S]{0,80}EN\s*FECHA|Material\s*damages\s*Section|PVL\s*Hogar|ESPECIFICACI[ÓO]N\s*QUE\s*SE\s*ADHIERE/i,
  ],
  [
    "ANA",
    /SPECIAL\s*POLICY\s*FOR\s*TOURISTS|DRIVER[´'`’]?S\s*POLICY\s*FOR\s*AUTOMOBILE|Tarjeta\s*de\s*Identificaci[óo]n\s*de\s*viajero/i,
  ],
];

export function detectPolicyProvider(text: string): string | null {
  for (const group of [BRAND, LAYOUT]) {
    for (const [name, pattern] of group) {
      if (pattern.test(text)) return name;
    }
  }
  return null;
}

// --- parsers ----------------------------------------------------------------

const PARSERS: Record<string, (page: OcrPage) => ParsedPolicy> = {
  GMX: parseGmx,
  ANA: parseAna,
};

/** Every field null / empty — the base each provider parser fills in. */
function emptyParsedPolicy(provider: string): ParsedPolicy {
  return {
    provider,
    policyTypeName: null,
    policyNumber: null,
    insuredName: null,
    additionalInsured: null,
    agentName: null,
    legalAddress: null,
    zip: null,
    policyFrom: null,
    policyTo: null,
    policyDate: null,
    currency: null,
    netPremium: null,
    policyFee: null,
    brokerFee: null,
    tax: null,
    total: null,
    premiumPayment: null,
    coverages: [],
    coveragePeriodDays: null,
    vehicles: [],
    drivers: [],
    notes: [],
  };
}

export function parsePolicy(page: OcrPage): ParsedPolicy {
  const provider = detectPolicyProvider(page.text);
  if (!provider) {
    return { ...emptyParsedPolicy(""), notes: ["no se reconoció el proveedor"] };
  }
  return PARSERS[provider](page);
}

// --- GMX --------------------------------------------------------------------

/**
 * GMX ships (at least) two unrelated document families, and the office
 * downloads both from the same portal for the same policy:
 *
 * 1. **Caratula / translation** (`…_Traduccion.pdf`) — the English-labelled
 *    certificate: a boxed header table (Policy / Insured / Broker / From / To
 *    / Currency) and a four-column coverage table.
 * 2. **Especificación** (`…-CondicionesParticulares.pdf`) — the Spanish
 *    "ESPECIFICACIÓN QUE SE ADHIERE Y FORMA PARTE INTEGRANTE DE LA PÓLIZA"
 *    issued by GMX's Punto de Venta en Línea (PVL). Ten pages of prose with
 *    NO header table, NO dates, NO broker, and the coverages expressed as
 *    section headings above a `Límite Máximo de Responsabilidad:` label.
 *
 * They share only the brand and the policy number, so they get separate
 * parsers behind one provider. Both still return `provider: "GMX"` — the
 * matcher keys on the policy number alone and must not care which artifact
 * the office happened to upload.
 */
/**
 * Both GMX documents describe the same product: the caratula's own header
 * reads "Multiple Policy / Home" and the especificación is "PVL Hogar". MULT
 * is the legacy discriminator for that multi-line home policy, and the live
 * row carrying 769 of them.
 *
 * Not INCENDIO: that row is fire-only and no policy in the book has ever
 * used it.
 */
const GMX_POLICY_TYPE = "MULT";

function parseGmx(page: OcrPage): ParsedPolicy {
  return isEspecificacion(page.text) ? parseGmxEspecificacion(page) : parseGmxCaratula(page);
}

/** The PVL especificación announces itself in its own repeated page header. */
function isEspecificacion(text: string): boolean {
  return /ESPECIFICACI[ÓO]N\s+QUE\s+SE\s+ADHIERE|PVL\s+Hogar|Nombre\s+del\s+asegurado/i.test(text);
}

/**
 * GMX policy certificate layout (this is the translation PDF — the Spanish
 * version is the canonical source, but every GMX portal download is a
 * translation so the parser can rely on these English labels).
 *
 * Page 1 carries the contract header in a single boxed table:
 *   Policy | Insured | Additional insured | Legal address | ZIP | Income Tax No.
 *   Broker | Term | From | To | Currency | Premium payment
 * followed by an "Agreed clauses" block, the signature date, and the GMX
 * letterhead.
 *
 * Page 2 carries the per-coverage table (Risk / Insured Amount / Deductible /
 * Loss Participation) under "Material damages Section" and "ADDITIONAL RISK".
 *
 * Premium / total / fees are NOT on the certificate page — they live on
 * GMX's separate "recibo" PDF. The parser leaves them null and flags the
 * gap in `notes`; the matcher still proposes a Policy update from the
 * certificate alone, and the staff confirm step fills premium in by hand
 * or after a follow-up receipt upload.
 */
function parseGmxCaratula(page: OcrPage): ParsedPolicy {
  const text = page.text;
  const notes: string[] = [];

  // ----- header table (page 1) --------------------------------------------
  // The Policy row repeats the number in a long run:
  //   "Policy 007-037-07005947-0000-02 in accordance with the enclosed clauses…"
  // so taking the first token-shaped number is correct; the trailing prose
  // never looks like one. The dashes are part of the printed number — keep
  // them (don't run toDigits, which would flatten them).
  const policyNumber = firstMatch(text, [
    new RegExp(`\\bPolicy\\s+${POLICY_NUMBER_SHAPE.source}`, "i"),
    /\bPolicy\s+([0-9OIlSBD][0-9OIlSBD\s-]{9,30})/,
  ]);

  // "Insured   JON ASHLEY STRABALA" — label, then 1+ whitespace, then the name.
  // Names can carry accents (ÁVILA) or apostrophes (O'NEILL); the label is
  // always upper-case English on this layout, so case is reliable.
  const insuredName = labelValue(text, /^Insured\s+([A-ZÁÉÍÓÚÑ'][A-ZÁÉÍÓÚÑ '\-.]+)$/m);
  const additionalInsured = labelValue(text, /^Additional\s+insured\s+([A-ZÁÉÍÓÚÑ '\-.]+)$/m);

  // Legal address is a single long line; the parser keeps it whole.
  const legalAddress = labelValue(text, /^Legal\s+address\s+(.+)$/m);
  let zip = labelValue(text, /^ZIP\s+(\d{4,6})\b/m);
  if (!zip && legalAddress) {
    // Last resort: zip often appears at the tail of the address run too
    // ("…C.P. 22550"). Cheap regex, no false-positive cost on this layout.
    const m = legalAddress.match(/\b(\d{5})\b(?!.*\b\d{5}\b)/);
    if (m) {
      zip = m[1];
      notes.push(`ZIP leído de la dirección (${m[1]})`);
    }
  }

  // Broker line on GMX: "(1176) Jorge Humberto Cuadros" — the number is the
  // agent code, the name is what lands on `Policy.agentName`. The parens
  // are optional: a future layout or scan drop them.
  const brokerRaw = labelValue(text, /^Broker\s+(?:\(\d+\)\s*)?(.+)$/m);
  const agentName = brokerRaw?.trim() ?? null;

  // Term: "12 months" — informational, not a free-standing date. Stored in
  // notes; the UI can derive `coveragePeriodDays` from From/To anyway.
  const term = firstMatch(text, [/^Term\s+(\d+\s+months?)$/m]);
  if (term) notes.push(`vigencia: ${term}`);

  const policyFrom = parseDate(
    labelValue(text, /^From\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m),
  );
  const policyTo = parseDate(
    firstMatch(text, [/^To\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m]),
  );

  // "at twelve hours (noon) Mexico City time." — kept in notes only.
  if (/twelve\s*hours|noon/i.test(text)) notes.push("vencimiento a las 12:00 hora del centro");

  // The Currency / Premium payment cells sit next to each other on one
  // line; pull them with bounded matches so the trailing label of the
  // adjacent cell doesn't swallow the wrong value.
  const currency = currencyCode(labelValue(text, /^Currency\s+(\S+?)(?:\s+Premium\s+payment|$)/m));
  const premiumPayment = labelValue(text, /Premium\s+payment\s+(\S+)$/m);

  // ----- signature date (page 1) -----------------------------------------
  // Appears above the signature line on its own: "July 23, 2026".
  const dateMatch = text.match(
    /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s*\d{4}\b/,
  );
  const policyDate = dateMatch ? parseDate(dateMatch[0]) : null;
  if (!policyDate) notes.push("no se pudo leer la fecha de firma");

  // ----- coverages table (page 2) -----------------------------------------
  const coverages = parseGmxCoverages(text, notes);

  if (!policyNumber) notes.push("no se pudo leer el número de póliza");
  if (!policyFrom || !policyTo) notes.push("no se pudo leer el período de vigencia");
  // Premium fields are expected to be missing on the certificate page; flag
  // it explicitly so the reviewer knows to look for a separate receipt.
  if (!text.match(/Prima\s*neta|net\s*premium/i)) {
    notes.push("esta página no trae prima; revisar el recibo de GMX por separado");
  }

  return {
    ...emptyParsedPolicy("GMX"),
    policyTypeName: GMX_POLICY_TYPE,
    policyNumber: normalizePolicyNumber(policyNumber),
    insuredName,
    additionalInsured,
    agentName,
    legalAddress,
    zip,
    policyFrom,
    policyTo,
    policyDate,
    currency,
    premiumPayment,
    coverages,
    // Derived from the dates, or left null so `Policy.coveragePeriodDays`
    // keeps its 365 default — which is the right answer for GMX, whose only
    // printed term is "12 months". Converting that term to a day count here
    // would just be a worse way of saying 365.
    coveragePeriodDays: daysBetween(policyFrom, policyTo),
    notes,
  };
}

/**
 * Whole days from `from` to `to`, or null when either is missing. Both are
 * UTC midnights out of `parseDate`, so the division is exact — no DST hour to
 * round away.
 */
function daysBetween(from: Date | null, to: Date | null): number | null {
  if (!from || !to) return null;
  const days = Math.round((to.getTime() - from.getTime()) / 86_400_000);
  return days > 0 ? days : null;
}

/**
 * Read the value that follows a `LABEL` on the same line. Used by every
 * "Label    Value" cell on the GMX header table — matches on the line
 * itself rather than across the page, so a label that also appears in body
 * text can't accidentally claim a different cell.
 */
function labelValue(text: string, pattern: RegExp): string | null {
  const m = text.match(pattern);
  if (!m?.[1]) return null;
  return m[1].replace(/\s+/g, " ").trim();
}

/**
 * Walk the GMX per-coverage table on page 2.
 *
 * Real sample row (single-line representation of the table after pdftotext
 * flattens it; the real layout uses fixed columns):
 *   "Building                  $350,000.00   Not applies   Not applies"
 *
 * The four columns are:
 *   Risk (left), Insured Amount ($ figure OR the word "Covered"),
 *   Deductible (free text — "Not applies", "5%", "2% of the sum insured…"),
 *   Loss Participation (same).
 *
 * "Covered" means the coverage is included with no dollar cap. We record
 * the word so the review queue surfaces it instead of inventing a number.
 *
 * Deductible / Loss Participation are kept as printed strings, not
 * converted to numbers — a "20%" loss participation is a different field
 * shape from a "$5,000" deductible and the JSON column lets the UI render
 * either verbatim.
 *
 * Multi-line cells (the "Earthquake" row's deductible wraps to three lines
 * because the column is narrow) are collapsed by joining consecutive
 * non-table-body lines onto the previous row's deductible cell before
 * applying the column regex.
 */
function parseGmxCoverages(text: string, notes: string[]): ParsedCoverage[] {
  const out: ParsedCoverage[] = [];

  // Stop at "VALUES ADDED" — the trailing prose section (homeowner
  // services, legal text) is not a coverage table. Re-enter at
  // "ADDITIONAL RISK" for the second coverage block on page 2.
  const segments = text.split(/VALUES\s*ADDED/i)[0].split(/ADDITIONAL\s*RISK/i);

  // `[ \t]` (not `\s`) inside a cell: the deductible/loss-participation
  // columns may wrap onto several lines in the raw `pdftotext` output, and
  // matching across newlines silently swallows the next row.
  const re = /^([A-Za-zÁÉÍÓÚÑ][A-Za-zÁÉÍÓÚÑ /\-.]+?)[ \t]+(\$[\d,.]+|Covered|Not[ \t]+applies)[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]*$/gim;
  let m: RegExpExecArray | null;
  for (const seg of segments) {
    re.lastIndex = 0;
    while ((m = re.exec(seg)) !== null) {
      const risk = m[1].trim();
      const amountCell = m[2].trim();
      const deductible = m[3].trim();
      const lossParticipation = m[4].trim();

      // Skip the "Risk / Insured Amount / Deductible / Loss Participation"
      // header row itself, which matches the same regex.
      if (/^Risk$/i.test(risk) && /Insured\s*Amount/i.test(amountCell)) continue;

      out.push({
        risk,
        insuredAmount:
          amountCell === "Covered" || amountCell === "Not applies"
            ? null
            : money(amountCell),
        deductible,
        lossParticipation,
      });
    }
  }

  if (out.length === 0) notes.push("no se encontraron coberturas en la tabla");
  return out;
}

// --- GMX / PVL especificación -----------------------------------------------

/**
 * Page furniture on the PVL especificación: the two-line header that repeats
 * on all ten pages (title + policy number on its own line) and the footer.
 * Skipped when hunting for a coverage's heading, or the heading found would
 * be the page break rather than the risk.
 */
const ESPEC_FURNITURE =
  /^(ESPECIFICACI[ÓO]N\s+QUE\s+SE\s+ADHIERE|PVL\s+Hogar\s*-\s*GMX|P[áa]gina\s*:|La\s+presente\s+p[óo]liza\s+queda\s+sujeta)/i;

/**
 * Labels that structure a coverage block rather than name one. A heading walk
 * that accepts these ends up calling every coverage "Deducible:".
 */
const ESPEC_STRUCTURAL =
  /^(L[íi]mites?\b|Deducibles?\b|Coaseguros?\b|Bienes\s+cubiertos|Riesgos\s+cubiertos|Riesgos\s+adicionales|Exclusiones?\b|Zona\s*:|Periodo\s+de\s+indemnizaci[óo]n|Nota\s+aclaratoria|CONDICIONES\s+ESPECIALES|IMPORTANTE\b|SECCI[ÓO]N\b|II+\.-|[●•]|\d+\.\s)/i;

/** `$200,000.00 USD`, `$ 35,050.00 M.N.` — amount plus its printed currency. */
const ESPEC_AMOUNT = /\$\s*([\d,]+(?:\.\d{2})?)\s*(USD|MXN|M\.?\s?N\.?|D[LlÓó]{1,3}[Ss]?)?/;

/** The label that introduces every insured amount on this layout. */
const ESPEC_LIMIT_LABEL = /^\s*L[íi]mite\s+(?:M[áa]ximo\s+)?(?:de\s+)?Responsabilidad\s*:?(.*)$/i;

/**
 * GMX's PVL "especificación" layout — ten pages of Spanish prose that the
 * office receives as `…-CondicionesParticulares.pdf`.
 *
 * What this document DOES carry: the policy number (printed on every page
 * header), the insured's name, the risk location, and every coverage limit.
 *
 * What it does NOT carry, at all: policy dates, broker, premium, currency as
 * a labelled field, or a coverage table. There is nothing to read for those,
 * so they stay null and the notes say why — the caratula is the document that
 * has them, and the office downloads both for the same policy.
 *
 * Coverages are not tabular here. Each one is a section heading followed by
 * `Límite Máximo de Responsabilidad:` and an amount on the next line (or two
 * amounts under `Edificio` / `Contenidos` sub-labels), with the deductible
 * printed further down its own block. So the parser anchors on the limit
 * label and walks BACKWARDS for the heading, rather than trying to match a
 * row shape that does not exist.
 */
function parseGmxEspecificacion(page: OcrPage): ParsedPolicy {
  const text = page.text;
  const lines = text.split("\n");
  const notes: string[] = [];

  // ----- policy number -----------------------------------------------------
  // Printed on its own line under the title on all ten pages. Ten independent
  // readings of the same number is a free cross-check: on a scan, a group that
  // OCR'd two different ways means one reading is wrong and there is no third
  // to break the tie, so the row goes to a human (the same rule the zona
  // federal statement parser applies to its clave).
  const readings = [
    ...new Set(
      allMatches(text, POLICY_NUMBER_SHAPE)
        .map(normalizePolicyNumber)
        .filter((n): n is string => n != null),
    ),
  ];
  const policyNumber = readings[0] ?? null;
  if (!policyNumber) notes.push("no se pudo leer el número de póliza");
  if (readings.length > 1) {
    notes.push(
      `el número de póliza se leyó de ${readings.length} formas distintas (${readings.join(", ")})`,
    );
  }

  // ----- insured + risk location ------------------------------------------
  // Values wrap onto the following lines within the same visual cell, and a
  // blank line always ends the cell, so the block reader joins until one.
  const insuredName = espectBlock(lines, /^\s*Nombre\s+del\s+asegurado\s+(.+)$/i);
  const additionalInsured = espectBlock(lines, /^\s*Asegurado\s+Adicional\s+(\S.*)$/i);
  const legalAddress = espectBlock(lines, /^\s*Ubicaci[óo]n\s+del\s+riesgo\s+(.+)$/i);

  // No ZIP field on this layout; it is the trailing run of the address
  // ("…BAJA CALIFORNIA 22713"). Take the LAST five-digit run — a street
  // number earlier in the line is never five digits, but anchoring on the
  // first match would still be the fragile choice.
  let zip: string | null = null;
  if (legalAddress) {
    const m = legalAddress.match(/(\d{5})(?!.*\d{5})/);
    if (m) {
      zip = m[1];
      notes.push(`código postal leído de la ubicación del riesgo (${m[1]})`);
    }
  }

  // ----- underwriting context ---------------------------------------------
  // Not `ParsedPolicy` fields, but they are the difference between a coverage
  // a reviewer can price and one they cannot, so they ride along in notes.
  const personType = espectBlock(lines, /^\s*Tipo\s+Persona\s+Asegurada\s+(.+)$/i);
  if (personType) notes.push(`tipo de persona asegurada: ${personType}`);
  const building = espectBlock(lines, /^\s*Caracter[íi]sticas\s+del\s+Inmueble\s+(.+)$/i);
  if (building) notes.push(`características del inmueble: ${building}`);
  const water = firstMatch(text, [/^\s*(-?\s*\d+\s*mts\.?\s*cuerpo\s+agua\s+\S+)\s*$/im]);
  if (water) notes.push(`distancia a cuerpo de agua: ${water.replace(/\s+/g, " ")}`);
  const generalConditions = firstMatch(text, [/^\s*(W_\S+\.pdf)\s*$/im]);
  if (generalConditions) notes.push(`condiciones generales: ${generalConditions}`);

  // ----- coverages ---------------------------------------------------------
  const { coverages, currency } = parseEspecificacionCoverages(lines, notes);

  // These are absent by design on this document, not failures to read. Say so
  // explicitly, or the reviewer reads three empty fields as a broken parse.
  //
  // The renewal consequence is spelled out rather than left implied: a Policy
  // confirmed with a null `policyTo` never matches the renewals window query
  // (`renewals.service.ts`, `policyTo: { gte, lte }`), so it drops out of the
  // renewal notices silently and stays out. Nothing downstream errors.
  notes.push(
    "la especificación PVL no trae vigencia, agente ni prima; captúrelos a mano " +
      "(sin vigencia la póliza no entra en los avisos de renovación)",
  );

  return {
    ...emptyParsedPolicy("GMX"),
    policyTypeName: GMX_POLICY_TYPE,
    policyNumber,
    insuredName,
    additionalInsured,
    legalAddress,
    zip,
    currency,
    coverages,
    notes,
  };
}

/**
 * Read a `Label   Value` cell whose value wraps onto the following lines.
 *
 * `pdftotext -layout` renders the especificación's header cells with the
 * value in a right-hand column, so a two-line risk location comes back as the
 * label line plus a continuation line indented to the same column. A blank
 * line always terminates the cell.
 *
 * The line budget is a guard, not the layout: the longest cell on this
 * document wraps once. It exists because "walk until the cell ends" is only
 * as good as the blank line it walks to, and when the OCR seam stopped
 * emitting those, this returned the whole first page as the insured's name —
 * a failure with no bad value to notice, just one enormous good one.
 */
const ESPEC_MAX_WRAP = 3;

function espectBlock(lines: string[], label: RegExp): string | null {
  for (let i = 0; i < lines.length; i++) {
    const m = lines[i].match(label);
    if (!m?.[1]) continue;
    const parts = [m[1]];
    const until = Math.min(lines.length, i + 1 + ESPEC_MAX_WRAP);
    for (let j = i + 1; j < until && lines[j].trim(); j++) {
      parts.push(lines[j].trim());
    }
    const value = parts.join(" ").replace(/\s+/g, " ").trim();
    return value || null;
  }
  return null;
}

/**
 * Walk the especificación's coverages by anchoring on the limit label.
 *
 * For each `Límite … Responsabilidad:` the amount is either on the same line
 * ("Bienes a la Intemperie"), on the next line (most), or split across two
 * lines under `Edificio` / `Contenidos` sub-labels ("Remoción de escombros").
 * The risk name is the nearest heading ABOVE the label: a short line that is
 * itself preceded by a blank one. That last condition is what distinguishes a
 * heading from the short tail of a wrapped paragraph ("efectuados.",
 * "Y CADA PÉRDIDA."), which is otherwise indistinguishable by length alone
 * and would name a coverage after the last word of the prose above it.
 *
 * Returns the currency alongside, because on this layout there is no currency
 * field — it is only ever printed next to the amounts, and the sublimits in
 * the body prose are quoted in M.N. while the limits themselves are in USD.
 * Only the limit amounts vote.
 */
function parseEspecificacionCoverages(
  lines: string[],
  notes: string[],
): { coverages: ParsedCoverage[]; currency: string | null } {
  const coverages: ParsedCoverage[] = [];
  const currencyVotes: Record<string, number> = {};
  let lastHeading: string | null = null;

  const limitLines: number[] = [];
  lines.forEach((line, i) => {
    if (ESPEC_LIMIT_LABEL.test(line)) limitLines.push(i);
  });

  limitLines.forEach((i, n) => {
    const nextLimit = limitLines[n + 1] ?? lines.length;
    const heading = espectHeadingAbove(lines, i);

    // "Sublímites:" / "Límite adicional." head a block that qualifies the
    // coverage above them rather than naming a new one, so they are read as
    // a sublimit OF the previous risk instead of as a standalone coverage.
    let risk = heading ?? "(sin nombre)";
    if (/^Subl[íi]mites?\b|^L[íi]mite\s+adicional/i.test(risk) && lastHeading) {
      risk = `${lastHeading} — sublímite`;
    } else if (heading) {
      lastHeading = heading;
    }
    if (!heading) notes.push(`un límite no tiene encabezado identificable (línea ${i + 1})`);

    const deductible = espectDeductibleFor(lines, i, nextLimit);
    const found = espectAmountsFor(lines, i);

    if (found.length === 0) {
      notes.push(`no se pudo leer el monto de "${risk}"`);
      coverages.push({ risk, insuredAmount: null, deductible, lossParticipation: null });
      return;
    }

    for (const a of found) {
      if (a.currency) currencyVotes[a.currency] = (currencyVotes[a.currency] ?? 0) + 1;
      coverages.push({
        risk: a.sublabel ? `${risk} — ${a.sublabel}` : risk,
        insuredAmount: a.amount,
        deductible,
        lossParticipation: null,
      });
    }
  });

  // The per-section coverages that are stated inline rather than under a
  // limit label. On this policy they are the two that matter most on the
  // coast — earthquake (EXCLUIDO here) and hydrometeorological — and skipping
  // them would leave the biggest exposure off the parsed row entirely.
  coverages.push(...espectInlineSectionRisks(lines, currencyVotes, notes));

  if (coverages.length === 0) notes.push("no se encontraron coberturas");

  const currency =
    Object.entries(currencyVotes).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
  if (currency) {
    notes.push(`moneda tomada de los límites impresos (${currency})`);
  } else {
    notes.push("no se pudo determinar la moneda de las sumas aseguradas");
  }

  return { coverages, currency };
}

/**
 * The nearest line above `at` that reads as a heading: short, not page
 * furniture, not a structural label, and itself preceded by a blank line.
 */
function espectHeadingAbove(lines: string[], at: number): string | null {
  for (let i = at - 1, walked = 0; i >= 0 && walked < 40; i--, walked++) {
    if (!isEspecHeading(lines, i)) continue;
    return lines[i].trim().replace(/\s*[:.]$/, "");
  }
  return null;
}

/**
 * Does line `i` read as a coverage heading?
 *
 * Short, not page furniture, not a structural label — and preceded by a blank
 * line, which is the condition that actually does the work: a heading sits in
 * its own paragraph, and the short tail of a wrapped sentence ("efectuados.",
 * "Y CADA PÉRDIDA.") does not. Length alone cannot tell those apart.
 */
function isEspecHeading(lines: string[], i: number): boolean {
  const line = lines[i]?.trim() ?? "";
  if (!line) return false;
  if (ESPEC_FURNITURE.test(line)) return false;
  // The repeated policy-number line in the page header.
  if (POLICY_NUMBER_SHAPE.test(line) && line.replace(/[\d\sOIlSBD-]/g, "") === "") return false;
  if (ESPEC_STRUCTURAL.test(line)) return false;
  if (line.length > 60) return false;
  // An amount on its own line is a value, never a heading — the sublimit
  // block prints one after a blank line, exactly where a heading would sit.
  if (/^\$?\s*[\d,]+(?:\.\d{2})?\s*(USD|MXN|M\.?\s?N\.?|DLLS?)?$/i.test(line)) return false;
  return i > 0 && !lines[i - 1].trim();
}

/**
 * Amounts belonging to one limit label: on the label line itself, then on the
 * following lines up to the blank that closes the block. A short non-amount
 * line inside the block is a sub-label for the amount beneath it
 * (`Edificio` / `Contenidos`).
 */
function espectAmountsFor(
  lines: string[],
  at: number,
): { amount: number | null; currency: string | null; sublabel: string | null }[] {
  const out: { amount: number | null; currency: string | null; sublabel: string | null }[] = [];

  const push = (line: string, sublabel: string | null) => {
    const m = line.match(ESPEC_AMOUNT);
    if (!m) return false;
    out.push({ amount: money(m[1]), currency: espectCurrency(m[2]), sublabel });
    return true;
  };

  const tail = lines[at].match(ESPEC_LIMIT_LABEL)?.[1]?.trim() ?? "";
  if (tail && push(tail, null)) return out;

  let sublabel: string | null = null;
  let seenContent = false;
  for (let i = at + 1; i < lines.length; i++) {
    const line = lines[i].trim();
    // A blank line closes the block — but only once the block has started.
    // The label and its amount are not always adjacent: the sublimit block on
    // page 7 renders as "Límite de Responsabilidad:", a blank line, then
    // "$2,000.00 USD", and breaking on the first blank drops that amount.
    if (!line) {
      if (seenContent) break;
      if (i - at > 3) break;
      continue;
    }
    if (ESPEC_FURNITURE.test(line)) continue;
    if (push(line, sublabel)) {
      sublabel = null;
      seenContent = true;
      continue;
    }
    seenContent = true;
    // Not an amount: a sub-label if it is short enough to be one.
    if (line.length <= 40 && !ESPEC_STRUCTURAL.test(line)) sublabel = line.replace(/\s*:$/, "");
  }
  return out;
}

/**
 * The deductible printed inside this coverage's own block, i.e. between its
 * limit and the next one.
 *
 * The value follows a bare `Deducible:` on the next line ("Sin deducible").
 * The length guard is load-bearing: the document also has a page-level
 * `DEDUCIBLES:` heading followed by a paragraph explaining how deductibles
 * interact with the inflation clause, and without the guard that paragraph
 * becomes the deductible of whichever coverage precedes it.
 */
function espectDeductibleFor(lines: string[], from: number, to: number): string | null {
  for (let i = from + 1; i < to && i < from + 30; i++) {
    const line = lines[i].trim();
    // A heading ends this coverage's block. Not every block that follows a
    // limit carries one of its own — the per-zone `Fenómenos
    // hidrometeorológicos` deductible table has no limit label to stop at, so
    // without this the coverage above it silently adopts its "1 POR CIENTO
    // SOBRE SUMA ASEGURADA".
    if (isEspecHeading(lines, i)) break;
    const inline = line.match(/^Deducibles?\s*:\s*(\S.*)$/i);
    if (inline) return inline[1].trim();
    if (!/^Deducibles?\s*:?\s*$/i.test(line)) continue;
    for (let j = i + 1; j < to; j++) {
      const value = lines[j].trim();
      if (!value) continue;
      // Too long to be a deductible: this is the page-level `DEDUCIBLES:`
      // heading and the paragraph explaining the inflation clause, not this
      // coverage's value. Keep looking rather than concluding "none".
      if (value.length <= 60) return value;
      break;
    }
  }
  return espectPercentDeductibleAbove(lines, from);
}

/**
 * Some coverages print their deductible and coinsurance as a sentence ABOVE
 * the limit instead of under a `Deducible:` label below it — "Bienes a la
 * Intemperie" reads
 *
 *   5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO
 *   DEL ASEGURADO DE TODA Y CADA PÉRDIDA.
 *
 * wrapped across two lines. Left unread it looks like a coverage with no
 * deductible at all, which is the one wrong answer worth avoiding here.
 *
 * Two bounds keep it from wandering into the body prose, and both are
 * load-bearing — without them the fire section's "…o hasta el 10% de la suma
 * asegurada de la sección de Edificio, lo que resulte menor" (a sublimit rule
 * for the building, several paragraphs up) becomes the deductible of
 * CONTENIDOS:
 *
 * - the walk stops at this coverage's own heading, so it can never read text
 *   belonging to the coverage above it, and
 * - the line must OPEN with the percentage, which a prose sentence that
 *   merely contains one does not.
 */
function espectPercentDeductibleAbove(lines: string[], limitAt: number): string | null {
  for (let i = limitAt - 1, walked = 0; i >= 0 && walked < 6; i--, walked++) {
    const line = lines[i].trim();
    if (!line) continue;
    // The heading of this coverage: stop, everything above belongs to another.
    if (line.length <= 60 && !ESPEC_STRUCTURAL.test(line) && i > 0 && !lines[i - 1].trim()) {
      return null;
    }
    if (!/^\d+\s*(POR\s*CIENTO|PORCIENTO|%)/i.test(line)) continue;
    // Re-join the wrapped continuation lines beneath it.
    const parts = [line];
    for (let j = i + 1; j < limitAt && lines[j].trim(); j++) parts.push(lines[j].trim());
    return parts.join(" ").replace(/\s+/g, " ");
  }
  return null;
}

/**
 * Coverages the especificación states inline inside the fire section instead
 * of under a limit label:
 *
 *   "2. Terremoto o erupción volcánica: Sección Edificio EXCLUIDO, Sección Contenidos EXCLUIDO"
 *   "3. Fenómenos hidrometeorológicos: Sección Edificio $200,000.00 USD, Sección Contenidos $20,000.00 USD"
 *
 * An exclusion is recorded in the risk label rather than as a zero amount:
 * a coverage that is excluded and a coverage insured for $0 are the same
 * number and very different facts, and `ParsedCoverage` has no field for the
 * distinction.
 */
function espectInlineSectionRisks(
  lines: string[],
  currencyVotes: Record<string, number>,
  notes: string[],
): ParsedCoverage[] {
  const out: ParsedCoverage[] = [];
  const cat = espectCatastrophicTerms(lines, notes);

  for (const raw of lines) {
    const m = raw.trim().match(/^\d+\.\s*([^:]{3,60}?)\s*:\s*(.*Secci[óo]n.+)$/i);
    if (!m) continue;
    const risk = m[1].trim();
    for (const part of m[2].split(/,\s*(?=Secci[óo]n)/i)) {
      const section = part.match(/Secci[óo]n\s+(\S+)/i)?.[1] ?? null;
      const label = section ? `${risk} — Sección ${section}` : risk;
      // Deductible/coinsurance for these two live in their own block further
      // down the document, keyed by section rather than printed here.
      const terms = /hidrometeorol/i.test(risk) && section ? cat[section.toLowerCase()] : undefined;

      if (/EXCLUIDO|NO\s+CUBIERT/i.test(part)) {
        out.push({
          risk: `${label}: EXCLUIDO`,
          insuredAmount: null,
          deductible: null,
          lossParticipation: null,
        });
        continue;
      }
      const amount = part.match(ESPEC_AMOUNT);
      if (!amount) continue;
      const currency = espectCurrency(amount[2]);
      if (currency) currencyVotes[currency] = (currencyVotes[currency] ?? 0) + 1;
      out.push({
        risk: label,
        insuredAmount: money(amount[1]),
        deductible: terms?.deductible ?? null,
        lossParticipation: terms?.coinsurance ?? null,
      });
    }
  }
  return out;
}

/**
 * The `Fenómenos hidrometeorológicos` block, which states the deductible and
 * the coinsurance per section:
 *
 *   Fenómenos hidrometeorológicos
 *   Zona: A2
 *   Deducible
 *       Edificio: 1 POR CIENTO SOBRE SUMA ASEGURADA
 *       Coaseguro:
 *       Zona 1: (INTERIOR) Participación a cargo del asegurado del 10% …
 *       Zona 2: Participación a cargo del asegurado del 10% …
 *
 * The coinsurance is quoted per catastrophe zone, and the policy's own zone
 * ("A2") is not one of the labels used in that list — so it is only read when
 * every listed zone quotes the SAME percentage, which makes the mapping moot.
 * When they differ, the reviewer is told to read the zone table by hand
 * rather than being handed a percentage picked by guesswork.
 */
function espectCatastrophicTerms(
  lines: string[],
  notes: string[],
): Record<string, { deductible: string | null; coinsurance: string | null }> {
  const out: Record<string, { deductible: string | null; coinsurance: string | null }> = {};

  const start = lines.findIndex((l) => /^\s*Fen[óo]menos\s+hidrometeorol[óo]gicos\s*$/i.test(l));
  if (start < 0) return out;
  let end = lines.length;
  for (let i = start + 1; i < lines.length; i++) {
    if (/^\s*(I+\.-\s*)?SECCI[ÓO]N\b/i.test(lines[i])) {
      end = i;
      break;
    }
  }
  const block = lines.slice(start, end);

  const zone = firstMatch(block.join("\n"), [/^\s*Zona\s*:\s*(\S+)\s*$/im]);
  if (zone) notes.push(`zona catastrófica declarada: ${zone}`);

  const percents = [
    ...new Set(
      allMatches(
        block.join("\n"),
        /Participaci[óo]n\s+a\s+cargo\s+del\s+asegurado\s+del\s+(\d+%)/i,
      ),
    ),
  ];
  let coinsurance: string | null = null;
  if (percents.length === 1) {
    coinsurance = percents[0];
  } else if (percents.length > 1) {
    notes.push(
      `el coaseguro de fenómenos hidrometeorológicos varía por zona (${percents.join(", ")}); revisarlo a mano`,
    );
  }

  for (const line of block) {
    const m = line.trim().match(/^(Edificio|Contenidos)\s*:\s*(\S.+)$/i);
    if (!m) continue;
    out[m[1].toLowerCase()] = { deductible: m[2].trim(), coinsurance };
  }
  return out;
}

/** `USD` / `M.N.` / `DLLS` as printed next to an amount → an ISO code. */
function espectCurrency(raw: string | null | undefined): string | null {
  if (!raw) return null;
  const s = raw.replace(/[.\s]/g, "").toUpperCase();
  if (s === "USD" || s.startsWith("DL") || s.startsWith("DLL")) return "USD";
  if (s === "MN" || s === "MXN") return "MXN";
  return null;
}

// --- A.N.A. Seguros ----------------------------------------------------------

/**
 * A.N.A. Compañía de Seguros — the tourist auto book the Rosarito office
 * sells. Unlike GMX these are born-digital portal PDFs, so `pdftotext
 * -layout` gives exact glyphs and exact columns; the parser leans on column
 * geometry where the labels alone are ambiguous, which OCR'd scans would not
 * allow. (The pipeline still runs Tesseract when a PDF has no text layer;
 * the label-anchored paths below survive that, the column-geometry path on
 * the driver's policy degrades to "amount read, column unknown".)
 *
 * ANA ships two unrelated faces from the same portal:
 *
 * 1. **AUTOMOBILE** (`SPECIAL POLICY FOR TOURISTS`) — insures a car. Carries
 *    the `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table and nine
 *    numbered risk sections in a single `LIMIT OF LIABILITY` column.
 * 2. **DRIVER´S POLICY FOR AUTOMOBILE** (the office calls it *licencia*) —
 *    insures up to five named drivers whatever they happen to be driving.
 *    NO vehicle table at all, and the risk table has two value columns
 *    (`SUM INSURED` and `PREMIUM`) instead of one.
 *
 * The four AUTOMOBILE products the office sells (amplia / responsabilidad
 * civil, annual / by-the-day) are the SAME layout with different numbers —
 * "amplia" prints a non-zero vehicle value and COVERED on sections 1-2,
 * "resp. civil" prints 0.00 and EXCLUDED. That is data, not a layout, so
 * there is one AUTOMOBILE parser rather than four.
 */
function parseAna(page: OcrPage): ParsedPolicy {
  const lines = page.text.split("\n");
  return isAnaDriverPolicy(page.text)
    ? parseAnaDriverPolicy(lines)
    : parseAnaAutomobile(lines);
}

/** The driver's policy announces itself in the title band above the No. cell. */
function isAnaDriverPolicy(text: string): boolean {
  return /DRIVER[´'`’]?S\s+POLICY\s+FOR\s+AUTOMOBILE/i.test(text);
}

/**
 * The header band both ANA faces share: policy number, issuing agent, the
 * three dates, the printed term in days, and the six-cell money row.
 *
 * Everything here is read off the FIRST copy in the merged text. Each ANA
 * PDF renders the same face two or three times — `ORIGINAL`, `AGENT COPY`,
 * a summary receipt, then three travel ID cards — and the pipeline
 * concatenates every page into one string before parsing. First-match is
 * therefore the right rule throughout, and the repeats are used only where
 * they buy a cross-check (the policy number, below).
 */
interface AnaHeader {
  policyNumber: string | null;
  agentName: string | null;
  agentCode: string | null;
  policyDate: Date | null;
  policyFrom: Date | null;
  policyTo: Date | null;
  coveragePeriodDays: number | null;
  netPremium: number | null;
  policyFee: number | null;
  tax: number | null;
  total: number | null;
}

function parseAnaHeader(lines: string[], notes: string[]): AnaHeader {
  const text = lines.join("\n");

  // ----- policy number -----------------------------------------------------
  // Printed in the header cell (`No.   700489651`), again on the summary
  // receipt (`Policy No:`), and once per travel ID card in both languages.
  // Reading all of them is a free cross-check; disagreement means one read
  // is wrong and there is no tie-breaker, so the row goes to a human.
  //
  // The two-space floor after `No.` is load-bearing: the agent's own street
  // address on this form is "BENITO JUAREZ 25 No.50 INT 38", and a looser
  // pattern reads the house number as the policy number.
  const readings = [
    ...new Set([
      ...allMatches(text, /(?:^|\s)No\.\s{2,}(\d{6,12})(?=\s|$)/),
      ...allMatches(text, /Policy\s+No\s*:\s*(\d{6,12})/i),
      ...allMatches(text, /Policy\s+Number\s*:\s*(\d{6,12})/i),
      ...allMatches(text, /No\.\s*de\s*p[óo]liza\s*:\s*(\d{6,12})/i),
    ]),
  ];
  const policyNumber = readings[0] ?? null;
  if (!policyNumber) notes.push("no se pudo leer el número de póliza");
  if (readings.length > 1) {
    notes.push(`número de póliza leído de ${readings.length} formas (${readings.join(", ")})`);
  }

  // ----- issuing agent -----------------------------------------------------
  const agentName = anaAgentName(lines);
  // The agent's clave sits in the cell under their address: on the same line
  // as the `TO` date on the automobile face, alone behind a stray "." on the
  // driver's policy. Both patterns are needed — the by-the-day products
  // print the agent's phone in that line's left cell ("(661) 612 12 55"), so
  // the clave is no longer the first thing on the line.
  //
  // What neither pattern can reach is the agent's own postal code
  // ("ROSARITO, BAJA CALIFORNIA 22710"), which is the misread worth
  // avoiding: it is five digits in the same header band.
  const agentCode = firstMatch(text, [
    /\s{2,}(\d{4,6})\s{2,}TO\s+\d/,
    /^\s*[.\s]*(\d{4,6})\s*$/m,
  ]);
  if (agentCode) notes.push(`clave de agente: ${agentCode}`);

  // ----- dates -------------------------------------------------------------
  // All three sit in DAY / MONTH / YEAR column cells, so they arrive as
  // three space-separated runs rather than a delimited date. DD MM YYYY is
  // confirmed by the travel card, which prints the same term twice —
  // "Desde : 07/08/2026" in Spanish next to "From : 08/07/2026" in English.
  const issued = text.match(/(?<!\d)(\d{1,2})\s+(\d{1,2})\s+(\d{4})\s+FROM\b/i);
  const from = text.match(/\bFROM\s+(\d{1,2})\s+(\d{1,2})\s+(\d{4})\b/i);
  const to = text.match(/\bTO\s+(\d{1,2})\s+(\d{1,2})\s+(\d{4})\b/i);
  const policyDate = issued ? utc(+issued[3], +issued[2] - 1, +issued[1]) : null;
  const policyFrom = from ? utc(+from[3], +from[2] - 1, +from[1]) : null;
  const policyTo = to ? utc(+to[3], +to[2] - 1, +to[1]) : null;
  if (!policyFrom || !policyTo) notes.push("no se pudo leer la vigencia");

  // ----- term in days ------------------------------------------------------
  // ANA sells 3- and 4-day tourist policies alongside annual ones and prints
  // the count in its own `DAYS` cell. Prefer the derived figure (two dates
  // beat one number) but say so when the printed cell disagrees — that is
  // either a misread date or a policy issued with a term the dates don't
  // support, and both need a human.
  const printedDays = anaPrintedDays(lines);
  const derivedDays = daysBetween(policyFrom, policyTo);
  const coveragePeriodDays = derivedDays ?? printedDays;
  if (printedDays != null && derivedDays != null && printedDays !== derivedDays) {
    notes.push(`vigencia impresa ${printedDays} días, calculada ${derivedDays}`);
  }

  // ----- money row ---------------------------------------------------------
  const row = anaMoneyRow(lines);
  if (!row) notes.push("no se pudo leer el renglón de primas");
  if (row?.discount) notes.push(`descuento: ${row.discount.toFixed(2)}`);
  // LOCAL TAX has no destination column and prints 0.00 on every A.N.A. policy
  // seen so far. A non-zero one means the total will not reconcile against the
  // stored IVA, so say so rather than folding it in and hiding the difference.
  if (row?.localTax) {
    notes.push(`impuesto local ${row.localTax.toFixed(2)} no capturado`);
  }

  return {
    policyNumber,
    agentName,
    agentCode,
    policyDate,
    policyFrom,
    policyTo,
    coveragePeriodDays,
    netPremium: row?.netPremium ?? null,
    policyFee: row?.policyFee ?? null,
    tax: row?.tax ?? null,
    total: row?.total ?? null,
  };
}

/**
 * The name under `ISSUED BY:`. On the automobile face it shares its line
 * with the `DAY MONTH YEAR` column headers of the adjacent date cell; on the
 * driver's policy it sits alone and the column headers are a line lower. So
 * the walk strips the headers rather than assuming a fixed offset.
 */
function anaAgentName(lines: string[]): string | null {
  const start = lines.findIndex((l) => /ISSUED\s+BY\s*:/i.test(l));
  if (start < 0) return null;
  for (let i = start + 1; i < lines.length && i < start + 6; i++) {
    const candidate = lines[i]
      .split(/DAY\s+MONTH\s+YEAR/i)[0]
      .replace(/\bDAYS?\b/gi, "")
      .replace(/\s+/g, " ")
      .trim();
    // The "DAYS" header line collapses to empty; a date/clave cell to digits.
    if (!candidate || /^[\d\s.,:-]+$/.test(candidate)) continue;
    return candidate;
  }
  return null;
}

/** The bare number in the `DAYS` cell, between `ISSUED BY:` and the money row. */
function anaPrintedDays(lines: string[]): number | null {
  const start = lines.findIndex((l) => /ISSUED\s+BY\s*:/i.test(l));
  if (start < 0) return null;
  for (let i = start + 1; i < lines.length && i < start + 14; i++) {
    if (/\bDISCOUNT\b/i.test(lines[i])) break;
    const m = lines[i].match(/^\s*(\d{1,3})\s*$/);
    if (m) return Number(m[1]);
  }
  return null;
}

/**
 * The six-cell money row under `DISCOUNT | PREMIUM | POLICY FEE | TAX |
 * LOCAL TAX | TOTAL`.
 *
 * Read positionally off the header, not by label: the cells carry nothing
 * but numbers, and an unused DISCOUNT prints as a bare "-" rather than 0.00,
 * so a "find the six amounts" approach would shift every value one column
 * left on a discounted policy. Requiring exactly six whitespace-separated
 * cells is the guard — a row that doesn't have them is reported as unread
 * instead of silently mis-mapped.
 */
function anaMoneyRow(lines: string[]): {
  discount: number | null;
  netPremium: number | null;
  policyFee: number | null;
  tax: number | null;
  localTax: number | null;
  total: number | null;
} | null {
  const header = lines.findIndex((l) =>
    /^DISCOUNT\s+PREMIUM\s+POLICY\s+FEE\s+TAX\s+LOCAL\s+TAX\s+TOTAL$/i.test(
      l.trim().replace(/\s+/g, " "),
    ),
  );
  if (header < 0) return null;

  for (let i = header + 1; i < lines.length && i < header + 4; i++) {
    const cells = lines[i].trim().split(/\s+/);
    if (cells.length !== 6) continue;
    const cell = (c: string) => (/\d/.test(c) ? money(c) : null);
    return {
      discount: cell(cells[0]),
      netPremium: cell(cells[1]),
      policyFee: cell(cells[2]),
      tax: cell(cells[3]),
      localTax: cell(cells[4]),
      total: cell(cells[5]),
    };
  }
  return null;
}

/**
 * ANA quotes its tourist book in dollars and prints the currency next to
 * every figure — `DLLS.` on the automobile face, `usd.` on the driver's
 * policy. Checked in that order because the MXN branch is deliberately
 * loose and would otherwise claim a page over an incidental "M.N.".
 */
function anaCurrency(text: string, notes: string[]): string | null {
  if (/\bDLLS?\.|\busd\.|\bUSD\b|AMERICAN\s+DOLLARS/i.test(text)) return "USD";
  if (/\bMXN\b|\bPESOS\b|\bM\.\s?N\.\b/i.test(text)) return "MXN";
  notes.push("no se pudo determinar la moneda");
  return null;
}

/**
 * A `LABEL   value` cell on ANA's insured block. The value ends where the
 * next cell's label begins — the block is three columns wide and
 * `pdftotext` renders all three on one line, so an unbounded read of
 * `ADDRESS` swallows "EMAIL PROLABSALE@AOL.COM".
 */
function anaLabelValue(lines: string[], label: RegExp): string | null {
  for (const line of lines) {
    const m = line.match(label);
    if (!m?.[1]) continue;
    const value = m[1]
      .replace(/\s{2,}(LICENSE|EMAIL|TELEPHONE|PAYMENT\s+DEADLINE)\b.*$/i, "")
      .replace(/\s+/g, " ")
      .trim();
    if (value) return value;
  }
  return null;
}

// --- ANA / AUTOMOBILE --------------------------------------------------------

/**
 * ANA's `SPECIAL POLICY FOR TOURISTS` automobile face.
 *
 * Layout, top to bottom: the ANA letterhead and claim phone numbers, the
 * `No.` cell, the issuing-agent / dates / term header band, the six-cell
 * money row, the insured's contact block, the vehicle table, and the nine
 * numbered risk sections.
 */
function parseAnaAutomobile(lines: string[]): ParsedPolicy {
  const text = lines.join("\n");
  const notes: string[] = [];
  const header = parseAnaHeader(lines, notes);

  // ----- insured contact block --------------------------------------------
  const insuredName = anaLabelValue(lines, /^\s*INSURED\s{2,}(\S.*)$/);
  const street = anaLabelValue(lines, /^\s*ADDRESS\s{2,}(\S.*)$/);
  const cityState = anaLabelValue(lines, /^\s*CITY\s*&\s*STATE\s{2,}(\S.*)$/);
  const legalAddress = [street, cityState].filter(Boolean).join(", ") || null;

  // The ZIP sits in its own column between the city/state cell and the
  // TELEPHONE cell, so it comes back glued to the city value after the
  // whitespace collapse. Take the last five-digit run — a US street number
  // ("10308 DONNA AVE") is in the ADDRESS cell, not this one, but anchoring
  // on the first match would still be the fragile choice.
  const zip = cityState?.match(/(\d{5})(?!.*\d{5})/)?.[1] ?? null;

  const license = firstMatch(text, [/\bLICENSE\s{2,}([A-Z0-9]{4,20})\b/i]);
  const email = firstMatch(text, [/\bEMAIL\s{2,}(\S+@\S+)/i]);
  const phone = firstMatch(text, [/\bTELEPHONE\s+([\d()\s.-]{7,20})/i])?.trim() ?? null;

  // The insured on this face is also the driver, and their US license number
  // is the only identifier ANA prints. One InsuredDriver row keeps it out of
  // free text where nothing can query it.
  const drivers: ParsedDriver[] = insuredName
    ? [{ fullName: insuredName, licenseNumber: license, address: legalAddress, phone, email }]
    : [];

  if (!insuredName) notes.push("no se pudo leer el nombre del asegurado");

  // ----- vehicle table -----------------------------------------------------
  const vehicles = parseAnaVehicles(lines, notes);

  // ----- risk sections -----------------------------------------------------
  const region = anaRiskRegion(lines);
  const coverages = parseAnaAutoCoverages(region, notes);
  if (vehicles.length > 1) {
    notes.push(
      "las sumas VEHICLE/TRAILER/TOWING son los valores declarados de cada " +
        "unidad, impresos a lo ancho de las secciones 1 y 2",
    );
  }

  // The `PAYMENT DEADLINE` cell prints its value on the line below the
  // label, in the same column — "IMMEDIATE" for everything the office sells
  // through the portal. Mapped onto `premiumPayment`, which is the same
  // "when is it due" field GMX fills with "CONTADO".
  const paymentDeadline = /^\s*PAYMENT\s+DEADLINE\s*$/m.test(text)
    ? firstMatch(text, [/^\s*(IMMEDIATE|INMEDIATO)\s*$/im])
    : null;

  return {
    ...emptyParsedPolicy("ANA"),
    // The face that insures a car.
    policyTypeName: "AUTO",
    policyNumber: header.policyNumber,
    insuredName,
    agentName: header.agentName,
    legalAddress,
    zip,
    policyFrom: header.policyFrom,
    policyTo: header.policyTo,
    policyDate: header.policyDate,
    currency: anaCurrency(text, notes),
    netPremium: header.netPremium,
    policyFee: header.policyFee,
    tax: header.tax,
    total: header.total,
    premiumPayment: paymentDeadline,
    coverages,
    coveragePeriodDays: header.coveragePeriodDays,
    vehicles,
    drivers,
    notes,
  };
}

/**
 * The `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table — three fixed
 * slots (VEHICLE, TRAILER, TOWING), of which the tourist policies the office
 * writes fill only the first. Unused slots print a "." per cell.
 *
 * Parsed by token role rather than by column offset, because the BODY cell
 * is the one that wraps: "PACIFICA" is one token and "GENESIS SEDAN" is two,
 * so a fixed token count reads the VIN out of the wrong slot on the second.
 * The VIN is the anchor — 17 characters is a shape nothing else in the row
 * has — and BODY is whatever sits between the make and it.
 */
function parseAnaVehicles(lines: string[], notes: string[]): ParsedVehicle[] {
  const header = lines.findIndex((l) =>
    /^ITEM\s+YEAR\s+MAKE\s+BODY\s+SERIAL\s+No\.\s+PLATES$/i.test(l.trim().replace(/\s+/g, " ")),
  );
  if (header < 0) return [];

  const out: ParsedVehicle[] = [];
  for (let i = header + 1; i < lines.length && i < header + 5; i++) {
    const m = lines[i].trim().match(/^(VEHICLE|TRAILER|TOWING)\b(.*)$/i);
    if (!m) break;
    const tokens = m[2].trim().split(/\s+/).filter((t) => t && t !== ".");
    if (tokens.length === 0) continue;

    const modelYear = /^\d{4}$/.test(tokens[0]) ? tokens.shift()! : null;
    const vinAt = tokens.findIndex((t) => /^[A-Z0-9]{17}$/i.test(t));
    const make = tokens[0] ?? null;
    const bodyType =
      (vinAt > 0 ? tokens.slice(1, vinAt) : tokens.slice(1, -1)).join(" ") || null;
    const vinNumber = vinAt >= 0 ? tokens[vinAt] : null;
    // With no VIN to anchor on, the last token is the plate — unless it is
    // the only token, in which case it is the make and there is no plate.
    const plate =
      vinAt >= 0
        ? tokens.slice(vinAt + 1).join(" ")
        : tokens.length > 1
        ? tokens[tokens.length - 1]
        : "";

    out.push({
      item: m[1].toUpperCase(),
      modelYear,
      make,
      bodyType,
      vinNumber,
      licensePlate: plate || null,
    });
    if (!vinNumber) notes.push(`sin número de serie en la unidad ${m[1].toUpperCase()}`);
  }
  return out;
}

/**
 * The risk table, bounded to the FIRST copy of the face.
 *
 * Every ANA PDF prints the whole page twice (ORIGINAL, then AGENT COPY) and
 * the pipeline hands the parser both concatenated. Without the end bound the
 * section walk runs straight into the second copy and every coverage is
 * emitted twice.
 */
function anaRiskRegion(lines: string[]): string[] {
  const start = lines.findIndex((l) => /SPECIFICATION\s+OF\s+RISKS/i.test(l));
  if (start < 0) return [];
  for (let i = start + 1; i < lines.length; i++) {
    if (
      /^\s*ISSUED\s+ONLINE\s*$/i.test(lines[i]) ||
      /The\s+following\s+risks\s+are\s+excluded/i.test(lines[i]) ||
      /HEREINAFTER\s+CALLED/i.test(lines[i])
    ) {
      return lines.slice(start, i);
    }
  }
  return lines.slice(start);
}

type AnaSectionKind = "vehicleValue" | "limit" | "perPersonAccident" | "addon" | "elite";

/**
 * The printed risk sections, as a fixed table rather than a generic row
 * regex: this is a pre-printed insurer form whose nine sections never change
 * name, and each one puts its numbers in a different place. Section 3 has a
 * single limit; 4 and 5 split per-person / per-accident; 6, 7 and 8 print a
 * PREMIUM (what it cost) where the others print a limit (what it pays); 1
 * and 2 carry the declared VEHICLE / TRAILER / TOWING values in a column
 * shared across both.
 *
 * The driver's policy reuses the same table minus the vehicle sections and
 * renames two — `LEGAL AID` without the `A.N.A.'s` prefix, and `AUTOMOBILE
 * ASSISTANCE` for what the automobile face calls `ROADSIDE ASSISTANCE`.
 * Same coverage, so it gets the same canonical name.
 */
const ANA_SECTIONS: { key: RegExp; name: string; kind: AnaSectionKind }[] = [
  { key: /^MATERIAL\s+DAMAGE\b/i, name: "MATERIAL DAMAGE", kind: "vehicleValue" },
  { key: /^TOTAL\s+THEFT\b/i, name: "TOTAL THEFT", kind: "vehicleValue" },
  {
    key: /^LIABILITY\s+FOR\s+PROPERTY\s+DAMAGE\s+TO\s+THIRD\s+PARTIES\b/i,
    name: "LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES",
    kind: "limit",
  },
  {
    key: /^BODILY\s+INJURY\s+LIABILITY\b/i,
    name: "BODILY INJURY LIABILITY",
    kind: "perPersonAccident",
  },
  { key: /^MEDICAL\s+EXPENSES\b/i, name: "MEDICAL EXPENSES", kind: "perPersonAccident" },
  {
    key: /^CATASTROPHIC\s+LIABILITY\s+FOR\s+DEATH\s+OF\s+THIRD\b/i,
    name: "CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES",
    kind: "addon",
  },
  { key: /^(?:A\.N\.A\.[´'`’]?s\s+)?LEGAL\s+AID\b/i, name: "LEGAL AID", kind: "addon" },
  {
    key: /^(?:A\.N\.A\.[´'`’]?s\s+)?(?:ROADSIDE|AUTOMOBILE)\s+ASSISTANCE\b/i,
    name: "ROADSIDE ASSISTANCE",
    kind: "addon",
  },
  { key: /^ELITE\s+OR\s+ELITE\s+PLUS\b/i, name: "ELITE / ELITE PLUS", kind: "elite" },
];

interface AnaBlock {
  name: string;
  kind: AnaSectionKind;
  lines: string[];
}

/**
 * Split the risk region into one block per section: from a section's label
 * line to the next section's.
 *
 * Blocks are keyed by where the labels actually appear, not by the order of
 * `ANA_SECTIONS` — the driver's policy prints CATASTROPHIC LIABILITY *above*
 * MEDICAL EXPENSES while the automobile face prints it below, and a walk
 * that assumed the declared order would hand one section the other's
 * numbers.
 *
 * The leading `\d+` strip is the section number, which prints in its own
 * narrow column and lands at the head of the line for the sections whose
 * label wraps (`9  PARTIAL THEFT (LIMIT …`).
 */
function anaSectionBlocks(region: string[]): AnaBlock[] {
  const hits: { at: number; name: string; kind: AnaSectionKind }[] = [];
  region.forEach((raw, i) => {
    const line = raw.trim().replace(/^\d{1,2}\s+/, "");
    const hit = ANA_SECTIONS.find((s) => s.key.test(line));
    if (hit) hits.push({ at: i, name: hit.name, kind: hit.kind });
  });
  return hits.map((h, n) => ({
    name: h.name,
    kind: h.kind,
    lines: region.slice(h.at, hits[n + 1]?.at ?? region.length),
  }));
}

/**
 * Is the section covered or excluded?
 *
 * `COVERED/EXCLUDED` is the column *header* and prints on every section
 * regardless — it has to be removed before the check, or every section reads
 * as EXCLUDED. What remains is the single word printed in that column.
 */
function anaStatus(block: string[]): "COVERED" | "EXCLUDED" | null {
  const t = block.join("\n").replace(/COVERED\s*\/\s*EXCLUDED/gi, " ");
  if (/\bEXCLUDED\b/i.test(t)) return "EXCLUDED";
  if (/\bCOVERED\b/i.test(t)) return "COVERED";
  return null;
}

/**
 * The `DEDUCTIBLE:` sentence, which wraps across the rest of the section's
 * block with the right-hand value columns interleaved on the same lines.
 * Those columns are stripped per line before the join, or the deductible
 * comes back as "…ON AUTOS COVERED (SEDANS…) 0.00 DLLS.".
 */
function anaDeductibleText(block: string[]): string | null {
  const start = block.findIndex((l) => /DEDUCTIBLE\s*:/i.test(l));
  if (start < 0) return null;
  const parts: string[] = [];
  for (let i = start; i < block.length; i++) {
    const stripped = block[i]
      .replace(/\s{2,}[\d,]+\.\d{2}\s*(?:DLLS?\.|usd\.)?\s*$/i, "")
      .replace(/\s{2,}(?:COVERED|EXCLUDED|VEHICLE|TRAILER|TOWING)\s*$/i, "")
      .replace(/^\s*\d{1,2}\s+/, "")
      .trim();
    if (!stripped || /^(COVERED|EXCLUDED|VEHICLE|TRAILER|TOWING)$/i.test(stripped)) continue;
    parts.push(stripped);
  }
  const joined = parts.join(" ").replace(/^DEDUCTIBLE\s*:\s*/i, "").replace(/\s+/g, " ").trim();
  return joined || null;
}

/**
 * Coverages off the automobile face's single `LIMIT OF LIABILITY` column.
 *
 * An excluded section is recorded in the risk label rather than as a zero
 * amount — same rule as the GMX especificación parser. "Excluded" and
 * "insured for $0.00" print the same number here (a responsabilidad-civil
 * policy shows 0.00 for material damage) and mean very different things, and
 * `ParsedCoverage` has no field for the distinction.
 */
function parseAnaAutoCoverages(region: string[], notes: string[]): ParsedCoverage[] {
  const out: ParsedCoverage[] = [];

  for (const block of anaSectionBlocks(region)) {
    const text = block.lines.join("\n");
    const status = anaStatus(block.lines);
    const label = (sub?: string | null) =>
      `${block.name}${sub ? ` — ${sub}` : ""}${status === "EXCLUDED" ? ": EXCLUDED" : ""}`;

    switch (block.kind) {
      case "vehicleValue": {
        const deductible = anaDeductibleText(block.lines);
        // The item keyword and its value are on different lines (the
        // keyword heads the column, the amount sits a line or two below),
        // so one pass over the whole block tracks whichever keyword was
        // seen most recently. Only amounts suffixed `DLLS.` count as
        // values — the "$500.00 ON AUTOS" inside the deductible sentence
        // is not one.
        const re = /\b(VEHICLE|TRAILER|TOWING)\b|([\d,]+\.\d{2})\s*DLLS?\./gi;
        let item: string | null = null;
        let found = false;
        let m: RegExpExecArray | null;
        while ((m = re.exec(text)) !== null) {
          if (m[1]) {
            item = m[1].toUpperCase();
            continue;
          }
          found = true;
          out.push({
            risk: label(item),
            insuredAmount: money(m[2]),
            deductible,
            lossParticipation: null,
            premium: null,
          });
        }
        if (!found) {
          out.push({
            risk: label(),
            insuredAmount: null,
            deductible,
            lossParticipation: null,
            premium: null,
          });
          notes.push(`sin suma asegurada en "${block.name}"`);
        }
        break;
      }

      case "limit": {
        const m = text.match(/([\d,]+\.\d{2})\s*DLLS?\./i);
        if (!m) notes.push(`sin límite en "${block.name}"`);
        out.push({
          risk: label(),
          insuredAmount: m ? money(m[1]) : null,
          deductible: null,
          lossParticipation: null,
          premium: null,
        });
        break;
      }

      case "perPersonAccident": {
        // The two limits are labelled by the tail of the wrapped column
        // header ("PER" on the label line, "PERSON"/"ACCIDENT" on the
        // value line), so the value-line keyword is the reliable anchor.
        const person = text.match(/\bPERSON\s+([\d,]+\.\d{2})/i);
        const accident = text.match(/\bACCIDENT\s+([\d,]+\.\d{2})/i);
        if (!person && !accident) notes.push(`sin límites en "${block.name}"`);
        if (person) {
          out.push({
            risk: label("POR PERSONA"),
            insuredAmount: money(person[1]),
            deductible: null,
            lossParticipation: null,
            premium: null,
          });
        }
        if (accident) {
          out.push({
            risk: label("POR EVENTO"),
            insuredAmount: money(accident[1]),
            deductible: null,
            lossParticipation: null,
            premium: null,
          });
        }
        break;
      }

      case "addon": {
        // These three print a PREMIUM, not a limit: the last amount in the
        // block is what the coverage cost. Recorded as `premium` so the
        // review screen never shows $40 as a sum insured.
        const amounts = [...text.matchAll(/([\d,]+\.\d{2})/g)];
        out.push({
          risk: label(),
          insuredAmount: null,
          deductible: null,
          lossParticipation: null,
          premium: amounts.length ? money(amounts[amounts.length - 1][1]) : null,
        });
        break;
      }

      case "elite": {
        // Section 9 packs both its coverages into parenthesised prose:
        // "PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER
        // EVENT)" — no space after "DLLS." in the source. One COVERED /
        // EXCLUDED applies to the whole section.
        let matched = false;
        for (const line of block.lines) {
          const m = line.match(
            /\b(PARTIAL\s+THEFT|VANDALISM)\s*\(\s*LIMIT\s*([\d,]+\.\d{2})\s*DLLS?\.\s*WITH\s+DEDUCTIBLE\s*:\s*([\d,]+\.\d{2})\s*DLLS?\.[^)]*\)(.*)$/i,
          );
          if (!m) continue;
          matched = true;
          const trailing = m[4].match(/([\d,]+\.\d{2})/);
          out.push({
            risk: label(m[1].toUpperCase().replace(/\s+/g, " ")),
            insuredAmount: money(m[2]),
            deductible: `${m[3]} DLLS. POR EVENTO`,
            lossParticipation: null,
            premium: trailing ? money(trailing[1]) : null,
          });
        }
        if (!matched) notes.push(`no se pudo desglosar "${block.name}"`);
        break;
      }
    }
  }

  if (out.length === 0) notes.push("no se encontraron coberturas");
  return out;
}

// --- ANA / DRIVER´S POLICY (licencia) ----------------------------------------

/**
 * ANA's `DRIVER´S POLICY FOR AUTOMOBILE` — the office's *licencia* product.
 * It insures up to five named drivers in whatever car they are driving, so
 * there is no vehicle table and the liability limits are the whole contract.
 *
 * The header band is the shared one (same `No.`, `ISSUED BY:` and money-row
 * cells as the automobile face, a line or two out of alignment); the insured
 * arrives as a numbered `POLICY HOLDER` list instead of a single INSURED
 * cell; and the risk table gains a second value column.
 */
function parseAnaDriverPolicy(lines: string[]): ParsedPolicy {
  const text = lines.join("\n");
  const notes: string[] = [];
  const header = parseAnaHeader(lines, notes);

  const drivers = parseAnaDrivers(lines);
  if (drivers.length === 0) notes.push("no se pudo leer ningún conductor");
  if (drivers.length > 1) notes.push(`${drivers.length} conductores nombrados`);

  // Driver 1 is the policy holder; the platform's single insured-name field
  // takes them and the rest ride on the InsuredDriver rows.
  const holder = drivers[0] ?? null;
  const legalAddress = holder?.address ?? null;
  const zip = legalAddress?.match(/(\d{5})(?!.*\d{5})/)?.[1] ?? null;

  const region = anaRiskRegion(lines);
  const coverages = parseAnaDriverCoverages(region, notes);

  // The excluded-risk sentence is the difference between this product and a
  // full automobile policy, so it is surfaced rather than dropped.
  const excluded = firstMatch(text, [
    /The\s+following\s+risks\s+are\s+excluded\s+(.+?)\s*$/im,
  ]);
  if (excluded) notes.push(`riesgos excluidos: ${excluded.replace(/\s+/g, " ")}`);
  notes.push("póliza de conductor: no ampara un vehículo determinado");

  return {
    ...emptyParsedPolicy("ANA"),
    // The office's own name for this product is *licencia*, and LICENCIAS is
    // the legacy Access table it was migrated from -- 306 policies.
    policyTypeName: "LICENCIAS",
    policyNumber: header.policyNumber,
    insuredName: holder?.fullName ?? null,
    agentName: header.agentName,
    legalAddress,
    zip,
    policyFrom: header.policyFrom,
    policyTo: header.policyTo,
    policyDate: header.policyDate,
    currency: anaCurrency(text, notes),
    netPremium: header.netPremium,
    policyFee: header.policyFee,
    tax: header.tax,
    total: header.total,
    coverages,
    coveragePeriodDays: header.coveragePeriodDays,
    drivers,
    notes,
  };
}

/**
 * The `POLICY HOLDER` list: five numbered slots of NAME / ADDRESS / DRIVER
 * LICENSE, of which the unused ones print an empty value and a bare "NONE"
 * under the licence label. Slots without a name are dropped — a driver with
 * no name is not a driver.
 *
 * The phone rides at the tail of the name cell ("PAMELA DENISE WAGONER
 * Ph.3102001538") rather than in a column of its own.
 *
 * Bounded to the FIRST `POLICY HOLDER` list in the merged text. The driver's
 * policy renders its face three times (ORIGINAL, AGENT COPY, INSURED COPY)
 * and an unbounded walk returns the same person once per copy — which reads
 * as a three-driver policy, not as a parse bug, so nothing downstream would
 * have caught it.
 */
function parseAnaDrivers(lines: string[]): ParsedDriver[] {
  const listAt = lines.findIndex((l) => /^\s*POLICY\s+HOLDER\s*$/i.test(l));
  if (listAt < 0) return [];
  const nextList = lines.findIndex(
    (l, i) => i > listAt && /^\s*POLICY\s+HOLDER\s*$/i.test(l),
  );
  const scope = lines.slice(listAt, nextList < 0 ? lines.length : nextList);

  const email = firstMatch(lines.join("\n"), [/\bEMAIL\s{2,}(\S+@\S+)/i]);
  const out: ParsedDriver[] = [];

  const starts: number[] = [];
  scope.forEach((l, i) => {
    if (/^\s*\d\.\s*NAME\s*:/i.test(l)) starts.push(i);
  });

  starts.forEach((at, n) => {
    const end = starts[n + 1] ?? Math.min(at + 6, scope.length);
    const raw = scope[at].match(/^\s*\d\.\s*NAME\s*:\s*(.*)$/i)?.[1]?.trim() ?? "";
    if (!raw) return;

    // One space is enough to split on: the "Ph." marker plus seven digits at
    // the end of the cell is not something a name does. Requiring the printed
    // column gap made this depend on the reassembler keeping it, and when that
    // collapsed the phone rode into `insuredName` ("PAMELA DENISE WAGONER
    // Ph.3102001538") and no customer matched it.
    const phoneAt = raw.match(/\s+Ph\.?\s*([\d()\s.-]{7,})\s*$/i);
    const fullName = (phoneAt ? raw.slice(0, phoneAt.index) : raw).replace(/\s+/g, " ").trim();
    if (!fullName) return;

    const block = scope.slice(at, end);
    const address = anaDriverField(block, /^\s*ADDRESS\s*:\s*(\S.*)$/i);
    const licenseNumber = anaDriverField(block, /^\s*DRIVER\s+LICENSE\s*:\s*(\S.*)$/i);

    out.push({
      fullName,
      licenseNumber,
      address,
      phone: phoneAt?.[1]?.trim() ?? null,
      // ANA prints one contact email per policy, in the header cell above
      // the list — not per driver. Attaching it to every driver would
      // invent a fact, so only the holder gets it.
      email: n === 0 ? email : null,
    });
  });

  return out;
}

/** A `LABEL : value` cell inside one driver slot; "NONE" means empty. */
function anaDriverField(block: string[], label: RegExp): string | null {
  for (const line of block) {
    const m = line.match(label);
    if (!m?.[1]) continue;
    const value = m[1].replace(/\s+/g, " ").replace(/,\s*$/, "").trim();
    if (!value || /^NONE$/i.test(value)) return null;
    return value;
  }
  return null;
}

/**
 * Coverages off the driver's policy, which prints `SUM INSURED` and
 * `PREMIUM` as two separate value columns.
 *
 * Both columns hold the same shape (`100,000.00 usd.` / `18.70 usd.`) and
 * neither carries a label on its own line, so the only thing that
 * distinguishes them is horizontal position. The split is taken from the
 * header's own column offsets rather than a hardcoded number, because the
 * offsets shift between ANA's products.
 *
 * When the header offsets can't be read — a scan OCR'd without column
 * fidelity — every amount is reported as a sum insured and the reviewer is
 * told the split failed, rather than half the premiums being silently filed
 * as coverage limits.
 */
function parseAnaDriverCoverages(region: string[], notes: string[]): ParsedCoverage[] {
  const head = region[0] ?? "";
  const sumCol = head.search(/SUM\s+INSURED/i);
  const premiumCol = head.search(/PREMIUM/i);
  const split =
    sumCol >= 0 && premiumCol > sumCol ? Math.round((sumCol + premiumCol) / 2) : null;
  if (split == null) {
    notes.push("no se pudieron separar las columnas SUMA ASEGURADA / PRIMA");
  }

  const out: ParsedCoverage[] = [];
  for (const block of anaSectionBlocks(region)) {
    const status = anaStatus(block.lines);
    const label = (sub?: string | null) =>
      `${block.name}${sub ? ` — ${sub}` : ""}${status === "EXCLUDED" ? ": EXCLUDED" : ""}`;

    const sums: { amount: number | null; sub: string | null }[] = [];
    let premium: number | null = null;

    for (const line of block.lines) {
      for (const m of line.matchAll(/([\d,]+\.\d{2})/g)) {
        const at = m.index ?? 0;
        // "Per Person" / "Per Accident" trail their amount on this layout
        // (they precede it on the automobile face).
        const after = line.slice(at + m[0].length, at + m[0].length + 24);
        const sub = /Per\s+Person/i.test(after)
          ? "POR PERSONA"
          : /Per\s+Accident/i.test(after)
          ? "POR EVENTO"
          : null;
        if (split != null && at >= split) premium = money(m[1]);
        else sums.push({ amount: money(m[1]), sub });
      }
    }

    if (sums.length === 0) {
      out.push({
        risk: label(),
        insuredAmount: null,
        deductible: null,
        lossParticipation: null,
        premium,
      });
      continue;
    }
    sums.forEach((s, i) => {
      out.push({
        risk: label(s.sub),
        insuredAmount: s.amount,
        deductible: null,
        lossParticipation: null,
        // The premium covers the section, not each of its limits — putting
        // it on every row would double it in any total the UI computes.
        premium: i === 0 ? premium : null,
      });
    });
  }

  if (out.length === 0) notes.push("no se encontraron coberturas");
  return out;
}