// 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: /admin/bonus/promo-codes · Admin\PromoCodeController@index|create|store|edit|update|destroy — see docs/ISYSTEM_REFERENCE.md §Batch 7 "Promo Codes"
/* Promo Codes — the list behind the "Promo Code" button on Bonus Programs.

   Built Aug 2026 to close a real gap: the button existed in the prototype but
   the feature had never been extracted from isystem, so it had nowhere to go.
   Batch 7 of docs/ISYSTEM_REFERENCE.md is the extraction; everything here
   traces to it.

   Faithful absences (the real screen has none of these — do not add them):
   no export, no bulk actions, no sortable columns. The order is fixed
   `created_at desc` and the pager is Laravel's `paginate(20)`.

   Known-bug policy (CLAUDE.md): the real Status filter reads the raw
   `status` enum while the Status *column* is derived from `status` +
   `expires_at`, so filtering "Active" also returns expired rows. This
   implements the evident intent — Active excludes expired, and Expired is
   selectable — and says so on screen.
   <!-- SUGGESTION: on the platform, make the Active branch add
        `expires_at IS NULL OR expires_at > NOW()` and add an Expired option. -->
   <!-- SUGGESTION: scope index/edit/update/destroy to the actor's skins —
        today any admin past the route middleware can edit another skin's code
        by id (no ownership assertion anywhere in the controller). --> */

const { useState: hpcUseState, useMemo: hpcUseMemo } = React;

/* Enum values are the DB ENUMs verbatim (promo_codes.type / .status). */
const HPC_TYPES = [
  { value: "cash", label: "Cash" },
  { value: "bonus", label: "Bonus" },
];
const HPC_STATUSES = [
  { value: "active", label: "Active" },
  { value: "inactive", label: "Inactive" },
];

/* PromoCode::generateCode() — strtoupper(Str::random(8)), retried until unique.
   Str::random uses the full alphanumeric alphabet; uppercasing it means digits
   stay and lowercase letters fold onto their uppercase twin. */
const HPC_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const hpcGenerateCode = (taken, rand) => {
  const r = rand || Math.random;
  for (let attempt = 0; attempt < 50; attempt++) {
    let code = "";
    for (let i = 0; i < 8; i++) code += HPC_ALPHABET[Math.floor(r() * HPC_ALPHABET.length)];
    if (!taken || !taken.has(code)) return code;
  }
  return "";
};

const hpcP2 = (n) => String(n).padStart(2, "0");
/* The list prints `Y-m-d H:i`; the form edits a `datetime-local` value. */
const hpcDateTime = (ts) => {
  if (!ts) return "Never";
  const d = new Date(ts);
  return `${d.getUTCFullYear()}-${hpcP2(d.getUTCMonth() + 1)}-${hpcP2(d.getUTCDate())} ${hpcP2(d.getUTCHours())}:${hpcP2(d.getUTCMinutes())}`;
};
const hpcToLocalInput = (ts) => (ts ? hpcDateTime(ts).replace(" ", "T") : "");
const hpcFromLocalInput = (s) => (s ? Date.parse(s + ":00Z") : null);
/* number_format($cash_amount, 2) */
const hpcMoney = (n) => Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });

/* Bonus programs the type=bonus dropdown offers. The real source is
   BonusProgram::where('status', STATUS_ACTIVE)->when(skin)->orderBy('name')
   selecting id, name, skin_id, bonus_type — mirrored in shape here. */
