import { useEffect, useMemo, useRef, useState } from 'react';
import { useHttp } from '@inertiajs/react';
import { Search } from 'lucide-react';
import { FloatingField } from '@/Components/Proto/UI/FloatingField';
import { SearchableSelect } from '@/Components/Form/SearchableSelect';
import { FilterPill } from '@/Components/ui/filter-pill';
import { PivotBoard } from '@/Components/Pivot/PivotBoard';
import { ListFooter } from '@/Components/Table/ListFooter';
import { useClientSort, SortButton } from '@/lib/ClientSort';
import { useToast } from '@/Components/Toast';

/**
 * Inline integration-report panel under the company list — expands beneath the option groups
 * when one of the five "Details … Generate" links is toggled (the user chose an in-page
 * panel over a separate page, 2026-08-05: "kalau langsung dibuka di page yang sama").
 *
 * Data comes over useHttp JSON (companies[.scope].reports — NOT an Inertia visit, so failure
 * toasts live here, per .claude/rules/notifications.md). Options load once per report open;
 * Search fetches facts only. The SCOPE is part of the ROUTE, never the payload — see
 * CompanyIntegrationReportController.
 *
 * ⚠️ THE COLUMN SETS BELOW ARE THE LEGACY GRIDS, header for header. They were previously keyed
 * on `DummyCompanyReportFacts`, which invented its own vocabulary while the backend was
 * unavailable — and three of its column sets did not survive contact with the real saved
 * searches: `getar` has no `division`, `getbookingorder` has neither `division` nor `etd`, and
 * the AST inv-analysis grid is a different shape from the NetSuite one rather than a copy of it.
 * Any column added here must exist in the report's source; a header over a permanently empty
 * cell is worse than no column.
 */

// ── Per-report vocabularies (detail columns + pivot fields) ─────────────────────────────
// Headers are the legacy <th> text verbatim, so a reader can diff a ported grid against the
// screen it replaces without a translation table.

const NS_INV_COLS = [
    { id: 'tahun', label: 'Tahun', int: true },
    { id: 'quarter', label: 'Quarter', int: true },
    { id: 'bulan', label: 'Bulan', int: true },
    { id: 'minggu', label: 'Minggu', int: true },
    { id: 'tanggal', label: 'Tanggal', date: true },
    { id: 'branch', label: 'Branch' },
    { id: 'division', label: 'Division' },
    { id: 'industry', label: 'Industry' },
    { id: 'application', label: 'Application' },
    { id: 'company', label: 'Company' },
    // Two DIFFERENT reps: SalesCode owns the customer, SalesTransCode wrote the transaction.
    { id: 'sales', label: 'SalesCode' },
    { id: 'salesTrans', label: 'SalesTransCode' },
    { id: 'principal', label: 'PrincipalName' },
    { id: 'namaBarang', label: 'NamaBarang' },
    { id: 'namaAlias', label: 'NamaAlias' },
    { id: 'qty', label: 'InvQt', num: true },
    { id: 'price', label: 'Price', num: true },
    { id: 'amount', label: 'InvAmount', num: true },
    // RA = Return Authorisation (customsearch217), subtracted from the invoice line.
    { id: 'returQty', label: 'RAQt', num: true },
    { id: 'returAmount', label: 'RAAmount', num: true },
    { id: 'totalQty', label: 'TotalQt', num: true },
    { id: 'totalAmount', label: 'TotalAmount', num: true },
];

const NS_INV_PIVOT = {
    fields: [
        { id: 'tahun', label: 'Tahun' }, { id: 'quarter', label: 'Quarter' }, { id: 'bulan', label: 'Bulan' },
        { id: 'minggu', label: 'Minggu' }, { id: 'tanggal', label: 'Tanggal' }, { id: 'branch', label: 'Branch' },
        { id: 'division', label: 'Division' }, { id: 'industry', label: 'Industry' }, { id: 'application', label: 'Application' },
        { id: 'company', label: 'Company' }, { id: 'sales', label: 'SalesCode' }, { id: 'salesTrans', label: 'SalesTransCode' },
        { id: 'principal', label: 'PrincipalName' }, { id: 'namaBarang', label: 'NamaBarang' }, { id: 'namaAlias', label: 'NamaAlias' },
    ],
    measures: [
        { id: 'totalAmount', label: 'TotalAmount' }, { id: 'totalQty', label: 'TotalQt' },
        { id: 'amount', label: 'InvAmount' }, { id: 'qty', label: 'InvQt' }, { id: 'price', label: 'Price' },
        // Legacy hands its whole grid to pivotUI and hides only "No", so every other column is
        // usable in both zones. Omitting these two made "Sum of RAAmount by Principal" — an
        // obvious returns question — impossible to ask.
        { id: 'returAmount', label: 'RAAmount' }, { id: 'returQty', label: 'RAQt' },
    ],
    // Legacy's own starting layout: Division × Tahun, Sum of TotalAmount, heatmap.
    initial: { rows: ['division'], cols: ['tahun'], measure: 'totalAmount', agg: 'sum', renderer: 'heatmap' },
};

