import { Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Fragment, useEffect, useMemo, useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { supabase } from "@/integrations/supabase/client";
import { useCompanyId } from "./lib/useCompanyId";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RTooltip, Legend, ResponsiveContainer } from "recharts";
import { FileText, Download, FileDown, Eye, RefreshCw, Layers, Trash2, Loader2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { generateBatchPdf, BATCHABLE_REPORTS } from "./lib/batchReports";
import { toast } from "sonner";
import { money, fmtDate } from "./lib/format";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import {
  renderReportPdf, fmtAmount,
  type StructuredReport, type StructuredRow,
} from "./lib/reportPdf";
import { reconcile, type RecAccount, type RecLine, type RecCheck } from "./lib/reconcile";
import {
  computePnL, computeMargins, toMinor, fromMinor, PnlValidationError,
  type PnlAccount, type PnlClassification, type Posting as PnlPosting, type PnlResult,
} from "./lib/pnl";
import { Lock } from "lucide-react";
import { TrialBalanceReport } from "./components/TrialBalanceReport";
import { GeneralLedgerReport } from "./components/GeneralLedgerReport";
import { ReserveFundReport } from "./components/ReserveFundReport";
import { ARAgingPropertyReport } from "./components/ARAgingPropertyReport";
import { PrepaidHomeownersReport } from "./components/PrepaidHomeownersReport";
import { CashDisbursementReport } from "./components/CashDisbursementReport";
import { ReportSheet } from "./components/ReportSheet";
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo } from "./lib/reportHeader";
import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";

type ReportId =
  | "pnl" | "income-statement" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
  | "trial-balance" | "general-ledger"
  | "invoice-summary" | "customer-balances" | "ar-aging" | "ar-aging-property" | "prepaid-homeowners" | "homeowner-summary" | "delinquency"
  | "expense-summary" | "vendor-balances" | "ap-aging" | "cash-disbursement" | "reconciliation"
  | "reserve-fund";

const APP_NAME = "Cozy Books";
const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of-equity"];

const GROUPS = [
  { name: "Business Overview", reports: [
    { id: "pnl" as ReportId, name: "Profit & Loss" },
    { id: "balance-sheet" as ReportId, name: "Balance Sheet" },
    { id: "cash-flow" as ReportId, name: "Cash Flow Statement" },
    { id: "movement-of-equity" as ReportId, name: "Movement of Equity" },
    { id: "trial-balance" as ReportId, name: "Trial Balance" },
    { id: "general-ledger" as ReportId, name: "General Ledger" },
    { id: "budget-vs-actuals" as ReportId, name: "Budget vs Actuals" },
  ]},
  { name: "Receivables", reports: [
    { id: "ar-aging-property" as ReportId, name: "AR Aging (Property)" },
    { id: "prepaid-homeowners" as ReportId, name: "Pre-Paid Homeowners" },
    { id: "ar-aging" as ReportId, name: "AR Aging Details" },
    { id: "homeowner-summary" as ReportId, name: "Homeowner Balance Summary" },
    { id: "customer-balances" as ReportId, name: "Invoice Summary by Customer" },
    { id: "invoice-summary" as ReportId, name: "Invoice Summary" },
    { id: "delinquency" as ReportId, name: "Delinquency Report" },
  ]},
  { name: "Payables", reports: [
    { id: "cash-disbursement" as ReportId, name: "Cash Disbursement" },
    { id: "ap-aging" as ReportId, name: "AP Aging Details" },
    { id: "expense-summary" as ReportId, name: "Expense Summary" },
    { id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
  ]},
  { name: "Reserves", reports: [
    { id: "reserve-fund" as ReportId, name: "Reserve Fund Schedule" },
  ]},
  { name: "Audit", reports: [
    { id: "reconciliation" as ReportId, name: "Reconciliation Checks" },
  ]},
];

const TZ_ET = "America/New_York";
function etDateStr(d = new Date()) { return d.toLocaleDateString("en-CA", { timeZone: TZ_ET }); } // YYYY-MM-DD in ET
function startOfYear() { return `${new Date().toLocaleDateString("en-US", { timeZone: TZ_ET, year: "numeric" })}-01-01`; }
function today() { return etDateStr(); }
function shiftBack(from: string, to: string) {
  const d1 = new Date(from), d2 = new Date(to);
  const span = d2.getTime() - d1.getTime();
  const pTo = new Date(d1.getTime() - 86400000);
  const pFrom = new Date(pTo.getTime() - span);
  return { from: pFrom.toISOString().slice(0, 10), to: pTo.toISOString().slice(0, 10) };
}

// PostgREST caps each response at 1000 rows. Reports aggregate GL lines
// client-side, so for companies with >1000 journal lines we must page through
// all of them — otherwise balances are truncated (accounts whose activity
// falls past row 1000 silently read as $0). Order by a stable key (id) so the
// pages don't overlap or skip rows.
const GL_PAGE = 1000;
async function fetchAllGLLines(cid: string, to: string, select: string, from?: string, includeArchived = false): Promise<any[]> {
  const out: any[] = [];
  for (let offset = 0; ; offset += GL_PAGE) {
    let q = accounting
      .from("journal_entry_lines")
      .select(select)
      .eq("journal_entries.company_id", cid)
      .lte("journal_entries.date", to);
    // Archived accounts are normally kept off the financial statements, BUT an
    // archived account that still holds a balance MUST stay on them — otherwise
    // its balance is silently dropped and the Balance Sheet goes out of balance
    // by exactly that amount. Including archived accounts here is safe: this
    // queries journal lines, so archived accounts with no activity never appear.
    if (!includeArchived) q = q.eq("accounts.is_archived", false);
    if (from) q = q.gte("journal_entries.date", from);
    const { data, error } = await q.order("id", { ascending: true }).range(offset, offset + GL_PAGE - 1);
    if (error) throw error;
    const rows = (data ?? []) as any[];
    out.push(...rows);
    if (rows.length < GL_PAGE) break;
  }
  return out;
}

// Shared fetch for the financial reports (also used by the report-batch engine).
export async function fetchReportData(cid: string, from: string, to: string) {
      const ytdStart = new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0,10);
      const [inv, bills, accs, exp, custs, vends, ob, ytdInv, ytdExp, ytdBills, allBills, glRes, glCumRes, allInvRes, companyRes, periodBillItemsRes] = await Promise.all([
        accounting.from("invoices").select("number,total,paid_amount,status,issue_date,customers(name)").eq("company_id", cid).gte("issue_date", from).lte("issue_date", to),
        accounting.from("bills").select("number,total,paid_amount,status,issue_date,due_date,vendors(name)").eq("company_id", cid).gte("issue_date", from).lte("issue_date", to),
        accounting.from("accounts").select("id,name,code,type,subtype,balance,is_bank,parent_account_id").eq("company_id", cid).eq("is_archived", false),
        accounting.from("expenses").select("date,category,amount,vendor_name,vendors(name)").eq("company_id", cid).gte("date", from).lte("date", to),
        accounting.from("customers").select("id,name,balance,email,phone,property_address,lot_number").eq("company_id", cid).order("name"),
        accounting.from("vendors").select("id,name").eq("company_id", cid),
        accounting.from("opening_balances").select("account_id,debit,credit").eq("company_id", cid),
        accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid).gte("issue_date", ytdStart).lte("issue_date", to),
        accounting.from("expenses").select("amount,date").eq("company_id", cid).gte("date", ytdStart).lte("date", to),
        // YTD bills (accrual — for net income in Balance Sheet / Movement of Equity)
        accounting.from("bills").select("total,status,issue_date").eq("company_id", cid).gte("issue_date", ytdStart).lte("issue_date", to),
        // All bills (not date-filtered) for AP aging
        accounting.from("bills").select("id,vendor_id,total,paid_amount,status,due_date,issue_date,vendors(id,name)").eq("company_id", cid),
        // General-ledger lines in period — P&L is built from these, grouped by account
        fetchAllGLLines(cid, to, "id,debit,credit,accounts!inner(id,name,code,type,parent_account_id),journal_entries!inner(company_id,date)", from, true),
        // Cumulative GL through `to` — Balance Sheet is built from these (as-of balances)
        fetchAllGLLines(cid, to, "id,debit,credit,account_id,accounts!inner(id,name,code,type),journal_entries!inner(company_id,date)", undefined, true),
        // All invoices (not date-filtered) — Accounts Receivable = unpaid invoices
        accounting.from("invoices").select("total,paid_amount,status").eq("company_id", cid),
        // Whether the platform manages this company's GL (A/R-A/P sub-ledgers tie to the GL).
        // Imported-GL companies (gl_auto_post=false) keep their own AR/AP, so the sub-ledger
        // vs GL control reconciliation (R7/R8) does not apply to them.
        accounting.from("companies").select("gl_auto_post").eq("id", cid).maybeSingle(),
        // Bill items for bills issued in the period — lets the Expense Summary
        // back out the expense of bills that aren't paid yet (cash-aware view).
        accounting.from("bill_items").select("account_id,amount,bills!inner(total,paid_amount,status,issue_date,company_id)").eq("bills.company_id", cid).gte("bills.issue_date", from).lte("bills.issue_date", to).not("account_id", "is", null),
      ]);
      return {
        invoices: inv.data ?? [], bills: bills.data ?? [], accounts: accs.data ?? [],
        expenses: exp.data ?? [], customers: custs.data ?? [], vendors: vends.data ?? [],
        openingBalances: ob.data ?? [],
        ytdInvoices: ytdInv.data ?? [], ytdExpenses: ytdExp.data ?? [], ytdBills: ytdBills.data ?? [],
        allBills: allBills.data ?? [],
        glLines: glRes ?? [],
        glCumulative: glCumRes ?? [],
        allInvoices: allInvRes.data ?? [],
        glManaged: companyRes.data ? companyRes.data.gl_auto_post !== false : true,
        periodBillItems: periodBillItemsRes.data ?? [],
        from, asOf: to,
      };
}

function useReportData(cid: string, from: string, to: string) {
  return useQuery({
    queryKey: ["reports-data", cid, from, to],
    enabled: !!cid,
    queryFn: () => fetchReportData(cid, from, to),
  });
}

// ── Period preset helpers ────────────────────────────────────────────────────

function etNow() {
  // Current date/time as a Date object normalised to ET by parsing the ET date string
  const etStr = new Date().toLocaleDateString("en-US", { timeZone: TZ_ET, year: "numeric", month: "2-digit", day: "2-digit" });
  return new Date(etStr); // local Date at ET midnight
}
function startOfMonth(d = etNow()) {
  return new Date(d.getFullYear(), d.getMonth(), 1).toLocaleDateString("en-CA");
}
function endOfMonth(d = etNow()) {
  return new Date(d.getFullYear(), d.getMonth() + 1, 0).toLocaleDateString("en-CA");
}
function startOfLastMonth() {
  const d = etNow(); d.setMonth(d.getMonth() - 1);
  return startOfMonth(d);
}
function endOfLastMonth() {
  const d = etNow(); d.setMonth(d.getMonth() - 1);
  return endOfMonth(d);
}
function startOfLastYear() {
  return `${etNow().getFullYear() - 1}-01-01`;
}
function endOfLastYear() {
  return `${etNow().getFullYear() - 1}-12-31`;
}

type Preset = "this-month" | "last-month" | "this-quarter" | "ytd" | "last-year" | "custom";
type CompareMode = "none" | "prior-period" | "prior-year" | "custom";

function presetDates(p: Preset): { from: string; to: string } {
  const now = new Date();
  const q = Math.floor(now.getMonth() / 3);
  switch (p) {
    case "this-month":    return { from: startOfMonth(), to: endOfMonth() };
    case "last-month":    return { from: startOfLastMonth(), to: endOfLastMonth() };
    case "this-quarter":  return { from: new Date(now.getFullYear(), q * 3, 1).toISOString().slice(0, 10), to: today() };
    case "ytd":           return { from: startOfYear(), to: today() };
    case "last-year":     return { from: startOfLastYear(), to: endOfLastYear() };
    default:              return { from: startOfYear(), to: today() };
  }
}

function compareDates(mode: CompareMode, from: string, to: string, customFrom: string, customTo: string) {
  if (mode === "none") return null;
  if (mode === "prior-period") return shiftBack(from, to);
  if (mode === "prior-year") {
    const py = (d: string) => `${parseInt(d.slice(0, 4)) - 1}${d.slice(4)}`;
    return { from: py(from), to: py(to) };
  }
  if (mode === "custom") return { from: customFrom, to: customTo };
  return null;
}

