import type { ServiceKind } from "@jorgecuadros/database";
import type { OcrPage, OcrWord } from "../ocr/ocr.provider";

/**
 * What one parsed statement page yields. `accountRef` is already normalised to
 * the form the migrated `PropertyService` columns hold, so the matcher compares
 * like with like and never has to know about provider-specific formatting.
 */
export interface ParsedStatement {
  /**
   * "CFE" | "CESPT" | "TELNOR" | "GAS TIJUANA" | "PREDIAL TIJUANA" |
   * "PREDIAL ROSARITO" | "PREDIAL ENSENADA" | "ZONA FEDERAL TIJUANA", or null
   * when no parser claimed the page.
   */
  provider: string | null;
  serviceKind: ServiceKind | null;
  accountRef: string | null;
  /** Clave catastral, when printed — a second key to match on. */
  cadastralKey: string | null;
  amount: number | null;
  dueDate: Date | null;
  period: string | null;
  /**
   * Independent corroboration of `accountRef`. CFE and Telnor both print a
   * payment barcode that repeats the account number (and the amount), so when
   * the barcode and the label agree the extraction is near-certainly right;
   * when they disagree, or only one is present, the page is worth a human
   * glance. Null when the layout has no second source.
   */
  crossChecked: boolean | null;
  /** Human-readable trail of what was read, surfaced in the review queue. */
  notes: string[];
}

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

/**
 * Tesseract confuses these glyphs inside numeric runs with some regularity —
 * a real clave catastral `KB078025` came back as `KBO78025`. Applied ONLY to
 * fields known to be digits, never to free text, where it would corrupt words.
 */
const DIGIT_CONFUSIONS: Record<string, string> = {
  O: "0",
  o: "0",
  D: "0",
  I: "1",
  l: "1",
  "|": "1",
  S: "5",
  B: "8",
};

export 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. A real Telnor bill OCR'd as "$ 649,00" — blindly stripping commas
 * as thousands separators turned $649.00 into $64,900, a hundredfold error that
 * would post silently. Two trailing digits after a single separator are always
 * cents here; a separator followed by three digits is a thousands group.
 */
function money(s: string | null | undefined): number | null {
  if (!s) return null;
  const cleaned = s.replace(/[\s$]/g, "");

  // 1.234,56 or 1,234.56 — grouped thousands plus optional cents.
  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);
  }

  // 649,00 / 649.00 — a single separator with exactly two digits after it.
  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;
}

/** Every capture of `pattern` across the page, in order. */
function allMatches(text: string, pattern: RegExp): string[] {
  const out: string[] = [];
  const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
  for (const m of text.matchAll(re)) {
    if (m[1]) out.push(m[1].trim());
  }
  return out;
}

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

