const router = require('express').Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const {
  decorateTransaction,
  ensureManualDataSource,
  getTransactionForUser,
} = require('../services/transactionService');
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
const { markUnmatched } = require('../services/transactionMatchState');
const {
  ignoreTransaction,
  matchTransactionToBill,
  unignoreTransaction,
  unmatchTransaction,
} = require('../services/transactionMatchService');
const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService');
const {
  findMerchantMatch,
  applyMerchantStoreMatches,
  findOrCreateCategory,
} = require('../services/merchantStoreMatchService');
const { categorizeTransaction } = require('../services/spendingService');
const { todayLocal } = require('../utils/dates.mts');
const { roundMoney } = require('../utils/money.mts');

const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']);
const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
const MATCH_CONTROL_FIELDS = ['matched_bill_id', 'match_status', 'ignored'];
const TEXT_FIELDS = {
  transaction_type: 64,
  currency: 16,
  description: 500,
  payee: 255,
  memo: 500,
  category: 255,
};

function todayStr() {
  return todayLocal();
}

function cleanText(value, maxLength) {
  if (value === undefined) return undefined;
  if (value === null) return null;
  const text = String(value).trim();
  if (!text) return null;
  return text.slice(0, maxLength);
}

function parseInteger(value, field, { allowNull = false, min = null, max = null } = {}) {
  if (value === null && allowNull) return { value: null };
  if (value === undefined) return { value: undefined };
  const n = typeof value === 'number' ? value : Number(value);
  if (!Number.isSafeInteger(n)) {
    return { error: standardizeError(`${field} must be an integer`, 'VALIDATION_ERROR', field) };
  }
  if (min !== null && n < min) {
    return { error: standardizeError(`${field} must be at least ${min}`, 'VALIDATION_ERROR', field) };
  }
  if (max !== null && n > max) {
    return { error: standardizeError(`${field} must be at most ${max}`, 'VALIDATION_ERROR', field) };
  }
  return { value: n };
}

function parseBooleanInt(value, field) {
  if (value === undefined) return { value: undefined };
  if (value === true || value === 'true' || value === '1' || value === 1) return { value: 1 };
  if (value === false || value === 'false' || value === '0' || value === 0) return { value: 0 };
  return { error: standardizeError(`${field} must be true or false`, 'VALIDATION_ERROR', field) };
}

function parseDate(value, field, { allowNull = false } = {}) {
  if (value === null && allowNull) return { value: null };
  if (value === undefined) return { value: undefined };
  const text = String(value).trim();
  if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
    return { error: standardizeError(`${field} must be a valid YYYY-MM-DD date`, 'VALIDATION_ERROR', field) };
  }
  const date = new Date(`${text}T00:00:00Z`);
  if (
    Number.isNaN(date.getTime()) ||
    date.toISOString().slice(0, 10) !== text
  ) {
    return { error: standardizeError(`${field} must be a valid calendar date`, 'VALIDATION_ERROR', field) };
  }
  return { value: text };
}

function parseOptionalDateTime(value, field) {
  if (value === undefined) return { value: undefined };
  if (value === null) return { value: null };
  const text = String(value).trim();
  if (!text) return { value: null };

  const match = /^(\d{4}-\d{2}-\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(text);
  if (!match) {
    return { error: standardizeError(`${field} must be a valid ISO date or date-time`, 'VALIDATION_ERROR', field) };
  }
  const parsedDate = parseDate(match[1], field);
  if (parsedDate.error) {
    return parsedDate;
  }
  return { value: text };
}

function hasOwn(obj, field) {
  return Object.prototype.hasOwnProperty.call(obj, field);
}

function getOwnedAccount(db, userId, accountId) {
  if (accountId == null) return null;
  return db.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ? AND monitored = 1').get(accountId, userId);
}

function getOwnedBill(db, userId, billId) {
  if (billId == null) return null;
  return db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, userId);
}

