// 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: admin.reports.bet.index · ReportsController — see docs/ISYSTEM_REFERENCE.md §Batch 2 "Bet report"
/* ====================================================================
   BET REPORT ("Betting") — Report ▾ rebuild (Batch 2), Hrs* shell
   ====================================================================
   Real screen: `GET /reports/bet/` (admin.reports.bet.index, routes/admin.php
   L1548) → ReportsController::bet (L2599). Data: GET /reports/bet/getBetReport
   (admin.reports.bet.data) → ::getBetReport (L3158). Drill-down: GET
   /reports/bet/getBetReportSubLevel (admin.reports.bet.level) →
   ::getBetReportSubLevel (L3318). Export: POST /reports/bet/excel
   (admin.reports.bet.export) → ::excelExportBetsReport (L3761). Blades
   admin/reports/bet/{index,bet,sublevel,table_settings}.blade.php, JS driver
   public/js/pages/reports/bet.js. Sidebar label `backend.bet_report` →
   "Betting" (public/default-lang/en/backend.php:317).

   One row per direct network child of the selected Parent (getChildUsers with
   $yshop = 1, so SHOP rows included). Per-row stats = getStatsSport('all'|
   'open') on CouponMongoReadonly: days < today from the pre-aggregated
   business_report / players_report tables (provider 101, reports_multiplier
   applied), today merged live from Mongo coupons. Rows with nr_ticket == 0
   are skipped (bet.blade.php L54 — the reference documents the skip for the
   top level only; applied at every depth here for consistency).

   Faithful absences (nothing added — brief §3):
   - NO sorting: `orderdir` / `column` are read (L3169-3170) and never used —
     no sortable headers here.
   - NO pagination: `page` read (L3172) and unused; the whole user level
     renders in one table.
   - NO KPIs / IN-OUT-NET bars, NO bulk actions (the newMessage modal is
     included by the blade but nothing on this page triggers it).
   - Dead request params custom_range / section (+ bet.js's tab /
     filter_player_id which the controller never reads) are not represented.
   - MySQL fallback path (CASINO_DB != 'mongodb') never selects live_bet /
     live_win / bet_tax / win_tax / profit (Coupon.php L468-483) — this
     rebuild models the Mongo production path where all 15 columns work.

   Divergences implemented as evident intent (known-bug policy, CLAUDE.md):
   1. Export — the real button posts CLIENT-SCRAPED DOM spans (user_levels,
      total_bets, …) to a Route::any endpoint with NO server-side permission
      check and no re-query; the server just lays the arrays into
      PhpSpreadsheet (betting_report.xlsx) and always deletes its Currency
      column (index 5, L3891). Rebuilt as an export of the applied dataset
      from state (CSV via the shared shell), honoring the Setting modal's
      hidden columns like the real hidden_cols post. The Currency column is
      KEPT — rows in a multi-currency network are ambiguous without it.
      // <!-- SUGGESTION: make /reports/bet/excel re-run the report query
      //      server-side with the same permission checks as getBetReport
      //      instead of formatting whatever the client posts — and stop
      //      deleting the Currency column it just built. -->
   2. Reset — the real Reset clears skin/user-type/username and sets Parent
      to self, but then flips the Period radio to MONTH ($('#periodo_mese_
      label').click(), index.blade.php L956-965), not back to the page-load
      default (Range/Today). Rebuilt as restore-page-load-defaults.
      // <!-- SUGGESTION: make Reset restore the page-load default period
      //      (Range/Today) instead of jumping to the Month radio. -->
   3. Drill-down affordance — real: the username cell itself carries
      onclick="elenco_utenti_sottostanti(…)" and is a link only when
      getCountUsers > 0. The shell's HrsTable exposes drill-down as a per-row
      expander chevron (table-wide); leaf rows explain "no sub-users" when
      expanded instead of suppressing the affordance. Visual-only divergence.
   4. The Excel is_conversion_line purple-highlight logic (L3832) expects
      duplicated user rows that current blades never render — not represented.

   Sub-level mechanics honored: unbounded recursion; each level fetched with
   only parent_id + the already-resolved start/end + currency/cumulate — the
   skin / user-type / username filters do NOT apply below the top level; SHOP
   rows included ($yshop = 1); no totals row on sub-tables; `type` is always
   the literal 'all' and `level` only ever feeds a CSS class (bet.js always
   sends 0). Also noted, not represented: the blade's `$period == "mese"` /
   `"anno"` pre-check comparisons can never match the controller's "range";
   $shops_rows (L3295) is computed and never passed to the view; the
   collection whereIn at L3292 is a no-op (harmless — getChildUsers already
   constrains skins at SQL level).

   Column settings: the real "Setting" modal (table_settings.blade.php)
   persists to localStorage['betting_report_table_settings'] and is honored
   by both display and export. Rebuilt with the same 15-column list, honored
   by display + export; kept session-local (prototype convention — no
   persistence), noted in the modal footer.

   Labels: `sport.total_bet_tax`, `sport.total_won_tax` and `backend.payout`
   resolve in no committed lang file (storage/lang is gitignored) and would
   render as raw keys on a vanilla checkout — operator labels inferred below
   and marked. "Payout" actually shows margin (profit / total bet × 100).

   Demo session = Super admin "admin" (level 0, id 1), currency ARS —
   matching the other Host screens' persona. All new top-level names are
   hrbet/Hrbet/HRBET-prefixed except the required page component `Betting`
   (app.jsx case "report-betting"; this file loads after HostReports.jsx so
   this definition wins over the legacy stub).
   ==================================================================== */

const { useState: hrbetUseState, useMemo: hrbetUseMemo } = React;

