// ============================================================================
// Citi Rewards statement PDF importer
// ============================================================================
// Loads pdfjs-dist from CDN lazily on first use, parses the e-statement text,
// classifies each row by merchant, shows a preview table, and bulk-inserts
// approved rows into LEDGER as predicted transactions on the Citi Rewards card.
//
// Exposes:
//   window.parseCitiStatement(file) → Promise<{rows, summary, error?}>
//   window.CitiStatementImporter (React component, embedded in CardDetailModal)
//
// Statement layout (validated against Mar/Apr 2026 statements):
//   - Date format: "DD MON" (two-digit day, three-letter month, no year)
//   - Year inferred from "Statement Date <Month> <DD>, <YYYY>" header.
//     Rows in the statement can be from the prior month/year — we walk
//     backwards from the statement date to assign years correctly.
//   - "(amount)" with parens = credit (negative). e.g. payment, reversals.
//   - "FOREIGN AMOUNT <CCY-NAME> <amount>" appears BELOW the parent row in
//     pdfjs text extraction; folded into the parent as a note.
//   - "XXXX-XXXX-XXXX-NNNN" (Klook supplementary card ref) is ignored.
//
// Skipped rows (never inserted into LEDGER):
//   - PAYMENT - THANK YOU (bill payment, handled separately as transfer)
//   - ANNUAL MEMBERSHIP FEE, GST ON ANNUAL MEMBERSHIP FEE
//   - ANNUAL MEMBER FEE REV, GST ON MEMBERSHIP FEE REVERSAL
//   - CCY CONVERSION FEE (negligible per user)
//   - BALANCE PREVIOUS STATEMENT, SUB-TOTAL, GRAND TOTAL (statement structure)
// ============================================================================

// ─── pdfjs lazy loader ──────────────────────────────────────────────────────
// Cache the promise so multiple calls don't re-trigger the CDN load.
let _pdfjsPromise = null;
function loadPdfjs() {
  if (_pdfjsPromise) return _pdfjsPromise;
  _pdfjsPromise = 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@4.0.379/build/pdf.min.js";
    script.onload = () => {
      const lib = window.pdfjsLib;
      if (!lib) { reject(new Error("pdfjs loaded but window.pdfjsLib missing")); return; }
      // Worker is required even for simple text extraction. Use the same CDN.
      lib.GlobalWorkerOptions.workerSrc =
        "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.0.379/build/pdf.worker.min.js";
      resolve(lib);
    };
    script.onerror = () => reject(new Error("Failed to load pdfjs from CDN"));
    document.head.appendChild(script);
  });
  return _pdfjsPromise;
}

// ─── Skip rules ─────────────────────────────────────────────────────────────
// A row matches a skip rule if its description (uppercased) starts with one
// of these prefixes. Skipped rows never appear in the preview.
const SKIP_PREFIXES = [
  "PAYMENT - THANK YOU",
  "ANNUAL MEMBERSHIP FEE",
  "GST ON ANNUAL MEMBERSHIP FEE",
  "ANNUAL MEMBER FEE REV",
  "GST ON MEMBERSHIP FEE REVERSAL",
  "CCY CONVERSION FEE",
  "BALANCE PREVIOUS STATEMENT",
];

// Lines that should never become rows even if they slip past structural parsing.
const STRUCTURAL_LINES = ["SUB-TOTAL:", "GRAND TOTAL", "SUB-TOTAL"];

// ─── Merchant classification ────────────────────────────────────────────────
// Each rule has a regex tested against the uppercased description.
// First match wins, so order matters: donations before transport, etc.
const MERCHANT_RULES = [
  // Donations — auto-excluded from points (category 'donations')
  { match: /SHARETHEMEAL|WFP/i,            category: "donations", bonusEligible: false, label: "ShareTheMeal" },
  { match: /THEWATERPROJECT/i,             category: "donations", bonusEligible: false, label: "The Water Project" },

  // 1x base (Citi explicitly excludes these from 10x)
  { match: /APPLE\.COM|APPLE\s+ITUNES/i,   category: "subscriptions", bonusEligible: false, label: "Apple" },
  { match: /KLOOK/i,                       category: "travel",        bonusEligible: false, label: "Klook" },

  // 10x eligible — common merchants
  { match: /SPOTIFY/i,                     category: "subscriptions", bonusEligible: true,  label: "Spotify" },
  { 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: /SHOPEE/i,                      category: "shopping",      bonusEligible: true,  label: "Shopee" },
];

