import { getDb } from "./db";
import { getBudgetVsActualData } from "./budgetService";
import { inPlaceholders } from "../utils/sqlFilters";
import type {
  MonthlyTrendItem,
  CategoryBreakdownItem,
  CategoryOverTimeData,
  CategoryOverTimeItem,
  OverTimeRow,
  HighlightsData,
  HighlightMover,
  CategoryDelta,
  CategoryZoomData,
  CategoryZoomChild,
  CategoryZoomEvolutionPoint,
  MonthBalance,
  RecentTransaction,
  CartesSnapshot,
  CartesKpi,
  CartesSparklinePoint,
  CartesTopMover,
  CartesMonthFlow,
  CartesBudgetAdherence,
  CartesBudgetWorstOverrun,
  CartesSeasonality,
  CartesSeasonalityYear,
  CartesKpiPeriodMode,
} from "../shared/types";

export async function getMonthlyTrends(
  dateFrom?: string,
  dateTo?: string,
  accountIds?: number[],
): Promise<MonthlyTrendItem[]> {
  const db = await getDb();

  const whereClauses: string[] = [];
  const params: unknown[] = [];
  let paramIndex = 1;

  if (dateFrom) {
    whereClauses.push(`date >= $${paramIndex}`);
    params.push(dateFrom);
    paramIndex++;
  }
  if (dateTo) {
    whereClauses.push(`date <= $${paramIndex}`);
    params.push(dateTo);
    paramIndex++;
  }
  const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
  if (accountPlaceholders) {
    whereClauses.push(`source_id IN (${accountPlaceholders})`);
    params.push(...accountIds!);
    paramIndex += accountIds!.length;
  }

  const whereSQL =
    whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";

  return db.select<MonthlyTrendItem[]>(
    `SELECT
       strftime('%Y-%m', date) AS month,
       COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
       ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
     FROM transactions
     ${whereSQL}
     GROUP BY month
     ORDER BY month ASC`,
    params
  );
}

export async function getCategoryOverTime(
  dateFrom?: string,
  dateTo?: string,
  topN: number = 50,
  accountIds?: number[],
  typeFilter?: "expense" | "income" | "transfer",
): Promise<CategoryOverTimeData> {
  const db = await getDb();

  const whereClauses: string[] = [];
  const params: unknown[] = [];
  let paramIndex = 1;

  if (typeFilter) {
    whereClauses.push(`COALESCE(c.type, 'expense') = $${paramIndex}`);
    params.push(typeFilter);
    paramIndex++;
  }

  if (dateFrom) {
    whereClauses.push(`t.date >= $${paramIndex}`);
    params.push(dateFrom);
    paramIndex++;
  }
  if (dateTo) {
    whereClauses.push(`t.date <= $${paramIndex}`);
    params.push(dateTo);
    paramIndex++;
  }
  const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
  if (accountPlaceholders) {
    whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
    params.push(...accountIds!);
    paramIndex += accountIds!.length;
  }

  const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";

  // Get top N categories by total spend
  const topCategories = await db.select<
    (CategoryBreakdownItem & { category_type: "expense" | "income" | "transfer" })[]
  >(
    `SELECT
       t.category_id,
       COALESCE(c.name, 'Uncategorized') AS category_name,
       COALESCE(c.color, '#9ca3af') AS category_color,
       COALESCE(c.type, 'expense') AS category_type,
       ABS(SUM(t.amount)) AS total
     FROM transactions t
     LEFT JOIN categories c ON t.category_id = c.id
     ${whereSQL}
     GROUP BY t.category_id
     ORDER BY total DESC
     LIMIT $${paramIndex}`,
    [...params, topN]
  );

  const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
  const colors: Record<string, string> = {};
  const categoryIds: Record<string, number | null> = {};
  const types: Record<string, "expense" | "income" | "transfer"> = {};
  for (const cat of topCategories) {
    colors[cat.category_name] = cat.category_color;
    categoryIds[cat.category_name] = cat.category_id;
    types[cat.category_name] = cat.category_type ?? "expense";
  }

  // Get monthly breakdown for all categories
  const monthlyRows = await db.select<
    Array<{
      month: string;
      category_id: number | null;
      category_name: string;
      total: number;
    }>
  >(
    `SELECT
       strftime('%Y-%m', t.date) AS month,
       t.category_id,
       COALESCE(c.name, 'Uncategorized') AS category_name,
       ABS(SUM(t.amount)) AS total
     FROM transactions t
     LEFT JOIN categories c ON t.category_id = c.id
     ${whereSQL}
     GROUP BY month, t.category_id
     ORDER BY month ASC`,
    params
  );

  // Build pivot data
  const monthMap = new Map<string, CategoryOverTimeItem>();
  let hasOther = false;

  for (const row of monthlyRows) {
    if (!monthMap.has(row.month)) {
      monthMap.set(row.month, { month: row.month });
    }
    const item = monthMap.get(row.month)!;

    if (topCategoryIds.has(row.category_id)) {
      item[row.category_name] = ((item[row.category_name] as number) || 0) + row.total;
    } else {
      item["Other"] = ((item["Other"] as number) || 0) + row.total;
      hasOther = true;
    }
  }

  if (hasOther) {
    colors["Other"] = "#9ca3af";
  }

  const categories = topCategories.map((c) => c.category_name);
  if (hasOther) {
    categories.push("Other");
  }

  const data = Array.from(monthMap.values());

  // --- Id-keyed hierarchical sidecar (Issue #264) ---
  // The pivot above stays byte-identical (top-N + "Other", name-keyed) so the
  // chart and dashboard are untouched. The tree is a SEPARATE view of the exact
  // same `monthlyRows`, keyed by category_id, carrying EVERY category (no top-N,
  // no "Other" bucket) so the trends table + its result lines are exact and two
  // homonym categories of different types never collide.
  const months = data.map((d) => d.month);
  const monthIndex = new Map<string, number>();
  months.forEach((m, i) => monthIndex.set(m, i));

  // Aggregate every category into an id-keyed per-month magnitude series. A null
  // category_id (truly uncategorized) collapses to one bucket; a non-null id that
  // has no metadata row (hard-deleted) stays distinct and surfaces as an orphan.
  const leafByKey = new Map<number | string, OverTimeRow>();
  for (const row of monthlyRows) {
    const key = row.category_id ?? "__uncategorized__";
    let leaf = leafByKey.get(key);
    if (!leaf) {
      leaf = {
        categoryId: row.category_id,
        categoryName: row.category_name,
        categoryColor: "#9ca3af",
        monthly: new Array(months.length).fill(0),
        total: 0,
        parent_id: null,
        is_parent: false,
        depth: 0,
        category_type: "expense",
      };
      leafByKey.set(key, leaf);
    }
    const idx = monthIndex.get(row.month);
    if (idx != null) {
      leaf.monthly[idx] += row.total;
      leaf.total += row.total;
    }
  }

  const cats = await db.select<TreeCatMeta[]>(
    `SELECT id, name, color, type, parent_id FROM categories`,
  );
  const tree = buildOverTimeTree(Array.from(leafByKey.values()), cats ?? [], months.length);

  return {
    categories,
    data,
    colors,
    categoryIds,
    types,
    tree,
  };
}

// --- Highlights (Issue #71) ---

/**
 * Shifts a YYYY-MM-DD date string by `months` months and returns the first day
 * of the resulting month as YYYY-MM-01. Used to compute the start of the
 * 12-month sparkline window relative to the reference date.
 */
