/**
 * Imports a Frollo transaction export into `transactions`.
 *
 *   node --experimental-strip-types scripts/import-frollo.mts --file <csv>
 *   node --experimental-strip-types scripts/import-frollo.mts --file <csv> --apply
 *
 * Dry run by default: it prints exactly what it would do and touches nothing.
 * Rehearse first — every irreversible step in this repo that skipped that has
 * cost something, and the failure mode here is doubling reported income.
 *
 * Export the file with PENDING TRANSACTIONS EXCLUDED. A pending row changes both
 * its id and its description when it settles, so importing one guarantees a
 * duplicate on the next run; and every pending row observed so far has been on a
 * credit card, which this importer does not cover anyway. See DECISIONS.md
 * ING-11 in the smarthome repo.
 *
 * Needs DATABASE_URL. postgres-personal publishes no host port, so from the host:
 *   export DATABASE_URL="postgresql://personal:<pw>@$(docker inspect postgres-personal \
 *     --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'):5432/personal"
 * The container IP changes on every recreate.
 */
import { readFileSync } from "node:fs";
import pg from "pg";
import { parseCSVRows } from "../src/lib/csv-parser.ts";
import {
  accountFor,
  accountReport,
  dedupe,
  readRows,
  skipReason,
  toLedgerRow,
  unknownAccounts,
  type FrolloRow,
  type SkipReason,
} from "../src/lib/frollo-csv.ts";

const SOURCE = "frollo";

function arg(name: string, fallback?: string): string | undefined {
  const i = process.argv.indexOf(`--${name}`);
  if (i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith("--")) {
    return process.argv[i + 1];
  }
  return fallback;
}
const has = (name: string) => process.argv.includes(`--${name}`);

const file = arg("file");
const apply = has("apply");
const ownerId = Number(arg("owner", "1"));
const allowMissing = has("allow-missing-accounts");

if (!file) {
  console.error("usage: --file <csv> [--apply] [--owner <id>] [--allow-missing-accounts]");
  process.exit(2);
}

const money = (n: number) => n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });

const rows = readRows(parseCSVRows(readFileSync(file, "utf8")));
console.log(`\n${file}`);
console.log(`  ${rows.length} rows in file\n`);

// ---- 1. scope ------------------------------------------------------------
const skipped = new Map<SkipReason, FrolloRow[]>();
const inScope: FrolloRow[] = [];
for (const r of rows) {
  const why = skipReason(r);
  if (why) {
    const list = skipped.get(why);
    if (list) list.push(r);
    else skipped.set(why, [r]);
  } else inScope.push(r);
}
console.log("SCOPE");
for (const [why, list] of [...skipped.entries()].sort((a, b) => b[1].length - a[1].length)) {
  console.log(`  skipped ${String(list.length).padStart(5)}  ${why}`);
}
console.log(`  in scope ${String(inScope.length).padStart(4)}\n`);

if (skipped.has("pending")) {
  console.log(
    `  NOTE: ${skipped.get("pending")!.length} pending row(s) present — the export was taken\n` +
    `        with pending INCLUDED. They are skipped, but re-export with pending\n` +
    `        excluded so successive files are comparable.\n`
  );
}

// ---- 2. accounts ---------------------------------------------------------
// A lapsed CDR consent removes an account from the export silently; the row
// count just drops. Assert the roster rather than trusting it.
console.log("ACCOUNTS");
const report = accountReport(inScope);
for (const a of report) {
  const mark = a.present ? " " : "!";
  console.log(`  ${mark} ${a.spec.last4}  ${a.spec.label.padEnd(24)} ${String(a.rows).padStart(5)} rows`);
}
const missing = report.filter((a) => !a.present);
const unknown = unknownAccounts(rows);
if (unknown.length > 0) {
  console.log("\n  accounts in the file this importer does not know (skipped):");
  for (const u of unknown) console.log(`    ${u.accountNumber}  ${u.accountName}  ${u.rows} rows`);
}
if (missing.length > 0) {
  console.log(`\n  ${missing.length} configured account(s) contributed NO rows:`);
  for (const m of missing) console.log(`    ${m.spec.last4}  ${m.spec.label}  (${m.spec.provider})`);
  console.log("  A CDR consent may have lapsed — check before treating this file as complete.");
  if (!allowMissing) {
    console.log("  Refusing to continue. Re-run with --allow-missing-accounts if this is expected.\n");
    process.exit(1);
  }
}
console.log();

