// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const router = require('express').Router();
const { getDb } = require('../db/database.cts');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
const { getCycleRange, resolveDueDate } = require('../services/statusService.cts');
const { markUnmatched } = require('../services/transactionMatchState.cts');
const {
  markProvisionalManualPaymentsOverridden,
  reactivatePaymentsOverriddenBy,
} = require('../services/paymentAccountingService.cts');
const { todayLocal } = require('../utils/dates.mts');
const { fromCents } = require('../utils/money.mts');

// SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied.
// It cannot be a bind parameter (SQL fragments are not parameterisable — only
// values are). Interpolating a hardcoded constant like this is safe by
// construction; do NOT replace this pattern with dynamic/user-controlled input.
const SQL_NOT_DELETED = 'deleted_at IS NULL';
const TRANSACTION_MATCH_SOURCE = 'transaction_match';

function isTransactionLinkedPayment(payment) {
  return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null;
}

function rejectTransactionLinkedPayment(res) {
  return res
    .status(409)
    .json(
      standardizeError(
        'Transaction-linked payments must be changed through transaction match controls',
        'TRANSACTION_PAYMENT_LOCKED',
        'transaction_id',
      ),
    );
}

function parseYearMonth(body) {
  const year = parseInt(body.year, 10);
  const month = parseInt(body.month, 10);
  if (!Number.isInteger(year) || year < 2000 || year > 2100) {
    return {
      error: standardizeError(
        'year must be a 4-digit integer between 2000 and 2100',
        'VALIDATION_ERROR',
        'year',
      ),
    };
  }
  if (!Number.isInteger(month) || month < 1 || month > 12) {
    return {
      error: standardizeError(
        'month must be an integer between 1 and 12',
        'VALIDATION_ERROR',
        'month',
      ),
    };
  }
  return { year, month };
}

function getAutopaySuggestionContext(db, userId, billId, year, month) {
  const bill = db
    .prepare(
      `
    SELECT *
    FROM bills
    WHERE id = ? AND user_id = ? AND deleted_at IS NULL
  `,
    )
    .get(billId, userId);
  if (!bill)
    return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 };
  if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
    return {
      error: standardizeError(
        'Bill is not eligible for autopay suggestions',
        'VALIDATION_ERROR',
        'bill_id',
      ),
      status: 400,
    };
  }

  const state = db
    .prepare(
      `
    SELECT actual_amount, is_skipped
    FROM monthly_bill_state
    WHERE bill_id = ? AND year = ? AND month = ?
  `,
    )
    .get(bill.id, year, month);
  if (state?.is_skipped) {
    return {
      error: standardizeError(
        'Skipped bills cannot be suggested for payment',
        'VALIDATION_ERROR',
        'bill_id',
      ),
      status: 400,
    };
  }

  const dueDate = resolveDueDate(bill, year, month);
  if (!dueDate) {
    return {
      error: standardizeError(
        'Bill does not occur in the selected month',
        'VALIDATION_ERROR',
        'month',
      ),
      status: 400,
    };
  }
  const amount = fromCents(state?.actual_amount ?? bill.expected_amount);
  return { bill, dueDate, amount };
}