export default function AccountingReportsPage({ association }: { association?: { id: string; name?: string } | null } = {}) {
  const { companyId, associationName, associationId } = useCompanyId(association);
  const qc = useQueryClient();
  const [refreshing, setRefreshing] = useState(false);
  const refreshReport = async () => {
    setRefreshing(true);
    await qc.invalidateQueries();
    setTimeout(() => setRefreshing(false), 600);
  };
  const cid = companyId ?? "";
  const cur = "USD";
  const [active, setActive] = useState<ReportId>("pnl");
  // Drill-down: clicking an account amount in a financial report opens the
  // General Ledger focused on that account (its transaction list for the COA).
  const [drillAccountId, setDrillAccountId] = useState<string | null>(null);
  const drillToAccount = (accountId: string) => { setDrillAccountId(accountId); setActive("general-ledger"); };

  // Period
  const [preset, setPreset] = useState<Preset>("ytd");
  const [from, setFrom] = useState(startOfMonth());
  const [to, setTo] = useState(today());

  // ── Report batches (saved per-company report packets) ──
  const [batchOpen, setBatchOpen] = useState(false);
  const [batchName, setBatchName] = useState("");
  const [batchReportIds, setBatchReportIds] = useState<string[]>(["balance-sheet", "pnl", "trial-balance", "general-ledger"]);
  const [batchLoadedId, setBatchLoadedId] = useState<string | null>(null);
  const [generatingBatch, setGeneratingBatch] = useState(false);
  const { data: savedBatches = [], refetch: refetchBatches } = useQuery({
    queryKey: ["report-batches", cid],
    enabled: !!cid,
    queryFn: async () => (await accounting.from("report_batches").select("*").eq("company_id", cid).order("name")).data ?? [],
  });

  const toggleBatchReport = (id: string) =>
    setBatchReportIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);

  const saveBatch = async () => {
    const name = batchName.trim();
    if (!name) { toast.error("Name the batch first"); return; }
    if (batchReportIds.length === 0) { toast.error("Select at least one report"); return; }
    if (batchLoadedId) {
      const { error } = await accounting.from("report_batches").update({ name, report_ids: batchReportIds, updated_at: new Date().toISOString() }).eq("id", batchLoadedId);
      if (error) { toast.error(error.message); return; }
    } else {
      const { data, error } = await accounting.from("report_batches").insert({ company_id: cid, name, report_ids: batchReportIds }).select("id").single();
      if (error) { toast.error(error.message); return; }
      setBatchLoadedId(data.id);
    }
    toast.success(`Batch "${name}" saved`);
    refetchBatches();
  };

  const loadBatch = (b: any) => {
    setBatchLoadedId(b.id); setBatchName(b.name);
    setBatchReportIds(Array.isArray(b.report_ids) ? b.report_ids : []);
  };
  const deleteBatch = async (id: string) => {
    await accounting.from("report_batches").delete().eq("id", id);
    if (batchLoadedId === id) { setBatchLoadedId(null); setBatchName(""); }
    refetchBatches();
    toast.success("Batch deleted");
  };

  const generateBatch = async () => {
    if (batchReportIds.length === 0) { toast.error("Select at least one report"); return; }
    setGeneratingBatch(true);
    try {
      const doc = await generateBatchPdf({
        companyId: cid, companyName: associationName ?? "Company",
        from, to, currency: cur, fetchReportData, buildFinancial,
      }, batchReportIds);
      const slug = (associationName ?? "report").replace(/[^a-z0-9]+/gi, "-").toLowerCase().slice(0, 40);
      doc.save(`${slug}-report-packet-${from}-to-${to}.pdf`);
      toast.success("Report package generated");
    } catch (e: any) {
      toast.error(e?.message || "Could not generate package");
    } finally {
      setGeneratingBatch(false);
    }
  };

  const applyPreset = (p: Preset) => {
    setPreset(p);
    if (p !== "custom") {
      const d = presetDates(p);
      setFrom(d.from);
      setTo(d.to);
    }
  };

  // Comparison
  const [compareMode, setCompareMode] = useState<CompareMode>("none");
  const [compareFrom, setCompareFrom] = useState(startOfLastYear());
  const [compareTo, setCompareTo] = useState(endOfLastYear());
  const showCompare = compareMode !== "none";
  const comparePeriod = compareDates(compareMode, from, to, compareFrom, compareTo);

  // Toggles
  const [showCodes, setShowCodes] = useState(false);
  const [showZero, setShowZero] = useState(false);
  const [pnlMonthView, setPnlMonthView] = useState(false);

  const { data: companyMeta } = useQuery({
    queryKey: ["company-fy", cid],
    enabled: !!cid,
    queryFn: async () => (await accounting.from("companies").select("fiscal_year_start").eq("id", cid).maybeSingle()).data,
  });
  const fiscalYearStart = (companyMeta as any)?.fiscal_year_start || "01-01";

  // Association logo for branded reports (ACM fallback handled downstream).
  const { data: assocMeta } = useQuery({
    queryKey: ["assoc-logo", associationId],
    enabled: !!associationId,
    queryFn: async () => (await supabase.from("associations").select("logo_url").eq("id", associationId!).maybeSingle()).data,
  });
  const logoUrl = (assocMeta as any)?.logo_url || null;
  // Preloaded logo dataURL for synchronous PDF header drawing.
  const { data: brandedLogo } = useQuery({
    queryKey: ["branded-logo", logoUrl],
    queryFn: async () => await loadBrandedLogo(logoUrl),
  });

  const { data } = useReportData(cid, from, to);
  const { data: prevData } = useReportData(
    showCompare && comparePeriod ? cid : "",
    comparePeriod?.from ?? from,
    comparePeriod?.to ?? to
  );

  // AR open invoices — used by AR aging, homeowner summary, delinquency
  const arReports: ReportId[] = ["customer-balances", "ar-aging", "homeowner-summary", "delinquency"];
  const { data: arOpen = [] } = useQuery({
    queryKey: ["ar-aging", cid],
    enabled: !!cid && arReports.includes(active),
    queryFn: async () => {
      const { data } = await accounting
        .from("invoices")
        .select("id,customer_id,total,paid_amount,due_date,issue_date,status,number,customers(id,name)")
        .eq("company_id", cid);
      return data ?? [];
    },
  });

  const isFinancial = FINANCIAL.includes(active);
  const activeMeta = GROUPS.flatMap(g => g.reports).find(r => r.id === active)!;
  const rangeLabel = active === "balance-sheet"
    ? `As of ${fmtDate(to)}`
    : `${fmtDate(from)} – ${fmtDate(to)}`;

  const structured = useMemo<StructuredReport | null>(() => {
    if (!data || !isFinancial) return null;
    return buildFinancial(active, data, prevData, showCompare);
  }, [active, data, prevData, showCompare, isFinancial]);

  const flat = useMemo(() => (data && !isFinancial) ? buildFlat(active, data, cur) : null, [active, data, cur, isFinancial]);

  // Build exportable flat data for custom-rendered reports (aging, delinquency, etc.)
  const exportFlat = useMemo((): Flat | null => {
    if (structured || flat) return null; // already have a better source
    const m = (n: number) => money(n, cur);
    const now = new Date();

    if (active === "reconciliation") {
      if (!data) return null;
      const checks = buildReconChecks(data);
      return {
        title: "Reconciliation Checks",
        columns: ["Check", "Residual", "Status"],
        rows: checks.map((c) => [`${c.id}  ${c.label}`, m(c.residual), c.pass ? "Pass" : "FAIL"]),
        boldRows: [],
      };
    }

    if (active === "ar-aging" || active === "customer-balances") {
      type AR = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
      const byC = new Map<string, AR>();
      for (const inv of arOpen as any[]) {
        const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
        if (open <= 0) continue;
        const cid2 = inv.customer_id; if (!cid2) continue;
        const name = inv.customers?.name ?? "—";
        const days = Math.floor((now.getTime() - new Date(inv.due_date ?? inv.issue_date).getTime()) / 86400000);
        const r = byC.get(cid2) ?? { name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
        if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open;
        r.total += open; byC.set(cid2, r);
      }
      const list = [...byC.values()].sort((a, b) => b.total - a.total);
      const tot = list.reduce((s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }), { current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 });
      return {
        title: activeMeta.name, columns: ["Homeowner", "Current", "1-30 days", "31-60 days", "61-90 days", "90+ days", "Total"],
        rows: [...list.map(r => [r.name, m(r.current), m(r.d30), m(r.d60), m(r.d90), m(r.d90p), m(r.total)]),
               ["TOTAL", m(tot.current), m(tot.d30), m(tot.d60), m(tot.d90), m(tot.d90p), m(tot.total)]],
        boldRows: [list.length],
      };
    }

    if (active === "ap-aging") {
      type AP = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
      const byV = new Map<string, AP>();
      for (const b of ((data as any)?.allBills ?? []) as any[]) {
        const open = Number(b.total ?? 0) - Number(b.paid_amount ?? 0);
        if (open <= 0) continue;
        const vid = b.vendor_id ?? b.id; const name = b.vendors?.name ?? "Unknown";
        const days = Math.floor((now.getTime() - new Date(b.due_date ?? b.issue_date ?? Date.now()).getTime()) / 86400000);
        const r = byV.get(vid) ?? { name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
        if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open;
        r.total += open; byV.set(vid, r);
      }
      const list = [...byV.values()].sort((a, b) => b.total - a.total);
      return {
        title: "AP Aging Details", columns: ["Vendor", "Current", "1-30 days", "31-60 days", "61-90 days", "90+ days", "Total"],
        rows: list.map(r => [r.name, m(r.current), m(r.d30), m(r.d60), m(r.d90), m(r.d90p), m(r.total)]),
      };
    }

    if (active === "homeowner-summary") {
      const customers = (data as any)?.customers ?? [];
      const openByC = new Map<string, { open: number; count: number }>();
      for (const inv of arOpen as any[]) {
        const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
        if (open > 0) { const e = openByC.get(inv.customer_id) ?? { open: 0, count: 0 }; e.open += open; e.count++; openByC.set(inv.customer_id, e); }
      }
      const rows = (customers as any[])
        .map((c: any) => [c.name, c.property_address ?? "—", c.lot_number ? `Lot ${c.lot_number}` : "—", String(openByC.get(c.id)?.count ?? 0), m(openByC.get(c.id)?.open ?? 0)])
        .sort((a, b) => parseFloat(String(b[4]).replace(/[^0-9.]/g, "")) - parseFloat(String(a[4]).replace(/[^0-9.]/g, "")));
      return { title: "Homeowner Balance Summary", columns: ["Homeowner", "Property", "Lot", "Open Invoices", "Outstanding"] , rows };
    }

    if (active === "delinquency") {
      const customers = (data as any)?.customers ?? [];
      const overdue = new Map<string, { name: string; property: string; email: string; phone: string; amount: number; oldest: number }>();
      for (const inv of arOpen as any[]) {
        const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
        if (open <= 0) continue;
        const due = new Date(inv.due_date ?? inv.issue_date);
        const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
        if (days <= 0) continue;
        const cust = (customers as any[]).find((c: any) => c.id === inv.customer_id);
        const r = overdue.get(inv.customer_id) ?? { name: cust?.name ?? "—", property: cust?.property_address ?? "—", email: cust?.email ?? "—", phone: cust?.phone ?? "—", amount: 0, oldest: 0 };
        r.amount += open; r.oldest = Math.max(r.oldest, days); overdue.set(inv.customer_id, r);
      }
      const list = [...overdue.values()].sort((a, b) => b.amount - a.amount);
      return {
        title: "Delinquency Report", columns: ["Homeowner", "Property", "Email", "Phone", "Days Overdue", "Amount Overdue"],
        rows: list.map(r => [r.name, r.property, r.email, r.phone, String(r.oldest), m(r.amount)]),
      };
    }

    return null;
  }, [active, arOpen, data, flat, structured, cur, activeMeta.name]);

  // Reports whose export is handled internally (own PDF/CSV buttons inside the component)
  const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals"
    || (active === "pnl" && pnlMonthView)
    || active === "ar-aging-property" || active === "prepaid-homeowners" || active === "cash-disbursement";
  const anyExportable = !!(structured || flat || exportFlat);

  const doExportPDF = async () => {
    const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
    const src = flat ?? exportFlat;
    const logo = brandedLogo ?? (await loadBrandedLogo(logoUrl));
    if (structured) {
      const doc = renderReportPdf(
        structured,
        { companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero, logo },
      );
      doc.save(`${fileBase}.pdf`);
    } else if (src) {
      const doc = new jsPDF({ unit: "pt", format: "letter", orientation: src.columns.length > 6 ? "landscape" : "portrait" });
      const startY = drawBrandedHeader(doc, {
        logo, title: src.title,
        metaLines: [{ label: "Properties:", value: associationName ?? "" }, { label: "Period:", value: rangeLabel }],
      });
      const lastCol = src.columns.length - 1;
      autoTable(doc, {
        head: [src.columns],
        body: src.rows.map(r => r.map(String)),
        startY,
        margin: { left: 40, right: 40 },
        styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 },
        headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 },
        alternateRowStyles: { fillColor: [247, 248, 250] },
        columnStyles: { [lastCol]: { halign: "right" } },
        didParseCell: ({ row, cell }) => { if (src.boldRows?.includes(row.index)) cell.styles.fontStyle = "bold"; },
      });
      drawBrandedFooter(doc);
      doc.save(`${fileBase}.pdf`);
    } else {
      toast.error("No data to export for this report");
    }
  };

  const doExportCSV = () => {
    const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
    let lines: string[] = [];
    if (structured) {
      lines.push(["Account", "Amount", showCompare ? "Previous" : ""].filter(Boolean).join(","));
      for (const r of structured.rows) {
        if (r.kind === "spacer") continue;
        if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) continue;
        const label = (r.kind === "sub" ? "  " : "") + (showCodes && r.code ? `${r.code} ${r.label}` : r.label);
        const cells = [`"${label}"`, fmtAmount(r.amount)];
        if (showCompare) cells.push(fmtAmount(r.compare));
        lines.push(cells.join(","));
      }
    } else {
      const src = flat ?? exportFlat;
      if (src) {
        lines.push(src.columns.map(c => `"${c}"`).join(","));
        for (const r of src.rows) lines.push(r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(","));
      }
    }
    if (!lines.length) { toast.error("No data to export"); return; }
    const blob = new Blob([lines.join("\n")], { type: "text/csv" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob); a.download = `${fileBase}.csv`; a.click();
  };

  // Keep old names for any remaining references
  const exportPDF = doExportPDF;
  const exportCSV = doExportCSV;

  return (
    <div className="flex gap-6">
      <aside className="w-60 shrink-0">
        <Card>
          <CardContent className="p-3 space-y-4">
            {GROUPS.map((g) => (
              <div key={g.name}>
                <div className="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{g.name}</div>
                <ul className="space-y-0.5">
                  {g.reports.map((r) => (
                    <li key={r.id}>
                      <button
                        onClick={() => setActive(r.id)}
                        className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors ${active === r.id ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
                      >
                        <FileText className="h-3.5 w-3.5 shrink-0" />
                        <span className="truncate">{r.name}</span>
                      </button>
                    </li>
                  ))}
                </ul>
              </div>
            ))}
          </CardContent>
        </Card>
      </aside>

      <div className="min-w-0 flex-1 space-y-4">
        <div className="space-y-3">
          <div className="flex flex-wrap items-end justify-between gap-3">
            <div>
              <h1 className="text-2xl font-semibold">{activeMeta.name}</h1>
              <p className="text-sm text-muted-foreground">{rangeLabel}</p>
            </div>
            <div className="flex gap-2">
              <Button variant="outline" onClick={() => setBatchOpen(true)}>
                <Layers className="mr-1 h-4 w-4" /> Report Batches
              </Button>
              <Button variant="outline" onClick={refreshReport} disabled={refreshing}>
                <RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} /> Refresh
              </Button>
              {hasOwnExport ? (
                <span className="text-xs text-muted-foreground self-center">Export available inside the report ↓</span>
              ) : (
                <>
                  <Button variant="outline" onClick={exportCSV} disabled={!anyExportable}><Download className="mr-1 h-4 w-4" /> CSV</Button>
                  <Button onClick={exportPDF} disabled={!anyExportable}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
                </>
              )}
            </div>
          </div>

          {/* Period presets */}
          <Card>
            <CardContent className="py-3 px-4 space-y-3">
              <div className="flex flex-wrap items-center gap-2">
                <span className="text-xs font-medium text-muted-foreground w-14">Period</span>
                {([
                  { v: "this-month", l: "This Month" },
                  { v: "last-month", l: "Last Month" },
                  { v: "this-quarter", l: "This Quarter" },
                  { v: "ytd", l: "YTD" },
                  { v: "last-year", l: "Last Year" },
                  { v: "custom", l: "Custom" },
                ] as { v: Preset; l: string }[]).map(({ v, l }) => (
                  <button key={v} onClick={() => applyPreset(v)}
                    className={`rounded-md px-3 py-1 text-xs font-medium border transition-colors ${preset === v ? "bg-primary text-primary-foreground border-primary" : "bg-background border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"}`}>
                    {l}
                  </button>
                ))}
                {preset === "custom" && (
                  <div className="flex items-center gap-2 ml-2">
                    <Input type="date" value={from} onChange={e => setFrom(e.target.value)} className="h-7 w-36 text-xs" />
                    <span className="text-muted-foreground text-xs">to</span>
                    <Input type="date" value={to} onChange={e => setTo(e.target.value)} className="h-7 w-36 text-xs" />
                  </div>
                )}
              </div>

              {/* Comparison period — only for financial reports */}
              {isFinancial && (
                <div className="flex flex-wrap items-center gap-2 border-t pt-3">
                  <span className="text-xs font-medium text-muted-foreground w-14">Compare</span>
                  {([
                    { v: "none", l: "None" },
                    { v: "prior-period", l: "Prior Period" },
                    { v: "prior-year", l: "Prior Year" },
                    { v: "custom", l: "Custom" },
                  ] as { v: CompareMode; l: string }[]).map(({ v, l }) => (
                    <button key={v} onClick={() => setCompareMode(v)}
                      className={`rounded-md px-3 py-1 text-xs font-medium border transition-colors ${compareMode === v ? "bg-primary text-primary-foreground border-primary" : "bg-background border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"}`}>
                      {l}
                    </button>
                  ))}
                  {compareMode === "custom" && (
                    <div className="flex items-center gap-2 ml-2">
                      <Input type="date" value={compareFrom} onChange={e => setCompareFrom(e.target.value)} className="h-7 w-36 text-xs" />
                      <span className="text-muted-foreground text-xs">to</span>
                      <Input type="date" value={compareTo} onChange={e => setCompareTo(e.target.value)} className="h-7 w-36 text-xs" />
                    </div>
                  )}
                </div>
              )}

              {/* Display toggles */}
              <div className="flex flex-wrap gap-4 border-t pt-3">
                <Toggle id="t-codes" checked={showCodes} onChange={setShowCodes} label="Account codes" />
                <Toggle id="t-zero" checked={showZero} onChange={setShowZero} label="Zero-balance accounts" />
                {active === "pnl" && (
                  <Toggle id="t-pnl-month" checked={pnlMonthView} onChange={setPnlMonthView} label="Monthly columns" />
                )}
              </div>
            </CardContent>
          </Card>
        </div>
        {active === "budget-vs-actuals" && (
          <BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} logoUrl={logoUrl} />
        )}
        {active === "pnl" && pnlMonthView && (
          <IncomeStatementReport companyId={cid} companyName={associationName ?? "Company"} from={from} to={to} fiscalYearStart={fiscalYearStart} currency={cur} logoUrl={logoUrl} />
        )}
        {active === "trial-balance" && (
          <TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
        )}
        {active === "general-ledger" && (
          <GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} initialAccountId={drillAccountId} from={from} to={to} />
        )}
        {active === "reserve-fund" && (
          <ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} to={to} />
        )}
        {active === "ar-aging-property" && (
          <ARAgingPropertyReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
        )}
        {active === "prepaid-homeowners" && (
          <PrepaidHomeownersReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
        )}
        {active === "cash-disbursement" && (
          <CashDisbursementReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} from={from} to={to} />
        )}
        {active === "reconciliation" && (
          <ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
            <ReconciliationReport d={data} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} />
          </ReportSheet>
        )}
        {isFinancial && !(active === "pnl" && pnlMonthView) && (
          !data ? (
            <Card><CardContent className="p-6"><div className="py-8 text-center text-sm text-muted-foreground">Loading…</div></CardContent></Card>
          ) : structured ? (
            <ReportSheet title={activeMeta.name} subtitle="Accrual basis" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
              <StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} onDrill={drillToAccount} />
            </ReportSheet>
          ) : (
            <Card><CardContent className="p-6"><div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div></CardContent></Card>
          )
        )}
        {!isFinancial && active !== "budget-vs-actuals" && active !== "income-statement" && active !== "trial-balance" && active !== "general-ledger" && active !== "reserve-fund" && active !== "reconciliation" && active !== "ar-aging-property" && active !== "prepaid-homeowners" && active !== "cash-disbursement" && (
        <ReportSheet title={activeMeta.name} companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
            {!data ? (
              <div className="text-sm text-muted-foreground">Loading…</div>
            ) : structured ? (
              <StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} />
            ) : active === "customer-balances" ? (
              <ARAgingTable rows={arOpen as any[]} currency={cur} />
            ) : active === "ar-aging" ? (
              <ARAgingTable rows={arOpen as any[]} currency={cur} detailed />
            ) : active === "ap-aging" ? (
              <APAgingTable rows={(data as any)?.allBills ?? []} currency={cur} />
            ) : active === "homeowner-summary" ? (
              <HomeownerSummaryTable customers={(data as any)?.customers ?? []} invoices={arOpen as any[]} currency={cur} />
            ) : active === "delinquency" ? (
              <DelinquencyTable customers={(data as any)?.customers ?? []} invoices={arOpen as any[]} currency={cur} />
            ) : flat && flat.rows.length > 0 ? (
              <Table>
                <TableHeader>
                  <TableRow>{flat.columns.map((c, i) => (
                    <TableHead key={c} className={i === flat.columns.length - 1 ? "text-right" : ""}>{c}</TableHead>
                  ))}</TableRow>
                </TableHeader>
                <TableBody>
                  {flat.rows.map((row, i) => (
                    <TableRow key={i} className={flat.boldRows?.includes(i) ? "font-semibold bg-muted/30" : ""}>
                      {row.map((cell, j) => (
                        <TableCell key={j} className={j === row.length - 1 ? "text-right" : ""}>{cell}</TableCell>
                      ))}
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            ) : (
              <div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div>
            )}
        </ReportSheet>
        )}
      </div>

      {/* ── Report Batches dialog ── */}
      <Dialog open={batchOpen} onOpenChange={setBatchOpen}>
        <DialogContent className="max-w-lg">
          <DialogHeader><DialogTitle>Report Batches</DialogTitle></DialogHeader>
          <div className="space-y-4">
            <p className="text-sm text-muted-foreground">
              Pick a set of reports to combine into one PDF for <strong>{associationName ?? "this association"}</strong>.
              The package uses the period selected on this page (<span className="font-medium">{rangeLabel}</span>).
            </p>

            {savedBatches.length > 0 && (
              <div>
                <Label className="text-xs uppercase text-muted-foreground">Saved batches</Label>
                <div className="mt-1 space-y-1">
                  {(savedBatches as any[]).map((b) => (
                    <div key={b.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${batchLoadedId === b.id ? "border-primary bg-primary/5" : ""}`}>
                      <button className="flex-1 text-left truncate" onClick={() => loadBatch(b)}>
                        {b.name} <span className="text-xs text-muted-foreground">· {(b.report_ids ?? []).length} reports</span>
                      </button>
                      <Button size="icon" variant="ghost" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => deleteBatch(b.id)}>
                        <Trash2 className="h-3.5 w-3.5" />
                      </Button>
                    </div>
                  ))}
                </div>
              </div>
            )}

            <div>
              <Label className="text-xs">Batch name</Label>
              <Input value={batchName} onChange={(e) => setBatchName(e.target.value)} placeholder="e.g. Monthly Board Package" className="mt-1" />
            </div>

            <div>
              <Label className="text-xs uppercase text-muted-foreground">Reports (in packet order)</Label>
              <div className="mt-1 space-y-1 max-h-64 overflow-y-auto rounded border p-2">
                {BATCHABLE_REPORTS.map((r) => (
                  <label key={r.id} className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-accent cursor-pointer text-sm">
                    <Checkbox checked={batchReportIds.includes(r.id)} onCheckedChange={() => toggleBatchReport(r.id)} />
                    {r.name}
                  </label>
                ))}
              </div>
            </div>
          </div>
          <DialogFooter className="flex-wrap gap-2">
            {batchLoadedId && (
              <Button variant="ghost" onClick={() => { setBatchLoadedId(null); setBatchName(""); }}>New batch</Button>
            )}
            <Button variant="outline" onClick={saveBatch}>{batchLoadedId ? "Update" : "Save"} batch</Button>
            <Button onClick={generateBatch} disabled={generatingBatch || batchReportIds.length === 0}>
              {generatingBatch ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <FileDown className="mr-1 h-4 w-4" />}
              Generate PDF
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>

    </div>
  );
}

function Toggle({ id, checked, onChange, label, disabled }: { id: string; checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
  return (
    <div className="flex items-center gap-2">
      <Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
      <Label htmlFor={id} className={disabled ? "text-muted-foreground" : ""}>{label}</Label>
    </div>
  );
}

// ── Income Statement (multi-period: by month / quarter / year) ─────────────────
type ISGran = "month" | "quarter" | "year";
const IS_TEAL: [number, number, number] = [0, 137, 123];

function isPad2(n: number) { return String(n).padStart(2, "0"); }

/** Period columns spanning [from, to] at the chosen granularity. */
function isBuildPeriods(from: string, to: string, gran: ISGran): { key: string; label: string }[] {
  const out: { key: string; label: string }[] = [];
  const fy = Number(from.slice(0, 4)), fm = Number(from.slice(5, 7));
  const ty = Number(to.slice(0, 4)), tm = Number(to.slice(5, 7));
  if (gran === "month") {
    let cy = fy, cm = fm;
    while (cy < ty || (cy === ty && cm <= tm)) {
      out.push({ key: `${cy}-${isPad2(cm)}`, label: `${isPad2(cm)}-${cy}` });
      cm++; if (cm > 12) { cm = 1; cy++; }
    }
  } else if (gran === "quarter") {
    let cy = fy, cq = Math.floor((fm - 1) / 3) + 1;
    const tq = Math.floor((tm - 1) / 3) + 1;
    while (cy < ty || (cy === ty && cq <= tq)) {
      out.push({ key: `${cy}-Q${cq}`, label: `Q${cq} ${cy}` });
      cq++; if (cq > 4) { cq = 1; cy++; }
    }
  } else {
    for (let cy = fy; cy <= ty; cy++) out.push({ key: `${cy}`, label: `${cy}` });
  }
  return out;
}

/** First day of the fiscal year that contains `to`, given an "MM-DD" FY start. */
function isFyStartFor(to: string, fyStart: string): string {
  const [mm, dd] = (fyStart || "01-01").split("-").map(Number);
  const ty = Number(to.slice(0, 4));
  const candidate = `${ty}-${isPad2(mm || 1)}-${isPad2(dd || 1)}`;
  return to >= candidate ? candidate : `${ty - 1}-${isPad2(mm || 1)}-${isPad2(dd || 1)}`;
}

/** Period key a given YYYY-MM-DD date falls into, for the chosen granularity. */
function isPeriodKey(date: string, gran: ISGran): string {
  if (gran === "month") return date.slice(0, 7);
  if (gran === "year") return date.slice(0, 4);
  const q = Math.floor((Number(date.slice(5, 7)) - 1) / 3) + 1;
  return `${date.slice(0, 4)}-Q${q}`;
}

/** Detail-cell number: 2dp, thousands-separated, parens for negatives, blank for zero. */
function isNum(n: number): string {
  const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
  if (v === 0) return "";
  const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  return v < 0 ? `(${abs})` : abs;
}

type ISAcct = { id: string; code: string | null; name: string; type: string; category: string | null; by: Map<string, number>; total: number };
type ISTotal = { by: Map<string, number>; total: number };
type ISGroup = { name: string | null; accts: ISAcct[] } & ISTotal;

const isByCode = (a: ISAcct, b: ISAcct) => String(a.code ?? "").localeCompare(String(b.code ?? "")) || a.name.localeCompare(b.name);

function isSumRows(rows: { by: Map<string, number>; total: number }[], periods: { key: string }[]): ISTotal {
  const by = new Map<string, number>(); let total = 0;
  for (const p of periods) by.set(p.key, rows.reduce((s, r) => s + (r.by.get(p.key) ?? 0), 0));
  for (const r of rows) total += r.total;
  return { by, total };
}

/** Group accounts by their `category` into Buildium-style subgroups; ungrouped (null) sorts last. */
function isGroupAccts(accts: ISAcct[], periods: { key: string }[]): ISGroup[] {
  const map = new Map<string, ISAcct[]>();
  for (const a of accts) {
    const k = (a.category ?? "").trim();
    (map.get(k) ?? map.set(k, []).get(k)!).push(a);
  }
  const groups: ISGroup[] = [];
  for (const [name, list] of map) {
    list.sort(isByCode);
    groups.push({ name: name || null, accts: list, ...isSumRows(list, periods) });
  }
  groups.sort((a, b) => (a.name === null ? 1 : 0) - (b.name === null ? 1 : 0) || String(a.name).localeCompare(String(b.name)));
  return groups;
}

function IncomeStatementReport({ companyId, companyName, from, to, fiscalYearStart, currency, logoUrl }: {
  companyId: string; companyName: string; from: string; to: string; fiscalYearStart?: string; currency: string; logoUrl?: string | null;
}) {
  const [gran, setGran] = useState<ISGran>("month");

  // The point of the monthly P&L is a column per period. If the page Period is a
  // single month (the default), there'd be just one column — so widen the start
  // to the fiscal year containing `to`, giving every month of the FY-to-date.
  // A multi-month selected range is respected as-is.
  const effFrom = useMemo(
    () => (from.slice(0, 7) === to.slice(0, 7) ? isFyStartFor(to, fiscalYearStart || "01-01") : from),
    [from, to, fiscalYearStart],
  );

  const { data: glLines = [], isLoading } = useQuery({
    queryKey: ["income-statement-gl", companyId, effFrom, to],
    enabled: !!companyId,
    queryFn: () => fetchAllGLLines(
      companyId, to,
      "id,debit,credit,accounts!inner(id,name,code,type,category),journal_entries!inner(company_id,date)",
      effFrom,
    ),
  });

  const periods = useMemo(() => isBuildPeriods(effFrom, to, gran), [effFrom, to, gran]);

  const model = useMemo(() => {
    const accts = new Map<string, ISAcct>();
    for (const l of glLines as any[]) {
      const a = l.accounts; if (!a) continue;
      const type = a.type as string; if (type !== "income" && type !== "expense") continue;
      const date: string = l.journal_entries?.date ?? ""; if (!date) continue;
      const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
      const amt = type === "income" ? credit - debit : debit - credit; // both shown positive
      let rec = accts.get(a.id);
      if (!rec) { rec = { id: a.id, code: a.code, name: a.name, type, category: a.category ?? null, by: new Map(), total: 0 }; accts.set(a.id, rec); }
      const key = isPeriodKey(date, gran);
      rec.by.set(key, (rec.by.get(key) ?? 0) + amt);
      rec.total += amt;
    }
    const live = [...accts.values()].filter((a) => Math.abs(a.total) > 0.005);
    const income = live.filter((a) => a.type === "income");
    const expense = live.filter((a) => a.type === "expense");
    const incomeGroups = isGroupAccts(income, periods);
    const expenseGroups = isGroupAccts(expense, periods);
    const incTot = isSumRows(income, periods), expTot = isSumRows(expense, periods);
    const net: ISTotal = {
      by: new Map(periods.map((p) => [p.key, (incTot.by.get(p.key) ?? 0) - (expTot.by.get(p.key) ?? 0)])),
      total: incTot.total - expTot.total,
    };
    return { incomeGroups, expenseGroups, incTot, expTot, net, hasRows: income.length > 0 || expense.length > 0 };
  }, [glLines, periods, gran]);

  const subtitle = `${fmtDate(effFrom)} – ${fmtDate(to)}, By ${gran[0].toUpperCase()}${gran.slice(1)}, Accrual basis`;
  const { hasRows } = model;

  const exportPdf = async () => {
    const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
    const logo = await loadBrandedLogo(logoUrl);
    const startY = drawBrandedHeader(doc, {
      logo, title: "Profit & Loss", subtitle,
      metaLines: [{ label: "Properties:", value: companyName }],
    });
    const head = [["Account", ...periods.map((p) => p.label), "Total"]];
    const body: any[] = [];
    const fillRows = new Set<number>();   // section headers (Income / Expense)
    const boldRows = new Set<number>();   // group headers, subtotals, totals, net
    const pushRow = (label: string, t: ISTotal | null, kind: "section" | "group" | "account" | "subtotal" | "total") => {
      const asMoney = kind === "subtotal" || kind === "total";
      const cells = [
        label,
        ...periods.map((p) => (t ? (asMoney ? money(t.by.get(p.key) ?? 0, currency) : isNum(t.by.get(p.key) ?? 0)) : "")),
        t == null ? "" : (asMoney ? money(t.total, currency) : isNum(t.total)),
      ];
      if (kind === "section") fillRows.add(body.length);
      if (kind !== "account") boldRows.add(body.length);
      body.push(cells);
    };
    const emitSection = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
      pushRow(title, null, "section");
      for (const g of groups) {
        if (g.name) pushRow(`  ${g.name}`, null, "group");
        for (const a of g.accts) pushRow(`${g.name ? "    " : "  "}${a.code ? a.code + " " : ""}${a.name}`, a, "account");
        if (g.name) pushRow(`  Total for ${g.name}`, g, "subtotal");
      }
      pushRow(totalLabel, total, "total");
    };
    emitSection("Income", model.incomeGroups, "Total Income", model.incTot);
    emitSection("Expense", model.expenseGroups, "Total Expense", model.expTot);
    pushRow("Net Income", model.net, "total");

    const colStyles: Record<number, any> = { 0: { halign: "left", cellWidth: 160 } };
    for (let i = 1; i <= periods.length + 1; i++) colStyles[i] = { halign: "right" };
    autoTable(doc, {
      startY, head, body,
      styles: { fontSize: 7, cellPadding: 3, overflow: "linebreak" },
      headStyles: { fillColor: IS_TEAL, textColor: 255, halign: "right", fontSize: 7 },
      columnStyles: colStyles,
      margin: { left: 40, right: 40 },
      didParseCell: (data: any) => {
        if (data.section !== "body") return;
        if (boldRows.has(data.row.index)) data.cell.styles.fontStyle = "bold";
        if (fillRows.has(data.row.index)) data.cell.styles.fillColor = [241, 245, 249];
      },
    });
    drawBrandedFooter(doc);
    doc.save(`profit-loss-${gran}-${effFrom}-to-${to}.pdf`);
  };

  const exportCsv = () => {
    const esc = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
    const lines = [["Account", ...periods.map((p) => p.label), "Total"].map(esc).join(",")];
    const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2);
    const row = (label: string, t: ISTotal | null) =>
      lines.push([esc(label), ...periods.map((p) => (t ? f(t.by.get(p.key) ?? 0) : "")), t == null ? "" : f(t.total)].join(","));
    const section = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
      row(title, null);
      for (const g of groups) {
        if (g.name) row(g.name, null);
        for (const a of g.accts) row(`${a.code ? a.code + " " : ""}${a.name}`, a);
        if (g.name) row(`Total for ${g.name}`, g);
      }
      row(totalLabel, total);
    };
    section("Income", model.incomeGroups, "Total Income", model.incTot);
    section("Expense", model.expenseGroups, "Total Expense", model.expTot);
    row("Net Income", model.net);
    const blob = new Blob([lines.join("\n")], { type: "text/csv" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `profit-loss-${gran}-${effFrom}-to-${to}.csv`;
    a.click();
    URL.revokeObjectURL(a.href);
  };

  const numCell = "px-3 py-1.5 text-right tabular-nums whitespace-nowrap";

  return (
    <div className="space-y-4">
      <Card>
        <CardContent className="flex flex-wrap items-end gap-4 py-4">
          <div>
            <Label className="text-xs text-muted-foreground">By</Label>
            <Select value={gran} onValueChange={(v) => setGran(v as ISGran)}>
              <SelectTrigger className="w-36 mt-1 h-9"><SelectValue /></SelectTrigger>
              <SelectContent>
                <SelectItem value="month">Month</SelectItem>
                <SelectItem value="quarter">Quarter</SelectItem>
                <SelectItem value="year">Year</SelectItem>
              </SelectContent>
            </Select>
          </div>
          <div className="text-xs text-muted-foreground pb-1">{periods.length} period{periods.length !== 1 ? "s" : ""} · Accrual basis</div>
          {hasRows && (
            <div className="ml-auto flex gap-2">
              <Button variant="outline" onClick={exportCsv}><Download className="mr-1 h-4 w-4" /> CSV</Button>
              <Button onClick={exportPdf}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
            </div>
          )}
        </CardContent>
      </Card>

      {isLoading ? (
        <Card><CardContent className="p-8 text-center text-sm text-muted-foreground">Loading…</CardContent></Card>
      ) : !hasRows ? (
        <Card><CardContent className="p-8 text-center text-sm text-muted-foreground">No income or expense activity in this range.</CardContent></Card>
      ) : (
        <ReportSheet title="Profit & Loss" subtitle={subtitle} companyName={companyName} logoUrl={logoUrl}>
          <div className="overflow-x-auto">
            <table className="w-full text-sm">
              <thead>
                <tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
                  <th className="px-3 py-2 text-left font-semibold">Account</th>
                  {periods.map((p) => <th key={p.key} className="px-3 py-2 text-right font-semibold whitespace-nowrap">{p.label}</th>)}
                  <th className="px-3 py-2 text-right font-semibold">Total</th>
                </tr>
              </thead>
              <tbody>
                <ISSection title="Income" groups={model.incomeGroups} totalLabel="Total Income" total={model.incTot} periods={periods} currency={currency} numCell={numCell} />
                <ISSection title="Expense" groups={model.expenseGroups} totalLabel="Total Expense" total={model.expTot} periods={periods} currency={currency} numCell={numCell} />
                <tr className="border-t-2 border-primary font-bold">
                  <td className="px-3 py-2.5">Net Income</td>
                  {periods.map((p) => <td key={p.key} className={numCell}>{money(model.net.by.get(p.key) ?? 0, currency)}</td>)}
                  <td className={numCell}>{money(model.net.total, currency)}</td>
                </tr>
              </tbody>
            </table>
          </div>
        </ReportSheet>
      )}
    </div>
  );
}

function ISSection({ title, groups, periods, totalLabel, total, currency, numCell }: {
  title: string; groups: ISGroup[]; periods: { key: string; label: string }[];
  totalLabel: string; total: ISTotal; currency: string; numCell: string;
}) {
  return (
    <>
      <tr className="bg-muted/40 font-semibold">
        <td className="px-3 py-1.5" colSpan={periods.length + 2}>{title}</td>
      </tr>
      {groups.map((g, gi) => (
        <Fragment key={g.name ?? `__none-${gi}`}>
          {g.name && (
            <tr className="font-semibold text-[13px]">
              <td className="px-3 pl-6 py-1.5" colSpan={periods.length + 2}>{g.name}</td>
            </tr>
          )}
          {g.accts.map((a) => (
            <tr key={a.id} className="border-b hover:bg-muted/20">
              <td className={`px-3 py-1.5 ${g.name ? "pl-10" : "pl-6"}`}>
                {a.code && <span className="font-mono text-xs text-muted-foreground mr-2">{a.code}</span>}{a.name}
              </td>
              {periods.map((p) => <td key={p.key} className={numCell}>{isNum(a.by.get(p.key) ?? 0)}</td>)}
              <td className={numCell + " font-medium"}>{isNum(a.total)}</td>
            </tr>
          ))}
          {g.name && (
            <tr className="border-b font-medium text-muted-foreground">
              <td className="px-3 pl-6 py-1.5">Total for {g.name}</td>
              {periods.map((p) => <td key={p.key} className={numCell}>{money(g.by.get(p.key) ?? 0, currency)}</td>)}
              <td className={numCell}>{money(g.total, currency)}</td>
            </tr>
          )}
        </Fragment>
      ))}
      <tr className="border-y-2 border-primary/40 font-semibold">
        <td className="px-3 py-1.5">{totalLabel}</td>
        {periods.map((p) => <td key={p.key} className={numCell}>{money(total.by.get(p.key) ?? 0, currency)}</td>)}
        <td className={numCell}>{money(total.total, currency)}</td>
      </tr>
    </>
  );
}

function StructuredTable({ report, showCodes, showCompare, showZero, currency, onDrill }: {
  report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string;
  onDrill?: (accountId: string, label: string) => void;
}) {
  let alt = false;
  const span = showCompare ? 5 : 2;
  const pctStr = (amount?: number, compare?: number) => {
    if (amount === undefined || compare === undefined || Math.abs(compare) < 0.005) return "—";
    return `${(((amount - compare) / Math.abs(compare)) * 100).toFixed(1)}%`;
  };
  return (
    <table className="w-full text-sm">
      <thead>
        <tr className="border-b text-xs uppercase tracking-wide text-muted-foreground">
          <th className="py-2 text-left font-semibold">Account</th>
          <th className="py-2 text-right font-semibold">Amount</th>
          {showCompare && <th className="py-2 text-right font-semibold">Comparative</th>}
          {showCompare && <th className="py-2 text-right font-semibold">Change</th>}
          {showCompare && <th className="py-2 text-right font-semibold">Change %</th>}
        </tr>
      </thead>
      <tbody>
        {report.rows.map((r, i) => {
          if (r.kind === "spacer") { alt = false; return <tr key={i}><td colSpan={span} className="h-3" /></tr>; }
          if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) return null;
          if (r.kind === "section") {
            alt = false;
            return (
              <tr key={i} className="bg-[hsl(174_47%_94%)]">
                <td colSpan={span} className="py-2 px-2 font-semibold text-[hsl(174_70%_25%)] text-sm">{r.label}</td>
              </tr>
            );
          }
          if (r.kind === "group") {
            alt = false;
            return (
              <tr key={i}>
                <td colSpan={span} className="pt-2.5 pb-1 pl-4 font-semibold text-sm">{r.label}</td>
              </tr>
            );
          }
          const bold = r.kind === "total" || r.kind === "grand";
          const shaded = r.kind === "sub" && alt;
          if (r.kind === "sub") alt = !alt;
          const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined;
          const drillable = r.kind === "sub" && !!r.accountId && !!onDrill;
          return (
            <tr key={i}
              onClick={drillable ? () => onDrill!(r.accountId!, r.label) : undefined}
              title={drillable ? "View transactions for this account" : undefined}
              className={[
              shaded ? "bg-muted/40" : "",
              bold ? "border-t font-semibold" : "",
              drillable ? "cursor-pointer hover:bg-primary/5" : "",
            ].join(" ")}>
              <td className={r.kind === "sub" ? "pl-6 py-1.5 border-l-2 border-muted ml-2" : "py-1.5 px-2"}>
                {showCodes && r.code && <span className="text-xs text-muted-foreground mr-2 font-mono">{r.code}</span>}
                <span className={drillable ? "text-primary underline-offset-2 hover:underline" : ""}>{r.label}</span>
              </td>
              <AmountCell n={r.amount} bold={bold} doubleUnderline={r.kind === "grand"} />
              {showCompare && <AmountCell n={r.compare} />}
              {showCompare && <AmountCell n={delta} bold={bold} />}
              {showCompare && <td className={`py-1.5 px-2 text-right tabular-nums text-muted-foreground ${bold ? "font-semibold" : ""}`}>{r.amount !== undefined ? pctStr(r.amount, r.compare) : ""}</td>}
            </tr>
          );
        })}
      </tbody>
      {report.balanced !== undefined && (
        <tfoot>
          <tr>
            <td colSpan={span} className="pt-3">
              <div className={`rounded-md px-4 py-2 text-sm font-semibold text-white ${report.balanced ? "bg-primary" : "bg-red-600"}`}>
                {report.balanced ? "Balance Sheet is balanced ✓" : `Balance Sheet is OUT OF BALANCE by ${money(report.outOfBalanceAmount ?? 0, currency)} (Assets − Liabilities − Equity)`}
              </div>
            </td>
          </tr>
        </tfoot>
      )}
      {report.cashHighlight && (
        <tfoot>
          <tr><td colSpan={span} className="pt-3">
            <div className="flex items-center justify-between rounded-md border border-primary bg-[hsl(174_47%_94%)] px-4 py-2.5 text-sm font-semibold text-[hsl(174_70%_25%)]">
              <span>{report.cashHighlight.label}</span>
              <span className={report.cashHighlight.amount < 0 ? "text-red-600" : ""}>{fmtAmount(report.cashHighlight.amount)}</span>
            </div>
          </td></tr>
        </tfoot>
      )}
    </table>
  );
}

function AmountCell({ n, bold, doubleUnderline }: { n?: number; bold?: boolean; doubleUnderline?: boolean }) {
  if (n === undefined) return <td className="py-1.5 px-2" />;
  const cls = [
    "py-1.5 px-2 text-right tabular-nums",
    n < 0 ? "text-red-600" : "",
    bold ? "font-semibold" : "",
    doubleUnderline ? "border-b-4 border-double border-primary" : "",
  ].join(" ");
  return <td className={cls}>{fmtAmount(n)}</td>;
}

function PreviewSheet({ report, companyName, rangeLabel, showCodes, showCompare, showZero }: {
  report: StructuredReport | null;
  companyName: string; rangeLabel: string; showCodes: boolean; showCompare: boolean; showZero: boolean;
}) {
  if (!report) return <div className="text-center text-muted-foreground py-12">No data.</div>;
  return (
    <div className="mx-auto bg-white shadow-md" style={{ width: 816, minHeight: 1056, fontFamily: "Georgia, 'Times New Roman', serif" }}>
      <div className="px-12 pt-10 pb-6">
        <div className="flex items-start gap-4">
          <div className="h-11 w-11 rounded bg-primary flex items-center justify-center text-white font-bold text-lg">
            {(companyName[0] ?? "?").toUpperCase()}
          </div>
          <div className="text-xl font-bold text-gray-900 mt-1">{companyName}</div>
        </div>
        <h1 className="text-2xl font-bold text-center mt-4">{report.title}</h1>
        <div className="text-center text-sm text-gray-500 mt-1">{rangeLabel}</div>
        <div className="mt-3 h-[1.5px] bg-primary" />
      </div>
      <div className="px-12 pb-12">
        <StructuredTable report={report} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency="" />
      </div>
      <div className="px-12 pb-6">
        <div className="h-[1px] bg-primary mb-3" />
        <div className="text-center text-xs text-gray-500">Page 1 of 1</div>
        <div className="text-center text-xs text-gray-500">Generated by {APP_NAME} on {new Date().toLocaleDateString()}</div>
      </div>
    </div>
  );
}

// ---------- Financial report builders (structured) ----------

// Reconciliation matrix (§9) surfaced as visible residuals — never plug a residual.
// Shared so both the on-screen report and the PDF/CSV export run identical checks.
function buildReconChecks(d: any): RecCheck[] {
  const accounts: RecAccount[] = ((d.accounts ?? []) as any[]).map((a) => ({
    id: a.id, type: a.type, name: a.name,
    is_cash: !!a.is_bank || /cash|undeposited/i.test(String(a.name || "")),
  }));
  const knownAcctIds = new Set(accounts.map((a) => a.id));
  for (const l of (d.glCumulative ?? []) as any[]) {
    const meta = l.accounts; const id = l.account_id;
    if (!meta || !id || knownAcctIds.has(id)) continue;
    knownAcctIds.add(id);
    accounts.push({ id, type: meta.type, name: meta.name, is_cash: /cash|undeposited/i.test(String(meta.name || "")) });
  }
  const lines: RecLine[] = ((d.glCumulative ?? []) as any[]).map((l) => ({
    account_id: l.account_id, date: String(l.journal_entries?.date ?? ""),
    debit: Number(l.debit || 0), credit: Number(l.credit || 0),
  }));
  const openInv = ((d.allInvoices ?? []) as any[]).filter((i) => i.status !== "void").reduce((s, i) => s + (Number(i.total || 0) - Number(i.paid_amount || 0)), 0);
  const openBill = ((d.allBills ?? []) as any[]).filter((b) => b.status !== "void").reduce((s, b) => s + (Number(b.total || 0) - Number(b.paid_amount || 0)), 0);
  const pl = buildPnL(d, undefined, false);
  const plNI = pl.rows.find((r) => r.kind === "grand" && /net income/i.test(r.label))?.amount;
  const bs = buildBalanceSheet(d);
  const bsEquity = bs.rows.find((r) => r.kind === "total" && /total equity/i.test(r.label))?.amount;
  const sce = buildMovementOfEquity(d, undefined, false);
  const sceEnding = sce.rows.find((r) => r.kind === "grand" && /closing equity/i.test(r.label))?.amount;
  return reconcile({
    accounts, lines, periodStart: d.from, openInvoices: openInv, openBills: openBill,
    arApApplicable: d.glManaged,
    reportPLNetIncome: plNI, sceEndingEquity: sceEnding, bsTotalEquity: bsEquity,
  });
}

function ReconciliationReport({ d, currency, companyName, rangeLabel }: { d: any; currency: string; companyName?: string; rangeLabel?: string }) {
  if (!d) return <div className="text-sm text-muted-foreground">Loading…</div>;
  const checks = buildReconChecks(d);
  const ok = (r: number) => Math.abs(r) < 0.005;
  const allPass = checks.every((c) => c.pass);

  // Self-contained PDF (doesn't depend on the page-level exporter).
  const downloadPdf = () => {
    const doc = new jsPDF({ unit: "pt", format: "letter" });
    doc.setFontSize(16); doc.setFont("helvetica", "bold");
    doc.text("Reconciliation Checks", 40, 50);
    doc.setFontSize(10); doc.setFont("helvetica", "normal"); doc.setTextColor(90);
    if (companyName) doc.text(companyName, 40, 68);
    if (rangeLabel) doc.text(rangeLabel, 40, 82);
    doc.setTextColor(0);
    autoTable(doc, {
      startY: 96,
      head: [["Check", "Assertion", "Residual", "Status"]],
      body: checks.map((c) => [c.id, c.label, money(c.residual, currency), c.pass ? "Pass" : "FAIL"]),
      styles: { font: "helvetica", fontSize: 9, cellPadding: 5 },
      headStyles: { fillColor: [30, 41, 59], textColor: 255, fontStyle: "bold" },
      columnStyles: { 0: { cellWidth: 50 }, 2: { halign: "right", cellWidth: 90 }, 3: { halign: "right", cellWidth: 60 } },
      didParseCell: ({ section, row, column, cell }) => {
        if (section === "body" && !checks[row.index]?.pass && (column.index === 2 || column.index === 3)) {
          cell.styles.textColor = [220, 38, 38]; cell.styles.fontStyle = "bold";
        }
      },
      margin: { left: 40, right: 40 },
    });
    doc.save(`reconciliation-checks-${(companyName || "company").replace(/[^a-z0-9]+/gi, "_")}.pdf`);
  };

  return (
    <Card>
      <CardHeader className="flex-row items-center justify-between space-y-0">
        <CardTitle className="text-base flex items-center gap-2">
          Reconciliation Checks
          <span className={`text-xs rounded px-2 py-0.5 ${allPass ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-700"}`}>
            {allPass ? "All passing" : "Residuals present"}
          </span>
        </CardTitle>
        <Button variant="outline" size="sm" onClick={downloadPdf}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
      </CardHeader>
      <CardContent className="p-0">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead className="w-16">Check</TableHead>
              <TableHead>Assertion</TableHead>
              <TableHead className="text-right">Residual</TableHead>
              <TableHead className="text-right">Status</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {checks.map((c) => (
              <TableRow key={c.id}>
                <TableCell className="font-mono">{c.id}</TableCell>
                <TableCell>{c.label}</TableCell>
                <TableCell className={`text-right tabular-nums ${ok(c.residual) ? "" : "text-red-600 font-semibold"}`}>{money(c.residual, currency)}</TableCell>
                <TableCell className="text-right">{ok(c.residual) ? "✓" : "✗"}</TableCell>
              </TableRow>
            ))}
            {/* R6 / R9 are not numeric residuals in this model — shown for matrix completeness. */}
            <TableRow className="text-muted-foreground">
              <TableCell className="font-mono">R6</TableCell>
              <TableCell>GL closing balance = Trial Balance / Balance Sheet balance</TableCell>
              <TableCell className="text-right">—</TableCell>
              <TableCell className="text-right" title="Trial Balance and Balance Sheet are both derived directly from the GL, so they agree by construction.">n/a</TableCell>
            </TableRow>
            <TableRow className="text-muted-foreground">
              <TableCell className="font-mono">R9</TableCell>
              <TableCell>Direct-method CFO = Indirect-method CFO</TableCell>
              <TableCell className="text-right">—</TableCell>
              <TableCell className="text-right" title="Only the indirect-method cash flow is produced, so there is no second method to reconcile against.">n/a</TableCell>
            </TableRow>
          </TableBody>
        </Table>
        <p className="text-xs text-muted-foreground p-4">
          A non-zero residual is a bug signal (§9), not to be plugged. R1/R2 failing means the ledger is
          unbalanced (often an imported single-sided entry). R3 checks the P&L's net income against the raw GL;
          R5 checks the Movement of Equity against the Balance Sheet. R7/R8 failing means A/R or A/P is summing
          gross billings instead of open balances, or a sub-ledger doesn't tie to the GL control account.
          R6 is satisfied by construction (Trial Balance and Balance Sheet both derive from the GL); R9 is N/A
          because only the indirect-method cash flow is produced.
          {!d.glManaged && " R7/R8 are omitted for this company: its GL is imported, so its A/R/A/P are maintained in the GL rather than from the platform's invoice/bill sub-ledgers."}
        </p>
      </CardContent>
    </Card>
  );
}

export function buildFinancial(id: ReportId, d: any, p: any | undefined, useCompare: boolean): StructuredReport {
  if (id === "pnl") return buildPnL(d, p, useCompare);
  if (id === "balance-sheet") return buildBalanceSheet(d, p, useCompare);
  if (id === "movement-of-equity") return buildMovementOfEquity(d, p, useCompare);
  return buildCashFlow(d, p, useCompare);
}

function buildMovementOfEquity(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
  // GL-consistent with the Balance Sheet and P&L: net income is the GL's
  // current-year earnings (not a separate sub-ledger figure), and the equity
  // rolls forward to exactly the Balance Sheet's total equity (ties R5).
  const equityAccs = (d.accounts ?? []).filter((a: any) => a.type === "equity");
  const draws = equityAccs.filter((a: any) => /draw|dividend/i.test(a.name));
  const nonDraw = equityAccs.filter((a: any) => !/draw|dividend/i.test(a.name));

  // Per-dataset equity figures derived entirely from the GL (via bsBalances).
  const eq = (ds: any) => {
    const bs = bsBalances(ds);
    const bal = (a: any) => bs.glByAcct.get(a.id) ?? 0;
    const nonDrawGL = nonDraw.reduce((s: number, a: any) => s + bal(a), 0);
    const drawsGL = draws.reduce((s: number, a: any) => s + bal(a), 0);
    const opening = nonDrawGL + bs.rePrior;       // capital + opening RE + prior-year earnings
    const closing = opening + bs.cye + drawsGL;   // = Balance Sheet total equity (by construction)
    return { bal, drawsGL, opening, closing, netIncome: bs.cye, rePrior: bs.rePrior };
  };

  const cur = eq(d);
  const prev = useCompare && p ? eq(p) : undefined;
  const cmp = (v: number | undefined) => (prev ? v : undefined);

  const rows: StructuredRow[] = [
    { kind: "section",  label: "Opening Equity" },
    ...nonDraw.map((a: any) => ({ kind: "sub" as const, label: a.name, code: a.code ?? undefined, amount: cur.bal(a), compare: cmp(prev ? prev.bal(a) : undefined) })),
    { kind: "sub",      label: "Retained Earnings (prior years)", amount: cur.rePrior, compare: cmp(prev?.rePrior) },
    { kind: "total",    label: "Total Opening Equity", amount: cur.opening, compare: cmp(prev?.opening) },
    { kind: "spacer",   label: "" },

    { kind: "section",  label: "Period Activity" },
    { kind: "sub",      label: "Net Income", amount: cur.netIncome, compare: cmp(prev?.netIncome) },
    ...draws.map((a: any) => ({ kind: "sub" as const, label: `Less: ${a.name}`, amount: cur.bal(a), compare: cmp(prev ? prev.bal(a) : undefined) })),
    { kind: "total",    label: "Net Change in Equity", amount: cur.netIncome + cur.drawsGL, compare: cmp(prev ? prev.netIncome + prev.drawsGL : undefined) },
    { kind: "spacer",   label: "" },

    { kind: "grand",    label: "Closing Equity", amount: cur.closing, compare: cmp(prev?.closing) },
  ];

  return { title: "Movement of Equity", rows };
}

// ── P&L from the general ledger (grouped by parent account) ───────────────────

type GLAccountTotal = { name: string; code: string | null; amount: number; parentId: string | null };

/**
 * Group journal-entry lines by GL account, split into income/expense.
 * Income accounts net to credit−debit; expense accounts to debit−credit.
 */
function groupGLByAccount(lines: any[]) {
  const income = new Map<string, GLAccountTotal>();
  const expense = new Map<string, GLAccountTotal>();
  for (const l of lines ?? []) {
    const acc = l.accounts;
    if (!acc) continue;
    const debit = Number(l.debit ?? 0);
    const credit = Number(l.credit ?? 0);
    const parentId = acc.parent_account_id ?? null;
    if (acc.type === "income") {
      const e = income.get(acc.id) ?? { name: acc.name, code: acc.code ?? null, amount: 0, parentId };
      e.amount += credit - debit;
      income.set(acc.id, e);
    } else if (acc.type === "expense") {
      const e = expense.get(acc.id) ?? { name: acc.name, code: acc.code ?? null, amount: 0, parentId };
      e.amount += debit - credit;
      expense.set(acc.id, e);
    }
  }
  return { income, expense };
}

/**
 * Emit a P&L section, grouping accounts under their parent account. Top-level
 * accounts (no parent) are listed directly; child accounts are nested under a
 * parent group header with a "Total for <parent>" subtotal. Returns section totals.
 */
function pushPnLSection(
  rows: StructuredRow[], label: string,
  accMap: Map<string, GLAccountTotal>, prevMap: Map<string, GLAccountTotal>,
  parentName: Map<string, string>, useCompare: boolean
): { total: number; prevTotal: number } {
  rows.push({ kind: "section", label });

  const ungrouped: [string, GLAccountTotal][] = [];
  const groups = new Map<string, [string, GLAccountTotal][]>();
  for (const entry of accMap.entries()) {
    const pid = entry[1].parentId;
    if (pid && parentName.has(pid)) {
      const g = groups.get(pid) ?? [];
      g.push(entry); groups.set(pid, g);
    } else ungrouped.push(entry);
  }

  let total = 0, prevTotal = 0;
  const byName = (a: [string, GLAccountTotal], b: [string, GLAccountTotal]) => a[1].name.localeCompare(b[1].name);
  const emit = (id: string, v: GLAccountTotal) => {
    total += v.amount; prevTotal += prevMap.get(id)?.amount ?? 0;
    rows.push({ kind: "sub", label: v.name, code: v.code ?? undefined, amount: v.amount, compare: useCompare ? prevMap.get(id)?.amount : undefined });
  };

  for (const [id, v] of ungrouped.sort(byName)) emit(id, v);

  const sortedGroups = [...groups.entries()].sort((a, b) => (parentName.get(a[0]) ?? "").localeCompare(parentName.get(b[0]) ?? ""));
  for (const [pid, entries] of sortedGroups) {
    const gName = parentName.get(pid) ?? "Group";
    rows.push({ kind: "group", label: gName });
    let gTotal = 0, gPrev = 0;
    for (const [id, v] of entries.sort(byName)) {
      gTotal += v.amount; gPrev += prevMap.get(id)?.amount ?? 0;
      emit(id, v);
    }
    rows.push({ kind: "total", label: `Total for ${gName}`, amount: gTotal, compare: useCompare ? gPrev : undefined });
  }

  return { total, prevTotal };
}

// P&L sub-classification is read from the account's explicit `subtype` field
// (never inferred from names — see the P&L spec §4). Unclassified nominal
// accounts default to plain revenue / operating expense, which is correct for
// HOA charts that have no COGS, tax, or contra accounts.
const PNL_SUBTYPE_MAP: Record<string, PnlClassification> = {
  revenue: "REVENUE", sales: "REVENUE", income: "REVENUE", operating_income: "REVENUE",
  contra_revenue: "CONTRA_REVENUE", sales_returns: "CONTRA_REVENUE",
  sales_allowances: "CONTRA_REVENUE", sales_discounts: "CONTRA_REVENUE",
  cogs: "COGS", cost_of_goods_sold: "COGS", cost_of_sales: "COGS",
  contra_cogs: "CONTRA_COGS", purchase_discounts: "CONTRA_COGS", purchase_returns: "CONTRA_COGS",
  operating_expense: "OPERATING_EXPENSE", opex: "OPERATING_EXPENSE", sga: "OPERATING_EXPENSE",
  selling_general_admin: "OPERATING_EXPENSE", depreciation: "OPERATING_EXPENSE",
  other_income: "OTHER_INCOME", non_operating_income: "OTHER_INCOME", interest_income: "OTHER_INCOME",
  other_expense: "OTHER_EXPENSE", non_operating_expense: "OTHER_EXPENSE", interest_expense: "OTHER_EXPENSE",
  tax: "TAX_EXPENSE", income_tax: "TAX_EXPENSE", tax_expense: "TAX_EXPENSE",
};

function classifyAccount(a: any): PnlClassification {
  const key = String(a.subtype ?? "").trim().toLowerCase().replace(/[\s-]+/g, "_");
  if (key && PNL_SUBTYPE_MAP[key]) return PNL_SUBTYPE_MAP[key];
  return a.type === "income" ? "REVENUE" : "OPERATING_EXPENSE";
}

function runPnLEngine(d: any): PnlResult | { error: string } {
  const nominal = ((d.accounts ?? []) as any[]).filter((a) => a.type === "income" || a.type === "expense");
  const accts: PnlAccount[] = nominal.map((a) => ({ id: a.id, name: a.name, type: a.type, classification: classifyAccount(a) }));
  const allow = new Set(nominal.map((a) => a.id));
  const postings: PnlPosting[] = ((d.glLines ?? []) as any[])
    .filter((l) => l.accounts && allow.has(l.accounts.id))
    .map((l) => ({
      accountId: l.accounts.id,
      debitMinor: toMinor(l.debit ?? 0),
      creditMinor: toMinor(l.credit ?? 0),
      date: l.journal_entries?.date ?? d.from ?? d.asOf,
    }));
  try {
    return computePnL(postings, accts, { periodStart: d.from ?? d.asOf, periodEnd: d.asOf });
  } catch (e) {
    if (e instanceof PnlValidationError) return { error: e.message };
    return { error: e instanceof Error ? e.message : String(e) };
  }
}

function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
  const cur = runPnLEngine(d);
  if ("error" in cur) {
    return {
      title: "Profit & Loss",
      rows: [
        { kind: "section", label: "Profit & Loss could not be computed (validation failed)" },
        { kind: "section", label: cur.error },
      ],
    };
  }
  const prevRes = useCompare && p ? runPnLEngine(p) : undefined;
  const prevOk = prevRes && !("error" in prevRes) ? (prevRes as PnlResult) : undefined;

  const s = cur.subtotals;
  const ps = prevOk?.subtotals;
  const D = fromMinor;
  const cmp = (v?: number) => (useCompare && v !== undefined ? D(v) : undefined);
  const rows: StructuredRow[] = [];
  const prevByAcct = new Map((prevOk?.lines ?? []).map((l) => [l.accountId, l.amountMinor]));

  // Emit per-account line items for the given classifications. `displaySign`
  // shows contra/expense reductions as negative figures in the column.
  const lineRows = (classes: PnlClassification[], displaySign: 1 | -1) => {
    for (const l of cur.lines.filter((x) => classes.includes(x.classification)).sort((a, b) => a.name.localeCompare(b.name))) {
      rows.push({
        kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign),
        compare: useCompare ? D((prevByAcct.get(l.accountId) ?? 0) * displaySign) : undefined,
        accountId: l.accountId,
      });
    }
  };

  const has = (cs: PnlClassification[]) => cur.lines.some((l) => cs.includes(l.classification));
  const hasContra = has(["CONTRA_REVENUE"]);
  const hasCogs = has(["COGS", "CONTRA_COGS"]) || s.costOfGoodsSold !== 0;
  const hasOther = has(["OTHER_INCOME", "OTHER_EXPENSE"]) || s.otherNet !== 0;
  const hasTax = has(["TAX_EXPENSE"]) || s.incomeTaxExpense !== 0;

  // Revenue → Net Revenue
  rows.push({ kind: "section", label: "Revenue" });
  lineRows(["REVENUE"], 1);
  if (hasContra) lineRows(["CONTRA_REVENUE"], -1);
  rows.push({ kind: "total", label: "Net Revenue", amount: D(s.netRevenue), compare: cmp(ps?.netRevenue) });
  rows.push({ kind: "spacer", label: "" });

  // Cost of Goods Sold → Gross Profit (only when present)
  if (hasCogs) {
    rows.push({ kind: "section", label: "Cost of Goods Sold" });
    lineRows(["COGS"], 1);
    if (has(["CONTRA_COGS"])) lineRows(["CONTRA_COGS"], -1);
    rows.push({ kind: "total", label: "Total Cost of Goods Sold", amount: D(s.costOfGoodsSold), compare: cmp(ps?.costOfGoodsSold) });
    rows.push({ kind: "total", label: "Gross Profit", amount: D(s.grossProfit), compare: cmp(ps?.grossProfit) });
    rows.push({ kind: "spacer", label: "" });
  }

  // Operating Expenses → Operating Income (EBIT)
  rows.push({ kind: "section", label: "Operating Expenses" });
  lineRows(["OPERATING_EXPENSE"], 1);
  rows.push({ kind: "total", label: "Total Operating Expenses", amount: D(s.operatingExpenses), compare: cmp(ps?.operatingExpenses) });
  rows.push({ kind: "total", label: "Operating Income (EBIT)", amount: D(s.operatingIncome), compare: cmp(ps?.operatingIncome) });

  // Other Income / (Expense)
  if (hasOther) {
    rows.push({ kind: "spacer", label: "" });
    rows.push({ kind: "section", label: "Other Income / (Expense)" });
    lineRows(["OTHER_INCOME"], 1);
    lineRows(["OTHER_EXPENSE"], -1);
    rows.push({ kind: "total", label: "Total Other Income / (Expense)", amount: D(s.otherNet), compare: cmp(ps?.otherNet) });
  }

  // Pre-Tax Income (EBT) — shown when it differs from Operating Income
  if (hasOther || hasTax) {
    rows.push({ kind: "total", label: "Pre-Tax Income (EBT)", amount: D(s.preTaxIncome), compare: cmp(ps?.preTaxIncome) });
  }

  // Income Tax
  if (hasTax) {
    rows.push({ kind: "spacer", label: "" });
    rows.push({ kind: "section", label: "Income Tax" });
    lineRows(["TAX_EXPENSE"], 1);
    rows.push({ kind: "total", label: "Income Tax Expense", amount: D(s.incomeTaxExpense), compare: cmp(ps?.incomeTaxExpense) });
  }

  rows.push({ kind: "spacer", label: "" });
  rows.push({ kind: "grand", label: "Net Income", amount: D(s.netIncome), compare: cmp(ps?.netIncome) });

  // Margins (presentation-only, §5)
  const m = computeMargins(cur);
  if (m.netMargin !== null) {
    const pct = (x: number | null) => (x === null ? "—" : `${(x * 100).toFixed(1)}%`);
    rows.push({ kind: "spacer", label: "" });
    rows.push({ kind: "section", label: `Margins — Gross ${pct(m.grossMargin)} · Operating ${pct(m.operatingMargin)} · Net ${pct(m.netMargin)}` });
  }

  return { title: "Profit & Loss", rows };
}

// Per-account natural balances + retained-earnings split from a dataset's GL.
function bsBalances(ds: any) {
  const fyStart = `${String(ds?.asOf ?? "").slice(0, 4)}-01-01`;
  const isDebitNormal = (t: string) => t === "asset" || t === "expense";
  const glByAcct = new Map<string, number>();
  let incomeAll = 0, expenseAll = 0, incomePrior = 0, expensePrior = 0;
  for (const l of (ds?.glCumulative ?? []) as any[]) {
    const t = l.accounts?.type;
    if (!t) continue;
    const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
    const nat = isDebitNormal(t) ? debit - credit : credit - debit;
    glByAcct.set(l.account_id, (glByAcct.get(l.account_id) ?? 0) + nat);
    const isPrior = String(l.journal_entries?.date ?? "") < fyStart;
    if (t === "income") { incomeAll += credit - debit; if (isPrior) incomePrior += credit - debit; }
    else if (t === "expense") { expenseAll += debit - credit; if (isPrior) expensePrior += debit - credit; }
  }
  const rePrior = incomePrior - expensePrior;
  const cye = (incomeAll - expenseAll) - rePrior;
  return { glByAcct, rePrior, cye };
}

function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredReport {
  // GL-derived (opening balances posted to the GL). Current-Year Earnings is
  // computed independently from actuals (income − expense), never plugged.
  const accounts = (d.accounts ?? []) as any[];
  const cur = bsBalances(d);
  const prev = useCompare && p ? bsBalances(p) : undefined;
  // Surface accounts missing from the active COA list (i.e. archived) that still
  // carry a balance, so their balance lands on the statement instead of being
  // dropped (which would unbalance the Balance Sheet). Archived income/expense
  // accounts already flow into Net Income via bsBalances; asset/liability/equity
  // ones must appear as line items here. Zero-balance accounts are not added.
  const knownIds = new Set(accounts.map((a) => a.id));
  const extraAccounts: any[] = [];
  const seenExtra = new Set<string>();
  for (const l of (d.glCumulative ?? []) as any[]) {
    const id = l.account_id; const meta = l.accounts;
    if (!meta || !id || knownIds.has(id) || seenExtra.has(id)) continue;
    seenExtra.add(id);
    if (Math.abs(cur.glByAcct.get(id) ?? 0) > 0.005) extraAccounts.push({ id, name: meta.name, code: meta.code, type: meta.type });
  }
  const allAccounts = extraAccounts.length ? [...accounts, ...extraAccounts] : accounts;
  const balOf = (a: any) => (cur.glByAcct.get(a.id) ?? 0);
  const balOfP = (a: any) => (prev ? (prev.glByAcct.get(a.id) ?? 0) : undefined);
  const cmp = (v: number | undefined) => (prev ? v : undefined);
  const byType = (t: string) => allAccounts.filter((a) => a.type === t);
  const sumBal = (rows: any[]) => rows.reduce((s, a) => s + balOf(a), 0);
  const sumBalP = (rows: any[]) => (prev ? rows.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined);

  // Account-number + parent-category labels (Buildium style:
  // "3011 SIRS Reserves - 3066 Painting/Waterproofing"). Codes are baked into the
  // label so they always show, and rows are sorted so siblings group by parent.
  const acctMeta = new Map<string, { code: any; name: string }>(allAccounts.map((a: any) => [a.id, { code: a.code ?? null, name: a.name }]));
  const codeName = (code: any, name: any) => `${code != null && String(code).trim() !== "" ? String(code) + " " : ""}${name ?? ""}`.trim();
  const bsLabel = (a: any) => {
    const p = a.parent_account_id ? acctMeta.get(a.parent_account_id) : null;
    return p ? `${codeName(p.code, p.name)} - ${codeName(a.code, a.name)}` : codeName(a.code, a.name);
  };
  const bsKey = (a: any) => {
    const p = a.parent_account_id ? acctMeta.get(a.parent_account_id) : null;
    return `${p ? String(p.code ?? "") : String(a.code ?? "")}|${String(a.code ?? "")}`;
  };
  const bsSort = (arr: any[]) => [...arr].sort((x, y) => bsKey(x).localeCompare(bsKey(y), undefined, { numeric: true }));

  const rows: StructuredRow[] = [];

  // Assets
  rows.push({ kind: "section", label: "Assets" });
  const assets = byType("asset");
  for (const a of bsSort(assets)) rows.push({ kind: "sub", label: bsLabel(a), amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
  const totalA = sumBal(assets);
  rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) });
  rows.push({ kind: "spacer", label: "" });

  // Liabilities
  rows.push({ kind: "section", label: "Liabilities" });
  const liabs = byType("liability");
  for (const a of bsSort(liabs)) rows.push({ kind: "sub", label: bsLabel(a), amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
  const totalL = sumBal(liabs);
  rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) });
  rows.push({ kind: "spacer", label: "" });

  // Equity — equity accounts + calculated RE / current-year earnings
  rows.push({ kind: "section", label: "Equity" });
  const equityAccs = byType("equity");
  // A real "Retained Earnings" equity account is postable via journal entries.
  // Fold its posted balance into the calculated "Retained Earnings (prior years)"
  // line so manual JE adjustments show there instead of as a separate line.
  // Posted "Retained Earnings" and "Current Year Earnings" equity accounts are
  // postable via journal entries / opening balances. Fold their balances into the
  // calculated prior-years and Net Income lines (instead of separate lines) so the
  // figures the user enters in Opening Balances show up where expected.
  // Fold ONLY the standard "Retained Earnings" / "Current Year Earnings" accounts
  // into the calculated lines. Distinctly-named equity accounts (e.g. "Retained
  // Earnings Savings", a reserve-equity account) must NOT be swallowed — they
  // render as their own equity line. Match the full name, not a substring.
  const reAccts = equityAccs.filter((a) => /^\s*retained\s+earnings(\s*\(.*\))?\s*$/i.test(String(a.name || "")));
  const cyeAccts = equityAccs.filter((a) => /^\s*current\s*year\s*(earnings|income)(\s*\(.*\))?\s*$/i.test(String(a.name || "")) || /^\s*net\s*income\s*$/i.test(String(a.name || "")));
  const foldedIds = new Set([...reAccts, ...cyeAccts].map((a) => a.id));
  const otherEquity = equityAccs.filter((a) => !foldedIds.has(a.id));
  for (const a of bsSort(otherEquity)) rows.push({ kind: "sub", label: bsLabel(a), amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
  const rePosted = reAccts.reduce((s, a) => s + balOf(a), 0);
  const rePostedP = prev ? reAccts.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined;
  const cyePosted = cyeAccts.reduce((s, a) => s + balOf(a), 0);
  const cyePostedP = prev ? cyeAccts.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined;
  rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior + rePosted, compare: cmp(prev ? prev.rePrior + (rePostedP ?? 0) : undefined) });
  rows.push({ kind: "sub", label: "Net Income", amount: cur.cye + cyePosted, compare: cmp(prev ? prev.cye + (cyePostedP ?? 0) : undefined) });
  const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye;
  const totalEP = prev ? (sumBalP(equityAccs)! + prev.rePrior + prev.cye) : undefined;
  rows.push({ kind: "total", label: "Total Equity", amount: totalE, compare: cmp(totalEP) });
  rows.push({ kind: "spacer", label: "" });

  rows.push({ kind: "grand", label: "TOTAL LIABILITIES & EQUITY", amount: totalL + totalE, compare: cmp(prev ? (sumBalP(liabs)! + totalEP!) : undefined) });

  // Residual surfaced (never plugged): Assets − (Liabilities + Equity).
  const cents = (n: number) => Math.round(n * 100);
  const residualCents = cents(totalA) - cents(totalL + totalE);
  const balanced = residualCents === 0;
  return {
    title: "Balance Sheet",
    rows,
    balanced,
    outOfBalanceAmount: balanced ? undefined : residualCents / 100,
  };
}


// Indirect-method cash flow built from the GL (§5). It ties to the change in the
// Balance Sheet cash accounts by construction (R4): because every entry balances,
// the cash impact of net income + all non-cash balance movements equals ΔCash.
type CashFlowCalc = {
  netIncome: number; operating: { label: string; amount: number }[];
  cfo: number; cfi: number; cff: number; netChange: number;
  beginCash: number; endCash: number; residual: number;
};

function computeCashFlow(d: any): CashFlowCalc {
  const from: string = d.from;
  const acctById = new Map<string, any>((d.accounts ?? []).map((a: any) => [a.id, a]));
  const isCash = (a: any) => !!a && (a.is_bank || /cash|undeposited/i.test(String(a.name || "")));
  const beginRaw = new Map<string, number>();
  const endRaw = new Map<string, number>();
  for (const l of (d.glCumulative ?? []) as any[]) {
    const raw = Number(l.debit || 0) - Number(l.credit || 0);
    endRaw.set(l.account_id, (endRaw.get(l.account_id) ?? 0) + raw);
    if (String(l.journal_entries?.date ?? "") < from) beginRaw.set(l.account_id, (beginRaw.get(l.account_id) ?? 0) + raw);
  }
  const ids = new Set<string>([...endRaw.keys(), ...beginRaw.keys()]);
  const deltaRaw = (id: string) => (endRaw.get(id) ?? 0) - (beginRaw.get(id) ?? 0);

  let beginCash = 0, endCash = 0, revenue = 0, expense = 0, cfi = 0, cff = 0;
  const operating: { label: string; amount: number }[] = [];
  for (const id of ids) {
    const a = acctById.get(id);
    if (!a) continue;
    if (isCash(a)) { beginCash += beginRaw.get(id) ?? 0; endCash += endRaw.get(id) ?? 0; continue; }
    if (a.type === "income") { revenue += -deltaRaw(id); continue; }
    if (a.type === "expense") { expense += deltaRaw(id); continue; }
    const impact = -deltaRaw(id);
    if (Math.abs(impact) < 0.005) continue;
    const name = String(a.name || "").toLowerCase();
    if (a.type === "asset") {
      const naturalUp = deltaRaw(id) > 0;
      if (/investment|property|equipment|fixed|capital asset/.test(name)) cfi += impact;
      else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
    } else if (a.type === "liability") {
      const naturalUp = -deltaRaw(id) > 0;
      if (/loan|note|mortgage|debt|bond/.test(name)) cff += impact;
      else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
    } else if (a.type === "equity") { cff += impact; }
  }
  const netIncome = revenue - expense;
  const cfo = netIncome + operating.reduce((s, r) => s + r.amount, 0);
  const netChange = cfo + cfi + cff;
  return { netIncome, operating, cfo, cfi, cff, netChange, beginCash, endCash, residual: netChange - (endCash - beginCash) };
}

function buildCashFlow(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
  const cur = computeCashFlow(d);
  const prev = useCompare && p ? computeCashFlow(p) : undefined;
  const cmp = (v: number | undefined) => (useCompare && prev ? v : undefined);
  // Match prior-period operating line items by label for the compare column.
  const prevByLabel = new Map((prev?.operating ?? []).map((r) => [r.label, r.amount]));

  const rows: StructuredRow[] = [];
  rows.push({ kind: "section", label: "Operating Activities" });
  rows.push({ kind: "sub", label: "Net Income", amount: cur.netIncome, compare: cmp(prev?.netIncome) });
  for (const r of cur.operating) rows.push({ kind: "sub", label: r.label, amount: r.amount, compare: cmp(prevByLabel.get(r.label) ?? 0) });
  rows.push({ kind: "total", label: "Net Cash from Operating Activities", amount: cur.cfo, compare: cmp(prev?.cfo) });
  rows.push({ kind: "spacer", label: "" });
  rows.push({ kind: "section", label: "Investing Activities" });
  rows.push({ kind: "total", label: "Net Cash from Investing Activities", amount: cur.cfi, compare: cmp(prev?.cfi) });
  rows.push({ kind: "spacer", label: "" });
  rows.push({ kind: "section", label: "Financing Activities" });
  rows.push({ kind: "total", label: "Net Cash from Financing Activities", amount: cur.cff, compare: cmp(prev?.cff) });
  rows.push({ kind: "spacer", label: "" });
  rows.push({ kind: "sub", label: "Beginning Cash", amount: cur.beginCash, compare: cmp(prev?.beginCash) });
  rows.push({ kind: "sub", label: "Ending Cash", amount: cur.endCash, compare: cmp(prev?.endCash) });
  if (Math.abs(cur.residual) >= 0.005) {
    rows.push({ kind: "total", label: "⚠ Out of balance — R4 residual (CFO+CFI+CFF − ΔCash)", amount: cur.residual });
  }

  return {
    title: "Cash Flow Statement",
    rows,
    cashHighlight: { label: "Net Change in Cash", amount: cur.netChange },
  };
}

// ---------- Flat (non-financial) report builders ----------

type Flat = { title: string; columns: string[]; rows: (string | number)[][]; boldRows?: number[] };

function buildFlat(id: ReportId, d: any, cur: string): Flat | null {
  const m = (n: number) => money(n, cur);
  switch (id) {
    case "invoice-summary":
      return {
        title: "Invoice Summary", columns: ["Invoice #", "Customer", "Date", "Status", "Amount"],
        rows: d.invoices.map((i: any) => [i.number, i.customers?.name ?? "—", fmtDate(i.issue_date), i.status, m(Number(i.total))]),
      };
    case "customer-balances":
      return {
        title: "Customer Balances", columns: ["Customer", "Balance"],
        rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]),
      };
    case "expense-summary": {
      // GL-driven, billed-date recognition (a bill's expense counts on the bill
      // date — Dr Expense / Cr A/P — and a direct vendor payment on the payment
      // date — Dr Expense / Cr Bank), then we back out the expense of bills issued
      // in the period that aren't paid yet, so the summary reflects amounts the
      // association has actually paid. Direct payments are cash already, so they
      // stay; only the unpaid portion of open bills is removed.
      const byAcct: Record<string, number> = {};
      for (const l of (d.glLines ?? []) as any[]) {
        const acc = l.accounts;
        if (acc?.type !== "expense") continue;
        const amt = Number(l.debit) - Number(l.credit);
        if (amt === 0) continue;
        const name = acc.name ?? "Expense";
        byAcct[name] = (byAcct[name] ?? 0) + amt;
      }
      // Remove the unpaid portion of period bills, prorated, keyed by account name.
      const acctNameById = new Map((d.accounts ?? []).map((a: any) => [a.id, a.name]));
      for (const bi of (d.periodBillItems ?? []) as any[]) {
        const b = bi.bills;
        if (!b || b.status === "void") continue;
        const total = Number(b.total) || 0;
        if (total <= 0) continue;
        const unpaid = total - (Number(b.paid_amount) || 0);
        if (unpaid <= 0) continue;
        const name = acctNameById.get(bi.account_id);
        if (!name || byAcct[name] === undefined) continue;
        byAcct[name] -= Number(bi.amount) * (unpaid / total);
      }
      const kept = Object.entries(byAcct).filter(([, amt]) => Math.abs(amt) >= 0.005);
      const rows = kept.sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]);
      const total = kept.reduce((s, [, v]) => s + v, 0);
      return { title: "Expense Summary (Paid)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
    }
    case "vendor-balances": {
      const byVendor: Record<string, number> = {};
      for (const b of d.bills) {
        const name = b.vendors?.name ?? "—";
        const bal = Number(b.total) - Number(b.paid_amount ?? 0);
        if (bal > 0) byVendor[name] = (byVendor[name] ?? 0) + bal;
      }
      return {
        title: "Vendor Balances (open bills)", columns: ["Vendor", "Outstanding"],
        rows: Object.entries(byVendor).map(([n, v]) => [n, m(v)]),
      };
    }
    default: return null;
  }
}