function shiftMonthStart(refIso: string, months: number): string {
  const [y, m] = refIso.split("-").map(Number);
  const d = new Date(Date.UTC(y, m - 1 + months, 1));
  const yy = d.getUTCFullYear();
  const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
  return `${yy}-${mm}-01`;
}

function shiftDate(refIso: string, days: number): string {
  const [y, m, d] = refIso.split("-").map(Number);
  const dt = new Date(Date.UTC(y, m - 1, d + days));
  const yy = dt.getUTCFullYear();
  const mm = String(dt.getUTCMonth() + 1).padStart(2, "0");
  const dd = String(dt.getUTCDate()).padStart(2, "0");
  return `${yy}-${mm}-${dd}`;
}

function todayIso(today: Date): string {
  const y = today.getFullYear();
  const m = String(today.getMonth() + 1).padStart(2, "0");
  const d = String(today.getDate()).padStart(2, "0");
  return `${y}-${m}-${d}`;
}

/**
 * Returns the dashboard "highlights" snapshot for the reports hub:
 *   - monthly tile     — net balance of the REFERENCE month
 *   - YTD tile         — net balance from Jan 1st of `ytdYear` through today
 *   - 12-month series  — last 12 months ending at the reference month
 *   - top movers       — reference month vs the month immediately before it
 *   - top transactions — biggest absolute amounts in the last `windowDays`
 *                        days ending today
 *
 * `today` is an optional injection point so unit tests can pin both the YTD
 * window and the "recent transactions" window without depending on wall-clock
 * time. All SQL is parameterised.
 */
export async function getHighlights(
  referenceYear: number,
  referenceMonth: number,
  ytdYear: number,
  windowDays: number = 30,
  topMoversLimit: number = 5,
  topTransactionsLimit: number = 10,
  today: Date = new Date(),
): Promise<HighlightsData> {
  const db = await getDb();

  const currentMonth = `${referenceYear}-${String(referenceMonth).padStart(2, "0")}`; // YYYY-MM
  const { start: currentMonthStart, end: currentMonthEnd } = monthBoundaries(
    referenceYear,
    referenceMonth,
  );
  const prev = previousMonth(referenceYear, referenceMonth);
  const { start: previousMonthStart, end: previousMonthEnd } = monthBoundaries(
    prev.year,
    prev.month,
  );

  // Sparkline anchor = reference month; shift 11 back for a 12-month series.
  const sparklineStart = shiftMonthStart(`${currentMonthStart}`, -11);

  // YTD window: Jan 1 of `ytdYear` → today. The reference month does not
  // move this window — the YTD tile is pinned to the current civil year.
  const ytdStart = `${ytdYear}-01-01`;
  const todayStr = todayIso(today);
  // Guard: if the user picks a reference month in the future (unlikely) or if
  // `today` is clamped below Jan 1 (fixtures), cap the YTD end at `ytdStart`
  // so SQL never inverts its bounds.
  const ytdEnd = todayStr >= ytdStart ? todayStr : ytdStart;

  // Recent-transactions window ends today (not at the reference month).
  const recentWindowStart = shiftDate(todayStr, -(windowDays - 1));

  // 1. Net balance for the reference month
  const currentBalanceRows = await db.select<Array<{ net: number | null }>>(
    `SELECT COALESCE(SUM(amount), 0) AS net
       FROM transactions
      WHERE date >= $1 AND date <= $2`,
    [currentMonthStart, currentMonthEnd],
  );
  const netBalanceCurrent = Number(currentBalanceRows[0]?.net ?? 0);

  // 2. YTD balance — independent of the reference month.
  const ytdRows = await db.select<Array<{ net: number | null }>>(
    `SELECT COALESCE(SUM(amount), 0) AS net
       FROM transactions
      WHERE date >= $1 AND date <= $2`,
    [ytdStart, ytdEnd],
  );
  const netBalanceYtd = Number(ytdRows[0]?.net ?? 0);

  // 3. 12-month sparkline series, ending at the reference month.
  const seriesRows = await db.select<Array<{ month: string; net: number | null }>>(
    `SELECT strftime('%Y-%m', date) AS month, COALESCE(SUM(amount), 0) AS net
       FROM transactions
      WHERE date >= $1 AND date <= $2
      GROUP BY month
      ORDER BY month ASC`,
    [sparklineStart, currentMonthEnd],
  );
  const seriesMap = new Map(seriesRows.map((r) => [r.month, Number(r.net ?? 0)]));
  const monthlyBalanceSeries: MonthBalance[] = [];
  for (let i = 11; i >= 0; i--) {
    const monthKey = shiftMonthStart(currentMonthStart, -i).slice(0, 7);
    monthlyBalanceSeries.push({ month: monthKey, netBalance: seriesMap.get(monthKey) ?? 0 });
  }

  // 4. Top movers — expense-side only (amount < 0), compare reference month
  // vs the immediately-previous month.
  const moversRows = await db.select<
    Array<{
      category_id: number | null;
      category_name: string;
      category_color: string;
      current_total: number | null;
      previous_total: number | null;
    }>
  >(
    `SELECT
        t.category_id,
        COALESCE(c.name, 'Uncategorized') AS category_name,
        COALESCE(c.color, '#9ca3af')      AS category_color,
        COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total,
        COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total
      FROM transactions t
      LEFT JOIN categories c ON t.category_id = c.id
      WHERE t.amount < 0
        AND (
          (t.date >= $1 AND t.date <= $2)
          OR (t.date >= $3 AND t.date <= $4)
        )
      GROUP BY t.category_id, category_name, category_color
      ORDER BY ABS(current_total - previous_total) DESC
      LIMIT $5`,
    [currentMonthStart, currentMonthEnd, previousMonthStart, previousMonthEnd, topMoversLimit],
  );
  const topMovers: HighlightMover[] = moversRows.map((r) => {
    const current = Number(r.current_total ?? 0);
    const previous = Number(r.previous_total ?? 0);
    const deltaAbs = current - previous;
    const deltaPct = previous === 0 ? null : (deltaAbs / previous) * 100;
    // Highlights only exposes a monthly view, so the cumulative block mirrors
    // the monthly values — keeps the CategoryDelta shape uniform for
    // downstream consumers that never read the cumulative block.
    return {
      categoryId: r.category_id,
      categoryName: r.category_name,
      categoryColor: r.category_color,
      previousAmount: previous,
      currentAmount: current,
      deltaAbs,
      deltaPct,
      cumulativePreviousAmount: previous,
      cumulativeCurrentAmount: current,
      cumulativeDeltaAbs: deltaAbs,
      cumulativeDeltaPct: deltaPct,
    };
  });

  // 5. Top transactions within the recent window ending today.
  const recentRows = await db.select<RecentTransaction[]>(
    `SELECT
        t.id,
        t.date,
        t.description,
        t.amount,
        c.name  AS category_name,
        c.color AS category_color
       FROM transactions t
       LEFT JOIN categories c ON t.category_id = c.id
      WHERE t.date >= $1 AND t.date <= $2
      ORDER BY ABS(t.amount) DESC
      LIMIT $3`,
    [recentWindowStart, todayStr, topTransactionsLimit],
  );

  return {
    currentMonth,
    netBalanceCurrent,
    netBalanceYtd,
    monthlyBalanceSeries,
    topMovers,
    topTransactions: recentRows,
  };
}

// --- Compare (Issue #73) ---

interface RawDeltaRow {
  category_id: number | null;
  category_name: string;
  category_color: string;
  month_current_total: number | null;
  month_previous_total: number | null;
  cumulative_current_total: number | null;
  cumulative_previous_total: number | null;
}