function normalizeTransactionFields(db, userId, body = {}, { partial = false } = {}) {
  const normalized = {};

  if (!partial || body.amount !== undefined) {
    const parsed = parseInteger(body.amount, 'amount');
    if (parsed.error) return { error: parsed.error };
    if (parsed.value === 0) {
      return { error: standardizeError('amount must be non-zero integer cents', 'VALIDATION_ERROR', 'amount') };
    }
    normalized.amount = parsed.value;
  }

  if (!partial || body.posted_date !== undefined) {
    const postedDateValue = body.posted_date === undefined && !partial
      ? todayStr()
      : body.posted_date;
    const parsed = parseDate(postedDateValue, 'posted_date', { allowNull: partial });
    if (parsed.error) return { error: parsed.error };
    normalized.posted_date = parsed.value;
  }

  if (body.transacted_at !== undefined) {
    const parsed = parseOptionalDateTime(body.transacted_at, 'transacted_at');
    if (parsed.error) return { error: parsed.error };
    normalized.transacted_at = parsed.value;
  } else if (!partial) {
    normalized.transacted_at = null;
  }

  for (const [field, maxLength] of Object.entries(TEXT_FIELDS)) {
    if (!partial || body[field] !== undefined) {
      const value = cleanText(body[field], maxLength);
      normalized[field] = value === undefined ? null : value;
    }
  }

  if (!partial && !normalized.currency) normalized.currency = 'USD';

  if (body.account_id !== undefined) {
    const parsed = parseInteger(body.account_id, 'account_id', { allowNull: true });
    if (parsed.error) return { error: parsed.error };
    if (parsed.value !== null && !getOwnedAccount(db, userId, parsed.value)) {
      return { error: standardizeError('Financial account not found', 'NOT_FOUND', 'account_id'), status: 404 };
    }
    normalized.account_id = parsed.value;
  } else if (!partial) {
    normalized.account_id = null;
  }

  if (body.matched_bill_id !== undefined) {
    const parsed = parseInteger(body.matched_bill_id, 'matched_bill_id', { allowNull: true });
    if (parsed.error) return { error: parsed.error };
    if (parsed.value !== null && !getOwnedBill(db, userId, parsed.value)) {
      return { error: standardizeError('Matched bill not found', 'NOT_FOUND', 'matched_bill_id'), status: 404 };
    }
    normalized.matched_bill_id = parsed.value;
  } else if (!partial) {
    normalized.matched_bill_id = null;
  }

  if (body.match_status !== undefined) {
    const matchStatus = cleanText(body.match_status, 32);
    if (!MATCH_STATUSES.has(matchStatus)) {
      return { error: standardizeError('match_status must be unmatched, matched, or ignored', 'VALIDATION_ERROR', 'match_status') };
    }
    normalized.match_status = matchStatus;
  }

  if (body.ignored !== undefined) {
    const parsed = parseBooleanInt(body.ignored, 'ignored');
    if (parsed.error) return { error: parsed.error };
    normalized.ignored = parsed.value;
  }

  return { normalized };
}