function flatToStructured(flat: Flat, title: string): StructuredReport {
  const rows: StructuredRow[] = [{ kind: "section", label: flat.columns.join(" · ") }];
  for (const r of flat.rows) {
    const label = r.slice(0, r.length - 1).map(String).join(" · ");
    const last = r[r.length - 1];
    const num = typeof last === "number" ? last : parseFloat(String(last).replace(/[^0-9.\-]/g, "")) || 0;
    rows.push({ kind: "sub", label, amount: num });
  }
  return { title, rows };
}

// ---------- Budget vs Actuals ----------

/** Order accounts as a tree (parents first, children indented) instead of by number. */
function orderAccountsHierarchically(accs: any[]): any[] {
  const byId = new Map(accs.map((a) => [a.id, a]));
  const childrenByParent = new Map<string, any[]>();
  const roots: any[] = [];
  for (const a of accs) {
    if (a.parent_account_id && byId.has(a.parent_account_id)) {
      const arr = childrenByParent.get(a.parent_account_id) ?? [];
      arr.push(a); childrenByParent.set(a.parent_account_id, arr);
    } else {
      roots.push(a);
    }
  }
  const byCode = (a: any, b: any) => String(a.code ?? "").localeCompare(String(b.code ?? ""));
  roots.sort(byCode);
  const out: any[] = [];
  const visit = (node: any, depth: number) => {
    out.push({ ...node, _depth: depth });
    for (const k of (childrenByParent.get(node.id) ?? []).sort(byCode)) visit(k, depth + 1);
  };
  for (const r of roots) visit(r, 0);
  return out;
}