/* ---------- enums ----------
   usersLevels() as documented for THIS screen (ISYSTEM_REFERENCE §Betting
   "Enums"): 0 Super Admin · 2 Skin Access · 8 Agent · 9 Regulation User ·
   10 Promoter · 15 Shop · 20 Cashier · 30 Player. The unlabeled first table
   column shows the first letter of the level name. */
const HRBET_LEVELS = { 0: "Super Admin", 2: "Skin Access", 8: "Agent", 9: "Regulation User", 10: "Promoter", 15: "Shop", 20: "Cashier", 30: "Player" };
/* User Type filter = User::getLevel() minus CUSTOMER_CARE(4) / ADMINISTRATION(6)
   / AFFILIATE(1) / PLAYER(30). Names may be overridden per skin via the
   custom_*_name skin settings for non-admin viewers (noted, not mocked). */
const HRBET_USER_TYPES = [[0, "Super Admin"], [2, "Skin Access"], [8, "Agent"], [10, "Promoter"], [15, "Shop"], [20, "Cashier"]];
/* range select — 1..6 (ReportsController fallback default 1 = Today) */
const HRBET_RANGES = [["1", "Today"], ["2", "Yesterday"], ["3", "This week"], ["4", "Previous week"], ["5", "This month"], ["6", "Previous month"]];
/* Currency::distinct()->pluck('currency') — production rows are data, not
   code; mock list = the currencies of the mocked skins + majors. (CLP is
   excluded: its only skin is inactive in the mock and the prototype FX table
   carries no CLP rate.) */
const HRBET_CURRENCIES = ["ARS", "BOB", "EUR", "PYG", "USD"];
/* Auth::user()->getSkins() — mirrors the HostUsers.jsx mock network. */
/* THE ELEVEN INVENTED BRANDS AND THE PRNG THAT DROVE THEM ARE GONE — the brand
   filter is a `skins` feed now, so it cannot offer a brand this platform does
   not have. */

const hrbetR2 = (v) => Math.round(v * 100) / 100;
const hrbetPad = (n) => String(n).padStart(2, "0");
const hrbetIso = (d) => `${d.getFullYear()}-${hrbetPad(d.getMonth() + 1)}-${hrbetPad(d.getDate())}`;

/* THE INVENTED NETWORK AND EVERY TICKET IN IT ARE GONE. `hrbetBuildNet` grew a
   whole hierarchy from a PRNG and `hrbetLeafStats` gave each leaf a stake, a
   win, a ticket count, a singles count, an open stake and a live split — plus
   a two-rate "taxed market" for one invented brand, so the tax columns had
   something to show.

   `sport_coupons` (008) is the real thing and carries every column this report
   prints: stake, stake_tax, payout_tax, net_payout, selection_count,
   status_code and coupon_type. Nothing here is derived from a fraction of
   something else.

   FOUR COLUMNS, FOUR REAL PREDICATES — the ones that used to be a multiple of
   the total times a random:
     open      stake of tickets still PENDING            status_code = 'N'
     single    tickets with one selection                selection_count = 1
     liveBet   stake on live and live+prematch tickets   coupon_type in ('L','M')
     liveWin   net payout on the same                    coupon_type in ('L','M')
   'M' is Live & Prematch and is counted as live, because a ticket containing a
   live selection is exposed to live risk — excluding it would understate the
   column it exists to measure.

   WIN TAX IS SUMMED ON WINNING TICKETS ONLY, which is what the header says and
   what upstream does: a lost ticket has no payout to tax, and summing the
   column across every row would report tax on money nobody received. */

const HRBET_LIVE_TYPES = ["L", "M"];

const hrbetZero = () => ({ bet: 0, betTax: 0, winTax: 0, won: 0, profit: 0, nr: 0, single: 0, open: 0, liveBet: 0, liveWin: 0 });

/* One accumulator per user id, from the coupons themselves. */
const hrbetFoldCoupons = (coupons) => {
  const by = {};
  (coupons || []).forEach(c => {
    const uid = Number(c.user_id);
    const a = by[uid] || (by[uid] = hrbetZero());
    const stake = Number(c.stake) || 0;
    /* NET payout — what the player actually receives. gross_payout differs by
       payout_tax, and a column headed "Won" carrying the pre-tax figure
       overstates every settled ticket. */
    const net = Number(c.net_payout) || 0;
    a.bet += stake;
    a.betTax += Number(c.stake_tax) || 0;
    a.nr += 1;
    if (Number(c.selection_count) === 1) a.single += 1;
    if (c.status_code === "N") a.open += stake;
    if (c.status_code === "W") {
      a.won += net;
      a.winTax += Number(c.payout_tax) || 0;
    }
    if (HRBET_LIVE_TYPES.indexOf(c.coupon_type) >= 0) {
      a.liveBet += stake;
      if (c.status_code === "W") a.liveWin += net;
    }
  });
  Object.keys(by).forEach(k => {
    const a = by[k];
    Object.keys(a).forEach(f => { a[f] = hrbetR2(a[f]); });
    /* The default formula: giocato − bet_tax − vinto − win_tax. A skin setting
       (`profit_formula = no_sub_win_tax`) skips the last term upstream; that
       setting has no column here, so the default is used and this note is what
       stops it looking like the only formula there is. */
    a.profit = hrbetR2(a.bet - a.betTax - a.won - a.winTax);
  });
  return by;
};

/* Roll the per-user totals UP the tree: every ancestor row is the sum of its
   subtree, which is what getStatsSport's network aggregates are. Paths do the
   containment so the client never guesses the shape. */