// Classify a description into { category, bonusEligible }.
// Falls back to category='food', bonus_eligible=true if no rule matches.
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 → row array ───────────────────────────────────────────────────
// pdfjs returns text items page-by-page. We join all items with spaces and
// then split by newlines (using y-coordinate heuristics) to reconstruct the
// statement's table.
async function extractTextLines(pdfFile) {
  const pdfjs = await loadPdfjs();
  const buf = await pdfFile.arrayBuffer();
  const pdf = await pdfjs.getDocument({ data: buf }).promise;

  const lines = [];
  for (let i = 1; i <= pdf.numPages; i++) {
    const page = await pdf.getPage(i);
    const content = await page.getTextContent();
    // Group items by approximate y coordinate. Citi's PDF embeds each
    // character as its own text item with its own x — naive join with " "
    // produces "C i t i b a n k". We measure inter-item gaps and only
    // insert a space when the gap exceeds ~30% of font height.
    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 the statement date "Statement Date April 15, 2026" → "2026-04-15"
// PDF layout can split "Statement Date" and the date onto separate y-lines,
// so we try inline first, then walk forward looking for "<Month> <DD>, <YYYY>".
function parseStatementDate(lines) {
  const months = {
    JANUARY: 1, FEBRUARY: 2, MARCH: 3, APRIL: 4, MAY: 5, JUNE: 6,
    JULY: 7, AUGUST: 8, SEPTEMBER: 9, OCTOBER: 10, NOVEMBER: 11, DECEMBER: 12,
  };
  const buildIso = (mName, day, yr) => {
    const mo = months[mName.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,
    };
  };
  // Inline first
  for (const line of lines) {
    const m = line.match(/Statement Date\s+([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})/i);
    if (m) {
      const r = buildIso(m[1], m[2], m[3]);
      if (r) return r;
    }
  }
  // Fallback: find "Statement Date", then scan next few lines for date alone
  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*([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})/);
        if (m) {
          const r = buildIso(m[1], m[2], m[3]);
          if (r) return r;
        }
      }
    }
  }
  return null;
}

// Parse the PURCHASES & ADVANCES summary box on page 2.
// Layout: "PREVIOUS BAL - PAYMENTS&CREDITS + PURCHASES&ADV + INTEREST + FEES = CURRENT"
// Values line example: "612.28 612.28 452.43 0.00 -194.97 257.46"
// The PDF often splits the column headers across multiple y-lines, so we
// scan a wider window forward from the first ADVANCES/PURCHASES mention.
function parseSummaryTotals(lines) {
  for (let i = 0; i < lines.length; i++) {
    if (/ADVANCES/i.test(lines[i]) || /PURCHASES/i.test(lines[i])) {
      for (let j = i + 1; j < Math.min(i + 15, lines.length); j++) {
        // Skip lines that contain words — we're looking for the numbers-only row
        if (/[A-Za-z]{4,}/.test(lines[j])) continue;
        const nums = lines[j].match(/-?\d{1,3}(?:,\d{3})*\.\d{2}/g);
        if (nums && nums.length >= 5) {
          return {
            previousBalance: parseFloat(nums[0].replace(/,/g, "")),
            paymentsCredits: parseFloat(nums[1].replace(/,/g, "")),
            purchasesAdvances: parseFloat(nums[2].replace(/,/g, "")),
            interest: parseFloat(nums[3].replace(/,/g, "")),
            fees: parseFloat(nums[4].replace(/,/g, "")),
            currentBalance: nums[5] ? parseFloat(nums[5].replace(/,/g, "")) : null,
          };
        }
      }
    }
  }
  return null;
}

