// NO PROD JSON API (bucket B — feed is a DataTables render payload or
//   {"html":...}, not data) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: GET /players/ · PlayersController::index (+detail tabs) — see docs/ISYSTEM_REFERENCE.md §Batch 1 "Players"
/* Host-level Players module (white-label header → "Players").

   Distinct from the PayBO Payments → Players section. Shows the operator's
   player list (grid data: GET /getPlayers → PlayersController::getPlayersList)
   and an "Edit player" view with the real detail tabs
   (Home · Transactions · Sport Coupon History · History · Promotions ·
   Jackpot · Deposit · Logs · Verifications · Player stats).

   PROTECTED REGIONS — do not touch (docs/UIUX_ELEVATION_BRIEF.md §4.2/§4.3):
   - Promotions tab: HostPlayerPromotions + the PB_* helpers + genPlayerBonuses
     (BONUS_PROMO_REPORTS_SPEC.md, Task 4).
   - History tab + wallet filter: HpTxFilters, useHpLedger, useHpTxTypes,
     HpPager, the History render block and its state hooks
     (PLAYER_HISTORY_WALLET_FILTER_TASK.md).
   Everything else in this file is the Batch-1 elevation pass. */

const { useState: useStateHP, useMemo: useMemoHP } = React;

/* Per-player bonus history (Promotions tab). Deterministic from the player id
   so it stays stable across re-opens. */
const PB_PROMOS = ["300% Welcome Bonus Casino", "First Deposit 150 Percent Match", "Meet - Third Deposit Cashback", "2nd deposit", "PromoCode", "bet based"];
const PB_TYPOLOGY = { "300% Welcome Bonus Casino": "Wagering bonus", "First Deposit 150 Percent Match": "Wagering bonus", "Meet - Third Deposit Cashback": "Cash bonus", "2nd deposit": "Freespin", "PromoCode": "Wagering bonus", "bet based": "Freespin" };
const PB_STATUS_CHIP = { Active: "chip--ok", Redeemed: "chip--info", Expired: "chip--neutral", Failed: "chip--err", Canceled: "chip--warn" };
const pbP2 = (n) => String(n).padStart(2, "0");
const pbDate = (ts) => { const d = new Date(ts); return `${pbP2(d.getUTCDate())}/${pbP2(d.getUTCMonth() + 1)}/${String(d.getUTCFullYear()).slice(2)} ${pbP2(d.getUTCHours())}:${pbP2(d.getUTCMinutes())}`; };
const pbMoney = (n) => Number(n || 0).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
/* WAS `genPlayerBonuses(playerId)`: a mulberry32 seeded from the player id that
   produced three to seven bonus rows with invented amounts — bonus_amount,
   wagered, remaining wagering, redeemed amount, and a balance. Deterministic,
   so re-opening a player showed the same figures and they looked like records.

   Money on a player screen, with nothing on the screen saying it was fiction.

   `bonus_player_promotions` (004) is a view over `bonus_instances` shaped for
   exactly this tab, and its `status_label` is the `ui_label` column — which is
   why the vocabulary below still reads Active / Redeemed / Expired / Failed /
   Canceled without a mapping table here. A player with no bonus history now
   shows an empty tab, which is the true answer. */
const hpPromoRow = (r) => ({
  id: r.bonus_instance_id,
  name: r.campaign_name || "-",
  typology: r.typology || "-",
  status: r.status_label || r.status,
  bonusAmount: Number(r.bonus_amount || 0),
  balance: Number(r.balance || 0),
  wagered: Number(r.wagered_amount || 0),
  wageringRemaining: Number(r.wagering_remaining || 0),
  redeemedAmount: Number(r.redeemed_amount || 0),
  /* Dates are epoch ms here because every filter and formatter on this tab
     compares numbers. A null expiry sorts and filters as "no limit" rather
     than as 1970. */
  date: r.issued_at ? Date.parse(r.issued_at) : 0,
  expiry: r.expires_at ? Date.parse(r.expires_at) : Infinity,
  redeemedAt: r.redeemed_at ? Date.parse(r.redeemed_at) : null,
});

const hpP2 = (n) => String(n).padStart(2, "0");
const hpDate = (ts, secs = false) => {
  if (ts == null) return "-";
  const d = new Date(ts);
  const base = `${hpP2(d.getUTCDate())}/${hpP2(d.getUTCMonth() + 1)}/${d.getUTCFullYear()} ${hpP2(d.getUTCHours())}:${hpP2(d.getUTCMinutes())}`;
  return secs ? `${base}:${hpP2(d.getUTCSeconds())}` : base;
};
const hpARS = (n) => "ARS " + Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const hpParse = (s) => Date.parse(s.replace(" ", "T") + "Z");

/* ------------------------------------------------------------------ *
 * Players — read from `users` (level 30) and `ledger_entries`.
 *
 * What used to be here: five "featured" players with real-looking ids, a
 * generator that grew 38 more, a per-player persona generator that invented a
 * first name, a surname, an Argentinian mobile number, an email, an IP, three
 * loss figures and — when the row happened to be blocked — a compliance note
 * signed "admin365" saying a chargeback had been opened. And two ledger
 * generators that produced a plausible bet-and-win history with running
 * balances that never came from anywhere.
 *
 * A blocked player now shows the reason in `user_blocks.reason` or shows
 * nothing. Losses are not computed anywhere yet and are marked as such rather
 * than filled in.
 * ------------------------------------------------------------------ */
const hpTs = (iso) => { if (!iso) return null; const t = Date.parse(iso); return isNaN(t) ? null : t; };
const hpAmt = (v) => (v == null ? 0 : Number(v) || 0);
const hpOne = (v) => (Array.isArray(v) ? (v[0] || null) : (v || null));

/* History amounts, grouped the way the live platform prints them (5,540.00),
   and the seconds-precision timestamp its History column uses. Both were
   defined in the generator block this file used to open with; deleting that
   block took them with it while three call sites in the History table kept
   using them, so the tab threw the moment it rendered a row. tools/gate.js
   grew a check for exactly this. */
const hpNum = (n) => Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const hpDateSec = (ts) => {
  if (!ts) return "—";
  const d = new Date(ts);
  if (isNaN(d)) return "—";
  return `${hpP2(d.getUTCDate())}/${hpP2(d.getUTCMonth() + 1)}/${String(d.getUTCFullYear()).slice(2)} ` +
         `${d.getUTCHours()}:${hpP2(d.getUTCMinutes())}:${hpP2(d.getUTCSeconds())}`;
};

/* users(level 30) -> the row shape the list and the editor render.
   `wd` / `nwd` are the two halves of real money: balance_withdrawable and
   balance. isystem's own export mixes these up; keeping them separate here is
   the reason the schema stores them separately. */
const hpPlayerRow = (r) => {
  const w = hpOne(r.wallet) || {};
  return {
    id: Number(r.id),
    username: String(r.username || ""),
    parent: r.parent ? r.parent.username : "",
    parentId: r.parent_id == null ? null : Number(r.parent_id),
    skin: r.skin ? r.skin.name : "",
    skinId: r.skin_id == null ? null : Number(r.skin_id),
    cur: r.currency || (r.skin ? r.skin.currency : "") || "",
    wd: hpAmt(w.balance_withdrawable),
    nwd: hpAmt(w.balance),
    bonus: hpAmt(w.bonus),
    credits: hpAmt(w.credits),
    reg: hpTs(r.created_at),
    last: hpTs(r.last_login_at),
    cashBlock: !!r.cash_blocked,
    userBlock: !!r.blocked,
    path: String(r.path || ""),
    firstname: r.firstname || "",
    lastname: r.lastname || "",
    email: r.email || "",
    mobile: r.mobile || "",
    /* users has no mobile_verified column. Rendering a green tick from a coin
       flip is exactly the kind of claim this pass removes.
       <!-- SUGGESTION: add users.mobile_verified_at. The signup flow validates a mobile through the MobileValidators strategies but records nothing, so no screen can answer "is this number confirmed". --> */
    mobileVerified: null,
    testUser: !!r.test_user,
    twoFactor: !!r.two_factor_enabled,
    ip: r.last_login_ip || "",
    regIp: r.registration_ip || "",
    /* 028's profile columns. Carried so the Home tab renders what is stored
       rather than an empty box; "" everywhere means "not recorded" and is
       never a placeholder. */
    gender: r.gender || "",
    birthdate: r.birthdate ? String(r.birthdate).slice(0, 10) : "",
    country: r.country || "",
    province: r.province || "",
    city: r.city || "",
    address: r.address || "",
    postcode: r.postcode || "",
    documentType: r.document_type || "",
    documentNumber: r.document_number || "",
    affiliateSource: r.affiliate_source || "",
    externalAffiliateId: r.external_affiliate_id || "",
    trackingData: r.tracking_data || "",
    /* Per-category loss is a report aggregate over the ledger, not a column.
       Left null so the cell can say so instead of showing a number. */
    sportLoss: null, casinoLoss: null, virtualLoss: null,
  };
};

/* ledger_entries -> the History/Transactions row shape.
   `balance` is balance_after, which post_transaction() writes inside the same
   transaction as the balance itself — so the running total on screen is the
   one the database committed, not one recomputed in the browser from a
   starting guess. */
const hpLedgerRow = (r) => ({
  hid: r.idempotency_key || String(r.id),
  nid: Number(r.id),
  typology: r.type ? r.type.label : `Type ${r.type_id}`,
  typeId: Number(r.type_id),
  description: r.description || "",
  reference: r.external_reference || "",
  amount: hpAmt(r.amount),
  balance: hpAmt(r.balance_after),
  wallet: r.wallet || "real",
  currency: r.currency || "",
  counterparty: r.counterparty ? r.counterparty.username : "",
  ts: hpTs(r.created_at),
});

/* The History tab splits by wallet, and each wallet offers its own type list.
   Which types belong to which wallet is a property of the type, so it is read
   off transaction_types rather than restated as two hand-written arrays that
   drift from the table. `wallet` on a ledger row is the authority; the code
   prefix is how a type declares which side it belongs to. */
const hpIsBonusType = (t) => /bonus/i.test(String(t.code || "")) || /bonus/i.test(String(t.label || ""));

const useHpTxTypes = () => {
  const feed = useHrsFetch(() => window.sb.list("transactionTypes", { limit: 200 }), []);
  const all = useMemoHP(() => (feed.data || []).map(t => ({ id: Number(t.id), code: t.code, label: t.label })), [feed.data]);
  return {
    ...feed,
    all,
    real:  useMemoHP(() => all.filter(t => !hpIsBonusType(t)), [all]),
    bonus: useMemoHP(() => all.filter(t => hpIsBonusType(t)), [all]),
  };
};

/* Per-player ledger, both wallets, one read each. Filtered server-side: a
   player's history is unbounded and pulling all of it to filter in the browser
   is how a screen becomes unusable on the one account that matters. */
const useHpLedger = (playerId) => {
  const real = useHrsFetch(
    () => window.sb.list("ledger", { limit: 500, filters: { user: playerId, wallet: "real" } }),
    [playerId]);
  const bonus = useHrsFetch(
    () => window.sb.list("ledger", { limit: 500, filters: { user: playerId, wallet: "bonus" } }),
    [playerId]);
  const history = useMemoHP(() => (real.data || []).map(hpLedgerRow), [real.data]);
  const bonusHistory = useMemoHP(() => (bonus.data || []).map(hpLedgerRow), [bonus.data]);
  return {
    loading: real.loading || bonus.loading,
    error: real.error || bonus.error,
    retry: () => { real.retry(); bonus.retry(); },
    history,
    bonusHistory,
    /* The Transactions tab is the deposit/withdraw subset of the real wallet.
       Type ids 1 and 2 — the constants, not the labels, which are translated. */
    transactions: useMemoHP(() => history.filter(e => e.typeId === 1 || e.typeId === 2), [history]),
  };
};

/* Per-player login feed — login_events (isystem users_login). */
const useHpLoginLogs = (playerId) => {
  const feed = useHrsFetch(
    () => window.sb.list("loginEvents", { limit: 100, filters: { user: playerId } }),
    [playerId]);
  return { ...feed, rows: useMemoHP(() => (feed.data || []).map(r => ({ ts: hpTs(r.occurred_at), ip: r.ip || "—" })), [feed.data]) };
};

/* Per-player block notes — user_blocks, both ends of each block. */
const useHpBlockNotes = (playerId) => {
  const feed = useHrsFetch(
    () => window.sb.list("userBlocks", { limit: 100, filters: { user: playerId } }),
    [playerId]);
  return {
    ...feed,
    rows: useMemoHP(() => (feed.data || []).map(b => ({
      ts: hpTs(b.blocked_at),
      type: "User block",
      by: b.blockedBy ? b.blockedBy.username : "—",
      comment: b.reason || "",
      unblockedAt: hpTs(b.unblocked_at),
      unblockedBy: b.unblockedBy ? b.unblockedBy.username : null,
    })), [feed.data]),
  };
};

/* Per-player identity verification — user_kyc (the account's decision) plus
   user_kyc_documents (each upload's own).

   TWO FEEDS, NOT ONE JOIN, and the reason is that they answer different
   questions: an account can be approved with no documents on file (a manual
   decision), and documents can exist with no account decision yet (the queue).
   Rendering one from the other would invent whichever half is missing.

   NO ROW IS NOT `status = none`. A player who has never been reviewed has no
   user_kyc row at all; the screen says "Never submitted" for that and "Not
   started" for an explicit `none`, because a compliance question wants to know
   which of the two it is.

   AN EMPTY LIST CAN ALSO MEAN "NOT ALLOWED TO SEE IT": 038 restricts these two
   tables to levels 0/2/4, so an agent's session reads zero rows rather than an
   error — RLS filters, it does not refuse. The tab reads the operator's own
   level and says which kind of empty this is, because "this player uploaded
   nothing" and "your role cannot see what they uploaded" are opposite facts
   that render identically. */
const useHpKyc = (playerId) => {
  const acct = useHrsFetch(
    () => window.sb.list("userKyc", { limit: 1, filters: { user: playerId } }),
    [playerId]);
  const docs = useHrsFetch(
    () => window.sb.list("userKycDocuments", { limit: 100, filters: { user: playerId } }),
    [playerId]);
  const row = useMemoHP(() => ((acct.data || [])[0] || null), [acct.data]);
  return {
    loading: acct.loading || docs.loading,
    error: acct.error || docs.error,
    retry: () => { acct.retry(); docs.retry(); },
    row,
    /* The status CODE, which is what set_kyc_status takes — never the label,
       which is display text and would break the moment it is translated. */
    code: row && row.status ? row.status.code : null,
    label: row && row.status ? row.status.label : null,
    decidedBy: row && row.decidedBy ? row.decidedBy.username : null,
    decidedAt: row ? hpTs(row.decided_at) : null,
    reason: row ? row.reason : null,
    expiresAt: row ? hpTs(row.expires_at) : null,
    docs: useMemoHP(() => (docs.data || []).map(d => ({
      id: d.id,
      type: d.doc_type,
      path: d.storage_path,
      code: d.status ? d.status.code : null,
      label: d.status ? d.status.label : "—",
      reviewedBy: d.reviewedBy ? d.reviewedBy.username : null,
      reviewedAt: hpTs(d.reviewed_at),
      note: d.note || "",
      createdAt: hpTs(d.created_at),
    })), [docs.data]),
  };
};

/* ---------------- Reusable filtered transaction table ---------------- */
/* withWallet adds the Real / Bonus wallet selector (History tab). The wallet
   choice swaps the Transaction Type options between the real- and bonus-wallet
   type sets. `wallet` / `onWallet` are controlled by the parent so the table
   below can render the matching ledger. */
/* THE FILTER PANEL, WHICH USED TO FILTER NOTHING. Every input was uncontrolled
   and Search had no handler: typing a date and pressing it left the same rows on
   screen, which reads as "no transactions match" rather than as "the button does
   not work". A filter that appears to have been applied is worse than one that
   is visibly disabled.

   Controlled by the caller through `draft` / `onDraft` / `onApply`, in the same
   draft-then-apply shape the Promotions tab and the list screen already use, so
   typing does not re-filter on every keystroke.

   THE TYPE FILTER MATCHES ON typeId, NOT ON THE LABEL. Labels come from
   transaction_types and are translatable; matching on one means the filter
   silently stops matching anything the day a translation lands, and a filter
   that returns nothing looks like an answer. */
