// Represents: GET /vouchers/ · VouchersController — see docs/ISYSTEM_REFERENCE.md §Batch 1 "Voucher"
/* Vouchers — top-level header page (sidebar key `backend.vouchers`, sits between Reports and
   Commissions payments). List (getVouchers) + New voucher / Redeem voucher / View-print modals.

   Fidelity notes (all cites are VouchersController.php unless said otherwise):
   - Columns, filters, sortable set, pagination, statuses and both modal flows follow the
     reference 1:1. The real screen has NO export (Buttons/pdfmake are loaded but never
     configured), no bulk actions and no KPIs — none are added here.
   - KNOWN BUG (implemented as evident intent, per build policy): the real Status filter
     matches `users.stato` — the CREATOR's account status — instead of `vouchers.stato`
     (getVouchers L163-165). This prototype filters the voucher's own status.
     <!-- SUGGESTION: fix getVouchers L163-165 to filter vouchers.stato; today the Status
          dropdown silently returns vouchers whose *creator* has that account status. -->
   - Real modal titles are hardcoded Italian "Nuovo voucher" on BOTH modals (the redeem one
     is a copy-paste mistake — modals/cashoutVoucher.blade.php). Evident intent used here.
     <!-- SUGGESTION: retitle the cashout modal "Redeem voucher"; it currently reads "Nuovo
          voucher" which tells the operator they are creating one. -->
   - saveNewVoucher's success response includes the generated voucher_code (L408) but the
     generic modal handler ignores it — the operator only finds the code in the reloaded
     table. Here the reloaded-table behavior is kept (new row appears on top, ID desc) and
     the toast also names the code, since the backend already returns it.
     <!-- SUGGESTION: surface the returned voucher_code in the create-success confirmation
          instead of making the operator hunt the reloaded table. -->
   - Label policy: every label on this screen resolves only to raw backend.* keys in-repo
     (backend.vouchers, created_by, voucher_code, value, status, redeem_by, creation_date,
     redeem_date, skin, enter_amount_voucher, insert_amount, invalid_amount, min_amount,
     max_amount, insufficient_funds, enter_voucher_code, insert_voucher_code,
     wrong_voucher_code, voucher_not_exists, print, all_selections, voucher_pending,
     voucher_redeemed, voucher_canceled). Operator-facing English written here — labels
     inferred. Filter placeholders are hardcoded Italian in the real blade ("Inserisci
     username" / "Inserisci codice"); English used, per the same policy.
   - Permission honesty: `support_vouchers` gates ONLY the sidebar entry. No controller
     method re-checks it — any authenticated 2FA'd BO user can hit /vouchers/* directly;
     only getVouchers' row scoping limits visibility, and create/redeem are open to every
     BO role. Surfaced in the page Tip, not hidden.
   - Demo session = Super admin (level 0): shows the superadmin-only Skin column + filter
     (index() L28-30, getVouchers L288-290) and the header buttons, which render only for
     isshop() (SHOP, 20) or isadmin() (SUPERADMIN, 0) — index.blade L53-62.
   - Mock rows are deterministic (seeded PRNG) so the list renders identically each load.
     Canceled(5)/Error(3) rows are included ONCE each to exercise the real status renderer —
     nothing in the real codebase ever writes stato 5 or 3 (ref Enums note); such rows can
     only originate outside the app. */

const { useState: useStateHv, useEffect: useEffectHv, useMemo: useMemoHv } = React;

const hvToast = (m, isErr) => window.PAYBO?.emitToast && window.PAYBO.emitToast({
  id: `hv-${Date.now()}`, tx_id: m, amount: 0, currency: isErr ? "ERR" : "HOST",
  player: "Vouchers", reason: isErr ? "Fix this before continuing." : "Prototype state only \u2014 not persisted.",
});

const hvEsc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));

/* Real print, no backend needed. The slip's whole content (skin, date, amount, code) is
   rendered client-side here, so the honest behaviour of `?print=1` — write the slip into a
   document and call window.print() on it — is reproducible verbatim. A hidden same-origin
   iframe stands in for the real modal's getVoucherPDF iframe: the operator gets the browser
   print dialog with the slip alone on the sheet, which is what the admin build does. */
const hvPrintDoc = (title, bodyHtml) => {
  const fr = document.createElement("iframe");
  fr.setAttribute("aria-hidden", "true");
  fr.style.cssText = "position:fixed;left:-10000px;top:0;width:380px;height:520px;border:0;";
  document.body.appendChild(fr);
  const doc = fr.contentDocument || fr.contentWindow.document;
  doc.open();
  doc.write(
    '<!doctype html><html><head><meta charset="utf-8"><title>' + hvEsc(title) + "</title><style>" +
    "@page{margin:12mm}" +
    "body{margin:0;font:13px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#181c32}" +
    ".s{width:280px;margin:0 auto;text-align:center;border:1px dashed #9aa1b4;border-radius:8px;padding:18px 16px}" +
    ".s h1{margin:0 0 14px;font-size:17px;letter-spacing:.16em;text-transform:uppercase}" +
    ".s .r{display:flex;justify-content:space-between;font-size:12.5px;padding:3px 0}" +
    ".s .r span{color:#5a6172}.s .r b{font-weight:700}" +
    ".s .c{margin-top:16px;font-size:30px;font-weight:700;letter-spacing:.12em}" +
    "</style></head><body>" + bodyHtml + "</body></html>"
  );
  doc.close();
  let fired = false;
  const go = () => {
    if (fired) return;
    fired = true;
    try { fr.contentWindow.focus(); fr.contentWindow.print(); } catch (e) { /* print dialog unavailable */ }
    setTimeout(() => { if (fr.parentNode) fr.parentNode.removeChild(fr); }, 1000);
  };
  // A written document is usually "complete" the moment doc.close() returns, but the load
  // event can also still be pending — take whichever happens first, with a timer so the
  // dialog cannot be lost to a load event that already fired.
  if (doc.readyState === "complete") go();
  else { fr.onload = go; setTimeout(go, 300); }
};