async function fetchBvaActuals(companyId: string, f: string, t: string) {
  // Actuals come straight from the General Ledger so Budget vs. Actuals ties to
  // the Income Statement and works for GL-import / import-mode associations whose
  // activity lives only in journal entries. Sourcing from the operational tables
  // (bills + payment transactions + a budget-weighted invoice "plug") both
  // double-counted (a bill counted as its line item AND its payment, plus any
  // redundant imported bills) and diverged from the posted books.
  const lines = await fetchAllGLLines(
    companyId,
    t,
    "debit,credit,account_id,accounts!inner(id,type),journal_entries!inner(company_id,date)",
    f,
  );
  return { lines };
}

// Actuals per account, netted from the GL: income = credit − debit,
// expense = debit − credit (the same convention as every other report here).
function computeBvaActuals(actualsData: any): Record<string, number> {
  const m: Record<string, number> = {};
  for (const l of (actualsData?.lines ?? []) as any[]) {
    const acctId = l.account_id ?? l.accounts?.id;
    if (!acctId) continue;
    const type = l.accounts?.type;
    if (type !== "income" && type !== "expense") continue;
    const debit = Number(l.debit || 0);
    const credit = Number(l.credit || 0);
    m[acctId] = (m[acctId] ?? 0) + (type === "income" ? credit - debit : debit - credit);
  }
  return m;
}