// AST inv-analysis is a GROUPED query (per month/product), so it has no per-line Tanggal,
// Quarter, Minggu or NamaAlias, and it carries a Satuan the NetSuite grid does not.
const AST_INV_COLS = [
    { id: 'tahun', label: 'Tahun', int: true },
    { id: 'bulan', label: 'Bulan', int: true },
    { id: 'branch', label: 'Branch' },
    { id: 'division', label: 'Division' },
    { id: 'industry', label: 'Industry' },
    { id: 'application', label: 'Application' },
    { id: 'company', label: 'Company' },
    { id: 'sales', label: 'SalesCode' },
    { id: 'salesTrans', label: 'SalesTransCode' },
    { id: 'principal', label: 'PrincipalName' },
    { id: 'namaBarang', label: 'NamaBarang' },
    { id: 'satuan', label: 'Satuan' },
    { id: 'sumQt', label: 'SumQt', num: true },
    { id: 'price', label: 'Price', num: true },
    { id: 'subtotal', label: 'Subtotal', num: true },
];

const AST_INV_PIVOT = {
    fields: [
        { id: 'tahun', label: 'Tahun' }, { id: 'bulan', label: 'Bulan' }, { id: 'branch', label: 'Branch' },
        { id: 'division', label: 'Division' }, { id: 'industry', label: 'Industry' }, { id: 'application', label: 'Application' },
        { id: 'company', label: 'Company' }, { id: 'sales', label: 'SalesCode' }, { id: 'salesTrans', label: 'SalesTransCode' },
        { id: 'principal', label: 'PrincipalName' }, { id: 'namaBarang', label: 'NamaBarang' }, { id: 'satuan', label: 'Satuan' },
    ],
    measures: [
        { id: 'subtotal', label: 'Subtotal' }, { id: 'sumQt', label: 'SumQt' }, { id: 'price', label: 'Price' },
    ],
    initial: { rows: ['division'], cols: ['tahun'], measure: 'subtotal', agg: 'sum', renderer: 'heatmap' },
};

// Both AR reports render the SAME 15 columns — that is legacy, not a shortcut. They differ only
// in column 4's heading (NetSuite prints the rep CODE, AST the rep NAME) and, invisibly, in
// which invoice population they cover: AST is `fak_balance <> 0` and keeps credit balances,
// while customsearch163's own filter is not readable from outside NetSuite. Do not merge them.
const arCols = (salesHeader) => [
    { id: 'branch', label: 'Branch' },
    { id: 'custCode', label: 'Cust.Code' },
    { id: 'customerName', label: 'CustomerName' },
    { id: 'salesCode', label: salesHeader },
    { id: 'crLimit', label: 'Cr.Limit', num: true },
    { id: 'top', label: 'TOP' },
    { id: 'noInvoice', label: 'NoInvoice' },
    { id: 'invoiceDate', label: 'InvoiceDate', date: true },
    { id: 'monthDue', label: 'MonthDue' },
    { id: 'yearDue', label: 'YearDue', int: true },
    // `Overdue` is AGING DAYS. On the NetSuite side it comes from a field NetSuite calls
    // `formulacurrency`, which is not currency at all.
    { id: 'overdue', label: 'Overdue', int: true },
    { id: 'jatuhTempo', label: 'JatuhTempo' },
    { id: 'amount', label: 'Amount', num: true },
    { id: 'paid', label: 'Paid', num: true },
    { id: 'balance', label: 'Balance', num: true },
];

