// Represents: admin.reports.summary.index · ReportsController — see docs/ISYSTEM_REFERENCE.md §Batch 2 "Summary"
/* ====================================================================
   Summary report — per-provider GGR breakdown over `business_report`.

   Real surface: GET /reports/summary/ (ReportsController::summary :5072)
   + GET /reports/summary/getSummaryReport (::getSummaryReport :6026 —
   data / HTML partial / XLSX export / $getOnlyData JSON reused by
   KraController L223 for KRA tax reporting). No FormRequest — inline
   coercions only. Rendered here on the shared Hrs* report shell.

   Faithfulness notes (docs/ISYSTEM_REFERENCE.md §Batch 2 "Summary"):
   - Every summed value = SUM(business_report.<col> × skins.reports_multiplier),
     grouped per provider (+ per currency when Cumulable is off), with a
     per-DATE pre-grouping so FX conversion happens at each day's rate.
   - Fixed row order, no sorting, no pagination, no row actions:
     `CASE WHEN provider_id = 69 THEN '-1' ELSE providers.name END ASC,
     profit DESC` — the sport provider (id 69) is pinned first.
   - No auto-load: fillReport() on page load is commented out (index
     L1070); only Search and the Cumulable toggle run the report.
   - "Include Bonus Bet" is never sent to the backend — it only flips the
     bonus_bet / bonus_win column-visibility checkboxes and saves them
     (index JS L1045-1059).
   - Column visibility persists under localStorage
     ['summary_report_table_settings']; first visit hides col_bonus_bet +
     col_bonus_win (table_settings.blade.php:189). The export honors the
     same hidden columns.
   - bet_closed / bonus_bet_closed / profit_closed are computed in the
     SQL but hidden everywhere (commented thead/tbody/settings toggles,
     force-stripped from the export at L7100-7114) — not rendered here.
     The real JS all_columns list even carries the typo 'col_pŕofit_closed'
     (accented ŕ, index L938), harmless only because the column is dead.
   - The page also @includes the admin.reports.modals.newMessage modal
     (index L916) but exposes no button that opens it — vestigial; not built.
   - A @csrf token and hidden export=1 sit inside a GET form on the real
     page; the AJAX path simply omits both. Nothing to build.

   Known-bug divergences (implemented as evident intent, per build policy):
   - The per-user check inside the filter_user_ids loop authorizes against
     support_report_daily_report — a copy-paste from the daily report
     (ReportsController L6138). The prototype treats the whole screen as
     gated by support_report_summary, the evident intent.
     <!-- SUGGESTION: authorize support_report_summary (not
          support_report_daily_report) inside the filter_user_ids loop. -->
   - Reset on the real form writes 01/m/Y into the END datepicker too
     (index L1021) — likely a bug; the prototype resets end back to today.
     <!-- SUGGESTION: fix the Reset handler to set the end datepicker to
          today instead of the first of the month. -->
   - period == "periodo_mese" with an empty month die()s with
     "seleziona le date!" (L6110); the prototype surfaces a validation
     toast instead (our month select always carries a value anyway).
     <!-- SUGGESTION: replace the raw die("seleziona le date!") with a
          localized validation error response. -->
   ==================================================================== */

const { useState: hrsuUseState, useMemo: hrsuUseMemo } = React;

/* ---------- deterministic PRNG (FNV-1a hash + mulberry32) ---------- */
const hrsuHash = (s) => { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
const hrsuRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
/* ---------- date helpers (local dates, ISO day numbers) ---------- */
const hrsuIso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const hrsuDayNum = (iso) => { const t = Date.parse(`${String(iso).slice(0, 10)}T00:00:00Z`); return isNaN(t) ? null : Math.round(t / 86400000); };
/* The inverse, so the period the filters resolved to can be sent to the server
   as dates rather than re-derived there. */
const hrsuIsoFromNum = (n) => new Date(n * 86400000).toISOString().slice(0, 10);
const HRSU_TODAY = (() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; })();
const HRSU_TODAY_ISO = hrsuIso(HRSU_TODAY);
const HRSU_TODAY_NUM = hrsuDayNum(HRSU_TODAY_ISO);
const HRSU_MONTH_START_ISO = hrsuIso(new Date(HRSU_TODAY.getFullYear(), HRSU_TODAY.getMonth(), 1));

/* ---------- FX — per-day EUR-based rates (`currencies`.rate/date_currency) ----------
   Conversion mirrors ReportsController L6251 / L6175-6178: each pre-grouped
   day converts as (value / day-rate) × eur_to_target_rate, where day-rate is
   the row currency's rate ON THAT DAY and eur_to_target_rate is the LATEST
   rate of the selected currency. fxRateFor() (L7915) falls back to the
   nearest earlier date, then 1.0 with a `reports`-channel warning
   (business_report.fx.fallback_one) — the mock always has a rate, so only
   the unknown-currency branch below mirrors the 1.0 fallback. */
