'use strict';

import type { Db } from '../types/db';

const { normalizeMerchant } = require('./subscriptionService.cts');
const { getUserSettings } = require('./userSettings.cts');
const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService.cts');
const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts');
const { fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts');

// Word-boundary merchant match — requires the rule to appear as complete word(s)
// within the transaction string (or vice versa), not just as a substring.
// Prevents "suno" matching "sunoco", "prime" matching "prime video", etc.
function merchantMatches(txMerchant: string, ruleMerchant: string): boolean {
  if (!txMerchant || !ruleMerchant) return false;
  if (txMerchant === ruleMerchant) return true;
  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const wordBoundary = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`);
  return wordBoundary(ruleMerchant).test(txMerchant) || wordBoundary(txMerchant).test(ruleMerchant);
}

// Detects when a payment posted just after month end but the bill was due in the
// prior month. Grace window: up to `graceDays` days into the new month.
// Module-scoped so both applyMerchantRules and syncBillPaymentsFromSimplefin can use it.
function lateAttributionCandidate(
  paidDateStr: string,
  dueDayOfMonth: number,
  graceDays = 5,
): string | null {
  const paid = new Date(paidDateStr + 'T00:00:00');
  const dayOfMonth = paid.getDate();
  if (dayOfMonth > graceDays) return null;
  const prevMonthLastDay = new Date(paid.getFullYear(), paid.getMonth(), 0);
  if (dueDayOfMonth > prevMonthLastDay.getDate()) return null;
  return localDateString(prevMonthLastDay); // suggested prior-month date
}

// Persist a merchant→bill rule so future synced transactions auto-match.
function addMerchantRule(db: Db, userId: number, billId: number, merchant: string): void {
  const normalized = normalizeMerchant(merchant);
  if (!normalized || normalized.length < 3) return;
  try {
    db.prepare(
      `
      INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
      VALUES (?, ?, ?)
      ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
    `,
    ).run(userId, billId, normalized);
  } catch {
    // Table may not exist yet on legacy DBs — safe to skip
  }
}

// Generic descriptor tokens that must never become a merchant rule on their own —
// a rule like "ach" or "atm" would word-match far too many unrelated transactions.
// (normalizeMerchant already strips pos/debit/card/payment/purchase/recurring/online;
//  this catches the residual generic tokens it leaves behind.)
const GENERIC_MERCHANT_TOKENS = new Set([
  'ach',
  'atm',
  'transfer',
  'xfer',
  'pmt',
  'withdrawal',
  'deposit',
  'check',
  'fee',
  'bill',
  'autopay',
  'draft',
  'credit',
  'bank',
  'visa',
  'mastercard',
  'amex',
  'web',
  'mobile',
  'pending',
  'transaction',
]);

// Derive a specific, reusable merchant string from a confirmed transaction match.
// Returns null when the text is too generic to be a safe auto-match rule.
function learnableMerchantFromTransaction(transaction: any): string | null {
  if (!transaction) return null;
  const raw = transaction.payee || transaction.description || '';
  const norm = normalizeMerchant(raw);
  if (!norm || norm.length < 4) return null;
  const tokens = norm.split(' ').filter(Boolean);
  // Require at least one meaningful (non-generic, length >= 3) token.
  const meaningful = tokens.filter((t: string) => t.length >= 3 && !GENERIC_MERCHANT_TOKENS.has(t));
  if (meaningful.length === 0) return null;
  return norm;
}

// Learn a merchant→bill rule from an explicit user confirmation so future synced
// transactions from the same merchant auto-match. Best-effort and idempotent —
// never throws, returns the merchant it stored (or null when nothing was learned).
function learnMerchantRuleFromMatch(
  db: Db,
  userId: number,
  billId: number,
  transaction: any,
): string | null {
  try {
    const merchant = learnableMerchantFromTransaction(transaction);
    if (!merchant) return null;
    addMerchantRule(db, userId, billId, merchant);
    return merchant;
  } catch {
    return null;
  }
}

// Scan all unmatched negative transactions for this user, apply any stored
// merchant rules, create payments, and mark the transactions matched.
// Returns { matched: number }.
function applyMerchantRules(
  db: Db,
  userId: number,
): { matched: number; matched_bills?: string[]; late_attributions?: any[] } {
  let rules: any[];
  try {
    rules = db
      .prepare(
        `
      SELECT bmr.bill_id, bmr.merchant, bmr.auto_attribute_late, b.name AS bill_name, b.due_day
      FROM bill_merchant_rules bmr
      JOIN bills b ON b.id = bmr.bill_id AND b.user_id = bmr.user_id AND b.deleted_at IS NULL
      WHERE bmr.user_id = ?
    `,
      )
      .all(userId);
  } catch {
    return { matched: 0 };
  }

  if (rules.length === 0) return { matched: 0, matched_bills: [] };

  // Longer (more specific) rules win when multiple could match the same transaction
  rules.sort((a, b) => b.merchant.length - a.merchant.length);

  let txRows: any[];
  try {
    txRows = db
      .prepare(
        `
      SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at
      FROM transactions t
      LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
      WHERE t.user_id = ?
        AND t.match_status = 'unmatched'
        AND t.ignored = 0
        AND t.amount < 0
        AND t.pending = 0
        AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
    `,
      )
      .all(userId);
  } catch (err: any) {
    console.error('[applyMerchantRules] Failed to fetch transactions:', err.message);
    return { matched: 0, matched_bills: [] };
  }

  if (txRows.length === 0) return { matched: 0, matched_bills: [] };

  // Global grace window — auto-apply late attribution for ALL bills within this many days
  const userSettings: any = (() => {
    try {
      return getUserSettings(userId);
    } catch {
      return {};
    }
  })();
  const globalGraceDays = parseInt(userSettings.bank_late_attribution_days, 10) || 0;

  const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
  const insertPayment = db.prepare(`
    INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
    VALUES (?, ?, ?, 'provider_sync', ?)
  `);
  const updateTx = db.prepare(`
    UPDATE transactions
    SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
    WHERE id = ? AND user_id = ? AND match_status = 'unmatched'
  `);

  let matched = 0;
  const matchedBills = new Map<number, string>(); // bill_id → bill_name for the summary
  const lateAttributions: any[] = []; // payments that crossed a month boundary

  try {
    db.transaction(() => {
      for (const tx of txRows) {
        const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
        if (!txMerchant) continue;

        // All rules that match this transaction (rules is pre-sorted most-specific
        // first by merchant length). If the most-specific tier maps to more than one
        // distinct bill — e.g. two bills both named "Amazon" with a matching rule —
        // the match is ambiguous, so skip auto-attribution and leave it for manual
        // review rather than silently guessing the wrong bill.
        const matchingRules = rules.filter((r) => merchantMatches(txMerchant, r.merchant));
        if (matchingRules.length === 0) continue;
        const topLen = matchingRules[0].merchant.length;
        const topBills = new Set(
          matchingRules.filter((r) => r.merchant.length === topLen).map((r) => r.bill_id),
        );
        if (topBills.size > 1) continue;
        const rule = matchingRules[0];

        const paidDate =
          tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
        if (!paidDate) continue;

        // tx.amount and payments.amount are both integer cents; keep a dollar
        // copy only for the lateAttributions display payload below.
        const amountCents = Math.round(Math.abs(tx.amount));
        const amount = fromCents(amountCents);
        const bill = getBill.get(rule.bill_id);

        const result = insertPayment.run(rule.bill_id, amountCents, paidDate, tx.id);
        if (result.changes > 0) {
          const inserted = db
            .prepare(
              'SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL',
            )
            .get(tx.id, rule.bill_id);
          updateTx.run(rule.bill_id, tx.id, userId);
          matched++;
          matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`);

          // Check if this payment just missed the previous month's window
          const effectiveGrace = Math.max(5, globalGraceDays);
          const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day, effectiveGrace);
          if (suggestedDate) {
            const dayOfMonth = new Date(paidDate + 'T00:00:00').getDate();
            // Auto-apply if: per-rule toggle is on OR global grace window covers this day
            const autoApply =
              rule.auto_attribute_late || (globalGraceDays > 0 && dayOfMonth <= globalGraceDays);

            if (autoApply) {
              db.prepare(
                "UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL",
              ).run(suggestedDate, tx.id, rule.bill_id);
              if (inserted) inserted.paid_date = suggestedDate;
            } else {
              const insertedForPrompt = db
                .prepare(
                  'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL',
                )
                .get(tx.id, rule.bill_id);
              if (insertedForPrompt) {
                lateAttributions.push({
                  payment_id: insertedForPrompt.id,
                  bill_name: rule.bill_name || `Bill #${rule.bill_id}`,
                  original_date: paidDate,
                  suggested_date: suggestedDate,
                  amount,
                });
              }
            }
          }
          if (bill && inserted) applyBankPaymentAsSourceOfTruth(db, bill, inserted);
        }
      }
    })();
  } catch (err: any) {
    console.error('[applyMerchantRules] Transaction failed, no payments recorded:', err.message);
    return { matched: 0, matched_bills: [], late_attributions: [] };
  }

  return {
    matched,
    matched_bills: [...matchedBills.values()],
    late_attributions: lateAttributions,
  };
}

