'use strict';

import type { Db } from '../types/db';

const { normalizeMerchant } = require('./subscriptionService.cts');
const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts');
const { toCents, fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts');

// Spending = unmatched outflows (amount < 0) that haven't been ignored.
// Bill-matched transactions are excluded so there's no double-counting.
const SPENDING_WHERE = `
  t.amount < 0
  AND t.ignored = 0
  AND t.match_status != 'matched'
  AND t.user_id = ?
`;

interface SpendingCatRow {
  category_id: number | string | null;
  category_name: string;
  group_id: number | null;
  group_name: string | null;
  amount: number;
  tx_count: number;
  budget: number | null;
  avg_3mo: number | null;
  pct_of_total?: number;
}
interface SpendingTxOptions {
  categoryId?: number | null;
  uncategorizedOnly?: boolean;
  page?: number;
  limit?: number;
}
interface IncomeOptions {
  page?: number;
  limit?: number;
  includeIgnored?: boolean;
}

function monthRange(year: number, month: number): { start: string; end: string } {
  const start = `${year}-${String(month).padStart(2, '0')}-01`;
  const end = localDateString(new Date(year, month, 0)); // last day
  return { start, end };
}

function shiftMonth(year: number, month: number, delta: number): { year: number; month: number } {
  let m = month + delta,
    y = year;
  while (m < 1) {
    m += 12;
    y--;
  }
  while (m > 12) {
    m -= 12;
    y++;
  }
  return { year: y, month: m };
}

function cents(raw: unknown): number {
  return Math.abs(Number(raw)) / 100;
}

// ── Summary ──────────────────────────────────────────────────────────────────

function getSpendingSummary(db: Db, userId: number, year: number, month: number) {
  const { start, end } = monthRange(year, month);
  const dateRangeParams = [start, end, start + 'T00:00:00', end + 'T23:59:59'];
  const DATE_RANGE_SQL =
    '(t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))';

  // Every spending-enabled category, even with $0 activity this month (YNAB-style: budgeted
  // categories are always visible, not just ones with transactions).
  const categoryRows = db
    .prepare(
      `
    SELECT
      c.id        AS category_id,
      c.name      AS category_name,
      c.group_id  AS group_id,
      cg.name     AS group_name,
      COALESCE(SUM(ABS(t.amount)), 0) AS total_cents,
      COUNT(t.id)                     AS tx_count
    FROM categories c
    LEFT JOIN category_groups cg ON cg.id = c.group_id
    LEFT JOIN transactions t ON t.spending_category_id = c.id
      AND ${SPENDING_WHERE} AND ${DATE_RANGE_SQL}
    WHERE c.user_id = ? AND c.deleted_at IS NULL AND c.spending_enabled = 1
    GROUP BY c.id
    ORDER BY CASE WHEN c.sort_order IS NULL THEN 1 ELSE 0 END, c.sort_order ASC, c.name COLLATE NOCASE ASC
  `,
    )
    .all(userId, ...dateRangeParams, userId);

  // Uncategorized outflows
  const uncatRow = db
    .prepare(
      `
    SELECT COALESCE(SUM(ABS(t.amount)), 0) AS total_cents, COUNT(t.id) AS tx_count
    FROM transactions t
    WHERE ${SPENDING_WHERE} AND t.spending_category_id IS NULL AND ${DATE_RANGE_SQL}
  `,
    )
    .get(userId, ...dateRangeParams);

  // Outflows categorized to a category that isn't (or is no longer) spending-enabled —
  // bundled into a single "Other categories" row so totals stay accurate without polluting
  // the spending-enabled category list.
  const otherRow = db
    .prepare(
      `
    SELECT COALESCE(SUM(ABS(t.amount)), 0) AS total_cents, COUNT(t.id) AS tx_count
    FROM transactions t
    LEFT JOIN categories c ON c.id = t.spending_category_id
    WHERE ${SPENDING_WHERE} AND ${DATE_RANGE_SQL}
      AND t.spending_category_id IS NOT NULL
      AND (c.id IS NULL OR c.deleted_at IS NOT NULL OR c.spending_enabled = 0)
  `,
    )
    .get(userId, ...dateRangeParams);

  const budgets = db
    .prepare(
      `
    SELECT category_id, amount FROM spending_budgets
    WHERE user_id = ? AND year = ? AND month = ?
  `,
    )
    .all(userId, year, month);
  const budgetMap = new Map(budgets.map((b: any) => [b.category_id, b.amount]));

  // 3-month spending average per category (the 3 calendar months before the requested one)
  const prev1 = monthRange(shiftMonth(year, month, -1).year, shiftMonth(year, month, -1).month);
  const prev3 = monthRange(shiftMonth(year, month, -3).year, shiftMonth(year, month, -3).month);
  const avgRows = db
    .prepare(
      `
    SELECT t.spending_category_id AS category_id, COALESCE(SUM(ABS(t.amount)), 0) AS total_cents
    FROM transactions t
    WHERE ${SPENDING_WHERE} AND t.spending_category_id IS NOT NULL
      AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
    GROUP BY t.spending_category_id
  `,
    )
    .all(userId, prev3.start, prev1.end, prev3.start + 'T00:00:00', prev1.end + 'T23:59:59');
  const avgMap = new Map(avgRows.map((r: any) => [r.category_id, r.total_cents / 3]));

  let totalCents = 0;
  const byCategory: SpendingCatRow[] = categoryRows.map((r: any) => {
    totalCents += r.total_cents;
    return {
      category_id: r.category_id,
      category_name: r.category_name,
      group_id: r.group_id ?? null,
      group_name: r.group_name ?? null,
      amount: r.total_cents / 100,
      tx_count: r.tx_count,
      budget: fromCents(budgetMap.get(r.category_id) ?? null),
      avg_3mo: fromCents(avgMap.get(r.category_id) ?? 0),
    };
  });

  if (uncatRow.tx_count > 0) {
    totalCents += uncatRow.total_cents;
    byCategory.push({
      category_id: null,
      category_name: '(Uncategorized)',
      group_id: null,
      group_name: null,
      amount: uncatRow.total_cents / 100,
      tx_count: uncatRow.tx_count,
      budget: null,
      avg_3mo: null,
    });
  }

  if (otherRow.tx_count > 0) {
    totalCents += otherRow.total_cents;
    byCategory.push({
      category_id: 'other',
      category_name: 'Other categories',
      group_id: null,
      group_name: null,
      amount: otherRow.total_cents / 100,
      tx_count: otherRow.tx_count,
      budget: null,
      avg_3mo: null,
    });
  }

  // Attach pct_of_total
  byCategory.forEach((c) => {
    c.pct_of_total = totalCents > 0 ? Math.round((c.amount / (totalCents / 100)) * 100) / 100 : 0;
  });

  const uncategorized_amount = uncatRow.total_cents / 100;
  const uncategorized_count = uncatRow.tx_count;

  // Income (positive unmatched transactions this month)
  const incomeRow = db
    .prepare(
      `
    SELECT COALESCE(SUM(t.amount), 0) AS total FROM transactions t
    WHERE t.user_id = ? AND t.ignored = 0 AND t.amount > 0
      AND t.match_status != 'matched'
      AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
  `,
    )
    .get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59');

  const categoryGroups = db
    .prepare(
      `
    SELECT id, name, sort_order FROM category_groups
    WHERE user_id = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC
  `,
    )
    .all(userId);

  return {
    year,
    month,
    total_spending: totalCents / 100,
    uncategorized_amount,
    uncategorized_count,
    by_category: byCategory,
    category_groups: categoryGroups,
    income: (incomeRow?.total ?? 0) / 100,
  };
}

// ── Transactions ─────────────────────────────────────────────────────────────

function getSpendingTransactions(
  db: Db,
  userId: number,
  year: number,
  month: number,
  {
    categoryId = undefined,
    uncategorizedOnly = false,
    page = 1,
    limit = 50,
  }: SpendingTxOptions = {},
) {
  const { start, end } = monthRange(year, month);
  const offset = (Math.max(1, page) - 1) * limit;

  let filter = '';
  const params: any[] = [userId, start, end, start + 'T00:00:00', end + 'T23:59:59'];

  if (uncategorizedOnly) {
    filter = 'AND t.spending_category_id IS NULL';
  } else if (categoryId !== undefined) {
    if (categoryId === null) {
      filter = 'AND t.spending_category_id IS NULL';
    } else {
      filter = 'AND t.spending_category_id = ?';
      params.push(categoryId);
    }
  }

  const rows = db
    .prepare(
      `
    SELECT
      t.id, t.amount, t.payee, t.description, t.memo,
      t.posted_date, t.transacted_at, t.spending_category_id,
      c.name AS category_name
    FROM transactions t
    LEFT JOIN categories c ON c.id = t.spending_category_id AND c.deleted_at IS NULL
    WHERE ${SPENDING_WHERE} ${filter}
      AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
    ORDER BY COALESCE(t.posted_date, DATE(t.transacted_at)) DESC, t.id DESC
    LIMIT ? OFFSET ?
  `,
    )
    .all(...params, limit, offset);

  const total = db
    .prepare(
      `
    SELECT COUNT(*) AS n FROM transactions t
    WHERE ${SPENDING_WHERE} ${filter}
      AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
  `,
    )
    .get(...params).n;

  return {
    transactions: rows.map((r: any) => ({
      id: r.id,
      amount: cents(r.amount),
      payee: r.payee || r.description || r.memo || '(Unknown)',
      date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null),
      spending_category_id: r.spending_category_id,
      spending_category_name: r.category_name ?? null,
    })),
    total,
    page,
    pages: Math.ceil(total / limit),
  };
}

