#!/usr/bin/env node
/**
 * Apply a bank .FH deposit file to QuickBooks Online.
 *
 * Reads a .FH line-delimited deposit file (one check per line) plus a
 * user-supplied mapping JSON (which check pays which invoice(s)), then:
 *   1. Validates that .FH amounts match mapping amounts (per-check + total)
 *   2. Resolves invoice CustomerRef + qbo_invoices.id via Postgres lookup
 *   3. Resolves deposit account ref id from qbo_deposits (most recent match)
 *   4. POSTs one Payment per check (applied to its invoice(s))
 *   5. POSTs one Deposit grouping all payments under the bank account
 *
 * Idempotency: writes a sibling `<mapping>.applied.json` after each successful
 * QBO post; re-running skips entries already present in that file.
 *
 * Usage:
 *   npx tsx scripts/apply-fh-deposit.ts <fh-file> <mapping.json> [--dry-run]
 *
 * Environment: requires QBO_CLIENT_ID, QBO_CLIENT_SECRET, QBO_REALM_ID, and a
 * valid QBO token row in qbo_tokens (i.e. someone has completed /api/qbo/auth).
 */

import * as fs from 'fs';
import * as path from 'path';
import postgresClient from '../lib/services/postgres-client';
import { getQboClient } from '../lib/services/qbo-client';
import type {
  QboPaymentCreatePayload,
  QboDepositCreatePayload,
} from '../lib/types/qbo';

// ─── Types ──────────────────────────────────────────────────────────────────

interface FhRow {
  rawDate: string;        // MMDDYYYY
  txnDate: string;        // YYYY-MM-DD
  slipNum: string;
  seq: number;
  checkNum: string;       // exact string from file, leading zeros preserved
  amount: number;
  payerAcct: string;
  payerRouting: string;
}

interface MappingInvoice {
  doc_number: string;
  amount: number;
}

interface MappingCheck {
  seq: number;
  check_num: string;
  amount: number;
  customer_name?: string;
  invoices: MappingInvoice[];
}

interface Mapping {
  deposit_date: string;             // YYYY-MM-DD
  deposit_account_name: string;
  private_note_prefix?: string;
  checks: MappingCheck[];
}

interface Applied {
  payments: Record<string, { qbo_payment_id: string; applied_at: string }>; // key = seq
  deposit?: { qbo_deposit_id: string; applied_at: string };
}

// ─── Helpers ────────────────────────────────────────────────────────────────

function normalizeCheckNum(s: string): string {
  return s.replace(/^0+/, '') || '0';
}

function round2(n: number): number {
  return Math.round(n * 100) / 100;
}

function parseFh(file: string): FhRow[] {
  const text = fs.readFileSync(file, 'utf8');
  const rows: FhRow[] = [];
  for (const line of text.split(/\r?\n/)) {
    if (!line.trim()) continue;
    const [rawDate, slipNum, seq, checkNum, amount, payerAcct, payerRouting] = line.split(',');
    if (!rawDate || !amount) continue;
    // MMDDYYYY -> YYYY-MM-DD
    const mm = rawDate.slice(0, 2);
    const dd = rawDate.slice(2, 4);
    const yyyy = rawDate.slice(4, 8);
    rows.push({
      rawDate,
      txnDate: `${yyyy}-${mm}-${dd}`,
      slipNum,
      seq: parseInt(seq, 10),
      checkNum,
      amount: parseFloat(amount),
      payerAcct,
      payerRouting,
    });
  }
  return rows;
}

function loadApplied(mappingPath: string): { path: string; data: Applied } {
  const appliedPath = mappingPath.replace(/\.json$/, '.applied.json');
  let data: Applied = { payments: {} };
  if (fs.existsSync(appliedPath)) {
    try {
      data = JSON.parse(fs.readFileSync(appliedPath, 'utf8')) as Applied;
      if (!data.payments) data.payments = {};
    } catch (err) {
      throw new Error(`Failed to parse ${appliedPath}: ${err}`);
    }
  }
  return { path: appliedPath, data };
}

