'use strict';

const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService');
const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeAmountSuggestionsBatch } = require('./amountSuggestionService');
const { accountingActiveSql } = require('./paymentAccountingService');
const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates.mts');
const { sumMoney, roundMoney, fromCents } = require('../utils/money.mts');

const DEFAULT_PENDING_DAYS = 3;

// Word-boundary match — same semantics as billMerchantRuleService.merchantMatches.
function txMerchantMatches(txNorm, ruleMerchant) {
  if (!txNorm || !ruleMerchant) return false;
  if (txNorm === ruleMerchant) return true;
  const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`);
  return wb(ruleMerchant).test(txNorm) || wb(txNorm).test(ruleMerchant);
}

// For bills that have merchant rules, count how many of that user's pending bank
// transactions match each bill. Only bills with at least one rule are checked.
function fetchBankPendingCounts(db, userId, billIds) {
  if (billIds.length === 0) return {};
  const ph = billIds.map(() => '?').join(',');
  const rules = db.prepare(`
    SELECT bill_id, merchant
    FROM bill_merchant_rules
    WHERE user_id = ? AND bill_id IN (${ph})
  `).all(userId, ...billIds);
  if (rules.length === 0) return {};

  const pendingTxs = db.prepare(`
    SELECT t.payee, t.description, t.memo
    FROM transactions t
    LEFT JOIN financial_accounts fa ON fa.id = t.account_id
    WHERE t.user_id = ?
      AND t.pending = 1
      AND t.amount < 0
      AND t.ignored = 0
      AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
  `).all(userId);
  if (pendingTxs.length === 0) return {};

  const rulesByBill = {};
  for (const rule of rules) {
    if (!rulesByBill[rule.bill_id]) rulesByBill[rule.bill_id] = [];
    rulesByBill[rule.bill_id].push(rule.merchant);
  }

  const counts = {};
  for (const tx of pendingTxs) {
    const txNorm = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
    if (!txNorm) continue;
    for (const [billId, merchants] of Object.entries(rulesByBill)) {
      if (merchants.some(m => txMerchantMatches(txNorm, m))) {
        counts[billId] = (counts[billId] || 0) + 1;
      }
    }
  }
  return counts;
}

// `gatedUnpaidThisMonth` (dollars) is the occurrence-gated unpaid total the
// caller already computed from the tracker rows (which honor resolveDueDate).
// When provided we use it instead of the ungated SQL below, so annual/quarterly/
// off-month bills don't inflate `unpaid_this_month` → the bank `remaining`.
function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null) {
  try {
  const settings = getUserSettings(userId);
  if (settings.bank_tracking_enabled !== 'true') return { enabled: false };

  const accountId = parseInt(settings.bank_tracking_account_id, 10);
  if (!Number.isInteger(accountId) || accountId < 1) return { enabled: false };

  const account = db.prepare(`
    SELECT id, name, org_name, balance, available_balance, updated_at
    FROM financial_accounts WHERE id = ? AND user_id = ?
  `).get(accountId, userId);
  if (!account || account.balance === null) return { enabled: false };

  const pendingDays = parseInt(settings.bank_tracking_pending_days, 10);
  const days = Number.isInteger(pendingDays) && pendingDays >= 0 ? pendingDays : DEFAULT_PENDING_DAYS;

  // Only count manually-entered payments as pending — bank-synced payments
  // (provider_sync) are already reflected in the live bank balance, so
  // including them would double-deduct.
  const pendingRow = days > 0
    ? db.prepare(`
        SELECT COALESCE(SUM(p.amount), 0) AS pending_total
        FROM payments p JOIN bills b ON b.id = p.bill_id
        WHERE b.user_id = ?
          AND p.paid_date >= date('now', '-' || ? || ' days')
          AND p.paid_date <= date('now') AND b.deleted_at IS NULL
          AND ${accountingActiveSql('p')}
          AND (p.payment_source IS NULL OR p.payment_source NOT IN ('provider_sync', 'transaction_match', 'auto_match'))
      `).get(userId, days)
    : { pending_total: 0 };

  // Fallback (only when the caller didn't pass a gated total, e.g. a direct
  // call): the ungated SQL sum. This overcounts off-month bills — prefer the
  // gated total from getTracker's rows.
  let unpaid;
  if (gatedUnpaidThisMonth != null) {
    unpaid = roundMoney(gatedUnpaidThisMonth);
  } else {
    const { start, end } = getCycleRange(year, month);
    const unpaidRow = db.prepare(`
      SELECT COALESCE(SUM(
        CASE WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
             ELSE COALESCE(b.expected_amount, 0) END
      ), 0) AS unpaid_total
      FROM bills b
      LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
      LEFT JOIN (
        SELECT bill_id, SUM(amount) AS paid_sum FROM payments
        WHERE paid_date BETWEEN ? AND ?
          AND deleted_at IS NULL
          AND ${accountingActiveSql()}
        GROUP BY bill_id
      ) pay ON pay.bill_id = b.id
      WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
        AND COALESCE(m.is_skipped, 0) = 0 AND COALESCE(pay.paid_sum, 0) = 0
    `).get(year, month, start, end, userId);
    unpaid = fromCents(unpaidRow.unpaid_total);
  }

  const balance   = fromCents(account.balance);
  const pending   = fromCents(pendingRow.pending_total);
  const effective = roundMoney(balance - pending);

  return {
    enabled:           true,
    account_id:        account.id,
    account_name:      account.name,
    org_name:          account.org_name,
    balance,
    pending_payments:  pending,
    pending_days:      days,
    effective_balance: effective,
    unpaid_this_month: unpaid,
    remaining:         roundMoney(effective - unpaid),
    last_updated:      account.updated_at,
  };
  } catch (err) {
    console.error('[buildBankTracking] Error computing bank tracking data:', err.message);
    return { enabled: false };
  }
}

function validateTrackerMonth(query = {}, now = new Date()) {
  const year = parseInt(query.year || now.getFullYear(), 10);
  const month = parseInt(query.month || now.getMonth() + 1, 10);

  if (Number.isNaN(year) || year < 2000 || year > 2100) {
    return { error: 'year must be a 4-digit integer between 2000 and 2100', status: 400 };
  }
  if (Number.isNaN(month) || month < 1 || month > 12) {
    return { error: 'month must be an integer between 1 and 12', status: 400 };
  }
  return { year, month };
}

function previousMonthFor(year, month) {
  return {
    year: month === 1 ? year - 1 : year,
    month: month === 1 ? 12 : month - 1,
  };
}

function monthOffset(year, month, offset) {
  let y = year;
  let m = month + offset;
  while (m <= 0) { m += 12; y -= 1; }
  while (m > 12) { m -= 12; y += 1; }
  return { year: y, month: m };
}

const FETCH_BILLS_ORDER = {
  due_day: 'CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC',
  id:      'b.id ASC',
};

function fetchActiveBills(db, userId, orderKey = 'due_day') {
  const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day;
  return db.prepare(`
    SELECT b.*, c.name AS category_name,
      CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule,
      CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions
    FROM bills b
    LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
    LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id
    LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id
    WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
    ORDER BY ${orderBy}
  `).all(userId);
}

function fetchMonthlyStates(db, billIds, year, month) {
  if (billIds.length === 0) return {};
  const placeholders = billIds.map(() => '?').join(',');
  const rows = db.prepare(`
    SELECT bill_id, actual_amount, notes, is_skipped, snoozed_until
    FROM monthly_bill_state
    WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
  `).all(...billIds, year, month);
  return Object.fromEntries(rows.map(row => [row.bill_id, row]));
}

function fetchPaymentsForBillCycle(db, bill, year, month) {
  const range = getCycleRange(year, month, bill);
  if (!range) return [];
  return db.prepare(`
    SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
    FROM payments
    WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
      AND deleted_at IS NULL
      AND ${accountingActiveSql()}
    ORDER BY paid_date DESC
  `).all(bill.id, range.start, range.end);
}

// Batched form of fetchPaymentsForBillCycle: one query for every bill's cycle
// payments (grouped in JS by each bill's own range) instead of one query per
// bill. Returns Map<billId, payments[]> with each list still ordered paid_date
// DESC — identical to calling fetchPaymentsForBillCycle per bill.
function fetchPaymentsForBillsInMonth(db, bills, year, month) {
  const ranges = new Map(); // billId → { start, end } (only bills due this month)
  let minStart = null;
  let maxEnd = null;
  for (const bill of bills) {
    const range = getCycleRange(year, month, bill);
    if (!range) continue;
    ranges.set(bill.id, range);
    if (minStart === null || range.start < minStart) minStart = range.start;
    if (maxEnd === null || range.end > maxEnd) maxEnd = range.end;
  }

  const byBill = new Map();
  if (minStart === null) return byBill;

  const ids = [...ranges.keys()];
  const placeholders = ids.map(() => '?').join(',');
  const rows = db.prepare(`
    SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
    FROM payments
    WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
      AND deleted_at IS NULL
      AND ${accountingActiveSql()}
    ORDER BY paid_date DESC
  `).all(...ids, minStart, maxEnd);

  for (const row of rows) {
    const range = ranges.get(row.bill_id);
    // The union window may be wider than this bill's own cycle — filter to it.
    if (row.paid_date < range.start || row.paid_date > range.end) continue;
    if (!byBill.has(row.bill_id)) byBill.set(row.bill_id, []);
    byBill.get(row.bill_id).push(row);
  }
  return byBill;
}

function fetchPreviousMonthPaid(db, billIds, range) {
  if (billIds.length === 0) return {};
  const placeholders = billIds.map(() => '?').join(',');
  const rows = db.prepare(`
    SELECT bill_id, SUM(amount) as total_paid
    FROM payments
    WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
      AND deleted_at IS NULL
      AND ${accountingActiveSql()}
    GROUP BY bill_id
  `).all(...billIds, range.start, range.end);
  return Object.fromEntries(rows.map(row => [row.bill_id, row.total_paid]));
}

function fetchDismissedSuggestions(db, userId, billIds, year, month) {
  if (billIds.length === 0) return new Set();
  const rows = db.prepare(`
    SELECT bill_id
    FROM autopay_suggestion_dismissals
    WHERE user_id = ? AND year = ? AND month = ?
  `).all(userId, year, month);
  return new Set(rows.map(row => row.bill_id));
}

function fetchSparklines(db, billIds) {
  if (billIds.length === 0) return {};
  const ph = billIds.map(() => '?').join(',');
  const rows = db.prepare(`
    SELECT bill_id, substr(paid_date, 1, 7) AS month_str, SUM(amount) AS total
    FROM payments
    WHERE bill_id IN (${ph})
      AND paid_date >= date('now', '-6 months')
      AND deleted_at IS NULL AND accounting_excluded = 0
    GROUP BY bill_id, month_str
    ORDER BY bill_id, month_str
  `).all(...billIds);
  const out = {};
  for (const r of rows) {
    if (!out[r.bill_id]) out[r.bill_id] = [];
    out[r.bill_id].push(fromCents(r.total));
  }
  return out;
}

function fetchAutopayStats(db, billIds) {
  if (billIds.length === 0) return {};
  const ph = billIds.map(() => '?').join(',');
  const rows = db.prepare(`
    SELECT
      p.bill_id,
      COUNT(*) AS total,
      SUM(p.autopay_failure) AS failures,
      MAX(CASE WHEN p.autopay_failure = 1 THEN p.paid_date END) AS last_failure_date,
      MAX(CASE WHEN p.autopay_failure = 1 THEN p.notes END) AS last_failure_notes
    FROM payments p
    JOIN bills b ON b.id = p.bill_id
    WHERE p.bill_id IN (${ph})
      AND b.autopay_enabled = 1
      AND p.deleted_at IS NULL
      AND p.paid_date >= date('now', '-12 months')
    GROUP BY p.bill_id
  `).all(...billIds);
  return Object.fromEntries(rows.map(r => [r.bill_id, {
    total: r.total,
    failures: r.failures || 0,
    last_failure_date: r.last_failure_date || null,
    last_failure_notes: r.last_failure_notes || null,
  }]));
}

function rowDueAmount(row) {
  const amount = Number(row.actual_amount ?? row.expected_amount);
  return Number.isFinite(amount) ? amount : 0;
}

function rowPaidTowardDue(row) {
  const cappedPaid = Number(row.paid_toward_due);
  if (Number.isFinite(cappedPaid)) return cappedPaid;
  return Math.min(Number(row.total_paid) || 0, rowDueAmount(row));
}

function rowOutstanding(row) {
  return Math.max(Number(row.balance) || 0, 0);
}

/**
 * Safe-to-spend projection for the current 1st/15th pay period.
 *
 * Pure function (no DB) so it is unit-testable: takes serialized tracker rows
 * (dollar-denominated, post buildTrackerRow) plus the cash available for the
 * period, and answers: "after every bill still due before the next payday is
 * covered — including overdue carry-over — what is left to spend?"
 *
 * The next payday is the next bucket boundary (the 15th, or the 1st of the
 * following month), matching the app's semi-monthly bucket model.
 */
function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfMonth }) {
  const nextPayday = dayOfMonth < 15
    ? `${year}-${String(month).padStart(2, '0')}-15`
    : (month === 12 ? `${year + 1}-01-01` : `${year}-${String(month + 1).padStart(2, '0')}-01`);

  // Both strings are YYYY-MM-DD → Date.parse treats them as UTC midnight,
  // so the difference is an exact whole number of days.
  const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000));

  const stillDueRows = activeRows
    .filter(r => !isPaidStatus(r.status))
    .filter(r => rowOutstanding(r) > 0)
    .filter(r => r.due_date < nextPayday)
    .sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name)));

  const stillDueTotal = sumMoney(stillDueRows, rowOutstanding);
  const safeToSpend = roundMoney(available - stillDueTotal);

  // Daily projection from today to payday: balance steps down as bills hit.
  // Overdue bills land on "today" — they need to be paid now, not in the past.
  const byDate = new Map();
  for (const r of stillDueRows) {
    const date = r.due_date > todayStr ? r.due_date : todayStr;
    if (!byDate.has(date)) byDate.set(date, []);
    byDate.get(date).push({ id: r.id, name: r.name, amount: rowOutstanding(r) });
  }

  const timeline = [];
  let running = roundMoney(available);
  if (!byDate.has(todayStr)) timeline.push({ date: todayStr, balance: running, bills: [] });
  for (const date of [...byDate.keys()].sort()) {
    const bills = byDate.get(date);
    running = roundMoney(running - sumMoney(bills, b => b.amount));
    timeline.push({ date, balance: running, bills });
  }
  if (timeline.length === 0 || timeline[timeline.length - 1].date !== nextPayday) {
    timeline.push({ date: nextPayday, balance: running, bills: [], payday: true });
  }

  return {
    next_payday: nextPayday,
    days_until_payday: daysUntilPayday,
    available: roundMoney(available),
    safe_to_spend: safeToSpend,
    still_due_total: stillDueTotal,
    still_due_count: stillDueRows.length,
    upcoming: stillDueRows.slice(0, 8).map(r => ({
      id: r.id,
      name: r.name,
      due_date: r.due_date,
      amount: rowOutstanding(r),
      status: r.status,
    })),
    timeline,
  };
}

function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) {
  const dueDate = resolveDueDate(bill, year, month);
  if (!dueDate) return null;

  const suggestedAmount = Number(mbs?.actual_amount ?? bill.expected_amount);
  const hasSuggestedAmount = Number.isFinite(suggestedAmount) && suggestedAmount > 0;
  const isEligible = !!(
    bill.autopay_enabled &&
    bill.autodraft_status === 'assumed_paid' &&
    hasSuggestedAmount &&
    dueDate <= todayStr &&
    !mbs?.is_skipped &&
    payments.length === 0
  );

  if (!isEligible) return null;

  if (bill.auto_mark_paid) {
    const range = getCycleRange(year, month, bill);
    const existingPayment = db.prepare(`
      SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
      FROM payments
      WHERE bill_id = ?
        AND deleted_at IS NULL
        AND ${accountingActiveSql()}
        AND paid_date BETWEEN ? AND ?
      ORDER BY paid_date DESC
      LIMIT 1
    `).get(bill.id, range.start, range.end);

    if (existingPayment) {
      payments.push(existingPayment);
      return null;
    }

    const balCalc = computeBalanceDelta(bill, suggestedAmount);
    // Atomic: the auto-mark payment INSERT and its balance update must both land
    // or neither. This runs on a GET /tracker, so a mid-way failure would
    // otherwise leave a payment without its balance adjustment (or vice versa).
    const insertAutoPayment = db.transaction(() => {
      const r = db.prepare(`
        INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
      `).run(
        bill.id,
        suggestedAmount,
        dueDate,
        'autopay',
        'Auto-marked paid on due date',
        balCalc?.balance_delta ?? null,
        balCalc?.interest_delta ?? null,
        'manual',
      );
      if (balCalc) applyBalanceDelta(db, bill.id, balCalc);
      return r.lastInsertRowid;
    });
    const paymentId = insertAutoPayment();

    if (balCalc) bill.current_balance = balCalc.new_balance;
    payments.push(db.prepare(`
      SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
      FROM payments
      WHERE id = ?
    `).get(paymentId));
    return null;
  }

  if (dismissedSuggestions.has(bill.id)) return null;
  return {
    bill_id: bill.id,
    amount: fromCents(suggestedAmount),
    paid_date: dueDate,
    method: 'autopay',
  };
}

function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
  const threeMonthsAgo = monthOffset(year, month, -2);
  const threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start;
  const rows = db.prepare(`
    SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
    FROM payments p
    JOIN bills b ON p.bill_id = b.id
    WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ?
      AND p.deleted_at IS NULL
      AND ${accountingActiveSql('p')}
    GROUP BY strftime('%Y-%m', p.paid_date)
  `).all(userId, threeMonthStart, end);

  const monthlyPaymentsMap = new Map();
  rows.forEach(payment => {
    monthlyPaymentsMap.set(payment.month_key, payment.total_paid);
  });

  const months = [];
  for (let i = 2; i >= 0; i--) {
    const date = new Date(year, month - 1 - i);
    const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
    months.push({
      year: date.getFullYear(),
      month: date.getMonth() + 1,
      key: monthKey,
      payment: fromCents(monthlyPaymentsMap.get(monthKey) || 0),
    });
  }

  const threeMonthAvg = sumMoney(months, m => m.payment) / 3;
  let percentChange = 0;
  let direction = 'flat';
  if (threeMonthAvg > 0) {
    percentChange = ((currentMonthPaid - threeMonthAvg) / threeMonthAvg) * 100;
    if (percentChange > 2) direction = 'up';
    else if (percentChange < -2) direction = 'down';
  }

  return {
    three_month_avg: parseFloat(threeMonthAvg.toFixed(2)),
    current_month_paid: parseFloat(currentMonthPaid.toFixed(2)),
    percent_change: parseFloat(percentChange.toFixed(1)),
    direction,
  };
}

function getTracker(userId, query = {}, now = new Date()) {
  const parsed = validateTrackerMonth(query, now);
  if (parsed.error) return parsed;

  const db = getDb();
  const { year, month } = parsed;
  const todayStr = localDateString(now);
  const userSettings = getUserSettings(userId);
  const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
  const { start, end } = getCycleRange(year, month);
  const previousMonth = previousMonthFor(year, month);
  const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.month);

  const bills = fetchActiveBills(db, userId);
  const billIds = bills.map(bill => bill.id);
  const monthlyStates = fetchMonthlyStates(db, billIds, year, month);
  const prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange);
  const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
  const sparklines = fetchSparklines(db, billIds);
  const autopayStatsMap = fetchAutopayStats(db, billIds);
  // Batched to avoid an N+1 across bills (was ~2–3 queries × N bills every load):
  // one query for all cycle payments, two for all amount suggestions.
  const paymentsByBill = fetchPaymentsForBillsInMonth(db, bills, year, month);
  const amountSuggestions = computeAmountSuggestionsBatch(db, billIds, year, month);

  const rows = bills.map(bill => {
    bill.sparkline = sparklines[bill.id] ?? null;
    bill.autopay_stats = autopayStatsMap[bill.id] ?? null;
    if (!resolveDueDate(bill, year, month)) return null;

    const payments = paymentsByBill.get(bill.id) || [];
    const mbs = monthlyStates[bill.id];
    const autopaySuggestion = applyAutopaySuggestions(
      db,
      bill,
      payments,
      mbs,
      year,
      month,
      todayStr,
      dismissedSuggestions,
    );

    const billForStatus = mbs?.actual_amount != null
      ? { ...bill, expected_amount: mbs.actual_amount }
      : bill;
    const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions);
    if (!row) return null;

    row.expected_amount = fromCents(bill.expected_amount);
    row.actual_amount = mbs?.actual_amount != null ? fromCents(mbs.actual_amount) : null;
    row.monthly_notes = mbs?.notes ?? null;
    row.is_skipped = !!(mbs?.is_skipped);
    row.snoozed_until = mbs?.snoozed_until ?? null;
    if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
    row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0);
    row.amount_suggestion = amountSuggestions.get(bill.id) ?? null;
    return row;
  }).filter(Boolean);

  const activeRows = rows.filter(r => !r.is_skipped);
  const startingAmounts = db.prepare(`
    SELECT COALESCE(first_amount, 0) AS first_amount,
           COALESCE(fifteenth_amount, 0) AS fifteenth_amount,
           COALESCE(other_amount, 0) AS other_amount,
           COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
    FROM monthly_starting_amounts
    WHERE user_id = ? AND year = ? AND month = ?
  `).get(userId, year, month);

  if (startingAmounts) {
    startingAmounts.first_amount = fromCents(startingAmounts.first_amount);
    startingAmounts.fifteenth_amount = fromCents(startingAmounts.fifteenth_amount);
    startingAmounts.other_amount = fromCents(startingAmounts.other_amount);
    startingAmounts.combined_amount = fromCents(startingAmounts.combined_amount);
  }

  const dayOfMonth = now.getDate();
  const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
  const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
  const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue);
  const periodOutstandingBalance = sumMoney(periodRows, rowOutstanding);
  const periodStartingAmount = activeRemainingPeriod === '1st'
    ? (startingAmounts?.first_amount || 0)
    : (startingAmounts?.fifteenth_amount || 0);
  const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';

  // Occurrence-gated unpaid total for this month: the still-owed amount across
  // bills actually due this month (activeRows already honor resolveDueDate),
  // netting partial payments. Feeds the bank card so its unpaid/remaining agree
  // with the rows instead of over-counting annual/off-month bills.
  const activeMonthUnpaid = sumMoney(activeRows, r => Math.max(rowDueAmount(r) - rowPaidTowardDue(r), 0));
  const bankTracking = buildBankTracking(db, userId, year, month, activeMonthUnpaid);
  const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {};
  const totalStarting = bankTracking.enabled
    ? bankTracking.effective_balance
    : (startingAmounts?.combined_amount || 0);
  const hasStartingAmounts = bankTracking.enabled || !!startingAmounts;

  // ── Cash flow projection ───────────────────────────────────────────────────
  // "Projected" means starting minus ALL bills due — paid or not.
  // This tells the user what they'll have left after everything clears,
  // not just what remains after what they've already paid.
  const lastDayOfMonth = new Date(year, month, 0).getDate();
  const periodEndDay   = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth;
  const periodEndLabel = `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month - 1]} ${periodEndDay}`;

  const activeTotalPaid = sumMoney(activeRows, r => r.total_paid);
  const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue);
  const activeTotalExpected = sumMoney(activeRows, rowDueAmount);
  const activeOutstandingBalance = sumMoney(activeRows, rowOutstanding);

  const periodBillsTotal   = sumMoney(periodRows, rowDueAmount);
  const periodPaidCount    = periodRows.filter(r => isPaidStatus(r.status)).length;
  const periodTotalCount   = periodRows.length;

  // When bank tracking is on use the effective balance as the period starting point
  const periodCashStart = bankTracking.enabled
    ? bankTracking.effective_balance
    : periodStartingAmount;
  const periodProjected = roundMoney(periodCashStart - periodBillsTotal);

  const monthBillsTotal  = activeTotalExpected;
  const monthPaidCount   = activeRows.filter(r => isPaidStatus(r.status)).length;
  const monthTotalCount  = activeRows.length;
  const monthProjected   = roundMoney(totalStarting - monthBillsTotal);

  // Safe to spend: cash on hand for this period after already-made payments
  // (bank mode: effective balance already nets them out), minus everything
  // still due before the next payday.
  const availableNow = bankTracking.enabled
    ? bankTracking.effective_balance
    : roundMoney(periodStartingAmount - periodPaidTowardDue);
  const safeToSpend = buildSafeToSpend({
    activeRows,
    available: availableNow,
    todayStr,
    year,
    month,
    dayOfMonth,
  });

  const cashflow = {
    ...safeToSpend,
    has_data:           hasStartingAmounts,
    uses_bank_balance:  bankTracking.enabled,
    period:             activeRemainingPeriod,
    period_end_label:   periodEndLabel,
    period_starting:    periodCashStart,
    period_bills_total: periodBillsTotal,
    period_paid:        periodPaidTowardDue,
    period_paid_count:  periodPaidCount,
    period_total_count: periodTotalCount,
    period_projected:   periodProjected,
    month_starting:     totalStarting,
    month_bills_total:  monthBillsTotal,
    month_paid:         roundMoney(activePaidTowardDue),
    month_paid_count:   monthPaidCount,
    month_total_count:  monthTotalCount,
    month_projected:    monthProjected,
  };
  const totalOverdue = sumMoney(
    rows.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')),
    r => r.balance);
  const previousMonthTotal = sumMoney(activeRows, r => r.previous_month_paid);

  return {
    year,
    month,
    today: todayStr,
    summary: {
      total_expected: activeTotalExpected,
      total_starting: totalStarting,
      has_starting_amounts: hasStartingAmounts,
      total_paid: activeTotalPaid,
      paid_toward_due: activePaidTowardDue,
      // In bank mode the effective bank balance is a single pool (no per-period
      // split), so both remaining figures use the bank card's own remaining
      // (effective_balance − gated unpaid) — keeping the summary consistent with
      // safe-to-spend and the bank card instead of showing a different number
      // derived from manual starting amounts.
      remaining: roundMoney(bankTracking.enabled
        ? bankTracking.remaining
        : (hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance)),
      total_remaining: roundMoney(bankTracking.enabled
        ? bankTracking.remaining
        : (hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance)),
      remaining_period: activeRemainingPeriod,
      remaining_label: periodLabel,
      remaining_hint: hasStartingAmounts
        ? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaidTowardDue.toFixed(2)} paid toward due`
        : `${periodLabel}: unpaid bills due in this period`,
      overdue: totalOverdue,
      count_paid: activeRows.filter(r => r.status === 'paid').length,
      count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
      count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length,
      count_autodraft: activeRows.filter(r => r.status === 'autodraft').length,
      previous_month_total: previousMonthTotal,
      trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid),
    },
    bank_tracking: bankTracking,
    cashflow,
    rows: bankTracking.enabled
      ? rows.map(r => {
          const bank_pending_count = bankPendingCounts[r.id] || 0;
          // Only flag manually-entered payments as pending-cleared — bank-synced
          // or bank-matched payments are already settled so they don't need the badge.
          const isManualPayment = r.payment_source !== 'provider_sync' && r.payment_source !== 'transaction_match';
          // Amber "Pending" only makes sense for bills linked to the bank — for unlinked bills
          // there's no bank clearing to wait for, so they should just show "Paid".
          const isBankLinked = !!(r.has_merchant_rule || r.has_linked_transactions);
          if (r.status === 'paid' && r.last_paid_date && isManualPayment && isBankLinked) {
            const cutoff = new Date();
            cutoff.setDate(cutoff.getDate() - bankTracking.pending_days);
            const paidAt = new Date(r.last_paid_date);
            return { ...r, pending_cleared: paidAt >= cutoff, bank_pending_count };
          }
          return { ...r, pending_cleared: false, bank_pending_count };
        })
      : rows,
  };
}

