// Represents: GET /withdrawrequests/ · WithdrawRequestsController — see docs/ISYSTEM_REFERENCE.md §Batch 1 "Withdraws"
/* Withdraws — withdrawal-request queue (header nav entry between Deposits and Messages).
   Replaces the HostWithdrawals component bundled in HostFinanceMsg.jsx (this file
   loads after it, so this definition wins).

   Traceability:
   - Routes: GET /withdrawrequests/ (index) · GET /withdrawrequests/getRequests
     (DataTables JSON + Excel) · POST /withdrawals/approve/{id} · POST
     /withdrawals/decline/{id} (routes/admin.php L711-721).
   - Columns, filters, statuses, approve/decline flows: WithdrawRequestsController
     (index L32, getWithdrawRequests L546, approveWithdraw L199, declineWithdraw
     L372, heldForReview L166, requestsStatus L506, getRequestStatusLabel L867).
   - Labels: the runtime lang path (storage/lang) is gitignored, so every label
     here is INFERRED from its backend.* key (request_status_*, approve_request,
     reject_request, user_search_type_*) per the repo Label policy.

   Known-bug policy divergences (evident intent implemented, real bug documented):
   1. Method filter — real server matches column "method" but the column is named
      payment_method, so the live filter NEVER applies. Implemented working.
   2. Excel export — real re-runs the query AFTER offset/limit, exporting only the
      current page (max 100 rows). Implemented: exports every filtered row (CSV in
      this prototype; real writes WithdrawRequests-<ts>-<admin_id>.xlsx).
   3. Player self-cancel (web.php cancelRequest) writes REJECTED(2) +
      "Cancelled by player", indistinguishable from admin rejections. Mock rows
      for player cancellations carry CANCELED(4) per the evident intent.
   4. Status badges — real getRequestStatusLabel only handles 0/1/2, so ERROR(3)
      and CANCELED(4) render as "Unknown" badge-secondary in the table. Here all
      five statuses get their proper badge.
   5. Status filter — real requestsStatus() also offers a phantom "5 = UNDEFINED"
      option (no such DB value). Omitted; only the 5 real statuses are offered.
   6. Sorting — real $columnMap whitelists id/username/amount/addedTime/method/
      request_status/decline_reason, but "method" is dead (same column-name
      mismatch as the filter) and clicking Parent silently sorts by id. Here
      Method sorts as intended and Parent is honestly non-sortable.
   // <!-- SUGGESTION: fix getWithdrawRequests to filter/sort on payment_method,
   //      run the Excel export before offset/limit, write CANCELED(4) in
   //      cancelRequest, extend getRequestStatusLabel to statuses 3/4, and drop
   //      the phantom UNDEFINED(5) filter option. -->

   No invented actions: no bulk actions, no create/edit, no setpay ("mark paid" is
   orphaned dead code on the real platform), no legacy modal approve/decline
   (routes commented out). Actions render only for request_status=0 rows whose
   method is not "online" — exactly like the real row builder (L744). */

const { useState: hwdUseState, useMemo: hwdUseMemo, useEffect: hwdUseEffect } = React;

const hwdToast = (m) => window.PAYBO?.emitToast && window.PAYBO.emitToast({
  id: `hwd-${Date.now()}`, tx_id: m, amount: 0, currency: "HOST", player: "Host",
  reason: "Prototype state only \u2014 not persisted.",
});

/* deterministic PRNG so the queue renders identically each load */
const hwdRand = (seed) => { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; };

const hwdPad = (n) => String(n).padStart(2, "0");
/* addedTime / updateTime render as d/m/Y H:i on the real screen */
const hwdFmtTs = (ts) => { const d = new Date(ts); return `${hwdPad(d.getDate())}/${hwdPad(d.getMonth() + 1)}/${d.getFullYear()} ${hwdPad(d.getHours())}:${hwdPad(d.getMinutes())}`; };
/* Amount → number_format(…, 2) + users.currency */
const hwdMoney = (n, cur) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " " + (cur || "ARS");
/* Method cell → ucfirst(str_replace('-', ' ', method)) */
const hwdMethodLabel = (code) => (code.charAt(0).toUpperCase() + code.slice(1)).replace(/-/g, " ");

/* WithdrawRequestStatus (app/Constants/WithdrawRequestStatus.php) — the real
   5-status enum. label keys per requestsStatus()/getRequestStatusLabel();
   row classes mirror getRichiestaPrelievoClass (pending-w / approved-w /
   declined-w / error-w / annulled-w). Labels inferred (see header). */
/* Labels come from withdrawal_request_statuses. Only the chip class stays here
   — a colour is a presentation choice the table has no opinion about. The
   status VALUES (0..4) are persisted and shared with reports, so they are keys,
   not an ordering to renumber. */
const HWD_STATUS_LABEL_FALLBACK = { 0: "Pending", 1: "Approved", 2: "Rejected", 3: "Error", 4: "Canceled" };
// EMBED-OK: `r` is a mapped row — status is the numeric status_id the mapper copied out, not the embedded status object.
const hwdStatusName = (r) => r.statusLabel || HWD_STATUS_LABEL_FALLBACK[r.status] || `Status ${r.status}`;