// Parse "GRAND TOTAL <amount>". Layout sometimes splits label and amount
// onto adjacent y-lines.
function parseGrandTotal(lines) {
  for (let i = lines.length - 1; i >= 0; i--) {
    const m = lines[i].match(/GRAND TOTAL\s+(-?\d{1,3}(?:,\d{3})*\.\d{2})/i);
    if (m) return parseFloat(m[1].replace(/,/g, ""));
    if (/^GRAND TOTAL\s*$|GRAND TOTAL\s+[A-Za-z]/i.test(lines[i])) {
      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*$/);
          if (m2) return parseFloat(m2[1].replace(/,/g, ""));
        }
      }
    }
  }
  return null;
}

// Parse "Bonus POINTS earned this month" from the THANKYOU POINTS BALANCE
// summary table. Values appear like "57,191 340 3,033 0 0 0 60,564" on the
// line after the column headers.
function parsePointsSummary(lines) {
  for (let i = 0; i < lines.length; i++) {
    if (/THANKYOU POINTS BALANCE/i.test(lines[i])) {
      // Walk forward looking for a line with 7 numbers (the values row)
      for (let j = i + 1; j < Math.min(i + 15, lines.length); j++) {
        const nums = lines[j].match(/\d{1,3}(?:,\d{3})*|\d+/g);
        if (nums && nums.length >= 7) {
          return {
            carriedForward: parseInt(nums[0].replace(/,/g, ""), 10),
            earned: parseInt(nums[1].replace(/,/g, ""), 10),
            bonus: parseInt(nums[2].replace(/,/g, ""), 10),
            adjusted: parseInt(nums[3].replace(/,/g, ""), 10),
            redeemed: parseInt(nums[4].replace(/,/g, ""), 10),
            expired: parseInt(nums[5].replace(/,/g, ""), 10),
            totalAvailable: parseInt(nums[6].replace(/,/g, ""), 10),
          };
        }
      }
    }
  }
  return null;
}