// GET /api/payments?bill_id=&year=&month=
router.get('/', (req: Req, res: Res) => {
  const db = getDb();
  const { bill_id, year, month } = req.query;

  // Validate year/month when provided
  if ((year || month) && !(year && month)) {
    return res
      .status(400)
      .json(
        standardizeError(
          'Both year and month are required when filtering by date',
          'VALIDATION_ERROR',
          'year',
        ),
      );
  }

  let y, m;
  if (year && month) {
    y = parseInt(year, 10);
    m = parseInt(month, 10);
    if (!Number.isInteger(y) || y < 2000 || y > 2100) {
      return res
        .status(400)
        .json(
          standardizeError(
            'year must be a 4-digit integer between 2000 and 2100',
            'VALIDATION_ERROR',
            'year',
          ),
        );
    }
    if (!Number.isInteger(m) || m < 1 || m > 12) {
      return res
        .status(400)
        .json(
          standardizeError(
            'month must be an integer between 1 and 12',
            'VALIDATION_ERROR',
            'month',
          ),
        );
    }
  }

  let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`;
  const params = [req.user.id];

  if (bill_id) {
    query += ' AND p.bill_id = ?';
    params.push(parseInt(bill_id, 10));
  }

  if (y && m) {
    const yStr = String(y);
    const mStr = String(m).padStart(2, '0');
    const daysInMonth = new Date(y, m, 0).getDate();
    const endDay = String(daysInMonth).padStart(2, '0');
    query += ' AND p.paid_date BETWEEN ? AND ?';
    params.push(`${yStr}-${mStr}-01`, `${yStr}-${mStr}-${endDay}`);
  }

  query += ' ORDER BY p.paid_date DESC';
  res.json(
    db
      .prepare(query)
      .all(...params)
      .map(serializePayment),
  );
});

// GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days
router.get('/recent-auto', (req: Req, res: Res) => {
  const db = getDb();
  const rows = db
    .prepare(
      `
    SELECT p.id, p.bill_id, p.amount, p.paid_date, p.payment_source,
           p.transaction_id, p.balance_delta, p.interest_delta, p.created_at,
           b.name AS bill_name,
           t.payee, t.description, t.amount AS tx_cents, t.posted_date
    FROM payments p
    JOIN bills b ON b.id = p.bill_id
    LEFT JOIN transactions t ON t.id = p.transaction_id
    WHERE b.user_id = ?
      AND p.payment_source = 'provider_sync'
      AND p.transaction_id IS NOT NULL
      AND p.deleted_at IS NULL
      AND b.deleted_at IS NULL
      AND p.created_at >= datetime('now', '-7 days')
    ORDER BY p.created_at DESC
    LIMIT 50
  `,
    )
    .all(req.user.id);
  res.json(rows.map(serializePayment));
});

// GET /api/payments/:id
router.get('/:id', (req: Req, res: Res) => {
  const db = getDb();
  const payment = db
    .prepare(
      `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
    )
    .get(req.params.id, req.user.id);
  if (!payment)
    return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
  res.json(serializePayment(payment));
});

// POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match
router.post('/:id/undo-auto', (req: Req, res: Res) => {
  const db = getDb();
  const payment = db
    .prepare(
      `
    SELECT p.* FROM payments p
    JOIN bills b ON b.id = p.bill_id
    WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL
  `,
    )
    .get(req.params.id, req.user.id);

  if (!payment)
    return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
  if (payment.payment_source !== 'provider_sync') {
    return res
      .status(409)
      .json(standardizeError('Only provider_sync payments can be undone here', 'NOT_AUTO_MATCH'));
  }
  if (!payment.transaction_id) {
    return res
      .status(409)
      .json(standardizeError('Payment has no linked transaction', 'NO_TRANSACTION'));
  }

  try {
    db.transaction(() => {
      // Restore balance (same logic as DELETE /:id)
      if (!payment.accounting_excluded && payment.balance_delta != null) {
        const bill = db
          .prepare('SELECT current_balance FROM bills WHERE id = ?')
          .get(payment.bill_id);
        if (bill?.current_balance != null) {
          const restored = Math.max(
            0,
            Number(bill.current_balance) - Number(payment.balance_delta),
          );
          db.prepare(
            `
            UPDATE bills
            SET current_balance        = ?,
                interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
                updated_at             = datetime('now')
            WHERE id = ?
          `,
          ).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
        }
      }
      reactivatePaymentsOverriddenBy(db, payment.id);
      db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
      markUnmatched(db, req.user.id, payment.transaction_id);
    })();
    res.json({ success: true });
  } catch (err) {
    console.error('[payments] undo-auto error:', err.message);
    res.status(500).json(standardizeError('Failed to undo auto-match', 'SERVER_ERROR'));
  }
});

