import { useState, useEffect, useMemo, useCallback, useDeferredValue, type ReactNode } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2, Download, Printer, FileSpreadsheet, CalendarDays } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker, useSettings, useSimplefinStatus, useSaveSettings } from '@/hooks/useQueries';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
import { cn, localDateString, parseUtc, settingEnabled } from '@/lib/utils';
import { downloadFile } from '@/lib/download';
import { formatUSD, asDollars, type Dollars } from '@/lib/money';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import SearchFilterPanel from '@/components/SearchFilterPanel';
import { Skeleton } from '@/components/ui/Skeleton';
import {
  Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import {
  DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
  DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
import CashFlowCard from '@/components/tracker/CashFlowCard';
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
import DriftInsightPanel from '@/components/tracker/DriftInsightPanel';
import {
  MONTHS, FILTER_ALL,
  paymentDateForTrackerMonth, amountSearchText, rowEffectiveStatus, rowIsPaid, rowIsDebt,
  TRACKER_SORT_DEFAULT, TRACKER_SORT_ASC, TRACKER_SORT_DESC, TRACKER_SORT_OPTIONS,
  TRACKER_SORT_DEFAULT_DIRS, TRACKER_SORT_LABELS,
  normalizeTrackerSortKey, normalizeTrackerSortDir, sortTrackerRows,
} from '@/lib/trackerUtils';
import { TRACKER_TABLE_COLUMNS, parseTrackerTableColumns, trackerTableColumnsToSetting } from '@/lib/trackerTableColumns';
import { FilterChip } from '@/components/tracker/FilterChip';
import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards';
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket';
import IncomeBreakdownModal from '@/components/IncomeBreakdownModal';
import type { TrackerRow, TrackerSummary, BankTracking, DriftBill, Bill, Category } from '@/types';

function fmt(v: number | null | undefined): string {
  return formatUSD(asDollars(Number(v) || 0));
}

interface LateAttr {
  payment_id: number;
  bill_name?: string;
  amount?: number;
  original_date: string;
  suggested_date: string;
}

interface SyncResult {
  auto_matched?: number;
  transactions_new?: number;
  matched_bills?: string[];
  late_attributions?: LateAttr[];
}

interface BankSyncStatus {
  enabled?: boolean;
  has_connections?: boolean;
  has_merchant_rules?: boolean;
}

interface EditBillData {
  bill: Bill | null;
  initialBill?: Partial<Bill>;
  categories: Category[];
}

interface TrackerFilters {
  category: string;
  cycle: string;
  autopay: boolean;
  firstBucket: boolean;
  fifteenthBucket: boolean;
  unpaid: boolean;
  overdue: boolean;
  debt: boolean;
}

type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'unpaid' | 'overdue' | 'debt';
type Patch = Record<string, string | number | boolean | null | undefined>;

function fmtBalanceAge(isoStr: string | null | undefined): string | null {
  const d = parseUtc(isoStr);
  if (!d) return null;
  return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}

function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }: {
  bankTracking?: BankTracking;
  onSnooze: () => void;
  onIgnore: () => void;
  busy?: boolean;
}) {
  if (!bankTracking?.enabled) return null;
  const isPositive = Number(bankTracking.remaining ?? 0) >= 0;

  return (
    <div className={cn(
      'flex flex-col gap-3 rounded-xl border px-4 py-3 text-xs shadow-sm sm:flex-row sm:items-center sm:justify-between',
      isPositive
        ? 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400'
        : 'border-destructive/20 bg-destructive/5 text-destructive',
    )}>
      <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
        <span className="relative flex h-2 w-2 shrink-0">
          <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
          <span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
        </span>
        <span className="truncate font-semibold">{bankTracking.account_name}</span>
        <span className="text-muted-foreground">·</span>
        <span>{fmt(bankTracking.balance ?? 0)} balance</span>
        {bankTracking.last_updated && (
          <>
            <span className="text-muted-foreground">·</span>
            <span className="text-muted-foreground">as of {fmtBalanceAge(bankTracking.last_updated)}</span>
          </>
        )}
        {Number(bankTracking.pending_payments ?? 0) > 0 && (
          <>
            <span className="text-muted-foreground">·</span>
            <span className="text-amber-600 dark:text-amber-400">{fmt(bankTracking.pending_payments)} pending</span>
          </>
        )}
      </div>
      <div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
        <span className="font-semibold tabular-nums">
          {Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected
        </span>
        <Button
          type="button"
          size="sm"
          variant="ghost"
          disabled={busy}
          onClick={onSnooze}
          className="h-7 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
        >
          <BellOff className="h-3 w-3" />
          Snooze
        </Button>
        <Button
          type="button"
          size="sm"
          variant="ghost"
          disabled={busy}
          onClick={onIgnore}
          className="h-7 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
        >
          <EyeOff className="h-3 w-3" />
          Ignore
        </Button>
      </div>
    </div>
  );
}

function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }: {
  visibleColumns: string[];
  onColumnToggle?: (key: string, checked: boolean) => void;
  saving?: boolean;
}) {
  const visibleSet = new Set(visibleColumns);

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          type="button"
          size="sm"
          variant="outline"
          className="h-8 gap-1.5 px-2.5 text-xs"
          disabled={saving}
        >
          <Settings2 className="h-3.5 w-3.5" />
          <span className="hidden sm:inline">Columns</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-56">
        <DropdownMenuLabel>Table columns</DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuCheckboxItem checked disabled>
          Bill
        </DropdownMenuCheckboxItem>
        {TRACKER_TABLE_COLUMNS.map(column => (
          <DropdownMenuCheckboxItem
            key={column.key}
            checked={visibleSet.has(column.key)}
            onSelect={event => event.preventDefault()}
            onCheckedChange={checked => onColumnToggle?.(column.key, checked)}
          >
            {column.label}
          </DropdownMenuCheckboxItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

// ── Main page ──────────────────────────────────────────────────────────────
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }: {
  attr?: LateAttr | null;
  remaining: number;
  busy?: boolean;
  onAccept: (attr: LateAttr) => void;
  onDismiss: () => void;
}) {
  if (!attr) return null;
  const fmtDate = (d: string) => new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
  const priorMonth = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
  return (
    <Dialog open onOpenChange={onDismiss}>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle>Payment posted after month end</DialogTitle>
          <DialogDescription>
            A <strong>{attr.bill_name}</strong> payment of <strong>{fmt(attr.amount)}</strong> posted on{' '}
            <strong>{fmtDate(attr.original_date)}</strong> — after the previous month closed.
            Should it count for {priorMonth}?
          </DialogDescription>
        </DialogHeader>
        <div className="rounded-lg border border-border/60 bg-muted/30 p-3 space-y-1">
          <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">What this does</p>
          <p className="text-sm">Moves the paid date to <strong>{fmtDate(attr.suggested_date)}</strong> so it appears in the prior month's tracker. Amount and bank link are unchanged.</p>
        </div>
        {remaining > 0 && (
          <p className="text-xs text-muted-foreground">{remaining} more similar payment{remaining > 1 ? 's' : ''} to review after this.</p>
        )}
        <DialogFooter className="gap-2 sm:gap-0">
          <Button variant="outline" onClick={onDismiss} disabled={busy}>
            Keep as {fmtDate(attr.original_date).replace(/,?\s*\d{4}/, '').trim()}
          </Button>
          <Button onClick={() => onAccept(attr)} disabled={busy}>
            {busy ? 'Moving…' : `Count for ${priorMonth}`}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

// Client-side fallbacks for the tracker display settings, merged under whatever
// the server returns (an absent key falls back to "shown"). Mirrors the list in
// SettingsPage's "Tracker Layout".
const TRACKER_SETTING_DEFAULTS: Record<string, string> = {
  tracker_show_bank_projection_banner: 'true',
  tracker_bank_projection_banner_snoozed_until: '',
  tracker_show_search_sort: 'true',
  tracker_show_summary_cards: 'true',
  tracker_show_safe_to_spend: 'true',
  tracker_show_overdue_command_center: 'true',
  tracker_show_drift_insights: 'true',
  tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
};

export default function TrackerPage() {
  const [searchParams, setSearchParams] = useSearchParams();
  const navigateTo = useNavigate();
  const now = new Date();

  // All navigation + filter state lives in the URL so views are bookmarkable/shareable.
  const year   = Number(searchParams.get('year'))  || now.getFullYear();
  const month  = Number(searchParams.get('month')) || (now.getMonth() + 1);
  const search = searchParams.get('q') || '';
  // Stable identity so the memo that filters rows on `filters` doesn't recompute
  // every render (a new object literal each render would defeat it).
  const filters = useMemo<TrackerFilters>(() => ({
    category:        searchParams.get('fc') || FILTER_ALL,
    cycle:           searchParams.get('cy') || FILTER_ALL,
    autopay:         searchParams.get('ap') === '1',
    firstBucket:     searchParams.get('b1') === '1',
    fifteenthBucket: searchParams.get('b2') === '1',
    unpaid:          searchParams.get('un') === '1',
    overdue:         searchParams.get('ov') === '1',
    debt:            searchParams.get('de') === '1',
  }), [searchParams]);
  const sortKey = normalizeTrackerSortKey(searchParams.get('sort') || TRACKER_SORT_DEFAULT);
  const hasSort = sortKey !== TRACKER_SORT_DEFAULT;
  const sortDir = hasSort
    ? normalizeTrackerSortDir(searchParams.get('dir') || TRACKER_SORT_DEFAULT_DIRS[sortKey] || TRACKER_SORT_ASC)
    : TRACKER_SORT_ASC;

  // replace: true keeps history clean for rapid navigation (e.g. search keystrokes)
  const updateParams = useCallback((patch: Patch) => {
    setSearchParams(prev => {
      const next = new URLSearchParams(prev);
      Object.entries(patch).forEach(([k, v]) => {
        if (v == null || v === '' || v === false) next.delete(k);
        else next.set(k, v === true ? '1' : String(v));
      });
      return next;
    }, { replace: true });
  }, [setSearchParams]);

  const [bankSyncing,       setBankSyncing]       = useState(false);
  const [lateAttributions,  setLateAttributions] = useState<LateAttr[]>([]); // pending month-attribution prompts
  const [attrBusy,          setAttrBusy]         = useState<number | null>(null); // payment_id being resolved
  const [pinUpcoming,    setPinUpcoming]    = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
  const [editBillData, setEditBillData] = useState<EditBillData | null>(null);
  // Edit Starting Amounts modal: true when open, false when closed
  const [editStartingOpen, setEditStartingOpen] = useState(false);
  const [incomeModalOpen,  setIncomeModalOpen]  = useState(false);
  const [orderedRows, setOrderedRows] = useState<TrackerRow[] | null>(null);
  const [movingBillId, setMovingBillId] = useState<number | null>(null);
  const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();

  // Row to open in PaymentLedgerDialog via the overdue command center
  const [commandCenterPayRow, setCommandCenterPayRow] = useState<TrackerRow | null>(null);

  // Use React Query for data fetching
  const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
  const { data: driftDataRaw } = useDriftReport();
  const driftData = driftDataRaw as { bills?: DriftBill[] } | undefined;

  // Display settings + SimpleFIN status via React Query (cached/deduped like the
  // rest of the app), with a save mutation that handles optimistic + rollback.
  const settingsQuery = useSettings();
  const simplefinStatusQuery = useSimplefinStatus();
  const saveSettings = useSaveSettings();
  const trackerSettings = useMemo<Record<string, string>>(
    () => ({ ...TRACKER_SETTING_DEFAULTS, ...((settingsQuery.data as Record<string, string> | undefined) ?? {}) }),
    [settingsQuery.data],
  );
  const bankSyncStatus = (simplefinStatusQuery.data as BankSyncStatus | undefined) ?? null;
  const savingTrackerSetting = saveSettings.isPending;
  // Live cross-query refresh: a bill mutation invalidates the tracker AND the
  // overdue badge / drift report / bills list so the whole shell updates, not
  // just the one tracker query. Use this everywhere a row/payment mutation lands.
  const invalidateData = useInvalidateTrackerData();
  const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]);

  useEffect(() => {
    setOrderedRows(null);
    setMovingBillId(null);
  }, [dataUpdatedAt, year, month]);

  // Surface a settings-load failure (React Query swallows it into isError).
  // The page still renders with TRACKER_SETTING_DEFAULTS.
  useEffect(() => {
    if (settingsQuery.isError) toast.error("Couldn't load tracker display settings — showing defaults.");
  }, [settingsQuery.isError]);

  // Listen for late-attribution events fired by BillModal's single-bill sync
  useEffect(() => {
    function handler(e: Event) {
      const attrs = (e as CustomEvent<{ attributions?: LateAttr[] }>).detail?.attributions;
      if (Array.isArray(attrs) && attrs.length > 0) {
        setLateAttributions(prev => [...prev, ...attrs]);
      }
    }
    window.addEventListener('tracker:late-attributions', handler);
    return () => window.removeEventListener('tracker:late-attributions', handler);
  }, []);

  function adjacentMonth(delta: number) {
    let nm = month + delta;
    let ny = year;
    if (nm > 12) { ny += 1; nm = 1; }
    if (nm < 1)  { ny -= 1; nm = 12; }
    return { year: ny, month: nm };
  }

  function navigate(delta: number) {
    updateParams(adjacentMonth(delta));
  }

  // Prefetch the adjacent month on hover/focus so clicking prev/next is instant.
  const prefetchTracker = usePrefetchTracker();
  function prefetchAdjacent(delta: number) {
    const { year: ny, month: nm } = adjacentMonth(delta);
    prefetchTracker(ny, nm);
  }

  function bankSyncMessage(result: SyncResult): string {
    const matched   = result.auto_matched ?? 0;
    const newTx     = result.transactions_new ?? 0;
    const billNames = result.matched_bills ?? [];
    if (matched > 0 && billNames.length > 0) {
      return `Synced — ${billNames.join(', ')} ✓` +
        (matched > billNames.length ? ` (+${matched - billNames.length} more)` : '');
    }
    if (matched > 0) return `Synced — ${matched} payment${matched === 1 ? '' : 's'} matched`;
    if (newTx > 0)   return `Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`;
    return 'Synced — no new transactions';
  }

  async function handleBankSync() {
    setBankSyncing(true);
    // One toast that transitions loading → done, instead of a manual spinner +
    // a separate success/error toast.
    const promise = api.syncAllSources();
    toast.promise(promise, {
      loading: 'Syncing bank…',
      success: (result: unknown) => bankSyncMessage(result as SyncResult),
      error: (err: unknown) => (err as { message?: string })?.message || 'Bank sync failed',
    });
    try {
      const result = await promise as SyncResult;
      // Surface late-attribution prompts (payments that just crossed a month boundary)
      if ((result.late_attributions ?? []).length > 0) setLateAttributions(result.late_attributions ?? []);
      invalidateData();
    } catch {
      // toast.promise already surfaced the error
    } finally {
      setBankSyncing(false);
    }
  }

  function togglePinUpcoming() {
    setPinUpcoming(prev => {
      const next = !prev;
      localStorage.setItem('tracker_pin_upcoming', String(next));
      return next;
    });
  }

  // Show sync button when SimpleFIN is enabled, connected, and user has matching rules
  const showBankSync = bankSyncStatus?.enabled &&
    bankSyncStatus?.has_connections &&
    bankSyncStatus?.has_merchant_rules;

  async function handleOpenEditBill(row: TrackerRow) {
    try {
      const [bill, categories] = await Promise.all([
        api.bill(row.id) as Promise<Bill>,
        api.categories() as Promise<Category[]>,
      ]);
      setEditBillData({ bill, categories });
    } catch (err) {
      toast.error((err as { message?: string })?.message || 'Failed to open bill');
    }
  }

  async function handleOpenAddBill() {
    try {
      const categories = await api.categories() as Category[];
      setEditBillData({ bill: null, categories });
    } catch (err) {
      toast.error((err as { message?: string })?.message || 'Failed to open bill editor');
    }
  }

  function goToday() {
    const n = new Date();
    updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
  }

  // Export the viewed month's activity as CSV via the existing /api/export
  // date-range endpoint (both bounds inclusive).
  async function handleExportMonthCsv() {
    const mm = String(month).padStart(2, '0');
    const lastDay = new Date(year, month, 0).getDate();
    const from = `${year}-${mm}-01`;
    const to   = `${year}-${mm}-${String(lastDay).padStart(2, '0')}`;
    try {
      await downloadFile(api.exportRangeUrl(from, to, 'csv'), `bills-${year}-${mm}.csv`);
      toast.success(`Exported ${from} – ${to} to CSV.`);
    } catch (err) {
      toast.error((err as { message?: string })?.message || 'Export failed');
    }
  }

  // Jump to the calendar view for the month currently in view.
  function goToCalendar() {
    navigateTo(`/calendar?year=${year}&month=${month}`);
  }

  const rows         = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]);
  const summary      = (data?.summary ?? {}) as TrackerSummary;
  const bankTracking = data?.bank_tracking;
  const cashflow     = data?.cashflow;
  const today = localDateString();
  // Safe-to-spend projects from "now", so it only makes sense on the current month.
  const isCurrentMonth = today.startsWith(`${year}-${String(month).padStart(2, '0')}`);
  const bannerSnoozedUntil = trackerSettings.tracker_bank_projection_banner_snoozed_until || '';
  const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort);
  const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards);
  const showOverdueCommandCenter = settingEnabled(trackerSettings.tracker_show_overdue_command_center);
  const showSafeToSpend = settingEnabled(trackerSettings.tracker_show_safe_to_spend);
  const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights);
  const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
    (!bannerSnoozedUntil || bannerSnoozedUntil <= today);
  const visibleTableColumns = useMemo(
    () => parseTrackerTableColumns(trackerSettings.tracker_table_columns),
    [trackerSettings.tracker_table_columns]
  );

  // Optimistic write + rollback now live in the useSaveSettings mutation; this
  // wrapper just fires it and owns the success/error toasts.
  function saveTrackerSettings(patch: Record<string, string>, successMessage?: string) {
    saveSettings.mutate(patch, {
      onSuccess: () => { if (successMessage) toast.success(successMessage); },
      onError: (err) => toast.error((err as { message?: string })?.message || 'Failed to update tracker setting'),
    });
  }

  function snoozeBankProjectionBanner() {
    const until = new Date();
    until.setDate(until.getDate() + 1);
    saveTrackerSettings({
      tracker_bank_projection_banner_snoozed_until: localDateString(until),
    }, 'Bank projection banner snoozed until tomorrow.');
  }

  function ignoreBankProjectionBanner() {
    saveTrackerSettings({
      tracker_show_bank_projection_banner: 'false',
      tracker_bank_projection_banner_snoozed_until: '',
    }, 'Bank projection banner hidden. You can turn it back on in Settings.');
  }

  function handleTableColumnToggle(columnKey: string, checked: boolean) {
    const nextColumns = checked
      ? [...visibleTableColumns, columnKey]
      : visibleTableColumns.filter(key => key !== columnKey);

    saveTrackerSettings({
      tracker_table_columns: trackerTableColumnsToSetting(nextColumns),
    });
  }

  function hideSearchSortPanel() {
    saveTrackerSettings({ tracker_show_search_sort: 'false' });
    toast('Search & sort hidden.', {
      action: { label: 'Undo', onClick: () => saveTrackerSettings({ tracker_show_search_sort: 'true' }) },
    });
  }

  const toggleFilter = (key: BoolFilterKey) => {
    const paramMap: Record<BoolFilterKey, string> = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
    updateParams({ [paramMap[key]]: !filters[key] });
  };
  const setFilterValue = (key: 'category' | 'cycle', value: string) => {
    const paramMap: Record<'category' | 'cycle', string> = { category: 'fc', cycle: 'cy' };
    updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
  };
  const setSort = (key: string) => {
    const normalizedKey = normalizeTrackerSortKey(key);
    if (normalizedKey === TRACKER_SORT_DEFAULT) {
      updateParams({ sort: null, dir: null });
      return;
    }
    updateParams({
      sort: normalizedKey,
      dir: TRACKER_SORT_DEFAULT_DIRS[normalizedKey] || TRACKER_SORT_ASC,
    });
  };
  const toggleSortDirection = () => {
    if (!hasSort) return;
    updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC });
  };
  const handleSortHeader = (key: string) => {
    const normalizedKey = normalizeTrackerSortKey(key);
    if (normalizedKey === TRACKER_SORT_DEFAULT) return;
    if (sortKey === normalizedKey) {
      updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC });
      return;
    }
    updateParams({
      sort: normalizedKey,
      dir: TRACKER_SORT_DEFAULT_DIRS[normalizedKey] || TRACKER_SORT_ASC,
    });
  };
  const hasFilters = !!(
    search.trim()
    || filters.category !== FILTER_ALL
    || filters.cycle !== FILTER_ALL
    || filters.autopay
    || filters.firstBucket
    || filters.fifteenthBucket
    || filters.unpaid
    || filters.overdue
    || filters.debt
    || hasSort
  );
  const searchSortLabel = hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : undefined;
  const resetFilters = () => {
    updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null, sort: null, dir: null });
  };
  const categoryOptions = useMemo(() => {
    const map = new Map<string, string>();
    rows.forEach(row => {
      if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name);
    });
    return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
  }, [rows]);
  const cycleOptions = useMemo(() => (
    Array.from(new Set(rows.map(scheduleValue))).sort()
  ), [rows]);
  // Defer the search term so the whole-list filter below (which re-scans every
  // row's haystack) runs off the urgent keystroke path — the input stays snappy
  // on big months while filtering catches up (React 19).
  const deferredSearch = useDeferredValue(search);
  const filteredRows = useMemo(() => {
    const q = deferredSearch.trim().toLowerCase();
    return rows.filter(row => {
      const effectiveStatus = rowEffectiveStatus(row);
      if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false;
      if (filters.cycle !== FILTER_ALL && scheduleValue(row) !== filters.cycle) return false;
      if (filters.autopay && !row.autopay_enabled) return false;
      if (filters.debt && !rowIsDebt(row)) return false;
      if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false;
      if (filters.overdue && !(effectiveStatus === 'late' || effectiveStatus === 'missed')) return false;
      if (filters.firstBucket && !filters.fifteenthBucket && row.bucket !== '1st') return false;
      if (filters.fifteenthBucket && !filters.firstBucket && row.bucket !== '15th') return false;

      if (!q) return true;
      const haystack = [
        row.name,
        row.category_name,
        row.notes,
        row.monthly_notes,
        scheduleValue(row),
        scheduleLabel(row),
        row.bucket,
        row.status,
        amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment),
      ].filter(Boolean).join(' ').toLowerCase();
      return haystack.includes(q);
    });
  }, [filters, rows, deferredSearch]);
  // When pin-upcoming is on, sort by urgency so overdue/due-soon bills surface
  // at the top of each bucket. Bucket split runs after so each bucket is sorted independently.
  const URGENCY_ORDER: Record<string, number> = { missed: 0, late: 1, due_soon: 2, upcoming: 3 };
  const sortedRows = hasSort
    ? sortTrackerRows(filteredRows, sortKey, sortDir)
    : pinUpcoming
      ? [...filteredRows].sort((a, b) => {
        // Rank on the reconciled status (same source the overdue filter uses),
        // so a bill that's paid-by-threshold but still carries a stale raw
        // 'late'/'missed' status sorts to the bottom instead of being pinned up.
        const ua = URGENCY_ORDER[rowEffectiveStatus(a)] ?? 99;
        const ub = URGENCY_ORDER[rowEffectiveStatus(b)] ?? 99;
        if (ua !== ub) return ua - ub;
        return (a.due_day ?? 99) - (b.due_day ?? 99);
      })
      : filteredRows;

  const searchResultLabel = `${filteredRows.length} of ${rows.length} shown`;

  const first   = sortedRows.filter(r => r.bucket === '1st');
  const second  = sortedRows.filter(r => r.bucket === '15th');
  const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming;

  async function persistTrackerOrder(nextRows: TrackerRow[], movedBillId: number | null) {
    const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
    setOrderedRows(nextRows);
    setMovingBillId(movedBillId);
    try {
      await api.reorderBills(payload);
      toast.success('Bill order saved');
      invalidateData();
    } catch (err) {
      setOrderedRows(null);
      toast.error((err as { message?: string })?.message || 'Failed to save bill order');
    } finally {
      setMovingBillId(null);
    }
  }

  function handleReorderBucket(bucket: string, orderedBucketRows: TrackerRow[]) {
    const sourceRows = rows;
    const nextRows = [...sourceRows];
    const replacement = [...orderedBucketRows];
    for (let i = 0; i < nextRows.length; i += 1) {
      const cur = nextRows[i];
      if (cur && cur.bucket === bucket) nextRows[i] = replacement.shift()!;
    }
    const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id));
    persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id || null);
  }

  return (
    <div className="space-y-5">

      {/* ── Header ── */}
      <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
        <div>
          <p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
            Monthly Overview
          </p>
          <h1 className="text-2xl font-bold tracking-tight">
            {MONTHS[month - 1]}
            <span className="text-muted-foreground font-normal ml-2 text-xl">{year}</span>
          </h1>
          <p className="text-xs text-muted-foreground mt-0.5">
            {rows.length} {rows.length === 1 ? 'bill' : 'bills'}
          </p>
        </div>

        <div className="flex flex-wrap items-center gap-2 tracker-print-hide">
          <Button
            size="sm"
            variant={pinUpcoming ? 'default' : 'outline'}
            onClick={togglePinUpcoming}
            className="h-9 gap-1.5 px-3"
            title={pinUpcoming ? 'Showing urgent bills first — click to restore normal order' : 'Pin overdue and due-soon bills to the top'}
          >
            <ArrowUpToLine className="h-4 w-4" />
            <span className="hidden sm:inline">{pinUpcoming ? 'Pinned' : 'Pin Due'}</span>
          </Button>

          {showBankSync && (
            <Button
              size="sm"
              variant="outline"
              onClick={handleBankSync}
              disabled={bankSyncing}
              className="h-9 gap-1.5 px-3"
              title="Scan bank transactions and match payments"
            >
              {bankSyncing
                ? <RefreshCw className="h-4 w-4 animate-spin" />
                : <Landmark className="h-4 w-4" />}
              <span className="hidden sm:inline">{bankSyncing ? 'Syncing…' : 'Sync Bank'}</span>
            </Button>
          )}
          <Button
            size="sm"
            onClick={handleOpenAddBill}
            className="h-9 gap-1.5 px-3 shadow-sm"
            aria-label="Add bill"
            title="Add bill"
          >
            <Plus className="h-4 w-4" />
            <span className="hidden sm:inline">Add Bill</span>
          </Button>

          <Button
            size="sm"
            variant="outline"
            onClick={goToCalendar}
            className="h-9 gap-1.5 px-3 tracker-print-hide"
            aria-label="Open calendar for this month"
            title="View this month on the calendar"
          >
            <CalendarDays className="h-4 w-4" />
            <span className="hidden sm:inline">Calendar</span>
          </Button>

          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button
                size="sm"
                variant="outline"
                className="h-9 gap-1.5 px-3 tracker-print-hide"
                aria-label="Export or print this month"
                title="Export or print this month"
              >
                <Download className="h-4 w-4" />
                <span className="hidden sm:inline">Export</span>
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuLabel>{new Date(year, month - 1, 1).toLocaleString('en-US', { month: 'long' })} {year}</DropdownMenuLabel>
              <DropdownMenuSeparator />
              <DropdownMenuItem onSelect={handleExportMonthCsv}>
                <FileSpreadsheet className="mr-2 h-4 w-4" />
                Export CSV
              </DropdownMenuItem>
              <DropdownMenuItem onSelect={() => window.print()}>
                <Printer className="mr-2 h-4 w-4" />
                Print
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>

          <div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
            <Button
              size="icon" variant="ghost"
              onClick={() => navigate(-1)}
              onMouseEnter={() => prefetchAdjacent(-1)}
              onFocus={() => prefetchAdjacent(-1)}
              className="h-7 w-7 hover:bg-white/5"
              aria-label="Previous month"
            >
              <ChevronLeft className="h-4 w-4" />
            </Button>
            <span className="min-w-[7.5rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
              {MONTHS[month - 1]} {year}
            </span>
            <Button
              size="icon" variant="ghost"
              onClick={() => navigate(1)}
              onMouseEnter={() => prefetchAdjacent(1)}
              onFocus={() => prefetchAdjacent(1)}
              className="h-7 w-7 hover:bg-white/5"
              aria-label="Next month"
            >
              <ChevronRight className="h-4 w-4" />
            </Button>
          </div>
          <Button
            size="sm" variant="outline"
            onClick={goToday}
            className="h-9 px-3 text-xs"
          >
            Today
          </Button>
        </div>
      </div>

      {showSearchSort && (
        <SearchFilterPanel
          title="Search & sort"
          className="tracker-print-hide"
          collapsed={searchPanelCollapsed}
          onCollapsedChange={setSearchPanelCollapsed}
          hasFilters={hasFilters}
          resultLabel={searchResultLabel}
          sortLabel={searchSortLabel}
          onClear={resetFilters}
          headerActions={(
            <div className="flex items-center gap-2">
              <TrackerColumnMenu
                visibleColumns={visibleTableColumns}
                onColumnToggle={handleTableColumnToggle}
                saving={savingTrackerSetting}
              />
              <Button
                type="button"
                size="sm"
                variant="ghost"
                onClick={hideSearchSortPanel}
                disabled={savingTrackerSetting}
                className="h-8 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-foreground"
              >
                <EyeOff className="h-3.5 w-3.5" />
                <span className="hidden sm:inline">Hide</span>
              </Button>
            </div>
          )}
        >
          <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(220px,1fr)_220px_180px_220px_auto] xl:items-center">
            <label className="relative">
              <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
              <Input
                value={search}
                onChange={e => updateParams({ q: e.target.value || null })}
                placeholder="Search this month by bill, category, notes, or amount"
                className="h-10 pl-9"
              />
            </label>
            <Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
              <SelectTrigger className="h-10" aria-label="Filter by category">
                <SelectValue placeholder="Category" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value={FILTER_ALL}>All categories</SelectItem>
                {categoryOptions.map(category => (
                  <SelectItem key={category.id} value={category.id}>{category.name}</SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
              <SelectTrigger className="h-10 capitalize" aria-label="Filter by billing schedule">
                <SelectValue placeholder="Billing schedule" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value={FILTER_ALL}>All cycles</SelectItem>
                {cycleOptions.map(cycle => (
                  <SelectItem key={cycle} value={cycle}>{scheduleLabel(cycle)}</SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Select value={sortKey} onValueChange={setSort}>
              <SelectTrigger className="h-10" aria-label="Sort by">
                <SelectValue placeholder="Sort by" />
              </SelectTrigger>
              <SelectContent>
                {TRACKER_SORT_OPTIONS.map(option => (
                  <SelectItem key={option.key} value={option.key}>{option.label}</SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Button
              type="button"
              variant="outline"
              disabled={!hasSort}
              onClick={toggleSortDirection}
              className="h-10 justify-center gap-2 text-xs"
              title={hasSort ? `Sort ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : 'Choose a sort first'}
            >
              {sortDir === TRACKER_SORT_ASC ? <ArrowUp className="h-3.5 w-3.5" /> : <ArrowDown className="h-3.5 w-3.5" />}
              <span>{sortDir === TRACKER_SORT_ASC ? 'Asc' : 'Desc'}</span>
            </Button>
          </div>
          <div className="flex flex-wrap items-center gap-2">
            <FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
            <FilterChip active={filters.overdue} onClick={() => toggleFilter('overdue')}>Overdue</FilterChip>
            <FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
            <FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
            <FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
            <FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
            <span className="ml-auto text-xs text-muted-foreground tabular-nums">
              {filteredRows.length} of {rows.length} shown
              {hasSort && (
                <span className="ml-2 hidden sm:inline">
                  · sorted by {TRACKER_SORT_LABELS[sortKey]} {sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}
                </span>
              )}
            </span>
          </div>
        </SearchFilterPanel>
      )}

      {/* ── Summary cards (backend already excludes skipped from totals) ── */}
      {showSummaryCards && loading ? (
        <div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
          <Skeleton variant="card" className="h-32" />
          <Skeleton variant="card" className="h-32" />
          <Skeleton variant="card" className="h-32" />
          <Skeleton variant="card" className="h-32" />
          <Skeleton variant="card" className="h-32" />
          {summary.trend ? <Skeleton variant="card" className="h-32" /> : null}
        </div>
      ) : showSummaryCards ? (
        <div className="grid grid-cols-2 gap-3 lg:flex">
          {bankTracking?.enabled ? (
            <button
              type="button"
              onClick={() => setIncomeModalOpen(true)}
              className={cn(
                'flex-1 min-w-0 relative overflow-hidden rounded-xl border px-5 py-4 shadow-sm shadow-black/15 transition-all duration-300 text-left',
                'hover:ring-2 hover:ring-primary/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
                Number(bankTracking.remaining ?? 0) >= 0
                  ? 'border-emerald-500/30 bg-card/95'
                  : 'border-destructive/30 bg-card/95',
              )}
              title="Click to see income breakdown"
            >
              <div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-emerald-500 to-teal-400" />
              <div className="flex items-center gap-2 mb-3">
                <Landmark className={cn('h-4 w-4', Number(bankTracking.remaining ?? 0) >= 0 ? 'text-emerald-500' : 'text-destructive')} />
                <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground truncate">
                  {bankTracking.account_name}
                </p>
                <span className="ml-auto flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
                  <span className="relative flex h-1.5 w-1.5">
                    <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
                    <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
                  </span>
                  Live
                </span>
              </div>
              <p className="text-[1.75rem] font-bold tracking-tight font-mono leading-none text-foreground">
                {fmt(bankTracking.effective_balance ?? 0)}
              </p>
              <p className="mt-2 text-[11px] text-muted-foreground">
                {Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected after bills
              </p>
              {bankTracking.last_updated && (
                <p className="mt-1 text-[10px] text-muted-foreground/60">
                  as of {fmtBalanceAge(bankTracking.last_updated)}
                </p>
              )}
            </button>
          ) : (
            <SummaryCard
              type="starting"
              value={summary.total_starting}
              hint={(() => {
                if (!summary.has_starting_amounts) return 'Set monthly starting cash';
                if (cashflow?.has_data && cashflow.period_projected !== undefined) {
                  const proj = Number(cashflow.period_projected);
                  const sign = proj < 0 ? '−' : '';
                  return `→ ${sign}${fmt(Math.abs(proj))} projected by ${cashflow.period_end_label}`;
                }
                return '';
              })()}
              onEdit={() => setEditStartingOpen(true)}
            />
          )}
          <SummaryCard
            type="paid"
            value={summary.total_paid}
            hint={Number(summary.previous_month_total ?? 0) > 0 ? `Last month: ${fmt(summary.previous_month_total)}` : undefined}
          />
          {/* In bank mode the hero card already surfaces the projected remaining,
              so only show the Remaining card when there's no bank hero. */}
          {!bankTracking?.enabled && (
            <SummaryCard
              type="remaining"
              value={summary.remaining}
              hint={summary.remaining_label || undefined}
            />
          )}
          <SummaryCard type="overdue"   value={summary.overdue} />
          {summary.trend ? <TrendCard trend={summary.trend as Parameters<typeof TrendCard>[0]['trend']} /> : null}
        </div>
      ) : null}

      {/* Compact month-progress line — quick "how far through the month am I?" */}
      {showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => {
        const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
        return (
          <p className="px-1 -mt-1 text-[11px] text-muted-foreground">
            <span className="font-mono font-semibold text-foreground">{fmt(summary.paid_toward_due)}</span>
            {' of '}
            <span className="font-mono">{fmt(summary.total_expected)}</span>
            {' paid'}
            {billsLeft > 0 && <> · {billsLeft} bill{billsLeft === 1 ? '' : 's'} left</>}
          </p>
        );
      })()}

      {/* ── Safe to Spend ── */}
      {!isError && !loading && showSafeToSpend && isCurrentMonth && cashflow && (
        <CashFlowCard
          cashflow={cashflow}
          onSetStartingAmounts={() => setEditStartingOpen(true)}
        />
      )}

      {/* ── Overdue Command Center ── */}
      {!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && (
        <OverdueCommandCenter
          rows={rows}
          year={year}
          month={month}
          refresh={invalidateData}
          onPayNow={(row) => setCommandCenterPayRow(row)}
        />
      )}

      {/* ── Bank Projection Banner ── */}
      {!isError && !loading && showBankProjectionBanner && (
        <BankProjectionBanner
          bankTracking={bankTracking}
          busy={savingTrackerSetting}
          onSnooze={snoozeBankProjectionBanner}
          onIgnore={ignoreBankProjectionBanner}
        />
      )}

      {/* ── Drift / Price-Change Insights ── */}
      {!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
        <DriftInsightPanel
          driftBills={driftData?.bills || []}
          refresh={invalidateData}
        />
      )}

      {/* ── Fetch error state ── */}
      {isError && (
        <div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-destructive/20 bg-destructive/5">
          <div className="h-10 w-10 rounded-full bg-destructive/10 flex items-center justify-center mb-3">
            <AlertCircle className="h-5 w-5 text-destructive" />
          </div>
          <p className="text-sm font-medium text-foreground">Failed to load tracker data</p>
          <p className="mt-1 text-xs text-muted-foreground">{error?.message || 'An unexpected error occurred.'}</p>
          <Button
            size="sm"
            variant="outline"
            onClick={() => refetch()}
            className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
          >
            <RefreshCw className="h-3 w-3" />
            Try again
          </Button>
        </div>
      )}

      {/* ── Empty state ── */}
      {!isError && rows.length === 0 && data != null && (
        <div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
          <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center mb-3">
            <CheckCircle2 className="h-5 w-5 text-muted-foreground" />
          </div>
          <p className="text-sm font-medium text-muted-foreground">No bills this month</p>
          <a href="/bills" className="mt-1.5 text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground transition-colors">
            Add a bill
          </a>
        </div>
      )}

      {/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */}
      {!isError && loading && (
        <div className="space-y-5" aria-busy="true">
          <div className="rounded-xl border border-border overflow-hidden bg-card p-4">
            <div className="flex items-center justify-between mb-4">
              <div className="h-4 w-32 rounded-md bg-muted" />
              <div className="h-4 w-16 rounded-md bg-muted" />
            </div>
            <div className="space-y-3">
              {Array.from({ length: 3 }).map((_, i) => (
                <div key={i} className="flex items-center gap-3 animate-pulse">
                  <div className="h-1.5 w-1.5 rounded-full bg-muted" />
                  <div className="h-4 w-64 rounded-md bg-muted" />
                </div>
              ))}
            </div>
          </div>
          <div className="rounded-xl border border-border overflow-hidden bg-card p-4">
            <div className="flex items-center justify-between mb-4">
              <div className="h-4 w-32 rounded-md bg-muted" />
              <div className="h-4 w-16 rounded-md bg-muted" />
            </div>
            <div className="space-y-3">
              {Array.from({ length: 3 }).map((_, i) => (
                <div key={i} className="flex items-center gap-3 animate-pulse">
                  <div className="h-1.5 w-1.5 rounded-full bg-muted" />
                  <div className="h-4 w-64 rounded-md bg-muted" />
                </div>
              ))}
            </div>
          </div>
        </div>
      )}
      {/* ── Filtered-to-empty state — bills exist this month but none match the active filters/search ── */}
      {!isError && !loading && rows.length > 0 && first.length === 0 && second.length === 0 && (
        <div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
          <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center mb-3">
            <Search className="h-5 w-5 text-muted-foreground" />
          </div>
          <p className="text-sm font-medium text-foreground">No bills match your filters</p>
          <p className="mt-1 text-xs text-muted-foreground">{rows.length} bill{rows.length === 1 ? '' : 's'} this month are hidden by the current search or filters.</p>
          {hasFilters && (
            <Button size="sm" variant="outline" onClick={resetFilters} className="mt-4 gap-1.5 text-xs">
              <RefreshCw className="h-3 w-3" />
              Clear filters
            </Button>
          )}
        </div>
      )}

      {!isError && (first.length > 0 || second.length > 0) && (
        <div className="space-y-5">
          {first.length  > 0 && <Bucket label="1st – 14th"  rows={first}  year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
          {second.length > 0 && <Bucket label="15th – 31st" rows={second} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
        </div>
      )}

      {/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
      {editBillData && (
        <BillModal
          key={editBillData.bill?.id ? `edit-${editBillData.bill.id}` : `new-${editBillData.initialBill?.name || 'blank'}`}
          bill={editBillData.bill}
          initialBill={editBillData.initialBill}
          categories={editBillData.categories}
          onClose={() => setEditBillData(null)}
          onSave={() => invalidateData()}
          onDuplicate={bill => setEditBillData({
            bill: null,
            initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }) as Partial<Bill>,
            categories: editBillData.categories,
          })}
        />
      )}

      {/* Edit Starting Amounts modal */}
      <StartingAmountsEditDialog
        open={editStartingOpen}
        onClose={() => setEditStartingOpen(false)}
        year={year}
        month={month}
        onSave={() => { setEditStartingOpen(false); invalidateData(); }}
      />

      {/* Income breakdown modal — opens when clicking the bank balance card */}
      {bankTracking?.enabled && (
        <IncomeBreakdownModal
          open={incomeModalOpen}
          onClose={() => setIncomeModalOpen(false)}
          year={year}
          month={month}
          bankTracking={bankTracking}
        />
      )}

      {/* Late-attribution dialog — fires after sync when a payment just crossed a month boundary */}
      {lateAttributions.length > 0 && (
        <LateAttributionDialog
          attr={lateAttributions[0]}
          remaining={lateAttributions.length - 1}
          busy={attrBusy === lateAttributions[0]?.payment_id}
          onAccept={async (attr) => {
            setAttrBusy(attr.payment_id);
            try {
              await api.attributePaymentToMonth(attr.payment_id, attr.suggested_date);
              const monthName = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
              toast.success(`${attr.bill_name} payment moved to ${monthName}`);
              setLateAttributions(prev => prev.slice(1)); // dismiss only on success
              invalidateData();
            } catch (err) {
              toast.error((err as { message?: string })?.message || 'Failed to reclassify payment — try again');
              // keep the attribution in queue so user can retry
            } finally {
              setAttrBusy(null);
            }
          }}
          onDismiss={() => setLateAttributions(prev => prev.slice(1))}
        />
      )}

      {/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */}
      {commandCenterPayRow && (
        <PaymentLedgerDialog
          row={commandCenterPayRow}
          year={year}
          month={month}
          threshold={(commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount) as Dollars}
          defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
          onClose={() => setCommandCenterPayRow(null)}
          onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }}
        />
      )}

    </div>
  );
}