const hrbetRollUp = (users, perUser) => {
  const nodes = (users || []).map(u => ({
    id: Number(u.id), username: u.username, lvl: Number(u.user_level),
    path: String(u.path || ""), cur: u.currency || (u.skin ? u.skin.currency : "") || "",
    skin: u.skin ? u.skin.name : "", children: [],
  }));
  const byId = {};
  nodes.forEach(n => { byId[n.id] = n; });
  nodes.forEach(n => {
    const parent = nodes.find(x => x.path !== n.path && n.path.indexOf(x.path + ".") === 0
                              && n.path.split(".").length === x.path.split(".").length + 1);
    if (parent) parent.children.push(n);
  });
  const statsById = {};
  const walk = (n) => {
    const acc = hrbetZero();
    const own = perUser[n.id];
    if (own) Object.keys(acc).forEach(k => { acc[k] += own[k]; });
    n.children.forEach(c => { const s = walk(c); Object.keys(acc).forEach(k => { acc[k] += s[k]; }); });
    Object.keys(acc).forEach(k => { acc[k] = hrbetR2(acc[k]); });
    /* Profit is RECOMPUTED at every level rather than summed, so the identity
       bet − betTax − won − winTax holds on an ancestor row too. Summing a
       derived column and summing its inputs give the same answer here, but only
       because every term is linear — recomputing says so on purpose. */
    acc.profit = hrbetR2(acc.bet - acc.betTax - acc.won - acc.winTax);
    statsById[n.id] = acc;
    return acc;
  };
  const roots = nodes.filter(n => !nodes.some(x => x !== n && n.path.indexOf(x.path + ".") === 0));
  roots.forEach(walk);
  return { nodes, byId, statsById, roots };
};

const hrbetMapNode = (n, statsById) => ({
    // EMBED-OK: `n` is a mapped node — `skin` is a name string, not the embedded skin row.
  node: n, id: n.id, username: n.username, lvl: n.lvl, cur: n.cur, skin: n.skin,
  subCount: n.children.length, ...(statsById[n.id] || hrbetZero()),
});
/* nr_ticket == 0 rows skipped (bet.blade.php L54). */
const hrbetRowsFor = (node, statsById) => node.children.map((n) => hrbetMapNode(n, statsById)).filter((r) => r.nr > 0);

/* ---------- period resolution (dates the controller derives server-side) ---------- */
const hrbetRangeDates = (code) => {
  const t = new Date();
  const monOff = (t.getDay() + 6) % 7;
  const shift = (d, days) => { const x = new Date(d); x.setDate(x.getDate() + days); return x; };
  switch (String(code)) {
    case "2": { const y = shift(t, -1); return [hrbetIso(y), hrbetIso(y)]; }
    case "3": return [hrbetIso(shift(t, -monOff)), hrbetIso(t)];
    case "4": { const m = shift(t, -monOff - 7); return [hrbetIso(m), hrbetIso(shift(m, 6))]; }
    case "5": return [hrbetIso(new Date(t.getFullYear(), t.getMonth(), 1)), hrbetIso(new Date(t.getFullYear(), t.getMonth() + 1, 0))];
    case "6": return [hrbetIso(new Date(t.getFullYear(), t.getMonth() - 1, 1)), hrbetIso(new Date(t.getFullYear(), t.getMonth(), 0))];
    default: return [hrbetIso(t), hrbetIso(t)]; // 1 = Today (controller fallback)
  }
};
const hrbetResolvePeriod = (v) => {
  if (v.periodMode === "periodo_mese") { const p = String(v.month || "").split("|"); return [p[0] || "", p[1] || ""]; }
  if (v.periodMode === "periodo_anno") return [`${v.year}-01-01`, `${v.year}-12-31`];
  if (v.periodMode === "custom_range") return [(v.custom || {}).from || "", (v.custom || {}).to || ""];
  return hrbetRangeDates(v.range);
};

/* ---------- page-load defaults (bet() L2606-2607: custom pickers prefilled
   1st / last day of current month; range default Today; month default = the
   commission period containing today; currency = auth user's own — persona
   "admin", ARS). getDateCalendario() years run 2020 → now. ---------- */
const hrbetDefaults = () => {
  const t = new Date();
  return {
    skin: "", usertype: "", parent: "1", username: "",
    periodMode: "range", range: "1",
    month: (hrsPeriodOptions("commission", 2020)[0] || {}).value || "",
    year: String(t.getFullYear()),
    custom: { from: hrbetIso(new Date(t.getFullYear(), t.getMonth(), 1)), to: hrbetIso(new Date(t.getFullYear(), t.getMonth() + 1, 0)), fromTime: "", toTime: "" },
    currency: "ARS", cumulate: false,
  };
};
const HRBET_DEFAULTS = hrbetDefaults();

/* ---------- the 15 table columns (display order, bet.blade.php L11-25) ----------
   Also drives the "Setting" modal list (table_settings.blade.php matches the
   same 15 columns) and the export's hidden_cols behavior. */
const HRBET_COL_META = [
  ["lvl", "User type"],          // unlabeled letter column — settings-list label inferred
  ["id", "ID"],
  ["username", "Username"],
  ["bet", "Total bet"],
  ["betTax", "Total Bet Tax"],   // label inferred — sport.total_bet_tax resolves to no committed translation
  ["winTax", "Total Won Tax"],   // label inferred — sport.total_won_tax resolves to no committed translation
  ["won", "Total Won"],
  ["profit", "Net profit"],      // backend.sport_prof_type_net_profit
  ["payout", "Payout"],          // label inferred — backend.payout unresolved; the value is actually margin
  ["avg", "Average bet"],
  ["nr", "Number bets"],
  ["single", "Single Bets"],
  ["open", "Open bets"],
  ["liveBet", "Total Bet Live"],
  ["liveWin", "Total Won Live"],
];
const HRBET_MONEY_KEYS = ["bet", "betTax", "winTax", "won", "profit", "avg", "open", "liveBet", "liveWin"];