const HpTxFilters = ({ withType = true, withWallet = false, wallet = "real", onWallet,
                       draft, onDraft, onApply, onReset }) => {
  /* Type options come from transaction_types, not from two hand-written arrays.
     The ids are persisted and shared with every report, so a label that drifts
     from the table is a label that names the wrong money. */
  const types = useHpTxTypes();
  const typeOpts = (!withWallet ? types.all : (wallet === "bonus" ? types.bonus : types.real));
  const d = draft || {};
  const set = (patch) => onDraft && onDraft(patch);
  return (
  <div className="rpt-filters hp-txfilters">
    <div className="rpt-field"><label>Transaction date</label><div className="rpt-daterow">
      <input className="input rpt-date" type="date" value={d.from || ""} onChange={e => set({ from: e.target.value })} />
      <input className="input rpt-date" type="date" value={d.to || ""} onChange={e => set({ to: e.target.value })} />
    </div></div>
    {withWallet && <div className="rpt-field" style={{ minWidth: 180 }}><label>Wallet</label>
      <select className="select" style={{ width: "100%" }} value={wallet} onChange={e => onWallet && onWallet(e.target.value)}>
        <option value="real">Real Wallet</option>
        <option value="bonus">Bonus Wallet</option>
      </select>
    </div>}
    {withType && <div className="rpt-field" style={{ flex: 1, minWidth: 260 }}><label>Transaction Type</label>
      <select className="select" style={{ width: "100%" }} value={d.typeId || "ALL"}
              onChange={e => set({ typeId: e.target.value })}>
        <option value="ALL">-ALL-</option>
        {typeOpts.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
      </select>
    </div>}
    <div className="rpt-field"><label>{withWallet ? "Reference" : "Transaction ID"}</label>
      <input className="input" placeholder={withWallet ? "Reference" : "Transaction ID"}
             value={d.ref || ""} onChange={e => set({ ref: e.target.value })} /></div>
    <div className="rpt-actions">
      <div className="rpt-actions-row" style={{ flexDirection: "column", gap: 8 }}>
        <button className="rpt-btn rpt-btn--reset" onClick={onReset}><Icon name="x" size={14} /> Reset</button>
        <button className="rpt-btn rpt-btn--search" onClick={onApply}><Icon name="search" size={14} /> Search</button>
      </div>
    </div>
  </div>
  );
};

/* Applied over rows already fetched. The ledger read is capped, so this narrows
   what was loaded rather than asking the server for a narrower set — which is
   why every screen using it states the cap. */
const HP_TXF_EMPTY = { from: "", to: "", typeId: "ALL", ref: "" };
const hpTxFilter = (rows, f) => {
  const dF = f.from ? Date.parse(f.from + "T00:00:00Z") : -Infinity;
  const dT = f.to ? Date.parse(f.to + "T23:59:59Z") : Infinity;
  const ref = String(f.ref || "").trim().toLowerCase();
  return rows.filter(r => {
    if (r.ts != null && (r.ts < dF || r.ts > dT)) return false;
    if (f.typeId && f.typeId !== "ALL" && r.typeId !== Number(f.typeId)) return false;
    if (ref && !String(r.reference).toLowerCase().includes(ref)
            && !String(r.hid).toLowerCase().includes(ref)
            && !String(r.nid).includes(ref)) return false;
    return true;
  });
};

/* Footer "Show N entries" + numbered page switcher, matching the live platform.
   Windows the page numbers (first · last · ±2 around current) with … gaps. */
const HpPager = ({ total, page, setPage, pageSize, setPageSize }) => {
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
  const nums = [];
  for (let i = 0; i < totalPages; i++) {
    if (i === 0 || i === totalPages - 1 || Math.abs(i - page) <= 2) nums.push(i);
    else if (nums[nums.length - 1] !== "…") nums.push("…");
  }
  const from = total === 0 ? 0 : page * pageSize + 1;
  const to = Math.min(total, (page + 1) * pageSize);
  return (
    <div className="hp-pager">
      <div className="hp-pager__show">
        Show
        <select className="select" value={pageSize} onChange={e => { setPageSize(+e.target.value); setPage(0); }}>
          {[10, 25, 50, 100].map(n => <option key={n} value={n}>{n}</option>)}
        </select>
        entries
        <span className="hp-pager__range">{from.toLocaleString()}–{to.toLocaleString()} of {total.toLocaleString()}</span>
      </div>
      <div className="hp-pager__pages">
        <button className="hp-pg-arrow" disabled={page === 0} onClick={() => setPage(p => Math.max(0, p - 1))}><Icon name="chevron_left" size={14} /></button>
        {nums.map((n, i) => n === "…"
          ? <span key={"e" + i} className="hp-pg-ellipsis">…</span>
          : <button key={n} className={`hp-pg-num ${n === page ? "active" : ""}`} onClick={() => setPage(n)}>{n + 1}</button>)}
        <button className="hp-pg-arrow" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}><Icon name="chevron_right" size={14} /></button>
      </div>
    </div>
  );
};

/* ================================================================
   Elevation-pass helpers (all new top-level names are Hpx-prefixed)
   ================================================================ */

/* KYC — the SUGGESTION that used to sit here ("model KYC; nothing in this
   schema covers it") was acted on. 028 added kyc_statuses, user_kyc and
   user_kyc_documents, 038 named the read audience, and 050 added
   set_kyc_status(). The stub that returned [] is gone; the tab reads
   useHpKyc() above and decides through the RPC. */

/* Player stats aggregates (detail → Player stats tab; real:
   PlayerStatsService::forPlayer() — "lifetime + last-30-days aggregates".
   Exact field list is not extracted in docs/ISYSTEM_REFERENCE.md (deep per-tab
   detail = future work), so the panels below derive honest aggregates from the
   same deterministic ledger the other tabs already render — fields inferred. */
/* transaction_types ids. Persisted values shared with every report — matching
   on the LABEL would break the moment a translation lands, and silently: a
   mismatched label just contributes nothing and the totals read low. */
const HPX_TYPE = { DEPOSIT: 1, WITHDRAW: 2, SPORT_BET: 3, SPORT_WIN: 4, CASINO_BET: 5, CASINO_WIN: 6 };

const hpxStats = (ledger) => {
  const agg = (rows) => {
    const s = { depCount: 0, depTotal: 0, wdCount: 0, wdTotal: 0, cBet: 0, cBetTotal: 0, cWinTotal: 0, sBet: 0, sBetTotal: 0, sWinTotal: 0 };
    rows.forEach(e => {
      if (e.typeId === HPX_TYPE.DEPOSIT) { s.depCount++; s.depTotal += e.amount; }
      else if (e.typeId === HPX_TYPE.WITHDRAW) { s.wdCount++; s.wdTotal += -e.amount; }
      else if (e.typeId === HPX_TYPE.CASINO_BET) { s.cBet++; s.cBetTotal += -e.amount; }
      else if (e.typeId === HPX_TYPE.CASINO_WIN) { s.cWinTotal += e.amount; }
      else if (e.typeId === HPX_TYPE.SPORT_BET) { s.sBet++; s.sBetTotal += -e.amount; }
      else if (e.typeId === HPX_TYPE.SPORT_WIN) { s.sWinTotal += e.amount; }
    });
    s.casinoGGR = s.cBetTotal - s.cWinTotal;
    s.sportGGR = s.sBetTotal - s.sWinTotal;
    return s;
  };
  const all = ledger.history;
  const latest = all.length ? all[0].ts : 0;
  return { lifetime: agg(all), last30: agg(all.filter(e => e.ts >= latest - 30 * 86400000)) };
};

/* Small clearable active-filter pill (list page). */
const HpxPill = ({ label, onClear }) => (
  <span className="hpx-pill">{label}<button onClick={onClear} title="Remove filter"><Icon name="x" size={9} /></button></span>
);

/* Honest "not wired yet" control. These actions genuinely exist on the real
   screen, so the button stays visible and keeps documenting them
   (docs/UIUX_ELEVATION_BRIEF.md §3 — real functionality must not disappear from
   the map), but it renders DISABLED and names the endpoint a backend engineer
   has to wire, instead of firing a toast that implies something happened.
   The tooltip lives on the wrapper span on purpose: browsers suppress `title`
   on a disabled control, so the hint needs an enabled ancestor to hang off. */
const HpxNoBackend = ({ need, what, className = "", children, style, block }) => (
  <span
    className="hpx-nobackend"
    style={{ display: block ? "block" : "inline-flex", cursor: "not-allowed" }}
    title={`${what ? what + " — " : ""}not wired in this prototype · requires backend: ${need}`}
  >
    <button type="button" className={className} disabled aria-disabled="true"
      style={{ opacity: .45, cursor: "not-allowed", pointerEvents: "none", ...(style || {}) }}>
      {children}
    </button>
  </span>
);

/* Short line under a disabled SAVE, so the reason is readable without hovering. */
const HpxNoBackendNote = ({ children }) => (
  <div style={{ marginTop: 8, fontSize: 11.5, color: "var(--text-tertiary)", textAlign: "center" }}>{children}</div>
);

/* Cross-link into the prototype's own Deposit/Transfer screen (route key
   host-deposit → /deposit) — the pushState + PopStateEvent convention app.jsx
   listens on. The real row link is
   `/transfer/?from={caller|parent}&type=player&to={id}`; that context is carried
   in the query string so the URL still documents the target, but HostDeposit
   does not read it yet — the buttons say so rather than implying a preselect. */
const hpxNavTransfer = (playerId) => {
  try {
    const path = (window.pathForActive && window.pathForActive("host-deposit")) || "/deposit";
    window.history.pushState({ active: "host-deposit" }, "", `${path}?type=player&to=${playerId}`);
    window.dispatchEvent(new PopStateEvent("popstate"));
  } catch (_e) { /* no-op: nothing to fake if history is unavailable */ }
};

/* List-footer pager — same chrome as HpPager but with the players grid's real
   lengthMenu [5, 10, 25, 50] (default 50). HpPager itself is pinned to the
   protected History tab and must not change. */
const HpxPager = ({ total, page, setPage, pageSize, setPageSize, sizes = [5, 10, 25, 50] }) => {
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
  const nums = [];
  for (let i = 0; i < totalPages; i++) {
    if (i === 0 || i === totalPages - 1 || Math.abs(i - page) <= 2) nums.push(i);
    else if (nums[nums.length - 1] !== "…") nums.push("…");
  }
  const from = total === 0 ? 0 : page * pageSize + 1;
  const to = Math.min(total, (page + 1) * pageSize);
  return (
    <div className="hp-pager">
      <div className="hp-pager__show">
        Show
        <select className="select" value={pageSize} onChange={e => { setPageSize(+e.target.value); setPage(0); }}>
          {sizes.map(n => <option key={n} value={n}>{n}</option>)}
        </select>
        entries
        <span className="hp-pager__range">{from.toLocaleString()}–{to.toLocaleString()} of {total.toLocaleString()}</span>
      </div>
      <div className="hp-pager__pages">
        <button className="hp-pg-arrow" disabled={page === 0} onClick={() => setPage(p => Math.max(0, p - 1))}><Icon name="chevron_left" size={14} /></button>
        {nums.map((n, i) => n === "…"
          ? <span key={"e" + i} className="hp-pg-ellipsis">…</span>
          : <button key={n} className={`hp-pg-num ${n === page ? "active" : ""}`} onClick={() => setPage(n)}>{n + 1}</button>)}
        <button className="hp-pg-arrow" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}><Icon name="chevron_right" size={14} /></button>
      </div>
    </div>
  );
};

/* Column set of the real grid (PlayersController::index L3033-3063). The
   default-hidden set mirrors the live admin's localStorage
   `hide_player_table_columns` defaults; `pd` marks columns the backend strips
   for scoped managers without support_player_personal_data. */
const HPX_COLUMNS = [
  { id: "id", label: "ID", always: true, sort: "id" },
  { id: "username", label: "Username", always: true, sort: "username" },
  { id: "parent", label: "Parent", sort: "parent" },
  { id: "skin", label: "Skin", sort: "skin", hidden: true },
  { id: "balance", label: "Balance", always: true, sort: "balance" },
  { id: "email", label: "Email", sort: "email", hidden: true, pd: true },
  { id: "reg", label: "Registration date", sort: "registration_date", hidden: true },
  { id: "last", label: "Last access", sort: "last_login" },
  { id: "cash", label: "Cash block" },
  { id: "block", label: "User block" },
  { id: "firstname", label: "Name", sort: "firstname", hidden: true, pd: true },
  { id: "lastname", label: "Lastname", sort: "lastname", hidden: true, pd: true },
  { id: "ip", label: "Last Login IP", sort: "last_login_ip", hidden: true, pd: true },
  { id: "mobile", label: "Mobile phone", sort: "mobile_phone", hidden: true, pd: true },
  { id: "actions", label: "Actions", always: true },
];
const HPX_COLS_DEFAULT = HPX_COLUMNS.filter(c => !c.hidden).map(c => c.id);

/* Column-visibility popover — the prototype equivalent of the real screen's
   "Setting" modal (GET /playerTableSettingForm → forms/tableSetting.blade.php;
   persistence is client-side localStorage there too). */
const HpxColsPopover = ({ columns, visible, onToggle, onReset, onClose }) => (
  <div className="hpx-cols-pop">
    <div className="hpx-cols-head">
      <span>Columns</span>
      <button className="hpx-cols-reset" onClick={onReset}>Reset</button>
      <button className="hpx-cols-x" onClick={onClose} title="Close"><Icon name="x" size={12} /></button>
    </div>
    <div className="hpx-cols-list">
      {columns.map(c => (
        <label key={c.id}>
          <input type="checkbox" disabled={c.always} checked={c.always || visible.includes(c.id)} onChange={() => onToggle(c.id)} />
          <span>{c.label}</span>
          {c.always && <em className="hpx-cols-req">Required</em>}
          {c.pd && <em className="hpx-cols-pd" title="Stripped server-side for scoped managers without support_player_personal_data">PD</em>}
        </label>
      ))}
    </div>
    <div className="hpx-cols-foot">Default hidden set mirrors the live admin's saved column settings.</div>
  </div>
);

/* Block / unblock confirmation with mandatory comment — mirrors the real
   infoblock modals: POST /setcashblock/{id}/ · /setblock/{id}/ write a `logs`
   row (USER_CASH_BLOCK/UNBLOCK · USER_BLOCK/UNBLOCK) with the comment.
   Unblocking on the live platform additionally requires the skin setting
   `enable_user_unblock` or super admin. */
const HpxBlockModal = ({ target, onConfirm, onClose }) => {
  const [comment, setComment] = useStateHP("");
  const label = target.field === "cashBlock" ? "Cash block" : "User block";
  const verb = target.next ? "Block" : "Unblock";
  return (
    <div className="hpx-scrim" onClick={onClose}>
      <div className="hpx-modal hpx-modal--sm" onClick={e => e.stopPropagation()}>
        <div className="hpx-modal-head">
          <span>{verb} — {label}</span>
          <button className="hpx-modal-x" onClick={onClose} title="Close"><Icon name="x" size={14} /></button>
        </div>
        <div className="hpx-modal-body">
          <p className="hpx-modal-p">
            {target.next
              ? <>You are about to set <b>{label}</b> on <b>{target.username}</b>. A comment is mandatory — it is written to the audit log and shown behind the <Icon name="info" size={11} /> icon on the row.</>
              : <>You are about to lift <b>{label}</b> on <b>{target.username}</b>. On the live platform un-blocking additionally requires the skin setting <code>enable_user_unblock</code> or super admin.</>}
          </p>
          <label className="form-label">Comment *</label>
          <textarea className="input hpx-modal-ta" rows={3} placeholder="Reason (mandatory)" value={comment} onChange={e => setComment(e.target.value)} autoFocus />
        </div>
        <div className="hpx-modal-foot">
          <button className="rpt-btn rpt-btn--search" onClick={onClose}>Cancel</button>
          <button className={`rpt-btn ${target.next ? "rpt-btn--danger" : "rpt-btn--green"}`} disabled={!comment.trim()} onClick={() => onConfirm(comment.trim())}>{verb}</button>
        </div>
      </div>
    </div>
  );
};

/* Block-note history — the real `GET /infoblock/` modal. */
const HpxNotesModal = ({ player, onClose }) => {
  const feed = useHpBlockNotes(player.id);
  const notes = feed.rows;
  return (
  <div className="hpx-scrim" onClick={onClose}>
    <div className="hpx-modal hpx-modal--sm" onClick={e => e.stopPropagation()}>
      <div className="hpx-modal-head">
        <span>Block notes — {player.username}</span>
        <button className="hpx-modal-x" onClick={onClose} title="Close"><Icon name="x" size={14} /></button>
      </div>
      <div className="hpx-modal-body">
        {feed.loading && <p className="hpx-modal-p">Loading…</p>}
        {!feed.loading && feed.error && <HrsError error={feed.error} onRetry={feed.retry} />}
        {!feed.loading && !feed.error && notes.length === 0 && <p className="hpx-modal-p">No block notes recorded for this account.</p>}
        {notes.map((n, i) => (
          <div className="hpx-note" key={i}>
            <div className="hpx-note-top"><b>{n.type}</b><span>{hpDate(n.ts)} · by {n.by}</span></div>
            <div className="hpx-note-txt">{n.comment}</div>
            {n.unblockedAt && <div className="hpx-note-txt">Unblocked {hpDate(n.unblockedAt)}{n.unblockedBy ? ` by ${n.unblockedBy}` : ""}</div>}
          </div>
        ))}
      </div>
      <div className="hpx-modal-foot">
        <button className="rpt-btn rpt-btn--search" onClick={onClose}>Close</button>
      </div>
    </div>
  </div>
  );
};

/* Delete confirmation — the real trash icon is super-admin only and hits
   GET /players/delete/{id}/ → PlayersController::delete (soft state via
   $user->delete()). Here it removes the row from this session's mock list,
   which is a real state change, so it is confirmed first rather than toasted
   over a row that never moved. Nothing is "restored" anywhere — the real
   platform has no restore either. */