function resolveTransactionState(next, existing = {}) {
  const hasStatus = hasOwn(next, 'match_status');
  const hasIgnored = hasOwn(next, 'ignored');
  const hasMatchedBill = hasOwn(next, 'matched_bill_id');

  if (hasStatus && next.match_status === 'ignored' && hasIgnored && next.ignored === 0) {
    return { error: standardizeError('ignored must be true when match_status is ignored', 'VALIDATION_ERROR', 'ignored') };
  }
  if (hasIgnored && next.ignored === 1 && hasStatus && next.match_status !== 'ignored') {
    return { error: standardizeError('match_status must be ignored when ignored is true', 'VALIDATION_ERROR', 'match_status') };
  }
  if (hasStatus && next.match_status === 'unmatched' && hasMatchedBill && next.matched_bill_id !== null) {
    return { error: standardizeError('matched_bill_id must be null when match_status is unmatched', 'VALIDATION_ERROR', 'matched_bill_id') };
  }
  if (hasStatus && next.match_status === 'matched' && hasMatchedBill && next.matched_bill_id === null) {
    return { error: standardizeError('matched_bill_id is required when match_status is matched', 'VALIDATION_ERROR', 'matched_bill_id') };
  }

  let matchedBillId = hasMatchedBill ? next.matched_bill_id : (existing.matched_bill_id ?? null);
  let matchStatus = hasStatus ? next.match_status : (existing.match_status ?? (matchedBillId ? 'matched' : 'unmatched'));
  let ignored = hasIgnored ? next.ignored : (existing.ignored ?? 0);

  if (hasIgnored && ignored === 0 && matchStatus === 'ignored') {
    matchStatus = 'unmatched';
    matchedBillId = null;
  }

  if (ignored === 1 || matchStatus === 'ignored') {
    return {
      matched_bill_id: null,
      match_status: 'ignored',
      ignored: 1,
    };
  }

  if (matchStatus === 'matched' || matchedBillId !== null) {
    if (matchedBillId === null) {
      return { error: standardizeError('matched_bill_id is required when match_status is matched', 'VALIDATION_ERROR', 'matched_bill_id') };
    }
    return {
      matched_bill_id: matchedBillId,
      match_status: 'matched',
      ignored: 0,
    };
  }

  return {
    matched_bill_id: null,
    match_status: 'unmatched',
    ignored: 0,
  };
}

function parseLimitOffset(query) {
  const limit = parseInteger(query.limit ?? 50, 'limit', { min: 1, max: 200 });
  if (limit.error) return { error: limit.error };
  const offset = parseInteger(query.offset ?? 0, 'offset', { min: 0 });
  if (offset.error) return { error: offset.error };
  return { limit: limit.value, offset: offset.value };
}

function selectedTransaction(db, userId, id) {
  return decorateTransaction(getTransactionForUser(db, userId, id));
}

function rejectDirectMatchState(body = {}) {
  const field = MATCH_CONTROL_FIELDS.find(name => hasOwn(body, name));
  if (!field) return null;
  return standardizeError(
    'Use the transaction match, unmatch, ignore, or unignore endpoint to change match state',
    'VALIDATION_ERROR',
    field,
  );
}

function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') {
  if (err.status) {
    return res.status(err.status).json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field));
  }
  console.error('[transactions] service error:', err.stack || err.message);
  return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
}

function emptyBankLedger(enabled, hasConnections = false) {
  return {
    enabled: Boolean(enabled),
    has_connections: Boolean(hasConnections),
    sources: [],
    accounts: [],
    summary: {
      total: 0,
      inflow: 0,
      outflow: 0,
      net: 0,
      pending: 0,
      matched: 0,
      unmatched: 0,
      latest_date: null,
      category_breakdown: [],
    },
    transactions: [],
    total: 0,
    limit: 50,
    offset: 0,
  };
}