// Walk lines and extract transaction rows.
// Returns array of { dateRaw, desc, amount, foreignNote, isCredit }
// where amount is signed (negative for credits).
function extractRawRows(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 };
  // Match "DD MON ..." at start of line. Capture the rest of the line.
  // Examples: "27 MAR Grab* A-9555KW8GW2A3AV Singapore SG 15.60"
  //           "23 MAR ANNUAL MEMBER FEE REV (180.00)"
  //           "30 MAR GRAB MAKATI PH 3.14"
  const ROW_RE = /^(\d{1,2})\s+(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\s+(.+)$/i;
  // Trailing amount at end of line. Allow "(N.NN)" for credits.
  const TRAILING_AMT_RE = /\s+(\(?-?\d{1,3}(?:,\d{3})*\.\d{2}\)?)\s*$/;
  // Foreign amount line: "FOREIGN AMOUNT PHILIPPINE PESO 142.14"
  const FOREIGN_RE = /^FOREIGN AMOUNT\s+(.+?)\s+(\d+(?:\.\d+)?)$/i;
  // Klook secondary line "XXXX-XXXX-XXXX-1324" — ignore
  const XXXX_RE = /^X{4}(-X{4}){2}-\d{4}$/i;

  const rows = [];
  let lastRow = null; // to attach FOREIGN AMOUNT lines

  // Determine year: rows can be from stmtDate's month, or prior months.
  // We walk through in document order and use a falling-month heuristic:
  // when month decreases or we see Dec after Jan/Feb, the year rolls back.
  // Start year = stmtDate.year; track last month seen.
  let curYear = stmtDate ? stmtDate.year : new Date().getFullYear();
  let lastMonth = stmtDate ? stmtDate.month : 12;

  for (const raw of lines) {
    // Foreign-amount continuation
    const fm = raw.match(FOREIGN_RE);
    if (fm && lastRow) {
      lastRow.foreignNote = `FX: ${fm[1].toUpperCase()} ${fm[2]}`;
      continue;
    }
    if (XXXX_RE.test(raw.trim())) continue;
    if (STRUCTURAL_LINES.some(s => raw.toUpperCase().includes(s))) continue;

    const m = raw.match(ROW_RE);
    if (!m) continue;

    const day = parseInt(m[1], 10);
    const mon = MONTHS[m[2].toUpperCase()];
    const rest = m[3].trim();

    // Walk year backwards if month decreased significantly (e.g. we saw
    // March rows and now see December — that's prior year).
    // Statements usually list earliest first, so months generally INCREASE
    // through the doc. We handle the prior-year case: if first row is
    // Feb and stmt is March, those Feb rows are same year. But if a Jan
    // statement has Dec rows, those are prior year.
    if (stmtDate) {
      // If row month > statement month, must be from prior year
      // (e.g. Jan stmt showing Dec rows)
      if (mon > stmtDate.month) {
        curYear = stmtDate.year - 1;
      } else {
        curYear = stmtDate.year;
      }
    }
    lastMonth = mon;

    const iso = `${curYear}-${String(mon).padStart(2, "0")}-${String(day).padStart(2, "0")}`;

    // Extract trailing amount
    const am = rest.match(TRAILING_AMT_RE);
    if (!am) {
      // No amount on this line — skip (probably a header or weird artifact)
      continue;
    }
    const amtRaw = am[1];
    const isCredit = amtRaw.startsWith("(") && amtRaw.endsWith(")");
    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
    const descUp = desc.toUpperCase();
    if (SKIP_PREFIXES.some(p => descUp.startsWith(p))) {
      lastRow = null;
      continue;
    }

    const row = {
      dateRaw: `${m[1]} ${m[2].toUpperCase()}`,
      date: iso,
      desc,
      amount,
      isCredit,
      foreignNote: null,
    };
    rows.push(row);
    lastRow = row;
  }

  return rows;
}

// Top-level parser. Returns:
//   { ok: true, statementDate, rows: [{date, desc, amount, category, bonusEligible, foreignNote, include}], summary, points }
//   { ok: false, error }
async function parseCitiStatement(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 summary = parseSummaryTotals(lines);
    const grandTotal = parseGrandTotal(lines);
    const points = parsePointsSummary(lines);
    const raw = extractRawRows(lines, stmtDate);

    // Classify each row and build the preview shape
    const rows = raw.map((r, idx) => {
      const cls = classifyMerchant(r.desc);
      // Build a clean merchant name. Strip trailing location tokens like
      // "Singapore SG", "MAKATI PH", "CORK IE", "STOCKHOLM SE", "ROMA IT",
      // "CONCORD US" — keep just the merchant.
      let merchant = r.desc
        .replace(/\s+(?:[A-Z][A-Za-z]+\s+)?(?:SG|IE|US|SE|IT|PH|MY|JP|HK|TH|ID|VN|CN|TW|KR|AU|GB|UK|FR|DE|ES|NL|AE)\s*$/i, "")
        .trim();
      // Compact whitespace runs
      merchant = merchant.replace(/\s{2,}/g, " ");
      return {
        id: idx,
        date: r.date,
        desc: r.desc,           // original PDF description (for tooltip)
        merchant,               // cleaned for LEDGER merchant field
        amount: r.amount,       // signed (negative for credits)
        category: cls.category,
        bonusEligible: cls.bonusEligible,
        matched: cls.matched,
        foreignNote: r.foreignNote,
        include: true,          // user can untick to skip a row
      };
    });

    return {
      ok: true,
      statementDate: stmtDate.iso,
      rows,
      summary,
      grandTotal,
      points,
    };
  } catch (err) {
    console.error("parseCitiStatement failed:", err);
    return { ok: false, error: err.message || String(err) };
  }
}

window.parseCitiStatement = parseCitiStatement;

