// 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 /payments/players/ · AdminPaymentsController::show('players') → playersData() — see docs/ISYSTEM_REFERENCE.md §Batch 10.1
/* Traced Aug 2026 (architecture item 2). PayBO's players list, distinct from
   the host Players screen (PlayersController → admin.players.index, built as
   HostPlayers.jsx) — same people, different surface and different columns.
   The real list is server-paginated via PaymentPlayerService with
   ->appends(filters). Row click goes to GET /payments/players/{id}/
   (playerDetail → sections/player.blade.php), which is Player360.jsx. */
/* Players list — searchable/filterable grid. Click a row → drill into Player 360.
   Filter chrome and table treatment mirror the Transactions + Activity pages. */

/* REAL ROWS, IN THE SHAPE THIS SCREEN ALREADY RENDERS.

   The list used to come from window.MOCK.PLAYERS_LIST — a synthetic set built
   in engine.jsx. It looked entirely convincing, which is the problem: the
   deposit totals, the countries and the "last active" times were invented, and
   nothing on the page said so. A screen that shows a plausible wrong number is
   worse than one that shows nothing.

   The mapping is deliberately narrow. Where the schema has the fact, it is
   used. Where it does NOT, the field is null and the table renders its own "—"
   — it is never filled with something reasonable-looking:

     country        no column on `users`. isystem has one; we did not extract
                    it, so it stays empty rather than being guessed from the
                    dial code or the skin.
     notes_list     player notes are a table we have not built. Empty list, so
                    the notes filter honestly matches nothing.
     lifetime_*     summed from the ledger, which is the only place that knows.
                    A player with no ledger rows shows 0, and that IS their
                    lifetime total — not a placeholder.

   `disabled_methods` maps to cash_blocked because that is the one real block
   the schema carries per player; per-method blocking is skin-level, not
   per-player, so this is the honest approximation and it is one-way (a
   cash-blocked player shows as blocked, a non-blocked one shows nothing). */
const PL_ROLE_BY_LEVEL = {
  0: "SUPERADMIN", 1: "AFFILIATE", 2: "SKIN", 4: "CUSTOMER_CARE", 6: "ADMINISTRATION",
  8: "MASTER", 9: "REGULATOR", 10: "AGENT", 15: "PROMOTER", 20: "SHOP", 30: "PLAYER",
};

const plRowFromUser = (u, ledgerByUser) => {
  const led = ledgerByUser.get(Number(u.id)) || { dep: 0, wd: 0, last: null };
  const w = (u.wallet && (Array.isArray(u.wallet) ? u.wallet[0] : u.wallet)) || {};
  const nm = [u.firstname, u.lastname].filter(Boolean).join(" ").trim();
  return {
    id: u.id,
    name: nm || u.username || `#${u.id}`,
    email: u.email || null,
    phone: u.mobile || null,
    country: null,
    currency: u.currency || (u.skin && u.skin.currency) || null,
    role: PL_ROLE_BY_LEVEL[Number(u.user_level)] || "PLAYER",
    suspended: !!u.blocked,
    disabled_methods: u.cash_blocked ? ["cash"] : [],
    notes_list: [],
    lifetime_deposits: led.dep,
    lifetime_withdrawals: led.wd,
    net_position: Number(w.real_total || 0),
    /* Last activity, and the fallback order matters: a ledger entry is a thing
       the player DID, a login is a thing they attempted, and registration is
       neither. Falling all the way through to created_at is what stops a
       never-active account sorting as if it were live. */
    last_active: led.last || (u.last_login_at ? Date.parse(u.last_login_at) : null)
      || (u.created_at ? Date.parse(u.created_at) : null),
    brand: u.skin_id,
    brand_name: (u.skin && u.skin.name) || null,
    brand_short: (u.skin && u.skin.name) ? String(u.skin.name).slice(0, 2).toUpperCase() : null,
    brand_color: null,
  };
};

/* Deposits and withdrawals per player, from the ledger. Types 1 and 2 are
   DEPOSIT and WITHDRAW (App\Constants\TransactionHistoryType); withdrawals are
   stored negative, so the magnitude is what a "lifetime withdrawals" column
   means. */
