// 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.daily.index · ReportsController — see docs/ISYSTEM_REFERENCE.md §Batch 2 "Daily report"
/* =====================================================================
   Daily Report — Report ▾ → Daily Report, rebuilt on the shared Hrs* kit
   (src/report-shell.jsx). Loads AFTER the legacy stub in HostReports.jsx,
   so this file's `DailyReport` wins (load-order overwrite is the project
   convention — see CLAUDE.md "Prototype technical constraints").

   Traceability
   - Route:      admin.reports.daily.index · GET /reports/daily/
                 (routes/admin.php:1554-1556; only sibling:
                 admin.reports.daily.data → GET /reports/daily/getStatsReport)
   - Controller: ReportsController::daily (:2755, page) ·
                 ReportsController::getDailyReport (:2810, AJAX data / Excel / api) ·
                 per-day source ReportsController::getUserReportBusinessNew (:434) ·
                 Excel writer ReportsController::excelExportDailyReport (:3912)
   - Views/JS:   admin/reports/daily/index.blade.php + daily.blade.php ({html}
                 JSON partial) + table_settings.blade.php ·
                 public/js/pages/reports/daily.js
   - No FormRequest and no inline validation on either endpoint.

   Known-bug divergences (evident intent implemented, per build policy):
   1) Custom-range date parsing, US format: the real page posts dd/mm/yyyy
      From/To strings that the backend parses month-first (US m/d/Y order —
      the data_fsql() quirk, ISYSTEM_REFERENCE §Batch 6 "Date/time handling";
      docs/EXTRACTION_PROGRESS.md: "custom-range date parsing US-format bug").
      So 05/08/2026 silently becomes May 8 and any day > 12 fails to parse.
      This prototype uses unambiguous ISO date inputs parsed exactly as typed.
      <!-- SUGGESTION: parse the Daily report's From/To with the d/m/Y helper
           family (sistemadata()/transRangeDate()) like the sibling reports,
           not the US-order parser — 13/08/2026 stops failing and 05/08/2026
           stops reading as May 8. -->
   2) Sticky header: daily.js points floatThead at `.table-prov`, a class this
      page's table doesn't have (daily.js:63-64), so the real header never
      sticks. Evident intent implemented via the shell's sticky scroll header.

   Dead code represented honestly (no invented numbers):
   - Jackpot tab: the button is resurrected by `enable_sport_jackpots` OR
     isadmin (ReportsController.php:2777) but getStatsReport's tab→category
     switch has no 'jackpot' case (and config/cats.php has no CATEGORY_JACKPOT)
     → $gamecategory_id stays empty → die() with an empty 200
     (ReportsController.php:2834-2854). The tab renders here (the prototype
     operator is an admin, who sees the button) with an honestly-empty body.
     <!-- SUGGESTION: add the missing 'jackpot' case to getStatsReport's tab
          switch (or stop rendering the button) so the resurrected Jackpot tab
          doesn't die() into an empty 200. -->
   - Poker tab: NEVER renders — $show_poker is hardcoded false and its
     checkSkinSett call is commented out (ReportsController.php:2775-2776);
     the poker blade branch would crash on undefined $poker_rake/$profit if it
     ever rendered (daily.blade.php:61-62). Honest representation = no tab.
   - Skin filter: sent by the JS but IGNORED by getDailyReport — skin scoping
     is always Auth::user()->getSkinIDS(). Its only real effect (auto-picking
     the skin's first admin user into Parent + auto-reload,
     index.blade.php:306-330) is mirrored below.
     <!-- SUGGESTION: honor the posted skin_id in getDailyReport (or drop the
          select) instead of silently ignoring it. -->

   Label policy: "Bet Tax", "Win Tax" and "Total Plays" are inferred labels —
   sport.bet_tax, sport.win_tax and backend.total_plays resolve nowhere in the
   committed default-lang (runtime lang lives in gitignored storage/lang).

   Not on the real page, so not here: no KPI cards, no sortable headers, no
   pagination, no row/bulk actions (the newMessage modal include has no
   trigger on this screen).
   ===================================================================== */