// ============================================================================
// React component — embedded section inside CardDetailModal
// ============================================================================
function CitiStatementImporter({ cardId }) {
  const { CATEGORIES, SETTINGS } = window.FIN_DATA;
  const [parsing, setParsing] = React.useState(false);
  const [result, setResult] = React.useState(null);   // {ok, rows, summary, statementDate, ...}
  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.parseCitiStatement(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 = ""; // allow re-selecting same file
  };

  const reset = () => {
    setResult(null);
    setInsertError(null);
    setInsertedCount(null);
  };

  // Insert all included rows as predicted transactions
  const handleInsert = async () => {
    if (!result || !result.ok) return;
    const toInsert = result.rows.filter(r => r.include);
    if (toInsert.length === 0) {
      setInsertError("No rows selected.");
      return;
    }
    setInserting(true);
    setInsertError(null);
    try {
      const payloads = toInsert.map(r => {
        const noteParts = [];
        if (r.foreignNote) noteParts.push(r.foreignNote);
        // Tag the source so it's traceable later
        noteParts.push(`Imported from Citi statement ${result.statementDate}`);
        return {
          date: r.date,
          type: "expense",
          amount: r.amount,            // signed; negative rows = refunds (allowed by relaxed constraint)
          currency: "SGD",
          amount_sgd: r.amount,
          fx_rate: 1,
          merchant: r.merchant || r.desc,
          category_id: r.category,
          card_id: cardId,
          bonus_eligible: r.bonusEligible,
          note: noteParts.join(" · "),
          status: "predicted",
        };
      });
      const inserted = await window.bulkInsertTransactions(payloads);
      setInsertedCount(inserted.length);
      // Trigger a refetch so the new rows show up everywhere
      if (window.refetchData) window.refetchData();
    } catch (err) {
      console.error("Bulk insert failed:", err);
      setInsertError(err.message || String(err));
    } finally {
      setInserting(false);
    }
  };

  // ─── Render ───────────────────────────────────────────────────────────────
  // Compact card with three states: idle → parsed (preview) → inserted (done)
  return (
    <div
      className="card"
      style={{
        padding: 16,
        background: "var(--bg-muted)",
        border: "1px solid var(--border-subtle)",
        borderRadius: 10,
      }}
    >
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: result ? 12 : 0 }}>
        <div>
          <div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-1)", display: "inline-flex", alignItems: "center", gap: 6 }}>
            <Icon name="arrow-up" size={13}/> Import statement PDF
          </div>
          <div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 2 }}>
            Upload your Citi e-statement to auto-extract transactions.
          </div>
        </div>
        {result && (
          <button onClick={reset} className="btn-ghost" style={{ fontSize: 11, padding: "4px 10px" }}>
            <Icon name="x" size={11}/> Clear
          </button>
        )}
      </div>

      {!result && !parsing && (
        <div
          onClick={() => fileInputRef.current?.click()}
          onDragOver={(e) => { e.preventDefault(); }}
          onDrop={onDrop}
          style={{
            marginTop: 12,
            padding: 18,
            border: "1.5px dashed var(--border-subtle)",
            borderRadius: 8,
            textAlign: "center",
            cursor: "pointer",
            background: "var(--bg)",
            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={16}/>
          <div style={{ fontSize: 12, color: "var(--text-2)", marginTop: 6 }}>
            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={{ marginTop: 12, padding: 18, textAlign: "center", fontSize: 12, color: "var(--text-muted)" }}>
          Parsing PDF…
        </div>
      )}

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

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

      {insertedCount !== null && (
        <div style={{ marginTop: 12, padding: 14, background: "var(--bg)", borderRadius: 7, fontSize: 12 }}>
          <div style={{ fontWeight: 600, color: "var(--text-1)", marginBottom: 4 }}>
            <Icon name="check" size={12}/> Inserted {insertedCount} transaction{insertedCount === 1 ? "" : "s"} as predicted.
          </div>
          <div style={{ color: "var(--text-muted)" }}>
            Review them in the transaction list below — edit or confirm as needed.
          </div>
        </div>
      )}
    </div>
  );
}