// POST /api/payments — create single payment
router.post('/', (req: Req, res: Res) => {
  const db = getDb();
  const {
    bill_id,
    amount,
    paid_date,
    method,
    notes,
    payment_source,
    autopay_failure,
    allow_duplicate,
  } = req.body;

  const validation = validatePaymentInput({
    bill_id,
    amount,
    paid_date,
    payment_source: payment_source ?? 'manual',
  });
  if (validation.error) {
    return res
      .status(400)
      .json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
  }
  const payment = validation.normalized;

  const bill = db
    .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
    .get(payment.bill_id, req.user.id);
  if (!bill)
    return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));

  // The deliberate manual add must not *silently* drop a same-key payment — a user
  // may legitimately record two identical payments on the same day, and dropping
  // one is itself a money-integrity bug. Surface a 409 the client turns into an
  // "add anyway?" confirm; a resend with allow_duplicate bypasses this. (The
  // automated one-click paths — /quick, /bulk, autopay-confirm — dedupe silently
  // instead, because a repeat there is a retry, not a second intent.)
  if (!allow_duplicate) {
    const existingDuplicate = db
      .prepare(
        `SELECT p.* FROM payments p
       WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
       ORDER BY p.id DESC LIMIT 1`,
      )
      .get(bill.id, payment.paid_date, payment.amount);
    if (existingDuplicate) {
      return res.status(409).json({
        ...standardizeError(
          'A matching payment already exists for this bill, date, and amount',
          'DUPLICATE_SUSPECTED',
          'amount',
        ),
        existing: serializePayment(existingDuplicate),
      });
    }
  }

  const balCalc = computeBalanceDelta(bill, payment.amount);
  const failureFlag = autopay_failure ? 1 : 0;

  // Atomic: the INSERT and the balance update land together or neither, so a
  // mid-way failure never leaves a payment without its balance adjustment.
  const insertManualPayment = db.transaction(() => {
    const r = db
      .prepare(
        'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, autopay_failure) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
      )
      .run(
        payment.bill_id,
        payment.amount,
        payment.paid_date,
        method || null,
        notes || null,
        balCalc?.balance_delta ?? null,
        balCalc?.interest_delta ?? null,
        payment.payment_source,
        failureFlag,
      );
    applyBalanceDelta(db, bill.id, balCalc);
    return r.lastInsertRowid;
  });
  const paymentId = insertManualPayment();

  res
    .status(201)
    .json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)));
});

// POST /api/payments/quick — pay a bill (expected amount, today)
router.post('/quick', (req: Req, res: Res) => {
  const db = getDb();
  const { bill_id, amount, paid_date, method, notes, payment_source } = req.body;

  const billValidation = validatePaymentInput(
    { bill_id },
    { requireAmount: false, requirePaidDate: false },
  );
  if (billValidation.error) {
    return res
      .status(400)
      .json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
  }

  const bill = db
    .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
    .get(billValidation.normalized.bill_id, req.user.id);
  if (!bill)
    return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));

  const paymentValidation = validatePaymentInput(
    {
      amount: amount != null ? amount : fromCents(bill.expected_amount),
      paid_date: paid_date || todayLocal(),
      payment_source: payment_source ?? 'manual',
    },
    { requireBillId: false },
  );
  if (paymentValidation.error) {
    return res
      .status(400)
      .json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
  }
  const payAmount = paymentValidation.normalized.amount;
  const payDate = paymentValidation.normalized.paid_date;
  const paySource = paymentValidation.normalized.payment_source;

  // Guard against a double-clicked / retried "pay" (or two tabs) creating a
  // duplicate payment. Mirrors the bulk route's composite-key check
  // (bill_id + paid_date + amount). If one already exists, return it idempotently.
  const existingDuplicate = db
    .prepare(
      `SELECT p.* FROM payments p
     WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
     ORDER BY p.id DESC LIMIT 1`,
    )
    .get(bill.id, payDate, payAmount);
  if (existingDuplicate) {
    return res.status(200).json(serializePayment(existingDuplicate));
  }

  const balCalc = computeBalanceDelta(bill, payAmount);

  // Atomic: the INSERT and the balance update must both land or neither, so a
  // mid-way failure never leaves a payment without its balance adjustment.
  const insertQuickPayment = 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,
        payAmount,
        payDate,
        method || null,
        notes || null,
        balCalc?.balance_delta ?? null,
        balCalc?.interest_delta ?? null,
        paySource,
      );
    applyBalanceDelta(db, bill.id, balCalc);
    return r.lastInsertRowid;
  });
  const paymentId = insertQuickPayment();

  res
    .status(201)
    .json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)));
});

