// Polyfill browser globals that @actual-app/api v26 expects in Node.js
if (typeof globalThis.navigator === 'undefined') {
  globalThis.navigator = { platform: 'linux', userAgent: 'node' };
}

const express = require('express');
const api = require('@actual-app/api');

const app = express();
// CORS disabled — internal service accessed only via gateway
app.use(express.json());

// Health check (before auth middleware)
app.get('/health', (req, res) => res.json({ status: 'ok', ready }));

// API key auth middleware — require X-API-Key header on all routes
const SERVICE_API_KEY = process.env.SERVICE_API_KEY || '';
if (SERVICE_API_KEY) {
    app.use((req, res, next) => {
        const key = req.headers['x-api-key'] || req.query.api_key;
        if (key !== SERVICE_API_KEY) {
            return res.status(401).json({ error: 'Unauthorized: invalid API key' });
        }
        next();
    });
}

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

const ACTUAL_SERVER_URL = process.env.ACTUAL_SERVER_URL || 'http://actualbudget:5006';
const ACTUAL_PASSWORD = process.env.ACTUAL_PASSWORD;
const ACTUAL_SYNC_ID = process.env.ACTUAL_SYNC_ID;
const PORT = process.env.PORT || 3001;
const DATA_DIR = process.env.ACTUAL_DATA_DIR || '/app/data';

// How often (ms) to silently re-sync with the server in the background.
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes

let ready = false;

// ---------------------------------------------------------------------------
// Actual Budget connection lifecycle
// ---------------------------------------------------------------------------

async function initActual() {
  console.log(`[budget] Connecting to Actual server at ${ACTUAL_SERVER_URL}`);
  await api.init({
    serverURL: ACTUAL_SERVER_URL,
    password: ACTUAL_PASSWORD,
    dataDir: DATA_DIR,
  });
  console.log(`[budget] Downloading budget ${ACTUAL_SYNC_ID}`);
  await api.downloadBudget(ACTUAL_SYNC_ID);
  ready = true;
  console.log('[budget] Budget loaded and ready');
}

async function syncBudget() {
  try {
    await api.sync();
    console.log(`[budget] Background sync completed at ${new Date().toISOString()}`);
  } catch (err) {
    console.error('[budget] Background sync failed:', err.message);
  }
}

// ---------------------------------------------------------------------------
// Middleware - reject requests until the budget is loaded
// ---------------------------------------------------------------------------