const { useState: hrdyUseState, useMemo: hrdyUseMemo, useRef: hrdyUseRef } = React;

/* ---------- dates ---------- */
const hrdyPad2 = (n) => String(n).padStart(2, "0");
const hrdyIso = (d) => `${d.getFullYear()}-${hrdyPad2(d.getMonth() + 1)}-${hrdyPad2(d.getDate())}`;
const hrdyDmy = (iso) => { if (!iso) return "…"; const [y, m, d] = iso.split("-"); return `${d}/${m}/${y}`; };
/* Default From = Monday of the current week — mirrors getFirstDateLastDateWeek
   (utils.php:2357; its "if today is Monday, compute from tomorrow" quirk still
   lands on this week's Monday, so plain Monday is the observable behavior). */
const hrdyMondayIso = () => { const d = new Date(); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return hrdyIso(d); };
const hrdyTodayIso = () => hrdyIso(new Date());
const hrdyListDates = (from, to) => {
  if (!from || !to) return [];
  const [fy, fm, fd] = from.split("-").map(Number);
  const [ty, tm, td] = to.split("-").map(Number);
  let cur = new Date(fy, fm - 1, fd);
  const end = new Date(ty, tm - 1, td);
  const out = [];
  /* The real endpoint loops one getUserReportBusinessNew call per day with no
     cap; 366 here is only a prototype render guard. */
  while (cur <= end && out.length < 366) { out.push(hrdyIso(cur)); cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1); }
  return out;
};

/* ---------- deterministic PRNG (per tab × day × currency × scope) ---------- */
/* The seed hash and the deterministic PRNG lived here (`hrdySeed`, `hrdyRng`)
   and are gone with the generator. Deleted rather than left in place: a dead
   generator on a money report is one line away from being live again. */
const hrdyR2 = (n) => Math.round(n * 100) / 100;

/* ---------- lookups (same skin world as the rest of the prototype) ---------- */
/* `hrdySkins()` read window.MOCK.BRANDS. Skins come from the database now. */
/* Mock CurrencyConverter rates, expressed vs ARS. (The real converter uses the
   nearest-dated row per currency in `currencies` and skips SDG entirely —
   CurrencyConverter.php:14.) */
/* EUR-based, matching `currency_rates`: convert = (amount / rate[from]) * rate[to]
   (007). The constant this replaces was ARS-based with the multiply and divide
   the other way round — internally consistent, plausible totals, and inverted
   against the database. A missing rate returns null rather than parity, because
   treating an unknown currency as 1:1 understates a converted total silently. */
const hrdyConv = (amt, from, to, rates) => {
  const a = Number(amt) || 0;
  if (!from || !to || from === to) return a;
  const rf = rates && Number(rates[from]);
  const rt = rates && Number(rates[to]);
  if (!rf || !rt) return null;
  return (a / rf) * rt;
};
/* User::getLevel() minus SUPERADMIN(0)/AFFILIATE(1)/CUSTOMER_CARE(4)/
   ADMINISTRATION(6)/PLAYER(30) (index.blade.php:94). Names 8/10/15/20 are the
   usertype_*_new defaults, per-skin overridable via custom_*_name. */
const HRDY_USER_TYPES = [
  { value: "2", label: "Skin Access" },
  { value: "8", label: "Agent" },
  { value: "10", label: "Promoter" },
  { value: "15", label: "Shop" },
  { value: "20", label: "Cashier" },
];
/* `hrdyParents()` built its list from window.MOCK.BRANDS by suffixing "admin"
   onto each brand name, and `HRDY_PLAYERS` was five invented usernames. Both
   come from `networkUsers` now — and a picker that lists accounts which do not
   exist is worse than an empty one, because every search it produces returns
   nothing for a reason the operator cannot see. */
const hrdyRoleName = (lvl) => ({
  0: "Super Admin", 1: "Affiliate", 2: "Skin Access", 4: "Customer care",
  6: "Administration", 8: "Agent", 9: "Regulator", 10: "Promoter",
  15: "Shop", 20: "Cashier", 30: "Player",
}[Number(lvl)] || `Level ${lvl}`);