function getUpcomingBills(userId, query = {}, now = new Date()) {
  const db = getDb();
  const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365));
  const todayStr = localDateString(now);
  const userSettings = getUserSettings(userId);
  const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
  const bills = fetchActiveBills(db, userId, 'id');

  const cutoff = new Date(now);
  cutoff.setDate(cutoff.getDate() + days);
  const cutoffStr = localDateString(cutoff);
  const upcoming = [];
  const seen = new Set();
  const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12
    + (cutoff.getMonth() - now.getMonth()) + 1;

  for (let offset = 0; offset < monthCount; offset += 1) {
    const target = monthOffset(now.getFullYear(), now.getMonth() + 1, offset);

    for (const bill of bills) {
      const dueDate = resolveDueDate(bill, target.year, target.month);
      if (!dueDate || dueDate < todayStr || dueDate > cutoffStr) continue;

      const key = `${bill.id}:${dueDate}`;
      if (seen.has(key)) continue;
      seen.add(key);

      const row = buildTrackerRow(
        bill,
        fetchPaymentsForBillCycle(db, bill, target.year, target.month),
        target.year,
        target.month,
        todayStr,
        rowOptions,
      );
      if (!row || row.status === 'paid') continue;

      upcoming.push({
        id: bill.id,
        name: bill.name,
        category_name: bill.category_name,
        due_date: dueDate,
        expected_amount: row.expected_amount,
        status: row.status,
        days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000),
      });
    }
  }

  upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date));
  return { days, today: todayStr, upcoming };
}