const plLedgerTotals = (rows) => {
  const m = new Map();
  for (const r of rows || []) {
    const id = Number(r.user_id);
    const cur = m.get(id) || { dep: 0, wd: 0, last: null };
    const amt = Number(r.amount || 0);
    if (Number(r.type_id) === 1) cur.dep += amt;
    if (Number(r.type_id) === 2) cur.wd += Math.abs(amt);
    const t = r.created_at ? Date.parse(r.created_at) : null;
    if (t && (!cur.last || t > cur.last)) cur.last = t;
    m.set(id, cur);
  }
  return m;
};

/* Role badge TONES on the shared .chip ramp — replaces the former 11-entry
   role-hex map painted inline (design canon §2.9: no hardcoded role-hex maps,
   no inline background/color on chips). Tone is presentation and stays here;
   the LEVELS and LABELS still come from the user_roles feed. */
const PL_ROLE_TONE = {
  SUPERADMIN: "neutral", SKIN: "neutral", AFFILIATE: "info",
  CUSTOMER_CARE: "info", ADMINISTRATION: "neutral", REGULATOR: "neutral",
  MASTER: "purple", AGENT: "warn", PROMOTER: "purple",
  SHOP: "gold", PLAYER: "ok",
};

/* Page-scoped CSS (tokens only — no hex literals, no raw rgba shadows).
   It lives in this file because the shared style sheets are additive-only
   surfaces owned elsewhere; everything here is namespaced under
   .players-list / .plv-* so it cannot leak into another screen.
   Replaces the former inline-style saturation: the JS mouse-enter hover
   mutation (a hard-coded near-white) becomes a CSS :hover rule (canon
   §2.16), the avatar / brand-square / dim-cell inline styles become
   classes. */
const PLV_CSS = `
.players-list .page__title { display: inline-flex; align-items: center; }
.players-list .plv-panel { overflow: hidden; }
.players-list .plv-tablewrap { overflow: auto; }
/* Hover via CSS, not JS style mutation. The extra specificity is needed
   because the iwakiri theme paints .data-table tbody td white at (0,2,3),
   which outranks the shared .hrs-trow--click:hover rule. */
html[data-theme="iwakiri"] .players-list .data-table tbody tr.hrs-trow--click:hover td { background: var(--p-50); }
.players-list tr.plv-suspended { opacity: .6; }
.players-list .plv-id { color: var(--p-700); font-weight: 600; }
.players-list .plv-name { display: inline-flex; align-items: center; gap: 8px; }
.players-list .plv-avatar { width: 26px; height: 26px; border-radius: var(--r-pill); background: linear-gradient(135deg, var(--p-200), var(--p-500)); color: var(--n-0); font-size: 11px; font-weight: 700; display: grid; place-items: center; flex-shrink: 0; }
.players-list .plv-nm { font-weight: 550; }
.players-list .plv-nm--suspended { text-decoration: line-through; }
.players-list .plv-sub { font-size: 11px; color: var(--text-tertiary); }
.players-list .plv-brand { display: inline-flex; align-items: center; gap: 6px; }
.players-list .plv-brandsq { width: 14px; height: 14px; border-radius: 4px; background: var(--n-100); color: var(--text-secondary); font-size: 8px; font-weight: 700; display: grid; place-items: center; }
.players-list .plv-dim { font-size: 11px; color: var(--text-tertiary); }
.players-list .plv-when { color: var(--text-secondary); }
.players-list .plv-strong { font-weight: 600; }
/* Page-scoped WARN tone for HrsRowActions — the shared kit ships danger/
   primary only, and "methods blocked" must not read as destructive red.
   Tokens only, same shape as the shared rules in tokens.css. */
.players-list .hrs-rowact .hrs-rowact--warn { color: var(--warn-700); }
.players-list .hrs-rowact .hrs-rowact--warn:hover { color: var(--warn-700); background: var(--warn-50); }
`;
/* <!-- SUGGESTION: promote a warn tone to the shared .hrs-rowact CSS in
     tokens.css (additive) so the two rules above can be deleted. --> */