function rowsToDeltas(rows: RawDeltaRow[]): CategoryDelta[] {
  return rows.map((r) => {
    const monthCurrent = Number(r.month_current_total ?? 0);
    const monthPrevious = Number(r.month_previous_total ?? 0);
    const monthDeltaAbs = monthCurrent - monthPrevious;
    const monthDeltaPct = monthPrevious === 0 ? null : (monthDeltaAbs / monthPrevious) * 100;

    const cumCurrent = Number(r.cumulative_current_total ?? 0);
    const cumPrevious = Number(r.cumulative_previous_total ?? 0);
    const cumDeltaAbs = cumCurrent - cumPrevious;
    const cumDeltaPct = cumPrevious === 0 ? null : (cumDeltaAbs / cumPrevious) * 100;

    return {
      categoryId: r.category_id,
      categoryName: r.category_name,
      categoryColor: r.category_color,
      // Monthly block (primary — also the legacy field set).
      previousAmount: monthPrevious,
      currentAmount: monthCurrent,
      deltaAbs: monthDeltaAbs,
      deltaPct: monthDeltaPct,
      // Cumulative YTD block.
      cumulativePreviousAmount: cumPrevious,
      cumulativeCurrentAmount: cumCurrent,
      cumulativeDeltaAbs: cumDeltaAbs,
      cumulativeDeltaPct: cumDeltaPct,
    };
  });
}

function monthBoundaries(year: number, month: number): { start: string; end: string } {
  const mm = String(month).padStart(2, "0");
  const endDate = new Date(Date.UTC(year, month, 0));
  const dd = String(endDate.getUTCDate()).padStart(2, "0");
  return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
}

// --- Generic leaf-driven tree builder (Issue #263) ---

/** Section key shared by every hierarchical report (income-statement order). */
export type TreeSectionType = "expense" | "income" | "transfer";

/**
 * Minimal category metadata every hierarchical report needs: id, display
 * name/color, income-statement `type`, and `parent_id` to walk the hierarchy.
 * Shared by the compare tree (Issue #247) and the trends tree (Issue #264).
 */
export interface TreeCatMeta {
  id: number;
  name: string;
  color: string | null;
  type: TreeSectionType | null;
  parent_id: number | null;
}

/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
const MAX_TREE_DEPTH = 5;

/**
 * Injection points that specialise the generic tree builder for a concrete row
 * type `T`. `T` is BOTH the flat leaf payload fed in and the enriched row shape
 * emitted — leaf/subtotal rows just add the hierarchy fields (`parent_id`,
 * `is_parent`, `depth`, …) the caller writes in `makeLeaf` / `makeSubtotal`.
 */
export interface LeafDrivenTreeOptions<T> {
  /** Category hierarchy (fetched without an is_active filter — keeps history). */
  categories: TreeCatMeta[];
  /** Category a leaf belongs to; a null/absent id → orphan (top-level leaf). */
  categoryIdOf: (leaf: T) => number | null;
  /** Enrich a leaf payload into a leaf row parented at `parentId` / `depth`. */
  makeLeaf: (cat: TreeCatMeta, leaf: T, parentId: number | null, depth: number) => T;
  /** Reduce a node's descendant leaves into its subtotal (is_parent) row. */
  makeSubtotal: (
    cat: TreeCatMeta,
    descendantLeaves: T[],
    parentId: number | null,
    depth: number,
  ) => T;
  /** Annotate the "(direct)" leaf of a parent that also has own transactions. */
  decorateDirectLeaf: (leafRow: T, cat: TreeCatMeta) => T;
  /** Enrich an orphan (Uncategorized / hard-deleted) payload into a root leaf. */
  makeOrphan: (leaf: T) => T;
  /** Magnitude used to order siblings (descending) — read off a block's head. */
  sortKey: (row: T) => number;
  /** True when a row is a subtotal, so descendant *leaves* can be summed. */
  isSubtotal: (row: T) => boolean;
  /** Section key of a row for the final contiguous type sort. */
  sectionOf: (row: T) => string;
  /** Section order, income-first e.g. `{ income: 0, expense: 1, transfer: 2 }`. */
  sectionOrder: Record<string, number>;
  /** Rank for a section absent from `sectionOrder` (default 9 — sorts last). */
  unknownSectionRank?: number;
  /** Cyclic-chain depth cap (default `MAX_TREE_DEPTH`). */
  maxDepth?: number;
}

/**
 * Generic leaf-driven hierarchy builder. Turns a flat list of per-category
 * leaves into a parent/child tree with subtotal rows, ordered by magnitude
 * within each type and grouped into contiguous income → expense → transfer
 * sections. Driven by the leaves that actually exist (not the full category
 * list), so netted-to-zero groups and soft-deleted categories with history are
 * preserved and no empty rows appear.
 *
 * Behavior — including the `MAX_TREE_DEPTH` guard and buildNode loop-break
 * against a corrupted parent_id cycle — is shared verbatim by every
 * hierarchical report; the row arithmetic is injected via `opts`. Extracted
 * from `buildCompareTree` (Issue #263) so the trends tree (Issue #264) reuses
 * the exact same skeleton.
 */