const hrbetColumns = (hidden, cumulate, selCur) => {
  const dispCur = (r) => (cumulate ? selCur : r.cur);
  const amt = (v, r) => (cumulate ? fxConvert(v, r.cur, selCur) : v); // CurrencyConverter::convert — nearest-dated rate rows
  const M = (v, r) => hrsMoney(amt(v, r), dispCur(r));
  const label = Object.fromEntries(HRBET_COL_META);
  return [
    { key: "lvl", label: "", width: 40, align: "center", hidden: !!hidden.lvl,
      render: (r) => <span className={`hrbet-lvl hrbet-lvl--${r.lvl}`} title={HRBET_LEVELS[r.lvl]}>{(HRBET_LEVELS[r.lvl] || "?")[0]}</span> },
    { key: "id", label: label.id, hidden: !!hidden.id },
    { key: "username", label: label.username, hidden: !!hidden.username,
      render: (r) => (
        <span className="hrbet-user">
          <b>{r.username}</b>
          {r.subCount > 0 && <span className="hrbet-user__sub" title="Has sub-users — expand the row to drill into its sub-network (real screen: the username itself is the drill-down link)">{r.subCount} sub</span>}
        </span>
      ) },
    { key: "bet", label: <>{label.bet}<Tip size={12}>giocato — days before today from business_report (SUM bet), today live from Mongo coupons (SUM ticketbet).</Tip></>, align: "right", hidden: !!hidden.bet, render: (r) => M(r.bet, r) },
    { key: "betTax", label: <>{label.betTax}<Tip size={12}>Header key sport.total_bet_tax resolves to no committed translation (storage/lang is gitignored) — label inferred.</Tip></>, align: "right", hidden: !!hidden.betTax, render: (r) => M(r.betTax, r) },
    { key: "winTax", label: <>{label.winTax}<Tip size={12}>Header key sport.total_won_tax resolves nowhere — label inferred. Summed only where ticketstatus = 'W'.</Tip></>, align: "right", hidden: !!hidden.winTax, render: (r) => M(r.winTax, r) },
    { key: "won", label: label.won, align: "right", hidden: !!hidden.won, render: (r) => M(r.won, r) },
    { key: "profit", label: label.profit, align: "right", hidden: !!hidden.profit,
      cellClass: (r) => (r.profit >= 0 ? "hrs-pos" : "hrs-neg"), render: (r) => M(r.profit, r) },
    { key: "payout", label: <>{label.payout}<Tip size={12}>backend.payout resolves in no committed lang file (label inferred) — and the value is margin (Net profit ÷ Total bet × 100), not a payout ratio.</Tip></>, align: "right", hidden: !!hidden.payout, render: (r) => hrsPct(r.bet ? (r.profit / r.bet) * 100 : 0) },
    { key: "avg", label: label.avg, align: "right", hidden: !!hidden.avg, render: (r) => M(r.nr ? r.bet / r.nr : 0, r) },
    { key: "nr", label: label.nr, align: "right", hidden: !!hidden.nr, render: (r) => hrsInt(r.nr) },
    { key: "single", label: <>{label.single}<Tip size={12}>Tickets with count_items = 1.</Tip></>, align: "right", hidden: !!hidden.single, render: (r) => hrsInt(r.single) },
    { key: "open", label: <>{label.open}<Tip size={12}>Stake of still-open tickets (status 'N') — a second getStatsSport('open') call per row.</Tip></>, align: "right", hidden: !!hidden.open, render: (r) => M(r.open, r) },
    { key: "liveBet", label: label.liveBet, align: "right", hidden: !!hidden.liveBet, render: (r) => M(r.liveBet, r) },
    { key: "liveWin", label: label.liveWin, align: "right", hidden: !!hidden.liveWin, render: (r) => M(r.liveWin, r) },
  ];
};

/* ---------- totals (bet.blade.php L240-363) ----------
   Non-cumulate: one dark Totals row PER CURRENCY in the result; margin and
   average bet are recomputed from the summed figures. Cumulate: a single
   grey "Total Converted" row in the selected currency. ($totals[0] — the
   converted grand total — is accumulated in BOTH modes on the real screen
   but displayed only under cumulate; not shown here either.) */
const hrbetTotals = (rows, cumulate, selCur) => {
  const sum = (rs, conv) => rs.reduce((a, r) => {
    const f = conv ? (v) => fxConvert(v, r.cur, selCur) : (v) => v;
    HRBET_MONEY_KEYS.forEach((k) => { if (k !== "avg") a[k] += f(r[k]); });
    a.nr += r.nr; a.single += r.single;
    return a;
  }, hrbetZero());
  const fmt = (s, cur, _label, _variant) => ({
    _label, _variant,
    bet: hrsMoney(s.bet, cur), betTax: hrsMoney(s.betTax, cur), winTax: hrsMoney(s.winTax, cur),
    won: hrsMoney(s.won, cur), profit: hrsMoney(s.profit, cur),
    payout: hrsPct(s.bet ? (s.profit / s.bet) * 100 : 0),
    avg: hrsMoney(s.nr ? s.bet / s.nr : 0, cur),
    nr: hrsInt(s.nr), single: hrsInt(s.single),
    open: hrsMoney(s.open, cur), liveBet: hrsMoney(s.liveBet, cur), liveWin: hrsMoney(s.liveWin, cur),
  });
  if (!rows.length) return [];
  if (cumulate) return [fmt(sum(rows, true), selCur, "Total Converted", "muted")];
  const curs = [...new Set(rows.map((r) => r.cur))];
  return curs.map((c) => fmt(sum(rows.filter((r) => r.cur === c), false), c, curs.length > 1 ? `Totals · ${c}` : "Totals", "dark"));
};