/** Parses the three date shapes these statements actually print. */
export function parseDate(raw: string | null | undefined): Date | null {
  if (!raw) return null;
  const s = raw.trim().toUpperCase();

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

  // 22-JUL-2026  /  22 JUN 26  /  31/ENE/2026 (Tijuana predial)
  m = s.match(/^(\d{1,2})[-\s/]([A-Z]{3})[A-Z]*[-\s/](\d{2,4})$/);
  if (m && MONTHS[m[2]] !== undefined) {
    const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
    return utc(y, MONTHS[m[2]], +m[1]);
  }

  // 2026-07-22 (already normalised, e.g. decoded from a barcode) and the
  // 2026/08/08 the gas bill prints — same field order, different separator.
  m = s.match(/^(\d{4})[-/](\d{2})[-/](\d{2})$/);
  if (m) return utc(+m[1], +m[2] - 1, +m[3]);

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

/**
 * Read the value printed *underneath* a column header.
 *
 * The CESPT "RECIBO" is a table: `No. DE CUENTA` is a header cell and its value
 * sits in the row below it, so no amount of label-adjacent regex on line text
 * can associate the two. This walks the word boxes instead — find the header
 * word, then take the nearest word below it whose horizontal centre falls
 * within the column.
 */
export function valueUnder(
  page: OcrPage,
  header: RegExp,
  opts: { maxDy?: number; tolerance?: number; match?: RegExp } = {},
): string | null {
  const { maxDy = 300, tolerance = 200, match } = opts;
  const centre = (w: OcrWord) => ({
    x: w.left + w.width / 2,
    y: w.top + w.height / 2,
  });

  for (const h of page.words.filter((w) => header.test(w.text))) {
    const hc = centre(h);
    const below = page.words
      .filter((w) => {
        const c = centre(w);
        return c.y > hc.y && c.y <= hc.y + maxDy && Math.abs(c.x - hc.x) <= tolerance;
      })
      .sort((a, b) => centre(a).y - centre(b).y);

    for (const w of below) {
      if (!match || match.test(w.text)) return w.text;
    }
  }
  return null;
}

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

/**
 * Brand wordmarks first, page structure only as a fallback — and the two passes
 * must not be interleaved. Scanned logos OCR badly (one CESPT header came back
 * as "E BAJA ES PAGO / EALIFORNIA", with neither "CESPT" nor "COMISIÓN ESTATAL"
 * readable), so the structural pass is what rescues those pages. But a Telnor
 * bill contains the words "Pagar antes de", which a CFE structural rule
 * evaluated first will happily claim — running all brand checks before any
 * structural check is what keeps that from happening.
 */
const BRAND: [string, RegExp][] = [
  ["CFE", /comisi[oó]n federal de electricidad|CFE.?contigo|Suministrador de Servicios/i],
  ["CESPT", /CESPT|COMISI[OÓ]N ESTATAL DE SERVICIOS/i],
  ["TELNOR", /TELNOR|TELEFONOS DEL NOROESTE/i],
  ["GAS TIJUANA", /COMPA[ÑN][IÍ]?A\s*DE\s*GAS\s*DE\s*TIJUANA|bajagas/i],
  // Ahead of the predial rules on purpose. Tijuana's zona federal receipt is
  // issued by the same treasury and carries the same header — "Ayuntamiento de
  // Tijuana", the same address, the same `ATB-541201` RFC — so every predial
  // discriminator matches it too, and whichever rule is asked first wins the
  // page. What only the zona federal layout says is "Marítimo Terrestre", which
  // survived OCR on all eight sample pages even where the heading above it came
  // back as "Zona Ledera) Maritimo Terrestre" and the printed concession clave
  // was lost under a highlighter mark.
  ["ZONA FEDERAL TIJUANA", /ZOFEMAT|Mar[ií]timo\s*Terrestre|ocupaci[oó]n\s*de\s*Zona\s*Federal/i],
  // The municipal RFCs are the single most reliable discriminator on a predial
  // receipt: they are printed in a clean monospaced run on every layout, they
  // never change, and they say which of the three city treasuries issued the
  // page — which the wordmarks alone do not, since a Tijuana receipt also
  // carries "PLAYAS DE TIJUANA" and a Rosarito one "TIJUANA ENSENADA".
  ["PREDIAL TIJUANA", /AYUNTAMIENTO\s*DE\s*TIJUANA|ATB.?541201/i],
  ["PREDIAL ROSARITO", /AYUNTAMIENTO\s*MUNICIPAL\s*DE\s*PLAYAS\s*DE\s*ROSARITO|AMP.?981201|rosarito\.gob/i],
  ["PREDIAL ENSENADA", /MUNICIPIO\s*DE\s*ENSENADA|MEN.?540301/i],
];

/**
 * The predial rules come first because a Rosarito receipt prints "Clave
 * Catastral" as a boxed label — the very string the CESPT structural rule
 * looks for — so a page whose municipal header failed to OCR would otherwise
 * be claimed as a water bill and matched against the wrong column entirely.
 * "IMPUESTO PREDIAL" appears on all three municipal layouts and on none of the
 * utility ones, so it is the safe first question to ask.
 */
const LAYOUT: [string, RegExp][] = [
  // Same reasoning as the brand pass, one rule earlier: the concept line
  // "Derechos de ocupación de Zona Federal Marítimo Terrestre" is printed on
  // the stub of every zona federal page and on no other layout, and it read
  // cleanly on 8 of 8 samples — including the two whose heading did not.
  ["ZONA FEDERAL TIJUANA", /Derechos\s*de\s*ocupaci[oó]n/i],
  ["PREDIAL TIJUANA", /IMPUESTO\s*PREDIAL[\s\S]*?(?:CERTIFICACION\s*DE\s*CAJA|PASEO\s*DEL\s*CENTENARIO|PAGA\s*TU\s*PREDIAL)/i],
  ["PREDIAL ENSENADA", /(?:IMPUESTO\s*PREDIAL[\s\S]*?TRANSPENINSULAR)|(?:IMPRESION\s*MAQUINA\s*REGISTRADORA)/i],
  ["PREDIAL ROSARITO", /IMPUESTO\s*PREDIAL/i],
  ["GAS TIJUANA", /Orden\s*de\s*Facturaci[oó]n|FACTOR\s*DE\s*PRESI[OÓ]N|GAS\s*LP/i],
  ["CFE", /NO\.?\s*DE\s*SERVICIO|L[IÍ]MITE\s*DE\s*PAGO|PERIODO\s*FACTURADO/i],
  ["CESPT", /SALDO\s+CORRIENTE|CLAVE\s*CATASTRAL|No\.?\s*DE\s*CUENTA/i],
  ["TELNOR", /Mes\s*de\s*Facturaci[oó]n|Pagar\s*antes\s*de/i],
];

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

// --- CFE (electric) ---------------------------------------------------------

function parseCfe(page: OcrPage): ParsedStatement {
  const text = page.text;
  const notes: string[] = [];

  // The payment barcode line repeats the service number, the due date (YYMMDD)
  // and the amount in one fixed-width run, and reads far more reliably than the
  // label: on one sample the label came back as "0059603001917" (a digit too
  // many) while its barcode gave the correct "005960300191". So the barcode
  // wins, and the label becomes the cross-check rather than the source.
  const barcode = text.match(/\b01\s+([0-9OIlSBD]{12})\s+([0-9OIlSBD]{6})\s+([0-9OIlSBD]{9})\b/);
  const label = firstMatch(text, [/NO\.?\s*DE\s*SERVICIO\s*[:;.]?\s*([0-9OIlSBD]{10,14})/i]);

  let accountRef: string | null = null;
  let amount: number | null = null;
  let dueDate: Date | null = null;
  let crossChecked: boolean | null = null;

  if (barcode) {
    // Leading zeros are print padding: DATMEX.rpu holds the bare 10 digits.
    accountRef = toDigits(barcode[1]).replace(/^0+/, "");
    amount = Number(toDigits(barcode[3]));
    const d = toDigits(barcode[2]);
    dueDate = parseDate(`20${d.slice(0, 2)}-${d.slice(2, 4)}-${d.slice(4, 6)}`);
    notes.push("importe y vencimiento leídos del código de barras");
    if (label) {
      crossChecked = toDigits(label).replace(/^0+/, "") === accountRef;
      if (!crossChecked) {
        notes.push(
          `el número impreso (${toDigits(label).replace(/^0+/, "")}) no coincide con el código de barras`,
        );
      }
    }
  } else if (label) {
    accountRef = toDigits(label).replace(/^0+/, "");
    notes.push("sin código de barras legible; número tomado de la etiqueta");
  }

  if (amount == null) {
    amount = money(firstMatch(text, [/TOTAL\s*A\s*PAGAR\s*[:;.]?\s*\$?\s*([\d,]+\.?\d*)/i]));
  }
  if (!dueDate) {
    dueDate = parseDate(
      firstMatch(text, [/L[IÍ]MITE\s*DE\s*PAGO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2,4})/i]),
    );
  }

  return {
    provider: "CFE",
    serviceKind: "ELECTRIC",
    accountRef: accountRef || null,
    cadastralKey: null,
    amount,
    dueDate,
    period: firstMatch(text, [
      /PERIODO\s*FACTURADO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2}\s*-\s*\d{1,2}\s+\w{3}\s+\d{2})/i,
    ]),
    crossChecked,
    notes,
  };
}