/* Filter options — real list comes from WithdrawMethodsController::
   getWithdrawMethods('', skin_id, 0): all withdraw_methods joined to
   skin_withdraw_methods for the operator's skin, keyed by method_code
   (method codes mocked; "wire-argentina" and "online" are the two codes the
   reference documents explicitly). */
/* Options come from payment_methods, which is what
   WithdrawMethodsController::getWithdrawMethods reads for the operator's skin. */

/* -------------------------------- live rows --------------------------------
   This block used to build 57 withdrawal requests from a linear-congruential
   PRNG: fourteen player names, three parent shops, ten Spanish full names for
   the "Destinatario" field, 22-digit CVUs, four Spanish decline reasons, and a
   hand-picked row marked as held for review. Every one of those was a claim
   about somebody's payout. They are `withdrawal_requests` rows now.
   ------------------------------------------------------------------------- */

/* withdrawal_requests -> the row shape this screen renders. isystem spread the
   payout destination across iban/swift/crypto_wallet/crypto_coin/pix_key/cpf/
   cvu/phone_number/fullname columns on EVERY row; here it is one jsonb, so a
   method that does not collect a CVU simply has none. */
const hwdRow = (r) => {
  const d = r.payout_details || {};
  return {
    id: Number(r.id),
    parent: r.user && r.user.parent ? r.user.parent.username : null,
    user: r.user ? r.user.username : "",
    userId: r.user_id == null ? null : Number(r.user_id),
    skin: r.skin ? r.skin.name : "",
    skinId: r.skin_id == null ? null : Number(r.skin_id),
    ts: r.created_at ? Date.parse(r.created_at) : null,
    amount: Number(r.amount) || 0,
    currency: r.currency || "",
    method: r.method ? r.method.code : "",
    methodName: r.method ? r.method.name : "",
    status: Number(r.status_id),
    statusLabel: r.status ? r.status.label : null,
    updateTs: r.decided_at ? Date.parse(r.decided_at) : null,
    doneBy: r.decidedBy ? r.decidedBy.username : null,
    paidAt: r.paid_at ? Date.parse(r.paid_at) : null,
    paidBy: r.paidBy ? r.paidBy.username : null,
    fullname: r.recipient_name || d.fullname || null,
    cvu: d.cvu || d.iban || d.pix_key || d.crypto_wallet || null,
    /* answer_description. isystem writes REJECTED(2) when a PLAYER cancels
       their own request, so its "Canceled" status is unreachable from that
       path; the schema has both and the transition table decides which is
       legal, so no divergence note is needed on the row itself. */
    answer: r.decision_note || null,
    debitLedgerId: r.debit_ledger_entry_id == null ? null : Number(r.debit_ledger_entry_id),
    refundLedgerId: r.refund_ledger_entry_id == null ? null : Number(r.refund_ledger_entry_id),
    /* Filled from payment_cascade_holds below — a real foreign key, not a
       64-character token compared as a string. */
    held: false,
    /* Latest approved withdrawal for the same player, scoped to the rows this
       screen fetched. */
    lastTx: null,
  };
};

/* Sortable-column whitelist mirrors $columnMap (L581-590): id, username, amount,
   addedTime, request_status, decline_reason(answer_description) — plus method per
   evident intent (dead on the real platform). Parent is honestly non-sortable
   (real header click silently falls back to id). balance_withdrawable is in the
   real whitelist but its column is commented out of the table — omitted. */
const HWD_COLS = [
  { label: "ID", sort: "id" },
  { label: "Parent", sort: null },
  { label: "Username", sort: "user" },
  { label: "Date", sort: "ts" },
  { label: "Amount", sort: "amount" },
  { label: "Method", sort: "method" },
  { label: "Request status", sort: "status" },
  { label: "Payment details", sort: null },
  { label: "Last transaction", sort: null },
  { label: "Decline reason", sort: "answer" },
  { label: "Actions", sort: null },
];
const hwdSortVal = (r, key) =>
  key === "user" ? r.user.toLowerCase()
  : key === "method" ? r.method
  : key === "answer" ? (r.answer || "")
  : r[key];

// EMBED-OK: mapped row — status is the numeric id, used as a CSS modifier.
const HwdChip = ({ r }) => (
  <span className={`hwd-chip hwd-chip--${r.status}`}>{hwdStatusName(r)}</span>
);

const HwdStatusCell = ({ r }) => (
  <div className="hwd-status">
    <HwdChip r={r} />
    {r.updateTs && (
      <div className="hwd-status-sub">
        {hwdFmtTs(r.updateTs)}
        {r.doneBy && <><br /><b>{r.doneBy}</b></>}
      </div>
    )}
  </div>
);

const HwdPdRow = ({ label, value }) => {
  const copy = () => {
    const done = () => hwdToast("Copied to clipboard");
    if (navigator.clipboard?.writeText) navigator.clipboard.writeText(value).then(done).catch(done);
    else done();
  };
  return (
    <div className="hwd-pd-row">
      <span className="hwd-pd-label">{label}</span>
      <span className="hwd-pd-val">{value}</span>
      <button className="hwd-pd-copy" title="Copy" onClick={copy}><Icon name="copy" size={12} /></button>
    </div>
  );
};