function bankLedgerWhere(query, userId) {
  const where = [
    't.user_id = ?',
    "ds.type = 'provider_sync'",
    "ds.provider = 'simplefin'",
    '(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
  ];
  const params = [userId];

  if (query.account_id && query.account_id !== 'all') {
    const parsed = parseInteger(query.account_id, 'account_id');
    if (parsed.error) return { error: parsed.error };
    where.push('t.account_id = ?');
    params.push(parsed.value);
  }

  const flow = query.flow ? String(query.flow).trim() : 'all';
  if (!['all', 'in', 'out', 'pending', 'matched', 'unmatched', 'ignored', 'uncategorized'].includes(flow)) {
    return { error: standardizeError('flow must be all, in, out, pending, matched, unmatched, ignored, or uncategorized', 'VALIDATION_ERROR', 'flow') };
  }
  if (flow === 'in') where.push('t.amount > 0');
  if (flow === 'out') where.push('t.amount < 0');
  if (flow === 'pending') where.push('t.pending = 1');
  if (flow === 'matched') where.push("t.match_status = 'matched'");
  if (flow === 'unmatched') where.push("t.match_status = 'unmatched' AND t.ignored = 0");
  if (flow === 'ignored') where.push('t.ignored = 1');
  if (flow === 'uncategorized') where.push('t.spending_category_id IS NULL AND t.amount < 0 AND t.ignored = 0');

  if (query.start_date) {
    const parsed = parseDate(query.start_date, 'start_date');
    if (parsed.error) return { error: parsed.error };
    where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?");
    params.push(parsed.value);
  }
  if (query.end_date) {
    const parsed = parseDate(query.end_date, 'end_date');
    if (parsed.error) return { error: parsed.error };
    where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?");
    params.push(parsed.value);
  }

  if (query.q) {
    const q = `%${String(query.q).trim()}%`;
    where.push(`(
      t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?
      OR fa.name LIKE ? OR fa.org_name LIKE ? OR b.name LIKE ? OR c.name LIKE ?
    )`);
    params.push(q, q, q, q, q, q, q, q);
  }

  return { whereClause: where.join(' AND '), params };
}

// GET /api/transactions
router.get('/', (req, res) => {
  const db = getDb();
  ensureManualDataSource(db, req.user.id);

  const page = parseLimitOffset(req.query);
  if (page.error) return res.status(400).json(page.error);

  const SORT_COLUMNS = {
    date: "COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)",
    amount: 't.amount',
  };
  const sortBy  = SORT_COLUMNS[req.query.sort_by]  ? req.query.sort_by  : 'date';
  const sortDir = req.query.sort_dir === 'asc'      ? 'ASC'              : 'DESC';
  const orderBy = `${SORT_COLUMNS[sortBy]} ${sortDir}, t.id ${sortDir}`;

  const where = [
    't.user_id = ?',
    '(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
  ];
  const params = [req.user.id];

  const matchStatusFilter = req.query.match_status ? String(req.query.match_status).trim() : '';
  if (matchStatusFilter && !MATCH_STATUSES.has(matchStatusFilter)) {
    return res.status(400).json(standardizeError('match_status must be unmatched, matched, or ignored', 'VALIDATION_ERROR', 'match_status'));
  }

  const ignored = req.query.ignored;
  if (ignored === 'true' || ignored === '1') {
    where.push('t.ignored = 1');
  } else if (ignored === 'false' || ignored === '0') {
    where.push('t.ignored = 0');
  } else if (ignored !== 'all') {
    if (ignored !== undefined) {
      return res.status(400).json(standardizeError('ignored must be true, false, or all', 'VALIDATION_ERROR', 'ignored'));
    }
    where.push(matchStatusFilter === 'ignored' ? 't.ignored = 1' : 't.ignored = 0');
  }

  if (req.query.source_type) {
    const sourceType = String(req.query.source_type).trim();
    if (!SOURCE_TYPES.has(sourceType)) {
      return res.status(400).json(standardizeError('source_type must be manual, file_import, or provider_sync', 'VALIDATION_ERROR', 'source_type'));
    }
    where.push('t.source_type = ?');
    params.push(sourceType);
  }

  if (matchStatusFilter) {
    where.push('t.match_status = ?');
    params.push(matchStatusFilter);
  }

  for (const field of ['transaction_type']) {
    if (req.query[field]) {
      where.push(`t.${field} = ?`);
      params.push(String(req.query[field]).trim());
    }
  }

  for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) {
    if (req.query[field] !== undefined) {
      const parsed = parseInteger(req.query[field], field);
      if (parsed.error) return res.status(400).json(parsed.error);
      where.push(`t.${field} = ?`);
      params.push(parsed.value);
    }
  }

  if (req.query.start_date) {
    const parsed = parseDate(req.query.start_date, 'start_date');
    if (parsed.error) return res.status(400).json(parsed.error);
    where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?");
    params.push(parsed.value);
  }
  if (req.query.end_date) {
    const parsed = parseDate(req.query.end_date, 'end_date');
    if (parsed.error) return res.status(400).json(parsed.error);
    where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?");
    params.push(parsed.value);
  }

  if (req.query.q) {
    const q = `%${String(req.query.q).trim()}%`;
    where.push('(t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?)');
    params.push(q, q, q, q);
  }

  const whereClause = where.join(' AND ');
  const joins = `
    FROM transactions t
    LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
    LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
    LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
  `;

  const total = db.prepare(`SELECT COUNT(*) AS n ${joins} WHERE ${whereClause}`).get(...params).n;

  const rows = db.prepare(`
    SELECT
      t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
      t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
      t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
      t.match_status, t.ignored, t.pending, t.created_at, t.updated_at,
      ds.type AS data_source_type, ds.provider AS data_source_provider,
      ds.name AS data_source_name, ds.status AS data_source_status,
      fa.name AS account_name, fa.org_name AS account_org_name,
      fa.account_type AS account_type,
      b.name AS matched_bill_name
    ${joins}
    WHERE ${whereClause}
    ORDER BY ${orderBy}
    LIMIT ? OFFSET ?
  `).all(...params, page.limit, page.offset);

  res.json({
    transactions: rows.map(row => {
      const decorated = decorateTransaction(row);
      const title = row.payee || row.description || row.memo || '';
      decorated.advisory_filter = advisoryCheck(title);
      return decorated;
    }),
    total,
    limit: page.limit,
    offset: page.offset,
  });
});