export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<T>): T[] {
  const {
    categories,
    categoryIdOf,
    makeLeaf,
    makeSubtotal,
    decorateDirectLeaf,
    makeOrphan,
    sortKey,
    isSubtotal,
    sectionOf,
    sectionOrder,
    unknownSectionRank = 9,
    maxDepth = MAX_TREE_DEPTH,
  } = opts;

  const catById = new Map<number, TreeCatMeta>();
  for (const c of categories) catById.set(c.id, c);

  // Flat leaf lookup by category id. Rows whose id is null (Uncategorized) or
  // absent from the table (hard-deleted) become orphans: depth-0 leaves
  // preserved in their incoming order.
  const leafByCat = new Map<number, T>();
  const orphans: T[] = [];
  for (const l of leaves) {
    const id = categoryIdOf(l);
    if (id != null && catById.has(id)) {
      leafByCat.set(id, l);
    } else {
      orphans.push(l);
    }
  }

  // "Relevant" = every category that has a leaf plus all of its ancestors.
  const relevant = new Set<number>();
  for (const id of leafByCat.keys()) {
    let cur: number | null | undefined = id;
    let guard = 0;
    while (cur != null && guard <= maxDepth) {
      if (relevant.has(cur)) break;
      relevant.add(cur);
      cur = catById.get(cur)?.parent_id ?? null;
      guard++;
    }
  }

  // Adjacency among relevant categories only, preserving DB order.
  const childrenByParent = new Map<number, TreeCatMeta[]>();
  for (const c of categories) {
    if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
      let arr = childrenByParent.get(c.parent_id);
      if (!arr) childrenByParent.set(c.parent_id, (arr = []));
      arr.push(c);
    }
  }

  interface Block {
    rows: T[];
    sortKey: number; // magnitude of the block head — orders siblings
  }

  // Builds a node's block: a pure leaf, or a subtotal followed by its
  // (recursively built) child blocks, siblings ordered by magnitude desc.
  const buildNode = (cat: TreeCatMeta, depth: number): Block | null => {
    // Stop descending past the depth cap so a corrupted parent_id cycle can
    // never recurse forever — deeper nodes collapse to leaves (real category
    // trees are ≤ 3 levels).
    const children = depth >= maxDepth ? [] : (childrenByParent.get(cat.id) ?? []);
    const hasDirect = leafByCat.has(cat.id);

    if (children.length === 0) {
      if (!hasDirect) return null;
      const leaf = makeLeaf(cat, leafByCat.get(cat.id)!, cat.parent_id ?? null, depth);
      return { rows: [leaf], sortKey: sortKey(leaf) };
    }

    const childBlocks: Block[] = [];
    // A category with both children AND its own transactions surfaces the direct
    // spend as a "(direct)" leaf so the subtotal stays the sum of its visible
    // rows. Rare: a parent auto-loses is_inputable when a child is added.
    if (hasDirect) {
      const direct = decorateDirectLeaf(
        makeLeaf(cat, leafByCat.get(cat.id)!, cat.id, depth + 1),
        cat,
      );
      childBlocks.push({ rows: [direct], sortKey: sortKey(direct) });
    }
    for (const child of children) {
      const b = buildNode(child, depth + 1);
      if (b) childBlocks.push(b);
    }
    childBlocks.sort((a, b) => b.sortKey - a.sortKey);

    const childRows = childBlocks.flatMap((b) => b.rows);
    const descendantLeaves = childRows.filter((r) => !isSubtotal(r));
    const subtotal = makeSubtotal(cat, descendantLeaves, cat.parent_id ?? null, depth);
    return { rows: [subtotal, ...childRows], sortKey: sortKey(subtotal) };
  };

  // Roots = relevant categories with no relevant parent. Ordered by magnitude.
  const rootBlocks: Block[] = [];
  for (const c of categories) {
    if (!relevant.has(c.id)) continue;
    if (c.parent_id != null && relevant.has(c.parent_id)) continue;
    const b = buildNode(c, 0);
    if (b) rootBlocks.push(b);
  }
  rootBlocks.sort((a, b) => b.sortKey - a.sortKey);

  const rows = rootBlocks.flatMap((b) => b.rows);
  // Orphans (Uncategorized + hard-deleted) appended in their incoming order.
  for (const l of orphans) rows.push(makeOrphan(l));

  // Stable sort by section so types (income → expense → transfer) are
  // contiguous; magnitude/tree order within a type is preserved via the index.
  const order = new Map<T, number>();
  rows.forEach((r, i) => order.set(r, i));
  rows.sort((a, b) => {
    const ta = sectionOrder[sectionOf(a)] ?? unknownSectionRank;
    const tb = sectionOrder[sectionOf(b)] ?? unknownSectionRank;
    if (ta !== tb) return ta - tb;
    return order.get(a)! - order.get(b)!;
  });

  return rows;
}

// --- Compare hierarchy (Issue #247) ---

/**
 * Minimal category metadata for building the compare tree. Fetched WITHOUT an
 * `is_active` filter so soft-deleted categories (is_active = 0) that still carry
 * historic transactions keep their place in the hierarchy — matching the raw
 * LEFT JOIN behavior of compareDeltaSql() (no regression on the leaves shown).
 */
type CompareCatMeta = TreeCatMeta;

const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;

// Income statement reading order: revenues first, then expenses, then the
// (netted) transfers — the two result lines are injected around them in the UI.
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };

/**
 * Turns the flat per-category deltas returned by compareDeltaSql() into a
 * parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
 * getBudgetVsActualData.
 *
 * The #243 transfer netting is untouched: leaf values are exactly what the SQL
 * returned, and a subtotal is the arithmetic sum of its descendant leaves — so a
 * group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
 * net.
 *
 * A thin specialisation of `buildLeafDrivenTree` (Issue #263): the generic
 * skeleton drives the hierarchy walk, sibling ordering, depth guard and section
 * sort; this only supplies the delta arithmetic. Behavior is byte-identical to
 * the previous hand-rolled builder — the compare tests are the guard.
 *
 * Exported for unit testing.
 */
export function buildCompareTree(
  leaves: CategoryDelta[],
  categories: CompareCatMeta[],
): CategoryDelta[] {
  const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";

  return buildLeafDrivenTree<CategoryDelta>(leaves, {
    categories,
    categoryIdOf: (d) => d.categoryId,
    makeLeaf: (cat, d, parentId, depth) => ({
      ...d,
      parent_id: parentId,
      is_parent: false,
      depth,
      category_type: typeOf(cat),
    }),
    makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
      let previousAmount = 0;
      let currentAmount = 0;
      let cumulativePreviousAmount = 0;
      let cumulativeCurrentAmount = 0;
      for (const l of descendantLeaves) {
        previousAmount += l.previousAmount;
        currentAmount += l.currentAmount;
        cumulativePreviousAmount += l.cumulativePreviousAmount;
        cumulativeCurrentAmount += l.cumulativeCurrentAmount;
      }
      const deltaAbs = currentAmount - previousAmount;
      const cumulativeDeltaAbs = cumulativeCurrentAmount - cumulativePreviousAmount;
      return {
        categoryId: cat.id,
        categoryName: cat.name,
        categoryColor: cat.color ?? "#9ca3af",
        previousAmount,
        currentAmount,
        deltaAbs,
        // Match rowsToDeltas' leaf formula (signed denominator) so a single-child
        // parent shows the same % as its child.
        deltaPct: previousAmount !== 0 ? (deltaAbs / previousAmount) * 100 : null,
        cumulativePreviousAmount,
        cumulativeCurrentAmount,
        cumulativeDeltaAbs,
        cumulativeDeltaPct:
          cumulativePreviousAmount !== 0
            ? (cumulativeDeltaAbs / cumulativePreviousAmount) * 100
            : null,
        parent_id: parentId,
        is_parent: true,
        depth,
        category_type: typeOf(cat),
      };
    },
    // A parent with both children and its own transactions surfaces the direct
    // spend as a "(direct)" leaf (mirrors getBudgetVsActualData).
    decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
    // Orphans keep their own already-computed delta; only the hierarchy fields
    // are stamped. Uncategorized rows (category_type undefined) → 'expense'.
    makeOrphan: (d) => ({
      ...d,
      parent_id: null,
      is_parent: false,
      depth: 0,
      category_type: d.category_type ?? "expense",
    }),
    sortKey: (row) => Math.abs(row.deltaAbs),
    isSubtotal: (row) => row.is_parent === true,
    sectionOf: (row) => row.category_type ?? "expense",
    sectionOrder: COMPARE_TYPE_ORDER,
  });
}

// --- Trends hierarchy (Issue #264) ---

/**
 * Turns the flat per-category monthly leaves of `getCategoryOverTime` into a
 * parent/child tree with subtotal (`is_parent`) rows — the id-keyed sidecar to
 * the name-keyed pivot. Another thin specialisation of `buildLeafDrivenTree`
 * (Issue #263): the generic skeleton drives the hierarchy walk, sibling
 * ordering, `MAX_TREE_DEPTH` cycle guard and the income → expense → transfer
 * section sort (reusing `COMPARE_TYPE_ORDER`, income-first since #253); this only
 * supplies the per-month magnitude arithmetic. A subtotal's `monthly[i]` is the
 * sum of its descendant leaves' `monthly[i]`, so section subtotals and the
 * result lines can be read straight off the leaves. `monthCount` fixes the
 * length of every subtotal's series so it stays index-aligned with the pivot.
 *
 * Exported for unit testing.
 */
