/* ====================================================================
   HOST REPORT SHELL (Hrs… / hrs… globals) — shared chrome, Report ▾ rebuild
   ====================================================================
   Serves every Report ▾ screen (ReportsController / WithdrawController /
   CommissionsController / PlayersReportController / BusinessReport data —
   see docs/ISYSTEM_REFERENCE.md "Batch 2"). Presentation-only: no fetching,
   no localStorage; all data/filter state lives in the consuming page and
   flows in via props. Styling contract: classes prefixed `hrs-` (see the
   companion CSS block appended to styles/theme-iwakiri.css), built on the
   Iwakiri red ramp (--p-*) + neutral/semantic tokens. Never hardcode indigo.

   ------------------------- API CONTRACT -------------------------

   <HrsShell title subtitle? gate gateNote? explainer? actions? >…</HrsShell>
     Page chrome: `.page.report-page.host-report.hrs-page` wrapper, title row
     with a Tip naming the real-platform permission gate(s), optional
     Explainer, right-side action-button slot.
     - gate:      string | string[] | ReactNode. Strings render as <code>
                  chips inside a standard "requires …" Tip sentence.
     - gateNote:  extra ReactNode appended inside the same Tip (use for the
                  "sidebar-only gate" / copy-paste-perm-leak honesty notes).
     - explainer: ReactNode (rendered verbatim — pass your own <Explainer>)
                  OR { title?, bullets?, body? } (built into an <Explainer>).
     - actions:   ReactNode — buttons for the header right side.

   <HrsKpis items note? />
     KPI strip. items: [{ label, value, sub?, tone?, tip?, key? }],
     tone ∈ "neutral"(default) | "ok" | "warn" | "err" | "brand".
     note: optional callout under the strip — use for the BonusRetention-
     style "* KPI values reflect … and are not affected by the filters".
     Mobile: horizontally scrollable strip.

   <HrsFilters fields values onChange onSearch? onReset? resultLabel? />
     Declarative filter bar. Desktop = hero filter-card strip + active-
     filter pills + "Clear all"; mobile (≤860px) = "Filters (n)" button
     opening a full-height sheet with the same fields.
     - fields: [{ key, label, type, options?, placeholder?, tip?, icon?,
                  disabled?, defaultValue?, withTime?, mode?, fromYear?,
                  grow?, width?, hidden?, render?, onLabel?, offLabel? }]
       type ∈ "text" | "number" | "select" | "multi" | "date" | "daterange"
              | "month-period" | "toggle" | "custom".
       · select/multi options: ["A","B"] or [{ value, label }].
       · daterange value = { from, to, fromTime?, toTime? } (ISO date +
         "HH:MM:SS" when withTime: true).
       · month-period value = "YYYY-MM-DD|YYYY-MM-DD"; options generated by
         hrsPeriodOptions(mode, fromYear): mode "commission" (default —
         first-Monday→day-before-next-first-Monday, the real platform's
         getDateCalendario() commission months), "calendar" (plain months,
         getDateCalendarioMonth()), or "week" (Mon–Sun weeks).
       · custom: render(value, setValue, field) → node.
     - values: { [key]: value } — fully controlled by the consumer.
     - onChange(key, value): update ONE field (typically your draft state).
     - onSearch(values): if provided, a Search button renders and the bar
       follows the real platform's apply-on-Search semantics (keep separate
       draft/applied state; commit the passed values object on call).
       Clearing an active-filter pill also calls onSearch with the patched
       values so pill removal takes effect immediately. Omit onSearch for
       live-filter screens.
     - onReset(): renders a Reset button; also used by "Clear all" (falls
       back to per-field clearing when omitted).
     - resultLabel: string/node for the trailing Results card (e.g.
       `${shown} of ${all}`).
     - children: extra ReactNode cards appended to the strip (escape hatch).

   <HrsTable columns rows sort? onSort? totals? renderCard? empty?
             rowKey? onRowClick? rowDetail? maxHeight? dense? />
     Sortable table with dark Metronic-style header; optional totals rows;
     optional per-row expandable detail (Commissions/NetWin subnet drill-
     downs); mobile collapse to stacked cards via renderCard.
     - columns: [{ key, label, align?("left"|"center"|"right"), sortable?,
                   render?(row), renderTotal?(totalsRow), cellClass?(row),
                   width?, hidden?, firstDir?("asc"|"desc") }]
     - sort: { key, dir } (controlled). onSort(next) receives the NEXT sort
       object already computed ({ key, dir }) — store it and re-sort rows.
     - totals: one row object or an array (per-currency totals). Special
       keys: _label (first-cell label, default "Totals"), _variant: "dark"
       (default) | "muted" (use muted for "Total Converted" rows). Values
       should be pre-formatted strings, or supply col.renderTotal.
     - renderCard(row): mobile card body — put the 2-3 scan-first fields
       (status/amount/date/player) up top, the rest behind <details> or your
       own expander. Without renderCard the table horizontal-scrolls inside
       its own container on mobile.
     - rowDetail(row): when set, adds a chevron expander column; expanding
       injects a full-width detail row (drill-down layouts).
     - empty: node for the empty state. NOTE the no-auto-load convention:
       most real reports render empty until Search — pass an instructional
       empty like "Choose a period and press Search to load the report."
     - maxHeight: css value → scroll container with sticky header.

   <HrsBars items />
     IN/OUT/NET summary boxes used by the ledger reports (Transactions /
     Credit Transactions) and any other totals strip.
     items: [{ label, value, sub?, tip?, tone? }],
     tone ∈ "in"(green) | "out"(red) | "net"(amber) | "ok" | "warn" | "err"
            | "neutral" | "brand".

   <HrsExport onCsv filename gate? twoPhase? count note? />
     Export block implementing the two-phase pattern + an Export Requests
     audit line (session-local, like BonusRetention's).
     - onCsv(): perform the direct download (typically call hrsCsv(...)).
     - count: rows in the current filtered set (drives the phase decision
       and the audit line).
     - twoPhase: true (limit 10 000 — the real EXPORT_WEB_LIMIT) | number
       (custom limit) | falsy (always direct). Beyond the limit the click
       emits an "export queued — link emailed" toast instead of downloading.
     - gate: permission string (e.g. "support_export") shown as an honesty
       note — on the real platform it hides the button from Customer Care.

   <HrsPager page pageSize total onPage onPageSize? sizes? />
     DataTables-style footer pager. page is 0-based; sizes default
     [10, 25, 50, 100]. Omit onPageSize to hide the "Show N entries" select.

   <HrsSection title sub? actions? >…</HrsSection>
     Sub-section wrapper for multi-part reports (tab bodies, "Cashiers"
     tables under card lists, per-provider blocks).

   hrsCsv(rows, headers, filename)
     CSV download; headers: [{ key, label, get?(row) }] — same column shape
     as the engine's PAYBO.downloadCSV, which it delegates to when present.

   hrsPeriodOptions(mode, fromYear?) → [{ value:"iso|iso", label }]
   hrsMoney(n, cur?) / hrsInt(n) / hrsPct(n)  — en-US number formatting
   hrsToast(title, detail)                    — PAYBO toast-bus wrapper

   ------------------------- WORKED EXAMPLE -------------------------

   const RxDemo = () => {
     const FIELDS = [
       { key:"skin",   label:"Skin",   type:"select", icon:"flag", placeholder:"- Select -", options:[{value:"iwakiri",label:"Iwakiri"}] },
       { key:"period", label:"Month",  type:"month-period", icon:"calendar", mode:"commission", defaultValue: hrsPeriodOptions("commission")[0].value },
       { key:"range",  label:"Custom range", type:"daterange", withTime:true, icon:"calendar" },
       { key:"cats",   label:"Category", type:"multi", options:["Sport","Casino","Casino Live","Virtual"] },
       { key:"cumulate", label:"Cumulable", type:"toggle", defaultValue:true },
     ];
     const [draft, setDraft]     = useState(() => Object.fromEntries(FIELDS.map(f => [f.key, f.defaultValue ?? ""])));
     const [applied, setApplied] = useState(null);            // null = not searched yet (no-auto-load)
     const rows = applied ? myRows(applied) : [];
     const [sort, setSort] = useState({ key:"profit", dir:"desc" });
     return (
       <HrsShell title="Demo report" gate={["support_report","support_report_demo"]}
                 gateNote={<>Gates bind Customer Care only; other roles pass.</>}
                 explainer={{ bullets:[<>One row per day.</>] }}>
         <HrsKpis items={[{ label:"GGR", value:hrsMoney(1234.5,"ARS"), tone:"ok" }]}
                  note="* KPI values reflect the whole period and ignore the filters below." />
         <HrsFilters fields={FIELDS} values={draft}
                     onChange={(k,v) => setDraft(d => ({ ...d, [k]:v }))}
                     onSearch={(v) => setApplied(v)}
                     onReset={() => { const d = Object.fromEntries(FIELDS.map(f => [f.key, f.defaultValue ?? (f.type==="multi"?[]:f.type==="toggle"?false:f.type==="daterange"?{from:"",to:""}:"")])); setDraft(d); setApplied(null); }}
                     resultLabel={applied ? `${rows.length} rows` : "—"} />
         <HrsBars items={[{ label:"IN", value:hrsMoney(9000,"ARS"), tone:"in" },
                          { label:"OUT", value:hrsMoney(4000,"ARS"), tone:"out" },
                          { label:"NET", value:hrsMoney(5000,"ARS"), tone:"net" }]} />
         <HrsTable
           columns={[
             { key:"date",   label:"Date", sortable:true },
             { key:"bet",    label:"Bet",  align:"right", sortable:true, render:r => hrsMoney(r.bet,"ARS") },
             { key:"profit", label:"Profit", align:"right", sortable:true, render:r => hrsMoney(r.profit,"ARS"),
               cellClass:r => r.profit >= 0 ? "hrs-pos" : "hrs-neg" },
           ]}
           rows={rows} sort={sort} onSort={setSort}
           totals={{ _label:"Totals", bet:hrsMoney(999,"ARS"), profit:hrsMoney(1,"ARS") }}
           renderCard={r => <>
             <div className="hrs-card__top"><b>{r.date}</b><span>{hrsMoney(r.profit,"ARS")}</span></div>
             <div className="hrs-card__grid"><span>Bet</span><b>{hrsMoney(r.bet,"ARS")}</b></div>
           </>}
           empty="Choose a period and press Search to load the report." />
         <HrsExport count={rows.length} twoPhase filename="demo_report.csv" gate="support_export"
                    onCsv={() => hrsCsv(rows, [{ key:"date", label:"date" }, { key:"bet", label:"bet" }], "demo_report.csv")} />
         <HrsPager page={0} pageSize={50} total={rows.length} onPage={() => {}} onPageSize={() => {}} />
       </HrsShell>
     );
   };
   ==================================================================== */