/* ---------- tabs ----------
   Real order (index.blade.php:39-65): Sport · Casino · Casino Live · [Poker —
   never rendered, see header comment] · Virtual · Jackpot. Live-tab visibility
   comes from skin settings show_sport / show_casino / show_casinolive /
   show_virtual, each OR isadmin (ReportsController.php:2769-2772); the
   prototype operator is an admin so all render. Switching tab on the real page
   is a full ?tab= page reload (GET link buttons), which is why changing tabs
   below resets the filters and clears the loaded report. */
const HRDY_TABS = [
  { key: "sport",      label: "Sport",       cat: "6 (CATEGORY_SPORT)",       live: true },
  { key: "casino",     label: "Casino",      cat: "1 (CATEGORY_CASINO)",      live: true },
  { key: "casinolive", label: "Casino Live", cat: "2 (CATEGORY_CASINO_LIVE)", live: true },
  { key: "virtual",    label: "Virtual",     cat: "4 (CATEGORY_VIRTUAL)",     live: true },
  { key: "jackpot",    label: "Jackpot",     cat: null,                        live: false }, // dead endpoint — see header
];

/* ---------- scope → which currencies appear + volume scale ----------
   Row scope on the real platform is the user_path subtree of Username (which
   overrides Parent — $user_id = filter_user_id ?: user_id, :2819) further cut
   by User Type as user_level >= value (:476). The Skin select never scopes. */
/* The scope of a search: which skin's rows, and which currencies appear as
   separate lines in non-cumulate mode.

   WAS a seed string and a `scale` factor — the two inputs the generator
   multiplied to make a bigger or smaller network. It now names a skin id, and
   the currencies come from the ROWS THAT CAME BACK rather than from a guess,
   so a day with no EUR trade has no EUR line instead of a plausible one. */
const hrdyScope = (a, skins) => {
  const p = a.parent || "";
  const skin = (skins || []).find(s => s.value === String(p));
  return { skinId: skin ? skin.id : null };
};

/* WHICH TAB MAPS TO WHICH `vertical`, AND THE TWO THAT DO NOT.
   ------------------------------------------------------------------------
   `report_type_class.vertical` has three values — sport, casino, exchange —
   because it is derived from TRANSACTION TYPES, and a casino bet is one type
   whether it was live dealer, virtual or a slot. isystem splits those tabs by
   `g_cid`, the GAME category, which is a different dimension and one this
   ledger does not carry on the entry.

   So Sport and Casino have a source and Casino Live and Virtual do not. They
   render an honest empty state naming the reason rather than casino's rows
   under a Casino Live heading, which is what a fallback would produce and
   would be indistinguishable from a real answer.

   <!-- SUGGESTION: to split them properly, carry the game category on the
        ledger entry (or join games via external_reference 'game:<id>') and add
        it to report_revenue_daily. Until then these two tabs are honestly
        empty, not zero. -->

   UNCLEAR-13: whether Casino Live and Virtual are wanted as separate tabs at
   all, or whether one Casino tab is the right shape for this platform. */
const HRDY_VERTICAL = { sport: "sport", casino: "casino" };
const HRDY_NO_SOURCE = {
  casinolive: "Casino Live and Virtual are split by GAME category upstream. This ledger classifies a bet by transaction TYPE, which does not distinguish them — so there is no source for this tab yet, and showing casino's rows here would be a wrong answer that looks right.",
  virtual:    "Casino Live and Virtual are split by GAME category upstream. This ledger classifies a bet by transaction TYPE, which does not distinguish them — so there is no source for this tab yet, and showing casino's rows here would be a wrong answer that looks right.",
  jackpot:    "The real jackpot endpoint die()s — it returns nothing at all. Nothing is invented in its place.",
};

/* One day-aggregate for (day, currency) out of `report_revenue_daily`, or null
   when that day has no rows — which non-cumulate mode renders as the blade's
   zero row.

   WAS a generator: a mulberry32 seeded on (tab, day, currency, scope) with a
   per-tab band of plausible bet counts and volumes, a per-currency share and a
   7% chance of returning null so some days looked quiet. Profit is still
   recomputed as bet - win rather than read from a stored column, matching
   :2925 — that part was right and stays. */