/* Deterministic PRNG (mulberry32) — mock rows render identically each load. */
const hvRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
/* 5-char code from [0-9A-Z] — mirrors generateVoucherCode(5) L323-330. The real generator
   makes a SINGLE attempt; a collision aborts with "System error, try again". */
const hvCode = (rng) => Array.from({ length: 5 }, () => "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"[Math.floor(rng() * 36)]).join("");

const hvTs = (dd, mm, yyyy, h, mi) => new Date(yyyy, mm - 1, dd, h, mi).getTime();
/* date("d/m/Y G:i", ts) — day/month padded, hour NOT padded, exactly like the real list. */
const hvFmtDate = (ts) => {
  if (!ts) return "";
  const d = new Date(ts), p2 = (n) => String(n).padStart(2, "0");
  return `${p2(d.getDate())}/${p2(d.getMonth() + 1)}/${d.getFullYear()} ${d.getHours()}:${p2(d.getMinutes())}`;
};
/* Real Value column prints vouchers.amount raw (no formatting) — thousands separators added
   here as pure visual elevation, the underlying number is untouched. */
const hvFmtAmt = (n) => Number(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });

/* vouchers.stato → label + chip. Any unknown value renders "Error" (L261-271). */
const HV_STATUS = {
  1: { label: "Pending",  chip: "chip--warn" }, // backend.voucher_pending  (span.pending-w)
  2: { label: "Redeemed", chip: "chip--ok"   }, // backend.voucher_redeemed (span.approved-w)
  5: { label: "Canceled", chip: "chip--err"  }, // backend.voucher_canceled (span.error-w)
  3: { label: "Error",    chip: "chip--err"  }, // hardcoded "Error" (span.error-w) — never written by the app
};

/* Mock skins + their `voucher` withdraw-method row (skin_withdraw_methods: min_with/max_with
   bound the voucher amount; the method existing at all is the create precondition). */
/* WHAT WAS HERE

   HV_SKINS     two invented skins with currencies and invented voucher
                min/max bounds, driving the create-modal validation.
   HV_ME        an invented signed-in operator, including `available: 12500`
                shown to them as their own spendable balance.
   HV_FEATURED  four vouchers with real-looking codes and usernames.
   hvBuild      thirty more, with seeded codes and amounts.

   Showing an operator an invented figure labelled "available funds" and then
   validating their input against it is the shape of a bug that only surfaces
   when it is real: the form says yes, the server says no, and nothing on the
   screen explains why. The bounds now come from the skin's voucher method and
   the balance from the operator's own wallet.

   The codes were the other half. A 5-character client-generated code can
   collide, and a client that chooses the code chooses which voucher exists.
   issue_voucher() mints it (supabase/027). */

const hvRowFromDb = (r) => {
  const ms = (t) => (t ? Date.parse(t) : null);
  return {
    id: r.id,
    code: r.code,
    val: Number(r.amount || 0),
    currency: r.currency,
    stato: Number(r.status_id),
    by: (r.createdBy && r.createdBy.username) || "",
    byId: (r.createdBy && r.createdBy.id) || null,
    byRole: (r.createdBy && r.createdBy.user_level) ?? null,
    redeemBy: (r.redeemedBy && r.redeemedBy.username) || "",
    redeemRole: (r.redeemedBy && r.redeemedBy.user_level) ?? null,
    cts: ms(r.created_at),
    rts: ms(r.redeemed_at),
    skin: (r.skin && r.skin.name) || "",
    skinId: r.skin_id,
  };
};

const HV_EMPTY_FILTERS = { createdBy: "", redeemBy: "", code: "", cFrom: "", cTo: "", rFrom: "", rTo: "", aFrom: "", aTo: "", status: "", skin: "" };

/* Server-side ordering exists ONLY for id, amount, addedTime, CashingTime, stato (L168-210);
   the other four columns are not sortable in the real screen and stay plain here. */
const HV_SORT_GET = { id: r => r.id, val: r => r.val, created: r => r.cts, redeemed: r => r.rts, status: r => r.stato };

const HvChip = ({ stato }) => {
  const s = HV_STATUS[stato] || HV_STATUS[3];
  return <span className={`chip ${s.chip}`}>{s.label}</span>;
};

const HvPill = ({ label, onClear }) => (
  <span style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "3px 4px 3px 10px", background: "var(--p-50)", color: "var(--p-700)", border: "1px solid color-mix(in oklab, var(--p-500) 22%, transparent)", borderRadius: 999, fontSize: 11.5, fontWeight: 600 }}>
    {label}
    <button onClick={onClear} title="Remove" style={{ width: 16, height: 16, borderRadius: 999, border: "none", background: "color-mix(in oklab, var(--p-500) 18%, transparent)", color: "var(--p-700)", cursor: "pointer", display: "inline-grid", placeItems: "center" }}>
      <Icon name="x" size={9} />
    </button>
  </span>
);