const { useState: hrsUseState, useRef: hrsUseRef } = React;

/* ---------- formatting helpers ---------- */
const hrsPad = (n) => String(n).padStart(2, "0");
const hrsMoney = (n, cur) => `${Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${cur ? " " + cur : ""}`;
const hrsInt = (n) => Number(n || 0).toLocaleString("en-US");
const hrsPct = (n) => `${Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;

const HRS_MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const hrsIsoDate = (d) => `${d.getFullYear()}-${hrsPad(d.getMonth() + 1)}-${hrsPad(d.getDate())}`;
const hrsDM = (d) => `${d.getDate()}/${hrsPad(d.getMonth() + 1)}`;
/* First Monday of (year, month 0-11) — the anchor of real "commission months"
   (app/Helpers/utils.php getDateCalendario). */
const hrsFirstMonday = (y, m) => { const first = new Date(y, m, 1); return new Date(y, m, 1 + ((8 - first.getDay()) % 7)); };

/* THE PERIOD MODEL, RESOLVED TO TWO DATES.
   ------------------------------------------------------------------------
   Every report screen carries the same filter shape — `period` plus one of
   `range_val` / `month` / `year` / `custom` — and until now every screen turned
   it into a DAY COUNT, used only to scale invented volumes. None of them could
   say which dates the operator had actually asked for, which is precisely what
   a real query needs.

   So thirteen screens each had a `<prefix>Days(v)` and not one had a
   `<prefix>Range(v)`. That is the shape of a codebase that has never fetched:
   the mock needs "how many days", the database needs "which days".

   Returns { from, to } as YYYY-MM-DD inclusive, ready for PostgREST's
   gte./lte. on a `day` column. The preset numbering is isystem's and is not
   ours to renumber: 1 Today, 2 Yesterday, 3 This week, 4 Previous week,
   5 This month, 6 Previous month.

   Weeks start MONDAY, matching hrsFirstMonday and the commission calendar —
   getDay() returns 0 for Sunday, so the (d+6)%7 shift is what makes Monday 0
   rather than Sunday. Getting that backwards moves every weekly figure by a
   day and looks entirely plausible. */
const hrsPeriodRange = (v) => {
  const t = new Date();
  const day = (d) => hrsIsoDate(d);
  const shift = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; };
  const monday = (d) => shift(d, -(((d.getDay() + 6) % 7)));

  if (!v) return { from: day(t), to: day(t) };

  if (v.period === "range") {
    switch (Number(v.range_val) || 1) {
      case 1: return { from: day(t), to: day(t) };
      case 2: return { from: day(shift(t, -1)), to: day(shift(t, -1)) };
      case 3: return { from: day(monday(t)), to: day(t) };
      case 4: { const m = shift(monday(t), -7); return { from: day(m), to: day(shift(m, 6)) }; }
      case 5: return { from: day(new Date(t.getFullYear(), t.getMonth(), 1)), to: day(t) };
      case 6: return { from: day(new Date(t.getFullYear(), t.getMonth() - 1, 1)),
                       to:   day(new Date(t.getFullYear(), t.getMonth(), 0)) };
      default: return { from: day(t), to: day(t) };
    }
  }

  if (v.period === "periodo_mese") {
    const [a, b] = String(v.month || "").split("|");
    /* The option value is already two ISO dates; splitting it is the whole
       job. A malformed value falls back to this month rather than to an empty
       range, because an empty range renders as a report with no rows and
       nothing saying why. */
    if (a && b) return { from: a, to: b };
    return { from: day(new Date(t.getFullYear(), t.getMonth(), 1)), to: day(t) };
  }

  if (v.period === "periodo_anno") {
    const y = Number(v.year) || t.getFullYear();
    return { from: `${y}-01-01`, to: y < t.getFullYear() ? `${y}-12-31` : day(t) };
  }

  const c = v.custom || {};
  if (c.from && c.to) return { from: c.from, to: c.to };
  return { from: day(new Date(t.getFullYear(), t.getMonth(), 1)), to: day(t) };
};

/* Period option generator for the "month-period" filter type.
   mode "commission": first Monday of month → day before next month's first
   Monday. mode "calendar": 1st → last day. mode "week": Mon–Sun weeks.
   Newest first; future periods skipped. Value = "YYYY-MM-DD|YYYY-MM-DD". */
const hrsPeriodOptions = (mode = "commission", fromYear = 2021) => {
  const today = new Date();
  const out = [];
  if (mode === "week") {
    const monday = new Date(today);
    monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
    for (let d = monday; d.getFullYear() >= fromYear; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 7)) {
      const end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 6);
      out.push({ value: `${hrsIsoDate(d)}|${hrsIsoDate(end)}`, label: `Week ${hrsDM(d)} – ${hrsDM(end)}/${end.getFullYear()}` });
    }
    return out;
  }
  for (let y = today.getFullYear(); y >= fromYear; y--) {
    for (let m = 11; m >= 0; m--) {
      if (mode === "calendar") {
        const start = new Date(y, m, 1);
        if (start > today) continue;
        const end = new Date(y, m + 1, 0);
        out.push({ value: `${hrsIsoDate(start)}|${hrsIsoDate(end)}`, label: `${HRS_MONTHS[m]} ${y}` });
      } else {
        const start = hrsFirstMonday(y, m);
        if (start > today) continue;
        const nextStart = m === 11 ? hrsFirstMonday(y + 1, 0) : hrsFirstMonday(y, m + 1);
        const end = new Date(nextStart.getFullYear(), nextStart.getMonth(), nextStart.getDate() - 1);
        out.push({ value: `${hrsIsoDate(start)}|${hrsIsoDate(end)}`, label: `${HRS_MONTHS[m]} ${y} (${hrsDM(start)} – ${hrsDM(end)})` });
      }
    }
  }
  return out;
};

/* ---------- filter-field plumbing ---------- */
const hrsOpts = (options) => (options || []).map(o => (typeof o === "object" && o !== null) ? o : { value: o, label: String(o) });

const hrsFieldDefault = (f) => {
  if (f.defaultValue !== undefined) return f.defaultValue;
  switch (f.type) {
    case "multi": return [];
    case "toggle": return false;
    case "daterange": return { from: "", to: "", fromTime: "", toTime: "" };
    default: return "";
  }
};

const hrsFieldNorm = (f, v) => {
  switch (f.type) {
    case "multi": return (v || []).slice().sort().join(",");
    case "toggle": return v ? "1" : "0";
    case "daterange": { const x = v || {}; return [x.from || "", x.to || "", x.fromTime || "", x.toTime || ""].join("|"); }
    default: return v == null ? "" : String(v);
  }
};
const hrsFieldActive = (f, v) => hrsFieldNorm(f, v) !== hrsFieldNorm(f, hrsFieldDefault(f));

const hrsPillLabel = (f, v) => {
  const opts = f.type === "month-period" ? hrsPeriodOptions(f.mode || "commission", f.fromYear) : hrsOpts(f.options);
  switch (f.type) {
    case "toggle": return v ? f.label : `${f.label}: off`;
    case "multi": {
      const labels = (v || []).map(x => (opts.find(o => o.value === x) || { label: x }).label);
      return `${f.label}: ${labels.length <= 2 ? labels.join(", ") : labels.length + " selected"}`;
    }
    case "daterange": { const x = v || {}; return `${f.label}: ${x.from || "…"} → ${x.to || "…"}`; }
    case "select": case "month-period": {
      const hit = opts.find(o => String(o.value) === String(v));
      return `${f.label}: ${hit ? hit.label : v}`;
    }
    default: return `${f.label}: ${v}`;
  }
};

/* ---------- CSV + toast helpers ---------- */
const hrsCsv = (rows, headers, filename) => {
  /* notify:false — Host screens own their own messaging (HrsExport posts an
     "Export ready" line and an Export Requests audit row), so letting the
     engine also announce it would show the same download twice. */
  if (window.PAYBO && window.PAYBO.downloadCSV) { window.PAYBO.downloadCSV(filename, rows, headers, { notify: false }); return; }
  /* Fallback mirrors src/engine.jsx toCSV/downloadCSV */
  const esc = (v) => {
    if (v == null) return "";
    const s = typeof v === "object" ? JSON.stringify(v) : String(v);
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  const head = headers.map(c => esc(c.label != null ? c.label : c.key)).join(",");
  const body = rows.map(r => headers.map(c => esc(c.get ? c.get(r) : r[c.key])).join(","));
  const blob = new Blob([[head, ...body].join("\n")], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url; a.download = filename;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 250);
};

const hrsToast = (title, detail) => {
  window.PAYBO && window.PAYBO.emitToast && window.PAYBO.emitToast({
    id: `hrs-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
    tx_id: title, amount: 0, currency: "HOST", player: "Report", reason: detail,
  });
};