// ── Categorize ───────────────────────────────────────────────────────────────

function categorizeTransaction(
  db: Db,
  userId: number,
  txId: number,
  categoryId: number | null,
  saveMerchantRule = false,
): void {
  const tx = db
    .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
    .get(txId, userId);
  if (!tx) throw Object.assign(new Error('Transaction not found'), { status: 404 });

  db.prepare(
    "UPDATE transactions SET spending_category_id=?, updated_at=datetime('now') WHERE id=? AND user_id=?",
  ).run(categoryId ?? null, txId, userId);

  if (saveMerchantRule && categoryId) {
    const merchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
    if (merchant && merchant.length >= 3) {
      try {
        db.prepare(
          `
          INSERT INTO spending_category_rules (user_id, category_id, merchant)
          VALUES (?, ?, ?)
          ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
        `,
        ).run(userId, categoryId, merchant);
        // Auto-mark this category as spending-enabled
        db.prepare(
          "UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?",
        ).run(categoryId, userId);
        applySpendingCategoryRules(db, userId, merchant);
      } catch {
        /* safe to ignore */
      }
    }
  }
}

// ── Auto-categorization ──────────────────────────────────────────────────────

function applySpendingCategoryRules(
  db: Db,
  userId: number,
  onlyMerchant: string | null = null,
): number {
  let rules: any[];
  try {
    const q = onlyMerchant
      ? db
          .prepare(
            'SELECT merchant, category_id FROM spending_category_rules WHERE user_id=? AND merchant=?',
          )
          .all(userId, onlyMerchant)
      : db
          .prepare('SELECT merchant, category_id FROM spending_category_rules WHERE user_id=?')
          .all(userId);
    rules = q;
  } catch {
    return 0;
  }
  if (!rules.length) return 0;

  let applied = 0;
  const update = db.prepare(
    "UPDATE transactions SET spending_category_id=?, updated_at=datetime('now') WHERE id=? AND user_id=?",
  );

  const txRows = db
    .prepare(
      `
    SELECT id, payee, description, memo FROM transactions
    WHERE user_id=? AND amount<0 AND ignored=0 AND match_status!='matched' AND spending_category_id IS NULL
  `,
    )
    .all(userId);

  db.transaction(() => {
    for (const tx of txRows) {
      const merchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
      if (!merchant) continue;
      const rule = rules.find((r) => merchantMatches(merchant, r.merchant));
      if (rule) {
        update.run(rule.category_id, tx.id, userId);
        applied++;
      }
    }
  })();

  return applied;
}