const HvTh = ({ label, k, sort, onSort, right }) => (
  <th className="hv-sort-th" style={right ? { textAlign: "right" } : undefined} onClick={() => onSort(k)}
      title="Sortable — one of the five columns the real screen orders server-side (id, amount, creation date, redeem date, status)">
    {label}<span className="hv-sort-arrow">{sort.key === k ? (sort.dir === "asc" ? "▲" : "▼") : "↕"}</span>
  </th>
);

/* Filter fields — rendered inside the desktop hero strip AND the mobile full-height sheet.
   Field set is exactly the real one (index.blade L76-141): created by, redeem by, code,
   creation-date range, redeem-date range, amount range, status, skin (superadmin only). */
const HvFilterFields = ({ f, set, skins = [] }) => (
  <>
    <div className="filter-hero__card">
      <div className="filter-hero__label"><Icon name="user" size={11} /> Created by
        <Tip>Matches from the <strong>start</strong> of the creator's username — the server applies <code>users.username LIKE 'value%'</code> (getVouchers L111-113), unlike the other two text filters which match anywhere.</Tip>
      </div>
      <div style={{ position: "relative" }}>
        <input className="filter-hero__input" placeholder="Username…" value={f.createdBy} onChange={e => set({ createdBy: e.target.value })} />
        {f.createdBy && <button className="filter-hero__clear" title="Clear" onClick={() => set({ createdBy: "" })}><Icon name="x" size={11} /></button>}
      </div>
    </div>
    <div className="filter-hero__card">
      <div className="filter-hero__label"><Icon name="users" size={11} /> Redeem by
        <Tip>Matches anywhere in the redeemer's username (<code>cashedUser.username LIKE '%value%'</code>, L108-110).</Tip>
      </div>
      <div style={{ position: "relative" }}>
        <input className="filter-hero__input" placeholder="Username…" value={f.redeemBy} onChange={e => set({ redeemBy: e.target.value })} />
        {f.redeemBy && <button className="filter-hero__clear" title="Clear" onClick={() => set({ redeemBy: "" })}><Icon name="x" size={11} /></button>}
      </div>
    </div>
    <div className="filter-hero__card">
      <div className="filter-hero__label"><Icon name="tag" size={11} /> Voucher code</div>
      <div style={{ position: "relative" }}>
        <input className="filter-hero__input hv-code" placeholder="Code…" maxLength={5} value={f.code} onChange={e => set({ code: e.target.value.toUpperCase() })} />
        {f.code && <button className="filter-hero__clear" title="Clear" onClick={() => set({ code: "" })}><Icon name="x" size={11} /></button>}
      </div>
    </div>
    <div className="filter-hero__card">
      <div className="filter-hero__label"><Icon name="calendar" size={11} /> Creation date</div>
      <div className="hv-range">
        <input type="date" className="filter-hero__input" value={f.cFrom} onChange={e => set({ cFrom: e.target.value })} />
        <span className="hv-range-sep">—</span>
        <input type="date" className="filter-hero__input" value={f.cTo} onChange={e => set({ cTo: e.target.value })} />
      </div>
    </div>
    <div className="filter-hero__card">
      <div className="filter-hero__label"><Icon name="calendar" size={11} /> Redeem date</div>
      <div className="hv-range">
        <input type="date" className="filter-hero__input" value={f.rFrom} onChange={e => set({ rFrom: e.target.value })} />
        <span className="hv-range-sep">—</span>
        <input type="date" className="filter-hero__input" value={f.rTo} onChange={e => set({ rTo: e.target.value })} />
      </div>
    </div>
    <div className="filter-hero__card">
      <div className="filter-hero__label"><Icon name="wallet" size={11} /> Amount</div>
      <div className="hv-range">
        <input className="filter-hero__input" inputMode="decimal" placeholder="From" value={f.aFrom} onChange={e => set({ aFrom: e.target.value })} />
        <span className="hv-range-sep">—</span>
        <input className="filter-hero__input" inputMode="decimal" placeholder="To" value={f.aTo} onChange={e => set({ aTo: e.target.value })} />
      </div>
    </div>
    <div className="filter-hero__card">
      <div className="filter-hero__label"><Icon name="flag" size={11} /> Status
        <Tip>Filters the <strong>voucher's own</strong> status. Known real-platform bug: the server matches <code>users.stato</code> — the CREATOR's account status — instead of <code>vouchers.stato</code> (getVouchers L163-165); this prototype implements the evident intent. "Error" (3) is displayed by the list but has never been a filter option.</Tip>
      </div>
      {/* Option set + order from voucherStatus() L34-41: all, redeemed=2, pending=1, canceled=5. */}
      <select className="filter-hero__select" value={f.status} onChange={e => set({ status: e.target.value })}>
        <option value="">All</option>
        <option value="2">Redeemed</option>
        <option value="1">Pending</option>
        <option value="5">Canceled</option>
      </select>
    </div>
    <div className="filter-hero__card">
      <div className="filter-hero__label"><Icon name="globe" size={11} /> Skin
        <Tip><strong>Super admin only</strong> — the real screen renders this filter and the Skin column only for level 0 (index.blade L128-139). Everyone else is already scoped: skin admin / administration to their own skin, all other roles to vouchers they created or redeemed, always inside their <code>user_path</code> subtree (getVouchers L214-229).</Tip>
      </div>
      <select className="filter-hero__select" value={f.skin} onChange={e => set({ skin: e.target.value })}>
        <option value="">All skins</option>
        {(skins || []).map(k => <option key={k.id} value={k.name}>{k.name}</option>)}
      </select>
    </div>
  </>
);