// --- CESPT (water) ----------------------------------------------------------

/**
 * Two different layouts arrive under the same brand:
 *   - the line-oriented "COMPROBANTE DE PAGO" (`Cuenta : 7604192`), and
 *   - the tabular "RECIBO", where `No. DE CUENTA` is a column header.
 * Line patterns are tried first; anything they miss falls through to the
 * geometric read, which is what the tabular layout needs.
 */
function parseCespt(page: OcrPage): ParsedStatement {
  const text = page.text;
  const notes: string[] = [];

  let account = firstMatch(text, [/Cuenta\s*[:;.]?\s*([0-9OIlSBD]{5,9})/i]);
  if (!account) {
    account = valueUnder(page, /^CUENTA$/i, { match: /^[0-9OIlSBD]{5,9}$/ });
    if (account) notes.push("número de cuenta leído de la columna del recibo");
  }

  let clave = firstMatch(text, [/Cve\.?\s*Cat\.?\s*[:;.]?\s*([A-Z]{2}\s?[0-9OIlSBD]{6})/i]);
  if (!clave) {
    clave = valueUnder(page, /^CATASTRAL$/i, { match: /^[A-Z]{2}[0-9OIlSBD]{6}$/i });
    if (clave) notes.push("clave catastral leída de la columna del recibo");
  }

  let due = firstMatch(text, [/Fecha\s*Venc\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]);
  if (!due) due = valueUnder(page, /^VENCIMIENTO$/i, { match: /^\d{2}\/\d{2}\/\d{4}$/ });

  const amount = money(
    firstMatch(text, [
      /TOTAL\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i,
      /SALDO\s+CORRIENTE[^\n]*?([\d,]+\.\d{2})/i,
    ]),
  );

  // Leading zeros are print padding here too: the RECIBO prints `0457341` for
  // what DATMEX.agua holds as `457341`.
  const accountRef = account ? toDigits(account).replace(/^0+/, "") : null;
  const cadastralKey = clave
    ? clave.replace(/\s/g, "").slice(0, 2).toUpperCase() +
      toDigits(clave.replace(/\s/g, "").slice(2))
    : null;

  return {
    provider: "CESPT",
    serviceKind: "WATER",
    accountRef: accountRef || null,
    cadastralKey: cadastralKey || null,
    amount,
    dueDate: parseDate(due),
    period: null,
    crossChecked: null,
    notes,
  };
}