const AR_PIVOT = {
    fields: [
        { id: 'yearDue', label: 'YearDue' }, { id: 'monthDue', label: 'MonthDue' },
        { id: 'jatuhTempo', label: 'JatuhTempo' }, { id: 'customerName', label: 'CustomerName' },
        { id: 'branch', label: 'Branch' }, { id: 'salesCode', label: 'Sales' }, { id: 'top', label: 'TOP' },
        // Legacy's pivot offers every grid column; these three complete the set so that e.g.
        // "count of invoices per aging bucket" is expressible.
        { id: 'custCode', label: 'Cust.Code' }, { id: 'noInvoice', label: 'NoInvoice' },
        { id: 'invoiceDate', label: 'InvoiceDate' },
    ],
    measures: [
        { id: 'balance', label: 'Balance' }, { id: 'amount', label: 'Amount' },
        { id: 'paid', label: 'Paid' }, { id: 'crLimit', label: 'Cr.Limit' }, { id: 'overdue', label: 'Overdue' },
    ],
    initial: { rows: ['customerName'], cols: ['jatuhTempo'], measure: 'balance', agg: 'sum', renderer: 'heatmap' },
};

const BOOKING_COLS = [
    { id: 'principal', label: 'Principal' },
    { id: 'namaBarang', label: 'NamaBarang' },
    { id: 'orderType', label: 'OrderType' },
    { id: 'soId', label: 'SOID' },
    { id: 'soDate', label: 'SODate', date: true },
    { id: 'status', label: 'Status' },
    { id: 'customerName', label: 'CustomerName' },
    { id: 'sales', label: 'Sales' },
    { id: 'quantity', label: 'Quantity', num: true },
];

const BOOKING_PIVOT = {
    fields: [
        { id: 'tahun', label: 'Tahun' }, { id: 'bulan', label: 'Bulan' }, { id: 'orderType', label: 'OrderType' },
        { id: 'customerName', label: 'CustomerName' }, { id: 'sales', label: 'Sales' },
        { id: 'principal', label: 'Principal' }, { id: 'namaBarang', label: 'NamaBarang' }, { id: 'status', label: 'Status' },
        { id: 'soId', label: 'SOID' }, { id: 'soDate', label: 'SODate' },
    ],
    measures: [{ id: 'quantity', label: 'Quantity' }],
    // Legacy ships TWO booking defaults, one per HTML sub-family: the per-sales and SM screens
    // open on Principal × OrderType, while `all` and head-dept open on CustomerName × Principal
    // (which is also why only those two render a live CustomerName column). Reproduced rather
    // than flattened — the default layout is the first thing each audience sees.
    initial: { rows: ['principal'], cols: ['orderType'], measure: 'quantity', agg: 'sum', renderer: 'heatmap' },
    initialByScope: {
        all: { rows: ['customerName'], cols: ['principal'], measure: 'quantity', agg: 'sum', renderer: 'heatmap' },
        headDept: { rows: ['customerName'], cols: ['principal'], measure: 'quantity', agg: 'sum', renderer: 'heatmap' },
    },
};

// `controls` lists the filters whose service ACTUALLY CONSUMES them. A control the backend
// ignores is worse than a missing one: the user narrows the window, presses Search, and the grid
// comes back identical, so they conclude the report is broken (or, worse, that the unchanged
// figures are the filtered ones). The AR reports read only Sales — `getar` and `vf03_001` are
// outstanding-balance views with no period at all, aged against "now" on the server — and Booking
// reads only Sales plus the date window this port added.
//
// Keep this in step with each service's filter set; the structural test in
// tests/Feature/CompanyIntegrationReportTest.php pins it.
// Performance Budget & Target — plan vs actual. HIDDEN: no option-group entry renders for it
// (GH #380), but the vocabulary is complete so unhiding is a one-line change in List.jsx.
//
// Legacy emits Actual/Budget×100 per quarter in SQL. Those percentages are NOT carried here: a
// ratio of sums cannot be re-aggregated, so a pivot summing per-row percentages would be wrong at
// every grouping. The components are supplied instead and the reader divides real totals.
const BNT_COLS = [
    { id: 'tahun', label: 'Tahun', int: true },
    { id: 'quarter', label: 'Quarter' },
    { id: 'division', label: 'Division' },
    { id: 'company', label: 'Company' },
    { id: 'sales', label: 'Sales' },
    { id: 'principal', label: 'Principal' },
    { id: 'namaBarang', label: 'NamaBarang' },
    { id: 'actualQty', label: 'Actual Qt', num: true },
    { id: 'qtyBudget', label: 'Budget Qt', num: true },
    { id: 'qtyTarget', label: 'Target Qt', num: true },
    { id: 'actualValue', label: 'Actual USD', num: true },
    { id: 'valueBudget', label: 'Budget USD', num: true },
    { id: 'valueTarget', label: 'Target USD', num: true },
];