// POST /api/payments/autopay-suggestions/:billId/confirm
router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
  const db = getDb();
  const ym = parseYearMonth(req.body);
  if (ym.error) return res.status(400).json(ym.error);

  const billId = parseInt(req.params.billId, 10);
  if (!Number.isInteger(billId)) {
    return res
      .status(400)
      .json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
  }

  const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month);
  if (context.error) return res.status(context.status).json(context.error);
  const { bill, dueDate, amount } = context;
  if (dueDate > todayLocal()) {
    return res
      .status(400)
      .json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
  }
  const paymentValidation = validatePaymentInput(
    { amount, paid_date: dueDate },
    { requireBillId: false },
  );
  if (paymentValidation.error) {
    return res
      .status(400)
      .json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
  }
  const suggestedPayment = paymentValidation.normalized;

  const suggestionRange = getCycleRange(ym.year, ym.month, bill);
  const existing = db
    .prepare(
      `
    SELECT p.*
    FROM payments p
    JOIN bills b ON b.id = p.bill_id
    WHERE p.bill_id = ?
      AND b.user_id = ?
      AND p.deleted_at IS NULL
      AND p.paid_date BETWEEN ? AND ?
    ORDER BY p.paid_date DESC
    LIMIT 1
  `,
    )
    .get(bill.id, req.user.id, suggestionRange.start, suggestionRange.end);

  if (existing) {
    db.prepare(
      'DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?',
    ).run(req.user.id, bill.id, ym.year, ym.month);
    return res.json({ created: false, payment: serializePayment(existing) });
  }

  const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);

  // Atomic: the payment INSERT, its balance update, and clearing the dismissal
  // must all land or none, so a mid-way failure can't leave a half-recorded pay.
  const confirmAutopay = 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,
        suggestedPayment.amount,
        suggestedPayment.paid_date,
        'autopay',
        'Confirmed autopay suggestion',
        balCalc?.balance_delta ?? null,
        balCalc?.interest_delta ?? null,
        'manual',
      );
    applyBalanceDelta(db, bill.id, balCalc);
    db.prepare(
      'DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?',
    ).run(req.user.id, bill.id, ym.year, ym.month);
    return r.lastInsertRowid;
  });
  const paymentId = confirmAutopay();

  res.status(201).json({
    created: true,
    payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)),
  });
});

// POST /api/payments/autopay-suggestions/:billId/dismiss
router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => {
  const db = getDb();
  const ym = parseYearMonth(req.body);
  if (ym.error) return res.status(400).json(ym.error);

  const billId = parseInt(req.params.billId, 10);
  if (!Number.isInteger(billId)) {
    return res
      .status(400)
      .json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
  }
  if (
    !db
      .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
      .get(billId, req.user.id)
  ) {
    return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
  }

  db.prepare(
    `
    INSERT INTO autopay_suggestion_dismissals (user_id, bill_id, year, month, dismissed_at)
    VALUES (?, ?, ?, ?, datetime('now'))
    ON CONFLICT(user_id, bill_id, year, month)
    DO UPDATE SET dismissed_at = datetime('now')
  `,
  ).run(req.user.id, billId, ym.year, ym.month);

  res.json({ success: true });
});