// GET /api/transactions/bank-ledger
router.get('/bank-ledger', (req, res) => {
  const config = getBankSyncConfig();
  const page = parseLimitOffset(req.query);
  if (page.error) return res.status(400).json(page.error);

  const db = getDb();
  const sources = db.prepare(`
    SELECT id, user_id, type, provider, name, status, last_sync_at, created_at, updated_at
    FROM data_sources
    WHERE user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'
    ORDER BY updated_at DESC, id DESC
  `).all(req.user.id);

  if (!config.enabled || sources.length === 0) {
    return res.json({ ...emptyBankLedger(config.enabled, sources.length > 0), sources, limit: page.limit, offset: page.offset });
  }

  const accounts = db.prepare(`
    SELECT
      fa.id, fa.data_source_id, fa.name, fa.org_name, fa.account_type, fa.currency,
      fa.balance, fa.available_balance, fa.monitored, fa.updated_at,
      ds.name AS source_name, ds.status AS source_status, ds.last_sync_at,
      COUNT(t.id) AS transaction_count,
      MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS last_transaction_date
    FROM financial_accounts fa
    JOIN data_sources ds ON ds.id = fa.data_source_id AND ds.user_id = fa.user_id
    LEFT JOIN transactions t ON t.account_id = fa.id AND t.user_id = fa.user_id
    WHERE fa.user_id = ?
      AND fa.monitored = 1
      AND ds.type = 'provider_sync'
      AND ds.provider = 'simplefin'
    GROUP BY fa.id
    ORDER BY COALESCE(fa.org_name, ''), fa.name
  `).all(req.user.id);

  const filtered = bankLedgerWhere(req.query, req.user.id);
  if (filtered.error) return res.status(400).json(filtered.error);

  const SORT_COLUMNS = {
    date: "COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)",
    amount: 't.amount',
    merchant: "LOWER(COALESCE(t.payee, t.description, t.memo, ''))",
    account: "LOWER(COALESCE(fa.org_name, fa.name, ''))",
    status: 't.match_status',
  };
  const sortBy = SORT_COLUMNS[req.query.sort_by] ? req.query.sort_by : 'date';
  const sortDir = req.query.sort_dir === 'asc' ? 'ASC' : 'DESC';
  const orderBy = `${SORT_COLUMNS[sortBy]} ${sortDir}, t.id ${sortDir}`;

  const joins = `
    FROM transactions t
    JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
    LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
    LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
    LEFT JOIN categories c ON c.id = t.spending_category_id
  `;

  const summary = db.prepare(`
    SELECT
      COUNT(*) AS total,
      COALESCE(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 0) AS inflow,
      COALESCE(SUM(CASE WHEN t.amount < 0 THEN ABS(t.amount) ELSE 0 END), 0) AS outflow,
      COALESCE(SUM(t.amount), 0) AS net,
      COALESCE(SUM(CASE WHEN t.pending = 1 THEN 1 ELSE 0 END), 0) AS pending,
      COALESCE(SUM(CASE WHEN t.match_status = 'matched' THEN 1 ELSE 0 END), 0) AS matched,
      COALESCE(SUM(CASE WHEN t.match_status = 'unmatched' AND t.ignored = 0 THEN 1 ELSE 0 END), 0) AS unmatched,
      MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS latest_date
    ${joins}
    WHERE ${filtered.whereClause}
  `).get(...filtered.params);

  summary.category_breakdown = db.prepare(`
    SELECT
      COALESCE(c.name, 'Uncategorized') AS name,
      COUNT(*) AS count,
      COALESCE(SUM(CASE WHEN t.amount < 0 THEN ABS(t.amount) ELSE 0 END), 0) AS total
    ${joins}
    WHERE ${filtered.whereClause}
    GROUP BY COALESCE(c.name, 'Uncategorized')
    ORDER BY total DESC
    LIMIT 6
  `).all(...filtered.params);

  const total = summary.total;
  const rows = db.prepare(`
    SELECT
      t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
      t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
      t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
      t.match_status, t.ignored, t.pending, t.created_at, t.updated_at,
      t.spending_category_id, c.name AS spending_category_name,
      ds.type AS data_source_type, ds.provider AS data_source_provider,
      ds.name AS data_source_name, ds.status AS data_source_status,
      fa.name AS account_name, fa.org_name AS account_org_name,
      fa.account_type AS account_type, fa.balance AS account_balance,
      fa.available_balance AS account_available_balance,
      b.name AS matched_bill_name
    ${joins}
    WHERE ${filtered.whereClause}
    ORDER BY ${orderBy}
    LIMIT ? OFFSET ?
  `).all(...filtered.params, page.limit, page.offset);

  res.json({
    enabled: true,
    has_connections: true,
    sources,
    accounts,
    summary,
    transactions: rows.map(row => {
      const decorated = decorateTransaction(row);
      if (!row.spending_category_id) {
        decorated.suggested_match = findMerchantMatch(db, row.payee || row.description || row.memo || '');
      }
      return decorated;
    }),
    total,
    limit: page.limit,
    offset: page.offset,
  });
});

