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" today; the dispatcher lives on `detectProvider`. */
  provider: string;
  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;
  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[];
  /** 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;
}

// --- 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],
];

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,
  ],
];

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,
};

const EMPTY_COVERAGE: ParsedCoverage = {
  risk: "",
  insuredAmount: null,
  deductible: null,
  lossParticipation: null,
};

export function parsePolicy(page: OcrPage): ParsedPolicy {
  const provider = detectPolicyProvider(page.text);
  if (!provider) {
    return {
      provider: "",
      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,
      total: null,
      premiumPayment: null,
      coverages: [],
      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.
 */
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 {
    provider: "GMX",
    policyNumber: normalizePolicyNumber(policyNumber),
    insuredName,
    additionalInsured,
    agentName,
    legalAddress,
    zip,
    policyFrom,
    policyTo,
    policyDate,
    currency,
    netPremium: null,
    policyFee: null,
    brokerFee: null,
    total: null,
    premiumPayment,
    coverages,
    notes,
  };
}

/**
 * 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 {
    provider: "GMX",
    policyNumber,
    insuredName,
    additionalInsured,
    agentName: null,
    legalAddress,
    zip,
    policyFrom: null,
    policyTo: null,
    policyDate: null,
    currency,
    netPremium: null,
    policyFee: null,
    brokerFee: null,
    total: null,
    premiumPayment: null,
    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.
 */
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]];
    for (let j = i + 1; j < lines.length && 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;
}