// --- TELNOR (telephone) -----------------------------------------------------

function parseTelnor(page: OcrPage): ParsedStatement {
  const text = page.text;
  const notes: string[] = [];

  const label = firstMatch(text, [
    /Tel[eé]fono\s*[:;.]?\s*([0-9OIlSBD]{3}\s?[0-9OIlSBD]{3}\s?[0-9OIlSBD]{4})/i,
  ]);
  // The payment stub prints phone (10 digits) + amount in cents (9) + a check
  // digit: `6646093444 000099900 7` for a $999.00 bill. Reading the amount as
  // 10 digits swallows the check digit and inflates the figure 100-fold.
  const barcode = text.match(/\b(\d{10})(\d{9})\d\b/);

  let accountRef: string | null = null;
  let crossChecked: boolean | null = null;

  // The bill prints the number with its 664 Tijuana LADA; DATMEX stores the
  // bare local 7 digits, so the LADA is dropped rather than the stored value
  // being padded — padding would guess at an area code for the 500+ existing
  // rows that never recorded one.
  if (label) accountRef = toDigits(label).slice(-7);
  if (barcode) {
    const fromBarcode = barcode[1].slice(-7);
    if (accountRef) {
      crossChecked = fromBarcode === accountRef;
      if (!crossChecked) notes.push("el teléfono impreso no coincide con el código de barras");
    } else {
      accountRef = fromBarcode;
      notes.push("teléfono leído del código de barras");
    }
  }

  let amount = money(firstMatch(text, [/Total\s*a\s*Pagar\s*[:;.]?\s*\$?\s*([\d,]+\.?\d{0,2})/i]));
  if (amount == null && barcode) {
    amount = Number(barcode[2]) / 100;
    notes.push("importe leído del código de barras");
  }

  return {
    provider: "TELNOR",
    serviceKind: "TELEPHONE",
    accountRef: accountRef || null,
    cadastralKey: null,
    amount,
    dueDate: parseDate(
      firstMatch(text, [/Pagar\s*antes\s*de\s*[:;.]?\s*(\d{2}-\w{3}-\d{4})/i]),
    ),
    period: firstMatch(text, [/Mes\s*de\s*Facturaci[oó]n\s*[:;.]?\s*(\w+)/i]),
    crossChecked,
    notes,
  };
}

// --- GAS (Compañía de Gas de Tijuana / bajagas) ------------------------------

/**
 * These arrive as born-digital CFDI PDFs rather than scans, so the text layer
 * (see `TesseractOcrProvider.textPages`) usually reads them exactly and the
 * patterns below only have to be tolerant enough for the scanned case.
 *
 * The account number is printed three times — supply address, fiscal data, and
 * the payment stub at the foot — which is a free cross-check: three readings
 * that agree are near-certainly right, and any disagreement means one of them
 * was misread and the page deserves a human glance.
 *
 * `Cuenta` is what the matcher compares, not `Contrato`. The migration
 * recovered gas references out of `PropertyService.notes` into `meterNumber`
 * and what sat there is the 9-digit account (`900003463`), printed here with a
 * leading zero as `0900003463`.
 */
function parseGas(page: OcrPage): ParsedStatement {
  const text = page.text;
  const notes: string[] = [];

  const seen = allMatches(text, /Cuenta\s*[:;.]?\s*([0-9OIlSBD]{6,12})/i).map((s) =>
    toDigits(s).replace(/^0+/, ""),
  );
  const distinct = [...new Set(seen.filter(Boolean))];

  let accountRef: string | null = null;
  let crossChecked: boolean | null = null;
  if (distinct.length === 1) {
    accountRef = distinct[0];
    if (seen.length > 1) crossChecked = true;
  } else if (distinct.length > 1) {
    // Majority wins — the stub and the two address blocks print the same
    // number, so a single divergent reading is the misread one. It still goes
    // to review: `crossChecked: false` is what keeps the batch from
    // auto-matching a number one of three readings disagreed with.
    const tally = new Map<string, number>();
    for (const s of seen) tally.set(s, (tally.get(s) ?? 0) + 1);
    accountRef = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0];
    crossChecked = false;
    notes.push(`el número de cuenta se leyó de ${distinct.length} formas distintas (${distinct.join(", ")})`);
  }

  const amount = money(
    firstMatch(text, [
      /TOTAL\s*A\s*PAGAR\s*[:;.]?\s*\$\s*([\d,]+\.\d{2})/i,
      /Total\s*a\s*pagar\s*[:;.]?\s*\$\s*([\d,]+\.\d{2})/i,
    ]),
  );

  // `20260630-20260630` — the range the bill was cut for. Both ends are the
  // same reading date on every sample, so the period is reported as the ISO
  // month rather than a range no ledger row would ever be searched by.
  const facturado = firstMatch(text, [/PERIODO\s*FACTURADO\s*[:;.]?\s*(\d{8})\s*-\s*\d{8}/i]);
  const period = facturado ? `${facturado.slice(0, 4)}-${facturado.slice(4, 6)}` : null;

  return {
    provider: "GAS TIJUANA",
    serviceKind: "GAS",
    accountRef: accountRef || null,
    cadastralKey: null,
    amount,
    dueDate: parseDate(
      firstMatch(text, [/Fecha\s*de\s*Vencimiento\s*[:;.]?\s*(\d{4}\s*\/\s*\d{2}\s*\/\s*\d{2})/i])?.replace(
        /\s/g,
        "",
      ),
    ),
    period,
    crossChecked,
    notes,
  };
}