const BNT_PIVOT = {
    fields: [
        { id: 'tahun', label: 'Tahun' }, { id: 'quarter', label: 'Quarter' },
        { id: 'division', label: 'Division' }, { id: 'company', label: 'Company' },
        { id: 'sales', label: 'Sales' }, { id: 'principal', label: 'Principal' },
        { id: 'namaBarang', label: 'NamaBarang' },
    ],
    measures: [
        { id: 'actualValue', label: 'Actual USD' }, { id: 'valueBudget', label: 'Budget USD' },
        { id: 'valueTarget', label: 'Target USD' }, { id: 'actualQty', label: 'Actual Qt' },
        { id: 'qtyBudget', label: 'Budget Qt' }, { id: 'qtyTarget', label: 'Target Qt' },
    ],
    // Legacy ships six hardcoded grouping modes; every one is reachable by dragging a field here.
    initial: { rows: ['division'], cols: ['tahun'], measure: 'actualValue', agg: 'sum', renderer: 'heatmap' },
};

const REPORT_CONFIG = {
    'ns-inv-analysis': {
        cols: NS_INV_COLS, pivot: NS_INV_PIVOT,
        controls: ['dates', 'divisions', 'sales', 'principal'],
    },
    'ast-inv-analysis': {
        cols: AST_INV_COLS, pivot: AST_INV_PIVOT,
        controls: ['dates', 'divisions', 'sales', 'salesTrans', 'principal'],
    },
    'ns-ar': { cols: arCols('SalesCode'), pivot: AR_PIVOT, controls: ['sales'] },
    'ast-ar': { cols: arCols('SalesName'), pivot: AR_PIVOT, controls: ['sales'] },
    'ns-booking': { cols: BOOKING_COLS, pivot: BOOKING_PIVOT, controls: ['dates', 'sales'] },
    // Only `dates` — and only its END year, which picks the 3-year window (Y-2 … Y) the way
    // legacy derived it from the chosen periode. Division/Sales/Principal are pivot DIMENSIONS
    // here rather than query filters, because the report already returns the finest grain.
    'bnt-performance': { cols: BNT_COLS, pivot: BNT_PIVOT, controls: ['dates'] },
};

// Scope => the route serving it. The scope is a route segment, so it cannot be spoofed through
// the query string; each route is gated on that scope's own /companies/* grant server-side.
const SCOPE_ROUTES = {
    mine: 'companies.reports',
    all: 'companies.all.reports',
    sm: 'companies.sm.reports',
    headDept: 'companies.head-dept.reports',
};

// ── Formatting ──────────────────────────────────────────────────────────────────────────

const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const fmtDate = (v) => {
    if (!v) return '—';
    const [y, m, d] = String(v).split('-').map(Number);
    if (!y || !m || !d) return '—';
    return `${String(d).padStart(2, '0')}-${MONTHS[m - 1]}-${y}`;
};
const fmtNum = (v) => Number(v || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });

// The Details table is a LIST table, not a detail table (user decision 2026-08-24): 400
// rows, a paginator and a rows-per-page control are list grammar, so it wears the list
// treatment — grey header band, 16px rows, 14px cell padding, horizontal rules only.
// Copied from the companies list table in Pages/MenuCompanies/Companies/List.jsx so the
// two read as one table language on the same page; keep them in step.
//
// It replaced a "Design-3" detail grid (white header, full gridlines, py-2.5 rows). Two
// things that grid got wrong regardless of tier: py-2.5 was a fourth density tier, which
// .claude/rules/ui-conventions.md does not allow, and `text-foreground` (#4b5563, the
// body-gray) is not the ink for content on a card — `card-foreground` (#111) is, which is
// what the list table beside it already used.
//
// Square band ends, NOT the list page's rounded-l-full/rounded-r-full: this table bleeds
// to the card edge (-mx-5), so a pill-shaped band would run its rounded ends into the card
// border. The list page insets its table, which is what gives its band room to round off.
const TABLE =
    'w-max min-w-full border-separate border-spacing-0 text-card-foreground ' +
    '[&_thead_th]:whitespace-nowrap [&_thead_th]:bg-[color-mix(in_srgb,var(--color-secondary)_50%,var(--color-card))] ' +
    '[&_thead_th]:border-b [&_thead_th]:border-border [&_thead_th]:px-3.5 [&_thead_th]:py-3 [&_thead_th]:text-left ' +
    '[&_thead_th]:text-[11px] [&_thead_th]:font-semibold [&_thead_th]:uppercase [&_thead_th]:tracking-wide ' +
    '[&_thead_th]:text-muted-foreground [&_th.num]:!text-right ' +
    '[&_th:first-child]:pl-7 [&_td:first-child]:pl-7 [&_th:last-child]:pr-5 [&_td:last-child]:pr-5 ' +
    '[&_tbody_td]:whitespace-nowrap [&_tbody_td]:border-b [&_tbody_td]:border-border/60 [&_tbody_td]:px-3.5 ' +
    // leading-4 (16px), not the inherited 1.5/18px: the list table gets 16px from `text-xs`,
    // whose font-size it then overrides — `text-[12px]` alone carries no line-height.
    '[&_tbody_td]:py-[16px] [&_tbody_td]:text-[12px] [&_tbody_td]:leading-4 [&_tbody_td]:font-medium [&_tbody_td]:text-card-foreground ' +
    '[&_tbody_td.num]:text-right [&_tbody_td.num]:tabular-nums [&_tbody_tr:hover_td]:bg-secondary/60';

const CAPTION = 'm-0 text-[11px] font-bold uppercase tracking-wide text-muted-foreground';