/* WAS a five-currency base table with a SINE WAVE on top — "ARS drifts hardest"
   — so every historical figure converted at an invented rate that moved
   plausibly over time. `window.FX_RATES` holds the real ones, filled by app.jsx
   from `currency_latest_rate`, and it is EUR-per-unit; this function returns
   units-per-EUR because that is what the conversion below divides by. The
   inversion is the defect tools/fxcheck.js exists to catch, and it has shipped
   backwards on two report screens already.

   ONE RATE PER CURRENCY, NOT ONE PER DAY. `currency_latest_rate` is the latest
   rate, so `dayNum` is accepted and ignored — a historical period converts at
   today's rate. Upstream reads a per-day rate table. The argument stays in the
   signature so the call sites keep saying which day they meant, and the
   divergence is stated rather than hidden behind a plausible curve.
   <!-- SUGGESTION: to convert a historical period at the rate that applied then, currency_rates needs to be read by date rather than collapsed to currency_latest_rate. Every multi-currency report on this platform converts at today's rate until it is. --> */
const hrsuRate = (cur, _dayNum) => {
  if (cur === "EUR") return 1;
  const eurPerUnit = window.FX_RATES && window.FX_RATES[cur];
  /* No rate loaded, or a currency the table does not carry — fxRateFor()'s own
     final fallback is 1.0, logged. Same here: the figure is left unconverted
     rather than dropped, and it is the one case a reader can spot, because an
     ARS total that equals its EUR total is obviously not converted. */
  if (!eurPerUnit) return 1;
  return 1 / eurPerUnit;
};

/* ---------- enums straight from the controller ---------- */
/* Category ids → labels: ReportsController L5078-5085 */
const HRSU_CATS = [
  { value: "1", label: "Casino" }, { value: "2", label: "Casino Live" }, { value: "4", label: "Virtual" },
  { value: "5", label: "Poker" }, { value: "6", label: "Sport" }, { value: "8", label: "Jackpot" },
];
/* range_val 1-6 → windows: ReportsController L6048-6080 ($range='' so "Today" shows first) */
const HRSU_RANGES = [
  { value: "1", label: "Today" }, { value: "2", label: "Yesterday" }, { value: "3", label: "This week" },
  { value: "4", label: "Previous week" }, { value: "5", label: "This month" }, { value: "6", label: "Previous month" },
];
/* user_type options: index L156-163 — levels above the viewer and
   SUPERADMIN/CUSTOMER_CARE/ADMINISTRATION/AFFILIATE/PLAYER excluded */
const HRSU_USER_TYPES = [
  { value: "2", label: "Skin Access" }, { value: "8", label: "Agent" }, { value: "10", label: "Promoter" },
  { value: "15", label: "Shop" }, { value: "20", label: "Cashier" },
];
const HRSU_LEVELS = { 2: "Skin Access", 8: "Agent", 10: "Promoter", 15: "Shop", 20: "Cashier" };

/* THE PROVIDER CATALOGUE, THE NETWORK AND EVERY FIGURE THEY PRODUCED ARE GONE.
   `HRSU_PROVIDERS` listed thirty-two providers with an invented popularity,
   volume and house edge each; `HRSU_NODES` was twenty-two invented outlets with
   invented currencies and weights; and `hrsuCell` turned the three into a bet, a
   win, a 2% sport bet-tax, a 1.5% sport win-tax and a bonus pair for every
   (outlet, provider, day) in the period.

   `report_user_provider_daily` (022) is the same aggregate keyed the same way —
   one row per (user, provider, day, funding, currency) over `ledger_entries` —
   and `providers` supplies the catalogue with its real category ids.

   THE TWO TAX COLUMNS HAVE NO SOURCE. Nothing in this schema records a bet tax
   or a win tax; upstream they are sport turnover/win taxes carried on the
   coupon. `sport_coupons` does hold `stake_tax` and `payout_tax`, but it
   references an INTEGRATION, not a provider — so they cannot be attributed to
   the provider rows this report is keyed by. Both render "—" rather than 0.00,
   which would claim no tax was charged.
   <!-- SUGGESTION: to report sport bet/win tax per provider, sport_coupons needs a provider_id (or a provider↔integration mapping). Today the taxes exist but cannot be attributed to the rows this report groups by. -->

   `skins.reports_multiplier` is also gone. It was an empty map with a comment
   saying every demo skin runs at 1 — a multiplication by a constant nobody
   set. If the column is added it belongs in the view, not here. */