const HPC_BONUS_TYPES = ["deposit_match", "free_spins", "cashback", "bet_based", "registration"];
const HPC_PROGRAM_NAMES = [
  "300% Welcome Bonus Casino", "First Deposit 150 Percent Match", "Meet - Third Deposit Cashback",
  "2nd deposit", "bet based", "Weekend Reload", "Sport Free Bet", "Casino Cashback 10%",
];
/* ------------------------------------------------------------------ *
 * THE DATA. Both the codes and the bonus programmes they grant were
 * generated.
 *
 * `hpcSeedRows()` built thirty-four codes from a PRNG: a title from a list of
 * twenty, an eight-character code, a cash amount between 5 and 500, a max-claims
 * value from six options, and — the one that mattered — `claims_count`, a random
 * integer between 0 and the maximum. A promo code showing "213 of 250 claimed"
 * is a statement about a campaign, and it was `floor(rng() * (maxClaims + 1))`.
 *
 * `hpcBonusPrograms` invented the programmes themselves: two to four per brand,
 * each with a name picked from a list and a bonus type picked at random. The
 * bonus-type picker on the form was choosing between made-up instruments.
 *
 * Both are read now. `claims_count` is a column the write allowlist deliberately
 * excludes (042: "a writable copy is a code claimable more times than it was
 * configured for"), so it is displayed and never sent.
 * ------------------------------------------------------------------ */

const HPC_FETCH_MAX = 500;

const hpcRowFromDb = (r) => ({
  id: r.id,
  title: r.title || "",
  code: r.code || "",
  skin_id: r.skin_id,
  skin: r.skin ? r.skin.name : String(r.skin_id),
  type: r.type || "cash",
  cash_amount: r.cash_amount == null ? null : Number(r.cash_amount),
  bonus_program_id: r.bonus_program_id,
  max_claims: r.max_claims == null ? null : Number(r.max_claims),
  /* THE REAL COUNT. `claims_count` is the stored column; `claims(count)` is the
     live count of promo_code_claims rows. They should agree, and where they do
     not the ROW is what the redemption path enforces against — so that is what
     is shown, with the derived count available to notice a drift. */
  claims_count: r.claims_count == null ? 0 : Number(r.claims_count),
  _claimRows: Array.isArray(r.claims) && r.claims[0] ? Number(r.claims[0].count) || 0 : 0,
  expires_at: r.expires_at ? new Date(r.expires_at).getTime() : null,
  status: r.status || "inactive",
  created_at: r.created_at ? new Date(r.created_at).getTime() : 0,
});

const hpcUseDb = () => {
  const feed = useHrsFetch(() => window.sb.list("promoCodes", { limit: HPC_FETCH_MAX }), []);
  const skinsFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const progFeed = useHrsFetch(() => window.sb.list("bonusPrograms", { limit: 500 }), []);

  const rows = hpcUseMemo(() => (feed.data || []).map(hpcRowFromDb), [feed.data]);
  const skins = hpcUseMemo(
    () => (skinsFeed.data || []).map(k => ({ id: k.id, name: k.name })), [skinsFeed.data]);
  const programs = hpcUseMemo(
    () => (progFeed.data || []).map(p => ({
      id: p.id, name: p.name, skin_id: p.skin_id, bonus_type: p.bonus_type,
    })), [progFeed.data]);

  const all = [feed, skinsFeed, progFeed];
  return {
    rows, skins, programs,
    truncated: feed.meta && feed.meta.total > rows.length,
    loading: all.some(f => f.loading),
    error: all.map(f => f.error).find(Boolean) || null,
    retry: () => all.forEach(f => f.retry()),
    feeds: [feed],
  };
};

/* PromoCode::isExpired() */
const hpcIsExpired = (r, now) => !!(r.expires_at && r.expires_at < now);
/* The blade's three-way badge, which is NOT the stored enum. */
const hpcDerivedStatus = (r, now) =>
  r.status === "active" && !hpcIsExpired(r, now) ? "Active"
    : hpcIsExpired(r, now) ? "Expired"
      : "Inactive";
const HPC_STATUS_CHIP = { Active: "chip--ok", Expired: "chip--neutral", Inactive: "chip--warn" };

const hpcProgramName = (programs, id) => {
  const p = (programs || []).find(x => String(x.id) === String(id));
  return p ? p.name : "-";
};

/* ==================================================================
   Create / Edit form — GET …/create | …/{id}/edit, POST | PUT
   ================================================================== */