// POST /api/transactions/manual
router.post('/manual', (req, res) => {
  const db = getDb();
  const directMatchState = rejectDirectMatchState(req.body);
  if (directMatchState) return res.status(400).json(directMatchState);

  const validation = normalizeTransactionFields(db, req.user.id, req.body);
  if (validation.error) return res.status(validation.status || 400).json(validation.error);
  const tx = validation.normalized;
  const source = ensureManualDataSource(db, req.user.id);
  const state = resolveTransactionState(tx);
  if (state.error) return res.status(400).json(state.error);
  Object.assign(tx, state);

  const result = db.prepare(`
    INSERT INTO transactions
      (user_id, data_source_id, account_id, source_type, transaction_type,
       posted_date, transacted_at, amount, currency, description, payee, memo,
       category, matched_bill_id, match_status, ignored)
    VALUES (?, ?, ?, 'manual', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    req.user.id,
    source.id,
    tx.account_id,
    tx.transaction_type,
    tx.posted_date,
    tx.transacted_at,
    tx.amount,
    tx.currency,
    tx.description,
    tx.payee,
    tx.memo,
    tx.category,
    tx.matched_bill_id,
    tx.match_status,
    tx.ignored,
  );

  res.status(201).json(selectedTransaction(db, req.user.id, result.lastInsertRowid));
});

// PUT /api/transactions/:id
router.put('/:id', (req, res) => {
  const db = getDb();
  const directMatchState = rejectDirectMatchState(req.body);
  if (directMatchState) return res.status(400).json(directMatchState);

  const id = parseInteger(req.params.id, 'id');
  if (id.error) return res.status(400).json(id.error);

  const existing = getTransactionForUser(db, req.user.id, id.value);
  if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));

  const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true });
  if (validation.error) return res.status(validation.status || 400).json(validation.error);
  const tx = validation.normalized;
  const state = resolveTransactionState(tx, existing);
  if (state.error) return res.status(400).json(state.error);
  Object.assign(tx, state);

  const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date;
  const nextTransactedAt = hasOwn(tx, 'transacted_at') ? tx.transacted_at : existing.transacted_at;
  if (!nextPostedDate && !nextTransactedAt) {
    return res.status(400).json(standardizeError('posted_date or transacted_at is required', 'VALIDATION_ERROR', 'posted_date'));
  }

  const fields = [
    'account_id', 'transaction_type', 'posted_date', 'transacted_at', 'amount',
    'currency', 'description', 'payee', 'memo', 'category', 'matched_bill_id',
    'match_status', 'ignored',
  ];
  const sets = [];
  const params = [];

  for (const field of fields) {
    if (hasOwn(tx, field)) {
      sets.push(`${field} = ?`);
      params.push(tx[field]);
    }
  }

  if (!sets.length) {
    return res.status(400).json(standardizeError('No transaction fields provided', 'VALIDATION_ERROR'));
  }

  sets.push("updated_at = datetime('now')");
  db.prepare(`
    UPDATE transactions
    SET ${sets.join(', ')}
    WHERE id = ? AND user_id = ?
  `).run(...params, id.value, req.user.id);

  res.json(selectedTransaction(db, req.user.id, id.value));
});

// DELETE /api/transactions/:id
router.delete('/:id', (req, res) => {
  const db = getDb();
  const id = parseInteger(req.params.id, 'id');
  if (id.error) return res.status(400).json(id.error);

  const existing = getTransactionForUser(db, req.user.id, id.value);
  if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));

  db.transaction(() => {
    unmatchTransaction(req.user.id, id.value);
    db.prepare('DELETE FROM transactions WHERE id = ? AND user_id = ?').run(id.value, req.user.id);
  })();

  res.json({ success: true, deleted: true, id: id.value });
});

// POST /api/transactions/:id/match
router.post('/:id/match', (req, res) => {
  try {
    const result = matchTransactionToBill(
      req.user.id,
      req.params.id,
      req.body?.billId ?? req.body?.bill_id,
      { learnMerchant: true },
    );
    res.json(result);
  } catch (err) {
    return sendTransactionServiceError(res, err, 'Transaction match failed');
  }
});

// POST /api/transactions/:id/unmatch
router.post('/:id/unmatch', (req, res) => {
  try {
    const result = unmatchTransaction(req.user.id, req.params.id);
    res.json(result);
  } catch (err) {
    return sendTransactionServiceError(res, err, 'Transaction unmatch failed');
  }
});

// POST /api/transactions/unmatch-bulk
// Body: { matches: [{ transaction_id, payment_id, payment_source }] }
// Handles both provider_sync (full undo: balance restore + delete payment) and
// transaction_match (standard service unmatch) in one call.
router.post('/unmatch-bulk', (req, res) => {
  const matches = req.body?.matches;
  if (!Array.isArray(matches) || matches.length === 0) {
    return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR'));
  }
  if (matches.length > 50) {
    return res.status(400).json(standardizeError('Cannot unmatch more than 50 at once', 'VALIDATION_ERROR'));
  }

  const db = getDb();
  const userId = req.user.id;
  const results = [];

  db.transaction(() => {
    for (const m of matches) {
      const txId = parseInt(m.transaction_id, 10);
      if (!Number.isInteger(txId) || txId < 1) {
        results.push({ transaction_id: m.transaction_id, ok: false, error: 'Invalid transaction_id' });
        continue;
      }
      try {
        if (m.payment_source === 'provider_sync' && m.payment_id) {
          // Full reversal: restore balance, soft-delete payment, unlink tx
          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 NULL AND b.user_id = ?
          `).get(m.payment_id, userId);

          if (payment) {
            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, roundMoney(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, userId, txId);
          results.push({ transaction_id: txId, ok: true });
        } else {
          // Standard service unmatch (restores balance for transaction_match payments)
          unmatchTransaction(userId, String(txId));
          results.push({ transaction_id: txId, ok: true });
        }
      } catch (err) {
        results.push({ transaction_id: txId, ok: false, error: err.message });
      }
    }
  })();

  const failed = results.filter(r => !r.ok);
  if (failed.length > 0 && failed.length === results.length) {
    return res.status(500).json({ error: 'All unmatches failed', results });
  }
  res.json({ results, unmatched: results.filter(r => r.ok).length });
});