const hrsAlignCls = (align) => align === "right" ? "hrs-al-r" : align === "center" ? "hrs-al-c" : "";

/* ==================================================================
   HrsShell — page chrome
   ================================================================== */
const HrsShell = ({ title, subtitle, gate, gateNote, explainer, actions, children }) => {
  window.useLocale && window.useLocale();
  const gateChips = gate == null ? [] : (Array.isArray(gate) ? gate : (typeof gate === "string" ? [gate] : null));
  const tipBody = (gate != null || gateNote != null) && (
    <>
      {gateChips ? (
        <>Real-platform access requires {gateChips.map((g, i) => (
          <React.Fragment key={g}>{i > 0 && " + "}<code>{g}</code></React.Fragment>
        ))}.{" "}</>
      ) : gate}
      {gateNote}
    </>
  );
  return (
    <div className="page report-page host-report hrs-page">
      <div className="page__header">
        <div>
          <div className="page__title hrs-title">
            {title}
            {tipBody && <Tip>{tipBody}</Tip>}
          </div>
          {subtitle && <div className="page__subtitle">{subtitle}</div>}
        </div>
        {actions && <div className="page__actions hrs-headactions">{actions}</div>}
      </div>
      {explainer && (React.isValidElement(explainer)
        ? explainer
        : <Explainer compact title={explainer.title || "What this report shows, in plain English"} bullets={explainer.bullets}>{explainer.body}</Explainer>)}
      {children}
    </div>
  );
};

/* ==================================================================
   HrsKpis — KPI strip + optional "ignores filters" note
   ================================================================== */
const HrsKpis = ({ items, note }) => {
  if (!items || !items.length) return null;
  return (
    <div className="hrs-kpiblock">
      <div className="hrs-kpis">
        {items.map((k, i) => (
          <div key={k.key || k.label || i} className={`hrs-kpi hrs-kpi--${k.tone || "neutral"}`}>
            <div className="hrs-kpi__l">{k.label}{k.tip && <Tip size={12}>{k.tip}</Tip>}</div>
            <div className="hrs-kpi__v">{k.value}</div>
            {k.sub != null && <div className="hrs-kpi__s">{k.sub}</div>}
          </div>
        ))}
      </div>
      {note && <div className="hrs-kpis-note">{note}</div>}
    </div>
  );
};

/* ==================================================================
   HrsFilters — declarative filter bar
   Desktop: hero card strip + pills + Clear all.
   Mobile ≤860px: "Filters (n)" button → full-height sheet.
   ================================================================== */