// Budget vs. Actuals column shading — a light→dark grey gradient with left
// vertical borders so the Budget / Actual / Variance / Variance % columns are
// easy to tell apart. Applied to the header, group-total and detail cells.
// Page view stays plain; the grey gradient lives on the PDF export only.
const BVA_COL = {
  budget: "text-right tabular-nums",
  actual: "text-right tabular-nums",
  variance: "text-right tabular-nums",
  pct: "text-right tabular-nums",
  cmp: "text-right tabular-nums",
};

function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel, logoUrl }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string; logoUrl?: string | null }) {
  const { data: budgets = [] } = useQuery({
    queryKey: ["budgets-active", companyId],
    enabled: !!companyId,
    queryFn: async () => (await accounting.from("budgets").select("*").eq("company_id", companyId).eq("status", "active").order("created_at", { ascending: false })).data ?? [],
  });

  const [budgetId, setBudgetId] = useState<string>("");
  useEffect(() => { if (!budgetId && budgets.length) setBudgetId(budgets[0].id); }, [budgets, budgetId]);

  // Actuals comparison window — defaults to the report period, but the user can
  // choose any custom date range to compare against the budget.
  const [actFrom, setActFrom] = useState(from);
  const [actTo, setActTo] = useState(to);
  useEffect(() => { setActFrom(from); setActTo(to); }, [from, to]);
  const actualsLabel = `${actFrom} to ${actTo}`;

  const { data: accounts = [] } = useQuery({
    queryKey: ["accounts", companyId, "budget-actuals"],
    enabled: !!companyId,
    queryFn: async () => (await accounting.from("accounts").select("*").eq("company_id", companyId).eq("is_archived", false).order("code")).data ?? [],
  });

  const { data: entries = [] } = useQuery({
    queryKey: ["budget-entries", budgetId],
    enabled: !!budgetId,
    queryFn: async () => (await accounting.from("budget_entries").select("*").eq("budget_id", budgetId)).data ?? [],
  });

  // Optional comparison window (B3): compare actuals to another date range.
  const [cmpOn, setCmpOn] = useState(false);
  const [cmpFrom, setCmpFrom] = useState("");
  const [cmpTo, setCmpTo] = useState("");

  const { data: actualsData } = useQuery({
    queryKey: ["bva-actuals", companyId, actFrom, actTo],
    enabled: !!companyId,
    queryFn: () => fetchBvaActuals(companyId, actFrom, actTo),
  });

  const { data: cmpActualsData } = useQuery({
    queryKey: ["bva-actuals-cmp", companyId, cmpFrom, cmpTo],
    enabled: !!companyId && cmpOn && !!cmpFrom && !!cmpTo,
    queryFn: () => fetchBvaActuals(companyId, cmpFrom, cmpTo),
  });

  const TYPES_LOCAL = [
    { value: "income", label: "Income", favorableWhen: "over" as const },
    { value: "expense", label: "Expenses", favorableWhen: "under" as const },
  ];

  const grouped = useMemo(() => {
    const out: Record<string, any[]> = { income: [], expense: [] };
    for (const a of accounts) if (a.type === "income" || a.type === "expense") out[a.type].push(a);
    return out;
  }, [accounts]);

  // Same accounts, ordered as a parent→child tree (each carries `_depth`).
  const groupedOrdered = useMemo(() => ({
    income: orderAccountsHierarchically(grouped.income ?? []),
    expense: orderAccountsHierarchically(grouped.expense ?? []),
  } as Record<string, any[]>), [grouped]);

  const selectedBudget = useMemo(() => (budgets as any[]).find((b) => b.id === budgetId), [budgets, budgetId]);

  // Budget for the selected actuals window: include each budget period's FULL
  // amount, exactly as entered, for any period the window touches — no proration.
  // period_index maps to months (monthly), quarters (quarterly), or the whole
  // year (annual). E.g. Jan 1 – Jun 16 = full Jan…Jun monthly budgets.
  const budgetByAcct = useMemo(() => {
    const m: Record<string, number> = {};
    const pt = String(selectedBudget?.period_type ?? "annual");
    const fy = Number(selectedBudget?.fiscal_year) || new Date(actFrom || actTo || Date.now()).getFullYear();
    const fromT = actFrom ? new Date(actFrom).getTime() : -Infinity;
    const toT = actTo ? new Date(actTo).getTime() : Infinity;
    const span = (idx: number): [number, number] => {
      if (pt === "monthly") return [new Date(fy, idx, 1).getTime(), new Date(fy, idx + 1, 0).getTime()];
      if (pt === "quarterly") return [new Date(fy, idx * 3, 1).getTime(), new Date(fy, idx * 3 + 3, 0).getTime()];
      return [new Date(fy, 0, 1).getTime(), new Date(fy, 11, 31).getTime()];
    };
    for (const e of (entries as any[])) {
      const [s, en] = span(Number(e.period_index) || 0);
      if (s > toT || en < fromT) continue; // period not touched by the window
      m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
    }
    return m;
  }, [entries, selectedBudget, actFrom, actTo]);

  const actualByAcct = useMemo(() => computeBvaActuals(actualsData), [actualsData]);
  const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData), [cmpActualsData]);

  // Comparison-window budget (full per-period amounts like budgetByAcct, over
  // [cmpFrom, cmpTo]). No proration.
  const cmpBudgetByAcct = useMemo(() => {
    const m: Record<string, number> = {};
    if (!cmpOn || !cmpFrom || !cmpTo) return m;
    const pt = String(selectedBudget?.period_type ?? "annual");
    const fy = Number(selectedBudget?.fiscal_year) || new Date(cmpFrom || cmpTo || Date.now()).getFullYear();
    const fromT = new Date(cmpFrom).getTime();
    const toT = new Date(cmpTo).getTime();
    const span = (idx: number): [number, number] => {
      if (pt === "monthly") return [new Date(fy, idx, 1).getTime(), new Date(fy, idx + 1, 0).getTime()];
      if (pt === "quarterly") return [new Date(fy, idx * 3, 1).getTime(), new Date(fy, idx * 3 + 3, 0).getTime()];
      return [new Date(fy, 0, 1).getTime(), new Date(fy, 11, 31).getTime()];
    };
    for (const e of (entries as any[])) {
      const [s, en] = span(Number(e.period_index) || 0);
      if (s > toT || en < fromT) continue; // period not touched by the window
      m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
    }
    return m;
  }, [entries, selectedBudget, cmpOn, cmpFrom, cmpTo]);

  // Full-year budget per account (no pro-ration) for the Annual Budget column.
  const annualBudgetByAcct = useMemo(() => {
    const m: Record<string, number> = {};
    for (const e of (entries as any[])) m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
    return m;
  }, [entries]);

  const chartData = useMemo(() => {
    const sumGroup = (type: "income" | "expense") => {
      const accs = grouped[type] ?? [];
      const b = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0);
      const ac = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
      return { b, ac };
    };
    const inc = sumGroup("income"); const exp = sumGroup("expense");
    return [
      { name: "Income", Budget: inc.b, Actual: inc.ac },
      { name: "Expenses", Budget: exp.b, Actual: exp.ac },
    ];
  }, [grouped, budgetByAcct, actualByAcct]);

  // Flattened rows (group totals + accounts) shared by the CSV and PDF exports.
  const exportRows = useMemo(() => {
    const rows: { label: string; budget: number; actual: number; variance: number; pct: string; group: boolean }[] = [];
    for (const t of TYPES_LOCAL) {
      const accs = grouped[t.value] ?? [];
      if (!accs.length) continue;
      const tb = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0);
      const ta = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
      const tv = ta - tb;
      rows.push({ label: t.label, budget: tb, actual: ta, variance: tv, pct: tb ? `${((tv / tb) * 100).toFixed(1)}%` : "—", group: true });
      for (const a of groupedOrdered[t.value] ?? []) {
        const b = budgetByAcct[a.id] ?? 0; const ac = actualByAcct[a.id] ?? 0; const v = ac - b;
        const indent = "    ".repeat(a._depth ?? 0);
        rows.push({ label: `${indent}${a.code ? `${a.name} (${a.code})` : a.name}`, budget: b, actual: ac, variance: v, pct: b ? `${((v / b) * 100).toFixed(1)}%` : "—", group: false });
      }
    }
    return rows;
  }, [grouped, groupedOrdered, budgetByAcct, actualByAcct]);

  const fileBase = `budget-vs-actuals-${actFrom}-to-${actTo}`;

  const exportCSV = () => {
    const esc = (s: any) => `"${String(s).replace(/"/g, '""')}"`;
    const lines = [["Account", "Budget", "Actual", "Variance", "Variance %"].join(",")];
    for (const r of exportRows) lines.push([esc(r.label), r.budget.toFixed(2), r.actual.toFixed(2), r.variance.toFixed(2), esc(r.pct)].join(","));
    const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url; a.download = `${fileBase}.csv`; a.click();
    URL.revokeObjectURL(url);
  };

  // Export using the shared branded Budget vs. Actuals generator (current period +
  // optional comparison + Annual Budget columns), matching the main report style.
  const exportPDF = async () => {
    const nameById = new Map((accounts as any[]).map((a) => [a.id, a.name]));
    const parentIds = new Set((accounts as any[]).filter((a) => a.parent_account_id).map((a) => a.parent_account_id));
    const rows: any[] = [];
    for (const type of ["income", "expense"] as const) {
      for (const a of (grouped[type] ?? [])) {
        if (parentIds.has(a.id)) continue; // parent accounts render as group headers
        const budget = budgetByAcct[a.id] ?? 0;
        const actual = actualByAcct[a.id] ?? 0;
        const cmpA = cmpActualByAcct[a.id] ?? 0;
        const cmpB = cmpBudgetByAcct[a.id] ?? 0;
        const annual = annualBudgetByAcct[a.id] ?? 0;
        rows.push({
          id: a.id,
          category: a.code ? `${a.code} ${a.name}` : a.name,
          accountType: type,
          parentId: a.parent_account_id ?? null,
          parentCategory: a.parent_account_id ? (nameById.get(a.parent_account_id) ?? null) : null,
          budget, annualBudget: annual, actual, variance: actual - budget,
          pctOfBudget: budget ? (actual / budget) * 100 : 0,
          comparisonActual: cmpA, comparisonBudget: cmpB, comparisonVariance: cmpA - cmpB,
          comparisonPctOfBudget: cmpB ? (cmpA / cmpB) * 100 : 0, cmpDelta: 0,
        });
      }
    }
    const inc = rows.filter((r) => r.accountType === "income");
    const exp = rows.filter((r) => r.accountType !== "income");
    const sum = (rs: any[], k: string) => rs.reduce((s, r) => s + (Number(r[k]) || 0), 0);
    await generateBudgetVsActualPdf({
      association: { name: companyName, logo_url: logoUrl ?? null },
      fiscalYear: Number(selectedBudget?.fiscal_year) || new Date().getFullYear(),
      rangeLabel: actualsLabel,
      comparisonLabel: cmpOn && cmpFrom && cmpTo ? "Comparison" : null,
      comparisonRangeLabel: cmpOn && cmpFrom && cmpTo ? `${cmpFrom} to ${cmpTo}` : null,
      rows,
      totals: {
        incomeBudget: sum(inc, "budget"), incomeActual: sum(inc, "actual"),
        incomeCmp: sum(inc, "comparisonActual"), incomeCmpBudget: sum(inc, "comparisonBudget"),
        expenseBudget: sum(exp, "budget"), expenseActual: sum(exp, "actual"),
        expenseCmp: sum(exp, "comparisonActual"), expenseCmpBudget: sum(exp, "comparisonBudget"),
      },
      comparisonBudgetMonths: null,
    });
  };

  if (!budgets.length) {
    return (
      <Card><CardContent className="py-12 text-center text-sm text-muted-foreground">
        No active budgets yet. <Link to="/dashboard/accounting/budgets" className="text-primary underline">Create one</Link> to compare against actuals.
      </CardContent></Card>
    );
  }

  return (
    <div className="space-y-4">
      <Card>
        <CardContent className="flex flex-wrap items-center gap-3 py-4">
          <Label className="text-sm">Budget:</Label>
          <Select value={budgetId} onValueChange={setBudgetId}>
            <SelectTrigger className="h-9 w-[280px]"><SelectValue /></SelectTrigger>
            <SelectContent>
              {budgets.map((b: any) => <SelectItem key={b.id} value={b.id}>{b.name} (FY {b.fiscal_year})</SelectItem>)}
            </SelectContent>
          </Select>
          <div className="flex items-center gap-2">
            <Label className="text-sm">Actuals from</Label>
            <Input type="date" value={actFrom} onChange={(e) => setActFrom(e.target.value)} className="h-9 w-40" />
            <Label className="text-sm">to</Label>
            <Input type="date" value={actTo} onChange={(e) => setActTo(e.target.value)} className="h-9 w-40" />
          </div>
          <div className="flex items-center gap-2">
            <Label className="text-sm flex items-center gap-1">
              <input type="checkbox" checked={cmpOn} onChange={(e) => setCmpOn(e.target.checked)} /> Compare to
            </Label>
            {cmpOn && (<>
              <Input type="date" value={cmpFrom} onChange={(e) => setCmpFrom(e.target.value)} className="h-9 w-40" />
              <Label className="text-sm">to</Label>
              <Input type="date" value={cmpTo} onChange={(e) => setCmpTo(e.target.value)} className="h-9 w-40" />
            </>)}
          </div>
          <div className="ml-auto flex gap-2">
            <Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button>
            <Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
          </div>
          <div className="w-full text-xs text-muted-foreground">
            Budget shows the full {String((selectedBudget as any)?.period_type ?? "annual")} amounts for every period the selected range touches — exactly as entered, no proration.
          </div>
        </CardContent>
      </Card>

      <ReportSheet title="Budget vs. Actuals" subtitle="Accrual basis" companyName={companyName} period={actualsLabel} logoUrl={logoUrl}>
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Account</TableHead>
                <TableHead className={BVA_COL.budget}>Budget</TableHead>
                <TableHead className={BVA_COL.actual}>Actual</TableHead>
                <TableHead className={BVA_COL.variance}>Variance</TableHead>
                <TableHead className={BVA_COL.pct}>Variance %</TableHead>
                {cmpOn && <TableHead className={BVA_COL.cmp}>Compare</TableHead>}
                {cmpOn && <TableHead className={BVA_COL.cmp}>Δ vs Compare</TableHead>}
              </TableRow>
            </TableHeader>
            <TableBody>
              {TYPES_LOCAL.map((t) => {
                const accs = grouped[t.value] ?? [];
                if (!accs.length) return null;
                const totalB = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0);
                const totalA = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
                const totalC = accs.reduce((s, a) => s + (cmpActualByAcct[a.id] ?? 0), 0);
                const totalVar = totalA - totalB;
                const totalPct = totalB ? (totalVar / totalB) * 100 : 0;
                const totalFavorable = t.favorableWhen === "over" ? totalVar >= 0 : totalVar <= 0;
                return (
                  <Fragment key={t.value}>
                    <TableRow className="bg-muted/60 font-semibold">
                      <TableCell>{t.label}</TableCell>
                      <TableCell className={BVA_COL.budget}>{money(totalB, currency)}</TableCell>
                      <TableCell className={BVA_COL.actual}>{money(totalA, currency)}</TableCell>
                      <TableCell className={`${BVA_COL.variance} ${totalFavorable ? "text-emerald-700" : "text-red-700"}`}>{money(totalVar, currency)}</TableCell>
                      <TableCell className={`${BVA_COL.pct} ${totalFavorable ? "text-emerald-700" : "text-red-700"}`}>{totalB ? `${totalPct.toFixed(1)}%` : "—"}</TableCell>
                      {cmpOn && <TableCell className={BVA_COL.cmp}>{money(totalC, currency)}</TableCell>}
                      {cmpOn && <TableCell className={BVA_COL.cmp}>{money(totalA - totalC, currency)}</TableCell>}
                    </TableRow>
                    {(groupedOrdered[t.value] ?? []).map((a: any) => {
                      const b = budgetByAcct[a.id] ?? 0;
                      const ac = actualByAcct[a.id] ?? 0;
                      const c = cmpActualByAcct[a.id] ?? 0;
                      const v = ac - b;
                      const pct = b ? (v / b) * 100 : 0;
                      const fav = t.favorableWhen === "over" ? v >= 0 : v <= 0;
                      const depth = a._depth ?? 0;
                      return (
                        <TableRow key={a.id} className="hover:bg-muted/20">
                          <TableCell style={{ paddingLeft: `${16 + depth * 20}px` }}>
                            <span className={depth === 0 ? "font-semibold" : "font-medium"}>{a.name}</span>
                            {a.code && <span className="text-xs text-muted-foreground font-mono ml-2">{a.code}</span>}
                          </TableCell>
                          <TableCell className={BVA_COL.budget}>{money(b, currency)}</TableCell>
                          <TableCell className={BVA_COL.actual}>{money(ac, currency)}</TableCell>
                          <TableCell className={`${BVA_COL.variance} ${fav ? "text-emerald-700" : "text-red-700"}`}>{money(v, currency)}</TableCell>
                          <TableCell className={`${BVA_COL.pct} ${fav ? "text-emerald-700" : "text-red-700"}`}>{b ? `${pct.toFixed(1)}%` : "—"}</TableCell>
                          {cmpOn && <TableCell className={BVA_COL.cmp}>{money(c, currency)}</TableCell>}
                          {cmpOn && <TableCell className={BVA_COL.cmp}>{money(ac - c, currency)}</TableCell>}
                        </TableRow>
                      );
                    })}
                  </Fragment>
                );
              })}
            </TableBody>
          </Table>
      </ReportSheet>
    </div>
  );
}