// POST /api/payments/bulk — record multiple payments in one request
// Bulk payment creation endpoint
// Validation rules:
// - Request body must contain a `payments` array
// - Maximum 50 items per request
// - Each item requires: bill_id (integer), paid_date (valid date), amount (positive number)
// - Duplicate payments (same bill_id + paid_date + amount) are skipped, not created
// - Returns { created: [...], skipped: [...], errors: [...] }
router.post('/bulk', (req: Req, res: Res) => {
  const db = getDb();
  const { payments } = req.body;

  // Validate request body has payments array
  if (!payments || !Array.isArray(payments))
    return res
      .status(400)
      .json(
        standardizeError(
          'Request body must contain a `payments` array',
          'VALIDATION_ERROR',
          'payments',
        ),
      );

  // Validate max items per request (50)
  if (payments.length > 50)
    return res
      .status(400)
      .json(
        standardizeError('Maximum 50 items allowed per request', 'VALIDATION_ERROR', 'payments'),
      );

  // Validate each payment item
  for (let i = 0; i < payments.length; i++) {
    const item = payments[i];
    const validation = validatePaymentInput(
      { ...item, payment_source: item.payment_source ?? 'manual' },
      { fieldPrefix: `payments[${i}].` },
    );
    if (validation.error) {
      return res
        .status(400)
        .json(
          standardizeError(
            `Payment at index ${i}: ${validation.error}`,
            'VALIDATION_ERROR',
            validation.field,
          ),
        );
    }
  }

  const insert = db.prepare(
    'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
  );
  const getBillForBalance = db.prepare(
    'SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
  );

  // Prepare statement for duplicate checking
  const duplicateCheckStmt = db.prepare(
    `SELECT 1 FROM payments p 
     JOIN bills b ON b.id = p.bill_id 
     WHERE b.user_id = ? 
     AND b.deleted_at IS NULL
     AND p.bill_id = ? 
     AND p.paid_date = ? 
     AND p.amount = ? 
     AND p.${SQL_NOT_DELETED}`,
  );

  const created = [];
  const skipped = [];
  const errors = [];

  const runBulk = db.transaction(() => {
    for (const item of payments) {
      const payment = validatePaymentInput({
        ...item,
        payment_source: item.payment_source ?? 'manual',
      }).normalized;
      const { bill_id, amount: parsedAmt, paid_date, payment_source } = payment;
      const { method, notes } = item;

      // Check for duplicates using composite key (bill_id + paid_date + amount)
      const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt);
      if (isDuplicate) {
        skipped.push({ bill_id, paid_date, amount: fromCents(parsedAmt) });
        continue;
      }

      const billRow = getBillForBalance.get(bill_id, req.user.id);
      if (!billRow) {
        errors.push({ item, error: `Bill ${bill_id} not found` });
        continue;
      }

      const balCalc = computeBalanceDelta(billRow, parsedAmt);
      const r = insert.run(
        bill_id,
        parsedAmt,
        paid_date,
        method || null,
        notes || null,
        balCalc?.balance_delta ?? null,
        balCalc?.interest_delta ?? null,
        payment_source,
      );
      applyBalanceDelta(db, bill_id, balCalc);

      created.push(
        serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)),
      );
    }
  });

  runBulk();
  res.status(201).json({ created, skipped, errors });
});