const HpcForm = ({ row, skins, programs: allPrograms, takenCodes, busy, onCancel, onSave }) => {
  const isNew = !row;
  const [d, setD] = hpcUseState(() => ({
    title: row ? row.title : "",
    code: row ? row.code : hpcGenerateCode(takenCodes),
    /* skin_id, not a brand NAME. The form used to carry the name because
       window.CG_SKINS_ALL was a list of names; the database keys on the id, and
       a bonus programme is scoped by skin_id too. */
    skin_id: row ? String(row.skin_id) : (skins[0] ? String(skins[0].id) : ""),
    expires_at: row ? hpcToLocalInput(row.expires_at) : "",
    max_claims: row ? String(row.max_claims) : "100",
    type: row ? row.type : "cash",
    cash_amount: row && row.cash_amount != null ? String(row.cash_amount) : "",
    bonus_program_id: row && row.bonus_program_id != null ? String(row.bonus_program_id) : "",
    status: row ? row.status : "active",
  }));
  const set = (k, v) => setD(x => ({ ...x, [k]: v }));
  const [errors, setErrors] = hpcUseState({});

  /* ajaxBonusPrograms — the skin dropdown's change handler refetches the
     active programs for that skin. Here the same set is filtered locally. */
  /* Programmes are scoped to the chosen brand — a bonus programme belongs to
     one skin, and offering another brand's would create a code that cannot be
     redeemed. Was `p.skin === d.skin` over an invented list keyed by NAME; it
     is skin_id against real rows now. */
  const programs = hpcUseMemo(
    () => (allPrograms || []).filter(p => String(p.skin_id) === String(d.skin_id)),
    [allPrograms, d.skin_id]);

  const validate = () => {
    const e = {};
    const code = d.code.trim().toUpperCase();
    if (!d.title.trim()) e.title = "The title field is required.";
    else if (d.title.length > 255) e.title = "The title may not be greater than 255 characters.";
    if (!code) e.code = "The code field is required.";
    else if (code.length > 32) e.code = "The code may not be greater than 32 characters.";
    else if (takenCodes.has(code) && (!row || row.code !== code)) e.code = "The code has already been taken.";
    if (!d.skin_id) e.skin_id = "The skin field is required.";
    const max = Number(d.max_claims);
    if (!d.max_claims || !Number.isInteger(max) || max < 1) e.max_claims = "The max claims must be an integer of at least 1.";
    /* after:now applies on create only — the real update rules drop it
       (PromoCodeController.php:123 vs :81). Divergence kept, flagged below. */
    if (d.expires_at && isNew && hpcFromLocalInput(d.expires_at) <= Date.now()) {
      e.expires_at = "The expiration date must be a date after now.";
    }
    if (d.type === "cash") {
      const amt = Number(d.cash_amount);
      if (!d.cash_amount || !(amt >= 0.01)) e.cash_amount = "The cash amount is required and must be at least 0.01.";
    } else if (!d.bonus_program_id) {
      e.bonus_program_id = "The bonus program field is required when type is bonus.";
    }
    setErrors(e);
    return Object.keys(e).length === 0;
  };

  const submit = () => {
    if (!validate()) {
      window.hrsToast && window.hrsToast("Fix the highlighted fields", "The form was not submitted.");
      return;
    }
    /* Server-side mutual exclusion, applied after validation
       (PromoCodeController.php:87-91 / :130-134). */
    const cash = d.type === "cash";
    onSave({
      ...(row || {}),
      title: d.title.trim(),
      code: d.code.trim().toUpperCase(),
      skin_id: d.skin_id,
      type: d.type,
      cash_amount: cash ? Number(d.cash_amount) : null,
      bonus_program_id: cash ? null : Number(d.bonus_program_id),
      max_claims: Number(d.max_claims),
      expires_at: hpcFromLocalInput(d.expires_at),
      status: d.status,
    });
  };

  const field = (key, label, node, required, hint) => (
    <div className="hpc-field">
      <label className="hpc-field__l">{label}{required && <span className="hpc-req"> *</span>}</label>
      <div className="hpc-field__c">
        {node}
        {errors[key] && <div className="hpc-err">{errors[key]}</div>}
        {!errors[key] && hint && <div className="hpc-hint">{hint}</div>}
      </div>
    </div>
  );

  return (
    <div className="hpc-formwrap">
      <button className="btn btn--ghost btn--sm hpc-back" onClick={onCancel}>
        <Icon name="chevron_left" size={13} /> All promo codes
      </button>

      <div className="panel hpc-form">
        <div className="section__head">
          <div className="section__title">
            {isNew ? "New Promo Code" : `Edit ${row.code}`}
            <Tip>
              {isNew
                ? <>POSTs to <code>admin.bonus.promo-codes.store</code>. The code is prefilled by <code>PromoCode::generateCode()</code> — 8 uppercase alphanumerics, retried until unique.</>
                : <>PUTs to <code>admin.bonus.promo-codes.update</code>. Note the real platform drops the <code>after:now</code> rule on update, so an existing code can be saved with a past expiry — which the list then shows as <b>Expired</b>.</>}
            </Tip>
          </div>
        </div>

        <div className="hpc-form__body">
          {field("title", "Title",
            <input className="input" value={d.title} maxLength={255}
              placeholder="e.g. Welcome Bonus 100 ARS"
              onChange={e => set("title", e.target.value)} />, true)}

          {field("code", "Code",
            <div className="hpc-codewrap">
              <input className="input hpc-code" value={d.code} maxLength={32}
                onChange={e => set("code", e.target.value.toUpperCase())} />
              <button className="btn btn--secondary btn--sm" type="button"
                onClick={() => set("code", hpcGenerateCode(takenCodes))}>
                <Icon name="refresh" size={12} /> Generate
              </button>
            </div>, true, "Max 32 characters, unique across every skin.")}

          {field("skin_id", "Skin",
            <select className="select" value={d.skin_id} onChange={e => { set("skin_id", e.target.value); set("bonus_program_id", ""); }}>
              <option value="">Select Skin</option>
              {skins.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
            </select>, true,
            "Scoped to the skins your account can see (Auth::user()->getSkins()).")}

          {field("expires_at", "Expiration date",
            <input className="input" type="datetime-local" value={d.expires_at}
              onChange={e => set("expires_at", e.target.value)} />, false,
            "Leave empty for a code that never expires.")}

          {field("max_claims", "Max claims",
            <input className="input" type="number" min={1} value={d.max_claims}
              onChange={e => set("max_claims", e.target.value)} />, true,
            "One claim per player is enforced by a UNIQUE (promo_code_id, user_id) on promo_code_claims.")}

          {field("type", "Type",
            <div className="hpc-radios">
              {HPC_TYPES.map(t => (
                <label key={t.value} className={`hpc-radio${d.type === t.value ? " hpc-radio--on" : ""}`}>
                  <input type="radio" name="hpc-type" checked={d.type === t.value}
                    onChange={() => set("type", t.value)} />
                  {t.label}
                </label>
              ))}
            </div>, true,
            "Cash and Bonus are mutually exclusive — the server nulls whichever field the other type owns.")}

          {d.type === "cash"
            ? field("cash_amount", "Cash amount",
              <input className="input" type="number" min={0.01} step={0.01} value={d.cash_amount}
                placeholder="e.g. 100.00" onChange={e => set("cash_amount", e.target.value)} />, true)
            : field("bonus_program_id", "Bonus program",
              <select className="select" value={d.bonus_program_id}
                onChange={e => set("bonus_program_id", e.target.value)}>
                <option value="">Select Bonus Program</option>
                {programs.map(p => (
                  <option key={p.id} value={p.id}>{p.name} ({p.bonus_type.replace(/_/g, " ")})</option>
                ))}
              </select>, true,
              programs.length ? "Active programs on the selected skin." : "This skin has no active bonus programs.")}

          {field("status", "Status",
            <select className="select" value={d.status} onChange={e => set("status", e.target.value)}>
              {HPC_STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
            </select>, true)}
        </div>

        <div className="hpc-form__foot">
          <button className="btn btn--ghost" onClick={onCancel} disabled={busy}>Cancel</button>
          {/* Disabled while the write is in flight — a second click would attempt
              a second code with the same string and hit the unique index. */}
          <button className="btn btn--primary" onClick={submit} disabled={busy}>
            <Icon name="check" size={13} /> {busy ? "Saving…" : (isNew ? "Create promo code" : "Save changes")}
          </button>
        </div>
      </div>
    </div>
  );
};

/* ==================================================================
   List — GET /admin/bonus/promo-codes
   ================================================================== */
const HostPromoCodes = () => {
  window.useLocale && window.useLocale();
  const now = Date.now();
  const { rows, skins, programs, truncated, loading, error, retry, feeds } = hpcUseDb();
  const saver = useHrsSave(feeds);
  const [view, setView] = hpcUseState({ mode: "list", row: null });

  /* The real filter bar is a plain GET form: skin_id submits on change, the
     rest apply on "Filter". Kept as apply-on-search to match. */
  const FIELDS = [
    /* Was `window.CG_SKINS_ALL` — a list of brand NAMES on the window object,
       which is also what the rows were filtered by. Ids now, so the filter and
       the column agree with the database rather than with each other. */
    { key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "All Skins",
      options: skins.map(k => ({ value: String(k.id), label: k.name })) },
    { key: "status", label: "Status", type: "select", icon: "toggle_left", placeholder: "All",
      options: [...HPC_STATUSES, { value: "expired", label: "Expired" }],
      tip: <>Divergence, deliberate: the real filter reads the raw <code>status</code> enum, so choosing <b>Active</b> there also returns rows the list is simultaneously badging <b>Expired</b>. Here Active excludes expired and Expired is selectable — the evident intent.</> },
    { key: "type", label: "Type", type: "select", icon: "tag", placeholder: "All", options: HPC_TYPES },
    { key: "search", label: "Search", type: "text", icon: "search", placeholder: "Title or code…", grow: true,
      tip: <>Server-side this is one <code>LIKE %…%</code> over <code>title</code> OR <code>code</code>.</> },
  ];
  const blank = { skin: "", status: "", type: "", search: "" };
  const [draft, setDraft] = hpcUseState(blank);
  const [applied, setApplied] = hpcUseState(blank);
  const [page, setPage] = hpcUseState(0);
  const [pageSize, setPageSize] = hpcUseState(20);   // paginate(20)

  const filtered = hpcUseMemo(() => {
    const q = applied.search.trim().toLowerCase();
    return rows.filter(r => {
      if (applied.skin && String(r.skin_id) !== String(applied.skin)) return false;
      if (applied.type && r.type !== applied.type) return false;
      if (applied.status) {
        const derived = hpcDerivedStatus(r, now);
        if (applied.status === "expired" && derived !== "Expired") return false;
        if (applied.status === "active" && derived !== "Active") return false;
        if (applied.status === "inactive" && derived !== "Inactive") return false;
      }
      if (q && !(r.title.toLowerCase().includes(q) || r.code.toLowerCase().includes(q))) return false;
      return true;
    });
  }, [rows, applied, now]);

  const pageRows = filtered.slice(page * pageSize, page * pageSize + pageSize);
  const takenCodes = hpcUseMemo(() => new Set(rows.map(r => r.code)), [rows]);

  const save = (draftRow) => saver.run(async () => {
    const creating = !draftRow.id;
    const body = {
      title: draftRow.title,
      code: draftRow.code,
      type: draftRow.type,
      /* One of the two is always null: a cash code has no programme and a bonus
         code has no amount. Sending both would store a code that is two things. */
      cash_amount: draftRow.type === "cash" ? Number(draftRow.cash_amount) : null,
      bonus_program_id: draftRow.type === "bonus" ? Number(draftRow.bonus_program_id) : null,
      max_claims: draftRow.max_claims == null || draftRow.max_claims === "" ? null : Number(draftRow.max_claims),
      expires_at: draftRow.expires_at ? new Date(draftRow.expires_at).toISOString() : null,
      status: draftRow.status,
      /* `claims_count` is NEVER sent. 042 keeps it out of the write allowlist —
         it counts rows in promo_code_claims, and a writable copy is a code
         claimable more times than it was configured for. app_write would refuse
         it; not sending it means the refusal never has to happen. */
    };
    if (creating) body.skin_id = Number(draftRow.skin_id);

    const res = creating
      ? await window.sb.create("promoCodes", body)
      : await window.sb.update("promoCodes", draftRow.id, body);
    if (!res || !res.ok) {
      return { ok: false, error: { kind: "server",
        message: (res && res.error && res.error.message) || "The promo code was refused." } };
    }
    return { ok: true, data: res.data, meta: res.meta || {} };
  }, {
    done: draftRow.id ? `Updated ${draftRow.code}` : `Created ${draftRow.code}`,
    fail: draftRow.id ? `${draftRow.code} was not updated` : `${draftRow.code} was not created`,
  }).then(res => { if (res && res.ok) setView({ mode: "list", row: null }); return res; });

  /* SOFT delete: promo_codes carries deleted_at, so the row and every
     promo_code_claims row under it survive. That matters more here than
     elsewhere — a claim is a player having received something, and a delete
     that took the claims with it would erase the record of what was given. */
  const remove = (r) => {
    /* The real delete is a POST form with @csrf behind a native confirm(). */
    if (!window.confirm("Delete this promo code?")) return;
    saver.run(() => window.sb.remove("promoCodes", r.id), {
      done: `Deleted ${r.code}`,
      fail: `${r.code} was not deleted`,
    });
  };

  if (view.mode !== "list") {
    return (
      <HrsShell title={view.row ? "Edit Promo Code" : "New Promo Code"}
        subtitle="Bonus ▸ Promo codes"
        gate="admin route group (auth · admin · 2fa · g2fa)"
        gateNote={<>The controller adds no <code>checkUserBoPerm</code> of its own.</>}>
        <HpcForm row={view.row} skins={skins} programs={programs} takenCodes={takenCodes} busy={saver.busy}
          onCancel={() => setView({ mode: "list", row: null })} onSave={save} />
      </HrsShell>
    );
  }

  const columns = [
    { key: "id", label: "ID", width: 64 },
    { key: "code", label: "Code", render: r => <code className="hpc-codecell">{r.code}</code> },
    { key: "title", label: "Title", render: r => <b>{r.title}</b> },
    { key: "skin", label: "Skin" },
    { key: "type", label: "Type", render: r => (
      <span className={`chip ${r.type === "cash" ? "chip--ok" : "chip--info"}`}>
        {r.type === "cash" ? "Cash" : "Bonus"}
      </span>) },
    { key: "reward", label: "Reward", align: "right",
      render: r => (r.type === "cash" ? hpcMoney(r.cash_amount) : hpcProgramName(programs, r.bonus_program_id)) },
    { key: "claims", label: "Claims", align: "right",
      render: r => <span className={r.claims_count >= r.max_claims ? "hpc-claims hpc-claims--full" : "hpc-claims"}>
        {r.claims_count} / {r.max_claims}</span> },
    { key: "status", label: "Status", render: r => {
      const s = hpcDerivedStatus(r, now);
      return <span className={`chip ${HPC_STATUS_CHIP[s]}`}>{s}</span>;
    } },
    { key: "expires_at", label: "Expires", render: r => hpcDateTime(r.expires_at) },
    { key: "actions", label: "Actions", align: "right", width: 120, render: r => (
      <div className="hpc-acts">
        <button className="hpc-act" title="Edit" onClick={() => setView({ mode: "form", row: r })}>
          <Icon name="edit" size={14} />
        </button>
        <button className="hpc-act hpc-act--danger" title="Delete" onClick={() => remove(r)}>
          <Icon name="trash" size={14} />
        </button>
      </div>) },
  ];

  const renderCard = (r) => {
    const s = hpcDerivedStatus(r, now);
    return (
      <>
        <div className="hpc-card__top">
          <code className="hpc-codecell">{r.code}</code>
          <span className={`chip ${HPC_STATUS_CHIP[s]}`}>{s}</span>
        </div>
        <div className="hpc-card__title">{r.title}</div>
        <div className="hpc-card__meta">
          <span>{r.skin}</span>
          <span>{r.type === "cash" ? `Cash ${hpcMoney(r.cash_amount)}` : hpcProgramName(programs, r.bonus_program_id)}</span>
          <span>{r.claims_count} / {r.max_claims} claims</span>
        </div>
        <details className="hpc-card__more">
          <summary>Details</summary>
          <div><b>ID</b> {r.id}</div>
          <div><b>Expires</b> {hpcDateTime(r.expires_at)}</div>
          <div className="hpc-acts">
            <button className="hpc-act" onClick={() => setView({ mode: "form", row: r })}><Icon name="edit" size={14} /> Edit</button>
            <button className="hpc-act hpc-act--danger" onClick={() => remove(r)}><Icon name="trash" size={14} /> Delete</button>
          </div>
        </details>
      </>
    );
  };

  return (
    <HrsShell
      title="Promo Codes"
      subtitle="Bonus ▸ Promo codes"
      gate="admin route group (auth · admin · 2fa · g2fa)"
      gateNote={<>The controller adds no <code>checkUserBoPerm</code> of its own, and never
        asserts skin ownership on a row — any admin past the middleware can edit or delete
        another skin's code by id. Recorded as a SUGGESTION in the file header.</>}
      explainer={{
        title: "What a promo code does",
        bullets: [
          "A player redeems the code once — UNIQUE (promo_code_id, user_id) on promo_code_claims makes that a database guarantee, not a check.",
          "Cash codes credit cash_amount directly; Bonus codes start an instance of the linked bonus program (which must be Active and on the same skin).",
          "The Status column is derived, not stored: Active means status=active AND not past expires_at. A code past its expiry reads Expired however the enum is set.",
          "claims_count is denormalised and only the claim path maintains it — nothing in this admin can reconcile it against promo_code_claims.",
        ],
      }}
      actions={
        <>
          <button className="rpt-btn rpt-btn--export" onClick={() => setView({ mode: "form", row: null })}>
            <Icon name="plus" size={13} /> New Promo Code
          </button>
          <button className="rpt-btn rpt-btn--blue"
            onClick={() => window.goRoute && window.goRoute("bonus-programs")}>
            <Icon name="chevron_left" size={13} /> Bonus Programs
          </button>
        </>
      }>

      <HrsFilters
        fields={FIELDS}
        values={draft}
        onChange={(k, v) => setDraft(x => ({ ...x, [k]: v }))}
        onSearch={(v) => { setApplied(v); setPage(0); }}
        onReset={() => { setDraft(blank); setApplied(blank); setPage(0); }}
        resultLabel={`${filtered.length} of ${rows.length}`}
      />

      {/* No export and no sortable columns: the real screen has neither, and
          the order is fixed created_at desc. */}
      {truncated && (
        <div className="hpc-warn">
          <Icon name="alert" size={13} /> There are more promo codes than the {HPC_FETCH_MAX} this
          screen fetches. The list and the search below cover the fetched rows only.
        </div>
      )}

      {loading ? <HrsSkeleton rows={6} cols={8} /> : error ? <HrsError error={error} onRetry={retry} /> : (
      <HrsTable
        columns={columns}
        rows={pageRows}
        rowKey={r => r.id}
        renderCard={renderCard}
        empty="No promo codes found."
      />

      )}

      <HrsPager page={page} pageSize={pageSize} total={filtered.length}
        onPage={setPage} onPageSize={(n) => { setPageSize(n); setPage(0); }}
        sizes={[10, 20, 50, 100]} />
    </HrsShell>
  );
};

window.HostPromoCodes = HostPromoCodes;
window.HpcForm = HpcForm;
