// NO PROD JSON API (bucket B — feed is a DataTables render payload or
//   {"html":...}, not data) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: GET /reports/players/ · PlayersReportController — see docs/ISYSTEM_REFERENCE.md §Batch 2 "Players report"
/* Host → Report ▾ → Players Report — the shop-level per-player betting report.
   Replaces the legacy `PlayersReport` bundled in src/pages/HostReports.jsx
   (this file loads after it in index.html, so this definition wins).

   Real surface (docs/ISYSTEM_REFERENCE.md §Batch 2 "Players Report"):
   - Route admin.reports.players.index `GET /reports/players/` (routes/admin.php:1502);
     data + export sibling admin.reports.players.data `GET|POST /reports/players/getReport`
     (:1503 — the AJAX driver only GETs; POST is effectively the Excel form submit).
   - Controller ReportsController::players (:2176 filter page) /
     ReportsController::getPlayerReport (:2220 data + export) / getProvidersByCategory
     (:2202); export writer Player::exportPlayersReport (Player.php:545).
   - Blade admin/reports/players/index.blade.php + result fragment template.blade.php
     (injected into #report-players-ajax); JS driver public/js/pages/reports/players.js;
     bulk-message partial admin/reports/modals/newMessage.blade.php (send_type
     `reportplayers` → AJAX GET /messages/newMessage/, routes/admin.php:659).
   - Lookups: admin.users.search `GET /users2` (Parent/Username select2) and
     admin.providers.search `GET /providers/search` (Providers multi-select2).
   - No FormRequest — every param is read via isset(...) ?: default.
   - Fact table: players_report JOIN users, hierarchy-scoped via
     users.user_path LIKE '<scope>%' and always constrained to getSkinIDS().

   Known real-platform quirks — implemented as the evident intent per the
   build policy (divergences recorded below and in inline comments):
   - Permission leak: when the Parent filter is set, the customer-care check at
     ReportsController.php:2358 tests `support_report_daily_report` instead of
     `support_report_players` (copy-pasted from Daily Report). Surfaced honestly
     in the header gate note.
     // <!-- SUGGESTION: fix the copy-pasted support_report_daily_report check at ReportsController.php:2358 to test support_report_players so the Daily-report grant stops leaking into this screen. -->
   - Page-load default conflict: markup ships column=all_profit but section=sport,
     and JS setColumns('sport') rebuilds the Column select without all_profit — the
     effective default is the section's first option (number_bets). The real Reset
     button then writes a DIFFERENT set (section=all, column=all_profit,
     range=Today) than the page-load defaults (section=sport, range=Yesterday).
     The prototype uses one canonical default set (the page-load one) for both.
     // <!-- SUGGESTION: pick one canonical default set (page-load: section=sport, range=Yesterday, column=first-of-section) and make the Reset button write the same values instead of section=all / column=all_profit / range=Today. -->
   - Header/data mismatch: for section=all the Profit HEADER is suppressed
     (template.blade.php:61-63) although the data cell still renders. The
     prototype shows the header — the evident intent.
     // <!-- SUGGESTION: render the Profit header for section=all (template.blade.php:61-63) — the data cell already renders, so the header/data columns are misaligned on the real page. -->
   - Export-scope markup bug: BOTH `export_page` options carry `selected`
     (index.blade.php:256-257) so the browser lands on "All Pages". The prototype
     defaults to All Pages explicitly.
     // <!-- SUGGESTION: drop the duplicate 'selected' attribute on the export-scope options (index.blade.php:256-257) and default explicitly to All Pages. -->
   - Dead time inputs: serach_time_start / serach_time_end exist in the Blade but
     players.js never sends them and the controller hardcodes 00:00:00/23:59:59.
     The prototype omits the dead inputs (daterange without time).
     // <!-- SUGGESTION: either wire serach_time_start/serach_time_end through to the query or remove the dead time inputs — the controller hardcodes 00:00:00/23:59:59. -->
   - Empty Month guard is a raw `die("seleziona le date!")` (ReportsController.php:2327).
     The prototype validates with a toast instead.
     // <!-- SUGGESTION: replace the raw die("seleziona le date!") guard (ReportsController.php:2327) with a translated validation error. -->
   - `section_spins` has NO case in the ORDER BY switch (:2397-2432) and falls
     through to the default tot_bet_count — the prototype sorts by the bet/spin
     count for it, which is the identical outcome.
     // <!-- SUGGESTION: add an ORDER BY case for section_spins (ReportsController.php:2397-2432) — today it silently falls through to tot_bet_count (same result, but only by accident). -->
   - >10k export emails the link to skin documents_email / the CC user's own email
     — or the HARDCODED random.nbr@gmail.com when user_level 0 or no email
     (Player.php:784-795). Surfaced in the export note.
     // <!-- SUGGESTION: replace the hardcoded random.nbr@gmail.com fallback recipient (Player.php:784-795) with a configurable ops mailbox. -->
   - Not reproduced (visual/legacy artifacts, no behavior): first row's
     username/checkbox rowspan=3 spanning the injected header row
     (template.blade.php:27-38); leftover bg-poker CSS class (:190-197); dead
     Highcharts/jsPDF/DataTables-buttons CDN baggage. Data-layer trivia noted
     only: aggregates selected but never displayed (tot_taxes, tot_bonus_bet/_win,
     tot_bet_closed, tot_single_bets, tot_live_bet/_win, balance_withdrawable),
     `total_profit` read but never selected (always 0), `custom_range` param read
     but unused, unreachable third return at :2593.
   - Row checkboxes are page-scoped: the real result fragment is re-rendered on
     every page change, so selections do not survive pagination — reproduced.

   Honesty pass (no control fires a toast implying an action that did not happen):
   - Username cell: now a real cross-page navigation to this build's Players screen
     (route key `host-players`). The real cell opens the per-player detail page
     (admin.players.show → GET /players/{id}/) in a new tab; this build's Players list
     has no per-player deep link, which the cell's title states.
   - "Send to users on the page" / "Send to selected users": rendered DISABLED with the
     compose deep-link they need in title + Tip. The real flow is
     GET /messages/newMessage/?send_type=reportplayers&recipients=<csv ids> →
     POST /messages/sendNewMessage/; this build's Messages compose modal is opened from
     that page's own state and accepts no recipients, so the selection cannot be carried.
     Row checkboxes still work and the selected count still shows on the button.
   - The CSV export toast is untouched: the file really downloads. The >10k "queued /
     emailed link" toast belongs to the shared HrsExport block, not to this file.

   Labels: runtime translations live in gitignored storage/lang; keys with no
   committed text are rendered as sensible operator-facing labels ("label
   inferred" per build policy): backend.include_test_users ("Include test
   users"), backend.total_plays ("Total plays"), backend.send_message,
   sport.bet_tax ("Bet Tax"), sport.win_tax ("Win Tax").

   All top-level names are prefixed Hrpl/HRPL/hrpl (globally unique — verified
   by grep). Mock rows are generated by a deterministic seeded PRNG.
   No page CSS needed — hrs-* kit classes + inline styles only. */

