const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const dbPath = path.join(os.tmpdir(), `bill-tracker-transaction-match-test-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;

const { getDb, closeDb } = require('../db/database');
const { ensureManualDataSource } = require('../services/transactionService');
const { getTracker } = require('../services/trackerService');
const {
  listMatchSuggestions,
  rejectMatchSuggestion,
  suggestionId,
} = require('../services/matchSuggestionService');
const {
  ignoreTransaction,
  matchTransactionToBill,
  unignoreTransaction,
  unmatchTransaction,
} = require('../services/transactionMatchService');

function createUser(db, suffix) {
  return db.prepare(`
    INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
    VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now'))
  `).run(`match-user-${suffix}`, `match-user-${suffix}@local`).lastInsertRowid;
}

function createBill(db, userId, name = 'City Water') {
  return db.prepare(`
    INSERT INTO bills (user_id, name, due_day, expected_amount)
    VALUES (?, ?, 16, 8500)
  `).run(userId, name).lastInsertRowid;
}

function createTransaction(db, userId, overrides = {}) {
  const source = ensureManualDataSource(db, userId);
  return db.prepare(`
    INSERT INTO transactions
      (user_id, data_source_id, source_type, posted_date, amount, currency,
       description, payee, match_status, ignored)
    VALUES (?, ?, 'manual', ?, ?, 'USD', ?, ?, 'unmatched', 0)
  `).run(
    userId,
    source.id,
    overrides.posted_date || '2026-05-16',
    overrides.amount ?? -8500,
    overrides.description || 'Water bill payment',
    overrides.payee || 'City Water',
  ).lastInsertRowid;
}

function activePaymentsForTransaction(db, transactionId) {
  return db.prepare(`
    SELECT *
    FROM payments
    WHERE transaction_id = ? AND deleted_at IS NULL
    ORDER BY id
  `).all(transactionId);
}

function createManualPayment(db, billId, overrides = {}) {
  return db.prepare(`
    INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, notes)
    VALUES (?, ?, ?, ?, ?, ?)
  `).run(
    billId,
    overrides.amount ?? 8500,
    overrides.paid_date || '2026-05-16',
    overrides.method || 'manual',
    overrides.payment_source || 'manual',
    overrides.notes || 'Manual payment',
  ).lastInsertRowid;
}

function trackerRow(userId, billId, today = '2026-05-20') {
  const tracker = getTracker(userId, { year: 2026, month: 5 }, new Date(`${today}T12:00:00Z`));
  assert.equal(tracker.error, undefined);
  const row = tracker.rows.find(item => item.id === billId);
  assert.ok(row, 'tracker row should exist');
  return row;
}

function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
  const billsRouter = require('../routes/bills');
  const layer = billsRouter.stack.find(item => item.route?.path === routePath && item.route.methods.get);
  assert.ok(layer, `route ${routePath} should exist`);
  const handler = layer.route.stack[0].handle;

  return new Promise((resolve, reject) => {
    const req = {
      params,
      query,
      user: { id: userId, role: 'user' },
    };
    const res = {
      statusCode: 200,
      status(code) {
        this.statusCode = code;
        return this;
      },
      json(data) {
        resolve({ status: this.statusCode, data });
      },
    };
    try {
      handler(req, res);
    } catch (err) {
      reject(err);
    }
  });
}

function callPaymentsRoute(routePath, method, { userId, params = {}, query = {}, body = {} }) {
  const paymentsRouter = require('../routes/payments');
  const layer = paymentsRouter.stack.find(item => item.route?.path === routePath && item.route.methods[method]);
  assert.ok(layer, `route ${method.toUpperCase()} ${routePath} should exist`);
  const handler = layer.route.stack[0].handle;

  return new Promise((resolve, reject) => {
    const req = {
      body,
      params,
      query,
      user: { id: userId, role: 'user' },
    };
    const res = {
      statusCode: 200,
      status(code) {
        this.statusCode = code;
        return this;
      },
      json(data) {
        resolve({ status: this.statusCode, data });
      },
    };
    try {
      handler(req, res);
    } catch (err) {
      reject(err);
    }
  });
}

test.after(() => {
  closeDb();
  for (const suffix of ['', '-wal', '-shm']) {
    fs.rmSync(`${dbPath}${suffix}`, { force: true });
  }
});

test('matching a transaction creates one active transaction_match payment and unmatch removes it', () => {
  const db = getDb();
  const userId = createUser(db, 'basic');
  const billId = createBill(db, userId);
  const transactionId = createTransaction(db, userId);

  const matched = matchTransactionToBill(userId, transactionId, billId);

  assert.equal(matched.transaction.id, transactionId);
  assert.equal(matched.transaction.matched_bill_id, billId);
  assert.equal(matched.transaction.match_status, 'matched');
  assert.equal(matched.transaction.ignored, 0);
  assert.equal(matched.payment.bill_id, billId);
  assert.equal(matched.payment.amount, 85);
  assert.equal(matched.payment.paid_date, '2026-05-16');
  assert.equal(matched.payment.method, 'transaction_match');
  assert.equal(matched.payment.payment_source, 'transaction_match');
  assert.equal(matched.payment.transaction_id, transactionId);

  const matchedAgain = matchTransactionToBill(userId, transactionId, billId);
  assert.equal(matchedAgain.payment.id, matched.payment.id);
  assert.equal(activePaymentsForTransaction(db, transactionId).length, 1);

  const unmatched = unmatchTransaction(userId, transactionId);
  assert.equal(unmatched.transaction.matched_bill_id, null);
  assert.equal(unmatched.transaction.match_status, 'unmatched');
  assert.equal(unmatched.transaction.ignored, 0);
  assert.equal(activePaymentsForTransaction(db, transactionId).length, 0);

  const deletedPayment = db.prepare('SELECT deleted_at FROM payments WHERE id = ?').get(matched.payment.id);
  assert.ok(deletedPayment.deleted_at);
});

test('ignoring a matched transaction removes the match payment and blocks rematching until unignored', () => {
  const db = getDb();
  const userId = createUser(db, 'ignore');
  const billId = createBill(db, userId, 'Internet');
  const transactionId = createTransaction(db, userId, {
    description: 'Internet payment',
    payee: 'Fiber Co',
    amount: -6500,
  });

  matchTransactionToBill(userId, transactionId, billId);
  const ignored = ignoreTransaction(userId, transactionId);

  assert.equal(ignored.transaction.match_status, 'ignored');
  assert.equal(ignored.transaction.ignored, 1);
  assert.equal(ignored.transaction.matched_bill_id, null);
  assert.equal(activePaymentsForTransaction(db, transactionId).length, 0);

  assert.throws(
    () => matchTransactionToBill(userId, transactionId, billId),
    /Ignored transactions must be unignored before matching/,
  );

  const unignored = unignoreTransaction(userId, transactionId);
  assert.equal(unignored.transaction.match_status, 'unmatched');
  assert.equal(unignored.transaction.ignored, 0);

  const rematched = matchTransactionToBill(userId, transactionId, billId);
  assert.equal(rematched.transaction.match_status, 'matched');
  assert.equal(activePaymentsForTransaction(db, transactionId).length, 1);
});

test('transaction match payments cannot be edited, deleted, or restored through payment routes', async () => {
  const db = getDb();
  const userId = createUser(db, 'payment-route-lock');
  const billId = createBill(db, userId, 'Water');
  const transactionId = createTransaction(db, userId);
  const matched = matchTransactionToBill(userId, transactionId, billId);

  const updateRes = await callPaymentsRoute('/:id', 'put', {
    userId,
    params: { id: String(matched.payment.id) },
    body: {
      amount: 1,
      paid_date: '2026-05-17',
      method: 'manual',
      payment_source: 'manual',
    },
  });
  assert.equal(updateRes.status, 409);

  let payment = db.prepare('SELECT amount, paid_date, method, payment_source, transaction_id, deleted_at FROM payments WHERE id = ?').get(matched.payment.id);
  assert.equal(payment.amount, 8500);
  assert.equal(payment.paid_date, '2026-05-16');
  assert.equal(payment.method, 'transaction_match');
  assert.equal(payment.payment_source, 'transaction_match');
  assert.equal(payment.transaction_id, transactionId);
  assert.equal(payment.deleted_at, null);

  const deleteRes = await callPaymentsRoute('/:id', 'delete', {
    userId,
    params: { id: String(matched.payment.id) },
  });
  assert.equal(deleteRes.status, 409);
  assert.equal(activePaymentsForTransaction(db, transactionId).length, 1);
  assert.equal(db.prepare('SELECT match_status FROM transactions WHERE id = ?').get(transactionId).match_status, 'matched');

  unmatchTransaction(userId, transactionId);
  payment = db.prepare('SELECT deleted_at FROM payments WHERE id = ?').get(matched.payment.id);
  assert.ok(payment.deleted_at);

  const restoreRes = await callPaymentsRoute('/:id/restore', 'post', {
    userId,
    params: { id: String(matched.payment.id) },
  });
  assert.equal(restoreRes.status, 409);
  assert.equal(activePaymentsForTransaction(db, transactionId).length, 0);
  assert.equal(db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(transactionId).match_status, 'unmatched');
});

test('generic payment routes cannot create transaction_match payments', async () => {
  const db = getDb();
  const userId = createUser(db, 'payment-source-lock');
  const billId = createBill(db, userId, 'Water');

  const createRes = await callPaymentsRoute('/', 'post', {
    userId,
    body: {
      bill_id: billId,
      amount: 85,
      paid_date: '2026-05-16',
      payment_source: 'transaction_match',
    },
  });
  assert.equal(createRes.status, 400);

  const quickRes = await callPaymentsRoute('/quick', 'post', {
    userId,
    body: {
      bill_id: billId,
      payment_source: 'transaction_match',
    },
  });
  assert.equal(quickRes.status, 400);

  const bulkRes = await callPaymentsRoute('/bulk', 'post', {
    userId,
    body: {
      payments: [{
        bill_id: billId,
        amount: 85,
        paid_date: '2026-05-16',
        payment_source: 'transaction_match',
      }],
    },
  });
  assert.equal(bulkRes.status, 400);
  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM payments WHERE bill_id = ?').get(billId).n, 0);
});

test('matching marks the tracker row paid and unmatching recalculates it as unpaid', () => {
  const db = getDb();
  const userId = createUser(db, 'tracker');
  const billId = createBill(db, userId, 'Electric');
  const transactionId = createTransaction(db, userId, {
    description: 'Electric bill payment',
    payee: 'Electric Utility',
  });

  assert.notEqual(trackerRow(userId, billId).status, 'paid');

  matchTransactionToBill(userId, transactionId, billId);
  const paidRow = trackerRow(userId, billId);
  assert.equal(paidRow.status, 'paid');
  assert.equal(paidRow.has_payment, true);
  assert.equal(paidRow.payments[0].transaction_id, transactionId);

  unmatchTransaction(userId, transactionId);
  const unpaidRow = trackerRow(userId, billId);
  assert.notEqual(unpaidRow.status, 'paid');
  assert.equal(unpaidRow.has_payment, false);
  assert.equal(unpaidRow.total_paid, 0);
});

test('ignoring a transaction does not change bill status or manual payments', () => {
  const db = getDb();
  const userId = createUser(db, 'ignore-status');
  const billId = createBill(db, userId, 'Phone');
  const transactionId = createTransaction(db, userId, {
    description: 'Phone store charge',
    payee: 'Phone Store',
    amount: -8500,
  });
  const manualPaymentId = createManualPayment(db, billId);

  const before = trackerRow(userId, billId);
  assert.equal(before.status, 'paid');
  assert.equal(before.payments.some(payment => payment.id === manualPaymentId), true);

  ignoreTransaction(userId, transactionId);

  const after = trackerRow(userId, billId);
  assert.equal(after.status, 'paid');
  assert.equal(after.total_paid, before.total_paid);
  assert.deepEqual(activePaymentsForTransaction(db, transactionId), []);
  assert.equal(after.payments.some(payment => payment.id === manualPaymentId), true);
});

test('match suggestions are read-only and rejections do not touch payments or transactions', () => {
  const db = getDb();
  const userId = createUser(db, 'suggestions');
  const billId = createBill(db, userId);
  const transactionId = createTransaction(db, userId);
  const beforeTransaction = db.prepare('SELECT matched_bill_id, match_status, ignored FROM transactions WHERE id = ?').get(transactionId);
  const beforePaymentCount = db.prepare('SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).n;

  const suggestions = listMatchSuggestions(userId, { transactionId });
  const match = suggestions.find(item => item.transactionId === transactionId && item.billId === billId);
  assert.ok(match, 'expected a suggestion for the matching bill');
  assert.equal(match.score > 0, true);
  assert.ok(match.reasons.length > 0);

  const afterSuggestTransaction = db.prepare('SELECT matched_bill_id, match_status, ignored FROM transactions WHERE id = ?').get(transactionId);
  const afterSuggestPaymentCount = db.prepare('SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).n;
  assert.deepEqual(afterSuggestTransaction, beforeTransaction);
  assert.equal(afterSuggestPaymentCount, beforePaymentCount);

  const rejected = rejectMatchSuggestion(userId, suggestionId(transactionId, billId));
  assert.equal(rejected.rejected, true);

  const afterRejectTransaction = db.prepare('SELECT matched_bill_id, match_status, ignored FROM transactions WHERE id = ?').get(transactionId);
  const afterRejectPaymentCount = db.prepare('SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).n;
  assert.deepEqual(afterRejectTransaction, beforeTransaction);
  assert.equal(afterRejectPaymentCount, beforePaymentCount);
  assert.equal(listMatchSuggestions(userId, { transactionId }).some(item => item.id === rejected.id), false);
});

test('manual payment history remains visible and suppresses duplicate suggestions for the same cycle', async () => {
  const db = getDb();
  const userId = createUser(db, 'manual-history');
  const billId = createBill(db, userId, 'Internet');
  const manualPaymentId = createManualPayment(db, billId, {
    amount: 6500,
    notes: 'Paid from checking',
  });
  const transactionId = createTransaction(db, userId, {
    amount: -6500,
    description: 'Internet bill',
    payee: 'Internet',
  });

  assert.equal(
    listMatchSuggestions(userId, { transactionId }).some(item => item.billId === billId),
    false,
  );

  const matched = matchTransactionToBill(userId, transactionId, billId);

  const paymentsRes = await callBillsRoute('/:id/payments', {
    userId,
    params: { id: String(billId) },
    query: { limit: '100' },
  });
  assert.equal(paymentsRes.status, 200);
  assert.equal(paymentsRes.data.payments.some(payment => payment.id === manualPaymentId && payment.payment_source === 'manual'), true);
  assert.equal(paymentsRes.data.payments.some(payment => payment.id === matched.payment.id && payment.payment_source === 'transaction_match'), true);

  const transactionsRes = await callBillsRoute('/:id/transactions', {
    userId,
    params: { id: String(billId) },
  });
  assert.equal(transactionsRes.status, 200);
  assert.equal(transactionsRes.data.transactions.length, 1);
  assert.equal(transactionsRes.data.transactions[0].id, transactionId);
  assert.equal(transactionsRes.data.transactions[0].linked_payment.id, matched.payment.id);
});

test('bank-backed match overrides same-cycle manual tracker payment but keeps it in history', async () => {
  const db = getDb();
  const userId = createUser(db, 'bank-override');
  const billId = createBill(db, userId, 'Internet Override');
  const manualPaymentId = createManualPayment(db, billId, {
    amount: 8500,
    notes: 'Marked paid while waiting for bank clear',
  });
  const transactionId = createTransaction(db, userId, {
    amount: -9000,
    description: 'Internet Override',
    payee: 'Internet Override',
  });

  const beforeMatch = trackerRow(userId, billId);
  assert.equal(beforeMatch.total_paid, 85);
  assert.equal(beforeMatch.status, 'paid');

  const matched = matchTransactionToBill(userId, transactionId, billId);
  const afterMatch = trackerRow(userId, billId);
  assert.equal(afterMatch.total_paid, 90);
  assert.equal(afterMatch.payments.length, 1);
  assert.equal(afterMatch.payments[0].id, matched.payment.id);
  assert.equal(afterMatch.payments.some(payment => payment.id === manualPaymentId), false);

  const manual = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualPaymentId);
  assert.equal(manual.accounting_excluded, 1);
  assert.equal(manual.exclusion_reason, 'overridden_by_bank');
  assert.equal(manual.overridden_by_payment_id, matched.payment.id);
  assert.match(manual.notes, /History only: overridden by bank payment/);

  const paymentsRes = await callBillsRoute('/:id/payments', {
    userId,
    params: { id: String(billId) },
    query: { limit: '100' },
  });
  assert.equal(paymentsRes.status, 200);
  assert.equal(paymentsRes.data.payments.some(payment => payment.id === manualPaymentId && payment.accounting_excluded === 1), true);
  assert.equal(paymentsRes.data.payments.some(payment => payment.id === matched.payment.id && payment.accounting_excluded === 0), true);
});

test('unmatching a bank-backed payment reactivates the provisional manual payment', () => {
  const db = getDb();
  const userId = createUser(db, 'bank-override-unmatch');
  const billId = createBill(db, userId, 'Internet Unmatch');
  const manualPaymentId = createManualPayment(db, billId);
  const transactionId = createTransaction(db, userId, {
    description: 'Internet Unmatch',
    payee: 'Internet Unmatch',
  });

  matchTransactionToBill(userId, transactionId, billId);
  assert.equal(db.prepare('SELECT accounting_excluded FROM payments WHERE id = ?').get(manualPaymentId).accounting_excluded, 1);

  unmatchTransaction(userId, transactionId);
  const manual = db.prepare('SELECT accounting_excluded, overridden_by_payment_id, exclusion_reason, notes FROM payments WHERE id = ?').get(manualPaymentId);
  assert.equal(manual.accounting_excluded, 0);
  assert.equal(manual.overridden_by_payment_id, null);
  assert.equal(manual.exclusion_reason, null);
  assert.match(manual.notes, /manual payment counts again/i);

  const row = trackerRow(userId, billId);
  assert.equal(row.status, 'paid');
  assert.equal(row.total_paid, 85);
  assert.equal(row.payments.some(payment => payment.id === manualPaymentId), true);
});

test('manual match learns a merchant rule; generic descriptors and background auto-match do not', () => {
  const db = getDb();
  const userId = createUser(db, 'learn');
  const rulesFor = (billId) =>
    db.prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? ORDER BY merchant')
      .all(userId, billId).map(r => r.merchant);

  // 1. Specific payee + explicit user confirmation → a normalized rule is learned.
  const waterBill = createBill(db, userId, 'Sparta Water');
  const waterTx = createTransaction(db, userId, {
    payee: 'SPARTA WATER ASS UTILITIES',
    description: 'SPARTA WATER ASS UTILITIES',
    amount: -8500,
  });
  matchTransactionToBill(userId, waterTx, waterBill, { learnMerchant: true });
  assert.deepEqual(rulesFor(waterBill), ['sparta water ass utilities'],
    'specific payee should be learned as a normalized merchant rule');

  // 2. Generic-only descriptor → nothing is learned (would match too much).
  const genBill = createBill(db, userId, 'Some Transfer');
  const genTx = createTransaction(db, userId, {
    payee: 'ACH Payment',
    description: 'ACH PAYMENT',
    amount: -5000,
  });
  matchTransactionToBill(userId, genTx, genBill, { learnMerchant: true });
  assert.deepEqual(rulesFor(genBill), [],
    'generic-only descriptor must not become an auto-match rule');

  // 3. Background auto-match (no opts.learnMerchant) → never creates rules,
  //    so a wrong auto-match can't compound into a permanent rule.
  const autoBill = createBill(db, userId, 'Spotify');
  const autoTx = createTransaction(db, userId, {
    payee: 'SPOTIFY USA',
    description: 'SPOTIFY USA',
    amount: -1199,
  });
  matchTransactionToBill(userId, autoTx, autoBill);
  assert.deepEqual(rulesFor(autoBill), [],
    'background auto-match must not create merchant rules');
});

test('applyMerchantRules skips ambiguous matches (rules for >1 bill) but still applies unambiguous ones', () => {
  const { applyMerchantRules, addMerchantRule } = require('../services/billMerchantRuleService');
  const db = getDb();
  const userId = createUser(db, 'ambiguous');

  // Two distinct bills both carry an "amazon" rule — a charge from AMAZON is ambiguous.
  const billA = createBill(db, userId, 'Amazon Card A');
  const billB = createBill(db, userId, 'Amazon Card B');
  addMerchantRule(db, userId, billA, 'amazon');
  addMerchantRule(db, userId, billB, 'amazon');
  const ambiguousTx = createTransaction(db, userId, {
    payee: 'AMAZON', description: 'AMAZON.COM', amount: -2500,
  });

  applyMerchantRules(db, userId);

  const ambiguousRow = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(ambiguousTx);
  assert.equal(ambiguousRow.match_status, 'unmatched', 'ambiguous match must be left for manual review');
  assert.equal(ambiguousRow.matched_bill_id, null);
  assert.equal(
    db.prepare('SELECT COUNT(*) AS n FROM payments WHERE transaction_id = ?').get(ambiguousTx).n,
    0,
    'no payment should be created for an ambiguous match',
  );

  // A unique rule still auto-matches as before.
  const spotifyBill = createBill(db, userId, 'Spotify');
  addMerchantRule(db, userId, spotifyBill, 'spotify');
  const spotifyTx = createTransaction(db, userId, {
    payee: 'SPOTIFY USA', description: 'SPOTIFY USA', amount: -1199,
  });

  applyMerchantRules(db, userId);

  const spotifyRow = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(spotifyTx);
  assert.equal(spotifyRow.match_status, 'matched', 'unambiguous merchant rule should still auto-match');
  assert.equal(spotifyRow.matched_bill_id, spotifyBill);
});

test('bill linked transactions require an active linked payment', async () => {
  const db = getDb();
  const userId = createUser(db, 'orphan-link');
  const billId = createBill(db, userId, 'Orphaned');
  const transactionId = createTransaction(db, userId);

  db.prepare(`
    UPDATE transactions
    SET matched_bill_id = ?, match_status = 'matched', ignored = 0
    WHERE id = ? AND user_id = ?
  `).run(billId, transactionId, userId);

  const transactionsRes = await callBillsRoute('/:id/transactions', {
    userId,
    params: { id: String(billId) },
  });
  assert.equal(transactionsRes.status, 200);
  assert.equal(transactionsRes.data.transactions.length, 0);
});