function merchantMatches(txMerchant: string, ruleMerchant: string): boolean {
  if (!txMerchant || !ruleMerchant) return false;
  if (txMerchant === ruleMerchant) return true;
  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const wb = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`);
  return wb(ruleMerchant).test(txMerchant) || wb(txMerchant).test(ruleMerchant);
}

// ── Budgets ──────────────────────────────────────────────────────────────────

function getSpendingBudgets(db: Db, userId: number, year: number, month: number) {
  const rows = db
    .prepare(
      `
    SELECT sb.category_id, sb.amount, c.name AS category_name
    FROM spending_budgets sb
    JOIN categories c ON c.id = sb.category_id AND c.deleted_at IS NULL
    WHERE sb.user_id=? AND sb.year=? AND sb.month=?
  `,
    )
    .all(userId, year, month);
  return rows.map((r: any) => ({ ...r, amount: fromCents(r.amount) }));
}

function setSpendingBudget(
  db: Db,
  userId: number,
  categoryId: number,
  year: number,
  month: number,
  amount: number | string | null,
): void {
  if (amount === null || amount === undefined) {
    db.prepare(
      'DELETE FROM spending_budgets WHERE user_id=? AND category_id=? AND year=? AND month=?',
    ).run(userId, categoryId, year, month);
  } else {
    db.prepare(
      `
      INSERT INTO spending_budgets (user_id, category_id, year, month, amount, updated_at)
      VALUES (?, ?, ?, ?, ?, datetime('now'))
      ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
        amount=excluded.amount, updated_at=datetime('now')
    `,
    ).run(userId, categoryId, year, month, toCents(amount));
  }
}

// ── Category rules ───────────────────────────────────────────────────────────

function getSpendingCategoryRules(db: Db, userId: number) {
  return db
    .prepare(
      `
    SELECT r.id, r.merchant, r.category_id, c.name AS category_name
    FROM spending_category_rules r
    JOIN categories c ON c.id = r.category_id AND c.deleted_at IS NULL
    WHERE r.user_id=?
    ORDER BY r.merchant ASC
  `,
    )
    .all(userId);
}

function addSpendingCategoryRule(
  db: Db,
  userId: number,
  categoryId: number,
  merchant: string,
): void {
  const normalized = normalizeMerchant(merchant);
  if (!normalized || normalized.length < 2)
    throw Object.assign(new Error('Merchant name too short'), { status: 400 });
  db.prepare(
    `
    INSERT INTO spending_category_rules (user_id, category_id, merchant)
    VALUES (?, ?, ?)
    ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
  `,
  ).run(userId, categoryId, normalized);
  // Auto-mark this category as spending-enabled so it shows in the picker
  try {
    db.prepare(
      "UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?",
    ).run(categoryId, userId);
  } catch {
    /* non-critical */
  }
  applySpendingCategoryRules(db, userId, normalized);
}

function deleteSpendingCategoryRule(db: Db, userId: number, ruleId: number): void {
  db.prepare('DELETE FROM spending_category_rules WHERE id=? AND user_id=?').run(ruleId, userId);
}

// ── Income ───────────────────────────────────────────────────────────────────

function getIncomeTransactions(
  db: Db,
  userId: number,
  year: number,
  month: number,
  { page = 1, limit = 50, includeIgnored = false }: IncomeOptions = {},
) {
  const { start, end } = monthRange(year, month);
  const offset = (Math.max(1, page) - 1) * limit;
  const ignoredFilter = includeIgnored ? '' : 'AND ignored = 0';

  try {
    const rows = db
      .prepare(
        `
      SELECT id, amount, payee, description, memo, posted_date, transacted_at, ignored
      FROM transactions
      WHERE user_id = ? AND amount > 0 ${ignoredFilter} AND match_status != 'matched'
        AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?))
      ORDER BY ignored ASC, COALESCE(posted_date, DATE(transacted_at)) DESC, id DESC
      LIMIT ? OFFSET ?
    `,
      )
      .all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59', limit, offset);

    const total = db
      .prepare(
        `
      SELECT COUNT(*) AS n FROM transactions
      WHERE user_id = ? AND amount > 0 ${ignoredFilter} AND match_status != 'matched'
        AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?))
    `,
      )
      .get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59').n;

    return {
      transactions: rows.map((r: any) => ({
        id: r.id,
        amount: Math.abs(Number(r.amount)) / 100,
        payee: r.payee || r.description || r.memo || '(Unknown)',
        date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null),
        ignored: r.ignored === 1,
      })),
      total,
      page,
      pages: Math.ceil(total / limit),
    };
  } catch (err: any) {
    console.error('[getIncomeTransactions]', err.message);
    return { transactions: [], total: 0, page: 1, pages: 1 };
  }
}

module.exports = {
  getSpendingSummary,
  getSpendingTransactions,
  categorizeTransaction,
  applySpendingCategoryRules,
  getSpendingBudgets,
  setSpendingBudget,
  getSpendingCategoryRules,
  addSpendingCategoryRule,
  deleteSpendingCategoryRule,
  getIncomeTransactions,
};