// ─── Preview table ──────────────────────────────────────────────────────────
function PreviewTable({ result, categories, onRowChange, onInsert, inserting, insertError }) {
  const rows = result.rows;
  const included = rows.filter(r => r.include);
  const parsedTotal = included.reduce((s, r) => s + r.amount, 0);
  // Reconciliation target: Citi's "PURCHASES & ADVANCES" figure (which
  // excludes payments and reversals — exactly the rows we also skip).
  // Grand Total is NOT the right comparison: it nets in payments, so it
  // would never match our parsed total.
  const citiTotal = result.summary?.purchasesAdvances ?? null;
  const matches = citiTotal != null && Math.abs(parsedTotal - citiTotal) < 0.02;

  return (
    <div>
      {/* Statement summary header */}
      <div style={{
        display: "grid",
        gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
        gap: 10,
        padding: 12,
        background: "var(--bg)",
        borderRadius: 7,
        marginBottom: 10,
        fontSize: 11,
      }}>
        <MiniStat label="Statement date" value={result.statementDate}/>
        <MiniStat label="Parsed rows" value={`${rows.length} (${included.length} selected)`}/>
        <MiniStat
          label="Parsed total"
          value={fmt.sgd(parsedTotal, { decimals: 2 })}
          tone={citiTotal == null ? null : matches ? "ok" : "warn"}
        />
        {citiTotal != null && (
          <MiniStat label="Citi: Purchases & advances" value={fmt.sgd(citiTotal, { decimals: 2 })}/>
        )}
        {result.points && (
          <MiniStat label="Citi: Bonus points" value={result.points.bonus.toLocaleString()}/>
        )}
      </div>

      {/* Reconciliation hint */}
      {citiTotal != null && !matches && (
        <div style={{
          padding: "6px 10px",
          background: "var(--bg)",
          border: "1px solid var(--warning-border, #c93)",
          borderRadius: 6,
          fontSize: 11,
          color: "var(--warning, #c93)",
          marginBottom: 10,
        }}>
          ⚠ Parsed total ({fmt.sgd(parsedTotal, { decimals: 2 })}) doesn't match Citi's Purchases & Advances ({fmt.sgd(citiTotal, { decimals: 2 })}). Difference: {fmt.sgd(Math.abs(parsedTotal - citiTotal), { decimals: 2 })}.
        </div>
      )}

      {/* Editable rows */}
      <div style={{
        maxHeight: 360,
        overflowY: "auto",
        border: "1px solid var(--border-subtle)",
        borderRadius: 7,
        background: "var(--bg)",
      }}>
        <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}>Date</th>
              <th style={thStyle}>Merchant</th>
              <th style={thStyle}>Category</th>
              <th style={{ ...thStyle, textAlign: "center" }} title="10x bonus eligible">10x</th>
              <th style={{ ...thStyle, textAlign: "right" }}>Amount</th>
            </tr>
          </thead>
          <tbody>
            {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(); }}
                  />
                </td>
                <td style={tdStyle}>{r.date.slice(5)}</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 10x 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>
            ))}
          </tbody>
        </table>
      </div>

      {/* Action row */}
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 12, gap: 10 }}>
        <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
          {insertError && <span style={{ color: "var(--danger, #c33)" }}>{insertError}</span>}
        </div>
        <button
          onClick={onInsert}
          disabled={inserting || included.length === 0}
          className="btn-primary"
          style={{ fontSize: 12, padding: "7px 14px" }}
        >
          {inserting
            ? "Inserting…"
            : `Insert ${included.length} as predicted →`}
        </button>
      </div>
    </div>
  );
}

// ─── Tiny presentational helpers ────────────────────────────────────────────
// Named MiniStat to avoid colliding with the global `Stat` component from
// components.jsx, which has a different API (label, value, icon, title).
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.CitiStatementImporter = CitiStatementImporter;
