import { Fragment, useCallback, useEffect, useMemo, useState, type ComponentType } from 'react';
import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useBankLedger } from '@/hooks/useQueries';
import { toast } from 'sonner';
import {
  ArrowDown, ArrowDownUp, ArrowUp, Building2, Check, CheckCircle2, ChevronLeft, ChevronRight,
  Clock3, Eye, EyeOff, Landmark, Link2, Link2Off, MoreVertical, RefreshCw, Search, Sparkles,
  TrendingDown, TrendingUp, WalletCards,
} from 'lucide-react';
import { api } from '@/api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/Skeleton';
import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
  DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
  Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
import { MatchBillDialog } from '@/components/transactions/MatchBillDialog';
import BillModal from '@/components/BillModal';
import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils';
import { formatUSD, asDollars } from '@/lib/money';
import type {
  Bill, Category, BankTransaction,
  BankLedgerTransaction as Tx,
  BankAccount as Account,
  BankLedger as Ledger,
  BankLedgerSummary as LedgerSummary,
  BankCategoryBreakdown as CategoryBreakdown,
  AutoCategorizeChange as AutoCatChange,
  AutoCategorizePreview as AutoCatPreview,
} from '@/types';

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

const PAGE_SIZE = 50;
const TABLE_COLUMN_COUNT = 8;

const flowOptions = [
  { value: 'all', label: 'All' },
  { value: 'in', label: 'Money in' },
  { value: 'out', label: 'Money out' },
  { value: 'pending', label: 'Pending' },
  { value: 'unmatched', label: 'Needs review' },
  { value: 'uncategorized', label: 'Needs category' },
  { value: 'matched', label: 'Matched' },
  { value: 'ignored', label: 'Ignored' },
];

const sortOptions = [
  { value: 'date', label: 'Date' },
  { value: 'amount', label: 'Amount' },
  { value: 'merchant', label: 'Merchant' },
  { value: 'account', label: 'Account' },
  { value: 'status', label: 'Status' },
];

function formatCents(value: number | null | undefined, { signed = false }: { signed?: boolean } = {}): string {
  const cents = Number(value || 0);
  const amount = fmt(Math.abs(cents) / 100);
  if (!signed || cents === 0) return amount;
  return `${cents > 0 ? '+' : '-'}${amount}`;
}

function formatSyncTime(value: unknown): string {
  if (!value) return 'Not synced yet';
  const normalized = String(value).includes('T') ? String(value) : String(value).replace(' ', 'T');
  const date = new Date(normalized);
  if (Number.isNaN(date.getTime())) return String(value);
  return new Intl.DateTimeFormat(undefined, {
    month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
  }).format(date);
}

function transactionTitle(tx: Tx): string {
  return tx.payee || tx.description || tx.memo || 'Transaction';
}

function transactionDate(tx: Tx): string | null {
  return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null);
}

function dateGroupLabel(dateStr: string | null): string {
  if (!dateStr) return 'Unknown date';
  const today = localDateString();
  const yesterdayDate = new Date();
  yesterdayDate.setDate(yesterdayDate.getDate() - 1);
  const yesterday = localDateString(yesterdayDate);
  if (dateStr === today) return 'Today';
  if (dateStr === yesterday) return 'Yesterday';
  return fmtDate(dateStr);
}

interface DateGroup { date?: string | null; label: string | null; items: Tx[] }

function groupByDate(transactions: Tx[], sortBy: string): DateGroup[] | null {
  if (sortBy !== 'date') return null;
  const groups: DateGroup[] = [];
  let current: DateGroup | null = null;
  for (const tx of transactions) {
    const date = transactionDate(tx);
    if (!current || current.date !== date) {
      current = { date, label: dateGroupLabel(date), items: [] };
      groups.push(current);
    }
    current.items.push(tx);
  }
  return groups;
}

function accountLabel(account?: Account): string {
  if (!account) return 'Unknown account';
  return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account';
}

function StatusBadge({ tx }: { tx: Tx }) {
  if (tx.pending) {
    return (
      <Badge className="border-amber-300/50 bg-amber-400/15 text-amber-700 dark:text-amber-200">
        <Clock3 className="mr-1 h-3 w-3" />
        Pending
      </Badge>
    );
  }
  if (tx.ignored || tx.match_status === 'ignored') {
    return <Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground">Ignored</Badge>;
  }
  if (tx.match_status === 'matched') {
    return (
      <Badge className="border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200">
        <CheckCircle2 className="mr-1 h-3 w-3" />
        Matched
      </Badge>
    );
  }
  return <Badge className="border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200">Review</Badge>;
}

function SummaryTile({ icon: Icon, label, value, tone, detail }: {
  icon: ComponentType<{ className?: string }>;
  label: React.ReactNode;
  value: React.ReactNode;
  tone: string;
  detail?: React.ReactNode;
}) {
  const tones: Record<string, string> = {
    emerald: 'border-emerald-300/40 bg-emerald-400/10 text-emerald-700 dark:text-emerald-200',
    rose: 'border-rose-300/40 bg-rose-400/10 text-rose-700 dark:text-rose-200',
    sky: 'border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200',
    amber: 'border-amber-300/40 bg-amber-400/10 text-amber-700 dark:text-amber-200',
  };

  return (
    <div className="rounded-lg border border-border/70 bg-card/80 p-4 shadow-sm">
      <div className="flex items-center justify-between gap-3">
        <p className="text-sm font-medium text-muted-foreground">{label}</p>
        <span className={cn('flex h-9 w-9 items-center justify-center rounded-full border', tones[tone])}>
          <Icon className="h-4 w-4" />
        </span>
      </div>
      <p className="tracker-number mt-3 text-2xl font-bold text-foreground">{value}</p>
      {detail && <p className="mt-1 text-xs font-medium text-muted-foreground">{detail}</p>}
    </div>
  );
}