export function IntegrationReportPanel({ report, title, integration, scope = 'all' }) {
    const { show: showToast } = useToast();
    const config = REPORT_CONFIG[report] ?? REPORT_CONFIG['ns-inv-analysis'];
    const routeName = SCOPE_ROUTES[scope] ?? SCOPE_ROUTES.all;
    const http = useHttp({});

    const thisYear = new Date().getFullYear();
    const [early, setEarly] = useState(`${thisYear}-01-01`);
    const [end, setEnd] = useState(new Date().toISOString().slice(0, 10));
    const [divisions, setDivisions] = useState([]);
    const [sales, setSales] = useState([]);
    const [salesTrans, setSalesTrans] = useState([]);
    const [principal, setPrincipal] = useState('');

    const [options, setOptions] = useState(null);
    const [facts, setFacts] = useState(null);
    // Set only when the ENGINE could not answer (AST unreachable, driver missing). An empty
    // `facts` with no notice means the query ran and matched nothing — a different fact, and
    // conflating the two tells a credit approver the opposite of the truth.
    const [notice, setNotice] = useState(null);
    const [loading, setLoading] = useState(false);

    // Guards a late response from a report the user already switched away from.
    const reportRef = useRef(report);
    reportRef.current = report;

    const has = (control) => config.controls.includes(control);

    // Only send what this report reads. Sending an ignored filter is harmless server-side but it
    // puts a value in the URL that never influenced the result, which is exactly how a screenshot
    // ends up "proving" a filter that was never applied.
    const query = (extra) => {
        const qs = new URLSearchParams();
        if (has('dates')) {
            qs.set('early', early);
            qs.set('end', end);
        }
        if (has('divisions')) divisions.forEach((d) => qs.append('divisions[]', d));
        if (has('sales')) sales.forEach((s) => qs.append('sales[]', s));
        if (has('salesTrans')) salesTrans.forEach((s) => qs.append('salesTrans[]', s));
        if (has('principal') && principal) qs.set('principal', principal);
        Object.entries(extra).forEach(([k, v]) => qs.set(k, v));
        return `${route(routeName, report)}?${qs}`;
    };

    // Options load once per report open; switching reports resets the result. The option
    // DOMAINS differ per engine (NetSuite internal ids vs AST char codes), so a stale list
    // from the previous report must never be reused — hence the reset.
    useEffect(() => {
        http.cancel();
        setFacts(null);
        setNotice(null);
        setOptions(null);
        setDivisions([]);
        setSales([]);
        setSalesTrans([]);
        setPrincipal('');
        // The detail-table search belongs to the payload being replaced, not to the panel.
        setTableSearch('');
        http.get(query({ withOptions: 1 }), {
            onSuccess: (data) => { if (reportRef.current === report) setOptions(data?.options ?? { divisions: [], sales: [], principals: [] }); },
        });
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [report, scope]);

    const generate = () => {
        setLoading(true);
        http.get(query({ generate: 1 }), {
            onSuccess: (data) => {
                if (reportRef.current !== report) return;
                setFacts(Array.isArray(data?.facts) ? data.facts : []);
                setNotice(data?.notice ?? null);
                setLoading(false);
            },
            onError: () => { setLoading(false); showToast('Could not load the report.', 'error'); },
        });
    };

    // Detail-table client search/sort/pagination over the generated payload (the whole set is
    // already on the client for the pivot — same precedent as the BnT board).
    //
    // Legacy got all three free from DataTables (`$('#input').DataTable(...)` in
    // listnssjanalysisall.php:296, …headdept.php:260, listnsarpersales.php:135,
    // listnsbookingsales.php:124). There is no jQuery in this stack, so search is a memo and
    // sort is the house hook — see the block below `cell()`, which is where it has to live
    // because the search haystack is built from RENDERED text, exactly as DataTables searched.
    const [page, setPage] = useState(1);
    const [pageSize, setPageSize] = useState(10);
    const [tableSearch, setTableSearch] = useState('');

    // Memoised, not `facts ?? []`: a bare literal is a NEW array every render, so `pivotRows`,
    // `haystacks` and PivotBoard's whole `rows` prop would recompute on every keystroke in the
    // detail-table search box.
    const rows = useMemo(() => facts ?? [], [facts]);

    // ⚠️ THE DETAIL TABLE SHOWS THE WHOLE PAYLOAD, ALWAYS. It used to follow the pivot's Filters
    // zone (PivotBoard reported the surviving rows through `onFilteredRowsChange`, and this held
    // them in state). The user removed that on 2026-08-27: the detail list is what you scroll to
    // BECAUSE the pivot above it is narrowed, so narrowing both left nothing on screen showing
    // the whole. The Search box below is the only thing that narrows this table.

    // ⚠️ PivotBoard uses '' as its OWN sentinel for "this zone has no dimension", and labels that
    // key `Totals` (its keyLabel). Its `?? '—'` guard only catches null/undefined, so a genuinely
    // BLANK value — an invoice with no due date, a line with no division — collides with the
    // sentinel and renders as a group header literally reading "Totals", sitting next to the real
    // Totals column. The figures stay right; the header lies about what it is.
    //
    // Blanks are unavoidable upstream: every NetSuite field is cast `(string) ($r->x ?? '')`, and
    // AstArReport deliberately returns '' for a NULL due date rather than legacy's bogus "0". So
    // substitute the em-dash the component already uses for a missing value, and only on the ids
    // that can actually reach a pivot zone.
    const pivotRows = useMemo(() => {
        const dims = config.pivot.fields.map((f) => f.id);

        return rows.map((r) => {
            let patched = null;
            for (const d of dims) {
                if (r[d] === '' || r[d] === null || r[d] === undefined) {
                    patched ??= { ...r };
                    patched[d] = '—';
                }
            }

            return patched ?? r;
        });
    }, [rows, config]);

    // Legacy ships a different opening layout per booking audience; see BOOKING_PIVOT.
    const initial = config.pivot.initialByScope?.[scope] ?? config.pivot.initial;

    const cell = (r, col) => {
        if (col.date) return fmtDate(r[col.id]);
        if (col.num) return fmtNum(r[col.id]);
        return r[col.id] ?? '—';
    };

    // One getter per column, derived from the SAME `config.cols` the table renders, so a column
    // added to a report becomes sortable the moment it appears — no second list to keep in step.
    // Values go in RAW on purpose: useClientSort has a numeric fast-path, and this port emits real
    // numbers where legacy pushed number_format() strings, so TotalAmount sorts as a quantity
    // rather than as text. Dates are `Y-m-d`, which already orders chronologically as a string.
    const sortGetters = useMemo(
        () => Object.fromEntries(config.cols.map((c) => [c.id, (r) => r[c.id]])),
        [config],
    );

    // Rendered text per row, built once per payload rather than once per keystroke. Searching the
    // RENDERED value is what DataTables did and is the only honest option here: the reader sees
    // `14-Jul-2026` and `19,375,000.00`, not `2026-07-14` and `19375000`.
    const haystacks = useMemo(
        () => rows.map((r) => config.cols.map((c) => cell(r, c)).join(' ').toLowerCase()),
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [rows, config],
    );

    const filtered = useMemo(() => {
        const term = tableSearch.trim().toLowerCase();
        if (term === '') return rows;
        // `?? ''` is insurance, not indexing paranoia: an exception thrown here would blank the
        // whole company list page, and the cost of the guard is nothing.
        return rows.filter((_, i) => (haystacks[i] ?? '').includes(term));
    }, [rows, haystacks, tableSearch]);

    // Sorts the WHOLE result set, then paginates — not the ten rows that happen to be on screen.
    // That ordering is the whole point of .claude/rules/list-pagination.md's carve-out for lists
    // held fully in memory; slicing first would answer "largest TotalAmount" with the largest of
    // page 1. Legal here because this panel has no server paginator: one JSON fetch, no LIMIT.
    const { sorted, sortKey, sortDir, toggleSort } = useClientSort(filtered, sortGetters);

    // Searching or re-sorting changes what "page 7" contains, so keeping the old page number
    // would show an empty table under a live paginator.
    useEffect(() => { setPage(1); }, [rows, tableSearch, sortKey, sortDir]);

    const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
    // Clamped rather than trusted: the reset above lands one render later, and for that render
    // `page` can still point past the end of a freshly narrowed set.
    const currentPage = Math.min(page, totalPages);
    const pageRows = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);

    // Filters live in PivotBoard's right column (`toolbar` slot) — the reference layout.
    // ⚠️ This one row used to mix THREE control families, each keeping its own defaults:
    // FloatingField dates at h-36/r-8, FilterPill at h-32/pill, SearchableSelect at h-32/r-6,
    // and the Search button at h-36/r-8 — two heights and three corner radii side by side.
    // Toolbars in this app are h-8 pills (.claude/rules/{design-system,ui-conventions}.md), and
    // two of the six controls already were, so the rest are pulled onto that shape here rather
    // than by editing the shared components (which serve other pages at their own sizes).
    const TOOLBAR_PILL = '[&_input]:!h-8 [&_input]:!rounded-full [&_button]:!h-8 [&_button]:!rounded-full';

    const toolbar = (
        <div className="flex flex-col gap-3">
            {/* Only the controls this report's service reads — see REPORT_CONFIG.controls. Both
                AR reports therefore show Sales alone: `getar` and `vf03_001` are outstanding-balance
                views aged against "now", with no period column to filter on. */}
            <div className="flex flex-wrap items-center gap-2.5">
                {has('dates') && (
                    <>
                        <FloatingField size="sm" type="date" label="Early Date" value={early} onChange={(e) => setEarly(e.target.value)} className={`w-[150px] ${TOOLBAR_PILL}`} />
                        <FloatingField size="sm" type="date" label="End Date" value={end} onChange={(e) => setEnd(e.target.value)} className={`w-[150px] ${TOOLBAR_PILL}`} />
                    </>
                )}
                {/* Options arrive as {id, name}: the id is the value the ENGINE filters on (a
                    NetSuite internal id or an AST char code) and the name is only a label. They
                    used to be bare strings mapped to {id: s, name: s}, which worked only because
                    the source was a local table of display names that no engine filters on. */}
                {has('divisions') && (
                    <FilterPill label="Division" value={divisions} options={options?.divisions ?? []} onChange={setDivisions} />
                )}
                {has('sales') && (
                    <FilterPill label="Sales" value={sales} options={options?.sales ?? []} onChange={setSales} />
                )}
                {/* AST inv-analysis only: legacy's separate SalesTransaction multiselect, which
                    filters the rep who WROTE the order rather than the one who owns the account. */}
                {has('salesTrans') && (
                    <FilterPill label="Sales Trans" value={salesTrans} options={options?.sales ?? []} onChange={setSalesTrans} />
                )}
                {has('principal') && (
                    <div className={`w-[220px] ${TOOLBAR_PILL}`}>
                        <SearchableSelect size="sm" label="Principal" placeholder="Select Principal" options={options?.principals ?? []} value={principal} onChange={setPrincipal} />
                    </div>
                )}
                <button
                    type="button"
                    onClick={generate}
                    disabled={loading || !options}
                    className="inline-flex h-8 items-center justify-center gap-1.5 rounded-full bg-linear-to-br from-violet-500 to-primary px-4 text-xs font-bold text-white shadow-sm transition-[filter] hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-40"
                >
                    <Search className="size-3.5" /> {loading ? 'Generating…' : 'Search'}
                </button>
            </div>
        </div>
    );

    return (
        <div className="flex flex-col gap-4 border-t border-border/60 pt-4">
            <span className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{title} · {integration}</span>

            {/* "The source could not answer" — NOT "there is nothing to show". Rendered above a
                populated table too, on purpose: a notice shown only in the empty branch is
                invisible in exactly the case where the reader most needs to distrust the rows. */}
            {notice && (
                <p role="status" className="m-0 rounded-lg border border-warning-border bg-warning-bg px-4 py-3 text-[12.5px] text-warning-text">
                    {notice}
                </p>
            )}

            <PivotBoard
                rows={pivotRows}
                fields={config.pivot.fields}
                measures={config.pivot.measures}
                initial={initial}
                toolbar={toolbar}
                placeholder={facts === null ? (
                    <p className="m-0 rounded-lg border border-dashed border-border px-4 py-10 text-center text-[12.5px] text-muted-foreground">
                        No report generated yet — set the filters above, then click Search.
                    </p>
                ) : null}
            />

            {facts && !loading && (
                <>
                    <div>
                        <div className="mb-2.5 flex flex-wrap items-center gap-3">
                            <p className={CAPTION}>Details</p>
                            {/* Client-side, and deliberately NOT in the query toolbar above: it
                                narrows THIS table only, never the pivot and never the request —
                                the mirror of the pivot's Filters zone, which narrows the pivot
                                and never this table. No row count beside it — ListFooter already
                                carries one, and .claude/rules/ui-conventions.md forbids the
                                second. */}
                            <label className="ml-auto flex h-8 w-[min(240px,100%)] items-center gap-2 rounded-full border border-transparent bg-muted/60 px-3.5 text-muted-foreground transition-colors hover:bg-muted focus-within:border-primary/40 focus-within:bg-card">
                                <Search aria-hidden="true" className="size-3.5 shrink-0" strokeWidth={2} />
                                <input
                                    type="search"
                                    placeholder="Search these rows…"
                                    aria-label="Search the details table"
                                    value={tableSearch}
                                    onChange={(e) => setTableSearch(e.target.value)}
                                    className="h-full min-w-0 flex-1 border-none bg-transparent p-0 text-[12.5px] font-medium text-foreground outline-none placeholder:text-muted-foreground/70"
                                />
                            </label>
                        </div>
                        <div className="-mx-5 overflow-x-auto">
                            <table className={TABLE}>
                                <thead>
                                    <tr>
                                        {/* No is a row counter, not data — per ui-conventions the
                                            sequence column gets no sort affordance. */}
                                        <th className="num">No</th>
                                        {config.cols.map((c) => (
                                            <th key={c.id} className={c.num || c.int ? 'num' : undefined}>
                                                <SortButton id={c.id} label={c.label} sortKey={sortKey} sortDir={sortDir} onToggle={toggleSort} />
                                            </th>
                                        ))}
                                    </tr>
                                </thead>
                                <tbody>
                                    {pageRows.length === 0 ? (
                                        <tr>
                                            <td colSpan={config.cols.length + 1} className="!bg-transparent px-4 py-10 !text-center text-[13px] text-muted-foreground">
                                                {/* Two different facts: the SEARCH matched nothing
                                                    (rows exist), vs the report itself came back
                                                    empty. Collapsing them would hide the search. */}
                                                {tableSearch.trim() !== '' && rows.length > 0
                                                    ? 'No rows match your search'
                                                    : 'No rows matched these filters'}
                                            </td>
                                        </tr>
                                    ) : pageRows.map((r, i) => (
                                        <tr key={i}>
                                            <td className="num text-muted-foreground">{(currentPage - 1) * pageSize + i + 1}</td>
                                            {config.cols.map((c) => (
                                                <td key={c.id} className={c.num || c.int ? 'num' : undefined}>{cell(r, c)}</td>
                                            ))}
                                        </tr>
                                    ))}
                                </tbody>
                            </table>
                        </div>
                        <ListFooter
                            page={currentPage}
                            totalPages={totalPages}
                            onPage={setPage}
                            pageSize={pageSize}
                            onPageSize={(n) => { setPageSize(n); setPage(1); }}
                            // The SEARCHED set, not the payload: a footer counting rows the table
                            // is no longer showing turns "Showing 1 to 10 of 412" into a lie.
                            total={sorted.length}
                            itemLabel="entries"
                        />
                    </div>
                </>
            )}
        </div>
    );
}