/* ==================================================================
   Sub-level drill-down (getBetReportSubLevel → sublevel.blade.php):
   same 15 columns, NO totals row, unbounded recursion. Only the searched
   period + currency/cumulate cascade — skin / user-type / username filters
   do not apply below the top level, and SHOP rows are included.
   ================================================================== */
const HrbetSubnet = ({ node, statsById, cumulate, selCur, hidden, depth }) => {
  const rows = hrbetRowsFor(node, statsById);
  if (!rows.length) return <div className="hrbet-subempty">No sport activity in this sub-network for the searched period.</div>;
  return (
    <div className="hrbet-sub">
      <div className="hrbet-subnote">
        Sub-network of <b>{node.username}</b> — depth {depth}
        <Tip size={12}>
          Real endpoint: GET /reports/bet/getBetReportSubLevel with parent_id + the already-resolved start/end + currency/cumulate.
          Skin, User Type and Username filters do NOT filter sub-levels; SHOP rows are included; recursion is unbounded.
          The level param only feeds a CSS class — bet.js always sends 0.
        </Tip>
      </div>
      <HrsTable
        dense
        columns={hrbetColumns(hidden, cumulate, selCur)}
        rows={rows}
        rowKey="id"
        rowDetail={(r) => r.subCount > 0
          ? <HrbetSubnet node={r.node} statsById={statsById} cumulate={cumulate} selCur={selCur} hidden={hidden} depth={depth + 1} />
          : <div className="hrbet-subempty">No sub-users below this {HRBET_LEVELS[r.lvl]} — on the real screen the username simply is not a drill-down link (getCountUsers = 0).</div>}
      />
    </div>
  );
};

/* Mobile stacked-card twin of the drill-down (brief §11 — nested tables do
   not survive narrow viewports; recursion continues through <details>). */
const HrbetSubCards = ({ node, statsById, cumulate, selCur, depth }) => {
  const rows = hrbetRowsFor(node, statsById);
  if (!rows.length) return <div className="hrbet-subempty">No sport activity in this sub-network for the searched period.</div>;
  return (
    <div className="hrbet-subcards">
      {rows.map((r) => {
        const cur = cumulate ? selCur : r.cur;
        const v = (x) => hrsMoney(cumulate ? fxConvert(x, r.cur, selCur) : x, cur);
        return (
          <div key={r.id} className="hrbet-subcard">
            <div className="hrbet-subcard__top">
              <span className={`hrbet-lvl hrbet-lvl--${r.lvl}`} title={HRBET_LEVELS[r.lvl]}>{(HRBET_LEVELS[r.lvl] || "?")[0]}</span>
              <b>{r.username}</b>
              <span className={`hrbet-subcard__profit ${r.profit >= 0 ? "hrs-pos" : "hrs-neg"}`}>{v(r.profit)}</span>
            </div>
            <div className="hrbet-subcard__row">Bet {v(r.bet)} · Won {v(r.won)} · {hrsInt(r.nr)} bets</div>
            {r.subCount > 0 && (
              <details className="hrbet-carddet">
                <summary>Sub-network ({r.subCount} direct)</summary>
                <HrbetSubCards node={r.node} statsById={statsById} cumulate={cumulate} selCur={selCur} depth={depth + 1} />
              </details>
            )}
          </div>
        );
      })}
    </div>
  );
};

/* ---------- "Setting" column-visibility modal (table_settings.blade.php) ---------- */
const HrbetColsModal = ({ open, hidden, onToggle, onClose }) => {
  if (!open) return null;
  return (
    <div className="hrbet-modal-scrim" onClick={onClose}>
      <div className="hrbet-modal" onClick={(e) => e.stopPropagation()}>
        <div className="hrbet-modal__head">
          <span><Icon name="settings" size={14} /> Setting — visible columns</span>
          <button className="hrs-x" onClick={onClose} title="Close"><Icon name="x" size={13} /></button>
        </div>
        <div className="hrbet-modal__body">
          {HRBET_COL_META.map(([key, lab]) => (
            <label key={key}>
              <input type="checkbox" checked={!hidden[key]} onChange={() => onToggle(key)} />
              <span>{lab}</span>
            </label>
          ))}
        </div>
        <div className="hrbet-modal__foot">
          <span>
            Honored by the tables (every drill-down level) and the export, like the real hidden_cols post.
            Real platform persists this to localStorage["betting_report_table_settings"]; session-local here.
          </span>
          <button className="hrs-btn hrs-btn--search" onClick={onClose}>Done</button>
        </div>
      </div>
    </div>
  );
};

/* ==================================================================
   Page component — name required by app.jsx ("report-betting").
   Loads after the legacy HostReports.jsx stub, so this wins.
   ================================================================== */