function MerchantAvatar({ tx }: { tx: Tx }) {
  const tone = categoryColor(tx.spending_category_name || 'Uncategorized');
  const initial = transactionTitle(tx).trim().charAt(0).toUpperCase() || '?';
  return (
    <span className={cn('flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold', tone.bg, tone.text)}>
      {initial}
    </span>
  );
}

function SuggestionBadge({ tx, onApply, applying }: { tx: Tx; onApply: (tx: Tx) => void; applying?: boolean }) {
  if (!tx.suggested_match) return null;
  return (
    <div className="mt-1 flex items-center gap-1.5">
      <Badge variant="outline" className="max-w-[160px] truncate border-dashed text-[10px] text-muted-foreground">
        {tx.suggested_match.display_name} · {tx.suggested_match.category}
      </Badge>
      <button
        type="button"
        disabled={applying}
        onClick={() => onApply(tx)}
        title="Apply suggested category"
        className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-primary/30 text-primary hover:bg-primary/10 transition-colors disabled:opacity-50"
      >
        <Check className="h-3 w-3" />
      </button>
    </div>
  );
}

function RowActionsMenu({ tx, onMatch, onUnmatch, onIgnore, onUnignore }: {
  tx: Tx;
  onMatch: (tx: Tx) => void;
  onUnmatch: (tx: Tx) => void;
  onIgnore: (tx: Tx) => void;
  onUnignore: (tx: Tx) => void;
}) {
  const isMatched = tx.match_status === 'matched';
  const isIgnored = tx.ignored || tx.match_status === 'ignored';

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button type="button" variant="ghost" size="icon" className="h-8 w-8">
          <MoreVertical className="h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => onMatch(tx)}>
          <Link2 className="h-3.5 w-3.5" />
          {isMatched ? 'Change bill match…' : 'Match to bill…'}
        </DropdownMenuItem>
        {isMatched && (
          <DropdownMenuItem onClick={() => onUnmatch(tx)}>
            <Link2Off className="h-3.5 w-3.5" />
            Unmatch
          </DropdownMenuItem>
        )}
        <DropdownMenuSeparator />
        {isIgnored ? (
          <DropdownMenuItem onClick={() => onUnignore(tx)}>
            <Eye className="h-3.5 w-3.5" />
            Unignore
          </DropdownMenuItem>
        ) : (
          <DropdownMenuItem onClick={() => onIgnore(tx)}>
            <EyeOff className="h-3.5 w-3.5" />
            Ignore
          </DropdownMenuItem>
        )}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

function TransactionMobileCard({ tx, categories, onCategorize, onApplySuggestion, applyingSuggestionId, onMatch, onUnmatch, onIgnore, onUnignore, selected, onToggleSelected }: {
  tx: Tx;
  categories: Category[];
  onCategorize: (tx: Tx, categoryId: number | string | null) => void;
  onApplySuggestion: (tx: Tx) => void;
  applyingSuggestionId: number | null;
  onMatch: (tx: Tx) => void;
  onUnmatch: (tx: Tx) => void;
  onIgnore: (tx: Tx) => void;
  onUnignore: (tx: Tx) => void;
  selected: boolean;
  onToggleSelected: (id: number) => void;
}) {
  const cents = Number(tx.amount || 0);
  const isCredit = cents > 0;

  return (
    <div className={cn('rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm', selected && 'ring-2 ring-primary/50')}>
      <div className="flex items-start justify-between gap-3">
        <div className="flex min-w-0 items-start gap-2.5">
          <Checkbox className="mt-1" checked={selected} onCheckedChange={() => onToggleSelected(tx.id)} aria-label="Select transaction" />
          <MerchantAvatar tx={tx} />
          <div className="min-w-0">
            <p className="truncate text-sm font-semibold text-foreground">{transactionTitle(tx)}</p>
            <p className="mt-1 truncate text-xs font-medium text-muted-foreground">
              {[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
            </p>
          </div>
        </div>
        <p className={cn('tracker-number shrink-0 text-sm font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
          {formatCents(cents, { signed: true })}
        </p>
      </div>
      <div className="mt-3 flex flex-wrap items-center gap-2">
        <Badge variant="outline" className="text-muted-foreground">{fmtDate(transactionDate(tx))}</Badge>
        <StatusBadge tx={tx} />
        {tx.matched_bill_name && (
          <Badge className="border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200">
            <Link2 className="mr-1 h-3 w-3" />
            {tx.matched_bill_name}
          </Badge>
        )}
      </div>
      <div className="mt-3 flex items-center justify-between gap-2">
        <div>
          <CategoryPicker categories={categories} current={tx.spending_category_id} onSelect={categoryId => onCategorize(tx, categoryId)} />
          <SuggestionBadge tx={tx} onApply={onApplySuggestion} applying={applyingSuggestionId === tx.id} />
        </div>
        <RowActionsMenu tx={tx} onMatch={onMatch} onUnmatch={onUnmatch} onIgnore={onIgnore} onUnignore={onUnignore} />
      </div>
    </div>
  );
}

export default function BankTransactionsPage() {
  const queryClient = useQueryClient();
  const [search, setSearch] = useState('');
  const [query, setQuery] = useState('');
  const [accountId, setAccountId] = useState('all');
  const [flow, setFlow] = useState('all');
  const [sortBy, setSortBy] = useState('date');
  const [sortDir, setSortDir] = useState('desc');
  const [page, setPage] = useState(1);
  const [categories, setCategories] = useState<Category[]>([]);
  const [bills, setBills] = useState<Bill[]>([]);
  const [matchTarget, setMatchTarget] = useState<Tx | null>(null);
  const [matchSubmitting, setMatchSubmitting] = useState(false);
  const [applyingSuggestionId, setApplyingSuggestionId] = useState<number | null>(null);
  const [autoCategorizing, setAutoCategorizing] = useState(false);
  const [autoCategorizePreview, setAutoCategorizePreview] = useState<AutoCatPreview | null>(null);
  const [createBillSourceTx, setCreateBillSourceTx] = useState<{ tx: Tx; initialBill: Partial<Bill> } | null>(null);
  const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
  const [bulkActing, setBulkActing] = useState(false);
  // React Query keys the ledger on the filters/page.
  const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
    useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE });
  const ledger = ledgerRaw;
  const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : '';
  const setLedger = useCallback((u: (prev: Ledger | undefined) => Ledger | undefined) => queryClient.setQueryData<Ledger>(
    ['bank-ledger', accountId, flow, page, query, sortBy, sortDir], u), [queryClient, accountId, flow, page, query, sortBy, sortDir]);

  useEffect(() => {
    const id = window.setTimeout(() => {
      setQuery(search.trim());
      setPage(1);
    }, 250);
    return () => window.clearTimeout(id);
  }, [search]);

  useEffect(() => {
    setPage(1);
  }, [accountId, flow, sortBy, sortDir]);

  useEffect(() => {
    setSelectedIds(new Set());
  }, [accountId, flow, sortBy, sortDir, query, page]);

  useEffect(() => {
    api.categories().then(data => setCategories((data as Category[]) || [])).catch(() => setCategories([]));
    api.bills().then(data => setBills((data as Bill[]) || [])).catch(() => setBills([]));
  }, []);

  const updateTransaction = useCallback((id: number, patch: Partial<Tx>) => {
    setLedger(prev => {
      if (!prev) return prev;
      return {
        ...prev,
        transactions: (prev.transactions || []).map(tx => (tx.id === id ? { ...tx, ...patch } : tx)),
      };
    });
  }, [setLedger]);

  const handleCategorize = useCallback(async (tx: Tx, categoryId: number | string | null) => {
    try {
      await api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false });
      const categoryName = categories.find(c => c.id === categoryId)?.name ?? null;
      updateTransaction(tx.id, { spending_category_id: categoryId as number | null, spending_category_name: categoryName, suggested_match: null });
    } catch (err) {
      toast.error(errMessage(err, 'Failed to categorize transaction'));
    }
  }, [categories, updateTransaction]);

  const handleApplySuggestion = useCallback(async (tx: Tx) => {
    setApplyingSuggestionId(tx.id);
    try {
      const result = await api.applyTransactionMerchantMatch(tx.id);
      if (result.matched && result.category) {
        updateTransaction(tx.id, {
          spending_category_id: result.category.id,
          spending_category_name: result.category.name,
          suggested_match: null,
        });
        toast.success(`Categorized as ${result.category.name}`);
      }
    } catch (err) {
      toast.error(errMessage(err, 'Failed to apply suggestion'));
    } finally {
      setApplyingSuggestionId(null);
    }
  }, [updateTransaction]);

  const handleAutoCategorize = useCallback(async () => {
    setAutoCategorizing(true);
    try {
      const preview = await api.autoCategorizeTransactions({ dry_run: true });
      if (!preview?.changes?.length) {
        toast.success('No new matches found');
        return;
      }
      setAutoCategorizePreview(preview);
    } catch (err) {
      toast.error(errMessage(err, 'Failed to preview auto-categorize'));
    } finally {
      setAutoCategorizing(false);
    }
  }, []);

  const handleUndoAutoCategorize = useCallback(async (changes: AutoCatChange[]) => {
    try {
      await Promise.all(changes.map(c => api.categorizeTransaction(c.transaction_id, { category_id: null, save_rule: false })));
      toast.success('Auto-categorize undone');
    } catch (err) {
      toast.error(errMessage(err, 'Failed to undo auto-categorize'));
    } finally {
      loadLedger();
    }
  }, [loadLedger]);

  const handleConfirmAutoCategorize = useCallback(async () => {
    setAutoCategorizing(true);
    try {
      const result = await api.autoCategorizeTransactions();
      const changes = result?.changes || [];
      setAutoCategorizePreview(null);
      await Promise.all([
        loadLedger(),
        api.categories().then(data => setCategories((data as Category[]) || [])).catch(() => {}),
      ]);
      const count = changes.length;
      toast.success(`Categorized ${count} transaction${count === 1 ? '' : 's'}`, {
        action: { label: 'Undo', onClick: () => handleUndoAutoCategorize(changes) },
      });
    } catch (err) {
      toast.error(errMessage(err, 'Failed to auto-categorize transactions'));
    } finally {
      setAutoCategorizing(false);
    }
  }, [loadLedger, handleUndoAutoCategorize]);

  const handleConfirmMatch = useCallback(async (billId: number | string) => {
    if (!matchTarget) return;
    setMatchSubmitting(true);
    try {
      const result = await api.matchTransaction(matchTarget.id, billId);
      updateTransaction(matchTarget.id, result.transaction);
      setMatchTarget(null);
      toast.success('Transaction matched to bill');
    } catch (err) {
      toast.error(errMessage(err, 'Failed to match transaction'));
    } finally {
      setMatchSubmitting(false);
    }
  }, [matchTarget, updateTransaction]);

  const openCreateBill = useCallback((txArg: BankTransaction | null, nameOverride?: string) => {
    const tx = txArg as Tx | null;
    if (!tx) return;
    const amount = Math.abs(Number(tx.amount || 0)) / 100;
    const dateStr = transactionDate(tx);
    const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1;
    setCreateBillSourceTx({
      tx,
      initialBill: {
        name: nameOverride || transactionTitle(tx),
        expected_amount: asDollars(amount || 0),
        due_day: day >= 1 && day <= 31 ? day : 1,
        billing_cycle: 'monthly',
        cycle_type: 'monthly',
        cycle_day: '1',
        active: 1,
      },
    });
  }, []);

  const handleBillCreated = useCallback(async (newBill?: Bill) => {
    const tx = createBillSourceTx?.tx;
    setCreateBillSourceTx(null);
    setMatchTarget(null);
    if (tx && newBill?.id) {
      try {
        const result = await api.matchTransaction(tx.id, newBill.id);
        updateTransaction(tx.id, result.transaction);
        toast.success('Bill created and matched to transaction');
      } catch (err) {
        toast.error(errMessage(err, 'Bill created but match failed'));
      }
    }
    api.bills().then(data => setBills((data as Bill[]) || [])).catch(() => {});
  }, [createBillSourceTx, updateTransaction]);

  const handleUnmatch = useCallback(async (tx: Tx) => {
    try {
      const result = await api.unmatchTransaction(tx.id);
      updateTransaction(tx.id, result.transaction);
      toast.success('Transaction unmatched');
    } catch (err) {
      toast.error(errMessage(err, 'Failed to unmatch transaction'));
      loadLedger();
    }
  }, [updateTransaction, loadLedger]);

  const handleIgnore = useCallback(async (tx: Tx) => {
    try {
      const transaction = await api.ignoreTransaction(tx.id);
      updateTransaction(tx.id, transaction);
    } catch (err) {
      toast.error(errMessage(err, 'Failed to ignore transaction'));
      loadLedger();
    }
  }, [updateTransaction, loadLedger]);

  const handleUnignore = useCallback(async (tx: Tx) => {
    try {
      const transaction = await api.unignoreTransaction(tx.id);
      updateTransaction(tx.id, transaction);
    } catch (err) {
      toast.error(errMessage(err, 'Failed to unignore transaction'));
      loadLedger();
    }
  }, [updateTransaction, loadLedger]);

  const accounts = ledger?.accounts || [];
  const transactions = useMemo(() => ledger?.transactions || [], [ledger]);
  const summary = ledger?.summary || {};
  const total = Number(ledger?.total || 0);
  const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
  const categoryBreakdown = summary.category_breakdown || [];
  const maxCategoryTotal = categoryBreakdown.reduce((max, c) => Math.max(max, Number(c.total || 0)), 0) || 1;
  const dateGroups = useMemo(() => groupByDate(transactions, sortBy), [transactions, sortBy]);

  useEffect(() => {
    if (!loading && page > totalPages) setPage(totalPages);
  }, [loading, page, totalPages]);
  const latestSync = useMemo(() => {
    const times = (ledger?.sources || []).map(source => source.last_sync_at).filter(Boolean).sort();
    return times[times.length - 1] || null;
  }, [ledger?.sources]);
  const connected = Boolean(ledger?.enabled && ledger?.has_connections);

  const selectedTransactions = useMemo(
    () => transactions.filter(tx => selectedIds.has(tx.id)),
    [transactions, selectedIds],
  );
  const allOnPageSelected = transactions.length > 0 && transactions.every(tx => selectedIds.has(tx.id));

  const toggleSelected = useCallback((id: number) => {
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  }, []);

  const toggleSelectAll = useCallback(() => {
    setSelectedIds(prev => {
      if (transactions.length > 0 && transactions.every(tx => prev.has(tx.id))) return new Set();
      return new Set(transactions.map(tx => tx.id));
    });
  }, [transactions]);

  const handleBulkApplySuggestions = useCallback(async () => {
    const targets = selectedTransactions.filter(tx => tx.suggested_match && !tx.spending_category_id);
    if (targets.length === 0) return;
    setBulkActing(true);
    try {
      const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id)));
      let applied = 0;
      results.forEach((r, i) => {
        const val = r.status === 'fulfilled' ? r.value : null;
        if (val?.matched && val.category) {
          updateTransaction(targets[i]!.id, {
            spending_category_id: val.category.id,
            spending_category_name: val.category.name,
            suggested_match: null,
          });
          applied++;
        }
      });
      toast.success(`Applied suggestions to ${applied} of ${targets.length}`);
      if (applied < targets.length) loadLedger();
    } finally {
      setBulkActing(false);
      setSelectedIds(new Set());
    }
  }, [selectedTransactions, updateTransaction, loadLedger]);

  const handleBulkCategorize = useCallback(async (categoryId: number | string | null) => {
    const targets = selectedTransactions;
    if (targets.length === 0) return;
    const categoryName = categories.find(c => c.id === categoryId)?.name ?? null;
    setBulkActing(true);
    try {
      const results = await Promise.allSettled(
        targets.map(tx => api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false })),
      );
      let applied = 0;
      results.forEach((r, i) => {
        if (r.status === 'fulfilled') {
          updateTransaction(targets[i]!.id, { spending_category_id: categoryId as number | null, spending_category_name: categoryName, suggested_match: null });
          applied++;
        }
      });
      toast.success(`Categorized ${applied} of ${targets.length}`);
      if (applied < targets.length) loadLedger();
    } finally {
      setBulkActing(false);
      setSelectedIds(new Set());
    }
  }, [selectedTransactions, categories, updateTransaction, loadLedger]);

  const handleBulkIgnoreToggle = useCallback(async (ignore: boolean) => {
    const targets = selectedTransactions;
    if (targets.length === 0) return;
    setBulkActing(true);
    try {
      const results = await Promise.allSettled(
        targets.map(tx => (ignore ? api.ignoreTransaction(tx.id) : api.unignoreTransaction(tx.id))),
      );
      let applied = 0;
      results.forEach((r, i) => {
        if (r.status === 'fulfilled') {
          updateTransaction(targets[i]!.id, r.value);
          applied++;
        }
      });
      toast.success(`${ignore ? 'Ignored' : 'Unignored'} ${applied} of ${targets.length}`);
      if (applied < targets.length) loadLedger();
    } finally {
      setBulkActing(false);
      setSelectedIds(new Set());
    }
  }, [selectedTransactions, updateTransaction, loadLedger]);

  if (!loading && error && !ledger) {
    return (
      <div className="space-y-6">
        <section className="rounded-lg border border-rose-300/40 bg-rose-400/10 p-6 shadow-sm">
          <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div>
              <h1 className="text-xl font-bold text-foreground">Bank Transactions</h1>
              <p className="mt-1 text-sm font-medium text-rose-700 dark:text-rose-200">{error}</p>
            </div>
            <Button type="button" variant="outline" onClick={() => loadLedger()}>
              <RefreshCw className="h-4 w-4" />
              Try again
            </Button>
          </div>
        </section>
      </div>
    );
  }

  if (!loading && !connected) {
    return (
      <div className="space-y-6">
        <section className="rounded-lg border border-border/70 bg-card/85 p-6 shadow-sm">
          <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div>
              <div className="flex items-center gap-2">
                <span className="flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200">
                  <Landmark className="h-5 w-5" />
                </span>
                <div>
                  <h1 className="text-2xl font-bold tracking-normal text-foreground">Bank Transactions</h1>
                  <p className="text-sm font-medium text-muted-foreground">SimpleFIN bridge required</p>
                </div>
              </div>
            </div>
            <Button asChild>
              <Link to="/data?section=bank-sync">
                <WalletCards className="h-4 w-4" />
                Open Data
              </Link>
            </Button>
          </div>
        </section>
      </div>
    );
  }

  return (
    <div className="space-y-6">
      <section className="rounded-lg border border-border/70 bg-[linear-gradient(135deg,oklch(var(--card))_0%,oklch(var(--muted)/0.72)_58%,oklch(var(--primary)/0.10)_100%)] p-5 shadow-sm">
        <div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
          <div className="min-w-0">
            <div className="flex flex-wrap items-center gap-3">
              <span className="flex h-11 w-11 items-center justify-center rounded-full border border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200">
                <Landmark className="h-5 w-5" />
              </span>
              <div>
                <h1 className="text-2xl font-bold tracking-normal text-foreground sm:text-3xl">Bank Transactions</h1>
                <p className="mt-1 text-sm font-medium text-muted-foreground">
                  {accounts.length} monitored {accounts.length === 1 ? 'account' : 'accounts'} synced through SimpleFIN
                </p>
              </div>
              <Badge className={cn(
                connected
                  ? 'border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200'
                  : 'border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200',
              )}>
                {connected ? 'Connected' : 'Loading'}
              </Badge>
            </div>
          </div>
          <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
            <div className="rounded-lg border border-border/70 bg-card/75 px-3 py-2 text-sm shadow-sm">
              <span className="font-medium text-muted-foreground">Last sync </span>
              <span className="font-semibold text-foreground">{formatSyncTime(latestSync)}</span>
            </div>
            <Button type="button" variant="outline" onClick={handleAutoCategorize} disabled={autoCategorizing}>
              <Sparkles className={cn('h-4 w-4', autoCategorizing && 'animate-pulse')} />
              Auto-categorize
            </Button>
            <Button type="button" variant="outline" onClick={() => loadLedger()} disabled={isFetching}>
              <RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
              Refresh
            </Button>
          </div>
        </div>
      </section>

      {error && (
        <div className="rounded-lg border border-rose-300/40 bg-rose-400/10 px-4 py-3 text-sm font-medium text-rose-700 dark:text-rose-200">
          {error}
        </div>
      )}

      <section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
        <SummaryTile icon={TrendingUp} label="Money in" value={formatCents(summary.inflow)} tone="emerald" detail={`${Number(summary.matched || 0)} matched`} />
        <SummaryTile icon={TrendingDown} label="Money out" value={formatCents(summary.outflow)} tone="rose" detail={`${Number(summary.unmatched || 0)} need review`} />
        <SummaryTile icon={ArrowDownUp} label="Net flow" value={formatCents(summary.net, { signed: true })} tone="sky" detail={summary.latest_date ? `Latest ${fmtDate(summary.latest_date)}` : 'No posted activity'} />
        <SummaryTile icon={Clock3} label="Pending" value={String(Number(summary.pending || 0))} tone="amber" detail={`${Number(summary.total || 0)} total transactions`} />
      </section>

      {categoryBreakdown.length > 0 && (
        <section className="flex gap-3 overflow-x-auto pb-1">
          {categoryBreakdown.map(cat => {
            const tone = categoryColor(cat.name);
            const pct = Math.max(4, Math.round((Number(cat.total || 0) / maxCategoryTotal) * 100));
            const isUncategorized = cat.name === 'Uncategorized';
            const active = isUncategorized
              ? flow === 'uncategorized'
              : search.trim().toLowerCase() === cat.name.toLowerCase();
            return (
              <button
                key={cat.name}
                type="button"
                onClick={() => {
                  if (isUncategorized) {
                    setSearch('');
                    setFlow(prev => (prev === 'uncategorized' ? 'all' : 'uncategorized'));
                    return;
                  }
                  setSearch(prev => (prev.trim().toLowerCase() === cat.name.toLowerCase() ? '' : cat.name));
                }}
                className={cn(
                  'flex min-w-[160px] shrink-0 flex-col gap-1.5 rounded-lg border p-3 text-left transition-shadow',
                  tone.border, tone.bg,
                  active && 'ring-2 ring-primary/50',
                )}
              >
                <div className="flex items-center justify-between gap-2">
                  <span className={cn('truncate text-sm font-semibold', tone.text)}>{cat.name}</span>
                  <span className="text-xs font-medium text-muted-foreground">{cat.count}</span>
                </div>
                <span className="tracker-number text-sm font-bold text-foreground">{formatCents(cat.total)}</span>
                <div className="h-1.5 w-full overflow-hidden rounded-full bg-background/60">
                  <div className={cn('h-full rounded-full', tone.bar)} style={{ width: `${pct}%` }} />
                </div>
              </button>
            );
          })}
        </section>
      )}

      {accounts.length > 0 && (
        <section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
          {accounts.map(account => (
            <div key={account.id} className="rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm">
              <div className="flex items-start justify-between gap-3">
                <div className="min-w-0">
                  <p className="truncate text-sm font-semibold text-foreground">{accountLabel(account)}</p>
                  <p className="mt-1 truncate text-xs font-medium text-muted-foreground">
                    {[account.account_type, account.currency].filter(Boolean).join(' - ') || 'Bank account'}
                  </p>
                </div>
                <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-indigo-300/40 bg-indigo-400/10 text-indigo-700 dark:text-indigo-200">
                  <Building2 className="h-4 w-4" />
                </span>
              </div>
              <div className="mt-4 grid grid-cols-2 gap-3">
                <div>
                  <p className="text-xs font-medium text-muted-foreground">Balance</p>
                  <p className="tracker-number mt-1 text-lg font-bold text-foreground">{formatCents(account.balance)}</p>
                </div>
                <div>
                  <p className="text-xs font-medium text-muted-foreground">Available</p>
                  <p className="tracker-number mt-1 text-lg font-bold text-emerald-600 dark:text-emerald-300">
                    {account.available_balance == null ? '—' : formatCents(account.available_balance)}
                  </p>
                </div>
              </div>
              <div className="mt-3 flex items-center justify-between text-xs font-medium text-muted-foreground">
                <span>{Number(account.transaction_count || 0)} transactions</span>
                <span>{account.last_transaction_date ? fmtDate(account.last_transaction_date) : 'No activity'}</span>
              </div>
            </div>
          ))}
        </section>
      )}

      <section className="space-y-4">
        <div className="grid gap-3 rounded-lg border border-border/70 bg-card/85 p-3 shadow-sm lg:grid-cols-[minmax(18rem,1fr)_12rem_12rem_10rem_auto]">
          <div 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={event => setSearch(event.target.value)} placeholder="Search merchant, memo, category" className="pl-9" />
          </div>

          <Select value={accountId} onValueChange={setAccountId}>
            <SelectTrigger><SelectValue placeholder="Account" /></SelectTrigger>
            <SelectContent>
              <SelectItem value="all">All accounts</SelectItem>
              {accounts.map(account => (
                <SelectItem key={account.id} value={String(account.id)}>{accountLabel(account)}</SelectItem>
              ))}
            </SelectContent>
          </Select>

          <Select value={flow} onValueChange={setFlow}>
            <SelectTrigger><SelectValue placeholder="Type" /></SelectTrigger>
            <SelectContent>
              {flowOptions.map(option => (
                <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
              ))}
            </SelectContent>
          </Select>

          <Select value={sortBy} onValueChange={setSortBy}>
            <SelectTrigger><SelectValue placeholder="Sort" /></SelectTrigger>
            <SelectContent>
              {sortOptions.map(option => (
                <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
              ))}
            </SelectContent>
          </Select>

          <Button type="button" variant="outline" onClick={() => setSortDir(value => (value === 'asc' ? 'desc' : 'asc'))} title={`Sort ${sortDir === 'asc' ? 'ascending' : 'descending'}`}>
            {sortDir === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />}
            {sortDir === 'asc' ? 'Ascending' : 'Descending'}
          </Button>
        </div>

        {selectedIds.size > 0 && (
          <div className="flex flex-wrap items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 p-3 shadow-sm">
            <span className="text-sm font-medium text-foreground">{selectedIds.size} selected</span>
            <Button type="button" variant="outline" size="sm" disabled={bulkActing} onClick={handleBulkApplySuggestions}>
              <Sparkles className="h-3.5 w-3.5" />
              Apply suggestions
            </Button>
            <CategoryPicker categories={categories} current={null} onSelect={categoryId => handleBulkCategorize(categoryId)} />
            <Button type="button" variant="outline" size="sm" disabled={bulkActing} onClick={() => handleBulkIgnoreToggle(true)}>
              <EyeOff className="h-3.5 w-3.5" />
              Ignore
            </Button>
            <Button type="button" variant="outline" size="sm" disabled={bulkActing} onClick={() => handleBulkIgnoreToggle(false)}>
              <Eye className="h-3.5 w-3.5" />
              Unignore
            </Button>
            <Button type="button" variant="ghost" size="sm" className="ml-auto" disabled={bulkActing} onClick={() => setSelectedIds(new Set())}>
              Clear selection
            </Button>
          </div>
        )}

        <div className="hidden overflow-hidden rounded-lg border border-border/70 bg-card/85 shadow-sm lg:block">
          <Table>
            <TableHeader>
              <TableRow className="bg-muted/35 hover:bg-muted/35">
                <TableHead className="w-10">
                  <Checkbox checked={allOnPageSelected} onCheckedChange={toggleSelectAll} aria-label="Select all transactions on this page" />
                </TableHead>
                <TableHead className="w-32">Date</TableHead>
                <TableHead>Merchant</TableHead>
                <TableHead>Account</TableHead>
                <TableHead>Category</TableHead>
                <TableHead>Status</TableHead>
                <TableHead className="text-right">Amount</TableHead>
                <TableHead className="w-12"></TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {loading && transactions.length === 0 && (
                Array.from({ length: 6 }).map((_, i) => (
                  <TableRow key={`skeleton-${i}`}>
                    {Array.from({ length: TABLE_COLUMN_COUNT }).map((__, j) => (
                      <TableCell key={j}><Skeleton variant="line" /></TableCell>
                    ))}
                  </TableRow>
                ))
              )}
              {!loading && transactions.length === 0 && (
                <TableRow>
                  <TableCell colSpan={TABLE_COLUMN_COUNT} className="py-10 text-center text-muted-foreground">No transactions found.</TableCell>
                </TableRow>
              )}
              {(dateGroups ?? [{ label: null, items: transactions }]).map((group, gi) => (
                <Fragment key={group.label ?? gi}>
                  {group.label && (
                    <TableRow className="bg-muted/20 hover:bg-muted/20">
                      <TableCell colSpan={TABLE_COLUMN_COUNT} className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
                        {group.label}
                      </TableCell>
                    </TableRow>
                  )}
                  {group.items.map(tx => {
                    const cents = Number(tx.amount || 0);
                    const isCredit = cents > 0;
                    return (
                      <TableRow key={tx.id} data-state={selectedIds.has(tx.id) ? 'selected' : undefined}>
                        <TableCell>
                          <Checkbox checked={selectedIds.has(tx.id)} onCheckedChange={() => toggleSelected(tx.id)} aria-label="Select transaction" />
                        </TableCell>
                        <TableCell className="text-muted-foreground">{fmtDate(transactionDate(tx))}</TableCell>
                        <TableCell>
                          <div className="flex min-w-0 items-center gap-2.5">
                            <MerchantAvatar tx={tx} />
                            <div className="min-w-0">
                              <p className="truncate font-semibold text-foreground">{transactionTitle(tx)}</p>
                              <p className="truncate text-xs font-medium text-muted-foreground">
                                {[tx.memo, tx.category].filter(Boolean).join(' - ') || tx.description || 'SimpleFIN'}
                              </p>
                            </div>
                          </div>
                        </TableCell>
                        <TableCell>
                          <div className="min-w-0">
                            <p className="truncate text-sm font-semibold text-foreground">
                              {[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
                            </p>
                            <p className="truncate text-xs font-medium text-muted-foreground">{tx.account_type || tx.currency || 'Bank'}</p>
                          </div>
                        </TableCell>
                        <TableCell>
                          <CategoryPicker categories={categories} current={tx.spending_category_id} onSelect={categoryId => handleCategorize(tx, categoryId)} />
                          <SuggestionBadge tx={tx} onApply={handleApplySuggestion} applying={applyingSuggestionId === tx.id} />
                        </TableCell>
                        <TableCell>
                          <div className="flex flex-wrap gap-2">
                            <StatusBadge tx={tx} />
                            {tx.matched_bill_name && (
                              <Badge className="border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200">
                                <Link2 className="mr-1 h-3 w-3" />
                                {tx.matched_bill_name}
                              </Badge>
                            )}
                          </div>
                        </TableCell>
                        <TableCell className={cn('tracker-number text-right font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
                          {formatCents(cents, { signed: true })}
                        </TableCell>
                        <TableCell>
                          <RowActionsMenu tx={tx} onMatch={setMatchTarget} onUnmatch={handleUnmatch} onIgnore={handleIgnore} onUnignore={handleUnignore} />
                        </TableCell>
                      </TableRow>
                    );
                  })}
                </Fragment>
              ))}
            </TableBody>
          </Table>
        </div>

        <div className="grid gap-3 lg:hidden">
          {loading && transactions.length === 0 && (
            Array.from({ length: 4 }).map((_, i) => <Skeleton key={`skeleton-${i}`} variant="card" />)
          )}
          {!loading && transactions.length === 0 && (
            <div className="rounded-lg border border-border/70 bg-card/85 p-8 text-center text-sm font-medium text-muted-foreground">
              No transactions found.
            </div>
          )}
          {(dateGroups ?? [{ label: null, items: transactions }]).map((group, gi) => (
            <div key={group.label ?? gi} className="space-y-3">
              {group.label && (
                <p className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
              )}
              {group.items.map(tx => (
                <TransactionMobileCard
                  key={tx.id}
                  tx={tx}
                  categories={categories}
                  onCategorize={handleCategorize}
                  onApplySuggestion={handleApplySuggestion}
                  applyingSuggestionId={applyingSuggestionId}
                  onMatch={setMatchTarget}
                  onUnmatch={handleUnmatch}
                  onIgnore={handleIgnore}
                  onUnignore={handleUnignore}
                  selected={selectedIds.has(tx.id)}
                  onToggleSelected={toggleSelected}
                />
              ))}
            </div>
          ))}
        </div>

        <div className="flex flex-col gap-3 rounded-lg border border-border/70 bg-card/85 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:justify-between">
          <p className="text-sm font-medium text-muted-foreground">
            {total === 0
              ? '0 transactions'
              : `${(page - 1) * PAGE_SIZE + 1}-${Math.min(page * PAGE_SIZE, total)} of ${total} transactions`}
          </p>
          <div className="flex items-center gap-2">
            <Button type="button" variant="outline" disabled={page <= 1 || loading} onClick={() => setPage(value => Math.max(1, value - 1))}>
              <ChevronLeft className="h-4 w-4" />
              Previous
            </Button>
            <span className="min-w-20 text-center text-sm font-semibold text-foreground">
              {page} / {totalPages}
            </span>
            <Button type="button" variant="outline" disabled={page >= totalPages || loading} onClick={() => setPage(value => Math.min(totalPages, value + 1))}>
              Next
              <ChevronRight className="h-4 w-4" />
            </Button>
          </div>
        </div>
      </section>

      <MatchBillDialog
        open={!!matchTarget}
        onOpenChange={open => { if (!open) setMatchTarget(null); }}
        transaction={matchTarget}
        bills={bills}
        loading={matchSubmitting}
        onConfirm={handleConfirmMatch}
        onCreateBill={openCreateBill}
      />

      {createBillSourceTx && (
        <BillModal
          key={`create-from-tx-${createBillSourceTx.tx.id}`}
          initialBill={createBillSourceTx.initialBill}
          categories={categories}
          onClose={() => setCreateBillSourceTx(null)}
          onSave={handleBillCreated}
        />
      )}

      <Dialog open={!!autoCategorizePreview} onOpenChange={open => { if (!open) setAutoCategorizePreview(null); }}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Auto-categorize transactions</DialogTitle>
            <DialogDescription>
              {autoCategorizePreview?.changes?.length} transaction{autoCategorizePreview?.changes?.length === 1 ? '' : 's'} will be
              categorized based on the merchant/store reference list. This also saves merchant
              rules, so future transactions from these merchants will be categorized
              automatically too.
            </DialogDescription>
          </DialogHeader>
          <div className="flex flex-wrap gap-2">
            {(autoCategorizePreview?.categories || []).map(cat => {
              const tone = categoryColor(cat.name);
              return (
                <Badge key={cat.name} className={cn(tone.border, tone.bg, tone.text)}>
                  {cat.name} · {cat.count}
                </Badge>
              );
            })}
          </div>
          <DialogFooter>
            <Button type="button" variant="outline" onClick={() => setAutoCategorizePreview(null)}>
              Cancel
            </Button>
            <Button type="button" onClick={handleConfirmAutoCategorize} disabled={autoCategorizing}>
              <Sparkles className={cn('h-4 w-4', autoCategorizing && 'animate-pulse')} />
              Apply
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}