// PUT /api/payments/:id
router.put('/:id', (req: Req, res: Res) => {
  const db = getDb();
  const existing = db
    .prepare(
      `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
    )
    .get(req.params.id, req.user.id);
  if (!existing)
    return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
  if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res);

  const { amount, paid_date, method, notes, payment_source } = req.body;
  const validation = validatePaymentInput(
    { amount, paid_date, payment_source },
    { requireBillId: false, requireAmount: false, requirePaidDate: false },
  );
  if (validation.error) {
    return res
      .status(400)
      .json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
  }

  const nextAmount = validation.normalized.amount ?? existing.amount;
  const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date;
  const nextPaymentSource =
    validation.normalized.payment_source ?? existing.payment_source ?? 'manual';
  let nextBalanceDelta = existing.balance_delta;

  const bill = db
    .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
    .get(existing.bill_id, req.user.id);
  let nextInterestDelta = existing.interest_delta ?? null;
  // Compute the balance mutation without writing yet — the write happens inside
  // the transaction below so it lands atomically with the payment UPDATE.
  let balCalc = null;
  let paymentPortion = null;
  let restoredBalance = null;
  if (bill) {
    // Reverse only the *payment* portion of the stored delta (not the interest component)
    // so that interest already charged this month is not double-counted. For legacy rows
    // where interest_delta is NULL, fall back to reversing the full delta as before.
    const interestPortion = existing.interest_delta ?? 0;
    paymentPortion =
      existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
    restoredBalance = bill.current_balance;
    if (paymentPortion != null && bill.current_balance != null) {
      restoredBalance = Math.max(0, bill.current_balance - paymentPortion);
    }

    // interest_accrued_month is still set to this month (if interest was charged) so
    // computeBalanceDelta will skip interest when the payment is within the same month,
    // and charge a fresh month of interest if editing into a new calendar month.
    balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
    nextBalanceDelta = balCalc?.balance_delta ?? null;
    nextInterestDelta = balCalc?.interest_delta ?? null;
  }

  const { autopay_failure } = req.body;
  const nextAutopayFailure =
    autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;

  // Atomic: the bill-balance write and the payment UPDATE land together or neither,
  // so an edit never leaves the balance reversed without the row updated (or vice versa).
  const applyEdit = db.transaction(() => {
    if (bill) {
      if (balCalc) {
        applyBalanceDelta(db, existing.bill_id, balCalc);
      } else if (paymentPortion != null && restoredBalance != null) {
        db.prepare(
          "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?",
        ).run(restoredBalance, existing.bill_id);
      }
    }
    db.prepare(
      `
      UPDATE payments SET
        amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
        payment_source = ?, autopay_failure = ?, updated_at = datetime('now')
      WHERE id = ?
        AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
    `,
    ).run(
      nextAmount,
      nextPaidDate,
      method !== undefined ? method || null : existing.method,
      notes !== undefined ? notes || null : existing.notes,
      nextBalanceDelta,
      nextInterestDelta,
      nextPaymentSource,
      nextAutopayFailure,
      req.params.id,
      req.user.id,
    );
  });
  applyEdit();

  res.json(
    serializePayment(
      db
        .prepare(
          `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
        )
        .get(req.params.id, req.user.id),
    ),
  );
});