const HpxDeleteModal = ({ player, onConfirm, onClose }) => (
  <div className="hpx-scrim" onClick={onClose}>
    <div className="hpx-modal hpx-modal--sm" onClick={e => e.stopPropagation()}>
      <div className="hpx-modal-head">
        <span>Delete player — {player.username}</span>
        <button className="hpx-modal-x" onClick={onClose} title="Close"><Icon name="x" size={14} /></button>
      </div>
      <div className="hpx-modal-body">
        <p className="hpx-modal-p">
          Removes <b>{player.username}</b> (ID {player.id}) from the list. On the live platform this is
          <code> GET /players/delete/{player.id}/</code> — <b>super admin only</b> (isadmin() + the delete policy);
          the row is copied into <code>deleted_users</code> and then removed, and <b>no restore exists</b> anywhere in the backoffice.
        </p>
        <p className="hpx-modal-p" style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
          In this prototype the removal is local to the session — a reload brings the seeded list back.
        </p>
      </div>
      <div className="hpx-modal-foot">
        <button className="rpt-btn rpt-btn--search" onClick={onClose}>Cancel</button>
        <button className="rpt-btn rpt-btn--danger" onClick={onConfirm}><Icon name="trash" size={13} /> Delete</button>
      </div>
    </div>
  </div>
);

/* New-player form field wrapper (errors mirror the real `campierrati` list
   returned by PlayersController::saveNewPlayer). */
const HpxField = ({ label, req, err, hint, children }) => (
  <div className={`hpx-nf ${err ? "hpx-nf--err" : ""}`}>
    <label className="form-label">{label}{req && <span className="hpx-star"> *</span>}</label>
    {children}
    {err && <div className="hpx-nf-err">{err}</div>}
    {hint && !err && <div className="hpx-nf-hint">{hint}</div>}
  </div>
);

const HPX_COUNTRIES = ["Argentina", "Chile", "Uruguay", "Paraguay", "Bolivia", "Peru"];
const HPX_DOC_TYPES = ["ID card", "Passport", "Driver license"];

/* "New player" modal — the real create form (GET /newPlayerForm →
   forms/newPlayer.blade.php, submit POST /saveNewPlayer). Required set follows
   config/player_fields.php; when the skin runs `fast_player` only username +
   password are required (config/fastplayer_fields.php). The button that opens
   this modal is hidden on the live platform for Customer Care, for skin 65
   (hardcoded) and when `disable_players_crud` is on. */
const HpxNewPlayerModal = ({ parents, onClose, onCreate }) => {
  const [d, setD] = useStateHP({
    parent: "", test: false, username: "",
    firstname: "", lastname: "", sex: "",
    bDay: "", bMonth: "", bYear: "", email: "", mobile: "",
    address: "", houseNo: "", zip: "", countryRes: "Argentina", provinceRes: "", cityRes: "",
    docType: "", docNumber: "",
  });
  const [errs, setErrs] = useStateHP({});
  const set = (p) => setD(s => ({ ...s, ...p }));
  const submit = () => {
    const e = {};
    if (!d.parent) e.parent = "Select the shop this player hangs under (checkParentPerm).";
    if (!d.username.trim()) e.username = "Username is required.";
    else if (!/^[A-Za-z0-9._-]+$/.test(d.username.trim())) e.username = "Letters, numbers, dot, dash and underscore only (validateUsername).";
    if (d.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(d.email)) e.email = "Enter a valid email address.";
    setErrs(e);
    if (Object.keys(e).length) return;
    onCreate(d);
  };
  return (
    <div className="hpx-scrim" onClick={onClose}>
      <div className="hpx-modal hpx-modal--lg" onClick={e => e.stopPropagation()}>
        <div className="hpx-modal-head">
          <span>New player</span>
          <button className="hpx-modal-x" onClick={onClose} title="Close"><Icon name="x" size={14} /></button>
        </div>
        <div className="hpx-modal-body">
          <div className="hpx-msec">Assignment</div>
          <div className="hpx-mgrid">
            {/* REAL SHOP IDS, not the parent NAMES scraped off the loaded page.
                The old list was `[...new Set(rows.map(r => r.parent))]` — the
                distinct parents of the players already on screen, so a brand new
                shop with no players yet could never be chosen, and a name is not
                something create_user() can resolve anyway. This is a feed of
                level-20 accounts inside the caller's own subtree, which is the
                same set the server will accept. */}
            <HpxField label="Assign to shop" req err={errs.parent} hint="SHOP-level (cashier) accounts only, inside your subtree — create_user() derives the new player's path from this parent's and refuses one outside it. The player inherits the parent's currency, skin and timezone.">
              <select className="select" value={d.parent} onChange={e => set({ parent: e.target.value })}>
                <option value="">- Select -</option>
                {parents.map(p => <option key={p.id} value={p.id}>{p.username}</option>)}
              </select>
            </HpxField>
            <HpxField label="Flags" hint="Test player is only rendered / accepted for super admin.">
              <div className="hpx-checkrow">
                <label><input type="checkbox" checked={d.test} onChange={e => set({ test: e.target.checked })} /> Test player</label>
                {/* users.default_cashier_id exists (028) but is not in the
                    write allowlist, so this flag has no write path yet. */}
                <label style={{ opacity: .55 }} title="No write path: users.default_cashier_id is not in the write allowlist."><input type="checkbox" disabled /> Default cashier player</label>
              </div>
            </HpxField>
          </div>

          <div className="hpx-msec">Login data <Tip>Skins running the <code>fast_player</code> setting require only Username and Password — every other field below becomes optional (config/fastplayer_fields.php).</Tip></div>
          <div className="hpx-mgrid">
            <HpxField label="Username" req err={errs.username}>
              <input className="input" value={d.username} onChange={e => set({ username: e.target.value })} placeholder="Username" />
            </HpxField>
          </div>
          {/* THE PASSWORD BOXES ARE GONE, and their absence is the honest
              version. Credentials live in Supabase auth, not in `users` —
              create_user() takes p_auth_user_id and no password at all, which is
              why the same two fields were removed from the new-operator form.
              Two boxes collecting a password that has nowhere to go is the
              working-fake pattern this project keeps finding: they validated
              each other, matched, and were discarded. */}
          <HpxNoBackendNote>
            No password here. Credentials belong to the auth provider rather than to
            <code> users</code>, so this creates the ACCOUNT and not a login —
            <code>link_auth_user()</code> attaches one afterwards.
            {/* <!-- SUGGESTION: an operator-created player cannot sign in. Closing that needs a service-role step the browser cannot take — mint the auth user, then link_auth_user() it — so it belongs in an Edge Function wrapping both. Same gap as the new-operator form. --> */}
          </HpxNoBackendNote>

          <div className="hpx-msec">Personal data</div>
          <div className="hpx-mgrid">
            <HpxField label="Name"><input className="input" value={d.firstname} onChange={e => set({ firstname: e.target.value })} /></HpxField>
            <HpxField label="Lastname"><input className="input" value={d.lastname} onChange={e => set({ lastname: e.target.value })} /></HpxField>
            <HpxField label="Gender" req>
              <select className="select" value={d.sex} onChange={e => set({ sex: e.target.value })}>
                <option value="">Select</option><option value="m">Male</option><option value="f">Female</option>
              </select>
            </HpxField>
            <HpxField label="Birthday" req hint="Years capped at 18+ (back to 1940).">
              <div className="hpx-dob">
                <select className="select" value={d.bDay} onChange={e => set({ bDay: e.target.value })}><option value="">Day</option>{Array.from({ length: 31 }, (_, i) => <option key={i}>{i + 1}</option>)}</select>
                <select className="select" value={d.bMonth} onChange={e => set({ bMonth: e.target.value })}><option value="">Month</option>{Array.from({ length: 12 }, (_, i) => <option key={i}>{i + 1}</option>)}</select>
                <select className="select" value={d.bYear} onChange={e => set({ bYear: e.target.value })}><option value="">Year</option>{Array.from({ length: 69 }, (_, i) => <option key={i}>{2008 - i}</option>)}</select>
              </div>
            </HpxField>
            <HpxField label="Email" req err={errs.email} hint="Sets email_confirmed when present; hidden when the skin uses email-registration.">
              <input className="input" value={d.email} onChange={e => set({ email: e.target.value })} placeholder="name@example.com" />
            </HpxField>
            <HpxField label="Mobile phone" req hint="Sets mobile_verified when present."><input className="input" value={d.mobile} onChange={e => set({ mobile: e.target.value })} /></HpxField>
          </div>
          {/* THE THREE BIRTHPLACE BOXES ARE GONE. 028 added ONE set of location
              columns and the form offered two, so country/province/city of birth
              and of residence would have written the same three columns with the
              second overwriting the first — a form that collects a fact and then
              throws it away is the pattern this project keeps finding. */}
          <HpxNoBackendNote>
            No birthplace fields: this schema has one set of location columns, used for
            residence below. Where somebody was born is a different fact and would need
            its own columns.
            {/* <!-- SUGGESTION: if birthplace is needed for compliance, add birth_country / birth_province / birth_city. KYC asks for both it and current residence. --> */}
          </HpxNoBackendNote>

          <div className="hpx-msec">Residence data</div>
          <div className="hpx-mgrid">
            <HpxField label="Address" req>
              <div className="hpx-dob"><input className="input" value={d.address} onChange={e => set({ address: e.target.value })} placeholder="Street" /><input className="input" style={{ maxWidth: 110 }} value={d.houseNo} onChange={e => set({ houseNo: e.target.value })} placeholder="N°" /></div>
            </HpxField>
            <HpxField label="Zip" req><input className="input" value={d.zip} onChange={e => set({ zip: e.target.value })} /></HpxField>
            {/* NAMES, and the column is ISO 3166-1 alpha-2. There is no country
                table to map "Argentina" to "AR" through, so this select's value
                is deliberately NOT written on create — the player's country is
                set from the edit screen's two-letter field. Left visible and
                marked, rather than silently discarded. */}
            <HpxField label="Country" hint="Not saved from this form: users.country is a two-letter ISO code and this list holds names, with no lookup table between them. Set it on the player's Home tab after creating.">
              <select className="select" value={d.countryRes} onChange={e => set({ countryRes: e.target.value })} disabled>{HPX_COUNTRIES.map(c => <option key={c}>{c}</option>)}</select>
            </HpxField>
            <HpxField label="Province" req><input className="input" value={d.provinceRes} onChange={e => set({ provinceRes: e.target.value })} /></HpxField>
            <HpxField label="City" req><input className="input" value={d.cityRes} onChange={e => set({ cityRes: e.target.value })} /></HpxField>
            {/* Fiscal code has no column in this schema, so the box is
                disabled rather than accepting a tax identifier and dropping it.
                `document_number` is not a substitute — a codice fiscale is not
                the number on the document. */}
            <HpxField label="Fiscal code" hint="No column in this schema — Italy-only upstream, validated with the CodiceFiscale checker.">
              <HpxNoBackend block className="input" style={{ textAlign: "left" }}
                what="Fiscal code" need="a fiscal_code column on users — document_number is a different fact">Not stored</HpxNoBackend>
            </HpxField>
          </div>

          <div className="hpx-msec">Documents</div>
          <div className="hpx-mgrid">
            <HpxField label="Document type" req>
              <select className="select" value={d.docType} onChange={e => set({ docType: e.target.value })}>
                <option value="">Select</option>{HPX_DOC_TYPES.map(t => <option key={t}>{t}</option>)}
              </select>
            </HpxField>
            <HpxField label="Document number" req hint="Read-only on edit unless empty (or super/skin admin)."><input className="input" value={d.docNumber} onChange={e => set({ docNumber: e.target.value })} /></HpxField>
          </div>
        </div>
        <div className="hpx-modal-foot">
          <button className="rpt-btn rpt-btn--search" onClick={onClose}>Cancel</button>
          <button className="rpt-btn rpt-btn--green" onClick={submit}><Icon name="plus" size={14} /> Create player</button>
        </div>
      </div>
    </div>
  );
};