export function buildOverTimeTree(
  leaves: OverTimeRow[],
  categories: TreeCatMeta[],
  monthCount: number,
): OverTimeRow[] {
  const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";

  return buildLeafDrivenTree<OverTimeRow>(leaves, {
    categories,
    categoryIdOf: (l) => l.categoryId,
    makeLeaf: (cat, l, parentId, depth) => ({
      ...l,
      categoryColor: cat.color ?? l.categoryColor,
      parent_id: parentId,
      is_parent: false,
      depth,
      category_type: typeOf(cat),
    }),
    makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
      const monthly = new Array<number>(monthCount).fill(0);
      let total = 0;
      for (const dl of descendantLeaves) {
        for (let i = 0; i < monthCount; i++) monthly[i] += dl.monthly[i] ?? 0;
        total += dl.total;
      }
      return {
        categoryId: cat.id,
        categoryName: cat.name,
        categoryColor: cat.color ?? "#9ca3af",
        monthly,
        total,
        parent_id: parentId,
        is_parent: true,
        depth,
        category_type: typeOf(cat),
      };
    },
    // A parent with both children and its own transactions surfaces the direct
    // spend as a "(direct)" leaf (mirrors buildCompareTree / getBudgetVsActualData).
    decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
    // Orphans (Uncategorized / hard-deleted) keep their own series; only the
    // hierarchy fields are stamped. No metadata → default 'expense'.
    makeOrphan: (l) => ({
      ...l,
      parent_id: null,
      is_parent: false,
      depth: 0,
      category_type: l.category_type ?? "expense",
    }),
    sortKey: (row) => Math.abs(row.total),
    isSubtotal: (row) => row.is_parent === true,
    sectionOf: (row) => row.category_type ?? "expense",
    sectionOrder: COMPARE_TYPE_ORDER,
  });
}

function previousMonth(year: number, month: number): { year: number; month: number } {
  if (month === 1) return { year: year - 1, month: 12 };
  return { year, month: month - 1 };
}

/**
 * Shared aggregation for the real-vs-real compare (both MoM and YoY drive the
 * exact same query — only the eight date bounds differ). Four date buckets
 * ($1..$8): monthly current/previous and cumulative current/previous.
 *
 * This report is an INCOME STATEMENT ("analyse de résultat", Issue #253), not a
 * pure expense report: revenues are surfaced alongside expenses so the compare
 * can show a result (revenues − expenses). Per-category sign convention:
 *   - `income` and `transfer` categories use a signed SUM(t.amount). Income
 *     credits stay positive; a `transfer` debit/credit pair (e.g. "Paiement CC":
 *     -500 out, +500 in) nets to ~0 (Issue #243 — a money move, not spending).
 *   - Every other type keeps the expense-report behavior: only outflows
 *     (amount < 0) count, summed as positive magnitudes via ABS.
 *
 * The WHERE admits outflows OR any income/transfer row, so income and transfer
 * *credits* survive the `amount < 0` filter — without it a pure-income category
 * would be dropped entirely and a transfer could never net to zero. Expense
 * rows still contribute only their outflows, byte-identical to before. The two
 * result lines (before/after transfers) are computed in ComparePeriodTable from
 * the per-type subtotals, not here. Uncategorized rows (c.type NULL) → 'expense'.
 *
 * `accountIds` (Issue #273) adds an optional `t.source_id IN (...)` clause —
 * one bound placeholder per id, starting at $9 (the 8 date bounds occupy
 * $1-$8). Omitted/empty → no clause at all, so the query text and results stay
 * byte-identical to the pre-#273 behavior.
 */
function compareDeltaSql(accountIds?: number[]): string {
  const accountPlaceholders = inPlaceholders(accountIds, 9);
  const accountFilter = accountPlaceholders ? ` AND t.source_id IN (${accountPlaceholders})` : "";
  return `SELECT
        t.category_id,
        COALESCE(c.name, 'Uncategorized') AS category_name,
        COALESCE(c.color, '#9ca3af')      AS category_color,
        COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total,
        COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total,
        COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total,
        COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total
      FROM transactions t
      LEFT JOIN categories c ON t.category_id = c.id
      WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))
        AND (
          (t.date >= $1 AND t.date <= $2)
          OR (t.date >= $3 AND t.date <= $4)
          OR (t.date >= $5 AND t.date <= $6)
          OR (t.date >= $7 AND t.date <= $8)
        )${accountFilter}
      GROUP BY t.category_id, category_name, category_color
      ORDER BY ABS(month_current_total - month_previous_total) DESC`;
}

/**
 * Month-over-month expense delta by category. Returns both a monthly view
 * (reference month vs immediately-previous month) and a cumulative YTD view
 * (Jan→refMonth of refYear vs Jan→prevMonth of refYear — i.e. "cumulative
 * progress through end of last month" vs "cumulative progress through end of
 * this month"). All SQL parameterised. `accountIds` (Issue #273) optionally
 * scopes the whole report to a subset of import sources (`transactions.source_id`).
 */
export async function getCompareMonthOverMonth(
  year: number,
  month: number,
  accountIds?: number[],
): Promise<CategoryDelta[]> {
  const db = await getDb();
  const { start: curStart, end: curEnd } = monthBoundaries(year, month);
  const prev = previousMonth(year, month);
  const { start: prevStart, end: prevEnd } = monthBoundaries(prev.year, prev.month);

  // Cumulative window: from Jan 1st of the reference year up to the end of
  // the month we care about. For MoM, the "previous" cumulative window ends
  // at the end of the previous month within the SAME year — so Jan only has
  // no cumulative-previous (empty window). We still bound the previous-year
  // handling: when month === 1 the previous month is in year - 1, so the
  // cumulative-previous window sits entirely in year - 1 (Jan → Dec).
  const cumCurrentStart = `${year}-01-01`;
  const cumCurrentEnd = curEnd;
  const cumPreviousStart = `${prev.year}-01-01`;
  const cumPreviousEnd = prevEnd;

  const deltaParams: unknown[] = [
    curStart, curEnd,
    prevStart, prevEnd,
    cumCurrentStart, cumCurrentEnd,
    cumPreviousStart, cumPreviousEnd,
  ];
  if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);

  // Delta select stays first so its params/SQL remain `mock.calls[0]`; the
  // category metadata (for the hierarchy) is fetched alongside. `?? []` guards
  // under-specified mocks — db.select never returns undefined in production.
  const [rows, cats] = await Promise.all([
    db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
    db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
  ]);
  return buildCompareTree(rowsToDeltas(rows), cats ?? []);
}

/**
 * Year-over-year expense delta by category. Returns both a monthly view
 * (reference month vs same month of the previous year) and a cumulative YTD
 * view (Jan→refMonth of refYear vs Jan→refMonth of refYear - 1). Uses the
 * reference year's December as the "current month" when no explicit
 * reference month is provided; callers typically pass the user's chosen
 * reference month. All SQL parameterised. `accountIds` (Issue #273) optionally
 * scopes the whole report to a subset of import sources (`transactions.source_id`).
 */
export async function getCompareYearOverYear(
  year: number,
  month: number = 12,
  accountIds?: number[],
): Promise<CategoryDelta[]> {
  const db = await getDb();
  const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
  const { start: prevMonthStart, end: prevMonthEnd } = monthBoundaries(year - 1, month);

  const cumCurrentStart = `${year}-01-01`;
  const cumCurrentEnd = curMonthEnd;
  const cumPreviousStart = `${year - 1}-01-01`;
  const cumPreviousEnd = prevMonthEnd;

  const deltaParams: unknown[] = [
    curMonthStart, curMonthEnd,
    prevMonthStart, prevMonthEnd,
    cumCurrentStart, cumCurrentEnd,
    cumPreviousStart, cumPreviousEnd,
  ];
  if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);

  // See getCompareMonthOverMonth: delta select first, categories alongside.
  const [rows, cats] = await Promise.all([
    db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
    db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
  ]);
  return buildCompareTree(rowsToDeltas(rows), cats ?? []);
}

