// ============================================================================
// UOB credit-card statement PDF importer
// ============================================================================
// UOB e-statements bundle ALL of your UOB credit cards into ONE PDF. Each card
// has its own section delimited by a banner like "PREFERRED PLATINUM VISA"
// followed by "<card-number> <name on card>". Sections end with "SUB TOTAL"
// and "TOTAL BALANCE FOR <CARD NAME>". The whole document closes with
// "End of Transaction Details".
//
// This file:
//   - Loads pdfjs-dist from CDN lazily (shares the same global cache as the
//     Citi importer — if Citi already loaded it, we skip the network round-trip)
//   - Parses the multi-card statement, classifying each row by which UOB card
//     it belongs to AND by merchant (category + bonus eligibility)
//   - Renders a preview with per-card subtotal reconciliation
//   - Bulk-inserts approved rows as predicted transactions, each with the
//     correct card_id + statement_date tag
//
// Exposes:
//   window.parseUobStatement(file) → Promise<{ok, statementDate, sections, ...}>
//   window.UobStatementImporter (React component, embedded in CardsView header)
//
// PDF layout notes (validated against the Jan 2026 PPV-only sample):
//   - Header: "Statement Date <DD MMM YYYY>" (e.g. "26 JAN 2026")
//   - Per-row: "<Post Date> <Trans Date> <Description> <Amount>"
//     where dates are "DD MMM" (no year — inferred from statement date)
//   - "Ref No. : <digits>" appears on the line below — ignored
//   - Foreign currency: "USD 21.80" appears on the line after the SGD amount
//     for FX transactions — folded into note as "FX: USD 21.80"
//   - "<amount> CR" suffix = credit (payment, refund); we negate the amount
//   - "PREVIOUS BALANCE" row has no dates — skipped
//   - Payment rows ("PAYMT THRU E-BANK/HOMEB/CYBERB") — skipped (handled
//     separately as transfers in LEDGER, just like Citi's "PAYMENT - THANK YOU")
//
// Card-name → LEDGER card slug mapping is data-driven: we look up cards in
// FIN_DATA.CARDS by matching the banner text to card.name (case-insensitive).
// New UOB cards (or rebrands) just need a row in the cards table — no code
// change needed here.
// ============================================================================

// ─── pdfjs lazy loader ──────────────────────────────────────────────────────
// Reuses window.pdfjsLib if the Citi importer already loaded it. Same CDN +
// version pin (3.11.174 UMD build — the last release with pdf.min.js global).
let _uobPdfjsPromise = null;
function loadPdfjsForUob() {
  if (_uobPdfjsPromise) return _uobPdfjsPromise;
  _uobPdfjsPromise = new Promise((resolve, reject) => {
    if (window.pdfjsLib) { resolve(window.pdfjsLib); return; }
    const script = document.createElement("script");
    script.src = "https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/legacy/build/pdf.min.js";
    script.onload = () => {
      const lib = window.pdfjsLib;
      if (!lib) { reject(new Error("pdfjs loaded but window.pdfjsLib missing")); return; }
      lib.GlobalWorkerOptions.workerSrc =
        "https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/legacy/build/pdf.worker.min.js";
      resolve(lib);
    };
    script.onerror = () => reject(new Error("Failed to load pdfjs from CDN"));
    document.head.appendChild(script);
  });
  return _uobPdfjsPromise;
}

// ─── Skip rules ─────────────────────────────────────────────────────────────
// A row is skipped if its description (uppercased) starts with one of these.
// These are structural/payment rows that should NOT become tx entries:
//   - PREVIOUS BALANCE: opening balance carry-forward, no real tx
//   - PAYMT THRU E-BANK/HOMEB/CYBERB: bill payment (we record as transfer
//     separately, just like Citi's "PAYMENT - THANK YOU")
//   - SUB TOTAL / TOTAL BALANCE / End of Transaction Details: section markers
const SKIP_PREFIXES = [
  "PREVIOUS BALANCE",
  "PAYMT THRU E-BANK",
  "PAYMENT - THANK YOU", // just in case UOB ever uses the Citi-style wording
];

// Structural marker lines — never become rows even if they slip through.
const STRUCTURAL_LINES = [
  "SUB TOTAL", "SUBTOTAL", "TOTAL BALANCE FOR",
  "End of Transaction Details",
];