function requireReady(req, res, next) {
  if (!ready) {
    return res.status(503).json({ error: 'Budget not loaded yet' });
  }
  next();
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Convert cents (integer) to a human-readable dollar amount. */
function centsToDollars(cents) {
  return cents / 100;
}

/** Get the first and last day of a YYYY-MM month string. */
function monthBounds(month) {
  const [year, m] = month.split('-').map(Number);
  const start = `${month}-01`;
  const lastDay = new Date(year, m, 0).getDate();
  const end = `${month}-${String(lastDay).padStart(2, '0')}`;
  return { start, end };
}

/** Current month as YYYY-MM. */
function currentMonth() {
  const now = new Date();
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}

/** Build lookup maps for payees, accounts, and categories (cached 2 min). */
let _lookupsCache = { data: null, expiresAt: 0 };
async function buildLookups() {
  if (_lookupsCache.data && Date.now() < _lookupsCache.expiresAt) {
    return _lookupsCache.data;
  }
  const [payees, accounts, categories] = await Promise.all([
    api.getPayees(),
    api.getAccounts(),
    api.getCategories(),
  ]);
  const payeeMap = {};
  for (const p of payees) payeeMap[p.id] = p.name;
  const accountMap = {};
  for (const a of accounts) accountMap[a.id] = a.name;
  const categoryMap = {};
  for (const c of categories) categoryMap[c.id] = c.name;
  const result = { payeeMap, accountMap, categoryMap };
  _lookupsCache = { data: result, expiresAt: Date.now() + 120000 };
  return result;
}

/** Enrich a transaction with resolved names. */
function enrichTransaction(t, payeeMap, accountMap, categoryMap) {
  return {
    ...t,
    amountDollars: centsToDollars(t.amount),
    payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || t.payee || '',
    accountName: t.accountName || accountMap[t.account] || t.account || '',
    categoryName: categoryMap[t.category] || null,
  };
}

// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------

app.get('/health', (_req, res) => {
  res.json({ status: 'ok', ready });
});

// ---- Accounts -------------------------------------------------------------

app.get('/accounts', requireReady, async (_req, res) => {
  try {
    const accounts = await api.getAccounts();
    const enriched = await Promise.all(
      accounts.map(async (acct) => {
        const balance = await api.getAccountBalance(acct.id);
        return {
          ...acct,
          balance,
          balanceDollars: centsToDollars(balance),
        };
      }),
    );
    res.json(enriched);
  } catch (err) {
    console.error('[budget] GET /accounts error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Transactions (filtered) ----------------------------------------------

app.get('/transactions', requireReady, async (req, res) => {
  try {
    const { accountId, startDate, endDate, limit, offset } = req.query;

    if (!accountId) {
      return res.status(400).json({ error: 'accountId query parameter is required' });
    }

    const start = startDate || '1970-01-01';
    const end = endDate || '2099-12-31';
    const { payeeMap, accountMap, categoryMap } = await buildLookups();

    let txns = await api.getTransactions(accountId, start, end);

    // Sort newest first
    txns.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));

    const total = txns.length;
    const pageOffset = parseInt(offset, 10) || 0;
    const pageLimit = parseInt(limit, 10) || 50;
    txns = txns.slice(pageOffset, pageOffset + pageLimit);

    res.json({
      total,
      offset: pageOffset,
      limit: pageLimit,
      transactions: txns.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap)),
    });
  } catch (err) {
    console.error('[budget] GET /transactions error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Recent transactions across all accounts (cached 30s) -----------------
// NOTE: Actual Budget API requires per-account queries. There is no cross-account
// transaction endpoint. Fan-out across accounts is unavoidable. Cache mitigates
// repeated calls from dashboard + page load.

let recentTxnCache = { data: null, limit: 0, expiresAt: 0 };

app.get('/transactions/recent', requireReady, async (_req, res) => {
  try {
    const limit = parseInt(_req.query.limit, 10) || 20;

    if (recentTxnCache.data && recentTxnCache.limit >= limit && Date.now() < recentTxnCache.expiresAt) {
      return res.json(recentTxnCache.data.slice(0, limit));
    }

    const accounts = await api.getAccounts();
    const { payeeMap, accountMap, categoryMap } = await buildLookups();

    const daysBack = new Date();
    daysBack.setDate(daysBack.getDate() - 90);
    const startDate = daysBack.toISOString().slice(0, 10);
    const endDate = new Date().toISOString().slice(0, 10);

    let all = [];
    for (const acct of accounts) {
      const txns = await api.getTransactions(acct.id, startDate, endDate);
      all.push(...txns.map((t) => ({ ...t, accountName: acct.name })));
    }

    all.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));
    const enriched = all.slice(0, Math.max(limit, 100)).map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap));

    recentTxnCache = { data: enriched, limit: Math.max(limit, 100), expiresAt: Date.now() + 30000 };
    res.json(enriched.slice(0, limit));
  } catch (err) {
    console.error('[budget] GET /transactions/recent error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Categories -----------------------------------------------------------

app.get('/categories', requireReady, async (_req, res) => {
  try {
    const groups = await api.getCategoryGroups();
    const categories = await api.getCategories();

    const grouped = groups.map((g) => ({
      ...g,
      categories: categories.filter((c) => c.group_id === g.id),
    }));

    res.json(grouped);
  } catch (err) {
    console.error('[budget] GET /categories error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Budget for a month ---------------------------------------------------

app.get('/budget/:month', requireReady, async (req, res) => {
  try {
    const { month } = req.params;
    if (!/^\d{4}-\d{2}$/.test(month)) {
      return res.status(400).json({ error: 'month must be YYYY-MM format' });
    }
    const data = await api.getBudgetMonth(month);
    res.json(data);
  } catch (err) {
    console.error(`[budget] GET /budget/${req.params.month} error:`, err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Update a transaction -------------------------------------------------

app.patch('/transactions/:id', requireReady, async (req, res) => {
  try {
    const { id } = req.params;
    const updates = req.body;

    if (!updates || Object.keys(updates).length === 0) {
      return res.status(400).json({ error: 'Request body must contain fields to update' });
    }

    await api.updateTransaction(id, updates);
    await api.sync();

    res.json({ success: true, id, updated: updates });
  } catch (err) {
    console.error(`[budget] PATCH /transactions/${req.params.id} error:`, err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Make Transfer (link two existing transactions) -----------------------

app.post('/make-transfer', requireReady, async (req, res) => {
  try {
    const { transactionId1, transactionId2 } = req.body;
    if (!transactionId1 || !transactionId2) {
      return res.status(400).json({ error: 'transactionId1 and transactionId2 are required' });
    }

    // Get payees to find transfer payees for each account
    const payees = await api.getPayees();
    const accounts = await api.getAccounts();

    // We need to figure out which account each transaction belongs to
    // Fetch both transactions by getting all recent + searching
    const allAccounts = await api.getAccounts();
    let tx1 = null;
    let tx2 = null;

    for (const acct of allAccounts) {
      const txns = await api.getTransactions(acct.id, '1970-01-01', '2099-12-31');
      if (!tx1) tx1 = txns.find((t) => t.id === transactionId1);
      if (!tx2) tx2 = txns.find((t) => t.id === transactionId2);
      if (tx1 && tx2) break;
    }

    if (!tx1 || !tx2) {
      return res.status(404).json({ error: 'Could not find one or both transactions' });
    }

    if (tx1.account === tx2.account) {
      return res.status(400).json({ error: 'Both transactions are from the same account' });
    }

    // Per Actual docs: set the transfer payee on ONE transaction.
    // Actual auto-creates the counterpart on the other account.
    // Then delete the other original transaction (it's now replaced).

    // Pick the outgoing (negative) transaction to keep, delete the incoming one
    const keepTx = tx1.amount < 0 ? tx1 : tx2;
    const deleteTx = tx1.amount < 0 ? tx2 : tx1;
    const keepId = keepTx === tx1 ? transactionId1 : transactionId2;
    const deleteId = deleteTx === tx1 ? transactionId1 : transactionId2;

    // Find the transfer payee for the account we're transferring TO (the deleted tx's account)
    const targetTransferPayee = payees.find((p) => p.transfer_acct === deleteTx.account);

    if (!targetTransferPayee) {
      return res.status(400).json({ error: 'Could not find transfer payee for destination account' });
    }

    // Step 1: Set transfer payee on the kept transaction → Actual creates counterpart
    await api.updateTransaction(keepId, { payee: targetTransferPayee.id });

    // Step 2: Delete the original other transaction (Actual already made a new linked one)
    await api.deleteTransaction(deleteId);

    await api.sync();

    const acctName1 = accounts.find((a) => a.id === keepTx.account)?.name || keepTx.account;
    const acctName2 = accounts.find((a) => a.id === deleteTx.account)?.name || deleteTx.account;

    console.log(`[budget] Linked transfer: ${acctName1} <-> ${acctName2} (kept ${keepId}, deleted ${deleteId})`);
    res.json({
      success: true,
      linked: { transactionId1: keepId, transactionId2: deleteId, account1: acctName1, account2: acctName2 },
    });
  } catch (err) {
    console.error('[budget] POST /make-transfer error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Uncategorized count (cached 2 min) ------------------------------------
// NOTE: Fans out across all accounts — Actual API constraint.

let uncatCache = { count: 0, expiresAt: 0 };

app.get('/uncategorized-count', requireReady, async (_req, res) => {
  try {
    if (Date.now() < uncatCache.expiresAt) {
      return res.json({ count: uncatCache.count });
    }

    const accounts = await api.getAccounts();
    const startDate = '2000-01-01';
    const endDate = new Date().toISOString().slice(0, 10);

    let total = 0;
    for (const acct of accounts) {
      if (acct.closed) continue;
      const txns = await api.getTransactions(acct.id, startDate, endDate);
      total += txns.filter((t) => !t.category && !t.transfer_id && t.amount !== 0).length;
    }
    uncatCache = { count: total, expiresAt: Date.now() + 120000 };
    res.json({ count: total });
  } catch (err) {
    console.error('[budget] GET /uncategorized-count error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Suggested transfers --------------------------------------------------

app.get('/suggested-transfers', requireReady, async (_req, res) => {
  try {
    // Sync first to get latest state
    await api.sync().catch(() => {});
    const accounts = await api.getAccounts();
    const { payeeMap, accountMap, categoryMap } = await buildLookups();

    // Get last 90 days of transactions across all accounts
    const daysBack = new Date();
    daysBack.setDate(daysBack.getDate() - 90);
    const startDate = daysBack.toISOString().slice(0, 10);
    const endDate = new Date().toISOString().slice(0, 10);

    let allTxns = [];
    for (const acct of accounts) {
      if (acct.closed) continue;
      const txns = await api.getTransactions(acct.id, startDate, endDate);
      allTxns.push(
        ...txns.map((t) => ({
          ...t,
          accountName: acct.name,
          payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || '',
          categoryName: categoryMap[t.category] || null,
          amountDollars: centsToDollars(t.amount),
        })),
      );
    }

    // Only consider transactions that aren't already transfers and aren't categorized
    const candidates = allTxns.filter(
      (t) => !t.transfer_id && t.amount !== 0 && !t.starting_balance_flag,
    );

    // Payment-related keywords
    const paymentKeywords = [
      'payment', 'credit card', 'crd epay', 'online transfer',
      'transfer from', 'transfer to', 'autopay', 'bill pay',
      'pymt', 'payment received', 'payment thank',
    ];

    function looksLikePayment(t) {
      const text = ((t.payeeName || '') + ' ' + (t.notes || '')).toLowerCase();
      return paymentKeywords.some((kw) => text.includes(kw));
    }

    // Build index by absolute amount
    const byAmount = {};
    for (const t of candidates) {
      const key = Math.abs(t.amount);
      if (!byAmount[key]) byAmount[key] = [];
      byAmount[key].push(t);
    }

    const suggestions = [];

    for (const [amt, group] of Object.entries(byAmount)) {
      if (group.length < 2) continue;

      // Find negative (outgoing) and positive (incoming) transactions
      const negatives = group.filter((t) => t.amount < 0);
      const positives = group.filter((t) => t.amount > 0);

      for (const neg of negatives) {
        for (const pos of positives) {
          // Must be different accounts
          if (neg.account === pos.account) continue;

          // At least one should look like a payment
          if (!looksLikePayment(neg) && !looksLikePayment(pos)) continue;

          // Dates should be within 3 days
          const d1 = new Date(neg.date);
          const d2 = new Date(pos.date);
          const daysDiff = Math.abs(d1 - d2) / 86400000;
          if (daysDiff > 3) continue;

          // Calculate confidence
          let confidence = 60; // base: matching amount + different accounts
          if (looksLikePayment(neg) && looksLikePayment(pos)) confidence += 20;
          else if (looksLikePayment(neg) || looksLikePayment(pos)) confidence += 10;
          if (daysDiff === 0) confidence += 15;
          else if (daysDiff <= 1) confidence += 10;
          else if (daysDiff <= 2) confidence += 5;

          // Check if payee name matches an account name (strong signal)
          const negPayeeLower = (neg.payeeName || '').toLowerCase();
          const posPayeeLower = (pos.payeeName || '').toLowerCase();
          const negAcctLower = (neg.accountName || '').toLowerCase();
          const posAcctLower = (pos.accountName || '').toLowerCase();
          if (
            negPayeeLower.includes(posAcctLower.slice(0, 10)) ||
            posPayeeLower.includes(negAcctLower.slice(0, 10)) ||
            negAcctLower.includes(negPayeeLower.slice(0, 10)) ||
            posAcctLower.includes(posPayeeLower.slice(0, 10))
          ) {
            confidence += 15;
          }

          suggestions.push({
            confidence: Math.min(confidence, 100),
            amount: Math.abs(neg.amount),
            amountDollars: Math.abs(neg.amountDollars),
            from: {
              id: neg.id,
              account: neg.accountName,
              accountId: neg.account,
              payee: neg.payeeName,
              date: neg.date,
              notes: neg.notes,
            },
            to: {
              id: pos.id,
              account: pos.accountName,
              accountId: pos.account,
              payee: pos.payeeName,
              date: pos.date,
              notes: pos.notes,
            },
          });
        }
      }
    }

    // Sort by confidence desc, then amount desc
    suggestions.sort((a, b) => b.confidence - a.confidence || b.amount - a.amount);

    // Deduplicate — each transaction should only appear in one suggestion
    const used = new Set();
    const deduped = [];
    for (const s of suggestions) {
      if (used.has(s.from.id) || used.has(s.to.id)) continue;
      used.add(s.from.id);
      used.add(s.to.id);
      deduped.push(s);
    }

    res.json(deduped);
  } catch (err) {
    console.error('[budget] GET /suggested-transfers error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Transfer payees (for "Make Transfer" feature) ------------------------

app.get('/transfer-payees', requireReady, async (_req, res) => {
  try {
    const payees = await api.getPayees();
    const accounts = await api.getAccounts();
    const accountMap = {};
    for (const a of accounts) accountMap[a.id] = a.name;

    // Transfer payees have transfer_acct set — they map to an account
    const transferPayees = payees
      .filter((p) => p.transfer_acct)
      .map((p) => ({
        payeeId: p.id,
        payeeName: p.name,
        accountId: p.transfer_acct,
        accountName: accountMap[p.transfer_acct] || p.name,
      }));

    res.json(transferPayees);
  } catch (err) {
    console.error('[budget] GET /transfer-payees error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Dashboard summary (cached) -------------------------------------------

let summaryCache = { data: null, expiresAt: 0 };
const SUMMARY_TTL_MS = 60 * 1000; // 1 minute cache

app.get('/summary', requireReady, async (_req, res) => {
  // Return cached summary if fresh
  if (summaryCache.data && Date.now() < summaryCache.expiresAt) {
    return res.json(summaryCache.data);
  }
  try {
    // Total balance across all accounts
    const accounts = await api.getAccounts();
    let totalBalance = 0;
    for (const acct of accounts) {
      totalBalance += await api.getAccountBalance(acct.id);
    }

    // Spending this month
    const month = currentMonth();
    const { start, end } = monthBounds(month);

    let allTxns = [];
    for (const acct of accounts) {
      const txns = await api.getTransactions(acct.id, start, end);
      allTxns.push(...txns);
    }

    // Spending = sum of negative amounts (expenses)
    const spending = allTxns
      .filter((t) => t.amount < 0)
      .reduce((sum, t) => sum + t.amount, 0);

    const income = allTxns
      .filter((t) => t.amount > 0)
      .reduce((sum, t) => sum + t.amount, 0);

    // Top spending categories
    const categories = await api.getCategories();
    const catMap = new Map(categories.map((c) => [c.id, c.name]));

    const byCat = {};
    for (const t of allTxns) {
      if (t.amount < 0 && t.category) {
        const name = catMap.get(t.category) || 'Uncategorized';
        byCat[name] = (byCat[name] || 0) + t.amount;
      }
    }

    const topCategories = Object.entries(byCat)
      .map(([name, amount]) => ({ name, amount, amountDollars: centsToDollars(amount) }))
      .sort((a, b) => a.amount - b.amount) // most negative first
      .slice(0, 10);

    const result = {
      month,
      totalBalance,
      totalBalanceDollars: centsToDollars(totalBalance),
      spending,
      spendingDollars: centsToDollars(spending),
      income,
      incomeDollars: centsToDollars(income),
      topCategories,
      accountCount: accounts.length,
      transactionCount: allTxns.length,
    };
    summaryCache = { data: result, expiresAt: Date.now() + SUMMARY_TTL_MS };
    res.json(result);
  } catch (err) {
    console.error('[budget] GET /summary error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---- Force sync -----------------------------------------------------------

app.post('/sync', requireReady, async (_req, res) => {
  try {
    await api.sync();
    res.json({ success: true, syncedAt: new Date().toISOString() });
  } catch (err) {
    console.error('[budget] POST /sync error:', err);
    res.status(500).json({ error: err.message });
  }
});

// ---------------------------------------------------------------------------
// Startup
// ---------------------------------------------------------------------------

async function start() {
  if (!ACTUAL_PASSWORD) {
    console.error('[budget] ACTUAL_PASSWORD environment variable is required');
    process.exit(1);
  }
  if (!ACTUAL_SYNC_ID) {
    console.error('[budget] ACTUAL_SYNC_ID environment variable is required');
    process.exit(1);
  }

  try {
    await initActual();
  } catch (err) {
    console.error('[budget] Failed to initialise Actual connection:', err);
    process.exit(1);
  }

  // Periodic background sync
  setInterval(syncBudget, SYNC_INTERVAL_MS);

  app.listen(PORT, () => {
    console.log(`[budget] Server listening on port ${PORT}`);
  });
}

// Graceful shutdown
async function shutdown() {
  console.log('[budget] Shutting down...');
  try {
    await api.shutdown();
  } catch (_) {
    // ignore
  }
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

start();