// --- Category zoom (Issue #74) ---

/**
 * Recursive CTE fragment bounded to `depth < 5` so a corrupted `parent_id`
 * loop (A → B → A) can never spin forever. The depth budget is intentionally
 * low: real category trees rarely exceed 3 levels.
 */
const CATEGORY_TREE_CTE = `
  WITH RECURSIVE cat_tree(id, depth) AS (
    SELECT id, 0 FROM categories WHERE id = $1
    UNION ALL
    SELECT c.id, ct.depth + 1
      FROM categories c
      JOIN cat_tree ct ON c.parent_id = ct.id
     WHERE ct.depth < 5
  )
`;

/**
 * Returns the zoom-on-category report for the given category id.
 *
 * When `includeSubcategories` is true the recursive CTE walks down the
 * category tree (capped at 5 levels) and aggregates matching rows. When
 * false, only direct transactions on `categoryId` are considered. Every
 * query is parameterised; the category id is never interpolated.
 */
export async function getCategoryZoom(
  categoryId: number,
  dateFrom: string,
  dateTo: string,
  includeSubcategories: boolean = true,
): Promise<CategoryZoomData> {
  const db = await getDb();

  const categoryFilter = includeSubcategories
    ? `${CATEGORY_TREE_CTE}
       SELECT t.id, t.date, t.description, t.amount, t.category_id
         FROM transactions t
        WHERE t.category_id IN (SELECT id FROM cat_tree)
          AND t.date >= $2 AND t.date <= $3`
    : `SELECT t.id, t.date, t.description, t.amount, t.category_id
         FROM transactions t
        WHERE t.category_id = $1
          AND t.date >= $2 AND t.date <= $3`;

  // 1. Transactions (with join for display)
  const txRows = await db.select<RecentTransaction[]>(
    `${includeSubcategories ? CATEGORY_TREE_CTE : ""}
     SELECT t.id, t.date, t.description, t.amount, c.name AS category_name, c.color AS category_color
       FROM transactions t
       LEFT JOIN categories c ON t.category_id = c.id
      WHERE ${includeSubcategories
        ? "t.category_id IN (SELECT id FROM cat_tree)"
        : "t.category_id = $1"}
        AND t.date >= $2 AND t.date <= $3
      ORDER BY ABS(t.amount) DESC
      LIMIT 500`,
    [categoryId, dateFrom, dateTo],
  );

  // 2. Rollup total (sum of absolute amounts of matching rows)
  const totalRows = await db.select<Array<{ rollup: number | null }>>(
    `${categoryFilter.replace("SELECT t.id, t.date, t.description, t.amount, t.category_id", "SELECT COALESCE(SUM(ABS(t.amount)), 0) AS rollup")}`,
    [categoryId, dateFrom, dateTo],
  );
  const rollupTotal = Number(totalRows[0]?.rollup ?? 0);

  // 3. Breakdown by direct child (only meaningful when includeSubcategories is true)
  const byChild: CategoryZoomChild[] = [];
  if (includeSubcategories) {
    const childRows = await db.select<
      Array<{ child_id: number; child_name: string; child_color: string; total: number | null }>
    >(
      `SELECT child.id AS child_id,
              child.name AS child_name,
              COALESCE(child.color, '#9ca3af') AS child_color,
              COALESCE(SUM(ABS(t.amount)), 0) AS total
         FROM categories child
         LEFT JOIN transactions t ON t.category_id = child.id
          AND t.date >= $2 AND t.date <= $3
        WHERE child.parent_id = $1
        GROUP BY child.id, child.name, child.color
        ORDER BY total DESC`,
      [categoryId, dateFrom, dateTo],
    );
    for (const r of childRows) {
      byChild.push({
        categoryId: r.child_id,
        categoryName: r.child_name,
        categoryColor: r.child_color,
        total: Number(r.total ?? 0),
      });
    }
  }

  // 4. Monthly evolution across the window
  const evolutionRows = await db.select<Array<{ month: string; total: number | null }>>(
    `${includeSubcategories ? CATEGORY_TREE_CTE : ""}
     SELECT strftime('%Y-%m', t.date) AS month,
            COALESCE(SUM(ABS(t.amount)), 0) AS total
       FROM transactions t
      WHERE ${includeSubcategories
        ? "t.category_id IN (SELECT id FROM cat_tree)"
        : "t.category_id = $1"}
        AND t.date >= $2 AND t.date <= $3
      GROUP BY month
      ORDER BY month ASC`,
    [categoryId, dateFrom, dateTo],
  );
  const monthlyEvolution: CategoryZoomEvolutionPoint[] = evolutionRows.map((r) => ({
    month: r.month,
    total: Number(r.total ?? 0),
  }));

  return {
    rollupTotal,
    byChild,
    monthlyEvolution,
    transactions: txRows,
  };
}

// --- Cartes dashboard (Issue #97) ---

/**
 * Signed month shift. Exported for unit tests.
 *   shiftMonth(2026, 1, -1) -> { year: 2025, month: 12 }
 *   shiftMonth(2026, 4, -24) -> { year: 2024, month: 4 }
 */
export function shiftMonth(
  year: number,
  month: number,
  offset: number,
): { year: number; month: number } {
  const total = year * 12 + (month - 1) + offset;
  return {
    year: Math.floor(total / 12),
    month: (total % 12) + 1,
  };
}

function monthKey(year: number, month: number): string {
  return `${year}-${String(month).padStart(2, "0")}`;
}

function extractDelta(
  current: number | null,
  previous: number | null,
): { abs: number | null; pct: number | null } {
  if (current === null || previous === null) return { abs: null, pct: null };
  const abs = current - previous;
  const pct = previous === 0 ? null : (abs / previous) * 100;
  return { abs, pct };
}

function buildKpi(
  sparkline: CartesSparklinePoint[],
  current: number | null,
  previousMonth: number | null,
  previousYear: number | null,
): CartesKpi {
  const mom = extractDelta(current, previousMonth);
  const yoy = extractDelta(current, previousYear);
  return {
    current,
    previousMonth,
    previousYear,
    deltaMoMAbs: mom.abs,
    deltaMoMPct: mom.pct,
    deltaYoYAbs: yoy.abs,
    deltaYoYPct: yoy.pct,
    sparkline,
  };
}

interface RawMonthFlow {
  month: string;
  income: number | null;
  expenses: number | null;
}

async function fetchMonthlyFlows(
  dateFrom: string,
  dateTo: string,
  accountIds?: number[],
): Promise<RawMonthFlow[]> {
  const db = await getDb();
  const params: unknown[] = [dateFrom, dateTo];
  const accountPlaceholders = inPlaceholders(accountIds, 3);
  const accountClause = accountPlaceholders
    ? ` AND source_id IN (${accountPlaceholders})`
    : "";
  if (accountPlaceholders) params.push(...accountIds!);
  return db.select<RawMonthFlow[]>(
    `SELECT
        strftime('%Y-%m', date) AS month,
        COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
        ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
      FROM transactions
      WHERE date >= $1 AND date <= $2${accountClause}
      GROUP BY month
      ORDER BY month ASC`,
    params,
  );
}

interface RawSeasonalityRow {
  year: number;
  amount: number | null;
}