/* Compact modal shell on the shared .bp-modal chrome; full-screen on mobile via .hv-modal. */
const HvModal = ({ title, onClose, children, footer }) => (
  <div className="bp-modal-scrim hv-scrim" onClick={onClose}>
    <div className="bp-modal hv-modal" onClick={e => e.stopPropagation()}>
      <div className="hv-modal__head">
        <div className="hv-modal__title">{title}</div>
        <button className="hv-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hv-modal__body">{children}</div>
      {footer && <div className="hv-modal__foot">{footer}</div>}
    </div>
  </div>
);

/* New voucher — GET /vouchers/newVoucher/ + POST /vouchers/saveNewVoucher/. One field.
   Real modal title is hardcoded Italian "Nuovo voucher"; evident intent used (see header). */
const HvCreateModal = ({ onClose, onCreate, bounds, available, currency }) => {
  /* Bounds come from the skin's `voucher` method row, and its ABSENCE is a real
     state: isystem hard-fails voucher creation when there is no such row, so
     the modal says that rather than validating against invented limits. */
  const m = bounds || null;
  const skin = { currency: currency || (m && m.currency) || "" };
  const [amount, setAmount] = useStateHv("");
  const [err, setErr] = useStateHv("");
  const save = () => {
    // Inline validation mirror of saveNewVoucher L346-365 (no FormRequest in the real controller).
    const raw = amount.trim();
    if (!raw) return setErr("Insert an amount");                 // backend.insert_amount (label inferred)
    const n = Number(raw);
    if (!isFinite(n)) return setErr("Invalid amount");           // backend.invalid_amount (is_numeric)
    if (!m) return setErr("This skin has no voucher method enabled, so vouchers cannot be issued on it");
    if (m.min != null && n < m.min) return setErr(`Minimum voucher amount is ${hvFmtAmt(m.min)} ${skin.currency}`);
    if (m.max != null && n > m.max) return setErr(`Maximum voucher amount is ${hvFmtAmt(m.max)} ${skin.currency}`);
    /* Client-side courtesy only. post_transaction refuses to take a balance
       below zero, so the real answer comes from the server and this just saves
       a round trip. */
    if (n > available) return setErr("Insufficient funds");
    onCreate(n);
    onClose();
  };
  return (
    <HvModal title="New voucher" onClose={onClose} footer={<>
      <button className="btn btn--secondary" onClick={onClose}>Close</button>
      <button className="btn btn--primary" onClick={save}><Icon name="check" size={13} /> Save</button>
    </>}>
      <label className="hv-field-label">Voucher amount <span className="hv-req">*</span></label>{/* backend.enter_amount_voucher — label inferred */}
      <input className="input" style={{ width: "100%" }} autoFocus inputMode="decimal"
        placeholder={`${m.min} – ${hvFmtAmt(m.max)} ${skin.currency}`}
        value={amount}
        onChange={e => { setAmount(e.target.value); setErr(""); }}
        onKeyDown={e => { if (e.key === "Enter") save(); }} />
      {err && <div className="hv-err"><Icon name="alert" size={12} /> {err}</div>}
      <div className="hv-hint">
        Allowed range <b>{hvFmtAmt(m.min)} – {hvFmtAmt(m.max)} {skin.currency}</b> — the min/max of this skin's <code>voucher</code> withdraw
        method (skin_withdraw_methods). If that method isn't enabled for the skin, creation fails with "Method not enabled".
      </div>
      <div className="hv-hint">
        Available funds (balance + credits): <b>{hvFmtAmt(available)} {skin.currency}</b>. On save, the server mints an 8-character code
        is generated and the amount moves from your account to the skin-admin account ("Voucher CODE"). If that transfer
        fails, the voucher row is deleted again.
      </div>
    </HvModal>
  );
};

/* Redeem voucher — GET /vouchers/cashoutVoucher/ + POST /vouchers/doCashoutVoucher/.
   Real flow marks stato=2 / cashed_by / CashingOutAdded_time BEFORE the money moves, then
   pays skin-admin → redeemer ("Cashout Voucher CODE"); a failed transfer reverts the row
   (non-transactional window in between, L474-497). Player redeemers additionally get a
   transaction_history row: transaction_type=150 (CASHOUT_VOUCHER), res_type=8 (undocumented). */