/* ---------- period resolution (radio `period` + per-mode inputs) ---------- */
const hrsuPeriodDays = (f) => {
  let s, e;
  if (f.period === "range") {
    const rv = String(f.range_val || "1"); // $range='' → "Today" preselected (L6048-6080)
    const dow = (HRSU_TODAY.getDay() + 6) % 7; // Monday = 0
    if (rv === "2") { s = e = HRSU_TODAY_NUM - 1; }
    else if (rv === "3") { s = HRSU_TODAY_NUM - dow; e = HRSU_TODAY_NUM; }
    else if (rv === "4") { s = HRSU_TODAY_NUM - dow - 7; e = HRSU_TODAY_NUM - dow - 1; }
    else if (rv === "5") { s = hrsuDayNum(HRSU_MONTH_START_ISO); e = HRSU_TODAY_NUM; }
    else if (rv === "6") {
      s = hrsuDayNum(hrsuIso(new Date(HRSU_TODAY.getFullYear(), HRSU_TODAY.getMonth() - 1, 1)));
      e = hrsuDayNum(hrsuIso(new Date(HRSU_TODAY.getFullYear(), HRSU_TODAY.getMonth(), 0)));
    } else { s = e = HRSU_TODAY_NUM; }
  } else if (f.period === "periodo_mese") {
    const parts = String(f.periodo_mese || "").split("|");
    s = hrsuDayNum(parts[0]); e = hrsuDayNum(parts[1]);
  } else if (f.period === "periodo_anno") {
    const y = Number(f.periodo_anno) || HRSU_TODAY.getFullYear();
    s = hrsuDayNum(`${y}-01-01`); e = hrsuDayNum(`${y}-12-31`);
  } else {
    const v = f.custom || {};
    s = hrsuDayNum(v.from || HRSU_MONTH_START_ISO); e = hrsuDayNum(v.to || HRSU_TODAY_ISO);
  }
  if (s == null) s = HRSU_TODAY_NUM;
  if (e == null) e = HRSU_TODAY_NUM;
  if (e > HRSU_TODAY_NUM) e = HRSU_TODAY_NUM; // business_report has no future rows
  if (s > e) s = e;
  return [s, e];
};

/* ---------- the aggregation (mirrors getSummaryReport's shape) ----------
   Cost note: a 2021→today custom range is ~1800 days × 10 outlets × 32
   providers of deterministic cells — a few hundred ms, run only on Search. */
