export type DateFormat = "DD/MM/YYYY" | "YYYY-MM-DD" | "MM/DD/YYYY" | "M/D/YYYY";

export interface ColumnMapping {
  dateCol: string;
  descriptionCol: string;
  amountMode: "single" | "debit_credit";
  amountCol?: string;
  debitCol?: string;
  creditCol?: string;
  merchantCol?: string;
  categoryCol?: string;
  /**
   * Column holding the account number, when the file covers more than one.
   *
   * Optional and usually absent — a bank's own export is one account per file.
   * An aggregator export (Frollo) is not, and without this every row would be
   * measured against the same statement coverage.
   */
  accountCol?: string;
  /**
   * Column holding the ISO currency code, when the file is multi-currency.
   *
   * Without it a foreign row is stored as if it were AUD — a USD 10,782 salary
   * lands as A$10,782, understating it by about a third. Mapping it records the
   * native figure with `foreign_currency_code` set and `amount_aud` left NULL,
   * which is the same shape order ingestion uses and what `AMOUNT_UNCONVERTED`
   * looks for.
   */
  currencyCol?: string;
  /**
   * Column holding the provider's own row id, when it has one.
   *
   * Stored as `source_ref` so re-importing the same file inserts nothing. The
   * importer has no other idempotency: `row_index` is assigned MAX+1 on every
   * run, which makes `uq_transaction_identity` structurally unable to fire.
   */
  sourceRefCol?: string;
}

export interface BankPreset {
  bankName: string;
  mapping: ColumnMapping;
  dateFormat: DateFormat;
}

export interface ParsedTransaction {
  date: string;
  description: string;
  amount: number;
  transaction_type: string;
  merchant_name?: string;
  category?: string;
  /** Raw account identifier from the file, when a column was mapped. */
  account?: string;
  /** Provider row id, when a column was mapped. */
  source_ref?: string;
  /** Set only for a non-AUD row: the native amount and its code. */
  foreign_currency_amount?: number;
  foreign_currency_code?: string;
}

export function parseCSVRows(text: string): string[][] {
  const rows: string[][] = [];
  let row: string[] = [];
  let field = "";
  let inQuotes = false;

  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    const next = text[i + 1];
    if (inQuotes) {
      if (c === '"' && next === '"') { field += '"'; i++; }
      else if (c === '"') { inQuotes = false; }
      else { field += c; }
    } else {
      if (c === '"') { inQuotes = true; }
      else if (c === ',') { row.push(field.trim()); field = ""; }
      else if (c === '\r' && next === '\n') {
        row.push(field.trim());
        if (row.some((f) => f !== "")) rows.push(row);
        row = []; field = ""; i++;
      } else if (c === '\n' || c === '\r') {
        row.push(field.trim());
        if (row.some((f) => f !== "")) rows.push(row);
        row = []; field = "";
      } else { field += c; }
    }
  }
  if (field || row.length > 0) {
    row.push(field.trim());
    if (row.some((f) => f !== "")) rows.push(row);
  }
  return rows;
}

export function parseDate(raw: string, format: DateFormat): string {
  const s = raw.trim();
  try {
    if (format === "DD/MM/YYYY") {
      const [d, m, y] = s.split("/");
      if (!d || !m || !y || y.length !== 4) return "";
      return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
    }
    if (format === "YYYY-MM-DD") {
      return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : "";
    }
    if (format === "MM/DD/YYYY" || format === "M/D/YYYY") {
      const [m, d, y] = s.split("/");
      if (!d || !m || !y || y.length !== 4) return "";
      return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
    }
  } catch { return ""; }
  return "";
}

export function detectHasHeaders(rows: string[][], dateFormat: DateFormat): boolean {
  if (rows.length === 0) return false;
  return parseDate(rows[0][0] ?? "", dateFormat) === "";
}

export function getColumnLabels(rows: string[][], hasHeaders: boolean): string[] {
  if (hasHeaders && rows.length > 0) {
    return rows[0].map((h, i) => h || `Column ${i + 1}`);
  }
  const maxCols = rows.reduce((m, r) => Math.max(m, r.length), 0);
  return Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
}

export function getDataRows(rows: string[][], hasHeaders: boolean): string[][] {
  return hasHeaders ? rows.slice(1) : rows;
}