// DELETE /api/payments/:id — soft delete (sets deleted_at)
router.delete('/:id', (req: Req, res: Res) => {
  const db = getDb();
  const payment = db
    .prepare(
      `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
    )
    .get(req.params.id, req.user.id);
  if (!payment)
    return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
  if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);

  // Atomic: reverse the balance delta and soft-delete the payment together, so a
  // failure can't leave the balance restored while the payment is still active
  // (or vice versa). If this payment charged interest this month, clear
  // interest_accrued_month so the next payment can re-accrue correctly.
  const softDeletePayment = db.transaction(() => {
    if (!payment.accounting_excluded && payment.balance_delta != null) {
      const bill = db
        .prepare(
          'SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
        )
        .get(payment.bill_id, req.user.id);
      if (bill?.current_balance != null) {
        const restored = Math.max(0, bill.current_balance - payment.balance_delta);
        db.prepare(
          `
          UPDATE bills
          SET current_balance        = ?,
              interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
              updated_at             = datetime('now')
          WHERE id = ?
        `,
        ).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
      }
    }
    db.prepare(
      "UPDATE payments SET deleted_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)",
    ).run(req.params.id, req.user.id);
  });
  softDeletePayment();
  res.json({ success: true });
});

// POST /api/payments/:id/restore — undo soft delete
router.post('/:id/restore', (req: Req, res: Res) => {
  const db = getDb();
  const payment = db
    .prepare(
      'SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL',
    )
    .get(req.params.id, req.user.id);
  if (!payment)
    return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
  if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);

  // Atomic: re-apply the balance delta and un-delete the payment together, so a
  // failure can't leave the balance re-applied while the payment stays deleted
  // (or vice versa). If this payment originally charged interest, restore
  // interest_accrued_month to the payment's month so future same-month payments
  // skip interest.
  const restorePayment = db.transaction(() => {
    if (!payment.accounting_excluded && payment.balance_delta != null) {
      const bill = db
        .prepare(
          'SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
        )
        .get(payment.bill_id, req.user.id);
      if (bill?.current_balance != null) {
        const reapplied = Math.max(0, bill.current_balance + payment.balance_delta);
        const interestMonth =
          payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null;
        db.prepare(
          `
          UPDATE bills
          SET current_balance        = ?,
              interest_accrued_month = CASE WHEN ? IS NOT NULL THEN ? ELSE interest_accrued_month END,
              updated_at             = datetime('now')
          WHERE id = ?
        `,
        ).run(reapplied, interestMonth, interestMonth, payment.bill_id);
      }
    }
    db.prepare(
      'UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)',
    ).run(req.params.id, req.user.id);
  });
  restorePayment();
  res.json(
    serializePayment(
      db
        .prepare(
          `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
        )
        .get(req.params.id, req.user.id),
    ),
  );
});

// PATCH /api/payments/:id/attribute-to-month
// Changes only the paid_date of a provider_sync payment to move it into the
// correct billing period when it posted just after month end.
// Does not touch the amount or balance_delta.
router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
  const db = getDb();
  const paymentId = parseInt(req.params.id, 10);
  if (!Number.isInteger(paymentId) || paymentId < 1) {
    return res.status(400).json(standardizeError('Invalid payment id', 'VALIDATION_ERROR'));
  }

  const { paid_date } = req.body;
  if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) {
    return res
      .status(400)
      .json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date'));
  }
  // Validate it is a real calendar date
  const newDate = new Date(paid_date + 'T00:00:00Z');
  if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
    return res
      .status(400)
      .json(
        standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'),
      );
  }

  try {
    const payment = db
      .prepare(
        `
      SELECT p.* FROM payments p
      JOIN bills b ON b.id = p.bill_id
      WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL
    `,
      )
      .get(paymentId, req.user.id);

    if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND'));

    // Only allow date-only reclassification for provider_sync payments
    if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
      return res
        .status(409)
        .json(
          standardizeError(
            'Only bank-synced payments can be reclassified to a different month',
            'RECLASSIFY_ONLY_SYNC',
          ),
        );
    }

    // Sanity check: new date must be in the month immediately before the original date
    const orig = new Date(payment.paid_date + 'T00:00:00');
    const origYM = orig.getFullYear() * 12 + orig.getMonth();
    const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
    if (newYM !== origYM - 1) {
      return res
        .status(400)
        .json(
          standardizeError(
            'The new paid_date must be in the month immediately before the original payment date',
            'VALIDATION_ERROR',
            'paid_date',
          ),
        );
    }

    db.transaction(() => {
      db.prepare(
        "UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?",
      ).run(paid_date, paymentId);

      const bill = db
        .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
        .get(payment.bill_id, req.user.id);
      if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
    })();

    res.json(
      serializePayment(
        db
          .prepare(
            `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
          )
          .get(paymentId, req.user.id),
      ),
    );
  } catch (err) {
    console.error('[payments] attribute-to-month error:', err.message);
    res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));
  }
});

module.exports = router;