const Betting = () => {
  window.useLocale && window.useLocale();
  const [draft, setDraft] = hrbetUseState(() => ({ ...HRBET_DEFAULTS, custom: { ...HRBET_DEFAULTS.custom } }));
  const [applied, setApplied] = hrbetUseState(null); // null = not searched yet (no-auto-load convention)
  const [hidden, setHidden] = hrbetUseState({});
  const [showCols, setShowCols] = hrbetUseState(false);

  /* Parent options = the real select2 AJAX pool (user_types [0,2,8,10,15]);
     default = the logged-in user (customer care: their parent; disabled for
     affiliates — persona here is the super admin). */
  /* Every option list is a feed. The hierarchy came from a PRNG and included
     "admin (Super Admin)" hardcoded as id 1 — a filter offering an operator
     the server may well not have. RLS scopes these to the caller's subtree,
     which is the same set the coupons come from. */
  const userFeed = useHrsFetch(() => window.sb.list("networkUsers", { limit: 2000 }), []);
  const playerFeed = useHrsFetch(() => window.sb.list("players", { limit: 2000 }), []);
  const skinFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const allUsers = hrbetUseMemo(() => {
    const ops = (userFeed.data || []).map(u => ({
      id: Number(u.id), username: u.username, user_level: Number(u.user_level),
      // EMBED-OK: `u` is a mapped node — `skin` is the skin NAME the mapper flattened out.
      path: String(u.path || ""), currency: u.currency, skin: u.skin,
    }));
    const pls = (playerFeed.data || []).map(u => ({
      id: Number(u.id), username: u.username, user_level: 30,
      // EMBED-OK: `u` is a mapped node — `skin` is the skin NAME the mapper flattened out.
      path: String(u.path || ""), currency: u.currency, skin: u.skin,
    }));
    return ops.concat(pls);
  }, [userFeed.data, playerFeed.data]);

  const parentOpts = hrbetUseMemo(
    () => allUsers.filter((n) => [0, 2, 8, 10, 15].includes(n.user_level))
      .map((n) => ({ value: String(n.id), label: `${n.username} (${HRBET_LEVELS[n.user_level]})` }))
      .sort((a, b) => a.label.localeCompare(b.label)),
    [allUsers]);
  /* Username options: user_types = the chosen User Type or [0,2,8,10,15,20,30],
     parent_id = the chosen Parent (select2 AJAX on the real page). */
  const usernameOpts = hrbetUseMemo(() => {
    const parent = allUsers.find(u => String(u.id) === String(draft.parent));
    const lvls = draft.usertype !== "" ? [Number(draft.usertype)] : [0, 2, 8, 10, 15, 20, 30];
    /* Scoped by ltree containment when a parent is chosen — the same predicate
       the server would use, never a string prefix. */
    return allUsers
      .filter(n => lvls.includes(n.user_level))
      .filter(n => !parent || (n.path !== parent.path && n.path.indexOf(parent.path + ".") === 0))
      .map((n) => ({ value: String(n.id), label: `${n.username} (${HRBET_LEVELS[n.user_level]})` }))
      .sort((a, b) => a.label.localeCompare(b.label));
  }, [allUsers, draft.parent, draft.usertype]);

  const FIELDS = [
    /* Skin select rendered only for isadmin() || isCustomCare() on the real
       page; applied as a post-query collection filter (L3283). */
    { key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "- Select -",
      options: (skinFeed.data || []).map((s) => s.name),
      tip: <>Only rendered for admins / Customer Care on the real page. Applied to the top level only — it does not cascade into drill-downs.</> },
    { key: "usertype", label: "User Type", type: "select", icon: "users", placeholder: "- Select -",
      options: HRBET_USER_TYPES.map(([v, l]) => ({ value: String(v), label: l })),
      tip: <>usersLevels() minus Customer Care(4), Administration(6), Affiliate(1) and Player(30). Role names may be renamed per skin (custom_*_name) for non-admin viewers.</> },
    { key: "parent", label: "Parent", type: "select", icon: "user", options: parentOpts, defaultValue: "1",
      tip: <>select2 AJAX on /users2 (levels 0/2/8/10/15) on the real page; defaults to the logged-in user (Customer Care: their parent). Disabled for Affiliates.</> },
    { key: "username", label: "Username", type: "select", icon: "search", placeholder: "- Select -", options: usernameOpts, grow: true,
      tip: <>When set, getChildUsers switches from "children of Parent" to that single user anywhere in the Parent's subtree (user_path LIKE + id =).</> },
    /* The real form renders the four period modes as radio buttons; a mode
       select is the same control surface. (Blade quirk, noted only: its
       pre-check compares $period == "mese"/"anno" while the controller always
       sends "range", so those radios can never arrive pre-checked.) */
    { key: "periodMode", label: "Period type", type: "select", icon: "calendar", defaultValue: "range",
      options: [{ value: "range", label: "Period" }, { value: "periodo_mese", label: "Month" }, { value: "periodo_anno", label: "Year" }, { value: "custom_range", label: "Custom range" }] },
    { key: "range", label: "Period", type: "select", hidden: draft.periodMode !== "range", defaultValue: "1",
      options: HRBET_RANGES.map(([v, l]) => ({ value: v, label: l })) },
    { key: "month", label: "Month", type: "month-period", mode: "commission", fromYear: 2020, hidden: draft.periodMode !== "periodo_mese", defaultValue: HRBET_DEFAULTS.month,
      tip: <>Commission months — first Monday to the day before the next month's first Monday (getDateCalendario), years 2020 to now. Not calendar months.</> },
    { key: "year", label: "Year", type: "select", hidden: draft.periodMode !== "periodo_anno", defaultValue: HRBET_DEFAULTS.year,
      options: Array.from({ length: new Date().getFullYear() - 2020 }, (_, i) => String(2021 + i)) },
    { key: "custom", label: "Custom range", type: "daterange", hidden: draft.periodMode !== "custom_range", defaultValue: HRBET_DEFAULTS.custom,
      tip: <>Prefilled with the 1st and last day of the current month (bet() L2606-2607). dd/mm/yyyy Italian-locale pickers on the real page.</> },
    { key: "currency", label: "Currency", type: "select", icon: "wallet", options: HRBET_CURRENCIES, defaultValue: "ARS",
      tip: <>Currency::distinct() list; defaults to your own currency (Customer Care: the parent's). Used as the conversion target when Cumulable is on.</> },
    { key: "cumulate", label: "Cumulable", type: "toggle", defaultValue: false,
      tip: <>On: every row is converted into the selected currency (CurrencyConverter, nearest-dated rate per currency) and the per-currency Totals collapse into one Total Converted line.</> },
  ];

  const onChange = (k, v) => setDraft((d) => {
    const next = { ...d, [k]: v };
    /* Changing Parent or User Type invalidates the scoped Username pool. */
    if (k === "parent" || k === "usertype") next.username = "";
    return next;
  });

  const onSearch = (v) => {
    const [start, end] = hrbetResolvePeriod(v);
    if (!start || !end) {
      /* Real guard: empty periodo_mese under period=periodo_mese hits a raw
         die("seleziona le date!") (L3253) — surfaced as a toast instead. */
      hrsToast("Select the dates", "The real controller answers the raw string \"seleziona le date!\" when the Month period is submitted empty.");
      return;
    }
    setApplied({ ...v, start, end });
  };

  /* Reset = restore page-load defaults (evident intent — see header
     divergence 2 for the real flip-to-Month quirk). */
  const onReset = () => {
    setDraft({ ...HRBET_DEFAULTS, custom: { ...HRBET_DEFAULTS.custom } });
    setApplied(null);
  };

  /* THE COUPONS THEMSELVES, for the applied period. Everything on the page is
     folded from these — no figure is a fraction of another. */
  const couponFeed = useHrsFetch(
    () => (applied
      ? window.sb.list("sportCoupons", { limit: 5000, filters: { from: applied.start, to: applied.end + "T23:59:59" } })
      : Promise.resolve({ ok: true, data: [] })),
    [applied && applied.start, applied && applied.end]);

  const tree = hrbetUseMemo(
    () => hrbetRollUp(allUsers, hrbetFoldCoupons(couponFeed.data || [])),
    [allUsers, couponFeed.data]);
  const statsById = applied ? tree.statsById : null;

  const topRows = hrbetUseMemo(() => {
    if (!applied || !statsById) return [];
    const parentNode = tree.byId[Number(applied.parent)]
      || { children: tree.roots, id: null };
    if (applied.username) {
      const hit = tree.byId[Number(applied.username)];
      return hit ? [hrbetMapNode(hit, statsById)].filter((r) => r.nr > 0) : [];
    }
    let rows = hrbetRowsFor(parentNode, statsById);
    if (applied.skin) rows = rows.filter((r) => r.skin === applied.skin);              // collection filter (L3283)
    if (applied.usertype !== "") rows = rows.filter((r) => r.lvl === Number(applied.usertype)); // (L3286)
    return rows;
  }, [applied, statsById, tree]);

  const cumulate = applied ? !!applied.cumulate : false;
  const selCur = applied ? applied.currency || "ARS" : "ARS";
  const columns = hrbetColumns(hidden, cumulate, selCur);
  const totals = hrbetTotals(topRows, cumulate, selCur);

  /* Export = the applied top-level dataset from state (see header divergence
     1 — the real path scrapes the rendered DOM and re-queries nothing).
     Honors the Setting modal's hidden columns; money exported as plain
     2-decimal numbers with an explicit Currency column. */
  const csvHeaders = () => {
    const num = (k) => (r) => (cumulate ? fxConvert(r[k], r.cur, selCur) : r[k]).toFixed(2);
    return [
      !hidden.lvl && { key: "lvl", label: "User type", get: (r) => HRBET_LEVELS[r.lvl] },
      !hidden.id && { key: "id", label: "ID" },
      !hidden.username && { key: "username", label: "Username" },
      { key: "cur", label: "Currency", get: (r) => (cumulate ? selCur : r.cur) },
      !hidden.bet && { key: "bet", label: "Total bet", get: num("bet") },
      !hidden.betTax && { key: "betTax", label: "Total Bet Tax", get: num("betTax") },
      !hidden.winTax && { key: "winTax", label: "Total Won Tax", get: num("winTax") },
      !hidden.won && { key: "won", label: "Total Won", get: num("won") },
      !hidden.profit && { key: "profit", label: "Net profit", get: num("profit") },
      !hidden.payout && { key: "payout", label: "Payout %", get: (r) => (r.bet ? ((r.profit / r.bet) * 100).toFixed(2) : "0.00") },
      !hidden.avg && { key: "avg", label: "Average bet", get: (r) => (cumulate ? fxConvert(r.bet / r.nr, r.cur, selCur) : r.bet / r.nr).toFixed(2) },
      !hidden.nr && { key: "nr", label: "Number bets" },
      !hidden.single && { key: "single", label: "Single Bets" },
      !hidden.open && { key: "open", label: "Open bets", get: num("open") },
      !hidden.liveBet && { key: "liveBet", label: "Total Bet Live", get: num("liveBet") },
      !hidden.liveWin && { key: "liveWin", label: "Total Won Live", get: num("liveWin") },
    ].filter(Boolean);
  };

  return (
    <HrsShell
      title="Betting"
      subtitle="Sport bet report across your network — one row per direct child of the selected Parent, drillable to any depth."
      gate={["support_report", "support_report_betting"]}
      gateNote={<>
        {" "}Both gates bind Customer Care only — every other role passes (403 forced via <code>authorize('asdasdas')</code> in
        bet / getBetReport / getBetReportSubLevel). The per-user branch and the drill-down additionally re-check the stray{" "}
        <code>support_report_daily_report</code> (copy-pasted from the Daily report) plus <code>authorize('view')</code> on the
        target user. The Export button is hidden from Customer Care lacking <code>support_export</code>. SHOP-level users reach
        this same URL from an un-gated sidebar duplicate.
      </>}
      explainer={{
        bullets: [
          <>One row per <b>direct network child</b> of the selected Parent — SHOP rows included (<code>getChildUsers</code> runs with <code>$yshop = 1</code>); users with <b>zero tickets</b> in the period are skipped entirely.</>,
          <>Sport figures only (provider 101): days before today come from the pre-aggregated <code>business_report</code> / <code>players_report</code> tables (×<code>skins.reports_multiplier</code>); <b>today is merged live</b> from Mongo coupons, so intraday numbers move. Cancelled/undone tickets (status C/U) are always excluded; test users too.</>,
          <>Expand any row with sub-users to drill into its sub-network — <b>unbounded depth</b>. Each level fetches only with the searched period + currency: the Skin / User Type / Username filters do <b>not</b> cascade, and sub-tables carry no totals row.</>,
          <>Money renders in each row user's <b>own currency</b>; Totals are per currency. <b>Cumulable</b> converts every row into the selected currency and swaps the totals for a single <b>Total Converted</b> line.</>,
          <>No sorting and no pagination exist on the real screen (<code>orderdir</code> / <code>column</code> / <code>page</code> are parsed and never used) — none here either.</>,
        ],
      }}
      actions={
        <button className="hrs-btn hrs-btn--search" onClick={() => setShowCols(true)} title="Column visibility (table_settings.blade.php modal)">
          <Icon name="settings" size={14} /> Setting
        </button>
      }
    >
      {/* No HrsKpis / HrsBars: the real page has no KPI strip and no IN/OUT/NET boxes. */}
      <HrsFilters
        fields={FIELDS}
        values={draft}
        onChange={onChange}
        onSearch={onSearch}
        onReset={onReset}
        resultLabel={applied ? `${topRows.length} users` : "—"}
      />
      {applied && couponFeed.loading && <HrsSkeleton rows={8} cols={8} />}
      {applied && !couponFeed.loading && couponFeed.error &&
        <HrsError error={couponFeed.error} onRetry={couponFeed.retry} />}
      {!(applied && (couponFeed.loading || couponFeed.error)) && (
      <HrsTable
        columns={columns}
        rows={topRows}
        rowKey="id"
        totals={totals}
        empty={applied
          ? "No network users with sport activity match these filters for the searched period."
          : "Choose a period and press Search to load the report — the real page performs no auto-load."}
        rowDetail={(r) => r.subCount > 0
          ? <HrbetSubnet node={r.node} statsById={statsById} cumulate={cumulate} selCur={selCur} hidden={hidden} depth={1} />
          : <div className="hrbet-subempty">No sub-users below this {HRBET_LEVELS[r.lvl]} — on the real screen the username simply is not a drill-down link (getCountUsers = 0).</div>}
        renderCard={(r) => {
          const v = (x) => hrsMoney(cumulate ? fxConvert(x, r.cur, selCur) : x, cumulate ? selCur : r.cur);
          return (
            <>
              <div className="hrs-card__top">
                <span className="hrbet-user">
                  <span className={`hrbet-lvl hrbet-lvl--${r.lvl}`} title={HRBET_LEVELS[r.lvl]}>{(HRBET_LEVELS[r.lvl] || "?")[0]}</span>
                  <b>{r.username}</b>
                </span>
                <span className={`hrbet-subcard__profit ${r.profit >= 0 ? "hrs-pos" : "hrs-neg"}`}>{v(r.profit)}</span>
              </div>
              <div className="hrs-card__grid">
                <span>Total bet</span><b>{v(r.bet)}</b>
                <span>Total Won</span><b>{v(r.won)}</b>
                <span>Number bets</span><b>{hrsInt(r.nr)}</b>
                <span>Payout</span><b>{hrsPct(r.bet ? (r.profit / r.bet) * 100 : 0)}</b>
              </div>
              <details className="hrbet-carddet">
                <summary>All columns</summary>
                <div className="hrs-card__grid">
                  <span>ID</span><b>{r.id}</b>
                  <span>Total Bet Tax</span><b>{v(r.betTax)}</b>
                  <span>Total Won Tax</span><b>{v(r.winTax)}</b>
                  <span>Average bet</span><b>{v(r.nr ? r.bet / r.nr : 0)}</b>
                  <span>Single Bets</span><b>{hrsInt(r.single)}</b>
                  <span>Open bets</span><b>{v(r.open)}</b>
                  <span>Total Bet Live</span><b>{v(r.liveBet)}</b>
                  <span>Total Won Live</span><b>{v(r.liveWin)}</b>
                </div>
              </details>
              {r.subCount > 0 && (
                <details className="hrbet-carddet">
                  <summary>Sub-network ({r.subCount} direct)</summary>
                  <HrbetSubCards node={r.node} statsById={statsById} cumulate={cumulate} selCur={selCur} depth={1} />
                </details>
              )}
            </>
          );
        }}
      />
      )}
      {/* No HrsPager: the entire user level renders in one table on the real screen. */}
      <HrsExport
        count={topRows.length}
        filename="betting_report.csv" /* real: betting_report.xlsx via PhpSpreadsheet, streamed synchronously (no two-phase) */
        gate="support_export"
        note={<>Real platform: XLSX built from client-scraped DOM values posted to a no-auth <code>Route::any</code> endpoint — rebuilt as a state export (see file header).</>}
        onCsv={() => hrsCsv(topRows, csvHeaders(), "betting_report.csv")}
      />
      <HrbetColsModal
        open={showCols}
        hidden={hidden}
        onToggle={(k) => setHidden((h) => ({ ...h, [k]: !h[k] }))}
        onClose={() => setShowCols(false)}
      />
    </HrsShell>
  );
};

/* Explicit global — overrides the legacy HostReports.jsx stub (load order). */
window.Betting = Betting;