const HvRedeemModal = ({ rows, onClose, onRedeem, meName }) => {
  const [code, setCode] = useStateHv("");
  const [err, setErr] = useStateHv("");
  const save = () => {
    // Inline validation mirror of doCashoutVoucher L424-448.
    const c = code.trim().toUpperCase();
    if (!c) return setErr("Insert the voucher code");         // backend.insert_voucher_code (label inferred)
    if (c.length !== 5) return setErr("Wrong voucher code");  // backend.wrong_voucher_code — length must be exactly 5
    const row = rows.find(r => r.code === c);
    // Nonexistent, already-redeemed (stato 2) and canceled (stato 5) deliberately share one
    // message in the real controller. Error (3) is NOT screened there — replicated as-is.
    if (!row || row.stato === 2 || row.stato === 5) return setErr("Voucher does not exist"); // backend.voucher_not_exists
    /* Also enforced server-side by redeem_voucher(): issuing and redeeming your
       own voucher is a round trip that moves nothing and leaves a paper trail
       suggesting it did. */
    if (meName && row.by === meName) return setErr("You can't collect vouchers created by yourself");
    onRedeem(row);
    onClose();
  };
  return (
    <HvModal title="Redeem voucher" onClose={onClose} footer={<>
      <button className="btn btn--secondary" onClick={onClose}>Close</button>
      <button className="btn btn--primary" onClick={save}><Icon name="check" size={13} /> Redeem</button>
    </>}>
      <label className="hv-field-label">Voucher code <span className="hv-req">*</span></label>{/* backend.enter_voucher_code — label inferred */}
      <input className="input hv-code" style={{ width: "100%", textTransform: "uppercase", letterSpacing: ".12em" }} autoFocus
        maxLength={5} placeholder="ABC12"
        value={code}
        onChange={e => { setCode(e.target.value.toUpperCase()); setErr(""); }}
        onKeyDown={e => { if (e.key === "Enter") save(); }} />
      {err && <div className="hv-err"><Icon name="alert" size={12} /> {err}</div>}
      <div className="hv-hint">
        Redeeming marks the voucher <b>Redeemed</b> and pays its amount from the skin-admin account to your account
        ("Cashout Voucher CODE"). You can't redeem vouchers you created yourself.
      </div>
    </HvModal>
  );
};

/* View / print voucher — GET /vouchers/getVoucher/{code}/ → iframe-loads getVoucherPDF (an
   HTML slip despite the name, voucher.blade.php; ?print=1 appends window.print()). Slip
   content 1:1: skin logo (skins.logo_black → logo_img; wordmark stands in for the mock),
   date (d/m/Y + G:i), amount + creator's currency, 30px bold code. */
const HvSlipModal = ({ row, onClose }) => {
  /* The currency is on the voucher row itself — it is the currency the
     voucher was ISSUED in, which is what the slip must show even if the skin
     has since changed its own. */
  const skin = { currency: row.currency || "" };
  const d = new Date(row.cts);
  const dateStr = `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()} · ${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`;
  const amtStr = `${hvFmtAmt(row.val)} ${skin.currency}`;
  // backend.print — the real button appends ?print=1 to the slip iframe, which fires
  // window.print(). Reproduced for real: the slip below is printed as-is (see hvPrintDoc).
  const doPrint = () => hvPrintDoc(`Voucher ${row.code}`,
    '<div class="s"><h1>' + hvEsc(row.skin) + "</h1>" +
    '<div class="r"><span>Date</span><b>' + hvEsc(dateStr) + "</b></div>" +
    '<div class="r"><span>Amount</span><b>' + hvEsc(amtStr) + "</b></div>" +
    '<div class="c">' + hvEsc(row.code) + "</div></div>");
  return (
    <HvModal title="Voucher" onClose={onClose} footer={<>
      <button className="btn btn--secondary" onClick={onClose}>Close</button>
      <button className="btn btn--primary" onClick={doPrint} title="Opens the browser print dialog with this slip"><Icon name="receipt" size={13} /> Print</button>
    </>}>
      <div className="hv-slip">
        <div className="hv-slip__logo">{row.skin}</div>
        <div className="hv-slip__row"><span>Date</span><b>{dateStr}</b></div>
        <div className="hv-slip__row"><span>Amount</span><b>{amtStr}</b></div>
        <div className="hv-slip__code">{row.code}</div>
      </div>
      <div className="hv-hint">
        <Icon name="alert" size={11} style={{ marginRight: 4, verticalAlign: "-1px" }} />
        Real gate, honestly: this slip route has <b>no scoping or permission check</b> — any authenticated backoffice user
        (and the player web side) can render any voucher by guessing its 5-character code.
      </div>
    </HvModal>
  );
};

/* Mobile stacked card — code, amount, status, created scan first; the rest behind a tap. */
const HvMobileCard = ({ r, open, onToggle, onView }) => (
  <div className={`hv-card${open ? " hv-card--open" : ""}`}>
    <button className="hv-card__head" onClick={onToggle}>
      <span className="hv-code hv-card__code">{r.code}</span>
      <HvChip stato={r.stato} />
      <span className="hv-card__amt">{hvFmtAmt(r.val)}</span>
      <span className="hv-card__date">{hvFmtDate(r.cts)}</span>
      <Icon name={open ? "chevron_down" : "chevron_right"} size={13} className="hv-card__chev" />
    </button>
    {open && (
      <div className="hv-card__body">
        <div className="hv-card__row"><span>ID</span><b>{r.id}</b></div>
        <div className="hv-card__row"><span>Created by</span><b>{r.by} ({r.byRole})</b></div>
        <div className="hv-card__row"><span>Redeem by</span><b>{r.redeemBy ? `${r.redeemBy} (${r.redeemRole})` : "—"}</b></div>
        <div className="hv-card__row"><span>Redeem date</span><b>{r.rts ? hvFmtDate(r.rts) : "—"}</b></div>
        <div className="hv-card__row"><span>Skin</span><b>{r.skin}</b></div>
        {r.stato === 1 && (
          <button className="btn btn--secondary btn--sm" style={{ marginTop: 8 }} onClick={onView}>
            <Icon name="eye" size={12} /> View / print voucher
          </button>
        )}
      </div>
    )}
  </div>
);