function saveApplied(appliedPath: string, data: Applied): void {
  fs.writeFileSync(appliedPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}

// ─── Validation ─────────────────────────────────────────────────────────────

function validate(fh: FhRow[], mapping: Mapping): { ok: true } | { ok: false; errors: string[] } {
  const errors: string[] = [];

  // Same row count
  if (fh.length !== mapping.checks.length) {
    errors.push(`FH has ${fh.length} rows but mapping has ${mapping.checks.length} checks`);
  }

  // Per-row: match by seq, then verify check_num (normalized) + amount
  const fhBySeq = new Map<number, FhRow>(fh.map((r) => [r.seq, r]));
  for (const c of mapping.checks) {
    const f = fhBySeq.get(c.seq);
    if (!f) {
      errors.push(`Mapping seq ${c.seq} has no matching FH row`);
      continue;
    }
    if (normalizeCheckNum(f.checkNum) !== normalizeCheckNum(c.check_num)) {
      errors.push(`seq ${c.seq}: check_num mismatch (FH=${f.checkNum} vs mapping=${c.check_num})`);
    }
    if (round2(f.amount) !== round2(c.amount)) {
      errors.push(`seq ${c.seq}: amount mismatch (FH=${f.amount.toFixed(2)} vs mapping=${c.amount.toFixed(2)})`);
    }
    // Per-check: sum of invoice amounts == check amount
    const invSum = round2(c.invoices.reduce((s, i) => s + i.amount, 0));
    if (invSum !== round2(c.amount)) {
      errors.push(`seq ${c.seq}: invoice splits sum to ${invSum.toFixed(2)} but check is ${c.amount.toFixed(2)}`);
    }
  }

  // Total
  const fhTotal = round2(fh.reduce((s, r) => s + r.amount, 0));
  const mapTotal = round2(mapping.checks.reduce((s, c) => s + c.amount, 0));
  if (fhTotal !== mapTotal) {
    errors.push(`Deposit total mismatch: FH=${fhTotal.toFixed(2)} mapping=${mapTotal.toFixed(2)}`);
  }

  return errors.length === 0 ? { ok: true } : { ok: false, errors };
}

// ─── DB lookups ─────────────────────────────────────────────────────────────

async function resolveInvoices(docNumbers: string[]): Promise<Map<string, { qbo_invoice_id: string; customer_ref_id: string; balance: number; customer_ref_name: string }>> {
  const res = await postgresClient.query<{
    id: string;
    doc_number: string;
    customer_ref_id: string | null;
    customer_ref_name: string | null;
    balance: string;
  }>(
    `SELECT id, doc_number, customer_ref_id, customer_ref_name, balance::text
       FROM qbo_invoices
      WHERE is_deleted = false
        AND doc_number = ANY($1::text[])`,
    [docNumbers],
  );
  const map = new Map<string, { qbo_invoice_id: string; customer_ref_id: string; balance: number; customer_ref_name: string }>();
  for (const r of res.rows) {
    if (!r.doc_number || !r.customer_ref_id) continue;
    map.set(r.doc_number, {
      qbo_invoice_id: r.id,
      customer_ref_id: r.customer_ref_id,
      balance: parseFloat(r.balance),
      customer_ref_name: r.customer_ref_name ?? '',
    });
  }
  return map;
}

async function resolveDepositAccountId(accountName: string): Promise<string> {
  const res = await postgresClient.query<{ deposit_to_account_ref_id: string | null }>(
    `SELECT deposit_to_account_ref_id
       FROM qbo_deposits
      WHERE deposit_to_account_ref_name = $1
        AND deposit_to_account_ref_id IS NOT NULL
      ORDER BY txn_date DESC
      LIMIT 1`,
    [accountName],
  );
  const id = res.rows[0]?.deposit_to_account_ref_id;
  if (!id) {
    throw new Error(`Could not resolve deposit account "${accountName}" — no prior qbo_deposits row has that name. Run a QBO sync or check the name spelling.`);
  }
  return id;
}

// ─── Payload builders ───────────────────────────────────────────────────────

function buildPaymentPayload(
  check: MappingCheck,
  fhRow: FhRow,
  mapping: Mapping,
  depositAccountId: string,
  invoiceMap: Map<string, { qbo_invoice_id: string; customer_ref_id: string; customer_ref_name: string }>,
): QboPaymentCreatePayload {
  // All invoices for a single check must belong to the same customer.
  const customerIds = new Set<string>();
  for (const inv of check.invoices) {
    const resolved = invoiceMap.get(inv.doc_number);
    if (!resolved) {
      throw new Error(`seq ${check.seq}: invoice doc_number ${inv.doc_number} not found in qbo_invoices`);
    }
    customerIds.add(resolved.customer_ref_id);
  }
  if (customerIds.size > 1) {
    throw new Error(`seq ${check.seq}: invoices span multiple customers (${[...customerIds].join(', ')}). One check = one customer.`);
  }
  const customerRefId = [...customerIds][0];

  // fhRow is used for context (seq validation happens in validate()); suppress unused-var
  void fhRow;

  return {
    CustomerRef: { value: customerRefId },
    TotalAmt: round2(check.amount),
    TxnDate: mapping.deposit_date,
    DepositToAccountRef: { value: depositAccountId },
    PaymentRefNum: check.check_num.slice(-21), // QBO limits to 21 chars
    PrivateNote: `${mapping.private_note_prefix ?? ''} / seq ${String(check.seq).padStart(4, '0')} / check ${check.check_num}`.trim(),
    Line: check.invoices.map((inv) => ({
      Amount: round2(inv.amount),
      LinkedTxn: [{ TxnId: invoiceMap.get(inv.doc_number)!.qbo_invoice_id, TxnType: 'Invoice' }],
    })),
  };
}

function buildDepositPayload(
  mapping: Mapping,
  depositAccountId: string,
  paymentIds: Array<{ seq: number; amount: number; paymentId: string }>,
): QboDepositCreatePayload {
  return {
    TxnDate: mapping.deposit_date,
    DepositToAccountRef: { value: depositAccountId },
    PrivateNote: `${mapping.private_note_prefix ?? ''} / ${mapping.deposit_date}`.trim(),
    Line: paymentIds.map((p) => ({
      Amount: round2(p.amount),
      DetailType: 'DepositLineDetail',
      LinkedTxn: [{ TxnId: p.paymentId, TxnType: 'Payment' }],
      DepositLineDetail: {},
    })),
  };
}

// ─── Main ───────────────────────────────────────────────────────────────────

async function main() {
  const args = process.argv.slice(2);
  const dryRun = args.includes('--dry-run');
  const positional = args.filter((a) => !a.startsWith('--'));
  if (positional.length < 2) {
    console.error('Usage: npx tsx scripts/apply-fh-deposit.ts <fh-file> <mapping.json> [--dry-run]');
    process.exit(2);
  }
  const [fhPath, mappingPath] = positional.map((p) => path.resolve(p));

  console.log(`[fh-deposit] FH file:     ${fhPath}`);
  console.log(`[fh-deposit] Mapping:     ${mappingPath}`);
  console.log(`[fh-deposit] Mode:        ${dryRun ? 'DRY-RUN (no QBO writes)' : 'LIVE'}`);

  const fhRows = parseFh(fhPath);
  const mapping: Mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8'));
  const { path: appliedPath, data: applied } = loadApplied(mappingPath);

  // 1. Validate
  const v = validate(fhRows, mapping);
  if (!v.ok) {
    console.error('[fh-deposit] Validation failed:');
    for (const e of v.errors) console.error(`  - ${e}`);
    process.exit(1);
  }
  const total = round2(fhRows.reduce((s, r) => s + r.amount, 0));
  console.log(`[fh-deposit] Validation OK — ${fhRows.length} checks, $${total.toFixed(2)} total`);

  // 2. Resolve invoices + deposit account
  const allDocs = mapping.checks.flatMap((c) => c.invoices.map((i) => i.doc_number));
  const invoiceMap = await resolveInvoices(allDocs);
  const missing = allDocs.filter((d) => !invoiceMap.has(d));
  if (missing.length) {
    console.error(`[fh-deposit] Missing invoices in qbo_invoices: ${missing.join(', ')}`);
    process.exit(1);
  }
  const depositAccountId = await resolveDepositAccountId(mapping.deposit_account_name);
  console.log(`[fh-deposit] Deposit account "${mapping.deposit_account_name}" -> ${depositAccountId}`);

  // 3. Build + post payments
  // Defer QBO client construction until first live write — dry-run needs no QBO credentials.
  let client: ReturnType<typeof getQboClient> | null = null;
  const getClient = () => {
    if (!client) client = getQboClient();
    return client;
  };
  const paymentResults: Array<{ seq: number; amount: number; paymentId: string }> = [];

  for (const check of mapping.checks) {
    const seqKey = String(check.seq);
    const existing = applied.payments[seqKey];
    if (existing?.qbo_payment_id) {
      console.log(`[fh-deposit] seq ${check.seq}: SKIP — already posted as Payment ${existing.qbo_payment_id}`);
      paymentResults.push({ seq: check.seq, amount: check.amount, paymentId: existing.qbo_payment_id });
      continue;
    }
    const fhRow = fhRows.find((r) => r.seq === check.seq)!;
    const payload = buildPaymentPayload(check, fhRow, mapping, depositAccountId, invoiceMap);
    console.log(`[fh-deposit] seq ${check.seq}: ${check.customer_name ?? '?'} — check ${check.check_num} $${check.amount.toFixed(2)} -> invoice(s) ${check.invoices.map((i) => i.doc_number).join(', ')}`);
    if (dryRun) {
      console.log(`  PAYLOAD: ${JSON.stringify(payload)}`);
      paymentResults.push({ seq: check.seq, amount: check.amount, paymentId: `DRY-RUN-seq-${check.seq}` });
      continue;
    }
    try {
      const created = await getClient().createPayment(payload);
      console.log(`  -> Payment ${created.Id} created`);
      applied.payments[seqKey] = { qbo_payment_id: created.Id, applied_at: new Date().toISOString() };
      saveApplied(appliedPath, applied);
      paymentResults.push({ seq: check.seq, amount: check.amount, paymentId: created.Id });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      console.error(`  -> FAILED: ${msg}`);
      console.error('  Stopping. Already-applied payments are recorded in:', appliedPath);
      process.exit(1);
    }
  }

  // 4. Build + post deposit
  if (applied.deposit?.qbo_deposit_id) {
    console.log(`[fh-deposit] Deposit already posted as ${applied.deposit.qbo_deposit_id} — skipping.`);
  } else {
    const depositPayload = buildDepositPayload(mapping, depositAccountId, paymentResults);
    console.log(`[fh-deposit] Deposit: ${paymentResults.length} lines, total $${total.toFixed(2)}`);
    if (dryRun) {
      console.log(`  PAYLOAD: ${JSON.stringify(depositPayload)}`);
    } else {
      try {
        const created = await getClient().createDeposit(depositPayload);
        console.log(`  -> Deposit ${created.Id} created`);
        applied.deposit = { qbo_deposit_id: created.Id, applied_at: new Date().toISOString() };
        saveApplied(appliedPath, applied);
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        console.error(`  -> FAILED: ${msg}`);
        console.error('  Payments were posted successfully but the Deposit failed. Re-run the script to retry the Deposit only.');
        process.exit(1);
      }
    }
  }

  console.log(`[fh-deposit] ${dryRun ? 'Dry-run complete' : 'Done'}.`);
  // Allow the postgres pool to drain so the process can exit cleanly.
  process.exit(0);
}

main().catch((err) => {
  console.error('[fh-deposit] Fatal:', err);
  process.exit(1);
});