function getOverdueCount(userId, now = new Date()) {
  const db = getDb();
  const todayStr = localDateString(now);
  const year  = now.getFullYear();
  const month = now.getMonth() + 1;
  const monthStr = String(month).padStart(2, '0');
  const rangeStart = `${year}-${monthStr}-01`;
  const lastDay    = new Date(year, month, 0).getDate();
  const rangeEnd   = `${year}-${monthStr}-${String(lastDay).padStart(2, '0')}`;

  const bills = db.prepare(`
    SELECT b.id, b.name, b.due_day, b.override_due_date, b.expected_amount,
           b.billing_cycle, b.cycle_type, b.cycle_day,
           b.autopay_enabled, b.autodraft_status,
           mbs.actual_amount, mbs.is_skipped, mbs.snoozed_until,
           COALESCE(SUM(p.amount), 0) AS total_paid
    FROM bills b
    LEFT JOIN monthly_bill_state mbs
      ON mbs.bill_id = b.id AND mbs.year = ? AND mbs.month = ?
    LEFT JOIN payments p
      ON p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL
     AND ${accountingActiveSql('p')}
    WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
    GROUP BY b.id
  `).all(year, month, rangeStart, rangeEnd, userId);

  let count = 0;
  const overdueNames = [];
  for (const bill of bills) {
    if (bill.is_skipped) continue;
    if (bill.snoozed_until && bill.snoozed_until > todayStr) continue;
    if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') continue;

    const dueDate = resolveDueDate(bill, year, month);
    // Use >= so bills due TODAY are not counted as overdue — only strictly past dates
    if (!dueDate || dueDate >= todayStr) continue;

    const threshold = bill.actual_amount != null ? bill.actual_amount : bill.expected_amount;
    if (threshold > 0 && bill.total_paid >= threshold) continue;

    count++;
    overdueNames.push(bill.name);
  }

  return { count, names: overdueNames, month, year, today: todayStr };
}

module.exports = {
  getTracker,
  getUpcomingBills,
  validateTrackerMonth,
  getOverdueCount,
  buildSafeToSpend,
};