// Sync all unmatched SimpleFIN transactions for a single bill using its stored
// merchant rules. If no rule exists yet but the bill has a detected merchant in
// its notes, the rule is created on the fly.
// Returns { added: number }.
function syncBillPaymentsFromSimplefin(
  db: Db,
  userId: number,
  billId: number,
): { added: number; late_attributions?: any[] } {
  // Load rules for this specific bill
  let rules: any[];
  try {
    rules = db
      .prepare(
        `
      SELECT merchant FROM bill_merchant_rules
      WHERE user_id = ? AND bill_id = ?
      ORDER BY LENGTH(merchant) DESC
    `,
      )
      .all(userId, billId)
      .map((r: any) => r.merchant);
  } catch {
    return { added: 0 };
  }

  // Fallback: extract merchant from notes "Detected from recurring merchant: X"
  if (rules.length === 0) {
    try {
      const bill = db
        .prepare('SELECT notes FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
        .get(billId, userId);
      const match = bill?.notes?.match(/Detected from recurring merchant:\s*(.+)/i);
      if (match) {
        const extracted = normalizeMerchant(match[1].trim());
        if (extracted && extracted.length >= 3) {
          addMerchantRule(db, userId, billId, extracted);
          rules = [extracted];
        }
      }
    } catch (err: any) {
      console.error('[syncBillPaymentsFromSimplefin] Failed to read bill notes:', err.message);
    }
    if (rules.length === 0) return { added: 0 };
  }

  let txRows: any[];
  try {
    txRows = db
      .prepare(
        `
      SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at
      FROM transactions t
      LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
      WHERE t.user_id = ?
        AND t.match_status = 'unmatched'
        AND t.ignored = 0
        AND t.amount < 0
        AND t.pending = 0
        AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
    `,
      )
      .all(userId);
  } catch (err: any) {
    console.error('[syncBillPaymentsFromSimplefin] Failed to fetch transactions:', err.message);
    return { added: 0 };
  }

  if (txRows.length === 0) return { added: 0 };

  const billMeta = db
    .prepare(
      'SELECT name, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL',
    )
    .get(billId);
  const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
  const insertPayment = db.prepare(`
    INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
    VALUES (?, ?, ?, 'provider_sync', ?)
  `);
  const updateTx = db.prepare(`
    UPDATE transactions
    SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
    WHERE id = ? AND user_id = ? AND match_status = 'unmatched'
  `);
  const getPaymentId = db.prepare(
    'SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL',
  );

  let added = 0;
  const lateAttributions: any[] = [];
  try {
    db.transaction(() => {
      for (const tx of txRows) {
        const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
        if (!txMerchant) continue;
        const matches = rules.some((r) => merchantMatches(txMerchant, r));
        if (!matches) continue;
        const paidDate =
          tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
        if (!paidDate) continue;
        // tx.amount and payments.amount are both integer cents; keep a dollar
        // copy only for the lateAttributions display payload below.
        const amountCents = Math.round(Math.abs(tx.amount));
        const amount = fromCents(amountCents);
        const bill = getBill.get(billId);

        const result = insertPayment.run(billId, amountCents, paidDate, tx.id);
        if (result.changes > 0) {
          const inserted = getPaymentId.get(tx.id, billId);
          updateTx.run(billId, tx.id, userId);
          added++;

          // Check for late attribution (payment just crossed month boundary)
          const globalDays2 = (() => {
            try {
              return parseInt(getUserSettings(userId).bank_late_attribution_days, 10) || 0;
            } catch {
              return 0;
            }
          })();
          const effectiveGrace2 = Math.max(5, globalDays2);
          const suggestedDate = billMeta
            ? lateAttributionCandidate(paidDate, billMeta.due_day, effectiveGrace2)
            : null;
          if (suggestedDate) {
            const dayOfMonth = new Date(paidDate + 'T00:00:00').getDate();
            const globalDays = globalDays2;
            const autoApply = globalDays > 0 && dayOfMonth <= globalDays;

            if (autoApply) {
              db.prepare(
                "UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL",
              ).run(suggestedDate, tx.id, billId);
              if (inserted) inserted.paid_date = suggestedDate;
            } else {
              const insertedForPrompt = getPaymentId.get(tx.id, billId);
              if (insertedForPrompt) {
                lateAttributions.push({
                  payment_id: insertedForPrompt.id,
                  bill_name: billMeta.name || `Bill #${billId}`,
                  original_date: paidDate,
                  suggested_date: suggestedDate,
                  amount,
                });
              }
            }
          }
          if (bill && inserted) applyBankPaymentAsSourceOfTruth(db, bill, inserted);
        }
      }
    })();
  } catch (err: any) {
    console.error(
      '[syncBillPaymentsFromSimplefin] Transaction failed, no payments recorded:',
      err.message,
    );
    return { added: 0, late_attributions: [] };
  }

  return { added, late_attributions: lateAttributions };
}

module.exports = {
  addMerchantRule,
  applyMerchantRules,
  syncBillPaymentsFromSimplefin,
  merchantMatches,
  learnMerchantRuleFromMatch,
  learnableMerchantFromTransaction,
};