// --- PREDIAL (municipal property tax) ---------------------------------------

/**
 * Normalise a printed clave catastral to the eight-character form
 * `Property.cadastralKey` holds. The municipalities print it grouped
 * (`KP-128-106`, `MM-B01-041`); the stored value drops the separators
 * (`KP128106`, `MMB01041`).
 *
 * The shape is *not* two letters and six digits, which is the assumption that
 * has to be resisted here. Across the 932 distinct claves on file, characters
 * four through eight are digits without exception, but the third is a digit in
 * 917 of them and one of `A`, `B`, `H`, `T` in the other fifteen. Running the
 * whole tail through `toDigits` — which maps `B` to `8` — is what turned a real
 * `MMB01041` into a nonexistent `MM801041`, so only positions four onward get
 * that treatment and a letter in the third position is kept as printed.
 *
 * That leaves a genuine ambiguity at that one position: a `B` there might be a
 * misread `8`, and 34 stored claves do carry an `8` there against six with a
 * `B`. It is left as read rather than guessed, because a page that fails to
 * match lands in the review queue where a human fixes it in seconds, while a
 * page that matches the wrong property posts a charge to the wrong customer.
 *
 * The two-letter prefix is the other fragile part. Tesseract inserts a spurious
 * `I` into letter pairs with some regularity — a real `MM-200-010` came back as
 * `MIM-200-010` — so a run longer than two letters has its `I`/`L` dropped
 * first, which recovers exactly that case. Anything still not two letters is
 * truncated and flagged, because a wrong prefix silently matches the wrong
 * property or, more often, nothing at all.
 */
export function normalizeCadastralKey(
  raw: string,
  notes: string[],
): string | null {
  const m = raw.match(/^([A-Za-z|]{2,5})[-\s]?([A-Za-z0-9|]{3})[-\s]?([0-9OIlSBD]{3})$/);
  if (!m) return null;

  let letters = m[1].toUpperCase().replace(/[^A-Z]/g, "");
  if (letters.length > 2) {
    const stripped = letters.replace(/[IL]/g, "");
    if (stripped.length === 2) {
      letters = stripped;
    } else {
      letters = letters.slice(0, 2);
      notes.push(`la clave catastral se leyó como "${m[1]}"; se tomó "${letters}"`);
    }
  }
  if (letters.length !== 2) return null;

  const third = m[2][0].toUpperCase();
  const tail =
    (/[A-Z]/.test(third) ? third : toDigits(third)) +
    toDigits(m[2].slice(1)) +
    toDigits(m[3]);

  return tail.length === 6 ? letters + tail : null;
}

/** The grouped clave as printed, anchored to its label when one survived OCR. */
const GROUPED_CLAVE = "[A-Z|]{2,5}-[A-Z0-9OIlSBD]{3}-[0-9OIlSBD]{3}";

function findCadastralKey(text: string, notes: string[]): string | null {
  const labelled = firstMatch(text, [
    new RegExp(`Clave\\s*Catastral\\s*[^A-Z0-9]{0,8}(${GROUPED_CLAVE})`, "i"),
    new RegExp(`CLAVE\\s*[^A-Z0-9]{0,8}(${GROUPED_CLAVE})`, "i"),
  ]);
  if (labelled) return normalizeCadastralKey(labelled, notes);

  // Ensenada's label ("CLAVE") lands inside a table header that OCRs into
  // noise more often than not, so the bare grouped shape is accepted as a
  // fallback. It is distinctive enough — two letters and two three-character
  // groups joined by hyphens appears nowhere else on these pages.
  const bare = firstMatch(text, [new RegExp(`\\b(${GROUPED_CLAVE})\\b`)]);
  return bare ? normalizeCadastralKey(bare, notes) : null;
}