export function applyMapping(
  dataRows: string[][],
  columnLabels: string[],
  mapping: ColumnMapping,
  dateFormat: DateFormat
): ParsedTransaction[] {
  const idx = (name: string) => columnLabels.indexOf(name);
  const dateIdx = idx(mapping.dateCol);
  const descIdx = idx(mapping.descriptionCol);
  const merchantIdx = mapping.merchantCol ? idx(mapping.merchantCol) : -1;
  const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
  const accountIdx = mapping.accountCol ? idx(mapping.accountCol) : -1;
  const sourceRefIdx = mapping.sourceRefCol ? idx(mapping.sourceRefCol) : -1;
  const currencyIdx = mapping.currencyCol ? idx(mapping.currencyCol) : -1;

  const results: ParsedTransaction[] = [];
  for (const row of dataRows) {
    const date = parseDate(row[dateIdx] ?? "", dateFormat);
    const description = (row[descIdx] ?? "").trim();
    if (!date || !description) continue;

    let amount = 0;
    let transaction_type = "debit";

    if (mapping.amountMode === "single" && mapping.amountCol) {
      const raw = (row[idx(mapping.amountCol)] ?? "").replace(/[^\d.\-+]/g, "");
      const val = parseFloat(raw);
      if (isNaN(val) || val === 0) continue;
      amount = Math.abs(val);
      transaction_type = val < 0 ? "debit" : "credit";
    } else if (mapping.amountMode === "debit_credit") {
      const debitIdx = mapping.debitCol ? idx(mapping.debitCol) : -1;
      const creditIdx = mapping.creditCol ? idx(mapping.creditCol) : -1;
      const dVal = parseFloat((row[debitIdx] ?? "").replace(/[^\d.]/g, ""));
      const cVal = parseFloat((row[creditIdx] ?? "").replace(/[^\d.]/g, ""));
      if (!isNaN(dVal) && dVal > 0) { amount = dVal; transaction_type = "debit"; }
      else if (!isNaN(cVal) && cVal > 0) { amount = cVal; transaction_type = "credit"; }
      else continue;
    }

    if (amount <= 0) continue;
    const tx: ParsedTransaction = { date, description, amount, transaction_type };
    if (merchantIdx >= 0 && row[merchantIdx]) tx.merchant_name = row[merchantIdx].trim();
    if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
    if (accountIdx >= 0 && row[accountIdx]) tx.account = row[accountIdx].trim();
    if (sourceRefIdx >= 0 && row[sourceRefIdx]) tx.source_ref = row[sourceRefIdx].trim();
    if (currencyIdx >= 0) {
      const code = (row[currencyIdx] ?? "").trim().toUpperCase();
      // AUD needs no marking; anything else is native and unconverted. No rate
      // is invented — the statement supplies the AUD figure when it arrives.
      if (/^[A-Z]{3}$/.test(code) && code !== "AUD") {
        tx.foreign_currency_code = code;
        tx.foreign_currency_amount = amount;
      }
    }
    results.push(tx);
  }
  return results;
}

export function saveBankPreset(preset: BankPreset): void {
  try {
    const existing = loadBankPresets().filter((p) => p.bankName !== preset.bankName);
    localStorage.setItem("csv-presets", JSON.stringify([...existing, preset]));
  } catch { /* localStorage unavailable */ }
}

export function loadBankPresets(): BankPreset[] {
  try { return JSON.parse(localStorage.getItem("csv-presets") || "[]"); }
  catch { return []; }
}

/** Last four digits of an account identifier, however it is written. */
export function last4(s: string): string {
  const d = s.replace(/\D/g, "");
  return d.length >= 4 ? d.slice(-4) : "";
}

/**
 * Splits parsed rows into what to import and what the statements already have.
 *
 * A statement's end date is a hard watermark: everything on that account up to
 * it is already in the ledger, itemised and converted. Without this the first
 * aggregator import wrote 422 duplicates out of 550 rows; with it the same
 * 2,607-row export offers 171.
 *
 * Rows are only excluded when an account column was mapped AND that account has
 * statements. Anything else is imported — a row is never dropped on a guess.
 */
export function splitByCoverage(
  rows: ParsedTransaction[],
  coverage: { last4: string; coveredTo: string }[]
): { keep: ParsedTransaction[]; covered: ParsedTransaction[] } {
  const wm = new Map(coverage.map((c) => [c.last4, c.coveredTo]));
  const keep: ParsedTransaction[] = [];
  const covered: ParsedTransaction[] = [];
  for (const r of rows) {
    const to = r.account ? wm.get(last4(r.account)) : undefined;
    // Both are YYYY-MM-DD, so a string compare is a date compare.
    if (to && r.date <= to) covered.push(r);
    else keep.push(r);
  }
  return { keep, covered };
}

/**
 * Rows that duplicate another row in the SAME file.
 *
 * Distinct from the coverage check, and not catchable by `source_ref`: a CDR
 * re-consent makes the provider re-export an account's whole history under
 * fresh ids while the originals survive, so the twins are identical in
 * everything except the id. That put 385 duplicates in one 2,563-row export and
 * doubled every salary payment. 385 rows is not something you spot by eye in a
 * review table, so it is counted here.
 */
export function inFileDuplicates(rows: ParsedTransaction[]): number {
  const seen = new Set<string>();
  let n = 0;
  for (const r of rows) {
    const key = [r.date, r.description, r.amount, r.transaction_type, r.account ?? ""].join("|");
    if (seen.has(key)) n += 1;
    else seen.add(key);
  }
  return n;
}