async function fetchSeasonality(
  month: number,
  yearFrom: number,
  yearTo: number,
  accountIds?: number[],
): Promise<RawSeasonalityRow[]> {
  const db = await getDb();
  const mm = String(month).padStart(2, "0");
  const params: unknown[] = [mm, yearFrom, yearTo];
  const accountPlaceholders = inPlaceholders(accountIds, 4);
  const accountClause = accountPlaceholders
    ? ` AND source_id IN (${accountPlaceholders})`
    : "";
  if (accountPlaceholders) params.push(...accountIds!);
  return db.select<RawSeasonalityRow[]>(
    `SELECT
        CAST(strftime('%Y', date) AS INTEGER) AS year,
        ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS amount
      FROM transactions
      WHERE strftime('%m', date) = $1
        AND CAST(strftime('%Y', date) AS INTEGER) >= $2
        AND CAST(strftime('%Y', date) AS INTEGER) <= $3${accountClause}
      GROUP BY year
      ORDER BY year DESC`,
    params,
  );
}

/**
 * Cartes dashboard snapshot. Single entry point that returns every widget's
 * data for the Cartes report, computed against a reference (year, month).
 *
 * Layout (all concurrent):
 *   1. 25-month expense/income series (covers ref, MoM, YoY, 12-month flow,
 *      13-month sparklines without any extra round trips).
 *   2. Month-over-month category deltas for top movers (existing service).
 *   3. Year-over-year category deltas to seed the savings-rate YoY lookup
 *      via the monthly series instead of re-querying.
 *   4. Budget vs actual for the reference month.
 *   5. Seasonality: same calendar month across the two prior years.
 *
 * `accountIds` (Issues #273/#279) is forwarded to every sub-query so the whole
 * dashboard honours an active account filter: the top-movers
 * (`getCompareMonthOverMonth`) and budget (`getBudgetVsActualData`) sub-reports,
 * and the KPI/sparkline/seasonality series (`fetchMonthlyFlows`,
 * `fetchSeasonality`). Omitting it anywhere would let the dashboard silently mix
 * filtered and unfiltered figures on the same screen.
 */