const hrsuMeasureKeys = ["bet", "bet_tax", "win", "win_tax", "profit", "bonus_bet", "bonus_win"];
const hrsuRunSummary = (f, dataset) => {
  const [startNum, endNum] = hrsuPeriodDays(f);
  const target = f.currency || "EUR"; // backend fallback 'EUR' (L6043)
  const eurToTarget = hrsuRate(target, HRSU_TODAY_NUM); // latest rate of the target currency (L6175-6178)
  const skinSet = f.skin_ids && f.skin_ids.length ? new Set(f.skin_ids) : null; // empty ⇒ all viewer skins
  const provSet = f.provider_ids && f.provider_ids.length ? new Set(f.provider_ids.map(Number)) : null; // empty ⇒ all active
  const catSet = f.category_ids && f.category_ids.length ? new Set(f.category_ids.map(Number)) : null; // empty ⇒ all six
  const minLevel = Number(f.user_type || 2); // backend coerces '' → 2, filters users.user_level >= user_type (L6040, 6130)
  /* THE ROWS COME FROM `report_user_provider_daily`, passed in by the component.
     Filtering happens over what the server returned for the period; the paths,
     brands, providers and categories all narrow a set that is already scoped by
     RLS to the caller's own subtree.

     `user_path` CONTAINMENT USES DOTS. The old node table wrote paths as
     "/1/9/310" and matched with `startsWith(p + "/")`; the real column is an
     ltree — "1.9.310" — and a slash-prefix test against it matches nothing at
     all, which renders as an empty report rather than as a broken filter. */
  const paths = (f.filter_user_ids || [])
    .map(id => { const n = (dataset.users || []).find(x => String(x.id) === String(id)); return n ? n.path : null; })
    .filter(Boolean);
  const inPaths = (rowPath) => paths.length === 0
    || paths.some(p => rowPath === p || String(rowPath).indexOf(p + ".") === 0);

  const zero = () => ({ bet: 0, bet_tax: 0, win: 0, win_tax: 0, profit: 0, bonus_bet: 0, bonus_win: 0 });
  const map = new Map(); // "provider|currency" → { prov, currency, n (native sums), c (per-day-converted sums) }
  (dataset.rows || []).forEach(row => {
    const dayNum = hrsuDayNum(row.day);
    if (dayNum == null || dayNum < startNum || dayNum > endNum) return;
    if (skinSet && !skinSet.has(String(row.skin_id))) return;
    if (provSet && !provSet.has(Number(row.provider_id))) return;
    if (!inPaths(row.user_path)) return;
    /* `user_level >= user_type` — the backend coerces an empty filter to 2 and
       compares with >=, which on this ladder means "this rung or further down".
       The view carries the level so this is the row's own, not a lookup. */
    if (Number(row.user_level) < minLevel) return;
    const prov = { id: Number(row.provider_id), name: row.provider_name || ("Provider " + row.provider_id),
                   cat: dataset.catByProvider[String(row.provider_id)] };
    if (catSet && !catSet.has(Number(prov.cat))) return;

    const cur = row.currency || "";
    const key = `${prov.id}|${cur}`;
    let acc = map.get(key);
    if (!acc) { acc = { prov, currency: cur, n: zero(), c: zero() }; map.set(key, acc); }
    const stake = Number(row.stake) || 0;
    const payout = Number(row.payout) || 0;
    /* `funding` splits real from bonus at the source — report_type_class
        classifies the type ids, so this is not a fraction of the total. */
    const cell = row.funding === "bonus"
      ? { bet: 0, win: 0, profit: 0, bonus_bet: stake, bonus_win: payout, bet_tax: 0, win_tax: 0 }
      : { bet: stake, win: payout, profit: stake - payout, bonus_bet: 0, bonus_win: 0, bet_tax: 0, win_tax: 0 };
    /* PER-DAY CONVERSION, as upstream: value ÷ that day's rate × the target's.
       This build has one rate per currency (`currency_latest_rate`), not a rate
       per day — so `hrsuRate` returns the same figure whatever day it is asked
       for, and a historical period converts at today's rate. Stated rather than
       silently different. */
    const fx = eurToTarget / hrsuRate(cur, dayNum);
    for (const m of hrsuMeasureKeys) { acc.n[m] += cell[m]; acc.c[m] += cell[m] * fx; }
  });

  /* ORDER BY CASE WHEN provider_id = 69 THEN '-1' ELSE providers.name END ASC, profit DESC */
  const ord = (a, b) => {
    const ka = a.pid === 69 ? "" : a.name.toLowerCase();
    const kb = b.pid === 69 ? "" : b.name.toLowerCase();
    return ka < kb ? -1 : ka > kb ? 1 : b.profit - a.profit;
  };

  let rows, totals;
  const grand = zero(); // everything, per-day-converted into the target currency
  map.forEach(a => { for (const m of hrsuMeasureKeys) grand[m] += a.c[m]; });

  if (f.cumulate) {
    /* Cumulable ON: one converted-currency row per provider (report.blade.php:46-62) */
    const byProv = new Map();
    map.forEach(a => {
      let r = byProv.get(a.prov.id);
      if (!r) { r = { pid: a.prov.id, name: a.prov.name, currency: target, ...zero() }; byProv.set(a.prov.id, r); }
      for (const m of hrsuMeasureKeys) r[m] += a.c[m];
    });
    rows = [...byProv.values()].map(r => ({ ...r, converted: r.profit })).sort(ord);
    totals = [{
      _label: `Total Converted (${target})`, _variant: "dark", _chip: true, _profitPos: grand.profit >= 0,
      bet: hrsMoney(grand.bet, target), bet_tax: hrsMoney(grand.bet_tax, target),
      win: hrsMoney(grand.win, target), win_tax: hrsMoney(grand.win_tax, target),
      profit: hrsMoney(grand.profit, target),
      bonus_bet: hrsMoney(grand.bonus_bet, target), bonus_win: hrsMoney(grand.bonus_win, target),
      converted: hrsMoney(grand.profit, target),
    }];
  } else {
    /* Cumulable OFF: per-currency rows per provider; per-currency Totals rows
       (grouped from the date+currency totals, L6373-6407) + Total Converted */
    rows = [...map.values()]
      .map(a => ({ pid: a.prov.id, name: a.prov.name, currency: a.currency, ...a.n, converted: a.c.profit }))
      .sort(ord);
    const perCur = new Map();
    map.forEach(a => {
      let t = perCur.get(a.currency);
      if (!t) { t = { ...zero(), convProfit: 0 }; perCur.set(a.currency, t); }
      for (const m of hrsuMeasureKeys) t[m] += a.n[m];
      t.convProfit += a.c.profit;
    });
    totals = [...perCur.keys()].sort().map(cur => {
      const t = perCur.get(cur);
      return {
        _label: `Totals · ${cur}`, _variant: "dark",
        bet: hrsMoney(t.bet, cur), bet_tax: hrsMoney(t.bet_tax, cur),
        win: hrsMoney(t.win, cur), win_tax: hrsMoney(t.win_tax, cur),
        profit: hrsMoney(t.profit, cur),
        bonus_bet: hrsMoney(t.bonus_bet, cur), bonus_win: hrsMoney(t.bonus_win, cur),
        converted: hrsMoney(t.convProfit, target),
      };
    });
    totals.push({
      _label: `Total Converted (${target})`, _variant: "muted", _chip: true, _profitPos: grand.profit >= 0,
      bet: hrsMoney(grand.bet, target), bet_tax: hrsMoney(grand.bet_tax, target),
      win: hrsMoney(grand.win, target), win_tax: hrsMoney(grand.win_tax, target),
      profit: hrsMoney(grand.profit, target),
      bonus_bet: hrsMoney(grand.bonus_bet, target), bonus_win: hrsMoney(grand.bonus_win, target),
      converted: hrsMoney(grand.profit, target),
    });
  }
  return { rows, totals, target, cumulate: !!f.cumulate };
};

/* ---------- column-visibility settings (real modal: table_settings.blade.php;
   persisted under the SAME localStorage key as the real screen) ---------- */