/**
 * Tijuana: a "CERTIFICACIÓN DE CAJA" whose payment barcode is one 32-digit run
 * of `account(8) + due date(DDMMYY) + amount(9) + folio(9)`, verified against
 * all five sample pages. Municipal totals are whole pesos (the receipt itself
 * carries a "Redondeo" line), so the barcode amount needs no decimal point.
 *
 * No clave catastral is printed anywhere on this layout — the 8-digit
 * municipal account is the only identifier, and it is not a number the legacy
 * database ever held. Until a reviewer confirms one, every Tijuana page lands
 * in review; confirming teaches the matcher (see `learnAccountRefs`) so the
 * same property matches itself next year.
 */
function parsePredialTijuana(page: OcrPage): ParsedStatement {
  const text = page.text;
  const notes: string[] = [];

  const barcode = text.match(/(?<![0-9OIlSBD])([0-9OIlSBD]{32})(?![0-9OIlSBD])/);
  const printedTotal = money(
    firstMatch(text, [/TOTAL\s*A?\s*PAGAR\s*[:;.]?\s*\$?\s*([\d,]+\.?\d{0,2})/i]),
  );

  let accountRef: string | null = null;
  let amount: number | null = printedTotal;
  let dueDate: Date | null = null;
  let crossChecked: boolean | null = null;

  if (barcode) {
    const run = toDigits(barcode[1]);
    const d = run.slice(8, 14);
    const fromBarcode = Number(run.slice(14, 23));
    accountRef = run.slice(0, 8);
    dueDate = parseDate(`20${d.slice(4, 6)}-${d.slice(2, 4)}-${d.slice(0, 2)}`);
    notes.push("cuenta, importe y vencimiento leídos del código de barras");

    if (printedTotal != null) {
      // Guarding the money, not the account number: the printed total is the
      // figure a human would key, so when the two disagree one of them is a
      // misread peso amount and nothing should post unreviewed.
      crossChecked = Math.abs(printedTotal - fromBarcode) < 0.5;
      if (!crossChecked) {
        notes.push(
          `el total impreso (${printedTotal}) no coincide con el código de barras (${fromBarcode})`,
        );
      }
    }
    if (amount == null) amount = fromBarcode;
  }

  if (!dueDate) {
    dueDate = parseDate(
      firstMatch(text, [/FECHA\s*VENCE\s*[:;.]?\s*(\d{1,2}\/\w{3}\/\d{4})/i]),
    );
  }

  return {
    provider: "PREDIAL TIJUANA",
    serviceKind: "PROPERTY_TAX",
    accountRef: accountRef || null,
    cadastralKey: null,
    amount,
    dueDate,
    // The fiscal year, which is what the legacy ledger's `period` holds for
    // predial ("2026" is its single most common value). It is read from the
    // assessment table's year column, and failing that from the deadline: a
    // predial bill for year N falls due on 31 January of year N.
    period:
      firstMatch(text, [/VALOR\s*FISCAL[\s\S]{0,160}?\b(20\d{2})\b/i]) ??
      (dueDate ? String(dueDate.getUTCFullYear()) : null),
    crossChecked,
    notes,
  };
}

/**
 * Rosarito: a wide "CERTIFICACIÓN DE CAJA" keyed by clave catastral, with no
 * account number of its own — the clave is the identifier, which is exactly
 * what `Property.cadastralKey` holds, so these match on the first pass.
 *
 * The total is read with a negative lookbehind on "Sub": the receipt prints
 * `Sub Total $5,409.39` (before the peso rounding) directly above
 * `Total $5,409.00`, and taking the first "Total" on the page books 39 cents
 * that the municipality did not charge. The lookbehind allows zero spaces
 * because the label prints both ways — `Sub Total` on one sample and
 * `SubTotal` on the next, and the tight one is what slipped past a fixed
 * `Sub\s` and read $9,624.85 off a receipt for $9,625.00.
 */
function parsePredialRosarito(page: OcrPage): ParsedStatement {
  const notes: string[] = [];
  const text = page.text;

  return {
    provider: "PREDIAL ROSARITO",
    serviceKind: "PROPERTY_TAX",
    accountRef: null,
    cadastralKey: findCadastralKey(text, notes),
    amount: money(firstMatch(text, [/(?<!Sub\s{0,3})Total\s*[|:;.]?\s*\$\s*([\d,]+\.\d{2})/i])),
    // "EXTEMPORANEO DESPUES DE: 31/01/2026" — the leading E is regularly eaten
    // by the box rule printed over it, so the anchor starts at "XTEMPORANEO".
    dueDate: parseDate(
      firstMatch(text, [/XTEMPOR[AÁ]NEO\s*DESPU[EÉ]S\s*DE\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]),
    ),
    period: firstMatch(text, [/Periodo\s*por\s*Pagar\s*[:;.]?\s*(20\d{2})/i]),
    crossChecked: null,
    notes,
  };
}