const HostVouchers = ({ brand }) => {
  window.useLocale && window.useLocale();
  const feed = useHrsFetch(() => window.sb.list("vouchers", { limit: 500 }), []);
  const rows = useMemoHv(() => (feed.data || []).map(hvRowFromDb), [feed.data]);
  const meFeed = useHrsFetch(() => window.sb.me(), []);
  const me = meFeed.data || null;
  /* The operator's own spendable balance, from their own wallet row — the
     figure the create modal validates against. */
  const balFeed = useHrsFetch(
    () => (me ? window.sb.list("balances", { limit: 1, filters: { user: me.id } })
              : Promise.resolve({ ok: true, data: [] })), [me && me.id]);
  const myAvailable = (balFeed.data && balFeed.data[0] && Number(balFeed.data[0].available)) || 0;
  /* Voucher bounds per skin: the `voucher` method row on skin_payment_methods.
     isystem reads exactly this to bound voucher creation, and hard-fails if the
     row is absent — so an absent row is a real, visible state here too. */
  const boundsFeed = useHrsFetch(() => window.sb.list("skinPaymentMethods", { limit: 500 }), []);
  const skinBounds = useMemoHv(() => {
    const out = {};
    (boundsFeed.data || []).forEach(r => {
      if (r.method && r.method.code === "voucher") {
        out[r.skin_id] = {
          currency: r.currency || (r.skin && r.skin.currency) || "",
          min: r.min_amount == null ? null : Number(r.min_amount),
          max: r.max_amount == null ? null : Number(r.max_amount),
          name: (r.skin && r.skin.name) || String(r.skin_id),
        };
      }
    });
    return out;
  }, [boundsFeed.data]);
  const save = useHrsSave([feed, balFeed]);
  const skinOpts = useMemoHv(
    () => Object.entries(skinBounds).map(([id, b]) => ({ id: Number(id), name: b.name })),
    [skinBounds]);
  const [f, setF] = useStateHv(HV_EMPTY_FILTERS);
  const [sort, setSort] = useStateHv({ key: "id", dir: "desc" }); // real client default: column 0 (ID) desc — ajax.js L20
  const [page, setPage] = useStateHv(0);
  const [pageSize, setPageSize] = useStateHv(100); // real default 100/page, length menu [5,10,25,50,100] — ajax.js L17-24
  const [sheetOpen, setSheetOpen] = useStateHv(false);
  const [modal, setModal] = useStateHv(null); // "create" | "redeem" | { slip: row }
  const [expanded, setExpanded] = useStateHv(() => new Set());

  const setFilter = (patch) => setF(s => ({ ...s, ...patch }));
  const clearAll = () => setF(HV_EMPTY_FILTERS);

  const filtered = useMemoHv(() => {
    const dayStart = (s) => (s ? new Date(s + "T00:00:00").getTime() : null);
    const dayEnd = (s) => (s ? new Date(s + "T23:59:59").getTime() : null);
    const cF = dayStart(f.cFrom), cT = dayEnd(f.cTo), rF = dayStart(f.rFrom), rT = dayEnd(f.rTo);
    const aF = parseFloat(f.aFrom), aT = parseFloat(f.aTo);
    return rows.filter(r => {
      if (f.createdBy && !r.by.toLowerCase().startsWith(f.createdBy.trim().toLowerCase())) return false; // prefix — LIKE 'value%'
      if (f.redeemBy && !r.redeemBy.toLowerCase().includes(f.redeemBy.trim().toLowerCase())) return false;
      if (f.code && !r.code.includes(f.code.trim().toUpperCase())) return false;
      if (cF !== null && r.cts < cF) return false;
      if (cT !== null && r.cts > cT) return false;
      if (rF !== null && (!r.rts || r.rts < rF)) return false;
      if (rT !== null && (!r.rts || r.rts > rT)) return false;
      if (!isNaN(aF) && r.val < aF) return false;
      if (!isNaN(aT) && r.val > aT) return false;
      // DIVERGENCE (evident intent): filters vouchers.stato. The real server matches
      // users.stato — the creator's ACCOUNT status — here (getVouchers L163-165). See header.
      if (f.status && String(r.stato) !== f.status) return false;
      if (f.skin && r.skin !== f.skin) return false; // superadmin only — users.skin_id = value
      return true;
    });
  }, [rows, f]);

  const sorted = useMemoHv(() => {
    const get = HV_SORT_GET[sort.key] || HV_SORT_GET.id;
    const dir = sort.dir === "asc" ? 1 : -1;
    return [...filtered].sort((a, b) => (get(a) - get(b)) * dir || (a.id - b.id) * dir);
  }, [filtered, sort]);

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

  const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
  const paged = sorted.slice(page * pageSize, page * pageSize + pageSize);
  const from = sorted.length === 0 ? 0 : page * pageSize + 1;
  const to = Math.min(sorted.length, (page + 1) * pageSize);

  const onSort = (k) => setSort(s => s.key === k ? { key: k, dir: s.dir === "asc" ? "desc" : "asc" } : { key: k, dir: "asc" });
  const toggleCard = (id) => setExpanded(s => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });

  const doCreate = (amt) => save.run(
    /* The server debits the issuer, mints the code and inserts the voucher in
       one transaction. The old version generated a 5-character code in the
       browser and retried locally on collision — a client that chooses the code
       chooses which voucher exists. */
    () => window.sb.rpc("issue_voucher", { p_amount: amt, p_currency: null, p_skin_id: null }));

  const doRedeem = (row) => save.run(
    /* Locks on the code server-side. A voucher is bearer money and two people
       presenting the same code in the same second is the ordinary case, not the
       exotic one. */
    () => window.sb.rpc("redeem_voucher", { p_code: row.code }));

  // Active-filter pills (Transactions-shape).
  const pills = [];
  const addPill = (cond, label, patch) => { if (cond) pills.push({ label, patch }); };
  addPill(f.createdBy, `Created by “${f.createdBy}”`, { createdBy: "" });
  addPill(f.redeemBy, `Redeem by “${f.redeemBy}”`, { redeemBy: "" });
  addPill(f.code, `Code “${f.code}”`, { code: "" });
  addPill(f.cFrom || f.cTo, `Created ${f.cFrom || "…"} — ${f.cTo || "…"}`, { cFrom: "", cTo: "" });
  addPill(f.rFrom || f.rTo, `Redeemed ${f.rFrom || "…"} — ${f.rTo || "…"}`, { rFrom: "", rTo: "" });
  addPill(f.aFrom || f.aTo, `Amount ${f.aFrom || "0"} — ${f.aTo || "∞"}`, { aFrom: "", aTo: "" });
  addPill(f.status, `Status: ${(HV_STATUS[Number(f.status)] || {}).label || f.status}`, { status: "" });
  addPill(f.skin, `Skin: ${f.skin}`, { skin: "" });

  return (
    <div className="page host-vouchers">
      <div className="page__header hv-header">
        <div>
          <div className="page__title" style={{ display: "inline-flex", alignItems: "center" }}>
            Vouchers
            <Tip>
              Real gate, honestly: the sidebar entry is gated by <code>support_vouchers</code> (checkUserBoPerm) — but that
              gate exists <strong>only in the sidebar</strong>. No VouchersController method re-checks it, so any
              authenticated 2FA'd backoffice user can open <code>/vouchers/*</code> directly; only the row scoping inside
              getVouchers limits what they see, and the create/redeem endpoints are open to every BO role. Quirk: the
              sidebar's role gate references <code>usertypes.AMMINISTRATORE_LEVEL</code>, which doesn't exist — so
              Administration (level 6) users never even see the menu entry.
            </Tip>
          </div>
          <div className="page__subtitle">Printable cash-out slips backed by the skin's "voucher" withdraw method</div>
        </div>
        <div className="page__actions hv-actions">
          <button className="btn btn--primary btn--sm" onClick={() => setModal("create")}><Icon name="plus" size={13} /> New voucher</button>
          <button className="btn btn--secondary btn--sm" onClick={() => setModal("redeem")}><Icon name="check" size={13} /> Redeem voucher</button>
          <Tip>
            These two buttons render only for <strong>Cashier (SHOP, level 20)</strong> and <strong>Super admin (level 0)</strong> —
            index.blade L53-62. Skin admins (2) and Customer care (4) see the list without them. This demo session is a Super
            admin, so both show — though the underlying endpoints are not role-gated at all (see the page tip).
          </Tip>
        </div>
      </div>

      <Explainer compact title="What this is, in plain English"
        bullets={[
          <><strong>Voucher</strong> — a printable 5-character cash-out slip (code from 0-9 A-Z) a cashier or admin hands to a player. Pending codes in the table open the printable slip.</>,
          <><strong>Create</strong> — moves the amount from the creator's balance to the skin-admin account ("Voucher CODE"). The amount must sit inside the skin's voucher withdraw-method min/max, and the feature only works at all if that method (<code>method_code = 'voucher'</code>) is enabled for the skin.</>,
          <><strong>Redeem</strong> — marks the voucher Redeemed, then pays the amount from the skin-admin account to the redeemer ("Cashout Voucher CODE"). Player redeemers also get a transaction-history row (type 150 · Cashout Voucher). Self-redeem is blocked.</>,
          <><strong>Statuses</strong> — Pending (1) → Redeemed (2). Canceled (5) and Error (3) are displayed but never written by the app — rows like that can only originate outside it.</>,
        ]}>
        The full voucher ledger for {brand?.name || "the platform"}. Who sees what is role-scoped: super admin sees everything
        (plus the Skin column and filter), skin admin/administration their own skin, everyone else only vouchers they created
        or redeemed.
      </Explainer>

      {/* Mobile: filters collapse into one button + full-height sheet (brief §11) */}
      <button className="hv-filters-toggle" onClick={() => setSheetOpen(true)}>
        <Icon name="filter" size={13} /> Filters
        {pills.length > 0 && <span className="hv-badge">{pills.length}</span>}
        <span className="hv-toggle-results">{sorted.length.toLocaleString()} results</span>
      </button>

      {/* Desktop: hero filter-card strip (Transactions shape) */}
      <div className="hv-hero">
        <HvFilterFields f={f} set={setFilter} skins={skinOpts} />
        <div className="filter-hero__card filter-hero__card--result">
          <div className="filter-hero__label"><Icon name="chart" size={11} /> Results</div>
          <div className="filter-hero__value">
            {sorted.length.toLocaleString()}
            <span className="filter-hero__value-sub">of {rows.length.toLocaleString()}</span>
          </div>
        </div>
      </div>

      {pills.length > 0 && (
        <div className="hv-pills">
          <span className="hv-pills__label">Active:</span>
          {pills.map((p, i) => <HvPill key={i} label={p.label} onClear={() => setFilter(p.patch)} />)}
          <button className="hv-clear-all" onClick={clearAll}>Clear all</button>
        </div>
      )}

      <div className="panel" style={{ overflow: "hidden" }}>
        <div className="hv-table-wrap" style={{ overflowX: "auto" }}>
          <table className="data-table hv-table">
            <thead>
              <tr>
                <HvTh label="ID" k="id" sort={sort} onSort={onSort} />
                <th>Created by</th>
                <th>Voucher code</th>
                <HvTh label="Value" k="val" sort={sort} onSort={onSort} right />
                <HvTh label="Status" k="status" sort={sort} onSort={onSort} />
                <th>Redeem by</th>
                <HvTh label="Creation date" k="created" sort={sort} onSort={onSort} />
                <HvTh label="Redeem date" k="redeemed" sort={sort} onSort={onSort} />
                <th>Skin</th>{/* superadmin only — index() L28-30 */}
              </tr>
            </thead>
            <tbody>
              {paged.length === 0 && (
                <tr>
                  <td colSpan={9} style={{ padding: "40px 20px", textAlign: "center", color: "var(--text-tertiary)" }}>
                    <Icon name="search" size={22} style={{ opacity: .4, marginBottom: 8 }} />
                    <div style={{ fontSize: 13.5, fontWeight: 500, color: "var(--text-secondary)" }}>No vouchers match your filters</div>
                    <div style={{ fontSize: 12, marginTop: 2 }}>Try clearing a filter or widening the date range.</div>
                  </td>
                </tr>
              )}
              {paged.map(r => (
                <tr key={r.id}>
                  <td style={{ color: "var(--text-tertiary)" }}>{r.id}</td>
                  <td>{r.by} <span className="hv-role">({r.byRole})</span></td>
                  <td>
                    {r.stato === 1
                      ? (/* real: code is a link (showVoucher → print slip) only while stato==1, L253-257 */
                        <button className="hv-code-link hv-code" title="View / print voucher" onClick={() => setModal({ slip: r })}>
                          {r.code} <Icon name="eye" size={11} style={{ opacity: .6 }} />
                        </button>)
                      : <span className="hv-code">{r.code}</span>}
                  </td>
                  <td style={{ textAlign: "right", fontWeight: 600 }}>{hvFmtAmt(r.val)}</td>
                  <td><HvChip stato={r.stato} /></td>
                  <td>{r.redeemBy ? <>{r.redeemBy} <span className="hv-role">({r.redeemRole})</span></> : <span style={{ color: "var(--text-tertiary)" }}>—</span>}</td>
                  <td className="hv-date">{hvFmtDate(r.cts)}</td>
                  <td className="hv-date">{r.rts ? hvFmtDate(r.rts) : <span style={{ color: "var(--text-tertiary)" }}>—</span>}</td>
                  <td>{r.skin}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>

        {/* Mobile: rows collapse into stacked cards (brief §11) */}
        <div className="hv-cards">
          {paged.length === 0 && (
            <div style={{ padding: "32px 16px", textAlign: "center", color: "var(--text-tertiary)", fontSize: 13 }}>
              No vouchers match your filters.
            </div>
          )}
          {paged.map(r => (
            <HvMobileCard key={r.id} r={r} open={expanded.has(r.id)} onToggle={() => toggleCard(r.id)} onView={() => setModal({ slip: r })} />
          ))}
        </div>

        <div className="hv-foot">
          <div className="hv-foot__info">
            Showing <strong>{from.toLocaleString()}–{to.toLocaleString()}</strong> of <strong>{sorted.length.toLocaleString()}</strong>
            <span style={{ marginLeft: 10 }}>Page {page + 1} of {totalPages}</span>
          </div>
          <div className="hv-foot__size">
            <span>Rows</span>
            <select className="select hv-foot__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>
          </div>
          <div className="hv-foot__nav">
            <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>

      {sheetOpen && (
        <div className="hv-sheet-scrim" onClick={() => setSheetOpen(false)}>
          <div className="hv-sheet" onClick={e => e.stopPropagation()}>
            <div className="hv-sheet__head">
              <div className="hv-sheet__title"><Icon name="filter" size={14} /> Filters</div>
              <button className="hv-x" title="Close" onClick={() => setSheetOpen(false)}><Icon name="x" size={14} /></button>
            </div>
            <div className="hv-sheet__body">
              <HvFilterFields f={f} set={setFilter} skins={skinOpts} />
            </div>
            <div className="hv-sheet__foot">
              <button className="btn btn--secondary" onClick={clearAll}>Clear all</button>
              <button className="btn btn--primary" onClick={() => setSheetOpen(false)}>Show {sorted.length.toLocaleString()} results</button>
            </div>
          </div>
        </div>
      )}

      {modal === "create" && <HvCreateModal onClose={() => setModal(null)} onCreate={doCreate}
          bounds={me ? skinBounds[me.skin_id] : null} available={myAvailable}
          currency={me ? me.currency : ""} />}
      {modal === "redeem" && <HvRedeemModal rows={rows} onClose={() => setModal(null)} onRedeem={doRedeem} meName={me ? me.username : ""} />}
      {modal && modal.slip && <HvSlipModal row={modal.slip} onClose={() => setModal(null)} />}
    </div>
  );
};

window.HostVouchers = HostVouchers;