const PlayersList = ({ brand, onOpen }) => {
  window.useLocale && window.useLocale();
  const T = window.T || ((k, fb) => fb || k);

  /* THREE reads, one verdict. Separate error panels would each say "you are
     signed out" and none of them would be actionable.

     BOTH SIDES OF THE LEVEL SPLIT, and that is not padding. This screen is
     titled Accounts and offers a Role filter with MASTER / AGENT / SHOP in it;
     the `players` resource is fixed to user_level = 30 and `networkUsers` to
     everything below it, so reading either one alone gives a role filter whose
     other options can never match anything. The mock list had every tier in it,
     which is why the filter was built — reading one resource would have looked
     like wiring and quietly broken the screen's main control. */
  const playersFeed = useHrsFetch(() => window.sb.list("players", { limit: 1000 }), []);
  const opsFeed     = useHrsFetch(() => window.sb.list("networkUsers", { limit: 1000 }), []);
  const ledgerFeed  = useHrsFetch(() => window.sb.list("ledger", { limit: 5000 }), []);

  const players = React.useMemo(() => {
    const totals = plLedgerTotals(ledgerFeed.data || []);
    return [...(playersFeed.data || []), ...(opsFeed.data || [])]
      .map(u => plRowFromUser(u, totals))
      .sort((a, b) => (b.last_active || 0) - (a.last_active || 0));
  }, [playersFeed.data, opsFeed.data, ledgerFeed.data]);

  const BRANDS = React.useMemo(() => {
    const seen = new Map();
    for (const p of players) {
      if (p.brand != null && !seen.has(p.brand)) {
        seen.set(p.brand, { id: p.brand, name: p.brand_name || `Skin ${p.brand}`,
                            short: p.brand_short, color: p.brand_color });
      }
    }
    return [...seen.values()];
  }, [players]);

  const [query, setQuery]    = useState("");
  const [countryF, setCountryF] = useState("all");
  const [roleF, setRoleF]    = useState("all");
  const [stateF, setStateF]  = useState("all"); // active / suspended / has-blocks
  const [brandF, setBrandF]  = useState("all");
  useEffect(() => { if (!brand.isAll) setBrandF("all"); }, [brand?.id, brand?.isAll]);
  const [notesF, setNotesF]  = useState("all"); // any-notes / no-notes
  const [sort, setSort]      = useState({ key: "last_active", dir: "desc" });
  const [tick, setTick]      = useState(0); // bump after mutating a record so the row re-renders

  /* Column visibility — through the shared HrsColsMenu picker (canon §2.16:
     one picker design, persisted per operator). ID, Name and Actions are not
     in this list, so they stay mandatory. Persistence moved from the
     page-local pbStore object to the picker's own hrscols:players.cols key —
     one primitive, one storage convention. */
  const PLAYER_COLUMNS = [
    { key:"role",          label:"Role"         },
    { key:"brand",         label:"Brand"        },
    { key:"country",       label:"Country"      },
    { key:"state",         label:"State"        },
    { key:"notes",         label:"Notes"        },
    { key:"deposits",      label:"Deposits"     },
    { key:"withdrawals",   label:"Withdrawals"  },
    { key:"net",           label:"Net"          },
    { key:"last_active",   label:"Last active"  },
  ];
  const [plCols, setPlCols] = useState(() => hrsColsLoad("players.cols", PLAYER_COLUMNS));
  const isPCol = (k) => plCols.includes(k);

  /* Client-side pager over the filtered list. The real screen is
     server-paginated (PaymentPlayerService, DataTables lengthMenu); the
     prototype reads its whole window up front, so the pager slices in
     memory — but the control is the shared HrsPager, not a bespoke strip. */
  const [page, setPage] = useState(0);
  const [pageSize, setPageSize] = useState(50);

  /* THE ROLE CHIPS COME FROM user_roles, not from a hand-written list.

     They used to come from MOCK.ROLES, whose ids were ADMIN / SKIN_ACCESS /
     CASHIER — names this platform does not have. The real hierarchy is
     Super admin(0) → Skin(2) → Master(8) → Agent(10) → Promoter(15) →
     Shop(20) → Player(30), plus four off-chain roles, and it is seeded in the
     database. So four of the eleven real roles had no chip at all and three of
     the mock's had no accounts: the filter offered options nothing could match
     and hid options that could.

     Tone is presentation and stays here (PL_ROLE_TONE, the shared .chip
     ramp); the LEVELS and LABELS come from the table, so adding a role is a
     seed row rather than an edit in two files. */
  const rolesFeed = useHrsFetch(() => window.sb.list("userRoles", { limit: 50 }), []);
  const ROLES = React.useMemo(() => (rolesFeed.data || []).map(r => {
    const id = PL_ROLE_BY_LEVEL[Number(r.level)] || String(r.code || "").toUpperCase();
    return { id, label: r.label || id, tier: Number(r.level), tone: PL_ROLE_TONE[id] || "neutral" };
  }), [rolesFeed.data]);
  const roleMetaById = Object.fromEntries(ROLES.map(r => [r.id, r]));

  // Distinct countries from the list
  const countries = Array.from(new Set(players.map(p => p.country))).filter(Boolean).sort();

  // Apply filters + brand scope
  const q = query.trim().toLowerCase();
  const noteCount = (p) => Array.isArray(p.notes_list) ? p.notes_list.length : 0;
  const rows = players.filter(p => {
    if (!brand.isAll && p.brand !== brand.id) return false;
    if (brand.isAll && brandF !== "all" && p.brand !== brandF) return false;
    if (countryF !== "all" && p.country !== countryF) return false;
    if (roleF !== "all" && (p.role || "PLAYER") !== roleF) return false;
    if (stateF === "suspended" && !p.suspended) return false;
    if (stateF === "active" && p.suspended) return false;
    if (stateF === "restricted" && (!(p.disabled_methods || []).length)) return false;
    if (notesF === "with" && noteCount(p) === 0) return false;
    if (notesF === "without" && noteCount(p) > 0) return false;
    if (q) {
      const hay = [p.id, p.name, p.email, p.phone, p.country, p.brand_name].join(" ").toLowerCase();
      if (!hay.includes(q)) return false;
    }
    return true;
  });

  // Sorting
  rows.sort((a, b) => {
    const dir = sort.dir === "asc" ? 1 : -1;
    const av = a[sort.key], bv = b[sort.key];
    if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
    return String(av ?? "").localeCompare(String(bv ?? "")) * dir;
  });

  /* Page window. Clamped rather than reset so a shrinking filter result
     lands on the last page that still exists instead of a blank one. */
  const plTotalPages = Math.max(1, Math.ceil(rows.length / pageSize));
  const plPage = Math.min(page, plTotalPages - 1);
  const pageRows = rows.slice(plPage * pageSize, plPage * pageSize + pageSize);

  const toggleSort = (key) =>
    setSort(s => s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: "desc" });

  const timeAgo = (ts) => {
    const diff = Date.now() - ts;
    if (diff < 60_000) return "just now";
    if (diff < 3600_000) return `${Math.floor(diff/60_000)}m ago`;
    if (diff < 86400_000) return `${Math.floor(diff/3600_000)}h ago`;
    if (diff < 7*86400_000) return `${Math.floor(diff/86400_000)}d ago`;
    return new Date(ts).toLocaleDateString("en-GB", { day:"2-digit", month:"short" });
  };

  /* Sortable header — the shared treatment (mirrors HrsTable's hrs-th-sort
     button + sort/arrow icons) instead of the former hand-rolled ▲/▼ span. */
  const SortHead = ({ label, k, align }) => (
    <th className={align === "right" ? "hrs-al-r" : undefined}>
      <button type="button" className="hrs-th-sort" onClick={() => toggleSort(k)}>
        {label}
        <Icon name={sort.key === k ? (sort.dir === "asc" ? "arrow_up" : "arrow_down") : "sort"} size={10}
          style={{ opacity: sort.key === k ? 1 : 0.45 }} />
      </button>
    </th>
  );

  const initials = (name) => (name || "??").split(/\s+/).filter(Boolean).map(s => s[0].toUpperCase()).slice(0,2).join("");

  const curSym = (c) => window.currencySymbol ? window.currencySymbol(c) : (c ? c + " " : "");

  /* THESE TWO USED TO BE FAKE WRITES, and that is a worse failure than a
     missing feature. Each mutated the row object in memory and bumped a
     counter so the table re-rendered — so the toggle moved, the chip changed,
     and the operator had every reason to believe a player had been suspended.
     Nothing left the browser. On reload the player was active again.

     Both go through app_write() now, which is the only write path: it checks
     the caller's capability, applies the row scope, and refuses a row outside
     the caller's subtree. The optimistic flip stays — it is what makes the
     button feel immediate — but it is REVERTED when the write is refused, and
     the reason is shown rather than swallowed. */
  const [rowErr, setRowErr] = useState(null);
  const [rowBusy, setRowBusy] = useState(null);

  const writeRow = async (p, patch, revert) => {
    setRowBusy(p.id); setRowErr(null);
    const r = await window.sb.update("users", p.id, patch);
    setRowBusy(null);
    if (!r || !r.ok) {
      revert();
      setRowErr({ id: p.id, name: p.name,
                  message: (r && r.error && r.error.message) || "The change was refused." });
    }
    setTick(t => t + 1);
  };

  const quickSuspend = (p, e) => {
    e.stopPropagation();
    const was = p.suspended;
    p.suspended = !was;
    setTick(t => t + 1);
    writeRow(p, { blocked: !was }, () => { p.suspended = was; });
  };

  /* PER-METHOD BLOCKING DOES NOT EXIST PER PLAYER in this schema — method
     enablement is per skin (skin_payment_methods), not per account. The one
     real per-player money block is `cash_blocked`, so that is what this button
     sets, and the label below says so instead of implying a method list was
     edited.
     <!-- SUGGESTION: if per-player method blocks are wanted, they need their
          own table; do not overload cash_blocked further. --> */
  const disableAllMethods = (p, e) => {
    e.stopPropagation();
    const had = (p.disabled_methods || []).length > 0;
    p.disabled_methods = had ? [] : ["cash"];
    setTick(t => t + 1);
    writeRow(p, { cash_blocked: !had }, () => { p.disabled_methods = had ? ["cash"] : []; });
  };

  /* Exports the ENTIRE filtered set, not just the visible page. The real
     platform's exports cover only the current DataTables page — documented as
     a bug (see CLAUDE.md known-bug policy) — so the prototype implements the
     evident intent.
     <!-- SUGGESTION: the real export endpoint should stream the filtered set
          server-side instead of dumping the current page. --> */
  const exportCSV = () => {
    if (!window.PAYBO) return;
    const stamp = new Date().toISOString().slice(0,10);
    window.PAYBO.downloadCSV(`paybo-players-${stamp}.csv`, rows, [
      { key:"id",    label:"user_id" },
      { key:"name",  label:"name" },
      { key:"email", label:"email" },
      { key:"phone", label:"phone" },
      { key:"brand_name", label:"brand" },
      { key:"country", label:"country" },
      { key:"lifetime_deposits",    label:"lifetime_deposits" },
      { key:"lifetime_withdrawals", label:"lifetime_withdrawals" },
      { key:"net_position", label:"net_position" },
      { key:"currency", label:"currency" },
      { key:"last_active", label:"last_active_ts",
        get: (r) => new Date(r.last_active).toISOString() },
    ]);
  };

  /* PbFilterBar fields (plan §4.4). Filtering here is CLIENT-SIDE over rows
     already in memory — there is no per-keystroke fetch to defer — so the
     strip keeps its live-apply behavior: this is the documented live-filter
     case in PbFilterBar's contract (onSearch omitted), not a violation of
     the draft→applied canon, which exists to stop live REFETCHES. */
  const plFields = [
    { key:"q", label:"Search", type:"search", icon:"search", defaultValue:"",
      placeholder:"Search by name, email, user ID, phone, country…" },
    { key:"brand", label:"Brand", type:"select", icon:"flag", defaultValue:"all",
      hidden: !brand.isAll,
      tip:<>Only shown when the top-right brand selector is on "All brands". Pick a tenant to narrow the player list. When the top-right selector locks a brand, this filter is hidden because that scope is already in effect (auto-login).</>,
      options:[{ value:"all", label:"All brands" }, ...BRANDS.map(b => ({ value:b.id, label:b.name }))] },
    { key:"role", label:T("lbl.role","Role"), type:"select", icon:"users", defaultValue:"all",
      tip:<>The white-label network role: <strong>Master</strong>, <strong>Promoter</strong>, <strong>Agent</strong>, <strong>Player</strong>. Each role inherits permissions and limits from the level above it. Internal back-office roles (Admin, Ops) are excluded from this list.</>,
      options:[{ value:"all", label:T("lbl.allRoles","All roles") },
               ...ROLES.filter(r => !r.internal).map(r => ({ value:r.id, label:r.label }))] },
    { key:"state", label:T("lbl.state","State"), type:"select", icon:"lock", defaultValue:"all",
      tip:<>
        <strong>Active</strong> — normal account.<br/>
        <strong>Suspended</strong> — every transaction is blocked until reactivated.<br/>
        <strong>Restricted methods</strong> — account is active but one or more payment methods are explicitly disabled for this player.
      </>,
      options:[{ value:"all", label:T("lbl.allStates","All states") },
               { value:"active", label:T("lbl.active","Active") },
               { value:"suspended", label:T("lbl.suspended","Suspended") },
               { value:"restricted", label:T("lbl.restricted","Restricted methods") }] },
    { key:"notes", label:"Notes", type:"select", icon:"message", defaultValue:"all",
      tip:<>Filter by whether the account has any operator-written notes attached. Use "With notes" to find players who've been flagged by the team for context (fraud history, VIP relationship, payout history, etc.).</>,
      options:[{ value:"all", label:"All" },
               { value:"with", label:"With notes" },
               { value:"without", label:"No notes" }] },
    { key:"country", label:T("lbl.country","Country"), type:"select", icon:"globe", defaultValue:"all",
      tip:<>Resolved from the player's KYC address (or IP at registration if KYC isn't completed). Use this to spot risky geos or to assemble a regional report.</>,
      options:[{ value:"all", label:T("lbl.allCountries","All countries") },
               ...countries.map(c => ({ value:c, label:c }))] },
  ];
  const plFilterValues = { q: query, brand: brandF, role: roleF, state: stateF, notes: notesF, country: countryF };
  const plFilterSetters = { q: setQuery, brand: setBrandF, role: setRoleF, state: setStateF, notes: setNotesF, country: setCountryF };
  const plSetFilter = (k, v) => { const set = plFilterSetters[k]; if (set) set(v); };

  /* THE FEEDS DECIDE WHETHER THERE IS A PAGE AT ALL.

     Rendering the grid regardless would show an empty table while the data is
     still in flight, and an empty table again if the read failed — and those
     two look exactly like "this operator has no players", which is a third
     thing entirely. HrsAsync is the contract that keeps them apart, and it is
     the same one every wired screen uses. */
  /* THE PAGE KEEPS ITS HEAD WHILE THE BODY LOADS. Returning a bare skeleton
     made the screen render as 8 characters of nothing — no title, no
     identity — and route smoke caught it: for the moment the feeds are in
     flight, this was a page that could not say which page it was. */
  const plHead = (
    <div className="page__header">
      <div><div className="page__title">{T("page.accounts", "Accounts")}</div></div>
    </div>
  );
  if (playersFeed.loading || opsFeed.loading || ledgerFeed.loading) {
    return (
      <div className="page">
        {plHead}
        <HrsAsync state={{ loading: true }}>{() => null}</HrsAsync>
      </div>
    );
  }
  const feedErr = playersFeed.error || opsFeed.error || ledgerFeed.error;
  if (feedErr) {
    return (
      <div className="page">
        {plHead}
        <HrsAsync state={{ loading: false, error: feedErr,
                           retry: (playersFeed.error && playersFeed.retry)
                                  || (opsFeed.error && opsFeed.retry) || ledgerFeed.retry }}>
          {() => null}
        </HrsAsync>
      </div>
    );
  }

  return (
    <div className="page players-list">
      <style>{PLV_CSS}</style>
      {/* A REFUSED WRITE MUST BE VISIBLE. The optimistic flip is reverted
          either way, so without this the toggle would spring back with no
          explanation — which reads as a UI glitch rather than as "the database
          would not let you do that", and sends the operator to try again. */}
      {rowErr && (
        <HrsNotice tone="err" title={`Not saved — ${rowErr.name}`}
          actions={<button type="button" className="btn btn--secondary btn--sm" onClick={() => setRowErr(null)}>Dismiss</button>}>
          {rowErr.message}
        </HrsNotice>
      )}
      <div className="page__header">
        <div>
          <div className="page__title">
            {T("page.accounts","Accounts")}
            <Tip>
              Every player account across the selected scope. Use search and filters to find a specific player; click a row to drill into the <strong>Player 360</strong> (full profile, transactions, limits, notes, KYC). Hover actions on each row: <strong>Suspend</strong> (block all activity) and <strong>Block all methods</strong> (deposit / withdrawal off without suspending the account).
            </Tip>
          </div>
          <div className="page__subtitle">
            {rows.length.toLocaleString()} / {players.length.toLocaleString()} · {brand.name}
          </div>
        </div>
        <div className="page__actions">
          <HrsColsMenu columns={PLAYER_COLUMNS} visible={plCols} onChange={setPlCols} storageKey="players.cols" />
          <button className="btn btn--secondary btn--sm" onClick={exportCSV}>
            <Icon name="download" size={13}/> {T("btn.exportCSV","Export CSV")}
          </button>
        </div>
      </div>

      <Explainer compact title="What this is, in plain English"
        bullets={[
          <><strong>Active</strong> — normal player account, transacting freely.</>,
          <><strong>Suspended</strong> — every transaction is blocked until reactivated. Operator hover-action: lock icon.</>,
          <><strong>Restricted methods</strong> — account is active but one or more payment methods are explicitly disabled for this player.</>,
          <><strong>Net</strong> = lifetime deposits − lifetime withdrawals. Positive = casino is up on this player; negative = player is net-up (a heavy-winner signal).</>,
        ]}>
          Every player account in the selected scope. Click a row to drill into the Player 360 view (profile, transactions, limits, notes, KYC). The two hover actions on each row are <strong>Suspend</strong> (block all activity) and <strong>Block all methods</strong> (deposit / withdrawal off without suspending the account).
      </Explainer>

      {/* Hero filter strip — the shared PbFilterBar (same shape as
          Transactions / Activity), with the active-filter pill row and
          Clear all handled by the primitive. */}
      <PbFilterBar
        fields={plFields}
        values={plFilterValues}
        onChange={plSetFilter}
        resultLabel={rows.length.toLocaleString()}
        resultSub="accounts"
      />

      {rows.length === 0 ? (
        /* Filter-miss empty state — shared block, never a bare <td>, and it
           can only render once the feeds have settled (the loading/error
           gates above return first), so it never claims "no players"
           mid-fetch. */
        <HrsEmpty>No players match these filters</HrsEmpty>
      ) : (
        <div className="panel plv-panel">
          <div className="plv-tablewrap">
            <table className="data-table">
              <thead>
                <tr>
                  <SortHead label="User ID" k="id"/>
                  <SortHead label="Name"    k="name"/>
                  {isPCol("role")        && <th>Role</th>}
                  {isPCol("brand")       && <th>Brand</th>}
                  {isPCol("country")     && <SortHead label="Country" k="country"/>}
                  {isPCol("state")       && <th>State</th>}
                  {isPCol("notes")       && <th>Notes</th>}
                  {isPCol("deposits")    && <SortHead label="Deposits"    k="lifetime_deposits"    align="right"/>}
                  {isPCol("withdrawals") && <SortHead label="Withdrawals" k="lifetime_withdrawals" align="right"/>}
                  {isPCol("net")         && <SortHead label="Net"         k="net_position"         align="right"/>}
                  {isPCol("last_active") && <SortHead label="Last active" k="last_active"/>}
                  <th style={{width:96}}>Actions</th>
                </tr>
              </thead>
              <tbody>
                {pageRows.map(p => {
                  /* The last fallback is a LITERAL, not another lookup. ROLES is
                     built from the user_roles feed, so when that feed is empty —
                     still loading, unreachable, or returning nothing under RLS —
                     roleMetaById is {} and `roleMetaById.PLAYER` is undefined too.
                     The badge below then read the role meta on undefined and took
                     the whole screen down: a lookup table that has not arrived is
                     not a reason for the player list to stop existing.

                     The label is honest about which case it is, rather than
                     claiming "Player" for a role nobody has confirmed. */
                  const role = roleMetaById[p.role || "PLAYER"] || roleMetaById.PLAYER
                    || { id: "PLAYER", label: rolesFeed.data ? "Player" : "—",
                         tier: 30, tone: PL_ROLE_TONE.PLAYER };
                  const blockCount = (p.disabled_methods || []).length;
                  const noteN = noteCount(p);
                  return (
                    <tr key={`${p.brand}:${p.id}`}
                      className={`hrs-trow hrs-trow--click${p.suspended ? " plv-suspended" : ""}`}
                      onClick={() => onOpen && onOpen(p)}>
                      <td className="mono plv-id">{p.id}</td>
                      <td>
                        <span className="plv-name">
                          <span className="plv-avatar">{initials(p.name)}</span>
                          <span>
                            <div className={`plv-nm${p.suspended ? " plv-nm--suspended" : ""}`}>{p.name}</div>
                            <div className="plv-sub">{p.email}</div>
                          </span>
                        </span>
                      </td>
                      {isPCol("role") && (
                        <td>
                          <span className={`chip chip--${role.tone || "neutral"}`}>
                            <span className="dot"/>
                            {role.label}
                          </span>
                        </td>
                      )}
                      {isPCol("brand") && (
                        <td>
                          <span className="plv-brand">
                            <span className="plv-brandsq" style={p.brand_color ? { background: p.brand_color } : undefined}>{p.brand_short}</span>
                            {p.brand_name}
                          </span>
                        </td>
                      )}
                      {isPCol("country") && <td>{p.country}</td>}
                      {isPCol("state") && (
                        <td>
                          {p.suspended ? (
                            <span className="chip chip--err">Suspended</span>
                          ) : blockCount > 0 ? (
                            <span className="chip chip--warn" title={`${blockCount} method(s) blocked`}>{blockCount} blocked</span>
                          ) : (
                            <span className="chip chip--ok">Active</span>
                          )}
                        </td>
                      )}
                      {isPCol("notes") && (
                        <td>
                          {noteN > 0 ? (
                            <span className="chip chip--info" title={`${noteN} note${noteN===1?"":"s"} on file`}>
                              <Icon name="message" size={10}/> {noteN}
                            </span>
                          ) : (
                            <span className="plv-dim">—</span>
                          )}
                        </td>
                      )}
                      {isPCol("deposits")    && <td className="hrs-al-r plv-strong">{curSym(p.currency)}{Math.round(p.lifetime_deposits).toLocaleString()}</td>}
                      {isPCol("withdrawals") && <td className="hrs-al-r">{curSym(p.currency)}{Math.round(p.lifetime_withdrawals).toLocaleString()}</td>}
                      {isPCol("net") && (
                        /* hrs-pos / hrs-neg per canon §2.5; exact 0 renders
                           uncolored (zero-coloring rule). */
                        <td className={`hrs-al-r ${p.net_position > 0 ? "hrs-pos" : p.net_position < 0 ? "hrs-neg" : "plv-strong"}`}>
                          {curSym(p.currency)}{Math.round(p.net_position).toLocaleString()}
                        </td>
                      )}
                      {isPCol("last_active") && <td className="plv-when">{timeAgo(p.last_active)}</td>}
                      <td>
                        <HrsRowActions actions={[
                          { icon:"lock",
                            title: p.suspended ? "Reactivate account" : "Suspend account",
                            tone: p.suspended ? "danger" : undefined,
                            onClick: (e) => quickSuspend(p, e) },
                          { icon:"x",
                            title: blockCount ? "Re-enable all methods" : "Disable all methods",
                            tone: blockCount ? "warn" : undefined,
                            onClick: (e) => disableAllMethods(p, e) },
                          { icon:"chevron_right",
                            title:"Open account",
                            onClick: () => onOpen && onOpen(p) },
                        ]}/>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>

          <HrsPager page={plPage} pageSize={pageSize} total={rows.length}
            onPage={setPage} onPageSize={(n) => { setPageSize(n); setPage(0); }} />
        </div>
      )}
    </div>
  );
};