/**
 * Ensenada: a dot-matrix "IMPRESION MAQUINA REGISTRADORA" statement, by some
 * distance the worst-scanning of the three. Matching is by clave catastral.
 *
 * The amount is read positionally rather than by label, because the label does
 * not survive: across five real pages the same word came back as `TOTAL:`,
 * `TOTA LA A` and `orAL:`. What is stable is the row — the summary line that
 * starts `TOTALES` carries the assessed figures across it and the amount
 * actually paid last, at the right margin.
 *
 * That last figure must carry a literal `$`. On a real sample the paid total
 * printed as `TOTAL: A $2,203.00` and OCR'd as `TOTAL: A 82,203.00` — the
 * dollar sign read as an 8, a mistake that would post a $2,203 charge as
 * $82,203 and look entirely ordinary in the ledger. Requiring the `$` costs
 * that page its amount and sends it to review, which is the only acceptable
 * failure here. The unprefixed figures earlier on the row are deliberately not
 * a fallback: they are the tax assessed before the early-payment discount, not
 * what was paid.
 */
function parsePredialEnsenada(page: OcrPage): ParsedStatement {
  const notes: string[] = [];
  const text = page.text;

  const totalsRow = text.split("\n").find((l) => /TOTALES/i.test(l)) ?? "";
  const figures = allMatches(totalsRow, /\$\s*(\d[\d,.\s]*\.\d{2})/);
  const amount = figures.length ? money(figures[figures.length - 1]) : null;
  if (amount == null) {
    notes.push("no se pudo leer el importe con certeza; capturarlo a mano");
  }

  return {
    provider: "PREDIAL ENSENADA",
    serviceKind: "PROPERTY_TAX",
    accountRef: null,
    cadastralKey: findCadastralKey(text, notes),
    amount,
    // This layout prints no payment deadline at all — it is a receipt for a
    // payment already made at the municipal window.
    dueDate: null,
    period: firstMatch(text, [/A[ÑN]O\s*[\s\S]{0,60}?\b(20\d{2})\b/i]),
    crossChecked: null,
    notes,
  };
}

// --- ZONA FEDERAL (ZOFEMAT, Tijuana) ----------------------------------------

/**
 * Normalise the concession clave the zona federal receipt is keyed by.
 *
 * It is printed grouped and loosely spaced — `12-T -012`, `14-A-119`,
 * `14-K -031` — and is a different shape from the cadastral key entirely: two
 * digits, one letter, three digits. The letter is kept as printed rather than
 * digitised, for the same reason `normalizeCadastralKey` keeps its third
 * character: `toDigits` maps `B` to `8` and `D` to `0`, and a real `14-D -014`
 * run through it becomes `140014`, which is not a clave at all.
 *
 * Stored without separators, because nothing on file holds this value yet (see
 * `parseZonaFederal`) so the canonical form is ours to pick, and a bare run
 * cannot be broken by the hyphen the scan renders as a dash, a minus or
 * nothing.
 */
export function normalizeZofematKey(raw: string): string | null {
  const m = raw.match(/^([0-9OIlSBD]{2})\s*-\s*([A-Za-z])\s*-?\s*([0-9OIlSBD]{3})$/);
  if (!m) return null;
  const zone = toDigits(m[1]);
  const lot = toDigits(m[3]);
  if (zone.length !== 2 || lot.length !== 3) return null;
  return `${zone}${m[2].toUpperCase()}${lot}`;
}

/**
 * The bimester the receipt prints as `2026-2 / 2026-2`, rendered in the
 * vocabulary the ledger already speaks.
 *
 * All 258 legacy FEDERAL ZONE transactions carry a period of `JAN/FEB`,
 * `MAR/APR`, `MAY/JUN` or `NOV/DEC`, and their payment dates confirm the
 * ordering — JAN/FEB was paid in March, MAR/APR in May, MAY/JUN in July,
 * NOV/DEC in January, i.e. always the month after the bimester closes. The
 * receipts agree: the two `2026-3` samples fall due 17/07/2026 with no
 * surcharge, which is bimester three, May and June. Writing `2026-3` instead
 * would leave the OCR-posted rows unsearchable alongside every hand-keyed one.
 */
const BIMESTERS = ["JAN/FEB", "MAR/APR", "MAY/JUN", "JUL/AUG", "SEP/OCT", "NOV/DEC"];