const { useState: hrplUseState, useMemo: hrplUseMemo } = React;

/* ---------------- deterministic PRNG + helpers ---------------- */
const hrplRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
const hrplHash = (s) => { s = String(s); let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
const hrplISO = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const hrplDMY = (iso) => String(iso || "").split("-").reverse().join("/"); // dd/mm/yyyy — the real page's Italian-locale datepicker format
const hrplR2 = (n) => Math.round(n * 100) / 100;

const HRPL_NOW = new Date();
const HRPL_MONTH_FIRST = hrplISO(new Date(HRPL_NOW.getFullYear(), HRPL_NOW.getMonth(), 1));
const HRPL_MONTH_LAST = hrplISO(new Date(HRPL_NOW.getFullYear(), HRPL_NOW.getMonth() + 1, 0));

/* ---------------- real enum values (reference §"Enums / status values") ---------------- */
/* range: 1=Today, 2=Yesterday, 3=This week, 4=Previous week, 5=This month, 6=Previous month */
const HRPL_RANGES = [["1", "Today"], ["2", "Yesterday"], ["3", "This week"], ["4", "Previous week"], ["5", "This month"], ["6", "Previous month"]];
/* period: range / periodo_mese / periodo_anno / custom_range — a radio group on
   the real page; rendered as the shell's compact mode select. */
const HRPL_PERIOD_MODES = [["range", "Range"], ["periodo_mese", "Month"], ["periodo_anno", "Year"], ["custom_range", "Custom range"]];
/* Sections + per-section count-column label (template.blade.php:24-113):
   sport → backend.number_bets; casino/casinolive/virtual → backend.total_spins;
   everything else → backend.total_plays (label inferred). Bet Tax / Win Tax
   columns render for sport + jackpot only (sport.bet_tax / sport.win_tax —
   labels inferred). */
const HRPL_SECTIONS = [
  { value: "all",        label: "- All -",     countLabel: "Total plays", tax: false },
  { value: "sport",      label: "Sport",       countLabel: "Number bets", tax: true },
  { value: "casino",     label: "Casino",      countLabel: "Total Spins", tax: false },
  { value: "casinolive", label: "Casino Live", countLabel: "Total Spins", tax: false },
  { value: "virtual",    label: "Virtual",     countLabel: "Total Spins", tax: false },
  { value: "jackpot",    label: "Jackpot",     countLabel: "Total plays", tax: true },
];
/* Column select options — exact mirror of the JS setColumns() rebuild per section. */
const HRPL_COLUMNS = {
  sport:      [["number_bets", "Number bets"], ["section_bet", "Bet"], ["section_win", "Win"], ["bet_tax", "Bet Tax"], ["win_tax", "Win Tax"], ["section_profit", "Profit"]],
  jackpot:    [["section_spins", "Total plays"], ["section_bet", "Bet"], ["section_win", "Win"], ["section_profit", "Profit"]],
  casino:     [["section_spins", "Total Spins"], ["section_bet", "Bet"], ["section_win", "Win"], ["section_profit", "Profit"]],
  casinolive: [["section_spins", "Total Spins"], ["section_bet", "Bet"], ["section_win", "Win"], ["section_profit", "Profit"]],
  virtual:    [["section_spins", "Total Spins"], ["section_bet", "Bet"], ["section_win", "Win"], ["section_profit", "Profit"]],
  all:        [["all_profit", "Total profit"]],
};
/* Single-sort mapping (ReportsController.php:2397-2432) — no clickable headers
   on the real page, so the table renders without header sorting on purpose.
   section_spins has no case and falls through to tot_bet_count (same field). */
const HRPL_SORT = { all_profit: "profit", section_profit: "profit", section_bet: "bet", section_win: "win", bet_tax: "betTax", win_tax: "winTax", number_bets: "count", section_spins: "count" };

/* ---------------- mock catalogue (consistent with HostUsers' network) ---------------- */
/* THE BRAND AND PARENT LISTS ARE FEEDS NOW. They were five invented brands with
   invented admin ids, and twelve invented operators — a filter panel offering
   brands that do not exist and, worse, ancestor ids the subtree filter would
   then send to the server. Both `skins` and `networkUsers` are already
   RLS-scoped to the caller, which is the same set the report itself can reach.
   Parent select2 upstream searches levels [0,2,8,10,15,20] scoped by skin. */
/* Currency select = distinct currency.currency values; default = auth user's
   currency (the parent's for customer care). Used as the Cumulable conversion
   target (ReportsController.php:2523-2532). Mock FX = units per EUR. */
/* WAS a seven-row hardcoded table with ARS at 1580 to the euro. Live rates come
   from `currency_latest_rate` through window.FX_RATES, and conversion runs
   through the shared `fxConvert` — the one place the EUR-per-unit /
   units-per-EUR inversion is settled, and the only one tools/fxcheck.js can
   test. Two report screens have already shipped that inversion backwards.

   fxConvert RETURNS NULL when it cannot convert. Null is passed through rather
   than falling back to the unconverted number: a figure labelled ARS that is
   really PYG is worse than a dash. */
const hrplFx = (v, from, to) => {
  if (from === to) return v;
  const c = window.fxConvert && window.fxConvert(v, from, to);
  return (c === null || c === undefined) ? null : c;
};
const HRPL_CURRENCIES = ["ARS", "BOB", "BRL", "CLP", "EUR", "PYG", "USD"];
/* Provider multi-select2 hits admin.providers.search with a category derived
   client-side from the section: sport→6, casino→1, casinolive→2, virtual→4,
   all/jackpot→none (config/cats.php; SPORTBOOK_PROVIDER_ID=101,
   JACKPOT_PROVIDER_ID=999999). "None" = no category constraint → full list. */
const HRPL_PROVIDERS = {
  sport:      [["101", "Sportsbook"]],
  casino:     [["18952", "Pragmatic Play"], ["18310", "3Oaks"], ["18401", "Amusnet"], ["18220", "BGaming"], ["18119", "Belatra"], ["18540", "Amigo Gaming"], ["18007", "7777"], ["18642", "Airdice"]],
  casinolive: [["18410", "Amusnet Live"], ["18730", "7 Mojos"], ["18771", "TVBet"]],
  virtual:    [["18820", "GoldenRace"], ["18831", "Kiron Virtuals"]],
};
const hrplProvidersFor = (section) => {
  const lists = HRPL_PROVIDERS[section] || (section === "all" || section === "jackpot"
    ? [].concat(HRPL_PROVIDERS.sport, HRPL_PROVIDERS.casino, HRPL_PROVIDERS.casinolive, HRPL_PROVIDERS.virtual)
    : []);
  return lists.map(([v, l]) => ({ value: v, label: l }));
};
const HRPL_YEARS = (() => { const out = []; for (let y = HRPL_NOW.getFullYear(); y >= 2021; y--) out.push(String(y)); return out; })(); // Year radio: 2021..current

/* ---------------- cross-page navigation to the Players screen ----------------
   The Username cell's real target is the per-player detail page (admin.players.show
   → GET /players/{id}/, opened in a new tab). This build's counterpart is the host
   Players screen (route key `host-players` → /players, src/pages/HostPlayers.jsx),
   which holds its selected player in memory and has no per-player deep link — so the
   cell really navigates there, using the app's own pushState + popstate convention
   (same as HostDashboard's hdNavTo), and names the row's true target in its title. */
const hrplGoPlayers = () => {
  const path = (window.pathForActive && window.pathForActive("host-players")) || "/players";
  try {
    if (window.location.pathname !== path) window.history.pushState({ active: "host-players" }, "", path);
    const ev = typeof PopStateEvent === "function" ? new PopStateEvent("popstate") : new Event("popstate");
    window.dispatchEvent(ev);
  } catch (_e) {
    window.location.href = path; // last resort: a real full-page load of the same path
  }
};

/* THE 128-PLAYER UNIVERSE IS GONE. It invented players, their brands, their
   ancestor chains and — the part that mattered — a bet, a win and a profit for
   each, per section, seeded so the same figures came back on every load. A
   report that is stable across refreshes is the one people trust.

   `report_player_daily` (022) is the aggregate the real screen runs: one row
   per (player, day, currency, vertical) over `ledger_entries`, joined through
   `report_type_class` so a stake is a stake whichever type id carried it, with
   the day computed in the SKIN's timezone rather than the reader's.

   WHAT THE VIEW CANNOT ANSWER, and is therefore not answered:
   · `vertical` is casino / sport / exchange. isystem splits Casino Live,
     Virtual and Jackpot out of Casino using the game's `g_cid`, which no ledger
     row here carries — UNCLEAR-13. Those three sections render an empty state
     naming the reason rather than showing casino's numbers under another
     heading, which is what a silent fallback would do.
   · There is no bet tax or win tax column anywhere in this schema, so the two
     Sport columns render "—". A 0.00 would claim no tax was charged. */
const HRPL_VERTICAL_FOR = { sport: "sport", casino: "casino", all: null };
const HRPL_UNSERVED = { casinolive: "Casino Live", virtual: "Virtual", jackpot: "Jackpot" };

/* ---------------- period bounds (controller date resolution) ---------------- */
/* Times are always 00:00:00 → 23:59:59 — the controller hardcodes them. */
const hrplBounds = (a) => {
  const t0 = new Date(HRPL_NOW.getFullYear(), HRPL_NOW.getMonth(), HRPL_NOW.getDate());
  const day = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
  if (a.period === "periodo_mese") {
    const parts = String(a.month || "").split("|");
    return parts[0] && parts[1] ? { from: parts[0], to: parts[1] } : null; // empty → real code die("seleziona le date!")
  }
  if (a.period === "periodo_anno") {
    const y = Number(a.year) || HRPL_NOW.getFullYear();
    return { from: `${y}-01-01`, to: `${y}-12-31` };
  }
  if (a.period === "custom_range") {
    const v = a.daterange || {};
    return { from: v.from || HRPL_MONTH_FIRST, to: v.to || HRPL_MONTH_LAST }; // Blade defaults: 1st/last of current month
  }
  const r = Number(a.range) || 1; // controller fallback 1 (Today) — the form itself defaults to 2 (Yesterday)
  const monday = day(t0, -((t0.getDay() + 6) % 7));
  switch (r) {
    case 2: { const y = day(t0, -1); return { from: hrplISO(y), to: hrplISO(y) }; }
    case 3: return { from: hrplISO(monday), to: hrplISO(t0) };
    case 4: return { from: hrplISO(day(monday, -7)), to: hrplISO(day(monday, -1)) };
    case 5: return { from: hrplISO(new Date(t0.getFullYear(), t0.getMonth(), 1)), to: hrplISO(t0) };
    case 6: return { from: hrplISO(new Date(t0.getFullYear(), t0.getMonth() - 1, 1)), to: hrplISO(new Date(t0.getFullYear(), t0.getMonth(), 0)) };
    default: return { from: hrplISO(t0), to: hrplISO(t0) };
  }
};

/* ---------------- per-player aggregation over report_player_daily ----------------
   The view is per (player, DAY, currency, vertical); the table is per player.
   So the rows fold here, and the fold is where two decisions live:

   · A PLAYER WITH TWO CURRENCIES IS TWO ROWS, keyed (user, currency) — the same
     shape the totals block already used, which groups by currency. Folding them
     together would add pesos to dollars.
   · `bet` is real_stake + bonus_stake and `win` is real_payout + bonus_payout.
     A bonus bet is a bet: leaving it out understates turnover on exactly the
     players a bonus campaign was aimed at, and Profit is what the operator
     keeps either way. */
const hrplFold = (rows, section) => {
  const want = HRPL_VERTICAL_FOR[section];
  const by = {};
  (rows || []).forEach(r => {
    if (want && r.vertical !== want) return;
    const cur = r.currency || "";
    const k = r.user_id + "|" + cur;
    const g = by[k] || (by[k] = {
      id: Number(r.user_id), username: r.username || String(r.user_id),
      currency: cur, count: 0, bet: 0, win: 0,
      /* NULL, not 0 — this schema has no tax column, so the cell renders "—".
         A 0.00 would read as "no tax was charged". */
      betTax: null, winTax: null, profit: 0,
    });
    g.count += Number(r.bet_count) || 0;
    g.bet += (Number(r.real_stake) || 0) + (Number(r.bonus_stake) || 0);
    g.win += (Number(r.real_payout) || 0) + (Number(r.bonus_payout) || 0);
  });
  return Object.keys(by).map(k => {
    const g = by[k];
    g.bet = hrplR2(g.bet); g.win = hrplR2(g.win); g.profit = hrplR2(g.bet - g.win);
    return g;
  });
};

/* ---------------- page-load defaults (ReportsController::players) ---------------- */
const hrplDefaults = () => ({
  skin_id: "",            // "- Select -"
  user_id: "",            // Parent — "- Select -"
  filter_user_id: "",     // Username — "- Select -"
  per_page: "10",         // Results per Page 10/20/50/100
  currency: "ARS",        // auth user's currency (parent's for customer care)
  cumulate: false,
  include_test_users: false,
  period: "range", range: "2", // period=range + range=2 (Yesterday) on page load
  month: (hrsPeriodOptions("calendar")[0] || { value: "" }).value, // getDateCalendarioMonth() — current calendar month preselected
  year: String(HRPL_NOW.getFullYear()),
  daterange: { from: HRPL_MONTH_FIRST, to: HRPL_MONTH_LAST, fromTime: "", toTime: "" },
  section: "sport",
  provider_ids: [],
  column: HRPL_COLUMNS.sport[0][0], // effective default after setColumns('sport') — see header quirk note
  orderdir: "2",          // 2=Descending (only "2" gives DESC in the controller)
});

/* ================================================================== */
const PlayersReport = () => {
  const [draft, setDraft] = hrplUseState(hrplDefaults);
  const [applied, setApplied] = hrplUseState(null); // null = not searched yet (no-auto-load convention)
  const [page, setPage] = hrplUseState(0);
  const [sel, setSel] = hrplUseState({});           // page-scoped row selection (cleared on page change, like the re-rendered real fragment)
  const [scope, setScope] = hrplUseState("all");    // export_page — real markup double-`selected` bug lands on "all"

  /* Three option feeds. RLS scopes each to the caller's own subtree, which is
     the same set the report itself can reach — so the filter cannot offer a
     brand, ancestor or player whose rows would come back empty. */
  const skinFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const parentFeed = useHrsFetch(() => window.sb.list("networkUsers", { limit: 2000 }), []);
  const playerFeed = useHrsFetch(() => window.sb.list("players", { limit: 2000 }), []);
  const fields = hrplUseMemo(() => [
    { key: "skin_id", label: "Skin", type: "select", icon: "flag", placeholder: "- Select -", defaultValue: "",
      /* VALUE IS THE ID, not the name. The old list keyed on the brand name and
         the filter compared names too — which works right up until two brands
         share one, and `report_player_daily.skin_id` is an id regardless. */
      options: (skinFeed.data || []).map(s => ({ value: String(s.id), label: s.name })),
      tip: <>Options from <code>Auth::user()-&gt;getSkins()</code> — all skins for admin, else own + multiple_skins. Rendered only for <code>isadmin() || isCustomCare()</code> on the real page.</> },
    { key: "user_id", label: "Parent", type: "select", icon: "user", placeholder: "- Select -", defaultValue: "",
      options: (parentFeed.data || []).map(p => ({
        value: String(p.id),
        label: p.username + " (" + (window.huRoleName ? window.huRoleName(Number(p.user_level)) : "level " + p.user_level) + ")",
      })),
      tip: <>select2 AJAX (<code>/users2</code>) over levels 0/2/8/10/15/20 scoped by skin. Scopes the report to that user's <code>user_path</code> subtree.</> },
    { key: "filter_user_id", label: "Username", type: "select", icon: "users", placeholder: "- Select -", defaultValue: "", grow: true,
      options: (playerFeed.data || []).map(p => ({ value: String(p.id), label: p.username })),
      tip: <>select2 AJAX search over players (level 30). Disabled for Affiliate accounts on the real platform.</> },
    { key: "per_page", label: "Results per Page", type: "select", icon: "list", defaultValue: "10",
      options: ["10", "20", "50", "100"].map(v => ({ value: v, label: v })) },
    { key: "currency", label: "Currency", type: "select", icon: "wallet", defaultValue: "ARS",
      options: HRPL_CURRENCIES.map(c => ({ value: c, label: c })),
      tip: <>Distinct <code>currency.currency</code> values; defaults to your own currency (the parent's for customer care). Conversion target when Cumulable is on.</> },
    { key: "cumulate", label: "Cumulable", type: "toggle", defaultValue: false,
      tip: <>Converts every money cell to the selected currency via CurrencyConverter and collapses the per-currency totals into a single "Total Converted" row.</> },
    { key: "include_test_users", label: "Include test users", type: "toggle", defaultValue: false,
      tip: <>Off adds <code>users.test_user = 0</code> to the query. Label inferred — <code>backend.include_test_users</code> resolves in no committed lang file.</> },
    { key: "period", label: "Period", type: "select", icon: "calendar", defaultValue: "range",
      options: HRPL_PERIOD_MODES.map(([v, l]) => ({ value: v, label: l })),
      tip: <>A four-way radio group on the real page (Range / Month / Year / Custom range) — rendered here as the shell's compact mode select.</> },
    { key: "range", label: "Range", type: "select", defaultValue: "2", hidden: draft.period !== "range",
      options: HRPL_RANGES.map(([v, l]) => ({ value: v, label: l })) },
    { key: "month", label: "Month", type: "month-period", mode: "calendar", fromYear: 2021, hidden: draft.period !== "periodo_mese",
      defaultValue: (hrsPeriodOptions("calendar")[0] || { value: "" }).value,
      tip: <>Calendar months from <code>getDateCalendarioMonth()</code> — not the first-Monday "commission months" other reports use.</> },
    { key: "year", label: "Year", type: "select", defaultValue: String(HRPL_NOW.getFullYear()), hidden: draft.period !== "periodo_anno",
      options: HRPL_YEARS.map(y => ({ value: y, label: y })) },
    { key: "daterange", label: "Custom range", type: "daterange", hidden: draft.period !== "custom_range",
      defaultValue: { from: HRPL_MONTH_FIRST, to: HRPL_MONTH_LAST, fromTime: "", toTime: "" },
      tip: <>Defaults to the 1st and last day of the current month. The real page also shows time inputs, but they are dead — the controller hardcodes 00:00:00 / 23:59:59.</> },
    { key: "section", label: "Section", type: "select", icon: "grid", defaultValue: "sport",
      options: HRPL_SECTIONS.map(s => ({ value: s.value, label: s.label })),
      tip: <>Changing the section rebuilds the Column and Providers selects, exactly like the real page's setColumns() + select2 rebuild.</> },
    { key: "provider_ids", label: "Providers", type: "multi", placeholder: "- All -", defaultValue: [],
      options: hrplProvidersFor(draft.section),
      tip: <>select2 AJAX (<code>/providers/search</code>) with skin + a category derived from the section (sport→6, casino→1, casino live→2, virtual→4; All/Jackpot send no category, so the full list is searchable).</> },
    { key: "column", label: "Column", type: "select", icon: "sort", defaultValue: (HRPL_COLUMNS[draft.section] || [[""]])[0][0],
      options: (HRPL_COLUMNS[draft.section] || []).map(([v, l]) => ({ value: v, label: l })),
      tip: <>The report's single sort — no clickable table headers on the real page. Options rebuild per section.</> },
    { key: "orderdir", label: "Order by", type: "select", icon: "arrow_down_up", defaultValue: "2",
      options: [{ value: "2", label: "Descending" }, { value: "1", label: "Ascending" }],
      tip: <>Only <code>2</code> gives DESC in the controller; anything else sorts ASC.</> },
  ], [draft.section, draft.period]);

  const onDraft = (k, v) => setDraft(d => {
    if (k === "section") {
      const first = (HRPL_COLUMNS[v] || [[""]])[0][0];
      return { ...d, section: v, column: first, provider_ids: [] }; // mirrors setColumns() + Providers select2 rebuild
    }
    return { ...d, [k]: v };
  });

  const doSearch = (v) => {
    if (v.period === "periodo_mese" && !v.month) {
      hrsToast("Select the dates", "The real controller answers an empty Month with a raw die(\"seleziona le date!\") — implemented as proper validation."); // evident intent
      return;
    }
    setApplied({ ...v });
    setPage(0);
    setSel({});
  };
  const doReset = () => { setDraft(hrplDefaults()); setApplied(null); setPage(0); setSel({}); };

  /* ---------------- report computation ----------------
     THE FETCH IS THE REPORT. The filters resolve to a period and the period is
     what the server is asked for — bounds, brand, subtree, vertical — rather
     than a whole table narrowed in the browser. `applied` is null until Search
     is pressed and the feed's deps say so, so nothing loads on arrival. */
  const bounds = hrplUseMemo(() => (applied ? hrplBounds(applied) : null), [applied]);
  const unserved = applied ? HRPL_UNSERVED[applied.section] : null;
  const feed = useHrsFetch(() => {
    if (!applied || !bounds || unserved) return Promise.resolve({ ok: true, data: [] });
    const filters = { from: bounds.from, to: bounds.to };
    if (applied.skin_id) filters.skin = applied.skin_id;
    /* SUBTREE, not parent_id. A report about an operator means everyone beneath
       them; `parent` is one level, and the difference is invisible in the output
       because both render a plausible number of players. `cd.` is ltree
       descendant-or-self — the same containment the permission checks use, never
       a string prefix, since '1.2' prefixes '1.23'. */
    if (applied.user_id) filters.subtree = applied.user_id;
    if (applied.filter_user_id) filters.user = applied.filter_user_id;
    const v = HRPL_VERTICAL_FOR[applied.section];
    if (v) filters.vertical = v;
    return window.sb.list("reportPlayers", { limit: 5000, filters });
  }, [applied, bounds && bounds.from, bounds && bounds.to, unserved]);

  const report = hrplUseMemo(() => {
    if (!applied || !bounds || unserved) return null;
    const base = hrplFold(feed.data || [], applied.section);
    let rows = base;
    const converted = !!applied.cumulate;
    if (converted) {
      const tgt = applied.currency || "ARS";
      /* fxConvert returns NULL when it cannot convert. A row whose currency has
         no rate is DROPPED rather than carried at its unconverted value under
         the target's name — which would put a PYG figure in an ARS column and
         then total it. The count of dropped rows is reported on screen. */
      rows = base.map(r => {
        const bet = hrplFx(r.bet, r.currency, tgt);
        const win = hrplFx(r.win, r.currency, tgt);
        const profit = hrplFx(r.profit, r.currency, tgt);
        if (bet === null || win === null || profit === null) return null;
        return Object.assign({}, r, { currency: tgt, bet: hrplR2(bet), win: hrplR2(win), profit: hrplR2(profit) });
      }).filter(Boolean);
    }
    const f = HRPL_SORT[applied.column] || "count"; // default (incl. missing section_spins case) → tot_bet_count
    const dir = String(applied.orderdir) === "2" ? -1 : 1;
    rows = rows.slice().sort((a, z) => ((a[f] - z[f]) * dir) || (a.id - z.id));
    /* Totals over ALL pages, one row per currency — a single Total Converted row
       with Cumulable. Tax stays null: there is no tax column to sum. */
    const groups = {};
    rows.forEach(r => {
      const g = groups[r.currency] || (groups[r.currency] = { cur: r.currency, count: 0, bet: 0, win: 0, betTax: null, winTax: null, profit: 0 });
      g.count += r.count; g.bet = hrplR2(g.bet + r.bet); g.win = hrplR2(g.win + r.win);
      g.profit = hrplR2(g.profit + r.profit);
    });
    return { rows, totals: Object.keys(groups).sort().map(k => groups[k]),
             bounds, converted, dropped: base.length - rows.length };
  }, [applied, feed.data, bounds, unserved]);

  const allRows = report ? report.rows : [];
  const total = allRows.length;
  const perPage = Math.max(1, Number(applied && applied.per_page) || 10); // controller fallback if the param is missing is 1 — the form always sends it
  const pageRows = allRows.slice(page * perPage, (page + 1) * perPage);
  const effSection = applied ? applied.section : draft.section;
  const secMeta = HRPL_SECTIONS.find(s => s.value === effSection) || HRPL_SECTIONS[1];

  /* ---------------- bulk "message to players" (template.blade.php:7-14) ----------------
     Both buttons open the shared compose modal by DEEP-LINK: GET /messages/newMessage/
     ?send_type=reportplayers&recipients=<comma-joined player ids>, whose newMessageForm
     (MessagesController L577-581) pre-populates users_aviable[] from that csv; sending
     posts to /messages/sendNewMessage/. This build's Messages screen has a compose modal,
     but it is opened from that page's own internal state on the Sent tab and has no
     deep-link entry — nothing here can hand it a recipient set. Navigating there anyway
     would drop the very thing these buttons exist for (the selection) while looking like
     it carried, so both controls stay visible and documented but are rendered DISABLED
     with the endpoint they need, rather than firing a toast describing a compose modal
     that never opened. The row checkboxes keep working — the selected count still shows
     on the second button, so the selection remains visible and meaningful. */
  const selCount = Object.keys(sel).filter(k => sel[k]).length;
  const HRPL_MSG_HINT = "Not wired in this prototype — needs the compose deep-link: GET /messages/newMessage/?send_type=reportplayers&recipients=<comma-joined player ids> → POST /messages/sendNewMessage/";
  const msgBtnStyle = { background: "#3a3f4b", opacity: .45, cursor: "not-allowed" };

  /* ---------------- table columns (template.blade.php display order) ---------------- */
  const columns = [
    { key: "username", label: "Username", render: r => (
        <a className="rpt-user" style={{ textDecoration: "none" }}
          href={(window.pathForActive && window.pathForActive("host-players")) || "/players"}
          title={`Opens the Players screen. On the real platform this opens ${r.username}'s detail page (admin.players.show · GET /players/${r.id}/) in a new tab — this build's Players list has no per-player deep link, so it opens the list.`}
          onClick={(e) => { e.preventDefault(); hrplGoPlayers(); }}>
          {r.username} <Icon name="external" size={11} className="chev" />
        </a>
      ) },
    { key: "sel", label: "", width: 36, align: "center", render: r => (
        <input type="checkbox" checked={!!sel[r.id]} aria-label={`Select ${r.username}`}
          onChange={() => setSel(m => Object.assign({}, m, { [r.id]: !m[r.id] }))} />
      ) },
    { key: "count", label: secMeta.countLabel, align: "right", render: r => hrsInt(r.count) },
    { key: "bet", label: "Bet", align: "right", render: r => hrsMoney(r.bet, r.currency) },
    { key: "win", label: "Win", align: "right", render: r => hrsMoney(r.win, r.currency) },
    /* "—", never 0.00. Bet tax and win tax have no column anywhere in this
       schema; a zero would be a claim that no tax was charged on these bets,
       which is a different statement from "this build does not track it". */
    { key: "betTax", label: "Bet Tax", align: "right", hidden: !secMeta.tax,
      render: r => (r.betTax == null ? "—" : hrsMoney(r.betTax, r.currency)) },  // label inferred: sport.bet_tax
    { key: "winTax", label: "Win Tax", align: "right", hidden: !secMeta.tax,
      render: r => (r.winTax == null ? "—" : hrsMoney(r.winTax, r.currency)) }, // label inferred: sport.win_tax
    /* Profit header renders for every section here — on the real page it is
       suppressed for section=all while the data cell still renders (see quirk). */
    { key: "profit", label: "Profit", align: "right", render: r => hrsMoney(r.profit, r.currency),
      cellClass: r => r.profit > 0 ? "hrs-pos" : r.profit < 0 ? "hrs-neg" : "" }, // green >0 / red <0, exactly like template.blade.php
  ];

  const totalsRows = report ? report.totals.map(t => ({
    _label: report.converted ? "Total Converted" : (report.totals.length > 1 ? `Totals · ${t.cur}` : "Totals"),
    _variant: report.converted ? "muted" : "dark",
    count: hrsInt(t.count),
    bet: hrsMoney(t.bet, t.cur),
    win: hrsMoney(t.win, t.cur),
    betTax: t.betTax == null ? "—" : hrsMoney(t.betTax, t.cur),
    winTax: t.winTax == null ? "—" : hrsMoney(t.winTax, t.cur),
    profit: (
      <span style={{ fontWeight: 700, color: report.converted
        ? (t.profit > 0 ? "var(--ok-600)" : t.profit < 0 ? "var(--err-600)" : undefined)
        : (t.profit > 0 ? "#7ee2a8" : t.profit < 0 ? "#ffa2a2" : undefined) }}>
        {hrsMoney(t.profit, t.cur)}
      </span>
    ),
  })) : [];

  /* ---------------- export (admin.reports.players.data, action=excel) ---------------- */
  const exportRows = scope === "current" ? pageRows : allRows;
  const csvHeaders = [
    { key: "username", label: "Username" },
    { key: "count", label: secMeta.countLabel },
    { key: "bet", label: "Bet", get: r => r.bet.toFixed(2) },
    { key: "win", label: "Win", get: r => r.win.toFixed(2) },
  ].concat(secMeta.tax ? [
    /* Exported as EMPTY, not "0.00". A spreadsheet column of zeros is a tax
       return; an empty one is a column this build does not fill. */
    { key: "betTax", label: "Bet Tax", get: r => (r.betTax == null ? "" : r.betTax.toFixed(2)) },
    { key: "winTax", label: "Win Tax", get: r => (r.winTax == null ? "" : r.winTax.toFixed(2)) },
  ] : []).concat([
    { key: "profit", label: "Profit", get: r => r.profit.toFixed(2) },
    { key: "currency", label: "Currency" },
  ]);

  const banner = report
    ? <>Selected period <b>{hrplDMY(report.bounds.from)} 00:00:00 - {hrplDMY(report.bounds.to)} 23:59:59</b>
        {report.dropped > 0 && <>
          {" · "}<span className="hrs-neg">{hrsInt(report.dropped)} row(s) left out of the conversion</span>
          {" — no exchange rate for their currency. They are omitted rather than "}
          {"carried at face value under the target's name."}
        </>}
      </>
    : "Set the filters and press Search — the report never auto-loads.";

  return (
    <HrsShell
      title="Players Report"
      gate={["support_report", "support_report_players"]}
      gateNote={<>{" "}Gates bind Customer Care only — the controller 403s a CC user missing either one via the deliberate <code>authorize('asdasdas')</code> hack; every other role passes. Known leak: when the Parent filter is set, the per-user check reads <code>support_report_daily_report</code> instead of this screen's own permission (copy-pasted from Daily Report), so a CC user holding only the Daily-report grant can still scope this report by Parent. SHOP-level users reach the same URL through an ungated sidebar entry labelled "Players".</>}
      explainer={{ bullets: [
        <>One row per player: <code>players_report</code> facts summed over the chosen period for the chosen Section (and Providers), joined to <code>users</code>.</>,
        <>Results are always scoped to your branch of the hierarchy (<code>users.user_path</code>) and your skins; the Parent filter narrows to that user's subtree.</>,
        <>Totals are computed over <b>all</b> pages, one row per currency; Cumulable converts every figure to the selected currency and shows a single "Total Converted" row instead.</>,
        <>Tick rows to build a recipient set for the bulk "message to players" compose (the same New-message form as Host → Messages). Both send buttons are <b>disabled in this prototype</b> — the compose is reached by a recipients deep-link the Messages screen here does not accept yet.</>,
      ] }}
    >
      <HrsFilters
        fields={fields}
        values={draft}
        onChange={onDraft}
        onSearch={doSearch}
        onReset={doReset}
        resultLabel={applied ? `${hrsInt(total)} players` : "—"}
      />

      <HrsSection
        title="Results"
        sub={banner}
        actions={<>
          {/* label inferred: backend.send_message — see the block comment above
              for why these two are disabled rather than toasting. */}
          <button className="hrs-btn hrs-btn--search" disabled aria-disabled="true"
            style={msgBtnStyle} title={HRPL_MSG_HINT}>
            <Icon name="message_square" size={13} /> Send to users on the page
          </button>
          <button className="hrs-btn hrs-btn--search" disabled aria-disabled="true"
            style={msgBtnStyle} title={HRPL_MSG_HINT}>
            <Icon name="message_square" size={13} /> Send to selected users{selCount ? ` (${selCount})` : ""}
          </button>
          <Tip size={12}>Both bulk-message buttons are disabled in this prototype. The real ones open the shared compose modal by deep-link — <code>GET /messages/newMessage/?send_type=reportplayers&amp;recipients=&lt;comma-joined ids&gt;</code>, which pre-fills the recipient select from that csv, then <code>POST /messages/sendNewMessage/</code>. This build's Messages screen has a compose modal but no deep-link entry, so the recipient set cannot be handed over; ticking rows still works and the count stays visible on the second button.</Tip>
        </>}
      >
        {/* THE THREE SECTIONS THE LEDGER CANNOT SPLIT. Casino Live, Virtual and
            Jackpot are `g_cid` distinctions upstream; here every casino stake
            carries vertical='casino', so serving them would mean showing
            casino's numbers under three other headings. UNCLEAR-13. */}
        {unserved && (
          <HrsEmpty>
            {unserved} cannot be reported separately in this build: a ledger entry carries
            vertical <code>casino</code> / <code>sport</code> / <code>exchange</code>, and the
            split isystem makes with the game&rsquo;s <code>g_cid</code> has no column here.
            Showing casino&rsquo;s figures under this heading would be the wrong answer rather
            than a missing one.
            {/* <!-- SUGGESTION: carry game_category_id on ledger_entries (or join through the wager) so report_type_class can classify Casino Live, Virtual and Jackpot separately. Three report sections and two Daily-report tabs depend on it. --> */}
          </HrsEmpty>
        )}
        {!unserved && applied && feed.loading && <HrsSkeleton rows={8} cols={6} />}
        {!unserved && applied && !feed.loading && feed.error && <HrsError error={feed.error} onRetry={feed.retry} />}
        {!unserved && !(applied && (feed.loading || feed.error)) && (
        <HrsTable
          columns={columns}
          rows={pageRows}
          rowKey="id"
          totals={totalsRows}
          empty="Choose a period and press Search to load the report — the real page never auto-loads."
          renderCard={r => <>
            <div className="hrs-card__top">
              <label style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
                <input type="checkbox" checked={!!sel[r.id]} aria-label={`Select ${r.username}`}
                  onChange={() => setSel(m => Object.assign({}, m, { [r.id]: !m[r.id] }))} />
                <b style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{r.username}</b>
              </label>
              <span className={r.profit > 0 ? "hrs-pos" : r.profit < 0 ? "hrs-neg" : ""} style={{ padding: "2px 8px", borderRadius: 6, whiteSpace: "nowrap" }}>
                {hrsMoney(r.profit, r.currency)}
              </span>
            </div>
            <div className="hrs-card__grid">
              <span>{secMeta.countLabel}</span><b>{hrsInt(r.count)}</b>
              <span>Bet</span><b>{hrsMoney(r.bet, r.currency)}</b>
              <span>Win</span><b>{hrsMoney(r.win, r.currency)}</b>
              {secMeta.tax && <React.Fragment>
                <span>Bet Tax</span><b>{r.betTax == null ? "—" : hrsMoney(r.betTax, r.currency)}</b>
                <span>Win Tax</span><b>{r.winTax == null ? "—" : hrsMoney(r.winTax, r.currency)}</b>
              </React.Fragment>}
            </div>
          </>}
        />
        )}

        {applied && !unserved && (
          /* Real pager: paginazioneRecord(...) with 'playerReportChangePage',
             hidden when total ≤ per-page; count = COUNT(DISTINCT player_id).
             "Results per Page" lives in the filter bar (per_page), so the
             pager's own page-size select is omitted. */
          <HrsPager page={page} pageSize={perPage} total={total}
            onPage={(n) => { setPage(n); setSel({}); }} />
        )}

        <div style={{ display: "flex", alignItems: "flex-start", gap: 18, flexWrap: "wrap", marginTop: 12 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 8, paddingTop: 8 }}>
            <span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-tertiary)", whiteSpace: "nowrap" }}>Export scope</span>
            <select className="hrs-fctl" style={{ width: 140 }} value={scope} onChange={e => setScope(e.target.value)}>
              <option value="current">Current Page</option>
              <option value="all">All Pages</option>
            </select>
            <Tip size={12}>On the real page both options ship a <code>selected</code> attribute (index.blade.php:256-257), so the browser lands on All Pages — implemented here as a proper All Pages default.</Tip>
          </div>
          <div style={{ flex: 1, minWidth: 280 }}>
            <HrsExport
              count={exportRows.length}
              twoPhase
              filename="players_report.csv"
              gate="support_export"
              note={<>Real export is XLSX (Spout) via the same getReport route with <code>action=excel</code><Tip size={12}>≤10,000 records stream synchronously (deleteFileAfterSend); above that a queued GenericExport job emails the download link — to the skin's <code>documents_email</code>, the customer-care user's own email, or the hardcoded <code>random.nbr@gmail.com</code> fallback when user_level 0 / no email (Player.php:784-795). The Excel path re-runs the builder SQL as a subquery in 100-row chunks.</Tip></>}
              onCsv={() => hrsCsv(exportRows, csvHeaders, "players_report.csv")}
            />
          </div>
        </div>
      </HrsSection>
    </HrsShell>
  );
};

window.PlayersReport = PlayersReport;
