// 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_type.index · ReportsController — see docs/ISYSTEM_REFERENCE.md §Batch 2 "Bet type"
/* ====================================================================
   Bet type — Report ▾ rebuild (Phase B) on the shared Hrs* report shell.

   Traceability (docs/ISYSTEM_REFERENCE.md §Batch 2 "Bet type"):
   - Route:    admin.reports.bet_type.index · GET /reports/bet_type/ (routes/admin.php:1570)
   - Data:     admin.reports.bet_type.data · GET /reports/bet_type/getBetType (routes/admin.php:1571)
               — serves both the AJAX table HTML and the XLSX export (export=1)
   - Ctrl:     ReportsController::bet_type (:5104) / ::getBetTypeReport (:5129) /
               ::excelBetTypeExport (:6442)
   - Views:    admin/reports/bet_type/index.blade.php (+ table_settings.blade.php modal,
               newMessage partial with no trigger); AJAX fragment bet_type/report.blade.php
   - Source:   business_report_hour JOIN users ON users.id = shop_id, provider_id
               hardcoded [69] (the sportsbook provider) — one row per
               (nr_events, currency); fixed ORDER BY nr_events ASC, profit DESC;
               no pagination, no sortable headers, no KPI cards.
   - Gates:    support_report + support_report_bet_type (Customer Care only —
               authorize('asdasdas') 403 hack); Export additionally support_export.
               The SHOP-level sidebar copy (sidebar.blade.php:421-427) has NO
               per-item gate at all.

   Known-bug policy divergences (evident intent implemented, real behavior noted):
   1. Column-hide class mismatch — headers use misspelled col_avergae_bet /
      col_avergae_odds while body cells use col_average_bet / col_average_odds
      (report.blade.php L10-11 vs L41-42): hiding the two Average columns via
      Settings hides ONLY the headers and misaligns the whole table. The XLSX
      export is unaffected (its map uses the misspelled keys). Prototype hides
      header AND cells together.
      <!-- SUGGESTION: fix report.blade.php L10-11 to the correctly spelled
           col_average_bet / col_average_odds classes (keeping the export map at
           ReportsController.php:6588-6601 in sync) so on-screen column hiding
           stays aligned with the body cells. -->
   2. Reset typo — $('#curency') (index.blade.php:338) means Currency is never
      actually reset; Reset does force Cumulable ON and re-hide the bonus
      columns. Prototype resets Currency too (evident intent), and keeps the
      real "Cumulable on + bonus columns hidden" effects.
      <!-- SUGGESTION: fix the $('#curency') selector typo to $('#currency') so
           Reset restores the default currency like every other field. -->
   3. Dormant bug (never displayed, implemented correctly here): the controller
      computes $total_converted_line->average_odds as bet/count (:5330) instead
      of total_odds/count, but both report.blade.php (L111) and the export
      (:6564) recompute it inline correctly — this prototype uses the correct
      total_odds/count everywhere.
   4. Copy-paste permission leak: the per-user filter authorize loop checks
      support_report_daily_report instead of support_report_bet_type (:5264) —
      surfaced as an honesty note in the header gate Tip.

   Faithful oddities kept (not bugs to fix, just mirrored/annotated):
   - No auto-load: fillReport() on page load is commented out ("prevent load
     data by default", index L392) — table stays empty until Search.
   - Cumulable toggling immediately re-runs the report (index L388-390), even
     before the first Search — mirrored.
   - Include Bonus Bet is FRONT-END ONLY: it flips the two bonus-column
     visibility switches; it is never sent to the query.
   - No validation server-side: empty periodo_mese dies with hardcoded Italian
     "seleziona le date!"; unknown/missing period leaves $start/$end undefined
     → 500. Unreachable in this UI (selects always carry a value).
   - Dead JS not reproduced: category_ids read from a non-existent #category_ids
     element (copy-paste from Summary, always undefined, ignored server-side);
     searchProviderRoute assigned but never used; @csrf on a GET form; the
     empty-state <tr> emitted outside <tbody> (malformed HTML).
   - The per-currency totals query selects a bare nr_events while grouping only
     by currency (:5285) — non-deterministic under non-strict MySQL, aliased
     `id`, never displayed (label cell just says "Totals"). Not reproduced.

   Label policy — headers resolving only to raw unresolved lang keys
   (storage/lang is gitignored) ship as sensible operator labels marked
   "label inferred": sport.bet_tax → "Bet Tax", sport.win_tax → "Win Tax",
   sport.avergae_bet (sic) → "Average Bet", sport.avergae_odds (sic) →
   "Average Odds".

   Prototype adaptations: XLSX (bettype.xlsx, PHPSpreadsheet) becomes a CSV
   download honoring the same hidden-column removal; the localStorage driver
   (key bet_type_report_table_settings, initial ['col_bonus_bet','col_bonus_win'])
   becomes session state with the same initial hidden set; the real select2
   AJAX user search (admin.users.search) becomes a static multi-select of
   network users; floatThead (top:65) becomes the shell's sticky-header scroll
   container; rowspan grouping of the events cell is emulated by blanking
   repeats. Deterministic PRNG mock data — Casino24hs/ARS universe shared with
   the other Host pages.
   ==================================================================== */