// ---- 3. dedupe -----------------------------------------------------------
const { kept, dropped } = dedupe(inScope);
console.log("DE-DUPLICATION  (re-ingest twins from CDR re-consent)");
console.log(`  ${dropped.length} dropped, ${kept.length} kept`);
if (dropped.length > 0) {
  const byAccount = new Map<string, number>();
  for (const d of dropped) {
    const label = accountFor(d.row.accountNumber)?.label ?? "?";
    byAccount.set(label, (byAccount.get(label) ?? 0) + 1);
  }
  for (const [label, n] of [...byAccount.entries()].sort((a, b) => b[1] - a[1])) {
    console.log(`    ${String(n).padStart(4)}  ${label}`);
  }
  console.log("  examples:");
  for (const d of dropped.slice(0, 3)) {
    console.log(
      `    ${d.row.transactionDate} ${money(d.row.amount).padStart(11)} ${d.row.description.slice(0, 34)}` +
      `   id ${d.row.transactionId} duplicates ${d.duplicateOf.transactionId} (gap ${d.idGap})`
    );
  }
  // Ids this close look like a genuine same-day repeat rather than a twin.
  // Nothing in scope has ever tripped it; if something does, look before
  // trusting the collapse.
  const suspect = dropped.filter((d) => d.suspectGenuineRepeat);
  if (suspect.length > 0) {
    console.log(`\n  REVIEW: ${suspect.length} collapsed row(s) had near-consecutive ids and may be`);
    console.log("          real repeats rather than duplicates:");
    for (const d of suspect.slice(0, 10)) {
      console.log(
        `    ${d.row.transactionDate} ${money(d.row.amount).padStart(11)} ${d.row.description.slice(0, 40)}` +
        `  ids ${d.duplicateOf.transactionId}/${d.row.transactionId} gap ${d.idGap}`
      );
    }
  }
}
console.log();

// ---- 4. what would change ------------------------------------------------
const ledger = kept.map(toLedgerRow);
if (!process.env.DATABASE_URL) {
  console.error("DATABASE_URL is not set — cannot compare against the ledger.");
  process.exit(2);
}
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

const existing = await client.query<{ source_ref: string }>(
  "SELECT source_ref FROM transactions WHERE source = $1",
  [SOURCE]
);
const seen = new Set(existing.rows.map((r) => r.source_ref));
const fresh = ledger.filter((r) => !seen.has(r.sourceRef));
const already = ledger.length - fresh.length;

console.log("LEDGER");
console.log(`  already imported : ${String(already).padStart(5)}`);
console.log(`  new to insert    : ${String(fresh.length).padStart(5)}`);
const debits = fresh.filter((r) => r.transactionType === "debit");
const credits = fresh.filter((r) => r.transactionType === "credit");
const aud = (rs: typeof fresh) => rs.filter((r) => !r.foreignCurrencyCode);
console.log(
  `  of which AUD     : ${aud(debits).length} debits ${money(aud(debits).reduce((s, r) => s + r.amount, 0))}` +
  ` / ${aud(credits).length} credits ${money(aud(credits).reduce((s, r) => s + r.amount, 0))}`
);
const fx = fresh.filter((r) => r.foreignCurrencyCode);
if (fx.length > 0) {
  const byCcy = new Map<string, number>();
  for (const r of fx) byCcy.set(r.foreignCurrencyCode!, (byCcy.get(r.foreignCurrencyCode!) ?? 0) + 1);
  console.log(`  foreign          : ${[...byCcy].map(([c, n]) => `${n} ${c}`).join(", ")} (amount_aud left NULL)`);
}
if (fresh.length > 0) {
  console.log("\n  first rows to insert:");
  for (const r of fresh.slice(0, 8)) {
    console.log(
      `    ${r.transactionDate} ${r.transactionType.padEnd(6)} ${money(r.amount).padStart(11)}` +
      ` ${(r.foreignCurrencyCode ?? "AUD").padEnd(4)} ${r.description.slice(0, 40)}`
    );
  }
}

// ---- 5. apply ------------------------------------------------------------
if (!apply) {
  console.log("\nDRY RUN — nothing written. Re-run with --apply to insert.\n");
  await client.end();
  process.exit(0);
}

if (fresh.length === 0) {
  console.log("\nNothing to insert.\n");
  await client.end();
  process.exit(0);
}

// row_index is left NULL: it exists to order statement lines, and the
// uniqueness this import relies on is uq_transaction_source_ref, not
// uq_transaction_identity.
//
// category is not set, because Frollo's own categories are not trustworthy. Note
// what actually lands: trg_transactions_normalize_category rewrites NULL to
// 'other' on insert, so these rows arrive categorised 'other' rather than
// uncategorised. That is equivalent for display and for the rules engine, which
// writes transaction_overrides.category_override and is read ahead of
// t.category by EFFECTIVE_CATEGORY — so a rule still wins.
let inserted = 0;
try {
  await client.query("BEGIN");
  for (const r of fresh) {
    const res = await client.query(
      `INSERT INTO transactions
         (statement_id, owner_id, transaction_date, description, amount, transaction_type,
          merchant_name, foreign_currency_amount, foreign_currency_code,
          source, source_ref, source_account)
       VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
       ON CONFLICT (source, source_ref) WHERE source IS NOT NULL AND source_ref IS NOT NULL
       DO NOTHING`,
      [
        ownerId,
        r.transactionDate,
        r.description,
        r.amount,
        r.transactionType,
        r.merchantName,
        r.foreignCurrencyAmount,
        r.foreignCurrencyCode,
        SOURCE,
        r.sourceRef,
        r.sourceAccount,
      ]
    );
    inserted += res.rowCount ?? 0;
  }
  await client.query("COMMIT");
} catch (e) {
  await client.query("ROLLBACK");
  console.error("\nROLLED BACK:", e);
  await client.end();
  process.exit(1);
}

const after = await client.query<{ n: string }>(
  "SELECT count(*) AS n FROM transactions WHERE source = $1",
  [SOURCE]
);
console.log(`\nInserted ${inserted} row(s). ${after.rows[0].n} Frollo rows now in the ledger.\n`);
await client.end();