// POST /api/transactions/:id/ignore
router.post('/:id/ignore', (req, res) => {
  try {
    const result = ignoreTransaction(req.user.id, req.params.id);
    res.json(result.transaction);
  } catch (err) {
    return sendTransactionServiceError(res, err, 'Transaction ignore failed');
  }
});

// POST /api/transactions/:id/unignore
router.post('/:id/unignore', (req, res) => {
  try {
    const result = unignoreTransaction(req.user.id, req.params.id);
    res.json(result.transaction);
  } catch (err) {
    return sendTransactionServiceError(res, err, 'Transaction unignore failed');
  }
});

// GET /api/transactions/:id/merchant-match
router.get('/:id/merchant-match', (req, res) => {
  try {
    const db = getDb();
    const id = parseInteger(req.params.id, 'id');
    if (id.error) return res.status(400).json(id.error);

    const existing = getTransactionForUser(db, req.user.id, id.value);
    if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));

    const match = findMerchantMatch(db, existing.payee || existing.description || existing.memo || '');
    res.json({ match });
  } catch (err) {
    return sendTransactionServiceError(res, err, 'Failed to look up merchant match');
  }
});

// POST /api/transactions/:id/apply-merchant-match
router.post('/:id/apply-merchant-match', (req, res) => {
  try {
    const db = getDb();
    const id = parseInteger(req.params.id, 'id');
    if (id.error) return res.status(400).json(id.error);

    const existing = getTransactionForUser(db, req.user.id, id.value);
    if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));

    const match = findMerchantMatch(db, existing.payee || existing.description || existing.memo || '');
    if (!match) return res.json({ matched: false });

    const categoryId = findOrCreateCategory(db, req.user.id, match.category);
    categorizeTransaction(db, req.user.id, id.value, categoryId, true);

    res.json({
      matched: true,
      category: { id: categoryId, name: match.category },
      display_name: match.display_name,
    });
  } catch (err) {
    return sendTransactionServiceError(res, err, 'Failed to apply merchant match');
  }
});

// POST /api/transactions/auto-categorize
router.post('/auto-categorize', (req, res) => {
  try {
    const db = getDb();
    const result = applyMerchantStoreMatches(db, req.user.id, { dryRun: Boolean(req.body?.dry_run) });
    res.json(result);
  } catch (err) {
    return sendTransactionServiceError(res, err, 'Failed to auto-categorize transactions');
  }
});

module.exports = router;