/**
 * Tijuana's "Zona Federal Marítimo Terrestre" — the federal maritime-zone
 * occupancy fee, billed by the municipality for beachfront lots.
 *
 * Nothing on file identifies these. `PropertyService.accountNumber` for
 * FEDERAL_ZONE holds DATMEX.zfed, which is not a reference at all but an
 * amount: its 77 values include `246.06`, `2369.09`, `22653.94` and a negative
 * `-1679`, and the concession claves these receipts are keyed by appear nowhere
 * in the database. So the clave goes to `meterNumber` (see `scopedRefField`),
 * every page starts cold, and the first confirm teaches the match — the same
 * arrangement Tijuana predial needed, for the same reason.
 *
 * The amount is taken from the SubTotal row rather than the "Total a pagar"
 * box, which is printed on a grey fill and OCR'd on only 1 of 8 sample pages
 * while the SubTotal row read on 8 of 8. The two differ by design: the
 * municipality rounds to whole pesos and prints the difference on its own
 * "Ajuste Ley Hacienda Mpal" line — `-$0.05` against a 591.05 subtotal, `$0.21`
 * against 2,872.79 — so the payable figure is the rounded subtotal, and where
 * the printed box did read, it agreed.
 */
function parseZonaFederal(page: OcrPage): ParsedStatement {
  const text = page.text;
  const notes: string[] = [];

  // Printed twice, once on the receipt and once on the stub below it, which is
  // a free second reading: on one sample the heading was struck through by the
  // office's own highlighter and only the stub survived.
  const claves = [
    ...new Set(
      allMatches(text, /Clave\s*[:;.]?\s*([0-9OIlSBD]{2}\s*-\s*[A-Za-z]\s*-?\s*[0-9OIlSBD]{3})/i)
        .map(normalizeZofematKey)
        .filter((k): k is string => k != null),
    ),
  ];

  const accountRef = claves[0] ?? null;
  let crossChecked: boolean | null = null;

  const subtotalRow = text.split("\n").find((l) => /SubTotal/i.test(l)) ?? "";
  const figures = allMatches(subtotalRow, /(\d[\d,]*\.\d{2})/);
  // Impuesto, Actualización, Recargo, Multa, Importe — the payable one is last.
  const importe = figures.length ? money(figures[figures.length - 1]) : null;
  const rounded = importe != null ? Math.round(importe) : null;
  const printed = money(
    firstMatch(text, [/Total\s*a\s*pagar\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i]),
  );

  if (printed != null && rounded != null) {
    crossChecked = Math.abs(printed - rounded) < 0.5;
    if (!crossChecked) {
      notes.push(
        `el total impreso (${printed}) no coincide con el subtotal redondeado (${rounded})`,
      );
    }
  } else if (rounded != null) {
    notes.push("importe tomado del subtotal, redondeado al peso");
  } else if (printed == null) {
    notes.push("no se pudo leer el importe con certeza; capturarlo a mano");
  }

  // A clave read two different ways means one of the two readings is wrong and
  // there is no third to break the tie, so the page goes to a human even if the
  // money cross-checked.
  if (claves.length > 1) {
    crossChecked = false;
    notes.push(`la clave se leyó de ${claves.length} formas distintas (${claves.join(", ")})`);
  }
  if (!accountRef) notes.push("no se pudo leer la clave de la concesión");

  const bimester = text.match(/\b(20\d{2})\s*-\s*([1-6])\s*\/\s*20\d{2}\s*-\s*[1-6]/);

  return {
    provider: "ZONA FEDERAL TIJUANA",
    serviceKind: "FEDERAL_ZONE",
    accountRef,
    cadastralKey: null,
    amount: printed ?? rounded,
    dueDate: parseDate(
      firstMatch(text, [/Vencimiento\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]),
    ),
    period: bimester ? BIMESTERS[+bimester[2] - 1] : null,
    crossChecked,
    notes,
  };
}

const PARSERS: Record<string, (page: OcrPage) => ParsedStatement> = {
  CFE: parseCfe,
  CESPT: parseCespt,
  TELNOR: parseTelnor,
  "GAS TIJUANA": parseGas,
  "PREDIAL TIJUANA": parsePredialTijuana,
  "PREDIAL ROSARITO": parsePredialRosarito,
  "PREDIAL ENSENADA": parsePredialEnsenada,
  "ZONA FEDERAL TIJUANA": parseZonaFederal,
};

const EMPTY: ParsedStatement = {
  provider: null,
  serviceKind: null,
  accountRef: null,
  cadastralKey: null,
  amount: null,
  dueDate: null,
  period: null,
  crossChecked: null,
  notes: [],
};

/** Detect the provider and run its parser. */
export function parseStatement(page: OcrPage): ParsedStatement {
  const provider = detectProvider(page.text);
  if (!provider) return { ...EMPTY, notes: ["no se reconoció el proveedor"] };
  return PARSERS[provider](page);
}