// ─── Card-section banner detection ──────────────────────────────────────────
// UOB statements declare each card's section with a banner like:
//   "PREFERRED PLATINUM VISA"
// followed by:
//   "4265-8820-1431-7898 ALESSANDRO"
// On subsequent pages the same banner reappears with "(continued)" appended
// to the card-number line, e.g.:
//   "PREFERRED PLATINUM VISA"
//   "4265-8820-1431-7898 ALESSANDRO (continued)"
//
// We match banners by looking for lines that look like a UOB card product
// name followed (within a couple of lines) by a "DDDD-DDDD-DDDD-DDDD" pattern.
// The product-name match is loose so it works for any UOB credit card:
//   - PREFERRED PLATINUM VISA
//   - KRISFLYER WORLD MASTERCARD (or similar)
//   - LADY'S CARD / LADY'S SOLITAIRE
//   - PRVI MILES (VISA / WORLD MASTERCARD / AMEX)
//   - ONE CARD (this is the debit slug uob-one — but if it ever appears in
//     a credit statement, the user can ignore it in the preview)
const CARD_BANNER_RE = /^([A-Z][A-Z'\s]+(?:VISA|MASTERCARD|AMEX|CARD))\s*$/;
const CARD_NUMBER_RE = /^(\d{4}-\d{4}-\d{4}-\d{4})\s+([A-Z][A-Z\s]+?)(?:\s+\(continued\))?\s*$/;

// Try to map a banner string to a LEDGER card slug by searching FIN_DATA.CARDS.
// Returns { cardId, cardName, matched } or null if no card matches.
//
// Matching strategy:
//   1. Normalize banner: uppercase, strip "VISA"/"MASTERCARD"/"AMEX"/"CARD",
//      compact whitespace.
//   2. Find a credit card in FIN_DATA.CARDS whose name (similarly normalized)
//      contains or is contained-in the banner core.
// We prefer the longest match so "PREFERRED PLATINUM VISA" matches "Preferred
// Platinum" rather than "Platinum" (if both existed).
function bannerToCardId(banner) {
  if (!banner || !window.FIN_DATA?.CARDS) return null;
  const normalize = (s) => s
    .toUpperCase()
    .replace(/\b(VISA|MASTERCARD|AMEX|WORLD|CARD)\b/g, " ")
    .replace(/[^A-Z0-9'\s]/g, " ")
    .replace(/\s{2,}/g, " ")
    .trim();
  const bannerCore = normalize(banner);
  if (!bannerCore) return null;

  let best = null;
  for (const c of window.FIN_DATA.CARDS) {
    if (c.type !== "credit") continue;
    // Only consider UOB cards — UOB ids start with "uob-" by convention.
    if (!c.id.startsWith("uob-")) continue;
    const nameCore = normalize(c.name || "");
    if (!nameCore) continue;
    // Both directions: banner contains name, or name contains banner.
    const matches = bannerCore.includes(nameCore) || nameCore.includes(bannerCore);
    if (matches) {
      if (!best || nameCore.length > best.nameCoreLen) {
        best = { cardId: c.id, cardName: c.name, nameCoreLen: nameCore.length };
      }
    }
  }
  return best ? { cardId: best.cardId, cardName: best.cardName, matched: true } : null;
}

// ─── Merchant classification ────────────────────────────────────────────────
// Same shape as the Citi importer: { match, category, bonusEligible, label }.
// First match wins, so order matters.
// We use a conservative default ("food" + bonusEligible=true) — the user
// reviews every row in the preview before insert anyway.
const MERCHANT_RULES = [
  // Donations
  { match: /SHARETHEMEAL|WFP/i,            category: "donations",     bonusEligible: false, label: "ShareTheMeal" },
  { match: /THEWATERPROJECT/i,             category: "donations",     bonusEligible: false, label: "The Water Project" },

  // Subscriptions
  { match: /APPLE\.COM|APPLE\s+ITUNES/i,   category: "subscriptions", bonusEligible: false, label: "Apple" },
  { match: /SPOTIFY/i,                     category: "subscriptions", bonusEligible: true,  label: "Spotify" },
  { match: /NETFLIX/i,                     category: "subscriptions", bonusEligible: true,  label: "Netflix" },
  { match: /OPENAI|CHATGPT/i,              category: "subscriptions", bonusEligible: true,  label: "OpenAI" },
  { match: /XIAOMI/i,                      category: "subscriptions", bonusEligible: true,  label: "Xiaomi" },

  // Transport
  { match: /GRAB\b|^GRAB$/i,               category: "transport",     bonusEligible: true,  label: "Grab" },
  { match: /GOPAY-?GOJEK|GOJEK/i,          category: "transport",     bonusEligible: true,  label: "Gojek" },
  { match: /CABCHARGE/i,                   category: "transport",     bonusEligible: true,  label: "Cabcharge" },
  { match: /SIMPLYGO|EZ-LINK|EZLINK/i,     category: "transport",     bonusEligible: true,  label: "SimplyGo" },

  // Groceries
  { match: /NTUC|FAIRPRICE/i,              category: "groceries",     bonusEligible: true,  label: "NTUC FairPrice" },
  { match: /COLD STORAGE|GIANT|SHENGSIONG/i, category: "groceries",   bonusEligible: true,  label: "Grocery" },

  // Shopping
  { match: /SHOPEE/i,                      category: "shopping",      bonusEligible: true,  label: "Shopee" },
  { match: /LAZADA/i,                      category: "shopping",      bonusEligible: true,  label: "Lazada" },
  { match: /WATSONS/i,                     category: "shopping",      bonusEligible: true,  label: "Watsons" },
  { match: /KINOKUNIYA/i,                  category: "shopping",      bonusEligible: true,  label: "Kinokuniya" },
  { match: /L'OCCITANE|LOCCITANE/i,        category: "shopping",      bonusEligible: true,  label: "L'Occitane" },
  { match: /LADERACH/i,                    category: "shopping",      bonusEligible: true,  label: "Laderach" },
  { match: /POKEMON/i,                     category: "shopping",      bonusEligible: true,  label: "Pokemon" },
  { match: /VENUS BEAUTY/i,                category: "shopping",      bonusEligible: true,  label: "Venus Beauty" },

  // Travel
  { match: /KLOOK/i,                       category: "travel",        bonusEligible: false, label: "Klook" },
  { match: /AGODA|EXPEDIA/i,               category: "travel",        bonusEligible: true,  label: "Hotel booking" },

  // Sports / wellness
  { match: /PADEL|MINT MEDIA SPORTS/i,     category: "sports",        bonusEligible: true,  label: "Padel" },
  { match: /LIMITLESS PHYSIO|PHYSIO/i,     category: "health",        bonusEligible: true,  label: "Physio" },

  // Food (these are FOOD but the default is already food, listed for clarity)
  { match: /KOPITIAM|FOOD REPUBLIC|FOODCOURT/i, category: "food",     bonusEligible: true,  label: "Food court" },
  { match: /LUCKIN COFFEE|CHAGEE|STARBUCKS|COFFEE/i, category: "food", bonusEligible: true, label: "Coffee" },
  { match: /SUSHI|RAMEN|SUKIYA|GENKI/i,    category: "food",          bonusEligible: true,  label: "Japanese" },
  { match: /PARADISE|UNIQLO|OLD CHANG KEE/i, category: "food",        bonusEligible: true,  label: "Restaurant" },
];

function classifyMerchant(desc) {
  const up = (desc || "").toUpperCase();
  for (const rule of MERCHANT_RULES) {
    if (rule.match.test(up)) {
      return { category: rule.category, bonusEligible: rule.bonusEligible, matched: true };
    }
  }
  return { category: "food", bonusEligible: true, matched: false };
}

// ─── PDF text extraction ────────────────────────────────────────────────────
// Same y-grouping technique as the Citi importer — UOB's PDF also embeds each
// character as a separate text item, so we cluster by y-coordinate and only
// insert spaces when inter-character gaps exceed ~30% of font height.
async function extractTextLines(pdfFile) {
  const pdfjs = await loadPdfjsForUob();
  const buf = await pdfFile.arrayBuffer();
  const pdf = await pdfjs.getDocument({ data: buf }).promise;

  // Returned as array of { page, line } so we can later distinguish page
  // boundaries if needed. For now we flatten into a single ordered list.
  const lines = [];
  for (let i = 1; i <= pdf.numPages; i++) {
    const page = await pdf.getPage(i);
    const content = await page.getTextContent();
    const byY = new Map();
    for (const item of content.items) {
      if (!item.str) continue;
      const y = Math.round(item.transform[5]);
      if (!byY.has(y)) byY.set(y, []);
      byY.get(y).push({
        x: item.transform[4],
        w: item.width || 0,
        h: item.height || item.transform[3] || 10,
        s: item.str,
      });
    }
    const yKeys = [...byY.keys()].sort((a, b) => b - a);
    for (const y of yKeys) {
      const items = byY.get(y).sort((a, b) => a.x - b.x);
      if (items.length === 0) continue;
      let line = items[0].s;
      let prevEnd = items[0].x + (items[0].w || items[0].h * items[0].s.length * 0.5);
      for (let k = 1; k < items.length; k++) {
        const it = items[k];
        const gap = it.x - prevEnd;
        const sizeEstimate = it.h || items[0].h || 10;
        if (gap > sizeEstimate * 0.3) line += " ";
        line += it.s;
        prevEnd = it.x + (it.w || sizeEstimate * 0.5 * it.s.length);
      }
      const cleaned = line.replace(/\s{2,}/g, "  ").trim();
      if (cleaned) lines.push(cleaned);
    }
  }
  return lines;
}

// Parse "Statement Date 26 JAN 2026" → "2026-01-26".
// PDF layout can split the label and the date onto separate y-lines, so we
// scan inline first, then walk forward looking for "DD MMM YYYY" alone.
function parseStatementDate(lines) {
  const MONTHS = { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 };
  const buildIso = (day, mAbbrev, yr) => {
    const mo = MONTHS[mAbbrev.toUpperCase()];
    if (!mo) return null;
    return {
      iso: `${yr}-${String(mo).padStart(2, "0")}-${String(parseInt(day, 10)).padStart(2, "0")}`,
      year: parseInt(yr, 10),
      month: mo,
      day: parseInt(day, 10),
    };
  };
  // Inline first: "Statement Date 26 JAN 2026"
  for (const line of lines) {
    const m = line.match(/Statement Date\s+(\d{1,2})\s+([A-Z]{3})\s+(\d{4})/i);
    if (m) {
      const r = buildIso(m[1], m[2], m[3]);
      if (r) return r;
    }
  }
  // Fallback: "Statement Date" on one line, "DD MMM YYYY" within next few
  for (let i = 0; i < lines.length; i++) {
    if (/Statement Date/i.test(lines[i])) {
      for (let j = i; j < Math.min(i + 6, lines.length); j++) {
        const m = lines[j].match(/^\s*(\d{1,2})\s+([A-Z]{3})\s+(\d{4})\s*$/i);
        if (m) {
          const r = buildIso(m[1], m[2], m[3]);
          if (r) return r;
        }
      }
    }
  }
  return null;
}

// Parse "TOTAL BALANCE FOR <CARD NAME> VISA <amount>" lines. Each card section
// ends with one of these, giving us a per-card reconciliation target.
// Returns a map: { "<UPPERCASE CARD NAME>": amount }
function parseCardSubtotals(lines) {
  const subtotals = {};
  for (let i = 0; i < lines.length; i++) {
    const m = lines[i].match(/TOTAL BALANCE FOR\s+(.+?)\s+(-?\d{1,3}(?:,\d{3})*\.\d{2})\s*(CR)?\s*$/i);
    if (m) {
      const key = m[1].toUpperCase().trim();
      let amount = parseFloat(m[2].replace(/,/g, ""));
      if (m[3]) amount = -amount;
      subtotals[key] = amount;
    } else if (/^TOTAL BALANCE FOR\b/i.test(lines[i])) {
      // Label and amount on adjacent y-lines — scan ±4 lines
      const labelMatch = lines[i].match(/^TOTAL BALANCE FOR\s+(.+?)\s*$/i);
      if (!labelMatch) continue;
      const key = labelMatch[1].toUpperCase().trim();
      if (!key || subtotals[key] != null) continue;
      for (let delta = 1; delta <= 4; delta++) {
        for (const j of [i + delta, i - delta]) {
          if (j < 0 || j >= lines.length) continue;
          const m2 = lines[j].match(/^\s*(-?\d{1,3}(?:,\d{3})*\.\d{2})\s*(CR)?\s*$/);
          if (m2) {
            let amount = parseFloat(m2[1].replace(/,/g, ""));
            if (m2[2]) amount = -amount;
            subtotals[key] = amount;
            break;
          }
        }
        if (subtotals[key] != null) break;
      }
    }
  }
  return subtotals;
}

// ─── Row extraction ─────────────────────────────────────────────────────────
// Walk through lines, track which card section we're in, and emit rows.
// Each row gets stamped with the current cardId (from the most recent banner).
//
// Returns array of:
//   { sectionKey, banner, cardId, cardName, matched,
//     postDateRaw, transDateRaw, postDate, transDate, desc, amount, isCredit, foreignNote }
// where postDate/transDate are ISO strings (year inferred from stmtDate).
function extractRowsByCard(lines, stmtDate) {
  const MONTHS = { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 };

  // Row pattern: "<DD> <MMM> <DD> <MMM> <description> <amount>[ CR]"
  // Example: "02 JAN 30 DEC POKEMON SINGAPORE PTE LTDSINGAPORE 26.00"
  //          "02 JAN 02 JAN PAYMT THRU E-BANK/HOMEB/CYBERB (EP09) 769.06 CR"
  const ROW_RE = /^(\d{1,2})\s+(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\s+(\d{1,2})\s+(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\s+(.+)$/i;
  // Trailing amount with optional CR suffix. CR = credit (payment/refund).
  const TRAILING_AMT_RE = /\s+(-?\d{1,3}(?:,\d{3})*\.\d{2})(\s+CR)?\s*$/i;
  // "Ref No. : 7454..." — pure metadata, drop
  const REF_RE = /^Ref No\.?\s*:/i;
  // FX line: amount on the next line in format "<CCY> <number>" (e.g. "USD 21.80")
  const FX_RE = /^([A-Z]{3})\s+(\d+(?:\.\d+)?)\s*$/;

  const rows = [];
  let curCardBanner = null;
  let curCardInfo = null;
  let lastRow = null;
  let sectionCounter = 0;
  let curSectionKey = null;

  // Year inference for DD MMM dates. statementDate is the anchor:
  //   - month <= stmtDate.month  →  current year
  //   - month  > stmtDate.month  →  previous year (Jan stmt with Dec rows)
  // Example: stmtDate = 26 JAN 2026 → "30 DEC" rows are 2025, "02 JAN" rows
  // are 2026.
  const inferYear = (rowMonthNum) => {
    if (!stmtDate) return new Date().getFullYear();
    return rowMonthNum > stmtDate.month ? stmtDate.year - 1 : stmtDate.year;
  };
  const toIso = (day, mAbbrev) => {
    const mo = MONTHS[mAbbrev.toUpperCase()];
    if (!mo) return null;
    const yr = inferYear(mo);
    return `${yr}-${String(mo).padStart(2, "0")}-${String(parseInt(day, 10)).padStart(2, "0")}`;
  };

  for (let i = 0; i < lines.length; i++) {
    const raw = lines[i];

    // Detect end-of-statement marker — stop processing
    if (/End of Transaction Details/i.test(raw)) break;

    // Detect a card-section banner: a uppercase product-name line followed
    // within the next 2 lines by a "DDDD-DDDD-DDDD-DDDD <NAME>" line.
    const bm = raw.match(CARD_BANNER_RE);
    if (bm) {
      // Look ahead 1-3 lines for the card-number line
      for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
        if (CARD_NUMBER_RE.test(lines[j])) {
          // Confirm this is a new banner (not the "(continued)" repeat of the
          // current section). If it's the same banner text, keep the same
          // sectionKey so reconciliation merges page-spilled rows together.
          if (bm[1].trim() !== curCardBanner) {
            curCardBanner = bm[1].trim();
            curCardInfo = bannerToCardId(curCardBanner);
            sectionCounter += 1;
            curSectionKey = `sec${sectionCounter}:${curCardBanner}`;
          }
          break;
        }
      }
      continue;
    }

    // Skip ref-number metadata
    if (REF_RE.test(raw)) continue;

    // FX continuation: "USD 21.80" on its own y-line, attach to previous row
    const fm = raw.match(FX_RE);
    if (fm && lastRow) {
      lastRow.foreignNote = `FX: ${fm[1].toUpperCase()} ${fm[2]}`;
      continue;
    }

    // Skip structural marker lines
    if (STRUCTURAL_LINES.some(s => raw.toUpperCase().includes(s.toUpperCase()))) {
      lastRow = null;
      continue;
    }

    // Try to match a transaction row
    const m = raw.match(ROW_RE);
    if (!m) continue;

    const postDay = m[1];
    const postMon = m[2];
    const transDay = m[3];
    const transMon = m[4];
    const rest = m[5].trim();

    // Extract trailing amount
    const am = rest.match(TRAILING_AMT_RE);
    if (!am) continue;
    const isCredit = !!am[2]; // "CR" suffix present
    const amtRaw = am[1];
    const amtNum = parseFloat(amtRaw.replace(/,/g, ""));
    const amount = isCredit ? -amtNum : amtNum;
    const desc = rest.slice(0, rest.length - am[0].length).trim();

    // Apply skip rules (PREVIOUS BALANCE, PAYMT THRU E-BANK, etc.)
    const descUp = desc.toUpperCase();
    if (SKIP_PREFIXES.some(p => descUp.startsWith(p))) {
      lastRow = null;
      continue;
    }

    const postDate = toIso(postDay, postMon);
    const transDate = toIso(transDay, transMon);
    if (!postDate || !transDate) continue;

    const row = {
      sectionKey: curSectionKey,
      banner: curCardBanner,
      cardId: curCardInfo?.cardId || null,
      cardName: curCardInfo?.cardName || null,
      matched: !!curCardInfo,
      postDateRaw: `${postDay} ${postMon.toUpperCase()}`,
      transDateRaw: `${transDay} ${transMon.toUpperCase()}`,
      postDate,
      transDate,
      desc,
      amount,
      isCredit,
      foreignNote: null,
    };
    rows.push(row);
    lastRow = row;
  }

  return rows;
}

// Top-level parser. Returns:
//   { ok: true, statementDate, sections: [{banner, cardId, cardName, matched, rows, subtotal, parsedTotal}], unmatchedRows? }
//   { ok: false, error }
async function parseUobStatement(file) {
  try {
    const lines = await extractTextLines(file);
    const stmtDate = parseStatementDate(lines);
    if (!stmtDate) {
      return { ok: false, error: "Couldn't find statement date in PDF." };
    }

    const subtotals = parseCardSubtotals(lines);
    const rawRows = extractRowsByCard(lines, stmtDate);

    // Group rows by sectionKey so the preview can render per-card subsections.
    // Order is preserved by first-seen sectionKey.
    const sectionOrder = [];
    const sectionMap = {};
    for (const r of rawRows) {
      const key = r.sectionKey || "unmatched";
      if (!sectionMap[key]) {
        sectionOrder.push(key);
        sectionMap[key] = {
          sectionKey: key,
          banner: r.banner,
          cardId: r.cardId,
          cardName: r.cardName,
          matched: r.matched,
          rows: [],
        };
      }
      sectionMap[key].rows.push(r);
    }

    // Build the preview row shape for each section
    const sections = sectionOrder.map(key => {
      const s = sectionMap[key];
      const rows = s.rows.map((r, idx) => {
        const cls = classifyMerchant(r.desc);
        // Clean merchant name: strip trailing location/country tokens.
        // UOB descriptions often have "<MERCHANT> <CITY/COUNTRY>" smushed
        // together, e.g. "POKEMON SINGAPORE PTE LTDSINGAPORE" or
        // "GENKI SUSHI-PLAZA SINGAPUSINGAPORE". The trailing SINGAPORE
        // gets stripped, leaving the merchant body. Also handle "Singapore"
        // case variants and other common country/city codes.
        let merchant = r.desc
          .replace(/SINGAPORE\s+\d+\s*$/i, "")          // "WOODERFUL LIFE SINGAPORE 179"
          .replace(/\s*-\s*Singapore\s*$/i, "")          // "GRB* Desert Dessert - Singapore"
          .replace(/SINGAPORE\s*$/i, "")                 // trailing "SINGAPORE"
          .replace(/Singapore\s*$/, "")                  // mixed case
          .replace(/\bSINGAPU?SINGAPORE\b/i, "")         // "GENKI SUSHI-PLAZA SINGAPUSINGAPORE"
          .replace(/\bSINGAPORE\s+\d{3}\b/i, "")         // "LUCKY CAFE & RESTAURANT PSINGAPORE 048"
          .replace(/\s+(?:SG|US|GB|UK|MY|JP|HK|TH|ID|VN|CN|TW|KR|AU|FR|DE|ES|NL|AE|N\/A)\s*$/i, "")
          .replace(/\s+OPENAI\.COM\s*$/i, "")
          .replace(/\s{2,}/g, " ")
          .trim();
        return {
          id: `${key}:${idx}`,
          sectionKey: key,
          postDate: r.postDate,
          transDate: r.transDate,
          postDateRaw: r.postDateRaw,
          transDateRaw: r.transDateRaw,
          date: r.transDate,             // Trans Date is the saved tx.date
          desc: r.desc,                  // raw description (tooltip)
          merchant,                      // cleaned merchant for LEDGER
          amount: r.amount,              // signed (negative for CR rows)
          category: cls.category,
          bonusEligible: cls.bonusEligible,
          matched: cls.matched,
          foreignNote: r.foreignNote,
          include: true,
        };
      });

      // Subtotal lookup — match by banner text (uppercased + trimmed)
      const bannerKey = (s.banner || "").toUpperCase().trim();
      const subtotal = subtotals[bannerKey] != null ? subtotals[bannerKey] : null;
      const parsedTotal = rows.filter(r => r.include).reduce((sum, r) => sum + r.amount, 0);

      return {
        ...s,
        rows,
        subtotal,
        parsedTotal,
      };
    });

    return {
      ok: true,
      statementDate: stmtDate.iso,
      sections,
    };
  } catch (err) {
    console.error("parseUobStatement failed:", err);
    return { ok: false, error: err.message || String(err) };
  }
}

window.parseUobStatement = parseUobStatement;

// ============================================================================
// React component — top-level CardsView import section
// ============================================================================
//
// Lives at the top of the Cards view (or wherever CardsView mounts it). The
// component manages its own modal-style overlay: closed by default, opened
// via a button. Once open, drag/drop or file-pick a PDF → preview → bulk
// insert. Each card gets its own subsection in the preview with its own
// reconciliation against the PDF's "TOTAL BALANCE FOR" line.
function UobStatementImporter({ onClose }) {
  const { CATEGORIES } = window.FIN_DATA;
  const [parsing, setParsing] = React.useState(false);
  const [result, setResult] = React.useState(null);
  const [inserting, setInserting] = React.useState(false);
  const [insertError, setInsertError] = React.useState(null);
  const [insertedCount, setInsertedCount] = React.useState(null);
  const [, forceTick] = React.useReducer(x => x + 1, 0);
  const fileInputRef = React.useRef(null);

  const handleFile = async (file) => {
    if (!file) return;
    setParsing(true);
    setResult(null);
    setInsertError(null);
    setInsertedCount(null);
    try {
      const res = await window.parseUobStatement(file);
      setResult(res);
    } catch (err) {
      setResult({ ok: false, error: err.message || String(err) });
    } finally {
      setParsing(false);
    }
  };

  const onDrop = (e) => {
    e.preventDefault();
    e.stopPropagation();
    const file = e.dataTransfer?.files?.[0];
    if (file && /\.pdf$/i.test(file.name)) handleFile(file);
  };
  const onPickFile = (e) => {
    const file = e.target.files?.[0];
    if (file) handleFile(file);
    e.target.value = "";
  };
  const reset = () => {
    setResult(null);
    setInsertError(null);
    setInsertedCount(null);
  };

  // ESC closes the modal
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  // Lock body scroll while modal is open
  React.useEffect(() => {
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.body.style.overflow = prev; };
  }, []);

  // Insert all included rows across all sections as predicted transactions.
  // Each row uses its section's cardId; rows from unmatched sections are
  // skipped (the preview shows a warning).
  const handleInsert = async () => {
    if (!result || !result.ok) return;
    const toInsert = [];
    for (const section of result.sections) {
      if (!section.cardId) continue; // skip unmatched sections
      for (const r of section.rows) {
        if (!r.include) continue;
        toInsert.push({ row: r, cardId: section.cardId });
      }
    }
    if (toInsert.length === 0) {
      setInsertError("No rows selected (or no cards matched).");
      return;
    }
    setInserting(true);
    setInsertError(null);
    try {
      const payloads = toInsert.map(({ row, cardId }) => {
        const noteParts = [];
        if (row.foreignNote) noteParts.push(row.foreignNote);
        // Post Date stored in note so we don't lose it (Trans Date goes in
        // the date column). Format: "Posted 02 Jan"
        if (row.postDateRaw && row.postDateRaw !== row.transDateRaw) {
          noteParts.push(`Posted ${row.postDateRaw}`);
        }
        noteParts.push(`Imported from UOB statement ${result.statementDate}`);
        return {
          date: row.date,                  // Trans Date (ISO)
          type: "expense",
          amount: row.amount,              // signed; negative = refund/credit
          currency: "SGD",
          amount_sgd: row.amount,
          fx_rate: 1,
          merchant: row.merchant || row.desc,
          category_id: row.category,
          card_id: cardId,
          bonus_eligible: row.bonusEligible,
          note: noteParts.join(" · "),
          status: "predicted",
          // Statement-date tag: guarantees the card detail modal's statement
          // chart bar matches exactly what UOB printed, regardless of how
          // UOB's cutoff varies month to month.
          statement_date: result.statementDate,
        };
      });
      const inserted = await window.bulkInsertTransactions(payloads);
      setInsertedCount(inserted.length);
      if (window.refetchData) window.refetchData();
    } catch (err) {
      console.error("Bulk insert failed:", err);
      setInsertError(err.message || String(err));
    } finally {
      setInserting(false);
    }
  };

  // ─── Render ───────────────────────────────────────────────────────────────
  return (
    <div
      onClick={onClose}
      style={{
        position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)",
        display: "flex", alignItems: "flex-start", justifyContent: "center",
        zIndex: 1000, padding: "5vh 24px", overflowY: "auto",
        backdropFilter: "blur(4px)",
      }}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          background: "var(--bg-1, var(--bg))",
          border: "1px solid var(--border)",
          borderRadius: 14,
          width: "100%",
          maxWidth: 980,
          padding: 24,
          display: "flex", flexDirection: "column", gap: 16,
          boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
        }}
      >
        {/* Header */}
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16 }}>
          <div>
            <div style={{ fontSize: 18, fontWeight: 600, color: "var(--text-1)", letterSpacing: "-0.01em" }}>
              Import UOB statement
            </div>
            <div style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 4 }}>
              Drop a UOB e-statement PDF to auto-extract transactions across all UOB cards on the statement.
            </div>
          </div>
          <button
            onClick={onClose}
            className="btn-ghost"
            style={{ fontSize: 12, padding: "5px 10px", display: "inline-flex", alignItems: "center", gap: 4 }}
          >
            <Icon name="x" size={12}/> Close
          </button>
        </div>

        {!result && !parsing && (
          <div
            onClick={() => fileInputRef.current?.click()}
            onDragOver={(e) => { e.preventDefault(); }}
            onDrop={onDrop}
            style={{
              padding: 28,
              border: "1.5px dashed var(--border-subtle)",
              borderRadius: 10,
              textAlign: "center",
              cursor: "pointer",
              background: "var(--bg-muted)",
              transition: "border-color 0.15s",
            }}
            onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--text-muted)"; }}
            onMouseLeave={(e) => { e.currentTarget.style.borderColor = ""; }}
          >
            <Icon name="arrow-up" size={20}/>
            <div style={{ fontSize: 13, color: "var(--text-2)", marginTop: 8 }}>
              Drop a PDF here, or click to browse
            </div>
            <input
              ref={fileInputRef}
              type="file"
              accept="application/pdf,.pdf"
              onChange={onPickFile}
              style={{ display: "none" }}
            />
          </div>
        )}

        {parsing && (
          <div style={{ padding: 28, textAlign: "center", fontSize: 13, color: "var(--text-muted)" }}>
            Parsing PDF…
          </div>
        )}

        {result && !result.ok && (
          <div style={{ padding: 14, background: "var(--bg-muted)", borderRadius: 8, fontSize: 13, color: "var(--danger, #c33)" }}>
            Couldn't parse: {result.error}
          </div>
        )}

        {result && result.ok && insertedCount === null && (
          <UobPreviewSections
            result={result}
            categories={CATEGORIES}
            onRowChange={() => forceTick()}
            onInsert={handleInsert}
            inserting={inserting}
            insertError={insertError}
            onReset={reset}
          />
        )}

        {insertedCount !== null && (
          <div style={{ padding: 14, background: "var(--bg-muted)", borderRadius: 8, fontSize: 13 }}>
            <div style={{ fontWeight: 600, color: "var(--text-1)", marginBottom: 4 }}>
              <Icon name="check" size={13}/> Inserted {insertedCount} transaction{insertedCount === 1 ? "" : "s"} as predicted.
            </div>
            <div style={{ color: "var(--text-muted)", fontSize: 12 }}>
              Review them in each card's detail modal — confirm or edit tags (online/contactless, region, etc.) as needed.
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

// ─── Preview sections (one per card) ────────────────────────────────────────
function UobPreviewSections({ result, categories, onRowChange, onInsert, inserting, insertError, onReset }) {
  const totalParsed = result.sections.reduce((s, sec) => s + sec.rows.filter(r => r.include).reduce((x, r) => x + r.amount, 0), 0);
  const totalIncluded = result.sections.reduce((s, sec) => s + sec.rows.filter(r => r.include).length, 0);
  const unmatchedSections = result.sections.filter(s => !s.cardId);

  return (
    <div>
      {/* Statement summary */}
      <div style={{
        display: "grid",
        gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))",
        gap: 12,
        padding: 14,
        background: "var(--bg-muted)",
        borderRadius: 8,
        marginBottom: 14,
      }}>
        <MiniStat label="Statement date" value={result.statementDate}/>
        <MiniStat label="Cards in statement" value={String(result.sections.length)}/>
        <MiniStat label="Rows to insert" value={String(totalIncluded)}/>
        <MiniStat label="Total amount" value={fmt.sgd(totalParsed, { decimals: 2 })}/>
      </div>

      {/* Unmatched-section warning */}
      {unmatchedSections.length > 0 && (
        <div style={{
          padding: "9px 12px",
          background: "var(--warning-soft, rgba(234,179,8,0.12))",
          border: "1px solid var(--warning-border, #c93)",
          borderRadius: 7,
          fontSize: 12,
          color: "var(--warning, #c93)",
          marginBottom: 12,
        }}>
          ⚠ Couldn't match {unmatchedSections.length} card section{unmatchedSections.length === 1 ? "" : "s"} to a LEDGER card: {unmatchedSections.map(s => s.banner).join(", ")}. Rows in these sections will not be imported. Check that the card exists in your Cards table and its name matches the PDF banner.
        </div>
      )}

      {/* Per-card sections */}
      <div style={{ display: "flex", flexDirection: "column", gap: 14, maxHeight: "55vh", overflowY: "auto" }}>
        {result.sections.map(section => (
          <UobCardSection
            key={section.sectionKey}
            section={section}
            categories={categories}
            onRowChange={onRowChange}
          />
        ))}
      </div>

      {/* Action row */}
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 14, gap: 12, paddingTop: 12, borderTop: "1px solid var(--border-subtle)" }}>
        <div style={{ fontSize: 12, color: "var(--text-muted)" }}>
          {insertError && <span style={{ color: "var(--danger, #c33)" }}>{insertError}</span>}
        </div>
        <div style={{ display: "flex", gap: 8 }}>
          <button onClick={onReset} className="btn-ghost" style={{ fontSize: 12, padding: "7px 12px" }}>
            <Icon name="x" size={11}/> Clear
          </button>
          <button
            onClick={onInsert}
            disabled={inserting || totalIncluded === 0}
            className="btn-primary"
            style={{ fontSize: 12, padding: "7px 14px" }}
          >
            {inserting ? "Inserting…" : `Insert ${totalIncluded} as predicted →`}
          </button>
        </div>
      </div>
    </div>
  );
}