const { useState: hrbtUseState, useMemo: hrbtUseMemo } = React;

/* ---------- mock universe (deterministic; Casino24hs/ARS shared with other Host pages) ---------- */
/* Shop networks that book sport tickets, per skin × wallet currency. */
/* WAS `HRBT_SEGMENTS`: four invented skins (Casino24hs, Jugaygana, win24hs,
   Tucasino) with a hardcoded currency and a volume weight each, which is what
   the generator multiplied to produce every figure on this report. The skin
   list now comes from `skins`. */
/* Currency::distinct()->pluck('currency') stand-in + CurrencyConverter mock
   (app/Classes/CurrencyConverter::convert; rates expressed in ARS per unit). */
/* CURRENCY CONVERSION — EUR-BASED, AND THE DIRECTION IS THE WHOLE THING.
   `currency_rates.rate` is units of that currency PER EUR, and 007 states the
   operator upstream uses: convert = (amount / rate[from]) * rate[to].

   The constant this replaces was ARS-based ({ARS: 1, USD: 1465, ...}) with the
   multiplication and division the other way round. Both conventions are
   internally consistent and produce plausible totals; only one of them agrees
   with the database. An inverted FX rate does not throw, does not warn, and
   turns a USD column into a number roughly two million times wrong while the
   ARS column — the base — stays exactly right, which is what makes it hard to
   see.

   A missing rate returns null rather than falling back to 1. Treating an
   unknown currency as parity is how a converted total silently understates by
   three orders of magnitude; the callers below render "—" instead. */