// ── AP Aging ─────────────────────────────────────────────────────────────────

function APAgingTable({ rows, currency }: { rows: any[]; currency: string }) {
  const now = new Date();
  type APRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
  const byVendor = new Map<string, APRow>();
  for (const bill of rows) {
    const open = Number(bill.total ?? 0) - Number(bill.paid_amount ?? 0);
    if (open <= 0) continue;
    const vid = bill.vendor_id ?? bill.vendors?.id ?? bill.id;
    const name = bill.vendors?.name ?? "Unknown Vendor";
    const due = bill.due_date ? new Date(bill.due_date) : new Date(bill.issue_date ?? Date.now());
    const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
    const r = byVendor.get(vid) ?? { id: vid, name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
    if (days <= 0) r.current += open;
    else if (days <= 30) r.d30 += open;
    else if (days <= 60) r.d60 += open;
    else if (days <= 90) r.d90 += open;
    else r.d90p += open;
    r.total += open;
    byVendor.set(vid, r);
  }
  const list = Array.from(byVendor.values()).sort((a, b) => b.total - a.total);
  const totals = list.reduce(
    (s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }),
    { current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }
  );
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Vendor</TableHead>
          <TableHead className="text-right">Current</TableHead>
          <TableHead className="text-right">1–30 days</TableHead>
          <TableHead className="text-right">31–60 days</TableHead>
          <TableHead className="text-right">61–90 days</TableHead>
          <TableHead className="text-right">90+ days</TableHead>
          <TableHead className="text-right font-semibold">Total</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {list.map((r) => (
          <TableRow key={r.id} className="hover:bg-muted/40">
            <TableCell className="font-medium">{r.name}</TableCell>
            <TableCell className="text-right tabular-nums">{money(r.current, currency)}</TableCell>
            <TableCell className="text-right tabular-nums text-amber-600">{money(r.d30, currency)}</TableCell>
            <TableCell className="text-right tabular-nums text-orange-600">{money(r.d60, currency)}</TableCell>
            <TableCell className="text-right tabular-nums text-red-500">{money(r.d90, currency)}</TableCell>
            <TableCell className="text-right tabular-nums text-red-700 font-medium">{money(r.d90p, currency)}</TableCell>
            <TableCell className="text-right tabular-nums font-semibold">{money(r.total, currency)}</TableCell>
          </TableRow>
        ))}
        {list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No outstanding payables.</TableCell></TableRow>}
        {list.length > 0 && (
          <TableRow className="font-semibold bg-muted/30">
            <TableCell>Total</TableCell>
            <TableCell className="text-right tabular-nums">{money(totals.current, currency)}</TableCell>
            <TableCell className="text-right tabular-nums">{money(totals.d30, currency)}</TableCell>
            <TableCell className="text-right tabular-nums">{money(totals.d60, currency)}</TableCell>
            <TableCell className="text-right tabular-nums">{money(totals.d90, currency)}</TableCell>
            <TableCell className="text-right tabular-nums">{money(totals.d90p, currency)}</TableCell>
            <TableCell className="text-right tabular-nums">{money(totals.total, currency)}</TableCell>
          </TableRow>
        )}
      </TableBody>
    </Table>
  );
}