export async function getCartesSnapshot(
  referenceYear: number,
  referenceMonth: number,
  mode: CartesKpiPeriodMode = "month",
  accountIds?: number[],
): Promise<CartesSnapshot> {
  // Date window: 25 months back from the reference to cover YoY + a 13-month
  // sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
  const windowStart = shiftMonth(referenceYear, referenceMonth, -24);
  const { start: windowStartIso } = monthBoundaries(windowStart.year, windowStart.month);
  const { end: refEnd } = monthBoundaries(referenceYear, referenceMonth);

  // Seasonality range: previous 2 years for the same calendar month.
  const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
    fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1, accountIds),
    fetchMonthlyFlows(windowStartIso, refEnd, accountIds),
    getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
    getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
  ]);

  // Index the flow rows by month for O(1) lookup, then fill missing months
  // with zeroes so downstream consumers get a contiguous series.
  const flowByMonth = new Map<string, { income: number; expenses: number }>();
  for (const r of flowRows) {
    flowByMonth.set(r.month, {
      income: Number(r.income ?? 0),
      expenses: Number(r.expenses ?? 0),
    });
  }

  const buildSeries = (count: number): CartesMonthFlow[] => {
    const series: CartesMonthFlow[] = [];
    for (let i = count - 1; i >= 0; i--) {
      const { year: y, month: m } = shiftMonth(referenceYear, referenceMonth, -i);
      const key = monthKey(y, m);
      const row = flowByMonth.get(key);
      const income = row?.income ?? 0;
      const expenses = row?.expenses ?? 0;
      series.push({ month: key, income, expenses, net: income - expenses });
    }
    return series;
  };

  // 13-month sparklines for each KPI (reference month + 12 prior).
  const sparkSeries = buildSeries(13);
  const incomeSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
    month: p.month,
    value: p.income,
  }));
  const expensesSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
    month: p.month,
    value: p.expenses,
  }));
  const netSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
    month: p.month,
    value: p.net,
  }));
  const savingsSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({
    month: p.month,
    value: p.income > 0 ? (p.net / p.income) * 100 : 0,
  }));

  // Compute MoM / YoY values directly from `flowByMonth` (which preserves the
  // "missing" distinction). The sparkline fills gaps with zero for display,
  // but deltas must remain null when the comparison month has no data.
  const refKey = monthKey(referenceYear, referenceMonth);
  const momMeta = shiftMonth(referenceYear, referenceMonth, -1);
  const momKey = monthKey(momMeta.year, momMeta.month);
  const yoyMeta = { year: referenceYear - 1, month: referenceMonth };
  const yoyKey = monthKey(yoyMeta.year, yoyMeta.month);

  // Monthly aggregates — the "month" mode default behavior.
  const refRow = flowByMonth.get(refKey);
  const monthRefIncome = refRow?.income ?? 0;
  const monthRefExpenses = refRow?.expenses ?? 0;
  const monthRefNet = monthRefIncome - monthRefExpenses;
  // Savings rate is undefined when income is zero — expose as null rather than
  // rendering a misleading "0 %" in the UI.
  const monthRefSavings = monthRefIncome > 0 ? (monthRefNet / monthRefIncome) * 100 : null;

  const momRow = flowByMonth.get(momKey);
  const monthMomIncome = momRow ? momRow.income : null;
  const monthMomExpenses = momRow ? momRow.expenses : null;
  const monthMomNet = momRow ? momRow.income - momRow.expenses : null;
  const monthMomSavings =
    momRow && momRow.income > 0 ? ((momRow.income - momRow.expenses) / momRow.income) * 100 : null;

  const yoyRow = flowByMonth.get(yoyKey);
  const monthYoyIncome = yoyRow ? yoyRow.income : null;
  const monthYoyExpenses = yoyRow ? yoyRow.expenses : null;
  const monthYoyNet = yoyRow ? yoyRow.income - yoyRow.expenses : null;
  const monthYoySavings =
    yoyRow && yoyRow.income > 0 ? ((yoyRow.income - yoyRow.expenses) / yoyRow.income) * 100 : null;

  // YTD aggregates — sum monthly flows from January of a given year up to (and
  // including) `upToMonth`. Reuses the already-fetched `flowByMonth` map, so
  // no additional SQL round trip is required. Missing months contribute zero
  // to the sum (unlike monthly deltas which preserve the null distinction).
  const sumYtd = (year: number, upToMonth: number): { income: number; expenses: number } => {
    let income = 0;
    let expenses = 0;
    for (let m = 1; m <= upToMonth; m++) {
      const row = flowByMonth.get(monthKey(year, m));
      if (row) {
        income += row.income;
        expenses += row.expenses;
      }
    }
    return { income, expenses };
  };

  // Current YTD: Jan→refMonth of refYear.
  const ytdCurrent = sumYtd(referenceYear, referenceMonth);
  const ytdRefIncome = ytdCurrent.income;
  const ytdRefExpenses = ytdCurrent.expenses;
  const ytdRefNet = ytdRefIncome - ytdRefExpenses;
  const ytdRefSavings = ytdRefIncome > 0 ? (ytdRefNet / ytdRefIncome) * 100 : null;

  // YTD MoM: previous YTD window is Jan→(refMonth-1) of refYear. When the
  // reference month is January, there is no prior window inside the same
  // year, so MoM deltas must be null (not zero).
  let ytdMomIncome: number | null = null;
  let ytdMomExpenses: number | null = null;
  let ytdMomNet: number | null = null;
  let ytdMomSavings: number | null = null;
  if (referenceMonth > 1) {
    const prev = sumYtd(referenceYear, referenceMonth - 1);
    ytdMomIncome = prev.income;
    ytdMomExpenses = prev.expenses;
    ytdMomNet = prev.income - prev.expenses;
    ytdMomSavings = prev.income > 0 ? ((prev.income - prev.expenses) / prev.income) * 100 : null;
  }

  // YTD YoY: same window (Jan→refMonth) of the previous calendar year.
  const ytdPrevYear = sumYtd(referenceYear - 1, referenceMonth);
  const ytdYoyIncome = ytdPrevYear.income;
  const ytdYoyExpenses = ytdPrevYear.expenses;
  const ytdYoyNet = ytdPrevYear.income - ytdPrevYear.expenses;
  const ytdYoySavings =
    ytdPrevYear.income > 0 ? (ytdYoyNet / ytdPrevYear.income) * 100 : null;

  // Select the KPI aggregate set based on the requested mode. Sparklines are
  // always the 13-month monthly series regardless of mode — the toggle only
  // affects the "current" value and its MoM/YoY comparisons.
  const isYtd = mode === "ytd";
  const refIncome = isYtd ? ytdRefIncome : monthRefIncome;
  const refExpenses = isYtd ? ytdRefExpenses : monthRefExpenses;
  const refNet = isYtd ? ytdRefNet : monthRefNet;
  const refSavings = isYtd ? ytdRefSavings : monthRefSavings;

  const kpiMomIncome = isYtd ? ytdMomIncome : monthMomIncome;
  const kpiMomExpenses = isYtd ? ytdMomExpenses : monthMomExpenses;
  const kpiMomNet = isYtd ? ytdMomNet : monthMomNet;
  const kpiMomSavings = isYtd ? ytdMomSavings : monthMomSavings;

  const kpiYoyIncome = isYtd ? ytdYoyIncome : monthYoyIncome;
  const kpiYoyExpenses = isYtd ? ytdYoyExpenses : monthYoyExpenses;
  const kpiYoyNet = isYtd ? ytdYoyNet : monthYoyNet;
  const kpiYoySavings = isYtd ? ytdYoySavings : monthYoySavings;

  const incomeKpi = buildKpi(incomeSpark, refIncome, kpiMomIncome, kpiYoyIncome);
  const expensesKpi = buildKpi(expensesSpark, refExpenses, kpiMomExpenses, kpiYoyExpenses);
  const netKpi = buildKpi(netSpark, refNet, kpiMomNet, kpiYoyNet);
  const savingsKpi = buildKpi(savingsSpark, refSavings, kpiMomSavings, kpiYoySavings);

  // 12-month income vs expenses series for the overlay chart.
  const flow12Months = buildSeries(12);

  // Top movers: biggest MoM increases / decreases. `momRows` now carries the
  // compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
  // parent group can't double-count against its own leaves. Keep only EXPENSE
  // leaves: since Issue #253 broadened compareDeltaSql() to surface income (and
  // net transfers), momRows now also carries revenue rows — and this card is a
  // spending view whose colours read "up = red" (more spending is bad), so a
  // salary rise must not appear under "biggest increases" in red. Mirror the
  // expense-only filter ComparePeriodChart uses. The surviving expense leaves
  // are byte-identical to the pre-#253 flat output. `momRows` are sorted by
  // absolute delta already; filter out near-zero noise and split by sign.
  const significantMovers = momRows.filter(
    (r) =>
      !r.is_parent &&
      (r.category_type ?? "expense") === "expense" &&
      r.deltaAbs !== 0 &&
      (r.previousAmount > 0 || r.currentAmount > 0),
  );
  // Project the richer CategoryDelta shape down to the narrower CartesTopMover
  // shape so the Cartes dashboard keeps its stable contract regardless of how
  // many views (monthly / cumulative) the Compare service exposes.
  const toCartesMover = (r: CategoryDelta): CartesTopMover => ({
    categoryId: r.categoryId,
    categoryName: r.categoryName,
    categoryColor: r.categoryColor,
    previousAmount: r.previousAmount,
    currentAmount: r.currentAmount,
    deltaAbs: r.deltaAbs,
    deltaPct: r.deltaPct,
  });
  const topMoversUp: CartesTopMover[] = significantMovers
    .filter((r) => r.deltaAbs > 0)
    .sort((a, b) => b.deltaAbs - a.deltaAbs)
    .slice(0, 5)
    .map(toCartesMover);
  const topMoversDown: CartesTopMover[] = significantMovers
    .filter((r) => r.deltaAbs < 0)
    .sort((a, b) => a.deltaAbs - b.deltaAbs)
    .slice(0, 5)
    .map(toCartesMover);

  // Budget adherence — only expense categories with a non-zero budget count.
  // Both `monthActual` and `monthBudget` are signed (expense categories are
  // stored with a negative sign by `budgetService`), so all comparisons and
  // deltas on the adherence card must go through `Math.abs` — otherwise the
  // `> 0` filter rejects every expense row and the card shows "no budgeted
  // categories this month" even when budgets exist.
  const budgetedExpenseRows = budgetRows.filter(
    (r) => r.category_type === "expense" && Math.abs(r.monthBudget) > 0 && !r.is_parent,
  );
  const budgetsInTarget = budgetedExpenseRows.filter(
    (r) => Math.abs(r.monthActual) <= Math.abs(r.monthBudget),
  ).length;

  const overruns: CartesBudgetWorstOverrun[] = budgetedExpenseRows
    .map((r) => {
      const actual = Math.abs(r.monthActual);
      const budget = Math.abs(r.monthBudget);
      const overrunAbs = actual - budget;
      const overrunPct = budget > 0 ? (overrunAbs / budget) * 100 : null;
      return {
        categoryId: r.category_id,
        categoryName: r.category_name,
        categoryColor: r.category_color,
        budget,
        actual,
        overrunAbs,
        overrunPct,
      };
    })
    .filter((r) => r.overrunAbs > 0)
    .sort((a, b) => b.overrunAbs - a.overrunAbs)
    .slice(0, 3);

  const budgetAdherence: CartesBudgetAdherence = {
    categoriesInTarget: budgetsInTarget,
    categoriesTotal: budgetedExpenseRows.length,
    worstOverruns: overruns,
  };

  // Seasonality — average of the same calendar month across the previous
  // two years. If no data, average stays null.
  const historicalYears: CartesSeasonalityYear[] = seasonalityRows.map((r) => ({
    year: Number(r.year),
    amount: Number(r.amount ?? 0),
  }));
  const historicalAverage = historicalYears.length
    ? historicalYears.reduce((sum, r) => sum + r.amount, 0) / historicalYears.length
    : null;
  // Seasonality compares the reference month's expenses to the same calendar
  // month in previous years — it is intentionally insensitive to the KPI
  // period toggle, so always use the monthly expenses (not the YTD figure).
  const referenceAmount = monthRefExpenses;
  const deviationPct =
    historicalAverage !== null && historicalAverage > 0
      ? ((referenceAmount - historicalAverage) / historicalAverage) * 100
      : null;

  const seasonality: CartesSeasonality = {
    referenceAmount,
    historicalYears,
    historicalAverage,
    deviationPct,
  };

  return {
    referenceYear,
    referenceMonth,
    kpis: {
      income: incomeKpi,
      expenses: expensesKpi,
      net: netKpi,
      savingsRate: savingsKpi,
    },
    flow12Months,
    topMoversUp,
    topMoversDown,
    budgetAdherence,
    seasonality,
  };
}