/* ---------------- Player edit view ---------------- */
const HostPlayerEdit = ({ player, onBack }) => {
  // Each tab gets its own URL (e.g. /players/history) via useUrlTab
  // (src/routes.jsx) — same pushState/popstate mechanism the rest of the
  // app's top-level nav uses. No player id in the path (selecting a
  // player is in-memory state, not a route), so a fresh load of e.g.
  // /players/history with nothing selected just falls back to the list.
  // Tab list = the real detail shell (players/template.blade.php); the orphan
  // "Event history" route is broken on the live platform (no Blade view) and
  // absent from its tab bar, so it is deliberately not represented here.
  const TABS = [
    ["home", "Home", ""], ["transactions", "Transactions", "transactions"], ["coupons", "Sport Coupon History", "sport-coupon-history"],
    ["history", "History", "history"], ["promotions", "Promotions", "promotions"], ["jackpot", "Jackpot", "jackpot"],
    ["deposit", "Deposit", "deposit"], ["logs", "Logs", "logs"], ["verifications", "Verifications", "verifications"],
    ["stats", "Player stats", "stats"],
  ];
  const [tab, setTab] = window.useUrlTab("/players", TABS, "home");
  /* CONTROLLED, so SAVE has something to save. Every field on the Home tab was
     an uncontrolled `defaultValue`, which is why the Save button was disabled
     with "the profile fields are display-only here" — there was genuinely
     nothing to persist. The five columns `users` actually exposes for editing
     are held in state now; the rest of the form (birthplace, residence, the
     birthday selects) has no column in this schema and stays display-only
     rather than pretending to collect something that goes nowhere. */
  const [prof, setProf] = useStateHP(() => ({
    username: player.username || "",
    firstname: player.firstname || "",
    lastname: player.lastname || "",
    email: player.email || "",
    mobile: player.mobile || "",
    /* 028's nine, opened by supabase/051. They were uncontrolled inputs on this
       tab too — the same defect the operator editor had, one screen over. */
    gender: player.gender || "",
    birthdate: player.birthdate || "",
    country: player.country || "",
    province: player.province || "",
    city: player.city || "",
    address: player.address || "",
    postcode: player.postcode || "",
    documentType: player.documentType || "",
    documentNumber: player.documentNumber || "",
  }));
  const setP = (k, v) => setProf(x => Object.assign({}, x, { [k]: v }));
  const psave = useHrsSave([]);
  /* Empty box = NULL, never "". An empty string in `country` is its own
     category in every group-by, and '' is not a date. */
  const hpNn = (v) => (String(v == null ? "" : v).trim() || null);
  const saveProfile = () => psave.run(
    () => window.sb.update("users", player.id, {
      username: prof.username.trim(),
      firstname: hpNn(prof.firstname),
      lastname: hpNn(prof.lastname),
      email: hpNn(prof.email),
      mobile: hpNn(prof.mobile),
      gender: hpNn(prof.gender),
      birthdate: hpNn(prof.birthdate),
      /* char(2), uppercased so 'ar' and 'AR' cannot become two countries. */
      country: prof.country ? String(prof.country).trim().toUpperCase().slice(0, 2) : null,
      province: hpNn(prof.province),
      city: hpNn(prof.city),
      address: hpNn(prof.address),
      postcode: hpNn(prof.postcode),
      document_type: hpNn(prof.documentType),
      document_number: hpNn(prof.documentNumber),
    }), {
      done: `${player.username} saved`,
      fail: `${player.username} was not saved`,
    });
  const [xfer, setXfer] = useStateHP({ op: "deposit", amount: "", reason: "" });
  const xsave = useHrsSave([]);
  const meFeedE = useHrsFetch(() => window.sb.me(), []);
  /* MONEY. One RPC, one transaction — post_transfer does the debit, the credit
     and both ledger entries together, because two post_transaction calls are
     two transactions with a window between them and an interruption there
     leaves money debited from one account and credited to none.

     The IDEMPOTENCY KEY describes WHAT is being transferred, never when. A key
     derived from a clock makes a double-submit indistinguishable from two
     genuine transfers, which on this screen means paying a player twice. */
  const doTransfer = () => {
    const me = meFeedE.data;
    const amt = Number(xfer.amount);
    if (!me || !amt) return;
    const toPlayer = xfer.op === "deposit";
    xsave.run(() => window.sb.transfer({
      fromUserId: toPlayer ? me.id : player.id,
      toUserId: toPlayer ? player.id : me.id,
      amount: amt,
      key: `player-transfer:${me.id}:${toPlayer ? "to" : "from"}:${player.id}:${amt}:${(xfer.reason || "").trim()}`,
      description: xfer.reason || null,
    }), {
      done: `${toPlayer ? "Deposited to" : "Withdrawn from"} ${player.username}`,
      fail: "The transfer did not go through",
    }).then(res => { if (res && res.ok) setXfer({ op: xfer.op, amount: "", reason: "" }); });
  };
  /* Two panels, two filter states. They look identical and filter different
     row sets — Transactions is deposits and withdrawals only, History is the
     whole wallet — so one shared state would make switching tabs silently
     re-filter the other. */
  const [txDraft, setTxDraft] = useStateHP(HP_TXF_EMPTY);
  const [txApplied, setTxApplied] = useStateHP(HP_TXF_EMPTY);
  const [hxDraft, setHxDraft] = useStateHP(HP_TXF_EMPTY);
  const [hxApplied, setHxApplied] = useStateHP(HP_TXF_EMPTY);
  const [histWallet, setHistWallet] = useStateHP("real"); // History tab: Real vs Bonus wallet
  const [histPage, setHistPage] = useStateHP(0);
  const [histPageSize, setHistPageSize] = useStateHP(100);
  const changeWallet = (w) => { setHistWallet(w); setHistPage(0); }; // reset to first page on wallet switch
  const ledger = useHpLedger(player.id);
  const logFeed = useHpLoginLogs(player.id);
  const noteFeed = useHpBlockNotes(player.id);
  const kyc = useHpKyc(player.id);
  const txRows = useMemoHP(() => hpTxFilter(ledger.transactions, txApplied),
                           [ledger.transactions, txApplied]);
  const couponFeed = useHrsFetch(
    () => window.sb.list("sportCoupons", { limit: 200, filters: { user: player.id } }), [player.id]);
  const jackpotFeed = useHrsFetch(
    () => window.sb.list("jackpotWins", { limit: 100, filters: { user: player.id } }), [player.id]);
  const [kycReason, setKycReason] = useStateHP("");
  const kycSave = useHrsSave([]);
  /* WHO MAY DECIDE, asked of the session and not of the page. The buttons are
     hidden for a role that cannot review — but that is a courtesy to the
     operator, NOT the check: set_kyc_status re-asks the same question inside
     the database, where the answer cannot be edited in a console. The levels
     here are 038's read audience and 050's decider set, which a verify block in
     050 keeps identical. */
  const kycLevelOk = !!meFeedE.data && [0, 2, 4].indexOf(Number(meFeedE.data.user_level)) >= 0;
  const decideKyc = (status) => kycSave.run(
    () => window.sb.rpc("set_kyc_status", {
      p_user_id: player.id,
      p_status: status,
      p_reason: kycReason.trim() || null,
      p_expires: null,
    }), {
      done: `${player.username} marked ${status}`,
      fail: `${player.username} was not marked ${status}`,
    }).then(res => { if (res && res.ok) { setKycReason(""); kyc.retry(); } });
  const availability = (player.wd || 0) + (player.nwd || 0) + (player.bonus || 0);
  /* THE SEVEN TOGGLES, SPLIT BY WHETHER THIS SCHEMA CAN STORE THE ANSWER.
     They were seven uncontrolled `defaultValue` switches: three of them showed
     a real column and silently discarded the flip, and four showed `false` for
     every player on the platform because there is nothing behind them at all —
     "Confirmed documents" read false next to a Verifications tab that may well
     say approved.

     `column: null` is not "not yet done": each of the four is a fact this
     schema does not carry. Email confirmation and mobile verification live in
     the auth provider, deposit limits have no table, and document confirmation
     IS the KYC status — which has its own tab, its own audit trail and its own
     RPC, so a duplicate boolean here could only ever disagree with it. */
  const [flags, setFlags] = useStateHP(() => ({
    blocked: !!player.userBlock,
    cash_blocked: !!player.cashBlock,
    test_user: !!player.testUser,
  }));
  const HP_SETTINGS = [
    { label: "Blocked", column: "blocked", value: !!flags.blocked,
      note: "Writes the flag and an audit row in user_blocks." },
    { label: "Cash block", column: "cash_blocked", value: !!flags.cash_blocked,
      note: "A narrower flag with no history table, upstream or here." },
    { label: "Test Player", column: "test_user", value: !!flags.test_user,
      note: "Excluded from reports; upstream renders it for super admin only." },
    { label: "Confirmed email", column: null,
      note: "Email confirmation belongs to the auth provider, not to `users`." },
    { label: "Maximum deposit active", column: null,
      note: "No deposit-limit table in this schema." },
    { label: "Confirmed documents", column: null,
      note: "This IS the KYC status — see the Verifications tab, which has the audit trail and the RPC. A second boolean here could only disagree with it." },
    { label: "Disable deposit and withdraw", column: null,
      note: "No column; the nearest real control is Cash block." },
  ];
  const fsave = useHrsSave([]);
  /* THE SWITCH SHOWS WHAT WAS STORED, NOT WHAT WAS CLICKED. Rendering these
     straight off the `player` prop would leave a flipped switch snapping back
     the moment anything re-rendered, because the prop comes from the list
     screen's feed and this editor cannot refetch it. Held locally and advanced
     only after the write comes back ok — so a refused block leaves the switch
     where it was, which is the truth. */
  /* One flag, one write, and the audit row only where there is a table for it.
     Deliberately NOT batched behind the Home tab's SAVE: blocking somebody is
     an action with a consequence, not a field edit that waits for a form
     submit — and the list screen's block flow already works this way. */
  const setFlag = (col, next, label) => fsave.run(
    () => window.sb.update("users", player.id, { [col]: next }), {
      done: `${label} ${next ? "on" : "off"} for ${player.username}`,
      fail: `${label} was not changed for ${player.username}`,
    }).then(async (res) => {
      if (!res || !res.ok) return;
      setFlags(f => Object.assign({}, f, { [col]: next }));
      if (col !== "blocked") return;
      const me = meFeedE.data;
      if (next) {
        await window.sb.create("userBlocks", {
          user_id: player.id, blocked_by: me ? me.id : null,
          reason: "Blocked from the player's Home tab",
        });
      } else {
        const open = await window.sb.list("userBlocks", { limit: 1, filters: { user: player.id, open: "1" } });
        const b = open && open.ok && open.data && open.data[0];
        if (b) {
          await window.sb.update("userBlocks", b.id, {
            unblocked_by: me ? me.id : null, unblocked_at: new Date().toISOString(),
          });
        }
      }
      noteFeed.retry();
    });

  /* `emptyTab` is gone with its last two callers. It rendered "<thing> for
     <player> ships with the white-label build" — a sentence that reads as a
     statement about this player and was in fact a statement about the
     prototype, identical for everyone. Both tabs it served ask the database
     now, and HrsAsync's empty state says what was actually looked for. */

  return (
    <div className="page report-page host-players">
      {/* Header — the real detail shell shows avatar + balances + bonus
          (players/template.blade.php) above the tab bar. */}
      <div className="hp-edit-head hpx-edit-head">
        <button className="hp-back" onClick={onBack} title="Back to players"><Icon name="chevron_left" size={18} /></button>
        <div className="hpx-avatar">{(player.username || "?").slice(0, 2).toUpperCase()}</div>
        <div className="hpx-edit-id">
          <div className="page__title" style={{ color: "var(--p-700)", marginBottom: 2 }}>Edit player</div>
          <div className="hpx-edit-sub">
            <b>{player.username}</b>
            <span>User ID: {player.id}</span>
            {player.testUser && <span className="chip chip--warn" style={{ fontSize: 10 }}>test player</span>}
            {player.cashBlock && <span className="chip chip--err" style={{ fontSize: 10 }}>Cash block</span>}
            {player.userBlock && <span className="chip chip--err" style={{ fontSize: 10 }}>User block</span>}
          </div>
        </div>
        <div className="hpx-head-bal">
          <div><span>Withdrawable</span><b>{hpARS(player.wd)}</b></div>
          <div><span>Non withdrawable</span><b>{hpARS(player.nwd)}</b></div>
          <div><span>Bonus</span><b>{hpARS(player.bonus)}</b></div>
        </div>
      </div>

      {/* Tab strip — scrollable pills (Skins.jsx editor shape). */}
      <div className="hpx-tabs">
        {TABS.map(([id, lab]) => (
          <button key={id} className={`hpx-tab ${tab === id ? "active" : ""}`} onClick={() => setTab(id)}>{lab}</button>
        ))}
      </div>

      {tab === "home" && (
        <div className="hp-home">
          <div className="hp-home__main">
            <section className="hp-card">
              <div className="hp-card__title">Login data <Tip>Field requiredness follows <code>config/player_fields.php</code>; skins running <code>fast_player</code> only require username + password. All inputs are force-disabled when the skin setting <code>disable_players_crud</code> is on.</Tip></div>
              <div className="hp-form">
                <label className="form-label">Username *</label>
                <input className="input" value={prof.username} onChange={e => setP("username", e.target.value)} />
                {/* THE TWO PASSWORD BOXES ARE GONE. They accepted a
                    credential, matched it against its confirmation and
                    discarded it — a form that behaves exactly like a working
                    password change and changes nothing. Credentials live in
                    Supabase auth, not in `users`; there is no column here to
                    write and no RPC that takes one, which is why the same two
                    fields were removed from both create forms. */}
              </div>
              <HpxNoBackendNote>
                No password field. Credentials belong to the auth provider rather than to
                <code> users</code> — a reset is an auth-admin operation, not a column on
                this row.
                {/* <!-- SUGGESTION: an operator has no way to reset a player's password. Closing it needs a service-role call the browser cannot make, so it belongs in an Edge Function wrapping Supabase auth's admin API. --> */}
              </HpxNoBackendNote>
            </section>

            <div className="hp-two">
              <section className="hp-card">
                <div className="hp-card__title">Personal data</div>
                <div className="hp-grid2">
                  <label className="form-label">Name</label><input className="input" value={prof.firstname} onChange={e => setP("firstname", e.target.value)} />
                  <label className="form-label">Lastname</label><input className="input" value={prof.lastname} onChange={e => setP("lastname", e.target.value)} />
                  {/* The three legal values of the CHECK on users.gender. The
                      CODE is stored, never the label — a translated label stops
                      matching the constraint the day this screen is localised.
                      The live form offers only m/f; 'x' is in the constraint, so
                      refusing to offer it would make a stored value unreadable
                      by the screen that has to display it. */}
                  <label className="form-label">Gender</label>
                  <select className="select" value={prof.gender} onChange={e => setP("gender", e.target.value)}>
                    <option value="">Not recorded</option>
                    <option value="m">Male</option>
                    <option value="f">Female</option>
                    <option value="x">Other / not stated</option>
                  </select>
                  {/* THREE BIRTHPLACE BOXES REMOVED. 028 added ONE set of
                      location columns and this form asked twice — birth and
                      residence would have written the same three columns with
                      the second overwriting the first. Same removal as the new
                      player form and the operator editor. */}
                  <label className="form-label">Birthday</label>
                  <input className="input" type="date" value={prof.birthdate} onChange={e => setP("birthdate", e.target.value)} />
                  {/* NO invented fallback address. It used to default to `<username>@gmail.com` when the column was empty — a plausible-looking address for a player who has none, in the field an operator would use to contact them. */}
                  <label className="form-label">Email</label><input className="input" value={prof.email} placeholder="No email on file" onChange={e => setP("email", e.target.value)} />
                  <label className="form-label">Mobile phone</label><input className="input" value={prof.mobile} onChange={e => setP("mobile", e.target.value)} />
                </div>
              </section>
              <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
                <section className="hp-card">
                  <div className="hp-card__title">Residence data</div>
                  <div className="hp-grid2">
                    {/* ONE address box: users.address is one text column, and two
                        inputs would need a join rule nobody wrote. */}
                    <label className="form-label">Address</label>
                    <input className="input" placeholder="Street and number" value={prof.address} onChange={e => setP("address", e.target.value)} />
                    <label className="form-label">Zip</label>
                    <input className="input" value={prof.postcode} onChange={e => setP("postcode", e.target.value)} />
                    {/* A two-letter box, not a select holding one country. The
                        column is ISO 3166-1 alpha-2 and there is no country
                        table here; a dropdown with "Argentina" in it is a list
                        of the only places a player may be said to live. */}
                    <label className="form-label">Country<Tip>ISO 3166-1 alpha-2 — "AR", "IT". Stored uppercase; free-text country names give every report one row per spelling.</Tip></label>
                    <input className="input" maxLength={2} placeholder="AR" style={{ textTransform: "uppercase" }}
                      value={prof.country} onChange={e => setP("country", e.target.value)} />
                    <label className="form-label">Province</label>
                    <input className="input" value={prof.province} onChange={e => setP("province", e.target.value)} />
                    <label className="form-label">City</label>
                    <input className="input" value={prof.city} onChange={e => setP("city", e.target.value)} />
                  </div>
                </section>
                <section className="hp-card">
                  <div className="hp-card__title">Documents</div>
                  <div className="hp-grid2">
                    {/* FREE TEXT, not a select. `document_type` has no CHECK and
                        no lookup table, so a fixed list here would be this
                        screen deciding which documents the platform accepts —
                        and a value typed by any other route would then render
                        as an empty select. */}
                    <label className="form-label">Document type<Tip>Free text — nothing in this schema enumerates the accepted document types, so the options are not the screen's to invent.</Tip></label>
                    <input className="input" placeholder="ID card, Passport, …" value={prof.documentType} onChange={e => setP("documentType", e.target.value)} />
                    <label className="form-label">Document number</label>
                    <input className="input" value={prof.documentNumber} onChange={e => setP("documentNumber", e.target.value)} />
                    {/* Fiscal code has no column, so the box does not accept a
                        tax identifier and drop it. document_number is a
                        different fact — the number ON the document. */}
                    <label className="form-label">Fiscal code</label>
                    <HpxNoBackend block className="input" style={{ textAlign: "left" }}
                      what="Fiscal code" need="a fiscal_code column on users — document_number is a different fact">Not stored</HpxNoBackend>
                  </div>
                </section>
              </div>
            </div>
          </div>

          <aside className="hp-home__side">
            <section className="hp-card hp-info">
              <div className="hp-card__title">Info <Tip>Personal data in this panel is masked server-side for scoped managers without <code>support_player_personal_data</code> (maskPersonalDataIfNotAllowed).</Tip></div>
              {[["Role", "Player"], ["Skin", player.skin], ["Parent", player.parent], ["Last access", hpDate(player.last)], ["IP", player.ip || "—"], ["Registration date", hpDate(player.reg)], ["Registration IP", player.regIp || "—"]].map(([k, v]) => (
                <div className="hp-info__row" key={k}><span className="k">{k}</span><span className="v">{v}</span></div>
              ))}
            </section>
            <section className="hp-card">
              <div className="hp-card__title">Balances</div>
              <div className="hp-bal"><span className="dot" style={{ background: "#1f9d57" }} /> Withdrawable balance <b>{hpARS(player.wd)}</b></div>
              <div className="hp-bal"><span className="dot" style={{ background: "#e9484a" }} /> Non withdrawable balance <b>{hpARS(player.nwd)}</b></div>
              <div className="hp-bal"><span className="dot" style={{ background: "#e6a82c" }} /> Bonus balance <b>{hpARS(player.bonus)}</b></div>
            </section>
            <section className="hp-card">
              <div className="hp-card__title">User settings</div>
              {HP_SETTINGS.map(s => (
                <div className="hp-setting" key={s.label}>
                  <span>{s.label}<Tip>{s.note}</Tip></span>
                  {s.column
                    ? <Toggle value={s.value} onChange={(v) => setFlag(s.column, v, s.label)}
                              disabled={fsave.busy} onLabel="" offLabel="" size="sm" />
                    /* Not a toggle at all. A disabled switch still shows a
                       POSITION, and the position it would show is "off" — which
                       reads as a fact about this player rather than as an
                       absence of one. */
                    : <span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>not stored</span>}
                </div>
              ))}
            </section>
          </aside>
        </div>
      )}

      {tab === "promotions" && <HostPlayerPromotions player={player} />}

      {tab === "transactions" && (
        <div>
          <HpTxFilters draft={txDraft} onDraft={(p) => setTxDraft(d => Object.assign({}, d, p))}
                       onApply={() => setTxApplied(txDraft)}
                       onReset={() => { setTxDraft(HP_TXF_EMPTY); setTxApplied(HP_TXF_EMPTY); }} />
          {ledger.error && <HrsError error={ledger.error} onRetry={ledger.retry} />}
          {ledger.loading && <HrsSkeleton rows={6} cols={8} />}
          {!ledger.loading && !ledger.error && (
          <div className="panel" style={{ overflow: "hidden" }}><div style={{ overflowX: "auto" }}>
            <table className="data-table hp-list">
              <thead><tr><th>ID</th><th>Typology</th><th>Description</th><th>Transaction ID</th><th>IN</th><th>OUT</th><th>Balance</th><th>Date</th></tr></thead>
              <tbody>
                {txRows.length === 0 && <tr><td colSpan={8} style={{ padding: "30px", textAlign: "center", color: "var(--text-tertiary)" }}>{ledger.transactions.length ? "No transactions match your filters." : "No data available in table"}</td></tr>}
                {txRows.map(t => (
                  <tr key={t.nid}><td>{t.nid}</td><td>{t.typology}</td><td style={{ textAlign: "left" }}>{player.username} - {t.typology}</td><td></td>
                    <td className="hp-in">{t.amount > 0 ? hpMoneyPlain(t.amount) : ""}</td>
                    <td className="hp-out">{t.amount < 0 ? hpMoneyPlain(-t.amount) : ""}</td>
                    <td>{hpMoneyPlain(t.balance)}</td><td>{hpDate(t.ts)}</td></tr>
                ))}
              </tbody>
              {/* THE TOTALS FOLLOW THE FILTER. Summing the unfiltered set under
                  a filtered table is a footer that answers a question nobody
                  asked, next to rows that answer a different one. */}
              {txRows.length > 0 && (
                <tfoot><tr className="hp-total">
                  <td colSpan={4}></td>
                  <td>{hpMoneyPlain(txRows.filter(t => t.amount > 0).reduce((a, t) => a + t.amount, 0))}</td>
                  <td>{hpMoneyPlain(txRows.filter(t => t.amount < 0).reduce((a, t) => a - t.amount, 0))}</td>
                  <td colSpan={2}></td>
                </tr></tfoot>
              )}
            </table>
          </div></div>
          )}
        </div>
      )}

      {tab === "history" && (() => {
        const rows = hpTxFilter(histWallet === "bonus" ? ledger.bonusHistory : ledger.history, hxApplied);
        const paged = rows.slice(histPage * histPageSize, histPage * histPageSize + histPageSize);
        return (
        <div>
          <HpTxFilters withWallet wallet={histWallet} onWallet={changeWallet}
                       draft={hxDraft} onDraft={(p) => setHxDraft(d => Object.assign({}, d, p))}
                       onApply={() => { setHxApplied(hxDraft); setHistPage(0); }}
                       onReset={() => { setHxDraft(HP_TXF_EMPTY); setHxApplied(HP_TXF_EMPTY); setHistPage(0); }} />
          {ledger.error && <HrsError error={ledger.error} onRetry={ledger.retry} />}
          {ledger.loading && <HrsSkeleton rows={8} cols={7} />}
          {!ledger.loading && !ledger.error && (
          <div className="panel" style={{ overflow: "hidden" }}><div style={{ overflowX: "auto" }}>
            <table className="data-table hp-list hp-history">
              <thead><tr><th>ID</th><th>Typology</th><th>Description</th><th>Reference</th><th>Amount</th><th>Balance</th><th>Date</th></tr></thead>
              <tbody>
                {paged.length === 0 && <tr><td colSpan={7} style={{ padding: "30px", textAlign: "center", color: "var(--text-tertiary)" }}>No data available in table</td></tr>}
                {paged.map(h => (
                  <tr key={h.hid}>
                    <td className="hp-mono">{h.hid}</td>
                    <td>{h.typology}</td>
                    <td className="hp-desc">{h.description}</td>
                    <td className="hp-mono hp-ref">{h.reference}</td>
                    <td className={`hp-amt ${h.amount < 0 ? "neg" : "pos"}`}>{hpNum(h.amount)}</td>
                    <td className="hp-bal">{hpNum(h.balance)}</td>
                    <td className="hp-date">{hpDateSec(h.ts)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div></div>
          )}
          <HpPager total={rows.length} page={histPage} setPage={setHistPage} pageSize={histPageSize} setPageSize={setHistPageSize} />
        </div>
        );
      })()}

      {/* Deposit tab. The real /players/{id}/deposit re-includes the shared
          Transfer panel (TransferController::index) with the payer side hidden.
          The prototype's shared panel is the Deposit screen (route host-deposit
          → /deposit), but it runs its own account directory and cannot be pinned
          to this player, so embedding it would offer a transfer against the
          WRONG account. Instead: the stub form's submit is disabled with the
          endpoint it needs, and a real cross-link opens the shared screen. */}
      {tab === "deposit" && (
        <div className="panel" style={{ padding: 20 }}>
          <div className="page__title" style={{ color: "var(--p-700)", fontSize: 22, marginBottom: 4 }}>Transfer</div>
          <div className="hpx-gate-note"><Icon name="lock" size={11} /> Delegates to TransferController::index — Customer Care without <code>support_player_transactions_read_only</code> is redirected away on the live platform.</div>
          <div className="hpx-gate-note" style={{ marginBottom: 12 }}>
            <Icon name="info" size={11} /> This tab is a stub of that shared panel — it has no user-type / target / withdrawable / apply-bonus controls and cannot post a transfer. The prototype's working Transfer screen is a separate page:
            <button className="rpt-btn rpt-btn--search hpx-mini-btn" style={{ marginLeft: 8 }} onClick={() => hpxNavTransfer(player.id)}
              title="Opens the prototype's Transfer screen (/deposit). It keeps its own account directory, so this player is not preselected there.">
              <Icon name="wallet" size={12} /> Open Transfer screen
            </button>
          </div>
          <div className="hp-transfer">
            <div className="hp-transfer__bal">
              <div><span>Withdrawable balance</span><b>{hpARS(player.wd)}</b></div>
              <div><span>Non withdrawable balance</span><b>{hpARS(player.nwd)}</b></div>
              <div><span>Bonus</span><b>{hpARS(player.bonus)}</b></div>
              <div><span>Availability</span><b>{hpARS(availability)} <Icon name="refresh" size={12} /></b></div>
            </div>
            <div className="hp-transfer__form">
              <label className="form-label">Operation Type</label>
              <select className="select" style={{ width: "100%" }} value={xfer.op}
                onChange={e => setXfer(x => Object.assign({}, x, { op: e.target.value }))}>
                <option value="deposit">Deposit</option><option value="withdraw">Withdraw</option>
              </select>
              <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 12 }}>
                <div><label className="form-label" style={{ display: "flex", gap: 8, alignItems: "center" }}>Amount</label>
                  <input className="input" placeholder="Amount" style={{ width: "100%" }} value={xfer.amount}
                    onChange={e => setXfer(x => Object.assign({}, x, { amount: e.target.value }))} /></div>
                <div><label className="form-label">Reason</label>
                  <input className="input" placeholder="Reason" style={{ width: "100%" }} value={xfer.reason}
                    onChange={e => setXfer(x => Object.assign({}, x, { reason: e.target.value }))} /></div>
              </div>
              {/* THE "Withdrawable" TICKBOX IS GONE. It sat next to Amount and
                  chose which of two real-money columns moved — and this schema
                  has ONE real wallet (`balance_withdrawable`); `balance` is
                  written by nothing, which is UNCLEAR-12. A tickbox that
                  silently selects a column nothing reads is worse than no
                  tickbox. */}
              <button className="rpt-btn rpt-btn--blue" style={{ width: "100%", marginTop: 16 }}
                disabled={xsave.busy || !Number(xfer.amount)}
                onClick={doTransfer}>
                {xsave.busy ? "TRANSFERRING…" : "TRANSFER"}
              </button>
              <HpxNoBackendNote>
                One call to <code>post_transfer()</code>: the debit, the credit and both ledger
                entries land in one transaction. The idempotency key describes the transfer
                itself, so a double-click is one payment, not two.
              </HpxNoBackendNote>
            </div>
          </div>
        </div>
      )}

      {tab === "logs" && (() => {
        const logs = logFeed.rows;
        return (
          <div className="panel" style={{ overflow: "hidden" }}>
            {/* login_events records successful logins only — nothing upstream
                writes a failed attempt. Empty means "no login since this table
                started recording", never "no failed attempts". */}
            <HrsAsync state={logFeed} skeletonRows={6} skeletonCols={2}
                      empty="No logins recorded for this player.">
              {() => (<>
            <table className="data-table hp-list">
              <thead><tr><th>Date</th><th>IP</th></tr></thead>
              <tbody>
                {logs.map((l, i) => (
                  <tr key={i}><td>{hpDate(l.ts, true)}</td><td style={{ textAlign: "left" }} className="hp-mono">{l.ip}</td></tr>
                ))}
              </tbody>
            </table>
            <div className="hp-entries">{logs.length} login event{logs.length === 1 ? "" : "s"}</div>
              </>)}
            </HrsAsync>
          </div>
        );
      })()}

      {tab === "verifications" && (() => {
        /* THE COLUMN LIST CHANGED, and deliberately. isystem's screen is one
           row per verification REQUEST with Name / Surname / City / Province
           repeated on it and four fixed document slots (Id card front, Id card
           back, Address, Funds). Here the identity fields live once on `users`
           — 028 added country/province/city/address/postcode/document_* — and
           the documents are rows, not columns, so a fifth document type is data
           rather than a migration. Reproducing four fixed slots would mean
           either dropping every other doc_type on the floor or inventing four
           that may not exist. Divergence recorded below in a SUGGESTION. */
        const canSee = kycLevelOk;
        /* NOT HrsAsync. Its contract treats a null-or-empty `data` as "nothing
           to show" and renders the empty state — which is right for a list and
           wrong here, because NO user_kyc ROW IS THE ANSWER on this tab: it
           means nobody has ever reviewed this player, and the screen has to say
           so next to an enabled Approve button. Loading and error are handled
           explicitly instead, the same way the Stats tab does. */
        return (
          <div>
            <div className="hpx-gate-note" style={{ marginBottom: 12 }}>
              <Icon name="lock" size={11} /> <code>user_kyc</code> and <code>user_kyc_documents</code> are readable by
              Super admin, Skin admin and Customer care only, inside the subtree (supabase/038). Deciding runs
              through <code>set_kyc_status()</code>, which records the operator from the session — never from the page.
            </div>

            {kyc.loading && <HrsSkeleton rows={4} cols={4} />}
            {!kyc.loading && kyc.error && <HrsError error={kyc.error} onRetry={kyc.retry} />}
            {!kyc.loading && !kyc.error && (<>
                {/* THE ACCOUNT'S DECISION. Rendered before the documents because
                    it is the answer; the documents are the evidence. */}
                <div className="panel" style={{ padding: 20, marginBottom: 12 }}>
                  <div className="hp-card__title" style={{ marginBottom: 12 }}>Identity verification</div>
                  <div className="hp-info__row"><span className="k">Status</span><span className="v">
                    <span className={`chip ${kyc.code === "approved" ? "chip--ok" : kyc.code === "rejected" ? "chip--err" : "chip--warn"}`} style={{ fontSize: 10 }}>
                      {kyc.row ? kyc.label : (canSee ? "Never submitted" : "—")}
                    </span>
                  </span></div>
                  {/* "Never submitted" and "Not started" are different answers.
                      No row at all means nobody has ever opened a review; an
                      explicit `none` means one was opened and set back. */}
                  {!kyc.row && canSee && (
                    <HpxNoBackendNote>
                      This player has no <code>user_kyc</code> row — nobody has ever recorded a decision.
                      That is not the same as the status <em>Not started</em>, which is a decision somebody made.
                    </HpxNoBackendNote>
                  )}
                  {kyc.row && (<>
                    <div className="hp-info__row"><span className="k">Decided by</span><span className="v">{kyc.decidedBy || "—"}</span></div>
                    <div className="hp-info__row"><span className="k">Decided at</span><span className="v">{kyc.decidedAt ? hpDate(kyc.decidedAt, true) : "—"}</span></div>
                    <div className="hp-info__row"><span className="k">Reason</span><span className="v">{kyc.reason || "—"}</span></div>
                    <div className="hp-info__row"><span className="k">Expires</span><span className="v">{kyc.expiresAt ? hpDate(kyc.expiresAt) : "—"}</span></div>
                  </>)}

                  {canSee ? (
                    <div className="hpx-kyc-actions">
                      <input className="input" placeholder="Reason (required to reject)" value={kycReason}
                        onChange={e => setKycReason(e.target.value)} style={{ flex: "1 1 240px", minWidth: 180 }} />
                      <button className="rpt-btn rpt-btn--green hpx-mini-btn"
                        disabled={kycSave.busy || kyc.code === "approved"}
                        onClick={() => decideKyc("approved")}>
                        {kycSave.busy ? "SAVING…" : "Approve"}
                      </button>
                      {/* Disabled without a reason rather than defaulting one.
                          set_kyc_status refuses a reasonless rejection anyway —
                          this only stops the round trip, it is not the check. */}
                      <button className="rpt-btn rpt-btn--red hpx-mini-btn"
                        disabled={kycSave.busy || !kycReason.trim()}
                        title={kycReason.trim() ? "" : "A rejection needs a reason — set_kyc_status refuses one without."}
                        onClick={() => decideKyc("rejected")}>Reject</button>
                      <button className="rpt-btn rpt-btn--search hpx-mini-btn"
                        disabled={kycSave.busy || kyc.code === "pending"}
                        onClick={() => decideKyc("pending")}>Mark pending</button>
                      <button className="rpt-btn rpt-btn--search hpx-mini-btn"
                        disabled={kycSave.busy || kyc.code === "expired"}
                        onClick={() => decideKyc("expired")}>Mark expired</button>
                    </div>
                  ) : (
                    <div className="hpx-gate-note" style={{ marginTop: 12 }}>
                      <Icon name="lock" size={11} /> Your role does not review identity documents, so this tab reads
                      empty whatever this player has uploaded. That is RLS filtering rows, not an error and not an
                      empty queue.
                    </div>
                  )}
                </div>

                {/* THE DOCUMENTS. */}
                <div className="panel" style={{ overflow: "hidden" }}><div style={{ overflowX: "auto" }}>
                  <table className="data-table hp-list">
                    <thead><tr><th>ID</th><th>Type</th><th>File</th><th>Status</th><th>Reviewed by</th><th>Reviewed</th><th>Note</th><th>Uploaded</th></tr></thead>
                    <tbody>
                      {kyc.docs.length === 0 && (
                        <tr><td colSpan={8} style={{ padding: "30px", textAlign: "center", color: "var(--text-tertiary)" }}>
                          {canSee
                            ? "No documents uploaded for this player."
                            : "Hidden from your role — Super admin, Skin admin and Customer care only."}
                        </td></tr>
                      )}
                      {kyc.docs.map(d => (
                        <tr key={d.id}>
                          <td>{d.id}</td>
                          <td>{d.type}</td>
                          {/* The PATH, not a link. user_kyc_documents stores a
                              path into object storage and never the document
                              itself; turning it into a URL needs a signed one
                              minted server-side. A URL built here would either
                              404 or, far worse, work without a signature. */}
                          <td style={{ textAlign: "left" }}>
                            <HpxNoBackend className="hpx-doclink" what="Open document"
                              need="a signed storage URL — user_kyc_documents holds a path, and signing it is a server-side operation">
                              <Icon name="receipt" size={10} /> <span className="hp-mono">{d.path}</span>
                            </HpxNoBackend>
                          </td>
                          <td><span className={`chip ${d.code === "approved" ? "chip--ok" : d.code === "rejected" ? "chip--err" : "chip--warn"}`} style={{ fontSize: 10 }}>{d.label}</span></td>
                          <td>{d.reviewedBy || "—"}</td>
                          <td>{d.reviewedAt ? hpDate(d.reviewedAt, true) : "—"}</td>
                          <td style={{ textAlign: "left" }}>{d.note || "—"}</td>
                          <td>{d.createdAt ? hpDate(d.createdAt, true) : "—"}</td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                  <div className="hp-entries">{kyc.docs.length} document{kyc.docs.length === 1 ? "" : "s"}</div>
                </div></div>

                <HpxNoBackendNote>
                  A decision moves the account row AND every still-pending document in one
                  transaction — an account approved while its documents still read
                  &ldquo;pending&rdquo; is a state no reviewer chose. A document already rejected
                  on its own keeps that decision.
                  {/* <!-- SUGGESTION: isystem's screen has four fixed document slots (Id card front/back, Address, Funds) as columns on the verification row. Here doc_type is data, so a fifth type needs no migration — but nothing yet constrains doc_type to a known list, so two operators can spell "id_front" two ways. A lookup table for doc_type would settle it. --> */}
                </HpxNoBackendNote>
            </>)}
          </div>
        );
      })()}

      {tab === "stats" && (() => {
        if (ledger.loading) return <HrsSkeleton rows={8} cols={2} />;
        if (ledger.error) return <HrsError error={ledger.error} onRetry={ledger.retry} />;
        /* Aggregated over the ledger rows this screen fetched — capped at 500
           per wallet. A player past that cap would show understated lifetime
           totals, so the cap is stated on screen rather than assumed away. */
        const st = hpxStats(ledger);
        const panel = (title, s) => (
          <section className="hp-card hpx-stat-panel">
            <div className="hp-card__title">{title}</div>
            {[
              ["Deposits", `${s.depCount} · ${hpARS(s.depTotal)}`],
              ["Withdrawals", `${s.wdCount} · ${hpARS(s.wdTotal)}`],
              ["Casino bets", `${s.cBet} · ${hpARS(s.cBetTotal)}`],
              ["Casino wins", hpARS(s.cWinTotal)],
              ["Casino GGR", hpARS(s.casinoGGR)],
              ["Sport bets", `${s.sBet} · ${hpARS(s.sBetTotal)}`],
              ["Sport wins", hpARS(s.sWinTotal)],
              ["Sport GGR", hpARS(s.sportGGR)],
            ].map(([k, v]) => (
              <div className="hp-info__row" key={k}><span className="k">{k}</span><span className="v">{v}</span></div>
            ))}
          </section>
        );
        return (
          <div>
            <div className="hpx-gate-note" style={{ marginBottom: 12 }}><Icon name="chart" size={11} /> PlayerStatsService::forPlayer() — lifetime + last-30-days aggregates. Customer Care needs <code>support_player_transactions</code>.</div>
            {ledger.history.length >= 500 && (
              <div className="hpx-gate-note" style={{ marginBottom: 12 }}>
                <Icon name="alert" size={11} /> Aggregated over the most recent 500 ledger rows, which is the read limit on this screen — this player has at least that many, so "Lifetime" is understated. A server-side aggregate is the fix.
                {/* <!-- SUGGESTION: expose lifetime player aggregates as a view or RPC. Summing a full ledger in the browser needs every row shipped to the client, which is both slow and the exposure RLS exists to prevent. --> */}
              </div>
            )}
            <div className="hpx-stat-cols">
              {panel("Lifetime", st.lifetime)}
              {panel("Last 30 days", st.last30)}
            </div>
          </div>
        );
      })()}

      {/* BOTH OF THESE HAD TABLES ALL ALONG. They rendered a fixed empty state
          saying "no coupons for this player yet" — a claim about the player,
          made without asking the database, and identical for every player on
          the platform. `sport_coupons` (008) and `jackpot_wins` (003) both
          carry a user_id and an RLS policy scoped to the caller's subtree. */}
      {tab === "coupons" && (
        <div>
          <div className="hpx-gate-note" style={{ marginBottom: 12 }}>
            <Icon name="lock" size={11} /> Gated by <code>support_sport_coupons</code> — hidden for Customer Care without it.
          </div>
          <HrsAsync state={couponFeed} skeletonRows={6} skeletonCols={7}
                    empty="No sport coupons for this player.">
            {(rows) => (
              <div className="panel" style={{ overflow: "hidden" }}><div style={{ overflowX: "auto" }}>
                <table className="data-table hp-list">
                  <thead><tr><th>ID</th><th>Ticket</th><th>Type</th><th>Sel.</th><th>Odds</th><th>Stake</th><th>Win</th><th>Status</th><th>Placed</th></tr></thead>
                  <tbody>
                    {rows.map(c => (
                      <tr key={c.id}>
                        <td>{c.id}</td>
                        <td className="hp-mono">{c.external_ticket_id || c.bet_code || "—"}</td>
                        <td>{c.type ? c.type.label : (c.coupon_type || "—")}</td>
                        <td>{c.selection_count == null ? "—" : c.selection_count}</td>
                        <td>{c.odds == null ? "—" : Number(c.odds).toFixed(2)}</td>
                        <td>{hpMoneyPlain(c.stake)}</td>
                        {/* NET payout, which is what the player receives.
                            gross_payout differs by payout_tax, and showing the
                            pre-tax figure in a column headed "Win" overstates
                            every settled ticket. */}
                        <td>{hpMoneyPlain(c.net_payout)}</td>
                        <td><span className="chip chip--neutral" style={{ fontSize: 10 }}>{c.status ? c.status.label : c.status_code}</span></td>
                        <td>{hpDate(hpTs(c.placed_at), true)}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
                <div className="hp-entries">{rows.length} coupon{rows.length === 1 ? "" : "s"}</div>
              </div></div>
            )}
          </HrsAsync>
        </div>
      )}
      {tab === "jackpot" && (
        <HrsAsync state={jackpotFeed} skeletonRows={4} skeletonCols={5}
                  empty="No jackpot wins for this player.">
          {(rows) => (
            <div className="panel" style={{ overflow: "hidden" }}><div style={{ overflowX: "auto" }}>
              <table className="data-table hp-list">
                <thead><tr><th>ID</th><th>Game</th><th>Amount</th><th>Paid</th><th>Won at</th></tr></thead>
                <tbody>
                  {rows.map(j => (
                    <tr key={j.id}>
                      <td>{j.id}</td>
                      <td>{j.game ? j.game.name : "—"}</td>
                      <td>{j.currency} {hpMoneyPlain(j.win_amount)}</td>
                      {/* PAID vs OWED, and it is not decoration. A NULL
                          ledger_entry_id means the win has not been credited —
                          a screen showing only the amount cannot tell the two
                          apart, and the difference is money the player is owed. */}
                      <td>{j.ledger_entry_id
                        ? <span className="chip chip--ok" style={{ fontSize: 10 }}>Credited</span>
                        : <span className="chip chip--warn" style={{ fontSize: 10 }}>Not paid yet</span>}</td>
                      <td>{hpDate(hpTs(j.won_at), true)}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
              <div className="hp-entries">{rows.length} jackpot win{rows.length === 1 ? "" : "s"}</div>
            </div></div>
          )}
        </HrsAsync>
      )}

      {tab === "home" && (
        <div>
          <button className="hp-save" onClick={saveProfile} disabled={psave.busy}>
            {psave.busy ? "SAVING…" : "SAVE"}
          </button>
          {/* What this saves, said plainly. `users` exposes five editable
              columns through the write allowlist; the birthplace, residence and
              birthday controls above have no column in this schema, so they are
              display-only and this note is what stops the button implying
              otherwise. Password is NOT here: it lives in Supabase auth, not in
              `users`, and changing it is an auth-admin operation. */}
          <HpxNoBackendNote>
            Saves username, name, lastname, email and mobile. The birthplace,
            residence and birthday fields have no column in this schema and are
            display-only; the password is held by the auth provider, not by
            <code> users</code>.
          </HpxNoBackendNote>
        </div>
      )}
    </div>
  );
};

const hpMoneyPlain = (n) => Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });

/* ---------------- Promotions tab (today's bonus work) ---------------- */
const HostPlayerPromotions = ({ player }) => {
  const feed = useHrsFetch(
    () => window.sb.list("playerPromotions", { limit: 200, filters: { user: player.id } }),
    [player.id]);
  const all = useMemoHP(() => (feed.data || []).map(hpPromoRow), [feed.data]);
  const promoFeed = useHrsFetch(
    () => window.sb.list("promotions", { limit: 300, filters: { skin: player.skinId } }),
    [player.skinId]);
  const [promoPick, setPromoPick] = useStateHP("");
  const [promoAmt, setPromoAmt] = useStateHP("");
  const gsave = useHrsSave([]);
  const assignPromo = () => {
    if (!promoPick) return;
    const amt = promoAmt.trim() === "" ? null : Number(promoAmt);
    /* An explicitly typed 0 is refused by the RPC ("a zero bonus is a row the
       player can see and never use"); an EMPTY box means "use the campaign's
       own amount" and must not become 0 on the way there. */
    gsave.run(() => window.sb.rpc("grant_bonus", {
      p_user_id: player.id,
      p_promotion_id: Number(promoPick),
      p_program_id: null,
      p_amount: amt,
      p_wagering: null,
      p_expires_at: null,
      p_reason: null,
    }), {
      done: `Bonus issued to ${player.username}`,
      fail: `No bonus was issued to ${player.username}`,
    }).then(res => { if (res && res.ok) { setPromoPick(""); setPromoAmt(""); feed.retry(); } });
  };
  const cancelBonus = (b) => gsave.run(
    () => window.sb.rpc("forfeit_bonus", { p_instance_id: b.id, p_reason: "Cancelled by operator" }),
    {
      done: `Bonus cancelled for ${player.username}`,
      fail: `The bonus was not cancelled for ${player.username}`,
    }).then(res => { if (res && res.ok) feed.retry(); });
  const [draft, setDraft] = useStateHP({ name: "", typology: "ALL", status: "ALL", dateFrom: "", dateTo: "", expFrom: "", expTo: "", amtFrom: "", amtTo: "" });
  const [applied, setApplied] = useStateHP(draft);
  const setD = (p) => setDraft(d => ({ ...d, ...p }));

  const rows = useMemoHP(() => {
    const dF = applied.dateFrom ? Date.parse(applied.dateFrom + "T00:00:00Z") : -Infinity;
    const dT = applied.dateTo ? Date.parse(applied.dateTo + "T23:59:59Z") : Infinity;
    const eF = applied.expFrom ? Date.parse(applied.expFrom + "T00:00:00Z") : -Infinity;
    const eT = applied.expTo ? Date.parse(applied.expTo + "T23:59:59Z") : Infinity;
    const aF = parseFloat(applied.amtFrom), aT = parseFloat(applied.amtTo);
    const nq = applied.name.trim().toLowerCase();
    return all.filter(b => {
      if (nq && !b.name.toLowerCase().includes(nq)) return false;
      if (applied.typology !== "ALL" && b.typology !== applied.typology) return false;
      if (applied.status !== "ALL" && b.status !== applied.status) return false;
      if (b.date < dF || b.date > dT) return false;
      if (b.expiry < eF || b.expiry > eT) return false;
      if (!isNaN(aF) && b.bonusAmount < aF) return false;
      if (!isNaN(aT) && b.bonusAmount > aT) return false;
      return true;
    });
  }, [all, applied]);

  const reset = () => { const d = { name: "", typology: "ALL", status: "ALL", dateFrom: "", dateTo: "", expFrom: "", expTo: "", amtFrom: "", amtTo: "" }; setDraft(d); setApplied(d); };

  // Per-player promotion totals (whole history, not filtered).
  const pbStats = useMemoHP(() => {
    const by = { Active: 0, Redeemed: 0, Canceled: 0, Failed: 0, Expired: 0 };
    let issued = 0, redeemedAmt = 0;
    all.forEach(b => { by[b.status] = (by[b.status] || 0) + 1; issued += b.bonusAmount; redeemedAmt += b.redeemedAmount || 0; });
    return { total: all.length, by, issued, redeemedAmt };
  }, [all]);

  /* An empty tab and a failed read look identical once the rows are mapped, and
     only one of them means "this player has no bonuses". Surfaced before the
     stats row, because the totals under it would otherwise read as zeros. */
  if (feed.loading) return <HrsSkeleton rows={5} cols={6} />;
  if (feed.error) return <HrsError error={feed.error} onRetry={feed.retry} />;

  return (
    <div>
      <div className="hp-promo-stats">
        <div className="hp-pstat hp-pstat--total"><div className="hp-pstat-v">{pbStats.total}</div><div className="hp-pstat-k">Total promotions</div></div>
        <div className="hp-pstat hp-pstat--active"><div className="hp-pstat-v">{pbStats.by.Active}</div><div className="hp-pstat-k">Active</div></div>
        <div className="hp-pstat hp-pstat--redeemed"><div className="hp-pstat-v">{pbStats.by.Redeemed}</div><div className="hp-pstat-k">Redeemed</div></div>
        <div className="hp-pstat hp-pstat--canceled"><div className="hp-pstat-v">{pbStats.by.Canceled}</div><div className="hp-pstat-k">Canceled</div></div>
        <div className="hp-pstat hp-pstat--failed"><div className="hp-pstat-v">{pbStats.by.Failed}</div><div className="hp-pstat-k">Failed</div></div>
        <div className="hp-pstat hp-pstat--money"><div className="hp-pstat-v">{pbMoney(pbStats.redeemedAmt)}</div><div className="hp-pstat-k">Redeemed amount</div></div>
      </div>
      <div className="rpt-filters">
        <div className="rpt-field"><label>Name</label><input className="input" value={draft.name} onChange={e => setD({ name: e.target.value })} placeholder="Insert name" /></div>
        <div className="rpt-field"><label>Typology</label>
          <select className="select" value={draft.typology} onChange={e => setD({ typology: e.target.value })}>
            <option value="ALL">Select</option><option>Wagering bonus</option><option>Cash bonus</option><option>Freespin</option>
          </select>
        </div>
        <div className="rpt-field"><label>Status</label>
          <select className="select" value={draft.status} onChange={e => setD({ status: e.target.value })}>
            <option value="ALL">Select</option>{["Active", "Redeemed", "Expired", "Failed", "Canceled"].map(s => <option key={s}>{s}</option>)}
          </select>
        </div>
        <div className="rpt-field"><label>Date</label><div className="rpt-daterow">
          <input className="input rpt-date" type="date" value={draft.dateFrom} onChange={e => setD({ dateFrom: e.target.value })} />
          <input className="input rpt-date" type="date" value={draft.dateTo} onChange={e => setD({ dateTo: e.target.value })} />
        </div></div>
        <div className="rpt-field"><label>Expiry date</label><div className="rpt-daterow">
          <input className="input rpt-date" type="date" value={draft.expFrom} onChange={e => setD({ expFrom: e.target.value })} />
          <input className="input rpt-date" type="date" value={draft.expTo} onChange={e => setD({ expTo: e.target.value })} />
        </div></div>
        <div className="rpt-field"><label>Amount</label><div className="rpt-daterow">
          <input className="input rpt-time" placeholder="From" value={draft.amtFrom} onChange={e => setD({ amtFrom: e.target.value })} />
          <input className="input rpt-time" placeholder="To" value={draft.amtTo} onChange={e => setD({ amtTo: e.target.value })} />
        </div></div>
        <div className="rpt-actions">
          <div className="rpt-actions-row">
            <button className="rpt-btn rpt-btn--blue" onClick={() => setApplied(draft)}><Icon name="search" size={14} /> Search</button>
            <button className="rpt-btn rpt-btn--reset" onClick={reset}><Icon name="x" size={14} /> Remove filters</button>
          </div>
        </div>
      </div>

      {/* ASSIGN. grant_bonus() (supabase/053) writes the bonus-wallet credit and
          the instance that names it in one transaction, in that order — 004's
          R8 refuses an active instance holding a balance with no grant ledger
          entry, so the other order cannot commit. The campaign's terms are
          copied onto the instance at issue and frozen there.

          The campaign list is the player's OWN BRAND only. A promotion from
          another skin would be refused by the RPC and by the guard trigger;
          filtering here is so the operator is not offered a choice that cannot
          work. */}
      <div className="hp-assign-row">
        <select className="select" style={{ minWidth: 220 }} value={promoPick}
          onChange={e => setPromoPick(e.target.value)}>
          <option value="">Select a promotion…</option>
          {(promoFeed.data || []).filter(pr => Number(pr.skin_id) === Number(player.skinId))
            .map(pr => <option key={pr.id} value={pr.id}>{pr.name}</option>)}
        </select>
        <input className="input" style={{ width: 130 }} placeholder="Amount (optional)"
          value={promoAmt} onChange={e => setPromoAmt(e.target.value)}
          title="Leave empty to use the promotion's own amount. Passing one here overrides it for this player only — the instance still freezes whatever it was issued on." />
        <button className="hp-assign" disabled={!promoPick || gsave.busy} onClick={assignPromo}>
          {gsave.busy ? "ASSIGNING…" : "ASSIGN PROMO TO PLAYER"}
        </button>
      </div>
      {promoFeed.error && <HrsError error={promoFeed.error} onRetry={promoFeed.retry} />}

      <div className="panel" style={{ overflow: "hidden" }}>
        <div style={{ overflowX: "auto" }}>
          <table className="data-table">
            <thead><tr>
              <th>Name</th><th>Bonus amount</th><th>Date</th><th>Typology</th><th>Expiry date</th>
              <th>Redeemed at</th><th>Balance</th><th>Wagered Amount</th><th>Wagering Amount</th>
              <th>Redeemed amount</th><th>Status</th><th>Cancel</th>
            </tr></thead>
            <tbody>
              {rows.length === 0 && <tr><td colSpan={12} style={{ padding: "30px", textAlign: "center", color: "var(--text-tertiary)" }}>No data available in table</td></tr>}
              {rows.map((b, i) => (
                <tr key={b.id + "-" + i}>
                  <td style={{ fontWeight: 600 }}>{b.name}</td>
                  <td>{pbMoney(b.bonusAmount)}</td>
                  <td>{pbDate(b.date)}</td>
                  <td><span className="chip chip--neutral" style={{ fontSize: 10 }}>{b.typology}</span></td>
                  <td>{pbDate(b.expiry)}</td>
                  <td>{b.redeemedAt ? pbDate(b.redeemedAt) : "-"}</td>
                  <td>{pbMoney(b.balance)}</td>
                  <td>{pbMoney(b.wagered)}</td>
                  <td>{pbMoney(b.wageringRemaining)}</td>
                  <td>{pbMoney(b.redeemedAmount)}</td>
                  <td><span className={`chip ${PB_STATUS_CHIP[b.status] || "chip--neutral"}`} style={{ fontSize: 10 }}>{b.status}</span></td>
                  {/* CANCEL. forfeit_bonus() debits whatever is left and only
                      then closes the row — R6 refuses a terminal instance
                      holding a balance, so "set status = forfeited" on its own
                      cannot commit. Cancelling an already-closed bonus is a
                      reported no-op rather than a second debit. */}
                  <td>{b.status === "Active"
                    ? <button className="btn btn--ghost btn--icon btn--sm" title="Cancel this bonus — the remaining balance leaves the bonus wallet with a ledger row saying where it went"
                        disabled={gsave.busy} onClick={() => cancelBonus(b)}><Icon name="x" size={12} /></button>
                    : "-"}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
};

/* ---------------- Players list ---------------- */
const HostPlayers = ({ brand }) => {
  window.useLocale && window.useLocale();
  const feed = useHrsFetch(() => window.sb.list("players", { limit: 2000 }), []);
  /* "Subnet block" means an ANCESTOR is blocked, not this player — the badge
     that tells an operator why an unblocked account cannot play
     (UsersController::checkUserBlock). It used to be a 4% coin flip. Only the
     blocked operators are fetched, and only those the caller can see: RLS stops
     at their own subtree, so an ancestor above that root cannot be checked from
     here. The server-side check on every request is the real one. */
  const blockedFeed = useHrsFetch(
    () => window.sb.list("networkUsers", { limit: 500, filters: { blocked: true } }), []);
  const blockedPaths = useMemoHP(
    () => (blockedFeed.data || []).map(u => String(u.path || "")).filter(Boolean),
    [blockedFeed.data]);
  /* The operator, for the audit rows a block writes. */
  const meFeed = useHrsFetch(() => window.sb.me(), []);
  const save = useHrsSave([feed, blockedFeed]);
  const rows = useMemoHP(() => (feed.data || []).map(r => {
    const row = hpPlayerRow(r);
    row.subnetBlock = !row.userBlock && blockedPaths.some(bp => row.path.indexOf(bp + ".") === 0);
    return row;
  }), [feed.data, blockedPaths]);
  const [selected, setSelected] = useStateHP(null);
  /* The Parent select used to be six invented shop names. It is the set of
     parents that actually own a visible player — which is also the only set an
     operator can filter to, since RLS shows nothing outside their subtree. */
  /* THE SHOPS A PLAYER MAY HANG UNDER — a feed, not a derivation. The old
     version was the distinct `parent` NAMES of the players already on screen,
     which excluded every shop that has no players yet (exactly the one an
     operator is most likely to be filling) and produced a string where
     create_user() needs an id. RLS limits this to the caller's subtree, which
     is the same set the server will accept. */
  const shopFeed = useHrsFetch(
    () => window.sb.list("networkUsers", { limit: 500, filters: { level: 20 } }), []);
  const parentOptions = useMemoHP(
    () => (shopFeed.data || [])
            .map(r => ({ id: Number(r.id), username: String(r.username || "") }))
            .sort((a, b) => a.username.localeCompare(b.username)),
    [shopFeed.data]);
  const [f, setF] = useStateHP({ id: "", name: "", lastname: "", email: "", skin: "ALL", ip: "", username: "", parent: "ALL", lastFrom: "", lastTo: "" });
  const [sort, setSort] = useStateHP({ key: "id", dir: "desc" }); // default order: ID desc (ajax.js:66)
  const [page, setPage] = useStateHP(0);
  const [pageSize, setPageSize] = useStateHP(50);                 // real default 50, lengthMenu [5,10,25,50]
  const [cols, setCols] = useStateHP(() => {
    const s = pbStore.get("hpx-player-cols", null); if (s) return s;
    return HPX_COLS_DEFAULT;
  });
  const [colsOpen, setColsOpen] = useStateHP(false);
  const [sheetOpen, setSheetOpen] = useStateHP(false);            // mobile full-height filter sheet
  const [newOpen, setNewOpen] = useStateHP(false);
  const [blockModal, setBlockModal] = useStateHP(null);           // { id, username, field, next }
  const [notesFor, setNotesFor] = useStateHP(null);
  const [deleteFor, setDeleteFor] = useStateHP(null);             // row pending delete confirmation
  const setFilter = (p) => { setF(s => ({ ...s, ...p })); setPage(0); };
  const isOn = (id) => cols.includes(id);
  const setColsPersist = (next) => { setCols(next); pbStore.set("hpx-player-cols", next); };
  const toggleCol = (id) => setColsPersist(isOn(id) ? cols.filter(c => c !== id) : [...cols, id]);

  /* Filter semantics per the real grid (getPlayersList): ID exact `=`,
     Username PREFIX match (LIKE v%), Name/Lastname/Email/IP substring,
     Skin/Parent exact, Last access whereBetween (open-ended sides ok). */
  const filtered = useMemoHP(() => rows.filter(p => {
    if (f.id && String(p.id) !== f.id.trim()) return false;
    if (f.username && !p.username.toLowerCase().startsWith(f.username.trim().toLowerCase())) return false;
    if (f.name && !(p.firstname || "").toLowerCase().includes(f.name.trim().toLowerCase())) return false;
    if (f.lastname && !(p.lastname || "").toLowerCase().includes(f.lastname.trim().toLowerCase())) return false;
    if (f.email && !(p.email || "").toLowerCase().includes(f.email.trim().toLowerCase())) return false;
    if (f.ip && !(p.ip || "").includes(f.ip.trim())) return false;
    if (f.skin !== "ALL" && p.skin !== f.skin) return false;
    if (f.parent !== "ALL" && p.parent !== f.parent) return false;
    if (f.lastFrom && (!p.last || p.last < Date.parse(f.lastFrom + "T00:00:00Z"))) return false;
    if (f.lastTo && (!p.last || p.last > Date.parse(f.lastTo + "T23:59:59Z"))) return false;
    return true;
  }), [rows, f]);

  const sorted = useMemoHP(() => {
    const val = {
      id: p => p.id, username: p => p.username.toLowerCase(), parent: p => p.parent, skin: p => p.skin,
      balance: p => p.nwd, // the backend orders `users.balance` = the NON-withdrawable column, not the displayed total
      email: p => (p.email || ""), last_login: p => p.last || 0, firstname: p => (p.firstname || ""),
      lastname: p => (p.lastname || ""), last_login_ip: p => (p.ip || ""), mobile_phone: p => (p.mobile || ""),
      registration_date: p => p.reg,
    }[sort.key] || ((p) => p.id);
    const dir = sort.dir === "asc" ? 1 : -1;
    return [...filtered].sort((a, b) => { const va = val(a), vb = val(b); return va < vb ? -dir : va > vb ? dir : 0; });
  }, [filtered, sort]);

  const paged = sorted.slice(page * pageSize, page * pageSize + pageSize);

  const toggleSort = (key) => {
    setPage(0);
    setSort(s => s.key === key
      ? { key, dir: s.dir === "asc" ? "desc" : "asc" }
      : { key, dir: ["id", "last_login", "registration_date", "balance"].includes(key) ? "desc" : "asc" });
  };

  /* Only the block/unblock flow reaches this now, and that flow really does
     mutate the row and append an audit note — so the message describes what
     just happened instead of claiming a backend call ran. */
  const toast = (label, p) => window.PAYBO?.emitToast && window.PAYBO.emitToast({ id: `pl-${label}-${p.id}-${Date.now()}`, tx_id: `${label} · ${p.username}`, amount: 0, currency: "HOST", player: "Players", reason: "Row updated and the comment appended to its block-note history. Real: POST /setcashblock/{id}/ · POST /setblock/{id}/ + a logs row." });

  /* XLSX export (admin.players.export → POST /players/excel). Column list =
     Player::exportList. Direct download ≤ EXPORT_WEB_LIMIT (default 10,000)
     rows, else queued ExportPlayers job that emails a link.
     KNOWN-BUG DIVERGENCE: the live DataTables exports re-run the query AFTER
     offset/limit and only ever export the current page; the prototype exports
     the full filtered set — the evident intent. */
  // <!-- SUGGESTION: fix Player::exportList to drop the DataTables offset/limit so exports cover the whole filtered result, not the visible page. -->
  const exportCSV = () => {
    if (!window.PAYBO) return;
    window.PAYBO.downloadCSV(`players-${new Date().toISOString().slice(0, 10)}.csv`, sorted, [
      { key: "id", label: "id" }, { key: "username", label: "username" }, { key: "parent", label: "parent" },
      { key: "skin", label: "skin" },
      { key: "balance", label: "balance", get: r => "ARS " + (r.wd + r.nwd).toFixed(2) },
      { key: "email", label: "email" },
      { key: "last", label: "last_access", get: r => hpDate(r.last) },
      { key: "cashBlock", label: "cash_block", get: r => (r.cashBlock ? 1 : 0) },
      { key: "userBlock", label: "user_block", get: r => (r.userBlock ? 1 : 0) },
      { key: "firstname", label: "name" }, { key: "lastname", label: "lastname" },
      { key: "ip", label: "last_login_ip" }, { key: "mobile", label: "mobile_phone" },
      { key: "mobileVerified", label: "mobile_verified", get: () => "" },   // no users.mobile_verified column — exported empty, not "No"
      { key: "reg", label: "registration_date", get: r => hpDate(r.reg, true) },
      { key: "bonus", label: "bonus_wallet", get: r => Number(r.bonus).toFixed(2) },
      // Per-category loss is a ledger aggregate, not a column. Exported empty
      // rather than as 0.00, which would read as "this player never lost".
      { key: "sportLoss", label: "sportbook_total_loss", get: () => "" },
      { key: "casinoLoss", label: "casino_total_loss", get: () => "" },
      { key: "virtualLoss", label: "virtual_total_loss", get: () => "" },
    ]);
    window.PAYBO.emitToast && window.PAYBO.emitToast({ id: `exp-pl-${Date.now()}`, tx_id: "Players export", amount: 0, currency: "CSV", player: `${sorted.length} rows`, reason: "≤ EXPORT_WEB_LIMIT rows → direct download; larger exports queue an ExportPlayers job that emails a link." });
  };

  /* TWO WRITES, FLAG FIRST, and the order is the safe direction to fail in.
     `users.blocked` / `users.cash_blocked` is the flag the engine reads;
     `user_blocks` is the audit row that says who did it and why. If the audit
     row fails the account is still blocked, which is the failure to prefer.

     Mirrors HostUsers.applyBlock deliberately rather than inventing a second
     shape — the two screens block the same column on the same table, and two
     implementations of that is two chances to write one of them differently.
     <!-- SUGGESTION: make this one RPC so the flag and the audit row cannot
          diverge. Two writes from a browser can always be interrupted. --> */
  const applyBlock = async (comment) => {
    const { id, field, next } = blockModal;
    const p = rows.find(r => r.id === id) || { id, username: "—" };
    setBlockModal(null);
    const column = field === "cashBlock" ? "cash_blocked" : "blocked";
    const res = await save.run(() => window.sb.update("users", id, { [column]: next }), {
      done: `${field === "cashBlock" ? "Cash" : "User"} ${next ? "block" : "unblock"} saved for ${p.username}`,
      fail: `${p.username} was NOT ${next ? "blocked" : "unblocked"}`,
    });
    if (!res || !res.ok) return;
    /* The audit row covers the full user block only. A cash block is a narrower
       flag with no history table, upstream or here. */
    if (field === "cashBlock") return;
    const me = meFeed.data;
    if (next) {
      await save.run(() => window.sb.create("userBlocks", {
        user_id: id, blocked_by: me ? me.id : null, reason: comment || null,
      }), { done: "Block recorded", fail: "The account is blocked, but the audit row was not written" });
    } else {
      const open = await window.sb.list("userBlocks", { limit: 1, filters: { user: id, open: "1" } });
      const b = open && open.ok && open.data && open.data[0];
      if (b) {
        await save.run(() => window.sb.update("userBlocks", b.id, {
          unblocked_by: me ? me.id : null, unblocked_at: new Date().toISOString(),
        }), { done: "Unblock recorded", fail: "The account is unblocked, but the audit row was not closed" });
      }
    }
  };

  /* Real: GET /players/delete/{id}/ (super admin only). */
  /* SOFT delete — users carries deleted_at, and this schema uses it instead of
     the copy-into-a-parallel-table-and-hard-delete upstream performs. There is
     no restore, there or here: the Deleted Users screen has no such action and
     inventing one would be a button the backend does not have. */
  const applyDelete = (p) => {
    setDeleteFor(null);
    save.run(() => window.sb.remove("users", p.id), {
      done: `${p.username} deleted`,
      fail: `${p.username} was not deleted`,
    });
  };

  /* TWO WRITES, AND THE SECOND ONE IS REPORTED HONESTLY.
     create_user() takes the account's identity and its place in the tree — id,
     path and created_at are the server's to assign, and inventing max(id)+1 is
     what produced player ids that existed nowhere. It does NOT take the profile
     columns 028 added, so the residence and document fields are a second PATCH
     through the same allowlist the edit screen uses.

     A partial is therefore possible: the player exists and the address did not
     land. That is said out loud rather than smoothed over, because the player
     IS created and re-submitting the form would make a second one. The fix is
     to open the row and save — which now works (supabase/051). */
  const createPlayer = async (d) => {
    const username = String(d.username || "").trim();
    const nn = (v) => (String(v == null ? "" : v).trim() || null);
    const res = await save.run(() => window.sb.createUser({
      parentId: Number(d.parent),
      userLevel: 30,
      username,
      email: nn(d.email),
      firstname: nn(d.firstname),
      lastname: nn(d.lastname),
      mobile: nn(d.mobile),
      /* Not sent. create_user() inherits the parent's currency and skin, and a
         guess here would be this screen choosing what money the account holds. */
      currency: null,
      skinId: null,
    }), {
      done: `${username} created under the selected shop · no login yet`,
      fail: `${username} was NOT created`,
    });
    if (!res || !res.ok) return;
    setNewOpen(false);

    const id = res.data && (res.data.id || (res.data[0] && res.data[0].id));
    /* Birthday arrives as three selects; the column is a date. Assembled only
       when all three are present — a partial date is not a date, and padding
       the missing parts would invent the day somebody was born. */
    const bd = (d.bYear && d.bMonth && d.bDay)
      ? `${d.bYear}-${String(d.bMonth).padStart(2, "0")}-${String(d.bDay).padStart(2, "0")}`
      : null;
    const profile = {
      gender: nn(d.sex),
      birthdate: bd,
      /* The form's country selects hold NAMES; the column is ISO alpha-2 and
         there is no country table to map through, so a name is not written.
         Leaving it null is "not recorded"; writing "Argentina" into a char(2)
         would raise, and writing "Ar" would be this screen inventing a code. */
      province: nn(d.provinceRes),
      city: nn(d.cityRes),
      /* Street and house number are one column upstream and one here. */
      address: [nn(d.address), nn(d.houseNo)].filter(Boolean).join(" ") || null,
      postcode: nn(d.zip),
      document_type: nn(d.docType),
      document_number: nn(d.docNumber),
      test_user: !!d.test,
    };
    const any = Object.keys(profile).some(k => k !== "test_user" && profile[k] != null);
    if (!id || !(any || d.test)) return;
    const p2 = await window.sb.update("users", id, profile);
    if (!p2 || !p2.ok) {
      window.PAYBO?.emitToast && window.PAYBO.emitToast({
        id: `np-partial-${id}`, tx_id: `${username}: created, personal details NOT saved`,
        amount: 0, currency: "HOST", player: username,
        reason: "The account exists — do not submit the form again, or there will be two. Open the player and save the Home tab to store the rest.",
      });
    }
    feed.retry();
  };

  if (selected) return <HostPlayerEdit player={selected} onBack={() => {
    // Leaving the edit view — drop whatever tab sub-path we were on
    // (e.g. /players/history) back to the plain list URL.
    try { if (window.location.pathname !== "/players") window.history.pushState(null, "", "/players"); } catch (_e) {}
    setSelected(null);
  }} />;

  /* Active-filter pills (Transactions.jsx exemplar shape). */
  const pills = [];
  if (f.id) pills.push([`ID = ${f.id}`, () => setFilter({ id: "" })]);
  if (f.username) pills.push([`Username: ${f.username}…`, () => setFilter({ username: "" })]);
  if (f.name) pills.push([`Name: ${f.name}`, () => setFilter({ name: "" })]);
  if (f.lastname) pills.push([`Lastname: ${f.lastname}`, () => setFilter({ lastname: "" })]);
  if (f.email) pills.push([`Email: ${f.email}`, () => setFilter({ email: "" })]);
  if (f.skin !== "ALL") pills.push([`Skin: ${f.skin}`, () => setFilter({ skin: "ALL" })]);
  if (f.ip) pills.push([`IP: ${f.ip}`, () => setFilter({ ip: "" })]);
  if (f.parent !== "ALL") pills.push([`Parent: ${f.parent}`, () => setFilter({ parent: "ALL" })]);
  if (f.lastFrom || f.lastTo) pills.push([`Last access: ${f.lastFrom || "…"} → ${f.lastTo || "…"}`, () => setFilter({ lastFrom: "", lastTo: "" })]);
  const clearAll = () => setFilter({ id: "", name: "", lastname: "", email: "", skin: "ALL", ip: "", username: "", parent: "ALL", lastFrom: "", lastTo: "" });

  const sortIcon = (key) => sort.key === key ? <Icon name={sort.dir === "asc" ? "arrow_up" : "arrow_down"} size={10} /> : null;
  const th = (c) => c.sort
    ? <th key={c.id}><button className="hpx-th" onClick={() => toggleSort(c.sort)} title={`Sort by ${c.label}`}>{c.label} {sortIcon(c.sort)}</button></th>
    : <th key={c.id}>{c.label}</th>;
  const visCols = HPX_COLUMNS.filter(c => c.always || isOn(c.id));

  /* Notes:
     - `blocco_user` is registered under the name "blocco_cash" in index() L3054
       (copy-paste in the real code) — harmless, kept out of the prototype.
     - the backend also matches a `data_creazione` search column that has no
       filter input in the view (dead branch) — not reproduced. */
  const filterBody = (
    <div className="hpx-hero">
      <div className="hpx-fcard">
        <div className="hpx-flabel"><Icon name="tag" size={10} /> ID<Tip>Exact match on <code>users.id</code>.</Tip></div>
        <input className="hpx-finput" value={f.id} onChange={e => setFilter({ id: e.target.value })} placeholder="Player ID" />
      </div>
      <div className="hpx-fcard">
        <div className="hpx-flabel"><Icon name="user" size={10} /> Username<Tip>Prefix match — <code>users.username LIKE value%</code>, unlike the substring filters.</Tip></div>
        <input className="hpx-finput" value={f.username} onChange={e => setFilter({ username: e.target.value })} placeholder="Starts with…" />
      </div>
      <div className="hpx-fcard">
        <div className="hpx-flabel"><Icon name="user" size={10} /> Name<Tip>Personal-data filter — only rendered when the viewer holds <code>support_player_personal_data</code> (scoped managers lose it server-side too).</Tip></div>
        <input className="hpx-finput" value={f.name} onChange={e => setFilter({ name: e.target.value })} placeholder="Name" />
      </div>
      <div className="hpx-fcard">
        <div className="hpx-flabel"><Icon name="user" size={10} /> Lastname<Tip>Personal-data filter (see Name).</Tip></div>
        <input className="hpx-finput" value={f.lastname} onChange={e => setFilter({ lastname: e.target.value })} placeholder="Lastname" />
      </div>
      <div className="hpx-fcard">
        <div className="hpx-flabel"><Icon name="mail" size={10} /> Email<Tip>Personal-data filter (see Name).</Tip></div>
        <input className="hpx-finput" value={f.email} onChange={e => setFilter({ email: e.target.value })} placeholder="Email" />
      </div>
      <div className="hpx-fcard">
        <div className="hpx-flabel"><Icon name="flag" size={10} /> Skin<Tip>Only rendered for Customer Care / super admin on the live platform; options come from the viewer's skin list.</Tip></div>
        <select className="hpx-fselect" value={f.skin} onChange={e => setFilter({ skin: e.target.value })}>
          <option value="ALL">Select</option><option>Casino24hs</option>
        </select>
      </div>
      <div className="hpx-fcard">
        <div className="hpx-flabel"><Icon name="globe" size={10} /> Last Login IP<Tip>Personal-data filter — substring match on <code>users.ip</code>.</Tip></div>
        <input className="hpx-finput" value={f.ip} onChange={e => setFilter({ ip: e.target.value })} placeholder="Last Login IP" />
      </div>
      <div className="hpx-fcard">
        <div className="hpx-flabel"><Icon name="users" size={10} /> Parent<Tip>SHOP-level (cashier) accounts, fed by the <code>admin.users.search</code> select2 and narrowed by the chosen Skin on the live platform.</Tip></div>
        <select className="hpx-fselect" value={f.parent} onChange={e => setFilter({ parent: e.target.value })}>
          <option value="ALL">- Select -</option>{parentOptions.map(p => <option key={p.id} value={p.username}>{p.username}</option>)}
        </select>
      </div>
      <div className="hpx-fcard">
        <div className="hpx-flabel"><Icon name="calendar" size={10} /> Last access<Tip>Range on <code>users.last_login</code>; either side may stay open.</Tip></div>
        <div className="hpx-fdates">
          <input className="hpx-finput" type="date" value={f.lastFrom} onChange={e => setFilter({ lastFrom: e.target.value })} />
          <input className="hpx-finput" type="date" value={f.lastTo} onChange={e => setFilter({ lastTo: e.target.value })} />
        </div>
      </div>
      <div className="hpx-fcard hpx-fcard--result">
        <div className="hpx-flabel"><Icon name="chart" size={10} /> Results</div>
        <div className="hpx-fval">{filtered.length.toLocaleString()}<span> of {rows.length.toLocaleString()}</span></div>
      </div>
    </div>
  );

  return (
    <div className="page report-page host-players">
      <div className="page__header" style={{ justifyContent: "space-between", width: "100%", flexWrap: "wrap", gap: 10 }}>
        <div>
          <div className="page__title" style={{ color: "var(--p-700)" }}>Players</div>
          <div className="page__subtitle">All PLAYER-level accounts (user_level 30) in your hierarchy · grid feed GET /getPlayers</div>
        </div>
        <div className="page__actions" style={{ display: "flex", gap: 8, flexWrap: "wrap", position: "relative" }}>
          <button className="rpt-btn rpt-btn--search hpx-headbtn" onClick={() => setColsOpen(o => !o)} title="Show / hide columns (the real screen's Setting modal)">
            <Icon name="settings" size={13} /> Columns <span className="hpx-count">{HPX_COLUMNS.filter(c => c.always || cols.includes(c.id)).length}/{HPX_COLUMNS.length}</span>
          </button>
          {colsOpen && <HpxColsPopover columns={HPX_COLUMNS} visible={cols} onToggle={toggleCol} onReset={() => setColsPersist(HPX_COLS_DEFAULT)} onClose={() => setColsOpen(false)} />}
          {/* PDF is the real screen's client-side DataTables/pdfmake button; no
              generator is bundled here, so it documents itself instead of
              pretending a file was produced. (Export, next to it, really does
              download a CSV — that toast is honest and stays.) */}
          <HpxNoBackend className="rpt-btn rpt-btn--search hpx-headbtn" what="PDF export" need="DataTables pdfmake bundle (client-side button on the real screen)">PDF</HpxNoBackend>
          <button className="rpt-btn rpt-btn--green hpx-headbtn" onClick={exportCSV} title="Gated by skin setting enable_export (+ support_player_export for Customer Care) or super admin"><Icon name="download" size={13} /> Export</button>
          <button className="rpt-btn rpt-btn--blue hpx-headbtn" onClick={() => setNewOpen(true)} title="Hidden for Customer Care, for skin 65, and when disable_players_crud is on"><Icon name="plus" size={13} /> New player</button>
        </div>
      </div>

      <Explainer compact title="Who sees what — real-platform permission gates" bullets={[
        <>Sidebar + page: <code>support_players</code> (only actually checked for Affiliate / Customer Care / Administration accounts — every other role auto-passes).</>,
        <>Email, Name, Lastname, Last Login IP, Mobile and Registration IP are stripped server-side for scoped managers without <code>support_player_personal_data</code>.</>,
        <>Transfer icon: hidden for Customer Care without <code>support_player_transactions_read_only</code> or holding <code>support_disable_transfers</code>. Delete: super admin only.</>,
        <>Skin settings: <code>disable_players_crud</code> disables the block toggles and hides New player; <code>enable_export</code> gates the Export button; <code>enable_user_unblock</code> allows un-blocking.</>,
      ]}>
        The operator's player grid — hard-scoped to <code>user_level = 30</code>, the caller's <code>user_path</code> subtree and <code>deleted = 0</code>. Reads run on the replica.
      </Explainer>

      {/* Filters — hero card strip on desktop, full-height sheet behind a
          "Filters" button on mobile (§11). */}
      <button className="hpx-filters-btn" onClick={() => setSheetOpen(true)}>
        <Icon name="filter" size={13} /> Filters {pills.length > 0 && <span className="hpx-count">{pills.length}</span>}
      </button>
      <div className={`hpx-hero-wrap ${sheetOpen ? "open" : ""}`}>
        <div className="hpx-sheet-head">
          <span>Filters</span>
          <button className="hpx-sheet-close" onClick={() => setSheetOpen(false)} title="Close"><Icon name="x" size={14} /></button>
        </div>
        {filterBody}
        <div className="hpx-sheet-foot">
          <button className="rpt-btn rpt-btn--reset" onClick={clearAll}><Icon name="x" size={13} /> Reset</button>
          <button className="rpt-btn rpt-btn--blue" onClick={() => setSheetOpen(false)}><Icon name="check" size={13} /> Apply</button>
        </div>
      </div>

      {pills.length > 0 && (
        <div className="hpx-pills">
          <span className="hpx-pills-label">Active:</span>
          {pills.map(([label, clear], i) => <HpxPill key={i} label={label} onClear={clear} />)}
          <button className="hpx-clearall" onClick={clearAll}>Clear all</button>
        </div>
      )}

      {/* One answer per cause, before the table: a "no players match your
          filters" line under a failed read sends an operator hunting for a
          filter that is not set. */}
      {feed.error && <HrsError error={feed.error} onRetry={feed.retry} />}
      {feed.loading && <HrsSkeleton rows={10} cols={9} />}
      {!feed.loading && !feed.error && (<>
      {/* Desktop table */}
      <div className="panel hpx-table-wrap" style={{ overflow: "hidden" }}>
        <div style={{ overflowX: "auto" }}>
          <table className="data-table hp-list hpx-list">
            <thead><tr>{visCols.map(th)}</tr></thead>
            <tbody>
              {paged.length === 0 && <tr><td colSpan={visCols.length} style={{ padding: "36px", textAlign: "center", color: "var(--text-tertiary)" }}>
                <Icon name="search" size={20} style={{ opacity: .4, marginBottom: 6 }} />
                <div>No players match your filters.</div>
              </td></tr>}
              {paged.map(p => (
                <tr key={p.id}>
                  <td>{p.id}</td>
                  <td>
                    <div className="hpx-usercell">
                      <button className="rpt-user" onClick={() => setSelected(p)} title="Open player">{p.username} <Icon name="chevron_right" size={12} className="chev" /></button>
                      {/* begin_impersonation (036) exists and is READ-ONLY by
                          design: app_assert_not_impersonating refuses every
                          write RPC while a session is open, so this opens a
                          look-don't-touch view rather than becoming the player. */}
                      <button className="hpx-imp" title="Impersonate player — read-only, and every write RPC refuses while it is open"
                        disabled={save.busy}
                        onClick={(e) => { e.stopPropagation(); save.run(
                          () => window.sb.beginImpersonation({ targetId: p.id, reason: "Support review from the Players list" }),
                          { done: `Impersonating ${p.username}`, fail: `Could not impersonate ${p.username}` }); }}>
                        <Icon name="external" size={12} />
                      </button>
                      {/* label inferred — backend.test_player is missing from the committed default lang file */}
                      {p.testUser && <span className="chip chip--warn hpx-testchip">test player</span>}
                    </div>
                  </td>
                  {isOn("parent") && <td>{p.parent}</td>}
                  {isOn("skin") && <td>{p.skin}</td>}
                  <td style={{ textAlign: "left", minWidth: 280 }}>
                    {/* The live payload repeats "Total Balance" twice — shown once here (evident intent). */}
                    {/* <!-- SUGGESTION: drop the duplicated Total Balance line from the real Balance cell HTML (getPlayersList L1487+). --> */}
                    <div className="hp-balcell">
                      <div className="hp-balcell__lines">
                        <div className="hpx-baltotal">Total: <b>{hpARS(p.wd + p.nwd)}</b></div>
                        <div>Withdrawable balance: <b className="ok">{hpARS(p.wd)}</b></div>
                        <div>Non withdrawable balance: <b>{hpARS(p.nwd)}</b></div>
                        <div>Bonus: <b>{hpARS(p.bonus)}</b></div>
                      </div>
                      <div className="hp-balcell__btns">
                        {/* Refresh re-reads the live balance — nothing to re-read
                            here, so it is disabled rather than flashing a toast
                            over unchanged numbers. Transfer really navigates. */}
                        {/* Upstream this POSTs /getBalance/ to re-read the wallet
                            from the game provider. Here the balance IS the ledger,
                            so the honest equivalent is to refetch — there is no
                            third party holding a different number. */}
                        <button title="Refresh balances from the ledger" onClick={(e) => { e.stopPropagation(); feed.retry(); }}
                          style={{ width: 34, height: 34, border: 0, display: "grid", placeItems: "center", color: "#fff", background: "#3b82f6", cursor: "pointer" }}>
                          <Icon name="refresh" size={13} />
                        </button>
                        <button title="Transfer funds — opens the Transfer screen (/deposit). Hidden for Customer Care without support_player_transactions_read_only or with support_disable_transfers. Real link: /transfer/?from={caller|parent}&type=player&to={id}" onClick={() => hpxNavTransfer(p.id)}><Icon name="wallet" size={13} /></button>
                      </div>
                    </div>
                  </td>
                  {isOn("email") && <td style={{ textAlign: "left" }}>{p.email}</td>}
                  {isOn("reg") && <td>
                    <div>{hpDate(p.reg, true)}</div>
                    <div className="hpx-regip">IP: {p.regIp || "—"}</div>
                  </td>}
                  {isOn("last") && <td>{hpDate(p.last)}</td>}
                  {isOn("cash") && <td>
                    <div className="hpx-blockcell">
                      <Toggle value={!!p.cashBlock} onChange={() => setBlockModal({ id: p.id, username: p.username, field: "cashBlock", next: !p.cashBlock })} onLabel="" offLabel="" size="sm" />
                      {p.cashBlock && <button className="hpx-noteic" title="Block notes — user_blocks" onClick={() => setNotesFor(p)}><Icon name="info" size={13} /></button>}
                    </div>
                  </td>}
                  {isOn("block") && <td>
                    {p.subnetBlock ? (
                      <div className="hpx-blockcell">
                        <Toggle value={true} disabled onLabel="" offLabel="" size="sm" />
                        <span className="hpx-subnet" title="An ancestor account is blocked (checkUserBlock) — the toggle is disabled">Subnet block</span>
                      </div>
                    ) : (
                      <div className="hpx-blockcell">
                        <Toggle value={!!p.userBlock} onChange={() => setBlockModal({ id: p.id, username: p.username, field: "userBlock", next: !p.userBlock })} onLabel="" offLabel="" size="sm" />
                        {p.userBlock && <button className="hpx-noteic" title="Block notes — user_blocks" onClick={() => setNotesFor(p)}><Icon name="info" size={13} /></button>}
                      </div>
                    )}
                  </td>}
                  {isOn("firstname") && <td>{p.firstname}</td>}
                  {isOn("lastname") && <td>{p.lastname}</td>}
                  {isOn("ip") && <td className="hp-mono">{p.ip}</td>}
                  {isOn("mobile") && <td>{p.mobile}</td>}
                  <td>
                    <div className="hp-list-actions">
                      <button className="hp-act hp-act--danger" title="Delete — super admin only (GET /players/delete/{id}/); asks for confirmation, then removes the row from this session's list" onClick={() => setDeleteFor(p)}><Icon name="trash" size={13} /></button>
                      <button className="hp-act hp-act--edit" title="View / Edit" onClick={() => setSelected(p)}><Icon name="edit" size={13} /></button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>

      {/* Mobile stacked cards — username / balance / status first, rest behind expand (§11). */}
      <div className="hpx-cards">
        {paged.length === 0 && <div className="hpx-cards-empty">No players match your filters.</div>}
        {paged.map(p => (
          <details className="hpx-card" key={p.id}>
            <summary>
              <div className="hpx-card-top">
                <span className="hpx-card-user">{p.username}</span>
                <b className="hpx-card-bal">{hpARS(p.wd + p.nwd)}</b>
              </div>
              <div className="hpx-card-sub">
                <span>ID {p.id}</span>
                {p.testUser && <span className="chip chip--warn" style={{ fontSize: 9.5 }}>test</span>}
                {p.cashBlock && <span className="chip chip--err" style={{ fontSize: 9.5 }}>Cash block</span>}
                {p.subnetBlock ? <span className="chip chip--err" style={{ fontSize: 9.5 }}>Subnet block</span> : p.userBlock && <span className="chip chip--err" style={{ fontSize: 9.5 }}>User block</span>}
                {!p.cashBlock && !p.userBlock && !p.subnetBlock && <span className="chip chip--ok" style={{ fontSize: 9.5 }}>OK</span>}
              </div>
            </summary>
            <div className="hpx-card-body">
              {[["Parent", p.parent], ["Skin", p.skin], ["Withdrawable", hpARS(p.wd)], ["Non withdrawable", hpARS(p.nwd)], ["Bonus", hpARS(p.bonus)], ["Registration", hpDate(p.reg, true)], ["Last access", hpDate(p.last)]].map(([k, v]) => (
                <div className="hpx-card-row" key={k}><span>{k}</span><b>{v}</b></div>
              ))}
              <div className="hpx-card-acts">
                <button className="rpt-btn rpt-btn--blue hpx-mini-btn" onClick={() => setSelected(p)}><Icon name="edit" size={12} /> Open</button>
                <button className="rpt-btn rpt-btn--search hpx-mini-btn" title="Refresh balances from the ledger"
                  onClick={(e) => { e.stopPropagation(); feed.retry(); }}><Icon name="refresh" size={12} /> Balance</button>
                <button className="rpt-btn rpt-btn--search hpx-mini-btn" onClick={() => hpxNavTransfer(p.id)} title="Opens the Transfer screen (/deposit)"><Icon name="wallet" size={12} /> Transfer</button>
              </div>
            </div>
          </details>
        ))}
      </div>

      <HpxPager total={sorted.length} page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} />
      </>)}

      {newOpen && <HpxNewPlayerModal parents={parentOptions} onClose={() => setNewOpen(false)} onCreate={createPlayer} />}
      {blockModal && <HpxBlockModal target={blockModal} onConfirm={applyBlock} onClose={() => setBlockModal(null)} />}
      {notesFor && <HpxNotesModal player={rows.find(r => r.id === notesFor.id) || notesFor} onClose={() => setNotesFor(null)} />}
      {deleteFor && <HpxDeleteModal player={deleteFor} onConfirm={() => applyDelete(deleteFor)} onClose={() => setDeleteFor(null)} />}
    </div>
  );
};

window.HostPlayers = HostPlayers;
