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

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/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 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 parseGmx(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, [
    /\bPolicy\s+([0-9OIlSBD]{3,4}[-\s][0-9OIlSBD]{3}[-\s][0-9OIlSBD]{8}[-\s][0-9OIlSBD]{4}[-\s][0-9OIlSBD]{2})/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);
  const 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/);
    if (m) 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: policyNumber ? policyNumber.replace(/\s+/g, "") : null,
    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;
}