// ── Homeowner Balance Summary ─────────────────────────────────────────────────

function HomeownerSummaryTable({ customers, invoices, currency }: { customers: any[]; invoices: any[]; currency: string }) {
  const byCustomer = useMemo(() => {
    const m = new Map<string, { open: number; count: number; lastPaid: string | null }>();
    for (const inv of invoices) {
      const cid = inv.customer_id;
      if (!cid) continue;
      const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
      const isPaid = inv.status === "paid";
      const cur = m.get(cid) ?? { open: 0, count: 0, lastPaid: null };
      if (open > 0) { cur.open += open; cur.count++; }
      if (isPaid && (!cur.lastPaid || inv.issue_date > cur.lastPaid)) cur.lastPaid = inv.issue_date;
      m.set(cid, cur);
    }
    return m;
  }, [invoices]);

  const rows = customers
    .map((c: any) => ({ ...c, ...(byCustomer.get(c.id) ?? { open: 0, count: 0, lastPaid: null }) }))
    .sort((a, b) => b.open - a.open);

  const totalBalance = rows.reduce((s, r) => s + Number(r.balance ?? 0), 0);
  const totalOpen = rows.reduce((s, r) => s + r.open, 0);

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Homeowner</TableHead>
          <TableHead>Property</TableHead>
          <TableHead className="text-right">Open Invoices</TableHead>
          <TableHead className="text-right">Outstanding Balance</TableHead>
          <TableHead className="text-right">Last Payment</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {rows.map((r: any) => (
          <TableRow key={r.id} className="hover:bg-muted/40">
            <TableCell>
              <Link to={`/dashboard/accounting/customers/${r.id}`} className="font-medium text-primary hover:underline">{r.name}</Link>
            </TableCell>
            <TableCell className="text-sm text-muted-foreground">{r.property_address ?? "—"}{r.lot_number ? ` · Lot ${r.lot_number}` : ""}</TableCell>
            <TableCell className="text-right tabular-nums">{r.count || "—"}</TableCell>
            <TableCell className={`text-right tabular-nums font-medium ${r.open > 0 ? "text-red-600" : ""}`}>{money(r.open, currency)}</TableCell>
            <TableCell className="text-right text-sm text-muted-foreground">{r.lastPaid ? fmtDate(r.lastPaid) : "—"}</TableCell>
          </TableRow>
        ))}
        {rows.length === 0 && <TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No homeowners.</TableCell></TableRow>}
        <TableRow className="font-semibold bg-muted/30">
          <TableCell colSpan={3}>Total ({rows.length} homeowners)</TableCell>
          <TableCell className="text-right tabular-nums">{money(totalOpen, currency)}</TableCell>
          <TableCell />
        </TableRow>
      </TableBody>
    </Table>
  );
}