/* Payment details — real screen renders these copy rows only for
   method == "wire-argentina" ("Destinatario" label is hardcoded Italian in the
   controller, L706 — kept verbatim); every other method shows "-". */
const HwdPayDetails = ({ r }) => r.method !== "wire-argentina"
  ? <span className="hwd-dim">—</span>
  : (
    <div className="hwd-pd">
      <HwdPdRow label="Destinatario" value={r.fullname} />
      <HwdPdRow label="CVU" value={r.cvu} />
    </div>
  );

/* Row actions — rendered only when request_status == 0 AND method !== 'online'
   (real row builder L744); otherwise the cell shows "-". Reject = red ✕
   (backend.reject_request), Approve = green ✓ (backend.approve_request) —
   labels inferred. */
const HwdActs = ({ r, onApprove, onDecline, block }) => {
  if (r.status !== 0) return <span className="hwd-dim">—</span>;
  if (r.method === "online") return <span className="hwd-dim" title="Gateway-routed withdrawal — handled in Payments → To-Confirm">—</span>;
  return (
    <div className={`hwd-acts${block ? " hwd-acts--block" : ""}`}>
      <button className="hwd-act hwd-act--reject" title="Reject request" onClick={() => onDecline(r)}>
        <Icon name="x" size={14} />{block && <span>Reject</span>}
      </button>
      <button className="hwd-act hwd-act--approve" title="Approve request" onClick={() => onApprove(r)}>
        <Icon name="check" size={14} />{block && <span>Approve</span>}
      </button>
    </div>
  );
};

/* Small clearable active-filter pill (red under the Iwakiri theme). */
const HwdPill = ({ label, onClear }) => (
  <span className="hwd-pill">
    {label}
    <button onClick={onClear} title="Remove"><Icon name="x" size={9} /></button>
  </span>
);

/* One filter field, rendered either as a hero card (desktop strip) or as a
   stacked row inside the mobile Filters sheet. */
const HwdField = ({ icon, label, tip, variant, children }) => variant === "sheet"
  ? (
    <div className="hwd-sh-field">
      <label><Icon name={icon} size={11} /> {label}{tip && <Tip>{tip}</Tip>}</label>
      {children}
    </div>
  )
  : (
    <div className="hwd-fcard">
      <div className="hwd-flab"><Icon name={icon} size={11} /> {label}{tip && <Tip>{tip}</Tip>}</div>
      {children}
    </div>
  );