const HrsFieldControl = ({ field: f, value, onChange }) => {
  const t = f.type || "text";
  if (t === "custom" && f.render) return f.render(value, onChange, f);
  if (t === "toggle") return (
    <div className="hrs-togglewrap">
      <Toggle value={!!value} onChange={(v) => onChange(v)} onLabel={f.onLabel != null ? f.onLabel : ""} offLabel={f.offLabel != null ? f.offLabel : ""} size="sm" disabled={f.disabled} />
    </div>
  );
  if (t === "select" || t === "month-period") {
    const opts = t === "month-period" ? hrsPeriodOptions(f.mode || "commission", f.fromYear) : hrsOpts(f.options);
    return (
      <select className="hrs-fctl" value={value == null ? "" : value} disabled={f.disabled} onChange={e => onChange(e.target.value)}>
        {f.placeholder != null && <option value="">{f.placeholder}</option>}
        {opts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
      </select>
    );
  }
  if (t === "multi") return <HrsMultiSelect field={f} value={value || []} onChange={onChange} />;
  if (t === "date") return <input type="date" className="hrs-fctl hrs-fctl--date" value={value || ""} disabled={f.disabled} onChange={e => onChange(e.target.value)} />;
  if (t === "daterange") {
    const v = value || {};
    const set = (patch) => onChange({ from: v.from || "", to: v.to || "", fromTime: v.fromTime || "", toTime: v.toTime || "", ...patch });
    return (
      <div className="hrs-rangerow">
        <input type="date" className="hrs-fctl hrs-fctl--date" value={v.from || ""} disabled={f.disabled} onChange={e => set({ from: e.target.value })} />
        {f.withTime && <input type="time" step="1" className="hrs-fctl hrs-fctl--time" value={v.fromTime || ""} disabled={f.disabled} onChange={e => set({ fromTime: e.target.value })} />}
        <span className="hrs-rangesep">→</span>
        <input type="date" className="hrs-fctl hrs-fctl--date" value={v.to || ""} disabled={f.disabled} onChange={e => set({ to: e.target.value })} />
        {f.withTime && <input type="time" step="1" className="hrs-fctl hrs-fctl--time" value={v.toTime || ""} disabled={f.disabled} onChange={e => set({ toTime: e.target.value })} />}
      </div>
    );
  }
  return (
    <input className="hrs-fctl" inputMode={t === "number" ? "decimal" : undefined} placeholder={f.placeholder}
      disabled={f.disabled} value={value == null ? "" : value} onChange={e => onChange(e.target.value)} />
  );
};

const HrsMultiSelect = ({ field: f, value, onChange }) => {
  const [open, setOpen] = hrsUseState(false);
  const [q, setQ] = hrsUseState("");
  const opts = hrsOpts(f.options);
  const shown = q ? opts.filter(o => o.label.toLowerCase().includes(q.toLowerCase())) : opts;
  const toggle = (v) => { value.includes(v) ? onChange(value.filter(x => x !== v)) : onChange([...value, v]); };
  const summary = value.length === 0
    ? (f.placeholder || "- All -")
    : value.length <= 2
      ? value.map(v => (opts.find(o => o.value === v) || { label: v }).label).join(", ")
      : `${value.length} selected`;
  return (
    <div className="hrs-multi">
      <button type="button" className="hrs-fctl hrs-multi__btn" disabled={f.disabled} onClick={() => setOpen(o => !o)}>
        <span className="hrs-multi__sum">{summary}</span>
        <Icon name="chevron_down" size={12} />
      </button>
      {open && (
        <>
          <div className="hrs-multi__scrim" onClick={() => setOpen(false)} />
          <div className="hrs-multi__pop">
            {opts.length > 8 && <input className="hrs-fctl hrs-multi__search" placeholder="Filter options…" value={q} onChange={e => setQ(e.target.value)} />}
            <div className="hrs-multi__list">
              {shown.map(o => (
                <label key={o.value} className="hrs-multi__opt">
                  <input type="checkbox" checked={value.includes(o.value)} onChange={() => toggle(o.value)} />
                  <span>{o.label}</span>
                </label>
              ))}
              {shown.length === 0 && <div className="hrs-multi__none">No matches</div>}
            </div>
            <div className="hrs-multi__foot">
              <button type="button" onClick={() => onChange([])}>Clear</button>
              <button type="button" onClick={() => setOpen(false)}>Done</button>
            </div>
          </div>
        </>
      )}
    </div>
  );
};

const HrsFilters = ({ fields = [], values = {}, onChange, onSearch, onReset, resultLabel, children }) => {
  const [sheet, setSheet] = hrsUseState(false);
  const vis = fields.filter(f => !f.hidden);
  const activeFields = vis.filter(f => hrsFieldActive(f, values[f.key]));
  const clearField = (f) => {
    const dv = hrsFieldDefault(f);
    onChange && onChange(f.key, dv);
    /* Apply-on-Search screens: a cleared pill should take effect at once. */
    if (onSearch) onSearch({ ...values, [f.key]: dv });
  };
  const clearAll = () => {
    if (onReset) { onReset(); return; }
    vis.forEach(f => onChange && onChange(f.key, hrsFieldDefault(f)));
  };
  const doSearch = () => { onSearch && onSearch(values); setSheet(false); };

  return (
    <div className="hrs-filters">
      {/* Desktop hero strip */}
      <div className="hrs-hero">
        {vis.map(f => (
          <div key={f.key} className={`hrs-fcard${f.grow ? " hrs-fcard--grow" : ""}${f.type === "toggle" ? " hrs-fcard--toggle" : ""}`}
            style={f.width ? { minWidth: f.width } : undefined}>
            <div className="hrs-flab">
              {f.icon && <Icon name={f.icon} size={11} />}{f.label}
              {f.tip && <Tip size={12}>{f.tip}</Tip>}
            </div>
            <HrsFieldControl field={f} value={values[f.key]} onChange={(v) => onChange && onChange(f.key, v)} />
          </div>
        ))}
        {children}
        {resultLabel != null && (
          <div className="hrs-fcard hrs-fcard--result">
            <div className="hrs-flab"><Icon name="chart" size={11} /> Results</div>
            <div className="hrs-fresult">{resultLabel}</div>
          </div>
        )}
        {(onSearch || onReset) && (
          <div className="hrs-fbtns">
            {onSearch && <button className="hrs-btn hrs-btn--search" onClick={doSearch}><Icon name="search" size={14} /> Search</button>}
            {onReset && <button className="hrs-btn hrs-btn--reset" onClick={() => onReset()}><Icon name="x" size={14} /> Reset Filters</button>}
          </div>
        )}
      </div>

      {/* Mobile trigger row */}
      <div className="hrs-filterbar">
        <button className="hrs-btn hrs-btn--filters" onClick={() => setSheet(true)}>
          <Icon name="filter" size={14} /> Filters{activeFields.length > 0 ? ` (${activeFields.length})` : ""}
        </button>
        {resultLabel != null && <span className="hrs-filterbar__result">{resultLabel}</span>}
      </div>

      {/* Active-filter pills + Clear all */}
      {activeFields.length > 0 && (
        <div className="hrs-pills">
          <span className="hrs-pills__lab">Active:</span>
          {activeFields.map(f => (
            <span key={f.key} className="hrs-pill">
              {hrsPillLabel(f, values[f.key])}
              <button className="hrs-pill__x" onClick={() => clearField(f)} title={`Clear ${f.label}`}><Icon name="x" size={10} /></button>
            </span>
          ))}
          <button className="hrs-clearall" onClick={clearAll}>Clear all</button>
        </div>
      )}

      {/* Mobile full-height filter sheet (brief §11) */}
      {sheet && (
        <div className="hrs-sheet-scrim" onClick={() => setSheet(false)}>
          <div className="hrs-sheet" onClick={e => e.stopPropagation()}>
            <div className="hrs-sheet__head">
              <span><Icon name="filter" size={14} /> Filters</span>
              <button className="hrs-x" onClick={() => setSheet(false)} title="Close"><Icon name="x" size={13} /></button>
            </div>
            <div className="hrs-sheet__body">
              {vis.map(f => (
                <div key={f.key} className="hrs-sheet__field">
                  <label>{f.label}</label>
                  <HrsFieldControl field={f} value={values[f.key]} onChange={(v) => onChange && onChange(f.key, v)} />
                </div>
              ))}
            </div>
            <div className="hrs-sheet__foot">
              <button className="hrs-btn hrs-btn--reset" onClick={clearAll}>Reset all</button>
              <button className="hrs-btn hrs-btn--search" onClick={doSearch}>{onSearch ? "Search" : "Done"}</button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
};

/* ==================================================================
   HrsTable — sortable table, totals rows, drill-down details,
   mobile stacked cards via renderCard
   ================================================================== */
const HrsTable = ({ columns = [], rows = [], sort, onSort, totals, renderCard, empty, rowKey, onRowClick, rowDetail, maxHeight, dense }) => {
  const [openDetail, setOpenDetail] = hrsUseState({});
  const keyOf = (r, i) => typeof rowKey === "function" ? rowKey(r, i) : (rowKey ? r[rowKey] : (r && r.id != null ? r.id : i));
  const cols = columns.filter(c => !c.hidden);
  const colCount = cols.length + (rowDetail ? 1 : 0);
  const totalRows = totals == null ? [] : (Array.isArray(totals) ? totals : [totals]);
  const nextSort = (c) => (sort && sort.key === c.key)
    ? { key: c.key, dir: sort.dir === "asc" ? "desc" : "asc" }
    : { key: c.key, dir: c.firstDir || "desc" };
  const emptyNode = empty || "No rows match your filters.";

  return (
    <div className={`panel hrs-tenv${renderCard ? " hrs-tenv--cards" : ""}${dense ? " hrs-tenv--dense" : ""}`}>
      <div className="hrs-tablewrap" style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}>
        <table className="hrs-table">
          <thead>
            <tr>
              {cols.map(c => (
                <th key={c.key} className={hrsAlignCls(c.align)} style={c.width ? { width: c.width } : undefined}>
                  {c.sortable ? (
                    <button className="hrs-th-sort" onClick={() => onSort && onSort(nextSort(c))}>
                      {c.label}
                      <Icon name={sort && sort.key === c.key ? (sort.dir === "asc" ? "arrow_up" : "arrow_down") : "sort"} size={10}
                        style={{ opacity: sort && sort.key === c.key ? 1 : 0.45 }} />
                    </button>
                  ) : c.label}
                </th>
              ))}
              {rowDetail && <th className="hrs-expandhead" />}
            </tr>
          </thead>
          <tbody>
            {rows.length === 0 && (
              <tr>
                <td colSpan={colCount} className="hrs-empty">
                  <Icon name="search" size={20} style={{ opacity: 0.4 }} />
                  <div>{emptyNode}</div>
                </td>
              </tr>
            )}
            {rows.map((r, i) => {
              const k = keyOf(r, i);
              const expanded = !!openDetail[k];
              return (
                <React.Fragment key={k}>
                  <tr className={onRowClick ? "hrs-trow hrs-trow--click" : "hrs-trow"}
                    onClick={onRowClick ? () => onRowClick(r) : undefined}>
                    {cols.map(c => (
                      <td key={c.key} className={[hrsAlignCls(c.align), c.cellClass ? c.cellClass(r) : ""].filter(Boolean).join(" ") || undefined}>
                        {c.render ? c.render(r) : r[c.key]}
                      </td>
                    ))}
                    {rowDetail && (
                      <td className="hrs-expandcell">
                        <button className="hrs-expand" title={expanded ? "Collapse" : "Expand"}
                          onClick={(e) => { e.stopPropagation(); setOpenDetail(m => ({ ...m, [k]: !m[k] })); }}>
                          <Icon name={expanded ? "chevron_down" : "chevron_right"} size={13} />
                        </button>
                      </td>
                    )}
                  </tr>
                  {rowDetail && expanded && (
                    <tr className="hrs-detailrow"><td colSpan={colCount}>{rowDetail(r)}</td></tr>
                  )}
                </React.Fragment>
              );
            })}
          </tbody>
          {totalRows.length > 0 && rows.length > 0 && (
            <tfoot>
              {totalRows.map((t, ti) => (
                <tr key={ti} className={`hrs-totals hrs-totals--${t._variant || "dark"}`}>
                  {cols.map((c, ci) => (
                    <td key={c.key} className={hrsAlignCls(c.align)}>
                      {ci === 0
                        ? (t._label != null ? t._label : (t[c.key] != null ? t[c.key] : "Totals"))
                        : (c.renderTotal ? c.renderTotal(t) : (t[c.key] != null ? t[c.key] : ""))}
                    </td>
                  ))}
                  {rowDetail && <td />}
                </tr>
              ))}
            </tfoot>
          )}
        </table>
      </div>

      {/* Mobile stacked cards */}
      {renderCard && (
        <div className="hrs-cards">
          {rows.length === 0 && <div className="hrs-empty">{emptyNode}</div>}
          {rows.map((r, i) => (
            <div key={keyOf(r, i)} className={onRowClick ? "hrs-card hrs-card--click" : "hrs-card"}
              onClick={onRowClick ? () => onRowClick(r) : undefined}>
              {renderCard(r)}
            </div>
          ))}
          {totalRows.length > 0 && rows.length > 0 && totalRows.map((t, ti) => (
            <div key={`t${ti}`} className={`hrs-card hrs-card--totals hrs-card--totals-${t._variant || "dark"}`}>
              <div className="hrs-card__top"><b>{t._label != null ? t._label : "Totals"}</b></div>
              <div className="hrs-card__grid">
                {cols.slice(t._label != null ? 0 : 1).map(c => (t[c.key] != null || c.renderTotal) && (
                  <React.Fragment key={c.key}>
                    <span>{c.label}</span>
                    <b>{c.renderTotal ? c.renderTotal(t) : t[c.key]}</b>
                  </React.Fragment>
                ))}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
};

/* ==================================================================
   HrsBars — IN / OUT / NET summary boxes (ledger reports)
   ================================================================== */
const HRS_BAR_TONES = { in: "ok", out: "err", net: "warn", ok: "ok", err: "err", warn: "warn", neutral: "neutral", brand: "brand" };
const HrsBars = ({ items }) => {
  if (!items || !items.length) return null;
  return (
    <div className="hrs-bars">
      {items.map((b, i) => (
        <div key={b.key || b.label || i} className={`hrs-bar hrs-bar--${HRS_BAR_TONES[b.tone] || "neutral"}`}>
          <div className="hrs-bar__l">{b.label}{b.tip && <Tip size={12}>{b.tip}</Tip>}</div>
          <div className="hrs-bar__v">{b.value}</div>
          {b.sub != null && <div className="hrs-bar__s">{b.sub}</div>}
        </div>
      ))}
    </div>
  );
};

/* ==================================================================
   HrsExport — two-phase export + Export Requests audit line
   ================================================================== */
const HrsExport = ({ onCsv, filename = "report.csv", gate, twoPhase, count = 0, note }) => {
  const [audit, setAudit] = hrsUseState([]);
  const [open, setOpen] = hrsUseState(false);
  const seq = hrsUseRef(0);
  const limit = twoPhase === true ? 10000 : (typeof twoPhase === "number" ? twoPhase : Infinity);
  const emailed = count > limit;
  const doExport = () => {
    seq.current += 1;
    const at = new Date();
    const entry = {
      id: seq.current,
      ts: `${hrsPad(at.getHours())}:${hrsPad(at.getMinutes())}:${hrsPad(at.getSeconds())}`,
      filename, rows: count, mode: emailed ? "emailed" : "direct",
    };
    if (emailed) {
      hrsToast("Export queued", `${hrsInt(count)} rows exceed the ${hrsInt(limit)}-row direct-download limit — the file is generated in the background and a download link is emailed to the account on file.`);
    } else {
      onCsv && onCsv();
      hrsToast("Export ready", `${filename} · ${hrsInt(count)} rows · direct download. Recorded in the Export Requests line below.`);
    }
    setAudit(a => [entry, ...a]);
  };
  return (
    <div className="hrs-export">
      <div className="hrs-export__row">
        <button className="hrs-btn hrs-btn--export" onClick={doExport}>
          <Icon name={emailed ? "mail" : "download"} size={14} /> Export{emailed ? " (email link)" : " CSV"}
        </button>
        {Number.isFinite(limit) && (
          <span className="hrs-export__limit">
            ≤ {hrsInt(limit)} rows download directly · larger exports are emailed as a link
            <Tip size={12}>Mirrors the real platform's two-phase export: up to the web limit the file streams straight to the browser; beyond it a queued job builds the file and emails a download link.</Tip>
          </span>
        )}
        {gate && (
          <span className="hrs-export__gate">
            Gated by <code>{gate}</code>
            <Tip size={12}>On the real platform the Export button is hidden from Customer Care accounts lacking <code>{gate}</code>; every other role always sees it.</Tip>
          </span>
        )}
        {note && <span className="hrs-export__note">{note}</span>}
      </div>
      {audit.length > 0 && (
        <div className="hrs-audit">
          <button className="hrs-audit__toggle" onClick={() => setOpen(o => !o)}>
            <Icon name={open ? "chevron_down" : "chevron_right"} size={11} />
            Export Requests ({audit.length}) — last: {audit[0].filename} · {hrsInt(audit[0].rows)} rows · {audit[0].mode === "emailed" ? "link emailed" : "direct download"} · {audit[0].ts}
          </button>
          {open && (
            <table className="hrs-audit__table">
              <thead><tr><th>Time</th><th>File</th><th>Rows</th><th>Delivery</th><th>Status</th></tr></thead>
              <tbody>
                {audit.map(a => (
                  <tr key={a.id}>
                    <td>{a.ts}</td>
                    <td>{a.filename}</td>
                    <td>{hrsInt(a.rows)}</td>
                    <td>{a.mode === "emailed" ? "Emailed link" : "Direct download"}</td>
                    <td><span className="hrs-audit__ok">Completed</span></td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>
      )}
    </div>
  );
};

/* ==================================================================
   HrsPager — DataTables-style footer pager (page is 0-based)
   ================================================================== */
const HrsPager = ({ page = 0, pageSize = 50, total = 0, onPage, onPageSize, sizes = [10, 25, 50, 100] }) => {
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
  const from = total === 0 ? 0 : page * pageSize + 1;
  const to = Math.min(total, (page + 1) * pageSize);
  return (
    <div className="hrs-pager">
      {onPageSize && (
        <div className="hrs-pager__show">
          Show
          <select className="hrs-fctl hrs-pager__select" value={pageSize} onChange={e => onPageSize(Number(e.target.value))}>
            {sizes.map(n => <option key={n} value={n}>{n}</option>)}
          </select>
          entries
        </div>
      )}
      <div className="hrs-pager__range">
        Showing <b>{hrsInt(from)}–{hrsInt(to)}</b> of <b>{hrsInt(total)}</b> · Page {Math.min(page + 1, totalPages)} of {totalPages}
      </div>
      <div className="hrs-pager__btns">
        <button className="hrs-pagebtn" disabled={page <= 0} onClick={() => onPage && onPage(Math.max(0, page - 1))}>
          <Icon name="chevron_left" size={12} /> Prev
        </button>
        <button className="hrs-pagebtn" disabled={page >= totalPages - 1} onClick={() => onPage && onPage(Math.min(totalPages - 1, page + 1))}>
          Next <Icon name="chevron_right" size={12} />
        </button>
      </div>
    </div>
  );
};

/* ==================================================================
   HrsSection — sub-section wrapper for multi-part reports
   ================================================================== */
const HrsSection = ({ title, sub, actions, children }) => (
  <div className="hrs-section">
    {(title || actions) && (
      <div className="hrs-section__head">
        <div>
          {title && <div className="hrs-section__title">{title}</div>}
          {sub && <div className="hrs-section__sub">{sub}</div>}
        </div>
        {actions && <div className="hrs-section__actions">{actions}</div>}
      </div>
    )}
    {children}
  </div>
);

/* ---------- window globals (Babel makes top-level consts implicit
   globals already; explicit assignment is the project convention) ---------- */
window.HrsShell = HrsShell;
window.HrsKpis = HrsKpis;
window.HrsFilters = HrsFilters;
window.HrsFieldControl = HrsFieldControl;
window.HrsMultiSelect = HrsMultiSelect;
window.HrsTable = HrsTable;
window.HrsBars = HrsBars;
window.HrsExport = HrsExport;
window.HrsPager = HrsPager;
window.HrsSection = HrsSection;
window.hrsCsv = hrsCsv;
window.hrsToast = hrsToast;
window.hrsMoney = hrsMoney;
window.hrsInt = hrsInt;
window.hrsPct = hrsPct;
window.hrsPeriodOptions = hrsPeriodOptions;
window.hrsPeriodRange = hrsPeriodRange;

/* ==================================================================
   HrsAsync — the loading / empty / error contract, once.

   Every wired screen renders through this. The alternative was each of the
   23 hand-rolled tables inventing its own spinner and its own error text,
   which is exactly the cost docs/ARCHITECTURE_AUDIT.md item 3 priced.

   It takes the api.js envelope directly, so a screen never unpacks
   {ok, data, error} by hand:

     const st = useHrsFetch(() => api.players({start, length}), [start, length]);
     <HrsAsync state={st} empty="No players match.">
       {rows => <HrsTable rows={rows} columns={COLS}/>}
     </HrsAsync>

   The error state is not a dead end: `retriable` errors get a Retry button,
   and auth/shape errors get the reason instead, because retrying those just
   fails again. ================================================== */

const HrsSkeleton = ({ rows = 6, cols = 5 }) => (
  <div className="panel hrs-tenv hrs-skel" aria-busy="true" aria-live="polite">
    <table className="hrs-table">
      <thead><tr>{Array.from({ length: cols }, (_, i) => <th key={i}><span className="hrs-skel__bar"/></th>)}</tr></thead>
      <tbody>
        {Array.from({ length: rows }, (_, r) => (
          <tr key={r}>{Array.from({ length: cols }, (_, c) => (
            <td key={c}><span className="hrs-skel__bar" style={{ width: `${45 + ((r * 7 + c * 13) % 45)}%` }}/></td>
          ))}</tr>
        ))}
      </tbody>
    </table>
    <span className="hrs-skel__sr">Loading…</span>
  </div>
);

const HRS_ERR_TITLE = {
  offline:  "Could not reach the API",
  timeout:  "The API did not respond",
  auth:     "Not authorised",
  notfound: "Endpoint not found",
  server:   "The server returned an error",
  shape:    "Unexpected response",
  aborted:  "Cancelled",
};

const HrsError = ({ error, onRetry }) => (
  <div className="panel hrs-tenv hrs-async-err" role="alert">
    <div className="hrs-async-err__h">
      <Icon name="alert" size={15}/>
      <span>{HRS_ERR_TITLE[error.kind] || "Error"}</span>
      {error.status ? <code className="hrs-async-err__code">HTTP {error.status}</code> : null}
    </div>
    <div className="hrs-async-err__b">{error.message}</div>
    {error.retriable && onRetry && (
      <button className="hrs-btn hrs-btn--filters" onClick={onRetry}>
        <Icon name="refresh" size={13}/> Retry
      </button>
    )}
  </div>
);

const HrsEmpty = ({ children }) => (
  <div className="panel hrs-tenv hrs-async-empty">
    <Icon name="search" size={22} style={{ opacity: .4 }}/>
    <div>{children || "Nothing to show."}</div>
  </div>
);

const HrsAsync = ({ state, empty, skeletonRows, skeletonCols, children }) => {
  if (!state || state.loading) return <HrsSkeleton rows={skeletonRows} cols={skeletonCols}/>;
  if (state.error) return <HrsError error={state.error} onRetry={state.retry}/>;
  const d = state.data;
  if (d == null || (Array.isArray(d) && d.length === 0)) return <HrsEmpty>{empty}</HrsEmpty>;
  return children(d, state.meta || {});
};

/* The hook side of the same contract. Handles the two things every hand-rolled
   fetch gets wrong: it aborts the in-flight request when deps change (so a
   slow first response cannot overwrite a fast second one — and it must, since
   isystem hardcodes sEcho to 0 and gives callers no ordering token), and it
   never sets state after unmount. */
const useHrsFetch = (fn, deps) => {
  const [state, setState] = hrsUseState({ loading: true, data: null, error: null, meta: null });
  const [nonce, setNonce] = hrsUseState(0);
  React.useEffect(() => {
    let alive = true;
    const ctrl = new AbortController();
    setState(s => ({ ...s, loading: true, error: null }));
    Promise.resolve(fn({ signal: ctrl.signal })).then(res => {
      if (!alive) return;
      if (!res || typeof res.ok !== "boolean") {
        setState({ loading: false, data: null, meta: null,
          error: { kind: "shape", status: 0, retriable: false,
                   message: "The data layer returned something that is not an api.js envelope." } });
        return;
      }
      setState(res.ok
        ? { loading: false, data: res.data, meta: res.meta, error: null, source: res.source }
        : { loading: false, data: null, meta: null, error: res.error, source: res.source });
    });
    return () => { alive = false; ctrl.abort(); };
  }, [...(deps || []), nonce]);
  return { ...state, retry: () => setNonce(n => n + 1) };
};

/* The write counterpart to useHrsFetch. Three things every hand-rolled save
   gets wrong, and all three are the difference between a screen that lies and
   one that does not:

     1. It reports success before the server has answered. The row on screen
        moves, the row in the database does not, and nobody finds out until
        someone reloads.
     2. It does not refetch. A save that triggers a default, a trigger or a
        generated column leaves the screen showing what was SENT rather than
        what was STORED — and those differ by design.
     3. It swallows the reason. "Something went wrong" over a constraint
        violation throws away the one sentence that says which rule was broken.

   `run` returns the envelope so a caller can branch (close a modal, keep it
   open with the field highlighted); it never throws. */
const useHrsSave = (feeds) => {
  const [busy, setBusy] = hrsUseState(false);
  const list = Array.isArray(feeds) ? feeds : (feeds ? [feeds] : []);

  const run = async (fn, { done, fail } = {}) => {
    if (busy) return { ok: false, error: { kind: "busy", message: "A save is already in flight." } };
    setBusy(true);
    let res;
    try {
      res = await Promise.resolve(fn());
    } catch (e) {
      res = { ok: false, error: { kind: "server", message: (e && e.message) || "The save threw." } };
    }
    setBusy(false);

    if (res && res.ok) {
      /* Refetch before the toast, so by the time the operator reads "saved"
         the table under it already shows the stored row. */
      list.forEach(f => f && f.retry && f.retry());
      const dropped = res.meta && res.meta.droppedFields;
      hrsToast(done || "Saved",
        dropped && dropped.length
          /* A field the write layer refused is NOT a saved field, and it is
             invisible unless it is said out loud. */
          ? `Stored. ${dropped.length} field(s) were not writable and were ignored: ${dropped.join(", ")}.`
          : "Stored, and the table below has been refreshed from the database.");
    } else {
      const msg = (res && res.error && res.error.message) || "The save failed.";
      hrsToast(fail || "Not saved", msg);
    }
    return res;
  };

  return { busy, run };
};

window.useHrsSave = useHrsSave;
window.HrsAsync = HrsAsync;
window.HrsSkeleton = HrsSkeleton;
window.HrsError = HrsError;
window.HrsEmpty = HrsEmpty;
window.useHrsFetch = useHrsFetch;

/* ==================================================================
   Wave 0 shared primitives — Host surface (design-alignment plan §4).
   CSS for all of these lives in styles/tokens.css ("Wave 0" section).
   ================================================================== */

/* HrsModal — THE shared modal chrome (plan §4.1).
   Usage: {open && <HrsModal title="Block player" size="sm" onClose={close}
            foot={<><button className="btn btn--secondary" onClick={close}>Cancel</button>
                    <button className="btn btn--primary" onClick={save}>Save</button></>}>
            <p className="hrsm-p">Body copy…</p></HrsModal>}
   The canonical hpx-modal shape promoted out of HostPlayers.jsx page CSS
   (scrim / head / body / foot, full-screen ≤860px), portaled to <body>.
   Footer buttons are the `.btn` family per canon §2.4 (secondary cancel
   before primary/danger action). Scrim click, the X and Escape all call
   onClose unless dismissable={false} (use for typed confirms / mid-save).
   Replaces the ~36 per-page modal skins, window.confirm() and hand-rolled
   position:fixed scrims. */
const HrsModal = ({ open = true, title, size = "md", onClose, foot, dismissable = true, children }) => {
  React.useEffect(() => {
    if (!open || !dismissable || !onClose) return;
    const onEsc = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onEsc);
    return () => document.removeEventListener("keydown", onEsc);
  }, [open, dismissable, onClose]);
  if (!open) return null;
  return ReactDOM.createPortal(
    <div className="hrsm-scrim" onClick={dismissable && onClose ? onClose : undefined}>
      <div className={`hrsm hrsm--${size}`} role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
        <div className="hrsm-head">
          <span>{title}</span>
          {onClose && <button type="button" className="hrsm-x" onClick={onClose} title="Close"><Icon name="x" size={14} /></button>}
        </div>
        <div className="hrsm-body">{children}</div>
        {foot && <div className="hrsm-foot">{foot}</div>}
      </div>
    </div>,
    document.body
  );
};

/* HrsConfirm — shared confirm dialog on HrsModal (plan §4.1).
   Usage: {ask && <HrsConfirm title="Archive program?" danger confirmLabel="Archive"
            onConfirm={doIt} onCancel={() => setAsk(false)}>
            <p className="hrsm-p">This hides the program from players.</p></HrsConfirm>}
   - danger: confirm button renders .btn--danger (destructive actions).
   - typed="NAME": typed-confirmation variant — the operator must type the
     string exactly before Confirm enables (skin deletes, kill-jobs).
   - busy: disables both buttons while a save is in flight.
   Replaces the 4 window.confirm() call sites and per-page confirm skins. */
const HrsConfirm = ({ open = true, title = "Are you sure?", children, confirmLabel = "Confirm", cancelLabel = "Cancel", danger, typed, busy, onConfirm, onCancel }) => {
  const [typedVal, setTypedVal] = hrsUseState("");
  const ready = !typed || typedVal === typed;
  return (
    <HrsModal open={open} title={title} size="sm" onClose={onCancel} dismissable={!busy}
      foot={<>
        <button type="button" className="btn btn--secondary" disabled={busy} onClick={onCancel}>{cancelLabel}</button>
        <button type="button" className={`btn ${danger ? "btn--danger" : "btn--primary"}`} disabled={busy || !ready}
          onClick={() => ready && onConfirm && onConfirm()}>{confirmLabel}</button>
      </>}>
      {children}
      {typed && (
        <div className="hrsm-typed">
          <label>Type <code>{typed}</code> to confirm</label>
          <input className="input" value={typedVal} autoFocus placeholder={typed}
            onChange={e => setTypedVal(e.target.value)}
            onKeyDown={e => { if (e.key === "Enter" && ready && !busy) onConfirm && onConfirm(); }} />
        </div>
      )}
    </HrsModal>
  );
};

/* hrsColsLoad — seed a page's column-visibility state from localStorage.
   Usage: const [cols, setCols] = useState(() => hrsColsLoad("tx.cols", COLUMNS));
   Returns the persisted visible-key array (intersected with the current
   column set, required columns always included), or every non-defaultHidden
   key when nothing is stored / storage is unavailable. */
const hrsColsLoad = (storageKey, columns = [], defaultHidden = []) => {
  const all = columns.map(c => c.key);
  const req = columns.filter(c => c.required).map(c => c.key);
  const fallback = all.filter(k => !defaultHidden.includes(k) || req.includes(k));
  if (!storageKey) return fallback;
  try {
    const raw = window.localStorage.getItem(`hrscols:${storageKey}`);
    if (!raw) return fallback;
    const saved = JSON.parse(raw);
    if (!Array.isArray(saved)) return fallback;
    const vis = all.filter(k => saved.includes(k) || req.includes(k));
    return vis.length ? vis : fallback;
  } catch (_e) { return fallback; }
};

/* HrsColsMenu — THE column-visibility picker (plan §4.2).
   Usage: <HrsColsMenu columns={COLUMNS} visible={cols} onChange={setCols} storageKey="tx.cols" />
   - columns: [{ key, label, required? }] — required columns can't be hidden.
   - visible + onChange: controlled visible-key array (seed with hrsColsLoad).
     Omit `visible` for the uncontrolled form (state lives here).
   - storageKey: persists the selection ("Saved automatically" in the foot).
     Deliberate exception to this file's no-localStorage rule — canon §2.16
     says pickers persist, and per-page persistence is how the eight
     divergent pickers each got it wrong.
   - align="left" flips the popover edge; label overrides the button text.
   Replaces ColumnVisibilityPopover, hrs-multi__pop hacks, hsu2-colpop,
   HrctColsBtn, HscColsMenu, Hdu2ColsMenu, HostReportBetType's modal and
   the PlayersList/Methods inline popovers. */
const HrsColsMenu = ({ columns = [], visible, onChange, storageKey, label = "Columns", align = "right" }) => {
  const [open, setOpen] = hrsUseState(false);
  const [inner, setInner] = hrsUseState(() => hrsColsLoad(storageKey, columns));
  const vis = visible !== undefined ? visible : inner;
  const commit = (next) => {
    if (visible === undefined) setInner(next);
    if (storageKey) { try { window.localStorage.setItem(`hrscols:${storageKey}`, JSON.stringify(next)); } catch (_e) {} }
    onChange && onChange(next);
  };
  const toggle = (c) => {
    if (c.required) return;
    /* Store in column order so adjacent code can rely on it. */
    const next = vis.includes(c.key) ? vis.filter(k => k !== c.key) : [...vis, c.key];
    commit(columns.map(x => x.key).filter(k => next.includes(k)));
  };
  const reset = () => commit(columns.map(c => c.key));
  return (
    <div className="hrscm">
      <button type="button" className="hrscm-btn" onClick={() => setOpen(o => !o)} aria-expanded={open}>
        <Icon name="settings" size={13} /> {label}
      </button>
      {open && (
        <>
          <div className="hrscm-scrim" onClick={() => setOpen(false)} />
          <div className={`hrscm-pop${align === "left" ? " hrscm-pop--left" : ""}`} role="dialog" aria-label="Columns">
            <div className="hrscm-head">
              <Icon name="settings" size={12} style={{ color: "var(--text-secondary)" }} />
              <div style={{ flex: 1 }}>
                <div className="hrscm-head-t">Columns</div>
                <div className="hrscm-head-s">Show or hide what's on the table</div>
              </div>
              <button type="button" className="hrsm-x hrscm-x" onClick={() => setOpen(false)} title="Close"><Icon name="x" size={10} /></button>
            </div>
            <div className="hrscm-list">
              {columns.map(c => {
                const on = vis.includes(c.key);
                return (
                  <label key={c.key} className={`hrscm-opt${c.required ? " hrscm-opt--req" : ""}`}>
                    <input type="checkbox" checked={on} disabled={!!c.required} onChange={() => toggle(c)} />
                    <span className={`hrscm-opt-l${on ? " on" : ""}`}>{c.label}</span>
                    {c.required && <span className="hrscm-req">Required</span>}
                  </label>
                );
              })}
            </div>
            <div className="hrscm-foot">
              <button type="button" className="hrscm-reset" onClick={reset}>Reset to defaults</button>
              {storageKey && <span className="hrscm-saved">Saved automatically</span>}
            </div>
          </div>
        </>
      )}
    </div>
  );
};

/* HrsRowActions — shared row-action icon buttons (plan §4.3).
   Usage: <HrsRowActions actions={[
            { icon:"edit",  title:"Edit",   onClick:() => edit(r) },
            { icon:"trash", title:"Delete", tone:"danger", onClick:() => del(r) },
            { icon:"mail",  title:"Resend", need:"POST /messages/resend" },  // renders via NoBackend
          ]} />
   Builds on `.btn--icon` (tokens.css); tone ∈ "danger" | "primary".
   `need` renders the honest disabled NoBackend form instead of a live
   button. Replaces the ~13 per-page `*-act` families. */
const HrsRowActions = ({ actions = [] }) => (
  <span className="hrs-rowact" onClick={e => e.stopPropagation()}>
    {actions.filter(a => a && !a.hidden).map((a, i) => {
      const cls = `btn btn--ghost btn--icon btn--sm${a.tone ? ` hrs-rowact--${a.tone}` : ""}`;
      if (a.need) return <NoBackend key={a.key || a.title || i} need={a.need} what={a.title} className={cls}><Icon name={a.icon} size={13} /></NoBackend>;
      return (
        <button key={a.key || a.title || i} type="button" className={cls} title={a.title}
          disabled={a.disabled} onClick={a.onClick}>
          <Icon name={a.icon} size={13} />
        </button>
      );
    })}
  </span>
);

/* HrsSearchSelect — searchable select / select2 stand-in (plan §4.7).
   Usage: <HrsSearchSelect placeholder="Search user" options={pool} picked={user} onPick={setUser}
            getLabel={u => u.username} getSub={u => u.skin} minChars={3} />
   Prefix-matches getLabel (override with filterFn(option, term)), sorts
   ascending, caps at maxResults with an honest "first N shown" hint —
   mirroring the real platform's select2 paging. Generalizes HostDeposit's
   HtfSearchSelect and HostSportBet's player search. */
const HrsSearchSelect = ({ placeholder, options = [], picked, onPick, minChars = 3, maxResults = 10,
  getLabel, getSub, getKey, filterFn, renderItem, hint, note, emptyText = "No matches.", disabled }) => {
  const lab = getLabel || ((o) => o == null ? "" : (o.label != null ? o.label : (o.username != null ? o.username : String(o))));
  const sub = getSub || ((o) => o && o.sub);
  const keyOf = getKey || ((o, i) => (o && (o.id != null ? o.id : o.value)) != null ? (o.id != null ? o.id : o.value) : i);
  const [q, setQ] = hrsUseState(picked ? lab(picked) : "");
  const [open, setOpen] = hrsUseState(false);
  const wrapRef = hrsUseRef(null);
  React.useEffect(() => {
    const onDoc = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, []);
  React.useEffect(() => { setQ(picked ? lab(picked) : ""); }, [picked ? lab(picked) : ""]);
  const term = q.trim().toLowerCase();
  const match = filterFn || ((o, t) => lab(o).toLowerCase().startsWith(t));
  const results = term.length >= minChars
    ? options.filter(o => match(o, term)).sort((a, b) => lab(a).localeCompare(lab(b))).slice(0, maxResults)
    : [];
  return (
    <div className={`hrs-ssel${picked ? " hrs-ssel--picked" : ""}`} ref={wrapRef}>
      <div className="hrs-ssel__wrap">
        <span className="hrs-ssel__icon"><Icon name="search" size={13} /></span>
        <input className="input" value={q} placeholder={placeholder} disabled={disabled}
          onFocus={() => setOpen(true)}
          onChange={e => { setQ(e.target.value); setOpen(true); if (picked) onPick(null); }} />
        {picked && (
          <button type="button" className="hrs-ssel__clear" onClick={() => { onPick(null); setQ(""); }} title="Clear selection">
            <Icon name="x" size={12} />
          </button>
        )}
      </div>
      {open && !picked && !disabled && (
        <div className="hrs-ssel__drop">
          {term.length < minChars ? (
            <div className="hrs-ssel__hint">{hint || `Type at least ${minChars} characters to search.`}</div>
          ) : results.length === 0 ? (
            <div className="hrs-ssel__hint">{emptyText}</div>
          ) : results.map((o, i) => (
            <button key={keyOf(o, i)} type="button" className="hrs-ssel__item" onClick={() => { onPick(o); setOpen(false); }}>
              {renderItem ? renderItem(o) : <>
                <span className="hrs-ssel__item-l">{lab(o)}</span>
                {sub(o) != null && <span className="hrs-ssel__item-s">{sub(o)}</span>}
              </>}
            </button>
          ))}
          {term.length >= minChars && results.length === maxResults && (
            <div className="hrs-ssel__hint">First {maxResults} shown — keep typing to narrow the match.</div>
          )}
        </div>
      )}
      {note && <div className="hrs-ssel__note">{note}</div>}
    </div>
  );
};

/* HrsNotice — shared callout/notice block (plan §4.8).
   Usage: <HrsNotice tone="warn" title="Truncated">Showing the first 2,000 rows.</HrsNotice>
   tone ∈ "info" (default) | "warn" | "err" | "ok"; children may be prose
   or a <ul> of validation errors. `actions` renders trailing buttons,
   compact tightens padding. Replaces the borrowed hu-errbox / hma-errs /
   hpc-warn one-offs and inline truncation banners. */
const HRS_NOTICE_ICON = { info: "info", warn: "alert", err: "alert", ok: "check" };
const HrsNotice = ({ tone = "info", title, children, actions, compact, icon }) => (
  <div className={`hrs-notice hrs-notice--${tone}${compact ? " hrs-notice--compact" : ""}`} role={tone === "err" ? "alert" : undefined}>
    <span className="hrs-notice__icon"><Icon name={icon || HRS_NOTICE_ICON[tone] || "info"} size={14} /></span>
    <div className="hrs-notice__body">
      {title && <div className="hrs-notice__title">{title}</div>}
      {children}
    </div>
    {actions && <div className="hrs-notice__actions">{actions}</div>}
  </div>
);

window.HrsModal = HrsModal;
window.HrsConfirm = HrsConfirm;
window.HrsColsMenu = HrsColsMenu;
window.hrsColsLoad = hrsColsLoad;
window.HrsRowActions = HrsRowActions;
window.HrsSearchSelect = HrsSearchSelect;
window.HrsNotice = HrsNotice;