const HRSU_LS_KEY = "summary_report_table_settings";
const HRSU_TOGGLE_COLS = [
  { id: "col_bet", label: "Bet ( All Valid Bets )" },
  { id: "col_bet_tax", label: "Bet Tax" },
  { id: "col_win", label: "Win ( Bet Closed )" },
  { id: "col_win_tax", label: "Win Tax" },
  { id: "col_profit", label: "Profit" },
  { id: "col_bonus_bet", label: "Bonus bet" },
  { id: "col_bonus_win", label: "Bonus win" },
  { id: "col_converted", label: "Converted" },
];
const hrsuReadCols = () => {
  try {
    const raw = pbStore.get(HRSU_LS_KEY, null);
    if (Array.isArray(raw)) return raw;
  } catch (e) { /* fall through */ }
  return ["col_bonus_bet", "col_bonus_win"]; // first visit hides Bonus bet + Bonus win (table_settings.blade.php:189)
};
const hrsuWriteCols = (hidden) => pbStore.set(HRSU_LS_KEY, hidden);

/* ---------- filter defaults ---------- */
const HRSU_MONTH_DEFAULT = hrsPeriodOptions("calendar", 2021)[0].value; // getDateCalendarioMonth(): Jan 2021 → current, current preselected
const HRSU_YEARS = (() => { const out = []; for (let y = HRSU_TODAY.getFullYear(); y >= 2021; y--) out.push(String(y)); return out; })();
const HRSU_CUSTOM_DEFAULT = { from: HRSU_MONTH_START_ISO, to: HRSU_TODAY_ISO }; // 01/m/Y → today (L5087-5088)
const hrsuDefaults = (hiddenCols) => ({
  skin_ids: [], provider_ids: [], category_ids: [],
  period: "custom_range", range_val: "1",
  periodo_mese: HRSU_MONTH_DEFAULT, periodo_anno: String(HRSU_TODAY.getFullYear()),
  custom: { ...HRSU_CUSTOM_DEFAULT },
  user_type: "", filter_user_ids: [], currency: "EUR", // viewer's currency; backend fallback EUR (L5092-5097, L6043)
  include_bonus: !hiddenCols.includes("col_bonus_bet") && !hiddenCols.includes("col_bonus_win"),
  cumulate: true, // default checked (JS L1064)
});

/* ==================================================================
   Summary — the page
   ================================================================== */