function UobCardSection({ section, categories, onRowChange }) {
  const includedRows = section.rows.filter(r => r.include);
  const parsedTotal = includedRows.reduce((s, r) => s + r.amount, 0);
  const matches = section.subtotal != null && Math.abs(parsedTotal - section.subtotal) < 0.02;

  // Bulk toggle: include/exclude all rows in this section
  const allIncluded = section.rows.every(r => r.include);
  const noneIncluded = section.rows.every(r => !r.include);
  const toggleAll = () => {
    const next = !allIncluded;
    for (const r of section.rows) r.include = next;
    onRowChange();
  };

  return (
    <div style={{
      border: "1px solid var(--border-subtle)",
      borderRadius: 8,
      background: "var(--bg)",
      overflow: "hidden",
    }}>
      {/* Card section header */}
      <div style={{
        padding: "10px 14px",
        background: section.cardId ? "var(--bg-muted)" : "var(--warning-soft, rgba(234,179,8,0.12))",
        borderBottom: "1px solid var(--border-subtle)",
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        gap: 10,
      }}>
        <div style={{ minWidth: 0 }}>
          <div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-1)" }}>
            {section.cardName || section.banner || "Unknown card"}
            {!section.cardId && (
              <span style={{ marginLeft: 8, fontSize: 10, fontWeight: 600, color: "var(--warning, #c93)", textTransform: "uppercase", letterSpacing: "0.04em" }}>
                Unmatched
              </span>
            )}
          </div>
          <div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 2 }}>
            {section.rows.length} row{section.rows.length === 1 ? "" : "s"} ({includedRows.length} selected)
            {section.subtotal != null && (
              <> · UOB sub-total: <span style={{ fontVariantNumeric: "tabular-nums" }}>{fmt.sgd(section.subtotal, { decimals: 2 })}</span></>
            )}
          </div>
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          {section.subtotal != null && (
            <div style={{
              fontSize: 11,
              fontWeight: 600,
              padding: "3px 8px",
              borderRadius: 4,
              fontVariantNumeric: "tabular-nums",
              background: matches ? "var(--success-soft, rgba(34,197,94,0.12))" : "var(--warning-soft, rgba(234,179,8,0.12))",
              color: matches ? "var(--success, #22c55e)" : "var(--warning, #c93)",
            }}>
              {matches
                ? `✓ ${fmt.sgd(parsedTotal, { decimals: 2 })}`
                : `Δ ${fmt.sgd(parsedTotal - section.subtotal, { decimals: 2 })}`}
            </div>
          )}
          <button
            onClick={toggleAll}
            className="btn-ghost"
            style={{ fontSize: 11, padding: "3px 8px" }}
          >
            {allIncluded ? "Deselect all" : "Select all"}
          </button>
        </div>
      </div>

      {/* Row table */}
      <div style={{ maxHeight: 280, overflowY: "auto" }}>
        <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 11 }}>
          <thead style={{ position: "sticky", top: 0, background: "var(--bg-muted)", zIndex: 1 }}>
            <tr>
              <th style={thStyle} title="Include in import"></th>
              <th style={thStyle}>Trans</th>
              <th style={thStyle}>Post</th>
              <th style={thStyle}>Merchant</th>
              <th style={thStyle}>Category</th>
              <th style={{ ...thStyle, textAlign: "center" }} title="Bonus eligible">★</th>
              <th style={{ ...thStyle, textAlign: "right" }}>Amount</th>
            </tr>
          </thead>
          <tbody>
            {section.rows.map(r => (
              <tr
                key={r.id}
                style={{
                  opacity: r.include ? 1 : 0.4,
                  borderTop: "1px solid var(--border-subtle)",
                }}
              >
                <td style={tdStyle}>
                  <input
                    type="checkbox"
                    checked={r.include}
                    onChange={() => { r.include = !r.include; onRowChange(); }}
                    disabled={!section.cardId}
                    title={!section.cardId ? "Card unmatched — cannot import" : ""}
                  />
                </td>
                <td style={tdStyle}>{r.transDateRaw}</td>
                <td style={{ ...tdStyle, color: "var(--text-muted)" }}>{r.postDateRaw}</td>
                <td style={tdStyle} title={r.desc}>
                  <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
                    <span>{r.merchant}</span>
                    {!r.matched && (
                      <span title="No merchant rule matched — default category applied"
                            style={{ fontSize: 9, color: "var(--text-muted)", padding: "1px 5px", border: "1px solid var(--border-subtle)", borderRadius: 3 }}>
                        ?
                      </span>
                    )}
                    {r.foreignNote && (
                      <span title={r.foreignNote}
                            style={{ fontSize: 9, color: "var(--text-muted)", padding: "1px 5px", border: "1px solid var(--border-subtle)", borderRadius: 3 }}>
                        FX
                      </span>
                    )}
                  </div>
                </td>
                <td style={tdStyle}>
                  <select
                    value={r.category}
                    onChange={(e) => { r.category = e.target.value; onRowChange(); }}
                    style={selectStyle}
                  >
                    {categories.filter(c => !c.isIncome).map(c => (
                      <option key={c.id} value={c.id}>
                        {c.emoji ? `${c.emoji} ${c.name}` : c.name}
                      </option>
                    ))}
                  </select>
                </td>
                <td style={{ ...tdStyle, textAlign: "center" }}>
                  <input
                    type="checkbox"
                    checked={r.bonusEligible}
                    onChange={() => { r.bonusEligible = !r.bonusEligible; onRowChange(); }}
                    disabled={r.category === "donations"}
                    title={r.category === "donations" ? "Donations are excluded by category" : "Toggle bonus eligibility"}
                  />
                </td>
                <td style={{ ...tdStyle, textAlign: "right", fontVariantNumeric: "tabular-nums" }}>
                  {r.amount < 0 ? `(${fmt.sgd(Math.abs(r.amount), { decimals: 2 })})` : fmt.sgd(r.amount, { decimals: 2 })}
                </td>
              </tr>
            ))}
            {section.rows.length === 0 && (
              <tr><td colSpan={7} style={{ ...tdStyle, textAlign: "center", color: "var(--text-muted)", padding: 14 }}>
                No transactions in this section.
              </td></tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// ─── Tiny presentational helpers ────────────────────────────────────────────
function MiniStat({ label, value, tone }) {
  const color = tone === "warn" ? "var(--warning, #c93)"
              : tone === "ok"   ? "var(--text-1)"
              : "var(--text-1)";
  return (
    <div>
      <div style={{ fontSize: 10, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 2 }}>
        {label}
      </div>
      <div style={{ fontSize: 12, fontWeight: 600, color, fontVariantNumeric: "tabular-nums" }}>
        {value}
      </div>
    </div>
  );
}

const thStyle = {
  textAlign: "left",
  padding: "7px 9px",
  fontSize: 10,
  fontWeight: 600,
  color: "var(--text-muted)",
  textTransform: "uppercase",
  letterSpacing: "0.04em",
};
const tdStyle = {
  padding: "6px 9px",
  color: "var(--text-1)",
};
const selectStyle = {
  padding: "3px 6px",
  fontSize: 11,
  background: "var(--bg-muted)",
  border: "1px solid transparent",
  borderRadius: 4,
  color: "var(--text-1)",
  fontFamily: "inherit",
  cursor: "pointer",
  outline: "none",
};

window.UobStatementImporter = UobStatementImporter;