const hrdyIndex = (rows) => {
  const ix = {};
  (rows || []).forEach(r => {
    const k = `${r.day}|${r.currency}`;
    const d = ix[k] || (ix[k] = { cnt: 0, bet: 0, win: 0, betTax: 0, winTax: 0, profit: 0 });
    d.cnt += Number(r.bet_count)   || 0;
    d.bet += Number(r.real_stake)  || 0;
    d.win += Number(r.real_payout) || 0;
    d.profit = hrdyR2(d.bet - d.win);
  });
  return ix;
};

/* ==================================================================== */
const DailyReport = () => {
  /* Declared before anything reads them. In-browser Babel makes a use-before-
     declaration read `undefined` rather than raise, so ordering here is load
     bearing — tools/tdzcheck.js enforces it. */
  const meta = useHrsFetch(() => Promise.all([
    window.sb.list("skins", { limit: 200 }),
    window.sb.list("currencies", { limit: 200, filters: { active: true } }),
    window.sb.list("networkUsers", { limit: 200 }),
  ]).then(([sk, cu, us]) => {
    const bad = [sk, cu, us].find(r => !r.ok);
    if (bad) return bad;
    return { ok: true, meta: {}, source: "live", data: { skins: sk.data, currencies: cu.data, users: us.data } };
  }), []);

  /* WAS `hrdySkins()`, which read `window.MOCK.BRANDS`. */
  const skins = ((meta.data && meta.data.skins) || []).map(k => ({
    value: String(k.id), id: k.id, name: k.name, currency: k.currency,
  }));
  const rates = {};
  ((meta.data && meta.data.currencies) || []).forEach(c => { rates[c.code] = Number(c.rate); });
  const curOpts = ((meta.data && meta.data.currencies) || []).map(c => c.code);
  const allUsers = (meta.data && meta.data.users) || [];
  const parentOpts = allUsers.filter(u => Number(u.user_level) <= 20)
    .map(u => ({ value: String(u.id), label: `${u.username} (${hrdyRoleName(u.user_level)})` }));
  const playerOpts = allUsers.filter(u => Number(u.user_level) === 30)
    .map(u => ({ value: String(u.id), label: `${u.username} (Player)` }));
  const hrdyDefaults = () => ({
    skin: "", usertype: "", parent: "", username: "",
    range: { from: hrdyMondayIso(), to: hrdyTodayIso(), fromTime: "", toTime: "" },
    cumulate: true,          // default CHECKED on page load (index.blade.php:332)
    /* EUR: the base of `currency_rates`, so it is the one target that needs no
       conversion and cannot be wrong before the rate table has loaded. Upstream
       defaults to the auth user's own currency. */
    currency: "EUR",
  });
  const [tab, setTab] = hrdyUseState("sport"); // page default tab (ReportsController.php:2762-2764)
  const [draft, setDraft] = hrdyUseState(hrdyDefaults);
  const [applied, setApplied] = hrdyUseState(null); // null = not searched — the real page never auto-loads
  const draftRef = hrdyUseRef(draft);
  draftRef.current = draft;

  /* Full ?tab= page reload on the real platform → filters reset + report empties. */
  const hrdySwitchTab = (k) => { setTab(k); const d = hrdyDefaults(); draftRef.current = d; setDraft(d); setApplied(null); };

  const hrdyChange = (k, v) => {
    const next = { ...draftRef.current, [k]: v };
    /* Skin change auto-picks that skin's first admin user into Parent and, if a
       report is on screen, re-runs it (index.blade.php:306-330). */
    if (k === "skin") next.parent = v ? `${v}admin` : "admin";
    draftRef.current = next;
    setDraft(next);
    /* Toggling Cumulable re-fetches the loaded report (daily.js:99-101);
       the skin auto-pick also auto-reloads. */
    if ((k === "skin" || k === "cumulate") && applied) setApplied(next);
  };

  const FIELDS = hrdyUseMemo(() => [
    { key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "- Select -", options: skins,
      tip: <>Rendered only for admins / Customer Care on the real page — and <b>ignored by the data endpoint</b>: skin scoping is always <code>Auth::user()-&gt;getSkinIDS()</code>. Picking one only auto-selects that skin's first admin user into Parent and reloads the report.</> },
    { key: "usertype", label: "User Type", type: "select", icon: "users", placeholder: "- Select -", options: HRDY_USER_TYPES,
      tip: <>Applied server-side as <code>users.user_level &gt;= value</code> — a threshold, not equality (ReportsController.php:476). List = <code>User::getLevel()</code> minus Super Admin / Affiliate / Customer Care / Administration / Player.</> },
    { key: "parent", label: "Parent", type: "select", icon: "user", options: parentOpts, defaultValue: "",
      tip: <>select2 AJAX on <code>admin.users.search</code> (levels 0/2/8/10/15) on the real page; defaults to yourself (Customer Care: your parent) and is disabled for Affiliates.</> },
    { key: "username", label: "Username", type: "select", icon: "user", placeholder: "- Select -", options: playerOpts,
      tip: <>When set it <b>overrides</b> Parent entirely: <code>$user_id = filter_user_id ?: user_id</code> (ReportsController.php:2819).</> },
    { key: "range", label: "From / To", type: "daterange", icon: "calendar", grow: true,
      defaultValue: { from: hrdyMondayIso(), to: hrdyTodayIso(), fromTime: "", toTime: "" },
      tip: <>Defaults: From = Monday of the current week, To = today (ReportsController.php:2765-2767). The real inputs are dd/mm/yyyy text pickers whose values the backend parses in US month-first order — a known bug; here the dates are unambiguous and parsed as entered.</> },
    { key: "cumulate", label: "Cumulable", type: "toggle", defaultValue: true,
      tip: <>ON (default): one currency-converted line per day in the selected currency plus a final Total Converted row. OFF: one row per day <b>per currency</b> plus a black Totals block per currency. Toggling re-runs a loaded report immediately.</> },
    /* Currency container fades out while Cumulable is unchecked (index.blade.php:335-340). */
    { key: "currency", label: "Currency", type: "select", icon: "wallet", options: curOpts, defaultValue: "EUR", hidden: !draft.cumulate,
      tip: <>Distinct list from the <code>currencies</code> table; defaults to your own currency (Customer Care: parent's). Conversion runs through <code>CurrencyConverter</code> (which skips SDG).</> },
  ], [draft.cumulate]);

  /* ---------- rows + totals for the applied search ---------- */
  /* The report. A tab with no `vertical` behind it fetches nothing rather than
     falling back to casino — see HRDY_NO_SOURCE. */
  const feed = useHrsFetch(() => {
    const vertical = HRDY_VERTICAL[tab];
    if (!applied || !vertical) {
      return Promise.resolve({ ok: true, data: [], meta: {}, source: "live" });
    }
    const scope = hrdyScope(applied, skins);
    const filters = {
      vertical,
      from: (applied.range && applied.range.from) || undefined,
      to:   (applied.range && applied.range.to) || undefined,
    };
    if (applied.skin) filters.skin = applied.skin;
    else if (scope.skinId) filters.skin = scope.skinId;
    return window.sb.list("reportRevenue", { limit: 5000, filters });
  }, [applied, tab, meta.data]);

  const built = hrdyUseMemo(() => {
    if (!applied || !HRDY_VERTICAL[tab]) return { rows: [], totals: [] };
    const dates = hrdyListDates(applied.range && applied.range.from, applied.range && applied.range.to);
    const ix = hrdyIndex(feed.data);
    /* The currencies that ACTUALLY traded, in the rows that came back — not a
       list guessed from the scope. A day with no EUR trade gets no EUR line. */
    const curs = [...new Set((feed.data || []).map(r => r.currency))].sort();
    const rows = [];
    const accBy = {};
    const accConv = { cnt: 0, bet: 0, win: 0 };
    for (const iso of dates) {
      const perCur = curs.map(c => ({ c, d: ix[`${iso}|${c}`] })).filter(x => x.d);
      if (applied.cumulate) {
        if (!perCur.length) continue; // zero-date rows exist in non-cumulate mode only (daily.blade.php:124-159)
        let cnt = 0, bet = 0, win = 0;
        perCur.forEach(({ c, d }) => {
          cnt += d.cnt;
          const b = hrdyConv(d.bet, c, applied.currency, rates);
          const w = hrdyConv(d.win, c, applied.currency, rates);
          bet = (b == null || bet == null) ? null : bet + b;
          win = (w == null || win == null) ? null : win + w;
        });
        bet = bet == null ? null : hrdyR2(bet); win = win == null ? null : hrdyR2(win);
        rows.push({ k: iso, date: iso, first: true, cur: applied.currency, cnt, bet, betTax: 0, win, winTax: 0, profit: (bet == null || win == null) ? null : hrdyR2(bet - win) });
        accConv.cnt += cnt; accConv.bet += (bet || 0); accConv.win += (win || 0);
      } else {
        if (!perCur.length) { rows.push({ k: `${iso}|zero`, date: iso, first: true, zero: true, cur: "", cnt: 0, bet: 0, betTax: 0, win: 0, winTax: 0, profit: 0 }); continue; }
        /* One row per currency; the real blade rowspans the date cell across a
           day's currency rows — emulated by blanking the repeats. */
        perCur.forEach(({ c, d }, i) => {
          rows.push({ k: `${iso}|${c}`, date: iso, first: i === 0, cur: c, ...d });
          const a = accBy[c] || (accBy[c] = { cnt: 0, bet: 0, win: 0 });
          a.cnt += d.cnt; a.bet += d.bet; a.win += d.win;
        });
      }
    }
    let totals;
    if (applied.cumulate) {
      totals = rows.length ? [{
        _label: "Total Converted", _variant: "muted",
        cnt: hrsInt(accConv.cnt), bet: hrsMoney(accConv.bet, applied.currency), betTax: hrsMoney(0, applied.currency),
        win: hrsMoney(accConv.win, applied.currency), winTax: hrsMoney(0, applied.currency),
        profit: hrsMoney(hrdyR2(accConv.bet - accConv.win), applied.currency),
      }] : [];
    } else {
      const curs = Object.keys(accBy);
      /* Black Totals block, one row per currency; zero fallback mirrors
         daily.blade.php:210-245 when the whole range is empty. */
      totals = curs.length ? curs.map(c => ({
        _label: "Totals", _variant: "dark",
        cnt: hrsInt(accBy[c].cnt), bet: hrsMoney(accBy[c].bet, c), betTax: hrsMoney(0, c),
        win: hrsMoney(accBy[c].win, c), winTax: hrsMoney(0, c),
        profit: hrsMoney(hrdyR2(accBy[c].bet - accBy[c].win), c),
      })) : [{ _label: "Totals", _variant: "dark", cnt: hrsInt(0), bet: hrsMoney(0), betTax: hrsMoney(0), win: hrsMoney(0), winTax: hrsMoney(0), profit: hrsMoney(0) }];
    }
    return { rows, totals };
  }, [applied, tab, feed.data, rates]);

  /* ---------- columns per tab ---------- */
  const cols = hrdyUseMemo(() => {
    const dateCol = { key: "date", label: "Date", render: r => r.first ? hrdyDmy(r.date) : "", cellClass: r => "hrdy-datecell" + (r.zero ? " hrdy-zero" : "") };
    const zc = r => r.zero ? "hrdy-zero" : undefined;
    const cnt = (label) => ({ key: "cnt", label, align: "right", render: r => hrsInt(r.cnt), cellClass: zc });
    const money = (key, label) => ({ key, label, align: "right", render: r => r.zero ? hrsMoney(0) : hrsMoney(r[key], r.cur), cellClass: zc });
    const profit = { key: "profit", label: "Profit", align: "right", render: r => r.zero ? hrsMoney(0) : hrsMoney(r.profit, r.cur), cellClass: r => r.zero ? "hrdy-zero" : (r.profit >= 0 ? "hrs-pos" : "hrs-neg") };
    const inferred = (raw) => <Tip size={12}>Label inferred — <code>{raw}</code> resolves only in the gitignored <code>storage/lang</code>; a vanilla checkout renders the raw key.</Tip>;
    const betTax = money("betTax", <>Bet Tax {inferred("sport.bet_tax")}</>);
    const winTax = money("winTax", <>Win Tax {inferred("sport.win_tax")}</>);
    if (tab === "sport") return [dateCol, cnt("Number bets"), money("bet", "Bet"), betTax, money("win", "Win"), winTax, profit];
    if (tab === "jackpot") /* unreachable blade/Excel column set, honestly empty */
      return [dateCol, cnt(<>Total Plays {inferred("backend.total_plays")}</>), money("bet", "Bet"), betTax, money("win", "Win"), winTax, profit, money("paid", "Paid")];
    /* Casino / Casino Live / Virtual: the tax columns render only for
       ['sport','jackpot'] even though the virtual SQL selects them (daily.blade.php:19-27). */
    return [dateCol, cnt("Total Spins"), money("bet", "Bet"), money("win", "Win"), profit];
  }, [tab]);

  const tabDef = HRDY_TABS.find(t => t.key === tab) || HRDY_TABS[0];
  /* Four states, and they must not collapse into one another: not searched
     yet, a tab with no source, a failed read, and a genuinely quiet period.
     Only the last one means "no trade". */
  const emptyNode = HRDY_NO_SOURCE[tab]
    ? (tab === "jackpot"
        ? <>Dead on the real platform: the Jackpot button is resurrected by <code>enable_sport_jackpots</code> (or admin), but <code>getStatsReport</code>'s tab→category switch has no <code>jackpot</code> case, so Search <code>die()</code>s with an empty response (ReportsController.php:2834-2854). Shown honestly empty — no numbers invented.</>
        : <>{HRDY_NO_SOURCE[tab]}</>)
    : !applied
      ? "Pick your period and press Search — the real page never auto-loads (the initial fetch call is commented out in daily.js)."
      : feed.loading ? ""
      : feed.error ? ""
      : "No trade in the selected period for this scope.";

  const csvHeaders = [
    { key: "date", label: "Date", get: r => hrdyDmy(r.date) },
    { key: "cur", label: "Currency" }, // the real XLSX adds an explicit Currency column
    { key: "cnt", label: tab === "sport" ? "Number bets" : "Total Spins" },
    { key: "bet", label: "Bet", get: r => r.bet.toFixed(2) },
    ...(tab === "sport" ? [{ key: "betTax", label: "Bet Tax", get: r => r.betTax.toFixed(2) }] : []),
    { key: "win", label: "Win", get: r => r.win.toFixed(2) },
    ...(tab === "sport" ? [{ key: "winTax", label: "Win Tax", get: r => r.winTax.toFixed(2) }] : []),
    { key: "profit", label: "Profit", get: r => r.profit.toFixed(2) },
  ];

  return (
    <HrsShell
      title="Daily Report"
      gate={["support_report", "support_report_daily_report"]}
      gateNote={<> Both bind <b>Customer Care only</b> — the controller forces the 403 via the deliberate <code>authorize('asdasdas')</code> hack (ReportsController.php:2757-2758, 2812-2813); every other role passes. SHOP-level cashiers get a duplicate, <b>ungated</b> sidebar entry to this same URL (sidebar.blade.php:429-434), and this daily perm also leaks into six sibling reports' per-user drill-down checks (the copy-paste leak).</>}
      explainer={{
        bullets: [
          <>One row per calendar day of the range — per <b>currency</b> with Cumulable off, or one converted line per day in the chosen currency with it on (Cumulable starts ON). Days with no data render a zero row (non-cumulate only).</>,
          <>Figures come from the <code>business_report</code> fact table (hourly cron), every money value × <code>skins.reports_multiplier</code>; Profit is recomputed as Bet − Win.</>,
          <>Scope is your own <code>user_path</code> subtree via Parent / Username / User Type. The Skin select is sent but <b>ignored</b> by the data endpoint — it only auto-picks that skin's admin into Parent.</>,
          <>Poker &amp; Jackpot are dead on the real platform: Poker's button never renders (flag hardcoded false) and Jackpot's Search returns an empty <code>die()</code> — the Jackpot tab is shown because admins see its button, but stays honestly empty.</>,
          <>The report stays empty until you press Search — the page never auto-loads.</>,
        ],
      }}
    >
      {/* Section tabs — link buttons with a full ?tab= page reload on the real
          platform (index.blade.php:39-65), hence the filter reset on switch. */}
      <div className="hrdy-tabs" role="tablist">
        {HRDY_TABS.map(t => (
          <button key={t.key} type="button" role="tab" aria-selected={tab === t.key}
            className={`hrdy-tab${tab === t.key ? " active" : ""}${t.live ? "" : " hrdy-tab--dead"}`}
            onClick={() => hrdySwitchTab(t.key)}
            title={t.live ? undefined : "Dead on the real platform — the data endpoint has no jackpot case"}>
            {t.label}{!t.live && <span className="hrdy-deadtag">dead</span>}
          </button>
        ))}
      </div>

      <HrsFilters
        fields={FIELDS}
        values={draft}
        onChange={hrdyChange}
        onSearch={(v) => setApplied({ ...v })}
        resultLabel={applied
          ? `${built.rows.length} rows · ${hrdyDmy(applied.range && applied.range.from)} → ${hrdyDmy(applied.range && applied.range.to)}`
          : "—"}
      />

      <HrsSection
        title={tabDef.label}
        sub={tabDef.cat
          ? <>getStatsReport maps <code>tab={tabDef.key}</code> → game category {tabDef.cat} from <code>config/cats.php</code>; providers of that category scope the <code>business_report</code> rows.</>
          : <>No tab→category mapping exists for <code>jackpot</code> — the request dies empty. Columns mirror the unreachable blade/Excel set (Total Plays, taxes, Paid).</>}
      >
        {meta.error && <HrsError error={meta.error} onRetry={meta.retry} />}
        {feed.error && <HrsError error={feed.error} onRetry={feed.retry} />}
        {applied && HRDY_VERTICAL[tab] && feed.loading && <HrsSkeleton rows={8} cols={7} />}

        <HrsTable
          columns={cols}
          rows={feed.loading || feed.error ? [] : built.rows}
          totals={built.totals}
          rowKey="k"
          maxHeight="62vh"
          empty={emptyNode}
          renderCard={r => (
            <>
              <div className="hrs-card__top">
                <b>{hrdyDmy(r.date)}{r.cur ? ` · ${r.cur}` : ""}</b>
                <span className={r.zero ? undefined : (r.profit >= 0 ? "hrs-pos" : "hrs-neg")}>{r.zero ? hrsMoney(0) : hrsMoney(r.profit, r.cur)}</span>
              </div>
              <div className="hrs-card__grid">
                <span>{tab === "sport" ? "Number bets" : "Total Spins"}</span><b>{hrsInt(r.cnt)}</b>
                <span>Bet</span><b>{r.zero ? hrsMoney(0) : hrsMoney(r.bet, r.cur)}</b>
                <span>Win</span><b>{r.zero ? hrsMoney(0) : hrsMoney(r.win, r.cur)}</b>
                {tab === "sport" && <>
                  <span>Bet Tax</span><b>{r.zero ? hrsMoney(0) : hrsMoney(r.betTax, r.cur)}</b>
                  <span>Win Tax</span><b>{r.zero ? hrsMoney(0) : hrsMoney(r.winTax, r.cur)}</b>
                </>}
              </div>
            </>
          )}
        />
        {/* No export on the Jackpot tab: the real endpoint die()s on the missing
            category before it ever reaches the action=excel branch. */}
        {tab !== "jackpot" && (
          <HrsExport
            count={built.rows.length}
            filename="daily_report.csv"
            gate="support_export"
            onCsv={() => hrsCsv(built.rows, csvHeaders, "daily_report.csv")}
            note={<>Real platform: XLSX <code>daily_report.xlsx</code> streamed from the same data route with <code>action=excel</code> (Settings-hidden columns deleted server-side); an undocumented <code>action=api</code> mode returns the raw data as JSON. Only the button is gated — the endpoint itself never re-checks <code>support_export</code>.</>}
          />
        )}
      </HrsSection>
    </HrsShell>
  );
};

window.DailyReport = DailyReport;