const Summary = ({ brand }) => { // eslint-disable-line no-unused-vars — host reports span the whole network; scoping is the Skin filter, not the brand switcher
  const [hiddenCols, setHiddenCols] = hrsuUseState(hrsuReadCols);
  const [draft, setDraft] = hrsuUseState(() => hrsuDefaults(hrsuReadCols()));
  const [applied, setApplied] = hrsuUseState(null); // null = not searched yet (no-auto-load, index L1070)
  const [settingsOpen, setSettingsOpen] = hrsuUseState(false);

  /* Four feeds. The catalogue and the operator list fill the filter panel and
     load immediately; the volumes wait for Search, so the page still does not
     auto-load — which is what the real one does too. */
  const provFeed = useHrsFetch(() => window.sb.list("providers", { limit: 1000 }), []);
  const userFeed = useHrsFetch(() => window.sb.list("networkUsers", { limit: 2000 }), []);
  const skinFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const bounds = hrsuUseMemo(() => (applied ? hrsuPeriodDays(applied) : null), [applied]);
  const volFeed = useHrsFetch(() => {
    if (!applied || !bounds) return Promise.resolve({ ok: true, data: [] });
    return window.sb.list("reportUserProviderDaily", {
      limit: 10000,
      filters: { from: hrsuIsoFromNum(bounds[0]), to: hrsuIsoFromNum(bounds[1]) },
    });
  }, [applied, bounds && bounds[0], bounds && bounds[1]]);

  const dataset = hrsuUseMemo(() => {
    const catByProvider = {};
    (provFeed.data || []).forEach(p => { catByProvider[String(p.id)] = p.category_id; });
    return { rows: volFeed.data || [], users: (userFeed.data || []).map(u => ({ id: u.id, path: String(u.path || "") })), catByProvider };
  }, [volFeed.data, userFeed.data, provFeed.data]);

  const report = hrsuUseMemo(
    () => (applied ? hrsuRunSummary(applied, dataset) : null),
    [applied, dataset]);
  const target = report ? report.target : (draft.currency || "EUR");
  const hidden = (id) => hiddenCols.includes(id);

  /* -- column-settings plumbing (Include Bonus Bet ⇆ the two bonus checkboxes) -- */
  const setColVisible = (id, visible) => {
    const next = visible ? hiddenCols.filter(x => x !== id) : (hiddenCols.includes(id) ? hiddenCols : [...hiddenCols, id]);
    setHiddenCols(next); hrsuWriteCols(next);
    setDraft(d => ({ ...d, include_bonus: !next.includes("col_bonus_bet") && !next.includes("col_bonus_win") }));
  };
  const setBonusCols = (on) => {
    /* "Include Bonus Bet" is never sent to the backend — it only flips the two
       bonus column-visibility checkboxes and saves (index JS L1045-1059). */
    const rest = hiddenCols.filter(x => x !== "col_bonus_bet" && x !== "col_bonus_win");
    const next = on ? rest : [...rest, "col_bonus_bet", "col_bonus_win"];
    setHiddenCols(next); hrsuWriteCols(next);
    setDraft(d => ({ ...d, include_bonus: on }));
  };

  const applySearch = (v) => {
    if (v.period === "periodo_mese" && !v.periodo_mese) {
      /* Real backend: die("seleziona le date!") (L6110). Evident intent → validation message. */
      hrsToast("Select a month", "The real endpoint die()s with “seleziona le date!” here; the prototype surfaces a validation toast instead.");
      return;
    }
    setApplied(v);
  };

  const onFilterChange = (k, v) => {
    if (k === "include_bonus") { setBonusCols(!!v); return; }
    const nd = { ...draft, [k]: v };
    setDraft(nd);
    /* Toggling Cumulable re-submits the whole form immediately, exactly like
       the real change handler calling fillReport() — draft values included. */
    if (k === "cumulate") applySearch(nd);
  };

  const onReset = () => {
    /* Real Reset only rewrites the form fields — it does NOT re-run the
       report, so an already-loaded table stays on screen. It also writes
       01/m/Y into the END datepicker (index L1021) — likely a bug; the
       prototype resets end to today (evident intent, see header comment). */
    setDraft(hrsuDefaults(hiddenCols));
  };

  /* ---------------- filter fields (form summary/index.blade.php:39-248) ---------------- */
  const fields = [
    {
      key: "skin_ids", label: "Skin", type: "multi", icon: "flag", placeholder: "- All skins -",
      options: BRANDS.map(b => ({ value: b.id, label: b.name })),
      tip: <>select2 multiple <code>skin_ids[]</code> over the viewer's skins, intersected server-side with <code>getSkinIDS()</code>. Only rendered for admin / customer-care viewers on the real form (index L63).</>,
    },
    {
      key: "provider_ids", label: "Providers", type: "multi", icon: "grid", placeholder: "- All active providers -",
      options: (provFeed.data || []).map(p => ({ value: String(p.id), label: p.name })),
      tip: <>Real control is a select2 AJAX search (<code>admin.providers.search</code>) constrained to the chosen skins and category; admins only see active (<code>stato=1</code>) providers. Default = every active provider (L6039).</>,
    },
    { key: "category_ids", label: "Category", type: "multi", icon: "tag", placeholder: "- All -", options: HRSU_CATS, tip: <>Default = all six categories (L6041).</> },
    {
      key: "period", label: "Period", type: "select", icon: "calendar",
      /* radio group on the real form; mode labels inferred from the param names */
      options: [
        { value: "range", label: "Quick range" }, { value: "periodo_mese", label: "Month" },
        { value: "periodo_anno", label: "Year" }, { value: "custom_range", label: "Custom range" },
      ],
      defaultValue: "custom_range",
    },
    { key: "range_val", label: "Quick range", type: "select", icon: "calendar", options: HRSU_RANGES, defaultValue: "1", hidden: draft.period !== "range" },
    { key: "periodo_mese", label: "Month", type: "month-period", mode: "calendar", fromYear: 2021, icon: "calendar", defaultValue: HRSU_MONTH_DEFAULT, hidden: draft.period !== "periodo_mese" },
    { key: "periodo_anno", label: "Year", type: "select", icon: "calendar", options: HRSU_YEARS, defaultValue: String(HRSU_TODAY.getFullYear()), hidden: draft.period !== "periodo_anno" },
    { key: "custom", label: "Custom range", type: "daterange", icon: "calendar", defaultValue: { ...HRSU_CUSTOM_DEFAULT }, hidden: draft.period !== "custom_range" },
    {
      key: "user_type", label: "User Type", type: "select", icon: "users", placeholder: "- All -", options: HRSU_USER_TYPES,
      tip: <>Backend coerces empty to <code>2</code> and filters <code>users.user_level &ge; user_type</code> (L6040, 6130) — so "- All -" and "Skin Access" behave identically, and higher picks narrow to lower network tiers.</>,
    },
    {
      key: "filter_user_ids", label: "User / Parent", type: "multi", icon: "user", placeholder: "- None -",
      options: (userFeed.data || []).map(n => ({ value: String(n.id), label: `${n.username} · ${HRSU_LEVELS[Number(n.user_level)] || "Level " + n.user_level}` })),
      /* Real: select2 AJAX (admin.users.search → searchUsers2) over the viewer's own
         descendants, filtered by skin + user_types [2,8,10,15,20]; disabled for
         affiliates (index L167). Backend expands each pick to a user_path LIKE
         OR-chain (L6134-6146). The per-user authorize() in that loop checks
         support_report_daily_report — a copy-paste from the daily report (L6138);
         the prototype gates the whole screen on support_report_summary instead.
         <!-- SUGGESTION: authorize support_report_summary inside the
              filter_user_ids loop instead of support_report_daily_report. --> */
      tip: <>Scopes the report to the picked users' subtrees (<code>user_path</code> prefix match). Disabled for affiliate viewers on the real form.</>,
    },
    {
      /* The currencies the platform actually holds, from `skins`. It was a
         five-entry list including LBP, which existed because one invented
         outlet used it. */
      key: "currency", label: "Currency", type: "select", icon: "wallet",
      options: Array.from(new Set(["EUR"].concat((skinFeed.data || []).map(s => s.currency).filter(Boolean)))),
      defaultValue: "EUR",
      tip: <>Defaults to the viewer's own currency (customer care: the parent's); backend falls back to EUR (L5092-5097, L6043).</>,
    },
    {
      key: "include_bonus", label: "Include Bonus Bet", type: "toggle", defaultValue: false,
      tip: <>Never sent to the backend — it only shows/hides the Bonus bet / Bonus win columns via the saved column settings (index JS L1045-1059).</>,
    },
    {
      key: "cumulate", label: "Cumulable", type: "toggle", defaultValue: true,
      tip: <>On: one row per provider, every measure FX-converted into the selected currency. Off: one row per provider <b>per native currency</b>, with per-currency Totals rows. Toggling re-submits the form immediately, like the real screen.</>,
    },
  ];

  /* ---------------- table columns (report.blade.php:4-16) ---------------- */
  const money = (r, key) => hrsMoney(r[key], r.currency);
  const chipTotal = (key) => (t) => t._chip && key === "profit"
    ? <span className={`chip ${t._profitPos ? "chip--ok" : "chip--err"}`}>{t[key]}</span> /* real: profit cell bg-success / bg-danger on the Total Converted row */
    : t[key];
  const inferredTip = (langKey) => <>Label inferred — the real header renders the raw key <code>{langKey}</code>, which resolves nowhere in the committed lang files (storage/lang is gitignored).</>;
  const columns = [
    { key: "pid", label: "ID", width: 56 },
    { key: "name", label: "Name", render: r => <span style={{ fontWeight: 600 }}>{r.name}</span> },
    { key: "bet", label: "Bet ( All Valid Bets )", align: "right", hidden: hidden("col_bet"), render: r => money(r, "bet"), renderTotal: t => t.bet },
    /* "—", NEVER 0.00. Nothing in this schema records a bet tax; sport_coupons
       holds stake_tax but references an integration, not a provider, so it
       cannot be attributed to these rows. A zero would say no tax was charged. */
    { key: "bet_tax", label: <>Bet Tax <Tip size={12}>No source in this build: sport_coupons carries stake_tax but references an integration, not a provider, so it cannot be attributed to a provider row. {inferredTip("sport.bet_tax")}</Tip></>, align: "right", hidden: hidden("col_bet_tax"), render: () => "—", renderTotal: () => "—" },
    { key: "win", label: "Win ( Bet Closed )", align: "right", hidden: hidden("col_win"), render: r => money(r, "win"), renderTotal: t => t.win },
    { key: "win_tax", label: <>Win Tax <Tip size={12}>No source in this build — same reason as Bet Tax. {inferredTip("sport.win_tax")}</Tip></>, align: "right", hidden: hidden("col_win_tax"), render: () => "—", renderTotal: () => "—" },
    { key: "profit", label: "Profit", align: "right", hidden: hidden("col_profit"), render: r => money(r, "profit"), renderTotal: chipTotal("profit") },
    { key: "bonus_bet", label: "Bonus bet", align: "right", hidden: hidden("col_bonus_bet"), render: r => money(r, "bonus_bet"), renderTotal: t => t.bonus_bet },
    { key: "bonus_win", label: "Bonus win", align: "right", hidden: hidden("col_bonus_win"), render: r => money(r, "bonus_win"), renderTotal: t => t.bonus_win },
    {
      key: "converted", label: `Converted (${target})`, align: "right", hidden: hidden("col_converted"),
      render: r => hrsMoney(r.converted, target),
      cellClass: r => (r.converted >= 0 ? "hrs-pos" : "hrs-neg"), // converted_classes green/red (L6251)
      renderTotal: t => t.converted,
    },
    /* bet_closed / bonus_bet_closed / profit_closed: computed in the SQL, hidden
       everywhere on the real screen and always stripped from its export — omitted. */
  ];

  /* ---------------- export (real: summary.xlsx via new-tab form GET with
     export=1 + hidden_cols[]; excelSummaryExport L6619 mirrors column
     visibility and always strips the three closed columns) ---------------- */
  const exportCsv = () => {
    if (!report) { hrsToast("Nothing to export", "Run a Search first — the export re-submits the same form."); return; }
    const headers = [{ key: "pid", label: "id" }, { key: "name", label: "name" }];
    if (!report.cumulate) headers.push({ key: "currency", label: "currency" });
    for (const c of HRSU_TOGGLE_COLS) {
      if (hiddenCols.includes(c.id)) continue; // export honors the saved hidden columns, like the real one
      const k = c.id.slice(4); // "col_bet" → "bet"
      headers.push({ key: k, label: k, get: r => Number(r[k] || 0).toFixed(2) });
    }
    hrsCsv(report.rows, headers, "summary.csv");
  };

  return (
    <HrsShell
      title="Summary"
      subtitle="Per-provider GGR summary · GET /reports/summary/ · ReportsController::getSummaryReport"
      gate={["support_report", "support_report_summary"]}
      gateNote={<>
        {" "}Gates bind <b>customer-care</b> users only — every other role passes. Customer-care and affiliate viewers are silently re-rooted to their <b>parent's</b> subtree (L6035-6037). Both controller methods re-assert the pair via <code>authorize('asdasdas')</code> — a deliberately nonexistent ability that 403s. The Export button additionally needs <code>support_export</code>.
      </>}
      explainer={{
        title: "What this report shows, in plain English",
        bullets: [
          <>One aggregate over nightly <code>business_report</code> day rows: every figure is <code>SUM(value × skins.reports_multiplier)</code>, grouped per provider — plus per native currency when Cumulable is off. The sport provider is pinned to row one; everything else sorts A→Z (ties by profit). No sorting, no paging: the whole aggregate renders in one table.</>,
          <><b>Per-day FX:</b> figures are pre-grouped per day, and each day converts at that day's stored rate — <code>value ÷ day-rate × latest target rate</code> (rates are EUR-based, from the <code>currencies</code> table). A day with no stored rate falls back to the nearest earlier rate, then to 1.0 with a logged warning. So "Converted" is rate-path-dependent — and because the round-trip always goes through EUR at the day's rate, even rows already in the selected currency drift when rates moved during the period.</>,
          <>No auto-load — press <b>Search</b>. Toggling <b>Cumulable</b> re-submits immediately; <b>Include Bonus Bet</b> only shows/hides the two bonus columns and is never sent to the backend.</>,
          <>The same aggregation doubles as a JSON feed: <code>KraController</code> instantiates <code>ReportsController</code> and calls <code>getSummaryReport($request, true)</code> for KRA tax reporting — changes here feed the tax report too.</>,
        ],
      }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setSettingsOpen(o => !o)}>
          <Icon name="settings" size={14} /> Table settings
        </button>
      }
    >
      {settingsOpen && (
        <div className="panel" style={{ padding: "12px 16px", display: "flex", flexWrap: "wrap", gap: "10px 22px", alignItems: "center" }}>
          <b style={{ fontSize: 12.5 }}>Visible columns</b>
          {HRSU_TOGGLE_COLS.map(c => (
            <label key={c.id} style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12.5, cursor: "pointer" }}>
              <input type="checkbox" checked={!hiddenCols.includes(c.id)} onChange={e => setColVisible(c.id, e.target.checked)} />
              {c.label}
            </label>
          ))}
          <span style={{ fontSize: 11.5, color: "#8a91a0", flexBasis: "100%" }}>
            Saved per operator under <code>localStorage["{HRSU_LS_KEY}"]</code> — same key as the real screen; first visit hides Bonus bet &amp; Bonus win, and the export honors the same hidden columns.
          </span>
        </div>
      )}

      <HrsFilters
        fields={fields}
        values={draft}
        onChange={onFilterChange}
        onSearch={applySearch}
        onReset={onReset}
        resultLabel={report ? `${hrsInt(report.rows.length)} rows` : "—"}
      />

      {applied && volFeed.loading && <HrsSkeleton rows={8} cols={9} />}
      {applied && !volFeed.loading && volFeed.error &&
        <HrsError error={volFeed.error} onRetry={volFeed.retry} />}
      {!(applied && (volFeed.loading || volFeed.error)) && (
      <HrsTable
        columns={columns}
        rows={report ? report.rows : []}
        totals={report ? report.totals : null}
        rowKey={(r) => `${r.pid}|${r.currency}`}
        maxHeight="calc(100vh - 340px)" /* the real page pins the header with floatThead */
        empty={<>Choose a period and press <b>Search</b> — the report never auto-loads (the on-load <code>fillReport()</code> call is commented out on the real page).</>}
        renderCard={r => <>
          <div className="hrs-card__top">
            <b>{r.name}</b>
            <span className={`chip ${r.converted >= 0 ? "chip--ok" : "chip--err"}`}>{hrsMoney(r.converted, target)}</span>
          </div>
          <div className="hrs-card__grid">
            <span>ID</span><b>{r.pid}</b>
            {!hidden("col_bet") && <><span>Bet</span><b>{money(r, "bet")}</b></>}
            {!hidden("col_win") && <><span>Win</span><b>{money(r, "win")}</b></>}
            {!hidden("col_profit") && <><span>Profit</span><b>{money(r, "profit")}</b></>}
            {!hidden("col_bonus_bet") && <><span>Bonus bet</span><b>{money(r, "bonus_bet")}</b></>}
            {!hidden("col_bonus_win") && <><span>Bonus win</span><b>{money(r, "bonus_win")}</b></>}
          </div>
        </>}
      />
      )}

      <HrsExport
        onCsv={exportCsv}
        filename="summary.csv"
        count={report ? report.rows.length : 0}
        gate="support_export"
        note={<>Real screen builds <code>summary.xlsx</code> (new-tab form GET with <code>export=1</code> + <code>hidden_cols[]</code>; the three closed columns are always stripped) — the prototype downloads CSV with the same visibility rules.</>}
      />
    </HrsShell>
  );
};

window.Summary = Summary;