/* The 7 real filters (index.blade.php L26-117; server handling L602-662). */
const HwdFilterControls = ({ f, setF, variant, methods, statuses, users }) => {
  const inCls = variant === "sheet" ? "input" : "hwd-fin";
  const selCls = variant === "sheet" ? "select" : "hwd-fsel";
  const set = (k) => (e) => setF(prev => ({ ...prev, [k]: e.target.value }));
  return (
    <>
      <HwdField variant={variant} icon="credit_card" label="Method"
        tip={<>Options come from the withdraw methods enabled for this skin (<code>withdraw_methods</code> ⋈ <code>skin_withdraw_methods</code>), keyed by <code>method_code</code>. On the live platform this filter is <strong>dead</strong> — the server matches a column named <code>method</code> while the table column is <code>payment_method</code> — so it never applies; here it filters as evidently intended.</>}>
        {/* Real bug: server-side match on "method" vs column payment_method → filter never applies. Implemented working (see header divergence #1). */}
        <select className={selCls} value={f.method} onChange={set("method")}>
          <option value="">All methods</option>
          {methods.map(([code, name]) => <option key={code} value={code}>{name || hwdMethodLabel(code)}</option>)}
        </select>
      </HwdField>
      <HwdField variant={variant} icon="flag" label="Request status"
        tip={<><code>WithdrawRequestStatus</code>: PENDING=0, APPROVED=1, REJECTED=2, ERROR=3, CANCELED=4. The live filter also offers a phantom "5 = UNDEFINED" option (no such DB value) — omitted here; and the live table renders statuses 3/4 as an "Unknown" badge.</>}>
        {/* Phantom 5=UNDEFINED omitted per evident intent (header divergence #5). */}
        <select className={selCls} value={f.status} onChange={set("status")}>
          <option value="">All statuses</option>
          {statuses.map(([v, label]) => <option key={v} value={String(v)}>{label}</option>)}
        </select>
      </HwdField>
      <HwdField variant={variant} icon="calendar" label="Date"
        tip={<>Range on <code>addedTime</code>; either side may stay open. The live datepickers are hardcoded to Italian locale (dd/mm/yyyy).</>}>
        <div className="hwd-frange">
          <input type="date" className={inCls} value={f.dateFrom} onChange={set("dateFrom")} />
          <span className="hwd-dash">—</span>
          <input type="date" className={inCls} value={f.dateTo} onChange={set("dateTo")} />
        </div>
      </HwdField>
      <HwdField variant={variant} icon="arrow_up" label="Amount"
        tip={<>Between / ≥ / ≤ on <code>withdrawal_requests.amount</code>; leave a side blank for an open range.</>}>
        <div className="hwd-frange">
          <input className={inCls} placeholder="From" inputMode="numeric" value={f.amtFrom} onChange={set("amtFrom")} />
          <span className="hwd-dash">—</span>
          <input className={inCls} placeholder="To" inputMode="numeric" value={f.amtTo} onChange={set("amtTo")} />
        </div>
      </HwdField>
      <HwdField variant={variant} icon="user" label="User"
        tip={<>Remote user search (select2 on the live screen); the server resolves the picked user's <code>user_path</code> and applies it with the Search type. The list is always pre-scoped to your own network path.</>}>
        <input className={inCls} list="hwd-user-list" placeholder="Search user…" value={f.user} onChange={set("user")} />
        <datalist id="hwd-user-list">
          {users.map(u => <option key={u} value={u} />)}
        </datalist>
      </HwdField>
      <HwdField variant={variant} icon="users" label="Search type"
        tip={<>How the picked user's <code>user_path</code> is applied — <code>user_search_type_all</code>: that user or anyone below them; <code>…_subaccounts</code>: descendants only; <code>…_single</code>: exactly that account (also the default when a user is chosen). Labels inferred.</>}>
        <select className={selCls} value={f.searchType} onChange={set("searchType")}>
          <option value="">Single user (default)</option>
          <option value="all">User + sub-accounts</option>
          <option value="sub">Sub-accounts only</option>
        </select>
      </HwdField>
      <HwdField variant={variant} icon="search" label="Payment details"
        tip={<>Matches the <strong>CVU field only</strong> (server does <code>withdrawal_requests.cvu LIKE %…%</code>) — recipient names are not searched.</>}>
        <input className={inCls} placeholder="CVU contains…" value={f.payDetails} onChange={set("payDetails")} />
      </HwdField>
    </>
  );
};

/* key:value line inside the mobile stacked card */
const HwdKV = ({ k, children }) => (
  <div className="hwd-kv"><span className="hwd-kv-k">{k}</span><span className="hwd-kv-v">{children}</span></div>
);

/* Mobile stacked card — player, amount, status and requested-at scan first;
   everything else behind the expand tap (brief §11). */
const HwdCard = ({ r, onApprove, onDecline }) => {
  const [open, setOpen] = hwdUseState(false);
  return (
    <div className="hwd-mcard">
      <button className="hwd-mcard-top" onClick={() => setOpen(o => !o)}>
        <div className="hwd-mcard-main">
          <span className="hwd-mcard-user">{r.user}</span>
          <span className="hwd-mcard-date">{hwdFmtTs(r.ts)}</span>
        </div>
        <div className="hwd-mcard-side">
          <span className="hwd-mcard-amt">{hwdMoney(r.amount, r.currency)}</span>
          <HwdChip r={r} />
        </div>
        <Icon name={open ? "chevron_down" : "chevron_right"} size={14} className="hwd-mcard-chev" />
      </button>
      {open && (
        <div className="hwd-mcard-body">
          <HwdKV k="ID">{r.id}</HwdKV>
          <HwdKV k="Parent">{r.parent || "—"}</HwdKV>
          <HwdKV k="Method">{hwdMethodLabel(r.method)}</HwdKV>
          {r.updateTs && <HwdKV k="Processed">{hwdFmtTs(r.updateTs)}{r.doneBy ? <> · <b>{r.doneBy}</b></> : null}</HwdKV>}
          {r.method === "wire-argentina" && (
            <div className="hwd-mcard-pd"><HwdPayDetails r={r} /></div>
          )}
          <HwdKV k="Last transaction">{r.lastTx ? <>{hwdMoney(r.lastTx.amount, r.currency)} · {hwdFmtTs(r.lastTx.ts)}</> : "—"}</HwdKV>
          <HwdKV k="Decline reason">{r.status === 2 && r.answer ? <span className="hwd-decline">{r.answer}</span> : "—"}</HwdKV>
          <div className="hwd-mcard-acts">
            <HwdActs r={r} onApprove={onApprove} onDecline={onDecline} block />
          </div>
        </div>
      )}
    </div>
  );
};

/* Approve / Decline / held-for-review dialog — full-screen on mobile (brief §11).
   Approve: SweetAlert confirm on the live screen → POST /withdrawals/approve/{id}.
   Decline: SweetAlert reason prompt (client-side required via inputValidator;
   the server default 'No reason provided' makes it effectively optional
   server-side — kept required here, like the real client). */
const HwdActionModal = ({ action, busy, onClose, onConfirm }) => {
  const [reason, setReason] = hwdUseState("");
  const [tried, setTried] = hwdUseState(false);
  if (!action) return null;
  const { type, row } = action;
  const held = type === "held";
  const approve = type === "approve";
  const submit = () => {
    if (type === "decline" && !reason.trim()) { setTried(true); return; }
    onConfirm(action, reason.trim());
  };
  return (
    <div className="bp-modal-scrim" onClick={onClose}>
      <div className="hwd-modal" onClick={(e) => e.stopPropagation()}>
        <div className="hwd-modal-head">
          <div className={`hwd-modal-ic ${held ? "hwd-modal-ic--held" : approve ? "hwd-modal-ic--ok" : "hwd-modal-ic--err"}`}>
            <Icon name={held ? "lock" : approve ? "check" : "x"} size={16} />
          </div>
          <div className="hwd-modal-title">
            {held ? "Held for review" : approve ? "Approve request" : "Reject request"}
            <span className="hwd-modal-sub">#{row.id} · {row.user} · {hwdMoney(row.amount, row.currency)}</span>
          </div>
          <button className="hwd-modal-x" onClick={onClose} title="Close"><Icon name="x" size={13} /></button>
        </div>
        <div className="hwd-modal-body">
          {held ? (
            /* Exact heldForReview guard message (L166-197) — request token matches
               an open payment cascade hold. */
            <p>This withdrawal is awaiting review in Payments → To-Confirm. Approve or reject it there.</p>
          ) : approve ? (
            <>
              <p>Pay out <b>{hwdMoney(row.amount, row.currency)}</b> to <b>{row.user}</b> via <b>{hwdMethodLabel(row.method)}</b>?</p>
              <p className="hwd-modal-note">The amount is temporarily re-credited, then transferred out (description “Withdraw request {row.id}”). If the transfer fails, the credit is reversed and the request stays Pending.</p>
            </>
          ) : (
            <>
              <p>Rejecting refunds <b>{hwdMoney(row.amount, row.currency)}</b> to <b>{row.user}</b>, then marks the request Rejected with your reason.</p>
              <textarea
                className={`input hwd-modal-reason${tried && !reason.trim() ? " hwd-modal-reason--err" : ""}`}
                rows={3} placeholder="Reason (shown to the player)" autoFocus
                value={reason} onChange={(e) => setReason(e.target.value)} />
              {tried && !reason.trim() && <div className="hwd-modal-err">A reason is required.</div>}
            </>
          )}
        </div>
        <div className="hwd-modal-foot">
          <button className="rpt-btn hwd-modal-btn hwd-modal-btn--ghost" onClick={onClose}>{held ? "Close" : "Cancel"}</button>
          {!held && (
            <button className={`rpt-btn hwd-modal-btn ${approve ? "rpt-btn--green" : "rpt-btn--danger"}`}
              disabled={busy} onClick={submit}>
              {approve ? "Approve request" : "Reject request"}
            </button>
          )}
        </div>
      </div>
    </div>
  );
};

const HostWithdrawals = () => {
  window.useLocale && window.useLocale();
  const feed = useHrsFetch(() => window.sb.list("withdrawalRequests", { limit: 1000 }), []);
  const methodFeed = useHrsFetch(() => window.sb.list("paymentMethods", { limit: 200 }), []);
  const statusFeed = useHrsFetch(() => window.sb.list("withdrawalStatuses", { limit: 50 }), []);
  /* Only OPEN holds, and only withdrawal ones — a confirmed or expired hold no
     longer blocks a decision. */
  const holdFeed = useHrsFetch(() => window.sb.list("cascadeHolds", { limit: 500, filters: { kind: "withdrawal" } }), []);
  const save = useHrsSave([feed, holdFeed]);

  const rows = hwdUseMemo(() => {
    const heldIds = new Set((holdFeed.data || [])
      .map(h => h.withdrawal_request_id).filter(x => x != null).map(Number));
    const mapped = (feed.data || []).map(r => {
      const row = hwdRow(r);
      row.held = heldIds.has(row.id);
      return row;
    });
    /* "Last transaction" is isystem's per-row lookup: the latest APPROVED
       withdrawal for the same player. Derived from the rows fetched here. */
    const last = {};
    mapped.forEach(r => { if (r.status === 1 && (!last[r.userId] || r.ts > last[r.userId].ts)) last[r.userId] = r; });
    mapped.forEach(r => { const l = last[r.userId]; if (l && l.id !== r.id) r.lastTx = { amount: l.amount, ts: l.ts }; });
    return mapped;
  }, [feed.data, holdFeed.data]);

  const hwdMethods = hwdUseMemo(() => (methodFeed.data || []).map(m => [m.code, m.name]), [methodFeed.data]);
  const hwdStatuses = hwdUseMemo(() => (statusFeed.data || []).map(x => [Number(x.id), x.label]), [statusFeed.data]);
  const hwdUsers = hwdUseMemo(
    () => Array.from(new Set(rows.flatMap(r => [r.user, r.parent]).filter(Boolean))).sort(),
    [rows]);
  const [f, setF] = hwdUseState({ method: "", status: "", dateFrom: "", dateTo: "", amtFrom: "", amtTo: "", user: "", searchType: "", payDetails: "" });
  const [sort, setSort] = hwdUseState({ key: "id", dir: "desc" }); // real default order: id DESC
  const [page, setPage] = hwdUseState(0);
  const [pageSize, setPageSize] = hwdUseState(100); // ajax.js pageLength: 100
  const [sheet, setSheet] = hwdUseState(false);
  const [action, setAction] = hwdUseState(null); // {type:'approve'|'decline'|'held', row}

  hwdUseEffect(() => { setPage(0); }, [f, sort, pageSize]);

  /* Base scope always applied on the real screen: users.user_path LIKE
     "{operator_path}/%" — every mock row is already inside the network. */
  const filtered = hwdUseMemo(() => rows.filter(r => {
    if (f.method && r.method !== f.method) return false;
    if (f.status !== "" && String(r.status) !== f.status) return false; // exact match, "0" filterable (L613-615)
    const lo = parseFloat(f.amtFrom); const hi = parseFloat(f.amtTo);
    if (!isNaN(lo) && r.amount < lo) return false;
    if (!isNaN(hi) && r.amount > hi) return false;
    if (f.dateFrom && r.ts < new Date(f.dateFrom + "T00:00:00").getTime()) return false;
    if (f.dateTo && r.ts > new Date(f.dateTo + "T23:59:59").getTime()) return false;
    if (f.user.trim()) {
      const u = f.user.trim().toLowerCase();
      const isUser = r.user.toLowerCase() === u;
      const isSub = (r.parent || "").toLowerCase() === u;
      if (f.searchType === "all") { if (!isUser && !isSub) return false; }
      else if (f.searchType === "sub") { if (!isSub) return false; }
      else { if (!isUser && !r.user.toLowerCase().includes(u)) return false; } // single = default branch
    }
    if (f.payDetails.trim()) {
      // real server: withdrawal_requests.cvu LIKE %…% only (L637-641)
      if (!r.cvu || !r.cvu.includes(f.payDetails.trim())) return false;
    }
    return true;
  }), [rows, f]);

  const sorted = hwdUseMemo(() => {
    const list = [...filtered];
    const { key, dir } = sort;
    list.sort((a, b) => {
      const va = hwdSortVal(a, key); const vb = hwdSortVal(b, key);
      const c = va < vb ? -1 : va > vb ? 1 : 0;
      return dir === "asc" ? c : -c;
    });
    return list;
  }, [filtered, sort]);

  const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
  const paged = sorted.slice(page * pageSize, page * pageSize + pageSize);
  const pendingCount = rows.filter(r => r.status === 0).length; // sidebar badge = countPendingWithdrawRequests (request_status=0 within the network)

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

  /* heldForReview guard applies to BOTH approve and decline (L199/L372). */
  const askApprove = (r) => setAction({ type: r.held ? "held" : "approve", row: r });
  const askDecline = (r) => setAction({ type: r.held ? "held" : "decline", row: r });

  const confirmAction = async (act, reason) => {
    setAction(null);
    /* Both branches move money and the schema will not record either without
       the ledger entry that settled it: approving requires
       debit_ledger_entry_id, refusing requires refund_ledger_entry_id AND a
       debit to refund. Keys are derived from the request
       (`withdrawal:<id>:approve` / `:refund`), so a repeated click posts the
       same key and post_transaction() returns the first entry. */
    if (act.type === "approve") {
      await save.run(() => window.sb.approveWithdrawal({
        requestId: act.row.id,
        userId: act.row.userId,
        amount: act.row.amount,
        note: `Withdraw request ${act.row.id}`,
      }), {
        done: `Request #${act.row.id} approved — ${hwdMoney(act.row.amount, act.row.currency)} debited`,
        fail: `Request #${act.row.id} was NOT approved`,
      });
    } else if (act.type === "decline") {
      /* Whether the refusal refunds depends on whether the debit was ever
         taken. Deciding that from the row rather than assuming it is what
         keeps the refund from being posted against a withdrawal that never
         left the balance — the schema refuses that too, but reaching for it
         would mean the screen had already lost track of the money. */
      await save.run(() => window.sb.refuseWithdrawal({
        requestId: act.row.id,
        userId: act.row.userId,
        amount: act.row.amount,
        note: reason,
        hasDebit: !!act.row.debitLedgerId,
      }), {
        done: act.row.debitLedgerId
          ? `Request #${act.row.id} rejected — ${hwdMoney(act.row.amount, act.row.currency)} refunded`
          : `Request #${act.row.id} rejected — nothing had been debited, so nothing was refunded`,
        fail: `Request #${act.row.id} was NOT rejected`,
      });
    }
  };

  /* Export — real: same GET /withdrawrequests/getRequests with export_excel=1 →
     WithdrawRequests-<timestamp>-<admin_id>.xlsx (PhpSpreadsheet), but only the
     CURRENT page because offset/limit ran before the export re-query (L666 vs
     L763). Evident intent: every filtered row (header divergence #2). Prototype
     exports CSV with the real Excel column set (L783-865). */
  const exportRows = () => {
    if (!window.PAYBO?.downloadCSV) return;
    window.PAYBO.downloadCSV(`WithdrawRequests-${Date.now()}-1.csv`, sorted, [
      { key: "id", label: "ID" },
      { key: "user", label: "Username" },
      { key: "parent", label: "Parent", get: (r) => r.parent || "-" },
      { key: "amount", label: "Amount", get: (r) => r.amount.toFixed(2) },
      { key: "currency", label: "Currency" },
      { key: "ts", label: "Date", get: (r) => hwdFmtTs(r.ts) },
      { key: "method", label: "Method", get: (r) => hwdMethodLabel(r.method) },
      { key: "status", label: "Status", get: hwdStatusName },
      { key: "lastTx", label: "Last transaction", get: (r) => r.lastTx ? `${r.lastTx.amount.toFixed(2)} ${r.currency} ${hwdFmtTs(r.lastTx.ts)}` : "-" },
      { key: "pd", label: "Payment details", get: (r) => r.method === "wire-argentina" ? `Destinatario: ${r.fullname} , CVU: ${r.cvu}` : "-" },
      { key: "answer", label: "Decline reason", get: (r) => r.answer || "-" },
    ]);
    hwdToast(`Exported ${sorted.length} requests`);
  };

  const activePills = [];
  if (f.method) activePills.push(["Method: " + hwdMethodLabel(f.method), () => setF(p => ({ ...p, method: "" }))]);
  if (f.status !== "") activePills.push([(hwdStatuses.find(x => x[0] === Number(f.status)) || [0, `Status ${f.status}`])[1], () => setF(p => ({ ...p, status: "" }))]);
  if (f.dateFrom || f.dateTo) activePills.push([`${f.dateFrom || "…"} — ${f.dateTo || "…"}`, () => setF(p => ({ ...p, dateFrom: "", dateTo: "" }))]);
  if (f.amtFrom || f.amtTo) activePills.push([`${f.amtFrom || "0"} — ${f.amtTo || "∞"}`, () => setF(p => ({ ...p, amtFrom: "", amtTo: "" }))]);
  if (f.user.trim()) activePills.push([`User: ${f.user.trim()}${f.searchType === "all" ? " + subs" : f.searchType === "sub" ? " (subs only)" : ""}`, () => setF(p => ({ ...p, user: "", searchType: "" }))]);
  if (f.payDetails.trim()) activePills.push([`CVU: ${f.payDetails.trim()}`, () => setF(p => ({ ...p, payDetails: "" }))]);
  const clearAll = () => setF({ method: "", status: "", dateFrom: "", dateTo: "", amtFrom: "", amtTo: "", user: "", searchType: "", payDetails: "" });

  const from = sorted.length === 0 ? 0 : page * pageSize + 1;
  const to = Math.min(sorted.length, (page + 1) * pageSize);

  return (
    <div className="page report-page hwd">
      <div className="page__header hwd-header">
        <div>
          <div className="page__title" style={{ color: "var(--p-700)", display: "inline-flex", alignItems: "center" }}>
            Withdraws{/* nav label key backend.withdraws — untranslated on the live platform; label inferred */}
            <Tip>Sidebar entry gated by role (<code>isadmin</code> / <code>isSkinAdmin</code> / <code>isAdministration</code> / <code>isCustomCare</code> / <code>isShop</code>) AND <code>checkUserBoPerm(user, "support_withdraws")</code> — that permission gates the <strong>sidebar link only</strong>; the <code>/withdrawrequests/*</code> and <code>/withdrawals/*</code> routes themselves are reachable by any authenticated back-office user passing <code>auth/admin/2fa/g2fa</code>, scoped solely by <code>users.user_path</code>.</Tip>
          </div>
          <div className="page__subtitle">
            Withdrawal requests across your network · <b>{pendingCount} pending</b>
            {/* pending count = countPendingWithdrawRequests(), the sidebar badge polled via GET /getPendingCounts */}
          </div>
        </div>
        <div className="page__actions">
          <button className="btn btn--secondary btn--sm" onClick={exportRows}>
            <Icon name="download" size={13} /> Export
            <Tip>Live export writes <code>WithdrawRequests-&lt;timestamp&gt;-&lt;admin_id&gt;.xlsx</code> with the active filters — but only the currently displayed page (offset/limit applied before the export re-query). This prototype exports every filtered row, as evidently intended.</Tip>
          </button>
          <button className="btn btn--secondary btn--sm hwd-filters-btn" onClick={() => setSheet(true)}>
            <Icon name="filter" size={13} /> Filters{activePills.length > 0 && <span className="hwd-filters-count">{activePills.length}</span>}
          </button>
        </div>
      </div>

      <Explainer compact title="What this queue is, in plain English"
        bullets={[
          <><strong>Approve</strong> temporarily re-credits the player, then transfers the amount out via the request's method (description “Withdraw request &#123;id&#125;”). If the transfer fails, the credit is reversed and the request stays Pending.</>,
          <><strong>Reject</strong> refunds the amount to the player first, then marks the request Rejected with your reason.</>,
          <>Rows with method <strong>Online</strong> are gateway-routed and never get buttons here — they are approved in Payments → To-Confirm. The same guard blocks any request held by an open payment cascade hold.</>,
          <>A player can cancel their own pending request within the first 8 hours from the frontend (not on this screen).</>,
        ]}>
        Every player withdrawal request lands here as <strong>Pending</strong>. The list is always scoped to your network (<code>users.user_path</code>).
      </Explainer>

      {/* --- Hero filter strip (desktop) — Transactions.jsx card shape --- */}
      <div className="hwd-hero">
        <HwdFilterControls f={f} setF={setF} variant="hero" methods={hwdMethods} statuses={hwdStatuses} users={hwdUsers} />
        <div className="hwd-fcard hwd-fcard--result">
          <div className="hwd-flab"><Icon name="chart" size={11} /> Results</div>
          <div className="hwd-fval">{sorted.length.toLocaleString()}<span className="hwd-fval-sub">of {rows.length.toLocaleString()}</span></div>
        </div>
      </div>

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

      {/* A payout queue that renders empty because the read failed looks
          exactly like one with nothing to approve. They are opposite facts. */}
      {feed.error && <HrsError error={feed.error} onRetry={feed.retry} />}
      {feed.loading && <HrsSkeleton rows={10} cols={9} />}
      {!feed.loading && !feed.error && (
      <div className="panel" style={{ overflow: "hidden" }}>
        {/* Desktop table */}
        <div className="hwd-tablewrap">
          <table className="data-table hwd-list">
            <thead>
              <tr>
                {HWD_COLS.map(c => (
                  <th key={c.label}>
                    {c.sort ? (
                      <button className="hwd-th-sort" onClick={() => toggleSort(c.sort)}>
                        {c.label}
                        <Icon name={sort.key === c.sort ? (sort.dir === "asc" ? "arrow_up" : "arrow_down") : "sort"} size={10}
                          style={{ opacity: sort.key === c.sort ? 1 : 0.45 }} />
                      </button>
                    ) : c.label}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {paged.length === 0 && (
                <tr><td colSpan={11} className="hwd-empty">
                  <Icon name="search" size={20} style={{ opacity: 0.4 }} />
                  <div>No withdrawal requests match your filters</div>
                </td></tr>
              )}
              {/* EMBED-OK: mapped row — status is the numeric id, a CSS modifier */}
              {paged.map(r => (
            <tr key={r.id} className={`hwd-r hwd-r--${r.status}`}>
                  <td>{r.id}</td>
                  <td>{r.parent || "—"}</td>
                  <td className="hwd-user">{r.user}</td>
                  <td className="hwd-date">{hwdFmtTs(r.ts)}</td>
                  <td className="hwd-amt">{hwdMoney(r.amount, r.currency)}</td>
                  <td>{hwdMethodLabel(r.method)}</td>
                  <td><HwdStatusCell r={r} /></td>
                  <td><HwdPayDetails r={r} /></td>
                  <td className="hwd-lasttx">{r.lastTx ? <>{hwdMoney(r.lastTx.amount, r.currency)}<div>{hwdFmtTs(r.lastTx.ts)}</div></> : <span className="hwd-dim">—</span>}</td>
                  {/* answer_description shown red only when request_status == 2, else "-" (L593) */}
                  <td className="hwd-declinecell">{r.status === 2 && r.answer ? <span className="hwd-decline">{r.answer}</span> : <span className="hwd-dim">—</span>}</td>
                  <td><HwdActs r={r} onApprove={askApprove} onDecline={askDecline} /></td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>

        {/* Mobile stacked cards */}
        <div className="hwd-cards">
          {paged.length === 0 && <div className="hwd-empty">No withdrawal requests match your filters</div>}
          {paged.map(r => <HwdCard key={r.id} r={r} onApprove={askApprove} onDecline={askDecline} />)}
        </div>

        {/* Footer — DataTables server-side pagination: pageLength 100, lengthMenu [5,10,25,50,100] */}
        <div className="hwd-foot">
          <div className="hwd-foot-show">
            Show
            <select className="select" value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
              {[5, 10, 25, 50, 100].map(n => <option key={n} value={n}>{n}</option>)}
            </select>
            entries
          </div>
          <div className="hwd-foot-range">
            Showing <b>{from.toLocaleString()}–{to.toLocaleString()}</b> of <b>{sorted.length.toLocaleString()}</b> · Page {page + 1} of {totalPages}
          </div>
          <div className="hwd-foot-pages">
            <button className="btn btn--secondary btn--sm" disabled={page === 0} onClick={() => setPage(p => Math.max(0, p - 1))}>
              <Icon name="chevron_left" size={12} /> Prev
            </button>
            <button className="btn btn--secondary btn--sm" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>
              Next <Icon name="chevron_right" size={12} />
            </button>
          </div>
        </div>
      </div>
      )}

      {/* Mobile full-height filter sheet (brief §11) */}
      {sheet && (
        <div className="hwd-sheet-scrim" onClick={() => setSheet(false)}>
          <div className="hwd-sheet" onClick={(e) => e.stopPropagation()}>
            <div className="hwd-sheet-head">
              <span><Icon name="filter" size={14} /> Filters</span>
              <button className="hwd-modal-x" onClick={() => setSheet(false)} title="Close"><Icon name="x" size={13} /></button>
            </div>
            <div className="hwd-sheet-body">
              <HwdFilterControls f={f} setF={setF} variant="sheet" methods={hwdMethods} statuses={hwdStatuses} users={hwdUsers} />
            </div>
            <div className="hwd-sheet-foot">
              <button className="rpt-btn hwd-modal-btn hwd-modal-btn--ghost" onClick={clearAll}>Reset all</button>
              <button className="rpt-btn rpt-btn--blue hwd-modal-btn" onClick={() => setSheet(false)}>Show {sorted.length} requests</button>
            </div>
          </div>
        </div>
      )}

      {action && (
        <HwdActionModal key={`${action.type}-${action.row.id}`} action={action} busy={save.busy}
          onClose={() => setAction(null)} onConfirm={confirmAction} />
      )}
    </div>
  );
};

window.HostWithdrawals = HostWithdrawals;