const hrbtConvert = (amount, from, to, rates) => {
  const a = Number(amount) || 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 Type — User::getLevel() minus SUPERADMIN / CUSTOMER_CARE / ADMINISTRATION /
   AFFILIATE / PLAYER and anything above the auth user; applied server-side as
   users.user_level >= value (NOT equality); empty falls back to 2. */
const HRBT_USER_TYPES = [
  { value: "2",  label: "Skin Access" },
  { value: "8",  label: "Agent" },
  { value: "10", label: "Promoter" },
  { value: "15", label: "Shop" },
  { value: "20", label: "Cashier" },
];

/* WAS `HRBT_USERS`: seven invented operators, each with a `share` of network
   volume the generator used to scale its rows. The user search now reads
   `networkUsers`. `HRBT_LEVEL_KEEP` went with it — a per-level "share still
   visible" factor that existed only to make the User Type filter appear to do
   something. It does something now: user_level >= threshold, in the query. */
const hrbtRoleName = (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}`);

/* Range presets — backend.today … backend.previous_month (1–6). */
const HRBT_RANGES = [
  { value: "1", label: "Today" }, { value: "2", label: "Yesterday" },
  { value: "3", label: "This week" }, { value: "4", label: "Previous week" },
  { value: "5", label: "This month" }, { value: "6", label: "Previous month" },
];

/* Column registry. Toggleable in the Settings modal like the real
   table_settings.blade.php; initial hidden set mirrors localStorage key
   bet_type_report_table_settings = ['col_bonus_bet','col_bonus_win'].
   "inferred" marks headers whose real lang keys resolve nowhere in the
   committed default lang (label inferred per build policy). */
const HRBT_COLS = [
  { key: "count",     label: "Number bets" },
  { key: "bet",       label: "Total bet" },
  { key: "bet_tax",   label: "Bet Tax",      inferred: "sport.bet_tax" },
  { key: "win",       label: "Total win" },
  { key: "win_tax",   label: "Win Tax",      inferred: "sport.win_tax" },
  { key: "avg_bet",   label: "Average Bet",  inferred: "sport.avergae_bet (sic)" },
  { key: "avg_odds",  label: "Average Odds", inferred: "sport.avergae_odds (sic)" },
  { key: "profit",    label: "Profit" },
  { key: "bonus_bet", label: "Bonus bet" },
  { key: "bonus_win", label: "Bonus win" },
  { key: "conv",      label: "Converted" },
];
const HRBT_HIDE_DEFAULT = ["bonus_bet", "bonus_win"];

/* ---------- deterministic PRNG + helpers ---------- */
/* The deterministic PRNG and its seed hash lived here (`hrbtHash`, `hrbtRng`)
   and are gone with the generator. Left as a note rather than silently deleted
   because a dead generator is a loaded gun — re-enabling one is a single line,
   and this report reads as authoritative either way. */
const hrbtIso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;

const hrbtDefaults = () => {
  const today = new Date();
  return {
    /* Real default: ALL of the user's skins preselected (multi skin_ids[]).
       Empty here and left empty — the skin list is not known until the fetch
       returns, and the query omits the filter when the selection is empty,
       which IS "all of them" and is also what RLS already scopes to. Seeding
       this with a stale list would filter the report to skins the operator may
       no longer have. */
    skins: [],
    /* Default checked radio is Custom range — controller passes $period='custom_range'. */
    period: "custom_range",
    range_val: "1", // preset select default option: Today ($range = '')
    month: (hrsPeriodOptions("calendar", 2021)[0] || {}).value || "",
    year: String(today.getFullYear()),
    custom: { from: hrbtIso(new Date(today.getFullYear(), today.getMonth(), 1)), to: hrbtIso(today) },
    user_type: "",  // "- All -"; server-side empty falls back to 2 (Skin Access)
    users: [],
    /* Upstream defaults to the auth user's currency (Customer Care: the
         parent's) and falls back to EUR server-side. EUR is also the base of
         `currency_rates`, so it is the one value that needs no conversion and
         cannot be wrong before the rate table has loaded. */
    currency: "EUR",
    bonus: false,    // Include Bonus Bet — front-end only
    cumulate: true,  // default ON (index.blade.php JS L386)
  };
};

/* `hrbtDays(v)` counted the days in the selected period and existed ONLY to
   scale invented volumes. Replaced by hrsPeriodRange() in the report shell,
   which answers the question a query actually asks: which days, not how many. */

/* Aggregate `report_bet_type_daily` rows into the report model.
   Returns { rows, perCur, grand } — all raw numbers; formatting happens in
   the column defs. rows: one per (nr_events, currency) [non-cumulate] or one
   per nr_events converted to the selected currency [cumulate].

   WAS a generator: four invented skin segments, a per-level "keep" factor, a
   per-user "share" of network volume, and a mulberry32 seeded on the filter
   values so the same filters always produced the same figures. Every number on
   this report — stake, payout, profit, average odds — was arithmetic on that
   seed. It looked like a report because it responded to the filters.

   The view supplies the columns directly; the only arithmetic left here is the
   two figures isystem itself derives rather than stores:

     profit     stake - payout
     avg odds   odds_sum / coupon_count, done in the column def, which is why
                the view sums odds rather than averaging them (022 says so:
                an average of averages is not an average).

   bet_tax / win_tax come from the view's stake_tax / payout_tax and are
   genuinely 0 wherever no sport tax is configured — the columns still render,
   as they do upstream. */
const hrbtBuildModel = (v, dbRows, rates) => {
  if (!v) return { rows: [], perCur: [], grand: null };
  const cur = v.currency || "EUR";

  const cells = {};                       // "selections|currency" -> accumulated
  (dbRows || []).forEach(r => {
    const e = Number(r.selection_count) || 0;
    const k = `${e}|${r.currency}`;
    const c = cells[k] || (cells[k] = {
      events: e, currency: r.currency, count: 0, bet: 0, bet_tax: 0,
      win: 0, win_tax: 0, total_odds: 0, profit: 0, bonus_bet: 0, bonus_win: 0,
    });
    const stake  = Number(r.stake)   || 0;
    const payout = Number(r.payout)  || 0;
    c.count      += Number(r.coupon_count) || 0;
    c.bet        += stake;
    c.bet_tax    += Number(r.stake_tax)    || 0;
    c.win        += payout;
    c.win_tax    += Number(r.payout_tax)   || 0;
    c.bonus_bet  += Number(r.bonus_stake)  || 0;
    c.bonus_win  += Number(r.bonus_payout) || 0;
    c.total_odds += Number(r.odds_sum)     || 0;
    c.profit     += stake - payout;
  });

  let rows = Object.values(cells);
  if (v.cumulate) {
    /* Cumulable ON: one row per nr_events, every money figure converted to the
       selected currency (count and total_odds are not money — summed raw). */
    const merged = {};
    rows.forEach(c => {
      const m = merged[c.events] || (merged[c.events] = { events: c.events, currency: cur, count: 0, bet: 0, bet_tax: 0, win: 0, win_tax: 0, total_odds: 0, profit: 0, bonus_bet: 0, bonus_win: 0 });
      m.count += c.count; m.total_odds += c.total_odds;
      ["bet", "bet_tax", "win", "win_tax", "profit", "bonus_bet", "bonus_win"].forEach(f => { const x = hrbtConvert(c[f], c.currency, cur, rates); m[f] = x == null ? null : (m[f] == null ? null : m[f] + x); });
    });
    rows = Object.values(merged);
  }
  /* Fixed ORDER BY nr_events ASC, profit DESC (:5289) — no sortable headers. */
  rows.sort((a, b) => a.events - b.events || b.profit - a.profit);
  let prev = null;
  rows.forEach(r => {
    r._first = r.events !== prev; prev = r.events; // emulates the real rowspan grouping
    r.conv = v.cumulate ? r.profit : hrbtConvert(r.profit, r.currency, cur, rates);
    r.key = `${r.events}|${r.currency}`;
  });

  /* Per-currency Totals (non-cumulate only) + always a final Total Converted. */
  const perCur = [];
  if (!v.cumulate) {
    const byCur = {};
    rows.forEach(r => {
      const t = byCur[r.currency] || (byCur[r.currency] = { currency: r.currency, count: 0, bet: 0, bet_tax: 0, win: 0, win_tax: 0, total_odds: 0, profit: 0, bonus_bet: 0, bonus_win: 0 });
      ["count", "bet", "bet_tax", "win", "win_tax", "total_odds", "profit", "bonus_bet", "bonus_win"].forEach(f => { t[f] += r[f]; });
    });
    Object.values(byCur).forEach(t => { t.conv = hrbtConvert(t.profit, t.currency, cur, rates); perCur.push(t); });
  }
  const grand = { currency: cur, count: 0, bet: 0, bet_tax: 0, win: 0, win_tax: 0, total_odds: 0, profit: 0, bonus_bet: 0, bonus_win: 0 };
  rows.forEach(r => {
    grand.count += r.count; grand.total_odds += r.total_odds;
    ["bet", "bet_tax", "win", "win_tax", "profit", "bonus_bet", "bonus_win"].forEach(f => {
      const g = v.cumulate ? r[f] : hrbtConvert(r[f], r.currency, cur, rates);
        grand[f] = (g == null || grand[f] == null) ? null : grand[f] + g;
    });
  });
  grand.conv = grand.profit;
  return { rows, perCur, grand };
};

/* Label + "label inferred" Tip for the four unresolved sport.* headers. */
const hrbtColLabel = (c) => c.inferred
  ? <>{c.label}<Tip size={12}>Label inferred — the real header renders the raw unresolved key <code>{c.inferred}</code> on a vanilla checkout (runtime lang loads from the gitignored storage/lang).</Tip></>
  : c.label;

/* ================================================================
   BetType — exact top-level name required by app.jsx routing
   (case "report-bettype"); this file loads after the legacy
   HostReports.jsx, so this definition wins.
   ================================================================ */
const BetType = () => {
  const [draft, setDraft] = hrbtUseState(hrbtDefaults);
  const [applied, setApplied] = hrbtUseState(null); // null = not searched yet (no-auto-load)
  const [hide, setHide] = hrbtUseState(HRBT_HIDE_DEFAULT.slice()); // session stand-in for localStorage bet_type_report_table_settings
  const [colsOpen, setColsOpen] = hrbtUseState(false);

  /* The option lists. Skins, currencies and the user search were three
     hardcoded arrays naming four skins, five currencies and seven operators
     that do not exist in this database — so every filter offered choices that
     could not match a row, and the report answered anyway. */
  const opts = 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(([skins, curs, users]) => {
    const bad = [skins, curs, users].find(r => !r.ok);
    if (bad) return bad;
    return { ok: true, source: "live", meta: {},
             data: { skins: skins.data, currencies: curs.data, users: users.data } };
  }), []);

  /* code -> units per EUR, for hrbtConvert. Built from the same rows that fill
     the currency selector, so the selector can never offer a currency the
     converter cannot handle. */
  const rates = hrbtUseMemo(() => {
    const m = {};
    ((opts.data && opts.data.currencies) || []).forEach(c => { m[c.code] = Number(c.rate); });
    return m;
  }, [opts.data]);

  /* The report itself. `applied` is null until Search is pressed — the real
     screen does not auto-load — and useHrsFetch would still fire, so the null
     case returns an empty envelope rather than fetching the whole table. */
  const feed = useHrsFetch(() => {
    if (!applied) return Promise.resolve({ ok: true, data: [], meta: {}, source: "live" });
    const range = hrsPeriodRange(applied);
    const filters = { from: range.from, to: range.to };
    if (applied.skins && applied.skins.length) filters.skins = applied.skins;
    /* User Type is a THRESHOLD upstream (user_level >= value), not equality,
       and empty falls back to 2. Copying that: `eq.` would drop every shop and
       cashier from an Agent-level report and still total correctly. */
    filters.levelFrom = Number(applied.user_type) || 2;
    if (applied.users && applied.users.length === 1) filters.subtree = applied.users[0];
    return window.sb.list("reportBetType", { limit: 5000, filters });
  }, [applied]);

  /* Several selected users are the UNION of their subtrees, and PostgREST
     cannot express several `cd.` conditions on one column — so one selection
     goes to the server and several are narrowed here, over rows the server has
     already scoped to the caller. isystem ANDs them into an INTERSECTION,
     which for sibling accounts is always empty; PROTOTYPE_INVENTORY records
     that as a known bug and this implements the evident intent. */
  const dbRows = hrbtUseMemo(() => {
    const rows = feed.data || [];
    const sel = (applied && applied.users) || [];
    if (sel.length < 2) return rows;
    return rows.filter(r => sel.some(p => r.user_path === p || String(r.user_path || "").startsWith(p + ".")));
  }, [feed.data, applied]);

  const model = hrbtUseMemo(() => hrbtBuildModel(applied, dbRows, rates), [applied, dbRows, rates]);
  const cur = (applied && applied.currency) || draft.currency || "EUR";
  const cumulate = !!(applied && applied.cumulate);

  const onChange = (k, val) => {
    const next = { ...draft, [k]: val };
    setDraft(next);
    if (k === "bonus") {
      /* Include Bonus Bet is front-end only: it flips the two bonus-column
         visibility switches (and their save); it is never sent to the query. */
      setHide(h => val ? h.filter(x => x !== "bonus_bet" && x !== "bonus_win")
                       : [...h.filter(x => x !== "bonus_bet" && x !== "bonus_win"), "bonus_bet", "bonus_win"]);
    }
    if (k === "cumulate") setApplied(next); // real: toggling calls fillReport() immediately (index L388-390), even before the first Search
  };
  const onReset = () => {
    /* Real Reset: typo $('#curency') leaves Currency untouched (bug #2 in the
       header — prototype resets it, evident intent); it DOES force Cumulable
       back ON and re-hide the bonus columns — both mirrored via defaults. */
    setDraft(hrbtDefaults());
    setApplied(null);
    setHide(HRBT_HIDE_DEFAULT.slice());
  };

  /* Real skins, real operators, real currencies. The select2 user search
     narrows by the chosen type upstream; here the threshold semantics apply to
     the option list too, so picking "Agent" offers agents and everything below
     them rather than agents alone. The VALUE is the ltree path, because that is
     what the subtree filter needs — the label carries the username. */
  const allUsers = (opts.data && opts.data.users) || [];
  const userOpts = (draft.user_type
    ? allUsers.filter(u => Number(u.user_level) >= Number(draft.user_type))
    : allUsers
  ).map(u => ({ value: String(u.path), label: `${u.username} (${hrbtRoleName(u.user_level)})` }));
  const skinOpts = ((opts.data && opts.data.skins) || []).map(k => ({ value: String(k.id), label: k.name }));
  const curOpts  = ((opts.data && opts.data.currencies) || []).map(c => c.code);
  const dv = hrbtDefaults();
  const FIELDS = [
    { key: "skins", label: "Skin", type: "multi", icon: "grid", options: skinOpts, defaultValue: dv.skins, placeholder: "- All -",
      tip: <>Multi-select <code>skin_ids[]</code>, default all of your skins; rendered only for admins / Customer Care, and the server intersects it with your <code>getSkinIDS()</code> either way.</> },
    { key: "period", label: "Period", type: "select", icon: "calendar", options: [
        { value: "range", label: "Preset range" }, { value: "periodo_mese", label: "Month" },
        { value: "periodo_anno", label: "Year" }, { value: "custom_range", label: "Custom range" },
      ], defaultValue: "custom_range",
      tip: <>Four radios on the real page; default checked is <b>Custom range</b> (the controller passes <code>period='custom_range'</code>). No validation: an empty month dies with hardcoded Italian "seleziona le date!", an unknown period 500s.</> },
    { key: "range_val", label: "Preset", type: "select", icon: "calendar", options: HRBT_RANGES, defaultValue: "1", hidden: draft.period !== "range" },
    { key: "month", label: "Month", type: "month-period", mode: "calendar", fromYear: 2021, icon: "calendar", defaultValue: dv.month, hidden: draft.period !== "periodo_mese",
      tip: <>Calendar months from <code>getDateCalendarioMonth()</code> (2021 → current), value <code>start|end</code>.</> },
    { key: "year", label: "Year", type: "select", icon: "calendar", defaultValue: dv.year, hidden: draft.period !== "periodo_anno",
      options: Array.from({ length: new Date().getFullYear() - 2020 }, (_, i) => String(new Date().getFullYear() - i)) },
    { key: "custom", label: "Custom range", type: "daterange", icon: "calendar", defaultValue: dv.custom, hidden: draft.period !== "custom_range",
      tip: <>Two dd/mm/yyyy datepickers <code>start</code>/<code>end</code>; defaults first of the current month → today.</> },
    { key: "user_type", label: "User Type", type: "select", icon: "users", options: HRBT_USER_TYPES, placeholder: "- All -", defaultValue: "",
      tip: <>Applied as <code>users.user_level &gt;= value</code> (a threshold, not equality); empty falls back to 2 (Skin Access). Options exclude Super Admin / Customer Care / Administration / Affiliate / Player and anything above your own level.</> },
    { key: "users", label: "User / Parent", type: "multi", icon: "user", options: userOpts, placeholder: "- Select -", defaultValue: [],
      tip: <>Real control is a select2 AJAX user search (<code>admin.users.search</code>), disabled for affiliates. Server applies the picks as chained <code>user_path LIKE</code> conditions (ANDed — the intersection of the subtrees); the prototype models the evident multi-select intent (union). For Customer Care each pick is policy-checked against the wrong permission string <code>support_report_daily_report</code> (copy-paste from the Daily report).</> },
    { key: "currency", label: "Currency", type: "select", icon: "wallet", options: curOpts, defaultValue: dv.currency,
      tip: <>Conversion target for the Converted column, the cumulate rows and Total Converted — it does not filter rows. Defaults to your own currency (Customer Care: the parent's); server-side fallback <code>EUR</code>.</> },
    { key: "bonus", label: "Include Bonus Bet", type: "toggle", defaultValue: false,
      tip: <>Front-end only: shows/hides the Bonus bet and Bonus win columns via the Settings switches — never sent to the query.</> },
    { key: "cumulate", label: "Cumulable", type: "toggle", defaultValue: true,
      tip: <>Default ON: one converted row per event count merging all currencies. Toggling re-runs the report immediately — on the real page even before the first Search.</> },
  ];

  const visCols = HRBT_COLS.filter(c => !hide.includes(c.key));
  const money = (val, ccy) => hrsMoney(val, ccy);
  const columns = [
    { key: "events", label: <>Number of events<Tip size={12}>Events on the ticket (<code>business_report_hour.nr_events</code>, raw integer — no label mapping). Grouped with a rowspan on the real table; repeats are blanked here.</Tip></>, width: 130,
      render: r => r._first ? <b>{r.events}</b> : "" },
    { key: "count", label: hrbtColLabel(HRBT_COLS[0]), align: "right", render: r => hrsInt(r.count) },
    { key: "bet", label: hrbtColLabel(HRBT_COLS[1]), align: "right", render: r => money(r.bet, r.currency) },
    { key: "bet_tax", label: hrbtColLabel(HRBT_COLS[2]), align: "right", render: r => money(r.bet_tax, r.currency) },
    { key: "win", label: hrbtColLabel(HRBT_COLS[3]), align: "right", render: r => money(r.win, r.currency) },
    { key: "win_tax", label: hrbtColLabel(HRBT_COLS[4]), align: "right", render: r => money(r.win_tax, r.currency) },
    { key: "avg_bet", label: hrbtColLabel(HRBT_COLS[5]), align: "right", render: r => money(r.count ? r.bet / r.count : 0, r.currency) },
    { key: "avg_odds", label: hrbtColLabel(HRBT_COLS[6]), align: "right", render: r => hrsMoney(r.count ? r.total_odds / r.count : 0) },
    /* Only the Converted cell is sign-colored on the real page (bg-success/bg-danger) — Profit stays plain. */
    { key: "profit", label: hrbtColLabel(HRBT_COLS[7]), align: "right", render: r => money(r.profit, r.currency) },
    { key: "bonus_bet", label: hrbtColLabel(HRBT_COLS[8]), align: "right", render: r => money(r.bonus_bet, r.currency) },
    { key: "bonus_win", label: hrbtColLabel(HRBT_COLS[9]), align: "right", render: r => money(r.bonus_win, r.currency) },
    { key: "conv", label: <>Converted ({cur})<Tip size={12}>Profit converted to the selected currency via CurrencyConverter; cell filled green/red by sign (real <code>bg-success</code>/<code>bg-danger</code>).</Tip></>, align: "right",
      render: r => money(r.conv, cur), cellClass: r => r.conv >= 0 ? "hrs-pos" : "hrs-neg" },
  /* Hiding a column removes header AND cells together — evident intent; the
     real page's header/body class mismatch (bug #1 in the file header) hides
     only the headers for the two Average columns. */
  ].filter(c => c.key === "events" || !hide.includes(c.key));

  const fmtTotals = (t, label, variant) => ({
    _label: label, _variant: variant, _raw: t,
    count: hrsInt(t.count), bet: money(t.bet, t.currency), bet_tax: money(t.bet_tax, t.currency),
    win: money(t.win, t.currency), win_tax: money(t.win_tax, t.currency),
    avg_bet: money(t.count ? t.bet / t.count : 0, t.currency),
    /* Correct total_odds/count — the controller's dormant bet/count bug (#3) is never displayed on the real page either. */
    avg_odds: hrsMoney(t.count ? t.total_odds / t.count : 0),
    profit: money(t.profit, t.currency), bonus_bet: money(t.bonus_bet, t.currency), bonus_win: money(t.bonus_win, t.currency),
  });
  const convTotalCol = columns.find(c => c.key === "conv");
  if (convTotalCol) convTotalCol.renderTotal = (t) => t._raw
    ? <span className={t._raw.conv >= 0 ? "hrs-pos" : "hrs-neg"}>{money(t._raw.conv, cur)}</span> : "";
  const totals = applied ? [
    /* Non-cumulate: one black "Totals" row per currency (clone query grouped by
       currency only); always a final Total Converted row in the selected currency. */
    ...model.perCur.map(t => fmtTotals(t, "Totals", "dark")),
    ...(model.rows.length ? [fmtTotals(model.grand, "Total Converted", "muted")] : []),
  ] : [];

  const csv = () => {
    /* Real export: bettype.xlsx (PHPSpreadsheet) from the same data route with
       export=1 + hidden_cols[] mirrored from localStorage — hidden columns are
       physically removed from the sheet; green/red fill on Converted; black
       header row. Prototype: CSV honoring the same hidden-column removal. */
    const heads = [
      { key: "events", label: "Number of events" },
      ...visCols.map(c => ({
        key: c.key,
        label: c.key === "conv" ? `Converted (${cur})` : c.label,
        get: (r) => {
          if (c.key === "count") return r.count;
          if (c.key === "avg_bet") return (r.count ? r.bet / r.count : 0).toFixed(2);
          if (c.key === "avg_odds") return (r.count ? r.total_odds / r.count : 0).toFixed(2);
          return Number(r[c.key] || 0).toFixed(2);
        },
      })),
    ];
    hrsCsv(model.rows, heads, "bettype.csv");
  };

  return (
    <HrsShell
      title="Bet type"
      subtitle="Sport tickets grouped by events per ticket — GET /reports/bet_type/ · ReportsController::bet_type"
      gate={["support_report", "support_report_bet_type"]}
      gateNote={<> Gates bind Customer Care only — every other role passes (<code>authorize('asdasdas')</code> 403 hack). The SHOP-level sidebar copy of this entry has no per-item gate at all, and the per-user filter check leaks the Daily-report permission (<code>support_report_daily_report</code>, copy-paste at :5264).</>}
      explainer={{ bullets: [
        <>One row per <b>number of events on the ticket × wallet currency</b>, aggregated from the hourly <code>business_report_hour</code> fact table (sportsbook provider 69 only), joined to shops in your <code>user_path</code> network (Customer Care / affiliates: the parent's).</>,
        <>Fixed order — events ascending, then profit descending. No sortable headers, no pagination: the whole result renders as one sticky-header table.</>,
        <>Cumulable (default on) merges all currencies into one converted row per event count; switched off you get per-currency rows plus a black Totals row per currency. Either way the report ends with a Total Converted row in the selected currency.</>,
        <>Nothing loads until you press Search — the real page's auto-load is commented out.</>,
      ] }}
      actions={
        <button className="hrs-btn hrs-btn--search" onClick={() => setColsOpen(true)} title="Column show/hide (real: table_settings modal persisted in localStorage bet_type_report_table_settings)">
          <Icon name="settings" size={14} /> Setting
        </button>
      }>

      <HrsFilters
        fields={FIELDS} values={draft}
        onChange={onChange}
        onSearch={(v) => setApplied(v)}
        onReset={onReset}
        resultLabel={applied ? `${model.rows.length} rows` : "—"} />

      {/* A failed read and an empty period both render zero rows, and the
          totals bar under the table would report a confident row of zeros for
          a request that never came back. Surfaced above the table so the
          numbers are never the first thing an operator sees. */}
      {opts.error && <HrsError error={opts.error} onRetry={opts.retry} />}
      {feed.error && <HrsError error={feed.error} onRetry={feed.retry} />}
      {applied && feed.loading && <HrsSkeleton rows={6} cols={8} />}

      <HrsTable
        columns={columns}
        rows={applied && !feed.loading && !feed.error ? model.rows : []}
        rowKey="key"
        totals={totals}
        maxHeight="62vh"
        empty={!applied
          ? "Choose a period and press Search — the report never auto-loads."
          : feed.loading || feed.error ? ""
          : "No sport tickets were placed in the selected period."}
        renderCard={r => <>
          <div className="hrs-card__top">
            <b>{r.events} event{r.events === 1 ? "" : "s"}{cumulate ? "" : ` · ${r.currency}`}</b>
            <span className={r.conv >= 0 ? "hrs-pos" : "hrs-neg"}>{money(r.conv, cur)}</span>
          </div>
          <div className="hrs-card__grid">
            {visCols.filter(c => c.key !== "conv").map(c => (
              <React.Fragment key={c.key}>
                <span>{c.label}</span>
                <b>{c.key === "count" ? hrsInt(r.count)
                  : c.key === "avg_bet" ? money(r.count ? r.bet / r.count : 0, r.currency)
                  : c.key === "avg_odds" ? hrsMoney(r.count ? r.total_odds / r.count : 0)
                  : money(r[c.key], r.currency)}</b>
              </React.Fragment>
            ))}
          </div>
        </>} />

      <HrsExport
        count={applied ? model.rows.length : 0}
        filename="bettype.csv"
        gate="support_export"
        onCsv={csv}
        note={<>Real export: <code>bettype.xlsx</code> via the same data route with <code>export=1</code>, hidden columns removed from the sheet; served synchronously (no queued/email phase). Prototype downloads CSV with the same columns.</>} />

      {/* Column show/hide — stands in for admin/reports/bet_type/table_settings.blade.php.
          Session-only here; the real modal persists to localStorage
          bet_type_report_table_settings (initial hidden: bonus bet / bonus win). */}
      {colsOpen && (
        <div onClick={() => setColsOpen(false)}
          style={{ position: "fixed", inset: 0, zIndex: 90, background: "rgba(15,20,32,.45)", display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }}>
          <div onClick={e => e.stopPropagation()} className="panel"
            style={{ width: "min(420px, 100%)", maxHeight: "80vh", overflowY: "auto", padding: 18, borderRadius: 8, background: "var(--surface, #fff)" }}>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
              <b style={{ fontSize: 14 }}>Table settings — show / hide columns</b>
              <button className="hrs-x" onClick={() => setColsOpen(false)} title="Close" style={{ background: "none", border: 0, cursor: "pointer" }}><Icon name="x" size={14} /></button>
            </div>
            <div style={{ fontSize: 12, color: "var(--text-tertiary, #7e8299)", marginBottom: 10 }}>
              Bonus bet and Bonus win start hidden. On the real page, hiding the two Average columns only hides their <i>headers</i> (misspelled <code>col_avergae_*</code> classes) and misaligns the table — here both header and cells hide together (evident intent).
            </div>
            {HRBT_COLS.map(c => (
              <label key={c.key} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 2px", fontSize: 13, cursor: "pointer" }}>
                <input type="checkbox" checked={!hide.includes(c.key)}
                  onChange={() => setHide(h => h.includes(c.key) ? h.filter(x => x !== c.key) : [...h, c.key])} />
                <span>{c.label}{c.inferred ? " *" : ""}</span>
              </label>
            ))}
            <div style={{ fontSize: 11, color: "var(--text-tertiary, #7e8299)", marginTop: 8 }}>* label inferred — unresolved lang key on the real platform.</div>
          </div>
        </div>
      )}
    </HrsShell>
  );
};

window.BetType = BetType;