// ── Delinquency Report ────────────────────────────────────────────────────────

function DelinquencyTable({ customers, invoices, currency }: { customers: any[]; invoices: any[]; currency: string }) {
  const now = new Date();
  type DelRow = { id: string; name: string; email: string | null; phone: string | null; property: string | null; overdue: number; oldest: number; invoiceCount: number };
  const byCustomer = new Map<string, DelRow>();

  for (const inv of invoices) {
    const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
    if (open <= 0) continue;
    const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date ?? Date.now());
    const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
    if (days <= 0) continue; // not overdue yet
    const cid = inv.customer_id;
    if (!cid) continue;
    const cust = customers.find((c: any) => c.id === cid);
    const r = byCustomer.get(cid) ?? {
      id: cid, name: cust?.name ?? inv.customers?.name ?? "—",
      email: cust?.email ?? null, phone: cust?.phone ?? null,
      property: cust?.property_address ?? null,
      overdue: 0, oldest: 0, invoiceCount: 0,
    };
    r.overdue += open;
    r.oldest = Math.max(r.oldest, days);
    r.invoiceCount++;
    byCustomer.set(cid, r);
  }

  const list = Array.from(byCustomer.values()).sort((a, b) => b.overdue - a.overdue);
  const totalOverdue = list.reduce((s, r) => s + r.overdue, 0);

  const ageBucket = (days: number) => {
    if (days <= 30) return { label: "1–30 days", cls: "bg-amber-100 text-amber-800" };
    if (days <= 60) return { label: "31–60 days", cls: "bg-orange-100 text-orange-800" };
    if (days <= 90) return { label: "61–90 days", cls: "bg-red-100 text-red-800" };
    return { label: "90+ days", cls: "bg-red-200 text-red-900 font-semibold" };
  };

  return (
    <div className="space-y-3">
      {totalOverdue > 0 && (
        <div className="rounded-md bg-red-50 border border-red-200 px-4 py-2 text-sm flex items-center justify-between">
          <span className="font-medium text-red-800">{list.length} homeowners with overdue balances</span>
          <span className="font-bold text-red-900">{money(totalOverdue, currency)} total overdue</span>
        </div>
      )}
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Homeowner</TableHead>
            <TableHead>Property</TableHead>
            <TableHead>Contact</TableHead>
            <TableHead className="text-right">Invoices</TableHead>
            <TableHead>Age</TableHead>
            <TableHead className="text-right">Amount Overdue</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {list.map((r) => {
            const { label, cls } = ageBucket(r.oldest);
            return (
              <TableRow key={r.id} className="hover:bg-muted/40">
                <TableCell>
                  <Link to={`/dashboard/accounting/customers/${r.id}`} className="font-medium text-primary hover:underline">{r.name}</Link>
                </TableCell>
                <TableCell className="text-sm text-muted-foreground">{r.property ?? "—"}</TableCell>
                <TableCell className="text-sm">
                  {r.email && <div><a href={`mailto:${r.email}`} className="text-primary hover:underline">{r.email}</a></div>}
                  {r.phone && <div className="text-muted-foreground">{r.phone}</div>}
                </TableCell>
                <TableCell className="text-right tabular-nums">{r.invoiceCount}</TableCell>
                <TableCell><span className={`rounded-full px-2 py-0.5 text-xs ${cls}`}>{label}</span></TableCell>
                <TableCell className="text-right tabular-nums font-semibold text-red-700">{money(r.overdue, currency)}</TableCell>
              </TableRow>
            );
          })}
          {list.length === 0 && <TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No overdue accounts. All homeowners are current.</TableCell></TableRow>}
          {list.length > 0 && (
            <TableRow className="font-semibold bg-muted/30">
              <TableCell colSpan={5}>Total overdue</TableCell>
              <TableCell className="text-right tabular-nums text-red-700">{money(totalOverdue, currency)}</TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
}

// ── AR Aging ──────────────────────────────────────────────────────────────────

function ARAgingTable({ rows, currency, detailed = false }: { rows: any[]; currency: string; detailed?: boolean }) {
  const now = new Date();
  type AgingRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
  const byCustomer = new Map<string, AgingRow>();
  for (const inv of rows) {
    const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
    if (open <= 0) continue;
    const cid = inv.customer_id ?? inv.customers?.id;
    if (!cid) continue;
    const name = inv.customers?.name ?? "—";
    const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date);
    const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
    const r = byCustomer.get(cid) ?? { id: cid, name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
    if (days <= 0) r.current += open;
    else if (days <= 30) r.d30 += open;
    else if (days <= 60) r.d60 += open;
    else if (days <= 90) r.d90 += open;
    else r.d90p += open;
    r.total += open;
    byCustomer.set(cid, r);
  }
  const list = Array.from(byCustomer.values()).sort((a, b) => b.total - a.total);
  const totals = list.reduce(
    (s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }),
    { current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }
  );
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Homeowner</TableHead>
          <TableHead className="text-right">Current</TableHead>
          <TableHead className="text-right">1–30 days</TableHead>
          <TableHead className="text-right">31–60 days</TableHead>
          <TableHead className="text-right">61–90 days</TableHead>
          <TableHead className="text-right">90+ days</TableHead>
          <TableHead className="text-right font-semibold">Total</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {list.map((r) => (
          <TableRow key={r.id} className="cursor-pointer hover:bg-muted/40">
            <TableCell>
              <Link to={`/dashboard/accounting/customers/${r.id}`} className="text-primary hover:underline">{r.name}</Link>
            </TableCell>
            <TableCell className="text-right">{money(r.current, currency)}</TableCell>
            <TableCell className="text-right">{money(r.d30, currency)}</TableCell>
            <TableCell className="text-right">{money(r.d60, currency)}</TableCell>
            <TableCell className="text-right">{money(r.d90, currency)}</TableCell>
            <TableCell className="text-right">{money(r.d90p, currency)}</TableCell>
            <TableCell className="text-right font-medium">{money(r.total, currency)}</TableCell>
          </TableRow>
        ))}
        {list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No outstanding receivables.</TableCell></TableRow>}
        {list.length > 0 && (
          <TableRow className="font-semibold bg-muted/30">
            <TableCell>Total</TableCell>
            <TableCell className="text-right">{money(totals.current, currency)}</TableCell>
            <TableCell className="text-right">{money(totals.d30, currency)}</TableCell>
            <TableCell className="text-right">{money(totals.d60, currency)}</TableCell>
            <TableCell className="text-right">{money(totals.d90, currency)}</TableCell>
            <TableCell className="text-right">{money(totals.d90p, currency)}</TableCell>
            <TableCell className="text-right">{money(totals.total, currency)}</TableCell>
          </TableRow>
        )}
      </TableBody>
    </Table>
  );
}

