// Represents: GET /gamecategories · GameCategoriesController; GET /gamesubcategories · GameSubcategoriesController; GET /gamelabels · GameLabelsController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "Game cat/subcat/labels"
/* CMS ▾ → Game categories · Game subcategories · Game Labels.
   Three sibling taxonomy screens that share ONE modal-CRUD pattern, so they share one file.
   It carries the GameCategories / GameSubcategories / GameLabels globals that used to live in the
   legacy src/pages/HostCmsGames.jsx bundle; that bundle has since been split apart entirely
   (Game import + Providers → HostCmsGamesImport.jsx, OAuth Clients → HostCmsOAuth.jsx) and no
   longer exists. Babel makes every top-level const an implicit window global and the last loader
   wins, so load order still matters: this file must come after anything that defines the same
   three names.

   ── Routes (all unnamed, all inside Route::name('admin.')->middleware(['auth','admin','2fa','g2fa']),
      routes/admin.php:25, itself inside the outer ['admin','adminsettings'] group at L15) ──
     GET  /gamecategories/                    L1208  GameCategoriesController::index          L14
     GET  /gamecategories/getCategoriesTable  L1212  ::getCategoriesTable                     L47   (legacy DataTables JSON — dead UI)
     GET  /gamecategories/form/               L1216  ::gamecategoryForm                       L143
     POST /gamecategories/saveCategory/       L1220  ::saveGamecategory                       L170
     GET  /gamecategories/delete/{id}/        L1593  ::delete                                 L219
     GET  /gamesubcategories/                     L1226  GameSubcategoriesController::index    L18
     GET  /gamesubcategories/getSubcategoriesTable L1230 ::getSubcategoriesTable               L88   (legacy — dead UI)
     GET  /gamesubcategories/form/                L1234  ::gameSubcategoryForm                 L261  (route says @gamesubcategoryForm; PHP resolves method names case-insensitively)
     POST /gamesubcategories/saveSubcategory/     L1238  ::saveGamesubcategory                 L281
     GET  /gamesubcategories/delete/{id}/         L1596  ::delete                              L529
     GET  /gamelabels/                    L1244  GameLabelsController::index    L18
     GET  /gamelabels/getLabelsTable      L1248  ::getLabelsTable               L75   (legacy — dead UI)
     GET  /gamelabels/form/               L1252  ::gameLabelForm                L210
     POST /gamelabels/savelabel/          L1256  ::saveGamelabel                L230  (URI/method name mismatch is legacy, kept)
     GET  /gamelabels/delete/{id}/        L1599  ::delete                       L447

   ── Views ── admin/{gamecategories,gamesubcategories,gamelabels}/index.blade.php + _paybo-head
      partial + modals/<entity>.blade.php (generaModalGestione → admin/utils/modal.blade.php) +
      forms/<entity>.blade.php. Shared mechanic: the open function GETs /…/form (with ?id= for
      edits) into the modal body; the save function serialises the form as FormData and POSTs to
      the form action; `ajaxError` answers {message:[…], params:{campierrati:[fields]}} and the
      shared JS paints .is-invalid on every named field it lists — reproduced here as the banner +
      red-outlined fields. Deletes are plain GETs behind a JS confirm().

   ── Permission model (identical on all three) ──
      index()  → isAdmin() else abort(404): SUPER_ADMIN / user_level 0 only.
      delete() → isadmin() else ajaxError('Permission error').
      getXxxTable / xxxForm / saveXxx → NO role check beyond the route-group middleware.
      Skin feature flags: none on any of the three.

   ── Faithful to the real screens, deliberately NOT added ──
      · No sorting. All three index actions are hard-ordered `id DESC`; the only sortable feed is
        the legacy getXxxTable DataTables endpoint whose target table no longer exists in the view
        (public/js/pages/gamecategories/ajax.js is not even included). Column-header Tips say so.
      · No export (none exists), no bulk actions (n/a), no KPI strip beyond the filter-hero
        "Results" card the real pages show.
      · No `stato` (Disabled/Active) column and no `type` (default/slick) picker: `stati()` and
        `tipologieGamecategories()/…Gamesubcategories()/…Gamelabels()` exist in all three
        controllers but are never surfaced on these screens.
      · No `priority` / `slug` / `img` inputs on the LABEL form — they are on GameLabel::$fillable
        but appear in neither the form nor the save path.
      · No page-size selector: the real pages are `paginate(25)` with Prev/Next only.

   ── Known real-platform defects, per the repo's known-bug policy (CLAUDE.md) ──
      · DIVERGENCE — category create: saveGamecategory answers `params => $id` where $id is still
        the *empty* request id (controller L207-210), so the modal callback never learns the new
        row's id. Evident intent implemented here: the created id is returned and reported.
      · Silent fix — forms/gamelabel.blade.php gives the Skins field a `<label for="tags_list">`
        pointing at an element that does not exist in that form. Labels here point at their own
        control. Cosmetic markup slip, not behaviour.
      · Faithfully KEPT (not "fixed"), because they are what the operator must be warned about:
        the no-cascade deletes and the empty-skin-selection semantics — see the two blocks below.

   ── The two footguns this rebuild refuses to hide ──
      1. EMPTY SKIN SELECTION = ALL SKINS. `selectSkins[]` is diff-synced into skins_subcategories /
         skins_labels; zero rows is not "hidden everywhere", it is "visible on every brand". So
         un-ticking the last skin silently publishes the row to the whole white-label estate.
         Surfaced here in the picker banner, the list's `All` chip, the Explainer and the save toast.
      2. DELETES ORPHAN THEIR CHILDREN. Category delete removes the gamecategories row only —
         subcategories keep a dangling category_id. Subcategory delete clears gamesubcategories_assoc
         but leaves skins_subcategories + gamesubcategories_skin_assoc; label delete clears
         gamelabels_assoc but leaves skins_labels + gamelabels_skin_assoc. The confirm dialog names
         the exact rows each delete leaves behind.

   <!-- SUGGESTION: make "all skins" an explicit choice. Today an empty selectSkins[] means "publish to every skin", so removing the last skin from a subcategory/label silently pushes it to all 16 brands — the same gesture an operator would use to unpublish it. Add an explicit "All skins" radio (all / selected) and treat an empty selected-list as a validation error. -->
   <!-- SUGGESTION: block or cascade the category delete. GameCategoriesController::delete removes the gamecategories row with no cascade and no guard, leaving every gamesubcategories row that referenced it with a dangling category_id and an empty Category cell (LEFT JOIN → NULL). Either refuse the delete while subcategories reference it, or reassign/delete them in the same transaction. -->
   <!-- SUGGESTION: delete the skin-association rows with their parent. GameSubcategoriesController::delete clears gamesubcategories_assoc but not skins_subcategories / gamesubcategories_skin_assoc; GameLabelsController::delete clears gamelabels_assoc but not skins_labels / gamelabels_skin_assoc. Both leave permanently unreachable rows keyed to a deleted id. -->
   <!-- SUGGESTION: gate the CRUD endpoints. index() aborts 404 unless isadmin(), but getCategoriesTable / gamecategoryForm / saveGamecategory (and the subcategory + label equivalents) carry no role check at all, so any authenticated 2FA'd back-office user who knows the URL can list, create and edit game taxonomy. The 404 on the index page protects nothing. -->
   <!-- SUGGESTION: deletes are GET requests behind a JS confirm() — a link-prefetcher, a crawler or a copied URL can destroy a row with no CSRF token involved. Move all three to POST/DELETE with @csrf. -->
   <!-- SUGGESTION: re-enable server-side image validation on the subcategory form. The BulletProof type/size check is commented out (controller L335-342), so the img field accepts whatever the client sends despite the accept=".png,.jpg,.jpeg" hint. -->
   <!-- SUGGESTION: `priority` on the subcategory form runs through an empty `if` (controller L316-317) — a dead validation branch. Either validate it as an integer or drop the branch. -->
   <!-- SUGGESTION: ship the missing en translation keys — game_labels, internal_name, public_name, img, search, results, apply, showing, prev, next, no_records, delete. They currently render as raw `backend.*` strings unless the gitignored storage/lang overrides them; every label marked "label inferred" in this file is one of them. --> */

const { useState: hgtUseState, useMemo: hgtUseMemo } = React;

/* Cross-page navigation — the pushState + PopStateEvent convention used by HostDashboard /
   HostReportNetWin. The real screens' header shortcut buttons are bare hrefs
   (/gamesubcategories/ on the categories page, /gamecategories/ on the subcategories page). */
const hgtNavTo = (routeId, label) => {
  try {
    const path = window.pathForActive && window.pathForActive(routeId);
    if (path) {
      if (window.location.pathname !== path) window.history.pushState({ active: routeId }, "", path);
      window.dispatchEvent(new PopStateEvent("popstate"));
      return;
    }
  } catch (_e) { /* fall through */ }
  hrsToast(label, "No prototype page is registered for this route yet.");
};

/* `slug` is never an input: saveGamecategory always regenerates it with altnome($request->name)
   (utils.php:115 — transliterate + slugify). UNCLEAR: the extraction does not record altnome's
   exact separator, so this stand-in uses the conventional hyphen. */
const hgtSlug = (s) => String(s || "")
  .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
  .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");

const hgtNorm = (s) => String(s == null ? "" : s).toLowerCase();

/* SkinsController::getSkinsList() — every skin, ordered by name. Names are kept byte-for-byte
   identical to the CG_SKINS_ALL list the legacy HostCmsGames.jsx bundle used, so the same estate is
   described across the CMS screens; ids are mock but stable. */
const HGT_SKINS = [
  { id: 99, name: "1xway - test skin" },
  { id: 41, name: "7lucky" },
  { id: 33, name: "AcarayBets" },
  { id: 45, name: "Anchodeespada" },
  { id: 37, name: "apostando365" },
  { id: 21, name: "apuestadepana" },
  { id: 27, name: "ApuestaVip" },
  { id: 39, name: "ArgenSlots" },
  { id: 43, name: "Casino166" },
  { id: 25, name: "Clubn1" },
  { id: 31, name: "Donjoker" },
  { id: 29, name: "Jokerenvivo" },
  { id: 23, name: "Juegojoker" },
  { id: 19, name: "Jugaygana" },
  { id: 35, name: "PlaySpin" },
  { id: 17, name: "Tucasino" },
];
const hgtSkinId = (name) => { const s = HGT_SKINS.find(x => x.name === name); return s ? s.id : 0; };
const hgtSkinName = (id) => { const s = HGT_SKINS.find(x => x.id === Number(id)); return s ? s.name : `#${id}`; };
const hgtSkinIds = (names) => (names || []).map(hgtSkinId);

/* ------------------------------------------------------------------ *
 * Seeds. Literal (not random) so every load renders the identical list.
 * ------------------------------------------------------------------ */

/* `gamecategories`. Ids 1/2/4/5/6 are the well-known model constants
   (GameCategory::CASINO / CASINO_LIVE / VIRTUAL / POKER / SPORT); 9 and 11 are ordinary
   operator-created rows. The list is served ORDER BY id DESC. */
/* ---------- the row sources -----------------------------------------------
   Was three hand-written seed arrays plus a module-level HGT_STORE that the
   screens mutated and read back, so an edit "persisted" while the tab stayed
   open. That store is gone with them: three views over ONE database is exactly
   what the database already is, and faking it in a module variable is how a
   deletion looked real until reload.

   game_categories · game_subcategories · game_labels, all live. */
const hgtCatRow = (c) => ({ id: c.id, name: c.name, slug: c.slug });

const hgtSubRow = (sc) => ({
  id: sc.id,
  internal: sc.internal_name,
  pub: sc.name,
  cat: sc.category_id,
  skins: [],            // per-skin visibility lives in skin_subcategories; not joined here yet
  tags: [],             // tags column is text[]; the editor writes it, nothing reads it back yet
  priority: sc.priority,
  featured: !!sc.featured,
  img: !!sc.image_url,
});

const hgtLabelRow = (l) => ({
  id: l.id,
  internal: l.internal_name,
  pub: l.name,
  skins: [],            // skin_labels, not joined here yet
  priority: l.priority,
  img: !!l.image_url,
});

const HGT_PAGE_SIZE = 25;   // paginate(25) on all three index actions — no length menu exists

/* ------------------------------------------------------------------ *
 * Shared chrome
 * ------------------------------------------------------------------ */

/* The real modal is generaModalGestione() → admin/utils/modal.blade.php (modal-xl shell, body
   AJAX-loaded from /…/form, footer Close + Save). Full-screen on mobile per brief §11. */
const HgtModal = ({ title, sub, onClose, children, footer, wide }) => (
  <div className="bp-modal-scrim hgt-scrim" onClick={onClose}>
    <div className={`bp-modal hgt-modal${wide ? " hgt-modal--wide" : ""}`} onClick={e => e.stopPropagation()}>
      <div className="hgt-modal__head">
        <div>
          <div className="hgt-modal__title">{title}</div>
          {sub && <div className="hgt-modal__sub">{sub}</div>}
        </div>
        <button className="hgt-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hgt-modal__body">{children}</div>
      {footer && <div className="hgt-modal__foot">{footer}</div>}
    </div>
  </div>
);

/* Sectioned config panel — the Settings.jsx shape, applied to the shared modal body. */
const HgtSection = ({ title, sub, children }) => (
  <div className="hgt-sec">
    <div className="hgt-sec__head">
      <div className="hgt-sec__title">{title}</div>
      {sub && <div className="hgt-sec__sub">{sub}</div>}
    </div>
    <div className="hgt-sec__body">{children}</div>
  </div>
);

const HgtField = ({ label, htmlFor, required, hint, error, inferred, children }) => (
  <div className="hgt-field">
    <label className="hgt-label" htmlFor={htmlFor}>
      {label}{required && <span className="hgt-req"> *</span>}
      {inferred && <span className="hgt-inf" title="Translation key missing from the committed en lang file — operator-facing label inferred">label inferred</span>}
    </label>
    {children}
    {error && <div className="hgt-fielderr"><Icon name="alert" size={11} /> {error}</div>}
    {hint && <div className="hgt-hint">{hint}</div>}
  </div>
);

/* Row actions — the real cells render a delete link (deleteConfirm) then an edit link
   (gestioneGamecategory / …Gamesubcategory / …Gamelabel). */
const HgtActs = ({ onEdit, onDelete }) => (
  <div className="hgt-acts">
    <button className="hgt-act hgt-act--danger" title="Delete" /* label inferred */
      onClick={(e) => { e.stopPropagation(); onDelete(); }}><Icon name="trash" size={13} /></button>
    <button className="hgt-act hgt-act--edit" title="Edit"
      onClick={(e) => { e.stopPropagation(); onEdit(); }}><Icon name="edit" size={13} /></button>
  </div>
);

const HgtNameLink = ({ children, onClick }) => (
  <button className="hgt-namelink" title="Edit" onClick={(e) => { e.stopPropagation(); onClick(); }}>
    <span>{children}</span>
    <Icon name="chevron_right" size={13} />
  </button>
);

/* Skins cell — count chip of skins_subcategories / skins_labels rows, `All` chip when there are
   none. The `All` chip is deliberately WARNING-toned: zero rows is not "unpublished", it is
   "published to every brand", and the flat count chip of the real screen reads like the opposite. */
const HgtSkinsCell = ({ skins, table }) => {
  const n = (skins || []).length;
  if (n === 0) return (
    <span className="hgt-allchip">
      <Icon name="alert" size={11} /> All skins
      <Tip size={11}>
        No rows in <code>{table}</code> for this row. The platform reads that as <b>visible on every one of
        the {HGT_SKINS.length} skins</b>, not as hidden. The real column shows a plain <code>All</code> chip
        here — it is tinted as a warning in this rebuild because it is the state an operator reaches by
        removing the last skin.
      </Tip>
    </span>
  );
  const names = (skins || []).map(hgtSkinName);
  return (
    <span className="hgt-skincell">
      <span className="hgt-skinchip">{n}</span>
      <span className="hgt-skinnames" title={names.join(", ")}>{names.join(", ")}</span>
    </span>
  );
};

/* Tagify stand-in for `tags_list` (subcategory form only). The server stores
   json_encode(decodeTagifyValues(...)) — a JSON-quoted "a;b" string — and the list view strips the
   quotes and splits on ";". */
const HgtTagify = ({ value, onChange }) => {
  const [draft, setDraft] = hgtUseState("");
  const add = () => {
    const t = draft.trim();
    if (!t || value.indexOf(t) !== -1) { setDraft(""); return; }
    onChange([...value, t]); setDraft("");
  };
  return (
    <div className="hgt-tagify">
      <div className="hgt-tagify__chips">
        {value.map(t => (
          <span key={t} className="hgt-tag">
            {t}
            <button type="button" title={`Remove ${t}`} onClick={() => onChange(value.filter(x => x !== t))}><Icon name="x" size={9} /></button>
          </span>
        ))}
        <input className="hgt-tagify__in" placeholder={value.length ? "" : "Type a tag and press Enter…"}
          value={draft} onChange={e => setDraft(e.target.value)}
          onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
          onBlur={add} />
      </div>
      <div className="hgt-tagify__stored">
        Stored as <code>{value.length ? `"${value.join(";")}"` : '""'}</code> — one JSON-quoted, semicolon-joined string.
      </div>
    </div>
  );
};

/* Image field for `img` (subcategory form only). Upload is not wired in the prototype; the box
   states the real storage contract instead of faking a file picker. */
const HgtImageField = ({ has, name, onToggle }) => (
  <div className="hgt-imgrow">
    <div className={`hgt-imgbox${has ? " hgt-imgbox--set" : ""}`}>
      {has ? <Icon name="grid" size={18} /> : <span className="hgt-imgbox__ph" aria-hidden="true" />}
    </div>
    <div className="hgt-imgmeta">
      <button type="button" className="btn btn--secondary btn--sm" onClick={() => onToggle(!has)}>
        <Icon name="upload" size={12} /> {has ? "Replace image" : "Choose image"}{/* label inferred */}
      </button>
      <div className="hgt-hint">
        <code>.png .jpg .jpeg</code> — that <code>accept</code> list is the <b>only</b> check: the server-side
        BulletProof type/size validation is commented out (controller L335-342). Saved as
        <code> /storage/subcategories/img/{hgtSlug(name) || "<name>"}_&lt;uniqid&gt;.&lt;ext&gt;</code>.
      </div>
    </div>
  </div>
);

/* ------------------------------------------------------------------ *
 * Skin picker — loumultiselect stand-in, diff-synced into
 * skins_subcategories / skins_labels by subscribe/unsubscribe on save.
 * The banner above the panes is the whole point: an empty selection is
 * NOT "no skins", it is "every skin".
 * ------------------------------------------------------------------ */
const HgtSkinPicker = ({ value, onChange, table, noun }) => {
  const [qa, setQa] = hgtUseState("");
  const [qs, setQs] = hgtUseState("");
  const sel = (value || []).map(Number);
  const hit = (s, q) => !q || hgtNorm(s.name).indexOf(hgtNorm(q).trim()) !== -1;
  const available = HGT_SKINS.filter(s => sel.indexOf(s.id) === -1 && hit(s, qa));
  const chosen = HGT_SKINS.filter(s => sel.indexOf(s.id) !== -1 && hit(s, qs));
  const all = sel.length === 0;

  return (
    <div className="hgt-picker">
      {all ? (
        <div className="hgt-allwarn">
          <Icon name="alert" size={16} />
          <div>
            <div className="hgt-allwarn__t">No skin selected = published to ALL {HGT_SKINS.length} skins</div>
            <div className="hgt-allwarn__b">
              Saving now writes <b>zero</b> rows into <code>{table}</code>, and the platform treats "no rows" as
              <b> visible everywhere</b> — every brand in the estate gets this {noun}, including the ones below.
              This is not the same as unpublishing it; there is no "hidden" state on this form.
              To restrict it, pick at least one skin.
            </div>
            <div className="hgt-allwarn__list">{HGT_SKINS.map(s => s.name).join(" · ")}</div>
          </div>
        </div>
      ) : (
        <div className="hgt-scoped">
          <Icon name="check" size={13} />
          Visible on <b>{sel.length}</b> of {HGT_SKINS.length} skins — one row per skin in <code>{table}</code>,
          diff-synced on save (new skins subscribed, removed skins unsubscribed).
          <b> Removing the last one flips this {noun} to all skins.</b>
        </div>
      )}

      <div className="hgt-dual">
        <div className="hgt-pane">
          <div className="hgt-pane__head"><span>Available skins</span><span className="hgt-count">{available.length}</span></div>
          <input className="input hgt-pane__search" placeholder="Search…" value={qa} onChange={e => setQa(e.target.value)} />
          <div className="hgt-pane__list">
            {available.length === 0 && <div className="hgt-pane__empty">No skins match.</div>}
            {available.map(s => (
              <button key={s.id} type="button" className="hgt-opt" title={`Add ${s.name}`} onClick={() => onChange([...sel, s.id])}>
                <span className="hgt-opt__n">{s.name}</span><Icon name="plus" size={12} />
              </button>
            ))}
          </div>
        </div>

        <div className="hgt-swap"><Icon name="arrow_down_up" size={16} style={{ transform: "rotate(90deg)" }} /></div>

        <div className="hgt-pane">
          <div className="hgt-pane__head"><span>Selected skins</span><span className={`hgt-count${all ? " hgt-count--warn" : ""}`}>{all ? "ALL" : chosen.length}</span></div>
          <input className="input hgt-pane__search" placeholder="Search…" value={qs} onChange={e => setQs(e.target.value)} />
          <div className="hgt-pane__list">
            {sel.length === 0 && <div className="hgt-pane__empty hgt-pane__empty--warn">Empty = every skin. Add one to scope this {noun}.</div>}
            {chosen.map(s => (
              <button key={s.id} type="button" className="hgt-opt hgt-opt--sel" title={`Remove ${s.name}`} onClick={() => onChange(sel.filter(x => x !== s.id))}>
                <span className="hgt-opt__n">{s.name}</span><Icon name="x" size={12} />
              </button>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

/* ------------------------------------------------------------------ *
 * Delete confirm — a plain GET behind a JS confirm() on the real
 * platform, guarded only by isadmin(). The dialog spells out what the
 * delete removes and, critically, what it leaves behind.
 * ------------------------------------------------------------------ */
const HgtDeleteDialog = ({ title, what, url, cascade, orphans, onClose, onConfirm }) => (
  <HgtModal title={title} sub={<>Sent as <code>GET {url}</code> behind a JS <code>confirm()</code> — the only guard is <code>isadmin()</code>.</>}
    onClose={onClose}
    footer={<>
      <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
      <button className="btn btn--danger" onClick={() => { onConfirm(); onClose(); }}><Icon name="trash" size={13} /> Delete</button>
    </>}>
    <div className="hgt-dlgq">Delete {what}?</div>
    <div className="hgt-effect">
      <div className="hgt-effect__h"><Icon name="check" size={12} /> Removed</div>
      <ul>{cascade.map((c, i) => <li key={i}>{c}</li>)}</ul>
    </div>
    <div className="hgt-effect hgt-effect--orph">
      <div className="hgt-effect__h"><Icon name="alert" size={12} /> Left behind — orphaned</div>
      <ul>{orphans.map((o, i) => <li key={i}>{o}</li>)}</ul>
      <div className="hgt-hint">
        Kept exactly as the real platform behaves. Nothing in the back office cleans these rows up afterwards
        and there is no restore.
      </div>
    </div>
  </HgtModal>
);

/* The permission story, identical on all three screens. */
const HgtGate = ({ screen }) => (
  <>Real-platform access: <b>Super Admin only</b> — <code>{screen}::index</code> aborts <code>404</code> unless
    <code> isadmin()</code> (user_level 0), and the whole CMS ▾ group is wrapped in <code>@if (isadmin())</code>
    (sidebar.blade.php:733). No skin setting is involved. </>
);
const HgtGateNote = ({ screen, table, form, save }) => (
  <>Permission asymmetry, honestly: only <code>index()</code> and <code>delete()</code> check the role.
    <code> {screen}::{table}</code>, <code>::{form}</code> and <code>::{save}</code> carry <b>no role check at all</b>
    beyond <code>['auth','admin','2fa','g2fa']</code> — any authenticated, 2FA'd back-office user who calls those
    URLs directly can list, create and edit this taxonomy.</>
);

/* Header Tip reused by the ID column of all three tables. */
const HgtOrderTip = ({ endpoint, cols }) => (
  <Tip size={11}>
    Not sortable. The index action is hard-ordered <code>id DESC</code>. The legacy
    <code> {endpoint}</code> DataTables feed does support sorting by {cols}, but its target table no longer
    exists in the redesigned view (its ajax.js is not even included), so nothing can reach it.
  </Tip>
);

/* =================================================================== *
 * 1 / 3 — Game categories · GET /gamecategories · GameCategoriesController
 * =================================================================== */
const HgtCategoryForm = ({ row, onClose, onSave }) => {
  const isNew = !row;
  const [name, setName] = hgtUseState(row ? row.name : "");
  const [err, setErr] = hgtUseState("");

  const save = () => {
    /* saveGamecategory has no FormRequest: `name` required, message __('backend.insert_name') =
       "Insert name", answered as ajaxError + params.campierrati = ["name"]. */
    if (!name.trim()) { setErr("Insert name"); return; }
    setErr("");
    onSave({ id: isNew ? null : row.id, name: name.trim() });
    onClose();
  };

  return (
    <HgtModal
      title={isNew ? <>New Category{/* label inferred */}</> : `Edit ${row.name}`}
      sub={<>{isNew ? <>POST <code>/gamecategories/saveCategory/</code></> : <>GET <code>/gamecategories/form/?id={row.id}</code> → POST <code>/gamecategories/saveCategory/?id={row.id}</code></>}</>}
      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>
      </>}>

      {err && <div className="hgt-err hgt-err--banner"><Icon name="alert" size={13} /> {err}</div>}

      <HgtSection title="Data" sub="The whole form — the real modal has exactly one input.">
        <HgtField label="Name" htmlFor="hgt-cat-name" required error={err}>
          <input id="hgt-cat-name" className={`input${err ? " hgt-invalid" : ""}`} style={{ width: "100%" }} autoFocus
            value={name} onChange={e => { setName(e.target.value); setErr(""); }} />
        </HgtField>

        <HgtField label="Slug" hint={<>Not an input on the real form: <code>saveGamecategory</code> regenerates it from Name with <code>altnome()</code> on <b>every</b> save, so renaming a category silently changes its slug.</>}>
          <div className="hgt-ro">{hgtSlug(name) || <span className="hgt-muted">— derived from Name on save —</span>}</div>
        </HgtField>
      </HgtSection>

      <div className="hgt-note">
        <Icon name="info" size={12} />
        <span>
          <code>GameCategory::$fillable</code> also carries <code>priority</code>, <code>type</code> and
          <code> stato</code>, but this form never sends them — new rows take the column defaults and the
          <code> stati()</code> (Disabled/Active) and <code>tipologieGamecategories()</code> (default/slick) maps in
          the controller are never surfaced anywhere on this screen.
        </span>
      </div>
    </HgtModal>
  );
};

const GameCategories = () => {
  window.useLocale && window.useLocale();

  const feed = useHrsFetch(() => window.sb.list("gameCategories", { limit: 200 }), []);
  const rows = hgtUseMemo(() => (feed.data || []).map(hgtCatRow), [feed.data]);
  const subFeed = useHrsFetch(() => window.sb.list("gameSubcategories", { limit: 500 }), []);
  const subs = hgtUseMemo(() => (subFeed.data || []).map(hgtSubRow), [subFeed.data]);
  const save = useHrsSave([feed, subFeed]);
  const [draft, setDraft] = hgtUseState({ q: "" });
  const [applied, setApplied] = hgtUseState({ q: "" });
  const [page, setPage] = hgtUseState(0);
  const [form, setForm] = hgtUseState(null);   // null | { row: row|null }
  const [del, setDel] = hgtUseState(null);     // null | row

  /* index() L24-29: one free-text box matching `name LIKE %v%` OR `id = v`. */
  const FIELDS = [
    {
      key: "q", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Name or exact ID",
      tip: <>One box, two matches: <code>name LIKE %value%</code> <b>OR</b> <code>id = value</code> (controller L24-29). Typing <code>6</code> returns category 6 and anything with a 6 in its name.</>,
    },
  ];

  const filtered = hgtUseMemo(() => {
    const q = String(applied.q || "").trim();
    if (!q) return rows;
    const n = hgtNorm(q);
    return rows.filter(r => hgtNorm(r.name).indexOf(n) !== -1 || String(r.id) === q);
  }, [rows, applied]);

  /* Fixed ORDER BY id DESC (controller L22) — no sortable columns exist on this view. */
  const sorted = hgtUseMemo(() => filtered.slice().sort((a, b) => b.id - a.id), [filtered]);
  const pageCount = Math.max(1, Math.ceil(sorted.length / HGT_PAGE_SIZE));
  const safePage = Math.min(page, pageCount - 1);
  const paged = sorted.slice(safePage * HGT_PAGE_SIZE, safePage * HGT_PAGE_SIZE + HGT_PAGE_SIZE);

  const childrenOf = (id) => subs.filter(s => Number(s.cat) === Number(id));

  /* DIVERGENCE, kept: the real saveGamecategory returns `params => $id` where
     $id is still the EMPTY request id (controller L207-210), so its own modal
     cannot learn the new row's id. Here the insert returns the stored row, so
     the id is real and comes from the identity column. */
  const onSave = (c) => {
    const body = { name: c.name, slug: hgtSlug(c.name) };
    if (c.id == null) {
      save.run(() => window.sb.create("gameCategories", body),
        { done: `Category "${c.name}" created`, fail: "Create failed" });
    } else {
      /* Slug is regenerated from the name on every save, as upstream does —
         renaming a category silently changes its slug there too. */
      save.run(() => window.sb.update("gameCategories", c.id, body),
        { done: `Category "${c.name}" saved`, fail: "Save failed" });
    }
  };

  const doDelete = (r) => {
    const orphaned = childrenOf(r.id);
    save.run(() => window.sb.remove("gameCategories", r.id), {
      done: `Category "${r.name}" deleted`,
      fail: orphaned.length
        /* A foreign key will refuse this, and that is the point: upstream the
           delete succeeds and leaves the subcategories dangling. */
        ? `Delete refused — ${orphaned.length} subcategor${orphaned.length === 1 ? "y" : "ies"} still point at category ${r.id}`
        : "Delete failed",
    });
  };

  const columns = [
    { key: "id", label: <>ID <HgtOrderTip endpoint="/gamecategories/getCategoriesTable" cols={<><code>id</code> and <code>name</code></>} /></>, width: 110, render: r => <span className="hgt-id">{r.id}</span> },
    { key: "name", label: "Name", render: r => <HgtNameLink onClick={() => setForm({ row: r })}>{r.name}</HgtNameLink> },
    /* "Slug" is hardcoded in the blade, not a backend.* key. */
    { key: "slug", label: "Slug", render: r => <code className="hgt-slug">{r.slug}</code> },
    { key: "_acts", label: "Actions", align: "center", width: 120, render: r => <HgtActs onEdit={() => setForm({ row: r })} onDelete={() => setDel(r)} /> },
  ];

  return (
    <HrsShell
      title="Game categories"
      subtitle="Top-level game buckets — every subcategory, provider and imported game hangs off one of these"
      gate={<HgtGate screen="GameCategoriesController" />}
      gateNote={<HgtGateNote screen="GameCategoriesController" table="getCategoriesTable" form="gamecategoryForm" save="saveGamecategory" />}
      explainer={{ title: "What this is, in plain English", bullets: [
        <>A <b>game category</b> is the coarsest bucket in the catalogue — <code>gamecategories</code>. Ids 1, 2, 4, 5 and 6 are the well-known constants on the model (<code>CASINO</code>, <code>CASINO_LIVE</code>, <code>VIRTUAL</code>, <code>POKER</code>, <code>SPORT</code>) and are referenced by name all over the platform; the rest are ordinary operator rows.</>,
        <>The form has <b>one</b> field. <code>slug</code> is regenerated from the name by <code>altnome()</code> on every save, and <code>priority</code> / <code>type</code> / <code>stato</code> stay at their column defaults because the form never sends them.</>,
        <><b>Delete does not cascade.</b> It removes the <code>gamecategories</code> row and nothing else, so every subcategory that pointed at it keeps a <code>category_id</code> that now resolves to nothing — the subcategory list's Category column just goes blank. The confirm dialog names them first.</>,
        <>The list is fixed to <code>id DESC</code> with <code>paginate(25)</code>; there is no sorting, no export and no bulk action anywhere on this screen.</>,
      ] }}
      actions={<>
        <button className="hrs-btn hrs-btn--search hgt-headbtn" onClick={() => hgtNavTo("cms-game-subcat", "Game subcategories")}>
          <Icon name="list" size={14} /> Game subcategories
        </button>
        <button className="hrs-btn hrs-btn--filters hgt-headbtn" onClick={() => setForm({ row: null })}>
          <Icon name="plus" size={14} /> New Category{/* label inferred */}
        </button>
      </>}>

      <HrsFilters
        fields={FIELDS} values={draft}
        onChange={(k, v) => setDraft(d => ({ ...d, [k]: v }))}
        onSearch={(v) => { setApplied({ q: v.q || "" }); setPage(0); }}
        onReset={() => { setDraft({ q: "" }); setApplied({ q: "" }); setPage(0); }}
        resultLabel={hrsInt(sorted.length)} />

      {/* HrsAsync owns loading / error / empty. The error branch is the one that
          matters: signed out, RLS returns zero rows, and a bare table would say
          "no categories yet" — wrong, and it hides the real cause. */}
      <HrsAsync state={feed} skeletonRows={8} skeletonCols={4}
                empty="No game categories yet. They are created on this screen once the write path exists.">
        {() => (<>
      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={(r) => setForm({ row: r })}   /* the whole row is clickable on the real screen */
        empty={applied.q ? "No category matches this search." : "No categories yet — create one with New Category."}
        renderCard={r => (
          <>
            <div className="hrs-card__top"><b>{r.name}</b><span className="hgt-id">ID {r.id}</span></div>
            <div className="hrs-card__grid"><span>Slug</span><b><code className="hgt-slug">{r.slug}</code></b></div>
            <div className="hgt-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); setForm({ row: r }); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hgt-card__del" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />

      <HrsPager page={safePage} pageSize={HGT_PAGE_SIZE} total={sorted.length} onPage={setPage} />
        </>)}
      </HrsAsync>

      {form && <HgtCategoryForm row={form.row} onClose={() => setForm(null)} onSave={onSave} />}

      {del && (
        <HgtDeleteDialog
          title="Delete game category"
          what={<><b>{del.name}</b> (ID {del.id})</>}
          url={`/gamecategories/delete/${del.id}/`}
          onClose={() => setDel(null)}
          onConfirm={() => doDelete(del)}
          cascade={[<>The <code>gamecategories</code> row itself. That is the entire delete — there is no cascade and no guard.</>]}
          orphans={childrenOf(del.id).length ? [
            <>
              <b>{childrenOf(del.id).length} subcategor{childrenOf(del.id).length === 1 ? "y" : "ies"}</b> keep
              <code> gamesubcategories.category_id = {del.id}</code>, which will no longer resolve. They are not deleted and
              not reassigned; their Category cell on the subcategories screen simply renders empty (LEFT JOIN → NULL):
              <div className="hgt-orphlist">{childrenOf(del.id).map(s => <span key={s.id} className="hgt-orphitem">#{s.id} {s.internal}</span>)}</div>
            </>,
          ] : [
            <>Nothing — no <code>gamesubcategories</code> row currently points at <code>category_id {del.id}</code>. Any subcategory created against it later would still dangle, since nothing revalidates the column.</>,
          ]} />
      )}
    </HrsShell>
  );
};

/* =================================================================== *
 * 2 / 3 — Game subcategories · GET /gamesubcategories · GameSubcategoriesController
 * =================================================================== */
const HgtSubcategoryForm = ({ row, cats, onClose, onSave }) => {
  const isNew = !row;
  const [internal, setInternal] = hgtUseState(row ? row.internal : "");
  const [pub, setPub] = hgtUseState(row ? row.pub : "");
  const [cat, setCat] = hgtUseState(row && row.cat != null ? String(row.cat) : "");
  const [priority, setPriority] = hgtUseState(row ? String(row.priority == null ? "" : row.priority) : "");
  const [img, setImg] = hgtUseState(row ? !!row.img : false);
  const [tags, setTags] = hgtUseState(row ? row.tags.slice() : []);
  const [skins, setSkins] = hgtUseState(row ? row.skins.slice() : []);
  const [featured, setFeatured] = hgtUseState(row ? !!row.featured : false);
  const [errs, setErrs] = hgtUseState({});
  const [banner, setBanner] = hgtUseState("");

  const save = () => {
    /* saveGamesubcategory L281+: internal_name required ("Insert internal name", hardcoded),
       name required ("Insert public name", hardcoded), category_id required
       (__('backend.select_category') = "Select category"). Failure → ajaxError + campierrati. */
    const e = {};
    if (!internal.trim()) e.internal_name = "Insert internal name";
    if (!pub.trim()) e.name = "Insert public name";
    if (!cat) e.category_id = "Select category";
    setErrs(e);
    const first = ["internal_name", "name", "category_id"].map(k => e[k]).filter(Boolean)[0];
    if (first) { setBanner(first); return; }
    setBanner("");
    onSave({
      id: isNew ? null : row.id, internal: internal.trim(), pub: pub.trim(), cat: Number(cat),
      priority: priority === "" ? 0 : Number(priority), img, tags: tags.slice(), skins: skins.slice(), featured,
    });
    onClose();
  };
  const clear = (k) => { setErrs(x => { const n = { ...x }; delete n[k]; return n; }); setBanner(""); };

  return (
    <HgtModal wide
      title={isNew ? <>New subcategory{/* label inferred */}</> : `Edit ${row.internal}`}
      sub={<>{isNew ? <>POST <code>/gamesubcategories/saveSubcategory/</code></> : <>GET <code>/gamesubcategories/form/?id={row.id}</code> → POST <code>/gamesubcategories/saveSubcategory/?id={row.id}</code></>}</>}
      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>
      </>}>

      {banner && <div className="hgt-err hgt-err--banner"><Icon name="alert" size={13} /> {banner}</div>}

      <HgtSection title="Data">
        <div className="hgt-2col">
          <HgtField label="Internal name" htmlFor="hgt-sub-int" required inferred error={errs.internal_name}
            hint={<>Back-office-only name. It is the column the list links from and one half of the Search filter.</>}>
            <input id="hgt-sub-int" className={`input${errs.internal_name ? " hgt-invalid" : ""}`} style={{ width: "100%" }} autoFocus
              value={internal} onChange={e => { setInternal(e.target.value); clear("internal_name"); }} />
          </HgtField>
          <HgtField label="Public name" htmlFor="hgt-sub-pub" required inferred error={errs.name}
            hint={<>Column <code>name</code> — what players see on the skin.</>}>
            <input id="hgt-sub-pub" className={`input${errs.name ? " hgt-invalid" : ""}`} style={{ width: "100%" }}
              value={pub} onChange={e => { setPub(e.target.value); clear("name"); }} />
          </HgtField>
        </div>
        <div className="hgt-2col">
          <HgtField label="Category" htmlFor="hgt-sub-cat" required error={errs.category_id}
            hint={<>Options come from <code>GameCategoriesController::getCategoriesList()</code>, ordered by id. Nothing revalidates this later: if the category is deleted, the row keeps the id and the Category cell goes blank.</>}>
            <select id="hgt-sub-cat" className={`select${errs.category_id ? " hgt-invalid" : ""}`} style={{ width: "100%" }}
              value={cat} onChange={e => { setCat(e.target.value); clear("category_id"); }}>
              <option value="">Select</option>{/* backend.select_option */}
              {cats.slice().sort((a, b) => a.id - b.id).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
            </select>
          </HgtField>
          <HgtField label="Priority" htmlFor="hgt-sub-pri"
            hint={<>Ordering weight, optional. The server-side check is an <b>empty <code>if</code></b> (controller L316-317), so any string is accepted and stored; the list shows <code>0</code> when it is null.</>}>
            <input id="hgt-sub-pri" className="input" style={{ width: "100%" }} inputMode="numeric"
              value={priority} onChange={e => setPriority(e.target.value)} />
          </HgtField>
        </div>
      </HgtSection>

      <HgtSection title="Media & tags">
        <HgtField label="Image" inferred>
          <HgtImageField has={img} name={internal} onToggle={setImg} />
        </HgtField>
        <HgtField label="TT Tags"
          hint={<>Hardcoded label. Free-text tags handed to the TimelessTech catalogue; the save path runs <code>json_encode(decodeTagifyValues(...))</code>, so what lands in the column is one quoted, semicolon-joined string.</>}>
          <HgtTagify value={tags} onChange={setTags} />
        </HgtField>
      </HgtSection>

      <HgtSection title="Skins & visibility" sub="Hardcoded label on the real form — the multi-select has no help text of its own.">
        <HgtField label="Skins">
          <HgtSkinPicker value={skins} onChange={setSkins} table="skins_subcategories" noun="subcategory" />
        </HgtField>
        <div className="hgt-togglerow">
          <Toggle value={featured} onChange={setFeatured} onLabel="" offLabel="" size="sm" />
          <div>
            <div className="hgt-label">Featured</div>{/* hardcoded label */}
            <div className="hgt-hint">Cast to 1/0 on save (controller L318) and shown as a Yes/No chip in the list. Independent of the skin selection above.</div>
          </div>
        </div>
      </HgtSection>
    </HgtModal>
  );
};

const GameSubcategories = () => {
  window.useLocale && window.useLocale();

  const feed = useHrsFetch(() => window.sb.list("gameSubcategories", { limit: 500 }), []);
  const rows = hgtUseMemo(() => (feed.data || []).map(hgtSubRow), [feed.data]);
  const catFeed = useHrsFetch(() => window.sb.list("gameCategories", { limit: 200 }), []);
  const cats = hgtUseMemo(() => (catFeed.data || []).map(hgtCatRow), [catFeed.data]);
  const save = useHrsSave([feed, catFeed]);
  const blank = { q: "", cat: "", skin: "", featured: "" };
  const [draft, setDraft] = hgtUseState(blank);
  const [applied, setApplied] = hgtUseState(blank);
  const [page, setPage] = hgtUseState(0);
  const [form, setForm] = hgtUseState(null);
  const [del, setDel] = hgtUseState(null);

  const category = (id) => cats.find(c => c.id === Number(id)) || null;

  const FIELDS = [
    {
      key: "q", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Internal or public name",
      tip: <>Matches <code>name LIKE %v%</code> OR <code>internal_name LIKE %v%</code>. Unlike the categories screen it does <b>not</b> match the id.</>,
    },
    {
      key: "cat", label: "Category", type: "select", icon: "grid", placeholder: "All categories",
      options: cats.slice().sort((a, b) => hgtNorm(a.name) < hgtNorm(b.name) ? -1 : 1).map(c => ({ value: String(c.id), label: c.name })),
      tip: <>All <code>gamecategories</code>, ordered by name. Rows whose category was deleted are unreachable through this filter — pick "All categories" to see them.</>,
    },
    {
      key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "All skins",   /* hardcoded label on the real filter */
      options: HGT_SKINS.map(s => ({ value: String(s.id), label: s.name })),
      tip: <>Not a plain equals: picking a skin returns subcategories assigned to <b>that</b> skin <b>OR</b> subcategories with no <code>skins_subcategories</code> rows at all — the global ones (controller L51-64). So an "All skins" row shows up under every skin, which is the point of it.</>,
    },
    {
      key: "featured", label: "Featured", type: "select", placeholder: "All",   /* hardcoded label */
      options: [{ value: "1", label: "Featured" }, { value: "0", label: "Not featured" }],
      tip: <>Strict <code>!== ''</code> check server-side (L66), so "Not featured" really does filter on <code>featured = 0</code> instead of collapsing into "All".</>,
    },
  ];

  const filtered = hgtUseMemo(() => {
    const q = hgtNorm(String(applied.q || "").trim());
    return rows.filter(r => {
      if (q && hgtNorm(r.internal).indexOf(q) === -1 && hgtNorm(r.pub).indexOf(q) === -1) return false;
      if (applied.cat && String(r.cat) !== String(applied.cat)) return false;
      /* assigned-to-this-skin OR global (no skins_subcategories rows at all) */
      if (applied.skin && !(r.skins.length === 0 || r.skins.indexOf(Number(applied.skin)) !== -1)) return false;
      if (applied.featured !== "" && (r.featured ? "1" : "0") !== applied.featured) return false;
      return true;
    });
  }, [rows, applied]);

  const sorted = hgtUseMemo(() => filtered.slice().sort((a, b) => b.id - a.id), [filtered]);   // fixed id DESC (L70)
  const pageCount = Math.max(1, Math.ceil(sorted.length / HGT_PAGE_SIZE));
  const safePage = Math.min(page, pageCount - 1);
  const paged = sorted.slice(safePage * HGT_PAGE_SIZE, safePage * HGT_PAGE_SIZE + HGT_PAGE_SIZE);

  /* The per-skin publication pivot (skin_subcategories) is NOT written here.
     It is a diff-sync against a separate table, and doing it as a second
     unrelated write would leave the two halves able to disagree when the
     second one fails. The base row saves; the skin scope is its own screen.
     <!-- SUGGESTION: give the pivot its own endpoint, or write both halves in one RPC. A subcategory saved with the wrong publication scope is live on skins it was never meant to reach. --> */
  const onSave = (s) => {
    const body = {
      category_id: s.cat == null ? null : Number(s.cat),
      internal_name: s.internal,
      name: s.pub || s.internal,
      slug: hgtSlug(s.internal),
      image_url: s.img || null,
      priority: s.priority == null ? 0 : Number(s.priority),
      featured: !!s.featured,
    };
    if (s.id == null) {
      save.run(() => window.sb.create("gameSubcategories", body),
        { done: `Subcategory "${s.internal}" created`, fail: "Create failed" });
    } else {
      save.run(() => window.sb.update("gameSubcategories", s.id, body),
        { done: `Subcategory "${s.internal}" saved`, fail: "Save failed" });
    }
  };

  const doDelete = (r) => {
    save.run(() => window.sb.remove("gameSubcategories", r.id),
      { done: `Subcategory "${r.internal}" deleted`, fail: "Delete failed" });
  };

  const columns = [
    { key: "id", label: <>ID <HgtOrderTip endpoint="/gamesubcategories/getSubcategoriesTable" cols={<><code>id</code>, <code>public_name</code>, <code>internal_name</code>, <code>priority</code>, <code>featured</code> and category name</>} /></>, width: 84, render: r => <span className="hgt-id">{r.id}</span> },
    {
      key: "img", label: "Image", align: "center", width: 74,   /* backend.img missing → label inferred from backend.image */
      render: r => r.img
        ? <span className="hgt-thumb" title="asset_static() thumbnail"><Icon name="grid" size={13} /></span>
        : <span className="hgt-thumb hgt-thumb--ph" title="No image — placeholder icon"><Icon name="eye" size={13} /></span>,
    },
    {
      key: "internal", label: "Internal name",   /* label inferred */
      render: r => (
        <div className="hgt-namecell">
          <HgtNameLink onClick={() => setForm({ row: r })}>{r.internal}</HgtNameLink>
          {/* `tags_list` is stored as a quoted "a;b" string and the index view strips the quotes and
              splits on ";". The reference's column list does not name a separate Tags column, so the
              chips ride under the name rather than inventing one — see UNCLEAR in the report. */}
          {r.tags.length > 0 && <div className="hgt-tagline">{r.tags.map(t => <span key={t} className="hgt-tag hgt-tag--ro">{t}</span>)}</div>}
        </div>
      ),
    },
    { key: "pub", label: "Public name", render: r => <span className="hgt-pub">{r.pub}</span> },   /* label inferred */
    {
      key: "cat", label: "Category",
      render: r => {
        const c = category(r.cat);
        return c
          ? <span className="hgt-catchip">{c.name}</span>
          : <span className="hgt-catchip hgt-catchip--dead">
            <Icon name="alert" size={10} /> category_id {r.cat}
            <Tip size={11}>The category this row points at no longer exists. <code>GameCategoriesController::delete</code> removes the <code>gamecategories</code> row without touching its children, so the LEFT JOIN returns NULL and the real screen renders an empty cell. Re-point it from the edit form.</Tip>
          </span>;
      },
    },
    { key: "skins", label: "Skins", render: r => <HgtSkinsCell skins={r.skins} table="skins_subcategories" /> },   /* hardcoded label */
    { key: "priority", label: "Priority", align: "right", width: 96, render: r => <span className="hgt-num">{r.priority == null ? 0 : r.priority}</span> },
    { key: "featured", label: "Featured", align: "center", width: 100, render: r => r.featured ? <span className="hgt-yes">Yes</span> : <span className="hgt-no">No</span> },   /* hardcoded label */
    { key: "_acts", label: "Actions", align: "center", width: 110, render: r => <HgtActs onEdit={() => setForm({ row: r })} onDelete={() => setDel(r)} /> },
  ];

  return (
    <HrsShell
      title="Game subcategories"
      subtitle="The shelves inside each category — what a skin's lobby actually renders as a row of games"
      gate={<HgtGate screen="GameSubcategoriesController" />}
      gateNote={<HgtGateNote screen="GameSubcategoriesController" table="getSubcategoriesTable" form="gameSubcategoryForm" save="saveGamesubcategory" />}
      explainer={{ title: "What this is, in plain English", bullets: [
        <>A <b>subcategory</b> (<code>gamesubcategories</code>) belongs to exactly one category and collects games through the <code>gamesubcategories_assoc</code> pivot. <code>internal_name</code> is what the back office lists; <code>name</code> is what players see.</>,
        <><b>Empty skin selection = every skin.</b> The picker writes one <code>skins_subcategories</code> row per selected skin; zero rows means "global", so removing the last skin publishes the shelf to all {HGT_SKINS.length} brands instead of hiding it. The list marks those rows <b>All skins</b>.</>,
        <>The <b>Skin filter is an OR</b>, not an equals: it returns rows assigned to the chosen skin <i>plus</i> every global row, because a global row genuinely is on that skin.</>,
        <><b>Delete is partial.</b> It removes the row and its <code>gamesubcategories_assoc</code> game links, but leaves <code>skins_subcategories</code> and <code>gamesubcategories_skin_assoc</code> rows pointing at an id that no longer exists.</>,
        <>Fixed <code>id DESC</code>, <code>paginate(25)</code>, no sorting, no export, no bulk actions. <code>stato</code> (Disabled/Active) and <code>type</code> (default/slick) exist in the controller but are surfaced nowhere.</>,
      ] }}
      actions={<>
        <button className="hrs-btn hrs-btn--search hgt-headbtn" onClick={() => hgtNavTo("cms-game-cat", "Game categories")}>
          <Icon name="grid" size={14} /> Game categories
        </button>
        <button className="hrs-btn hrs-btn--filters hgt-headbtn" onClick={() => setForm({ row: null })}>
          <Icon name="plus" size={14} /> New subcategory{/* label inferred */}
        </button>
      </>}>

      <HrsFilters
        fields={FIELDS} values={draft}
        onChange={(k, v) => setDraft(d => ({ ...d, [k]: v }))}
        onSearch={(v) => { setApplied({ q: v.q || "", cat: v.cat || "", skin: v.skin || "", featured: v.featured === undefined ? "" : v.featured }); setPage(0); }}
        onReset={() => { setDraft(blank); setApplied(blank); setPage(0); }}
        resultLabel={hrsInt(sorted.length)} />

      {/* Loading / error / empty for the subcategory feed. The category feed
          (catFeed) is only used to resolve names — a failure there degrades to
          the "category_id N" dead chip the table already renders, so it does
          not need a branch of its own. */}
      <HrsAsync state={feed} skeletonRows={10} skeletonCols={7}
                empty="No game subcategories yet. They are created on this screen once the write path exists.">
        {() => (<>
      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={(r) => setForm({ row: r })}
        empty="No subcategory matches these filters."
        renderCard={r => {
          const c = category(r.cat);
          return (
            <>
              <div className="hrs-card__top">
                <b>{r.internal}</b>
                <span className="hgt-id">ID {r.id}</span>
              </div>
              <div className="hgt-card__meta">
                {c ? <span className="hgt-catchip">{c.name}</span> : <span className="hgt-catchip hgt-catchip--dead"><Icon name="alert" size={10} /> category_id {r.cat}</span>}
                <HgtSkinsCell skins={r.skins} table="skins_subcategories" />
                {r.featured && <span className="hgt-yes">Featured</span>}
              </div>
              <details className="hgt-details">
                <summary>More</summary>
                <div className="hrs-card__grid">
                  <span>Public name</span><b>{r.pub}</b>
                  <span>Priority</span><b>{r.priority == null ? 0 : r.priority}</b>
                  <span>Image</span><b>{r.img ? "Yes" : "—"}</b>
                  <span>TT Tags</span><b>{r.tags.length ? r.tags.join("; ") : "—"}</b>
                </div>
              </details>
              <div className="hgt-card__acts">
                <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); setForm({ row: r }); }}><Icon name="edit" size={12} /> Edit</button>
                <button className="btn btn--ghost btn--sm hgt-card__del" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
              </div>
            </>
          );
        }} />

      <HrsPager page={safePage} pageSize={HGT_PAGE_SIZE} total={sorted.length} onPage={setPage} />
        </>)}
      </HrsAsync>

      {form && <HgtSubcategoryForm row={form.row} cats={cats} onClose={() => setForm(null)} onSave={onSave} />}

      {del && (
        <HgtDeleteDialog
          title="Delete game subcategory"
          what={<><b>{del.internal}</b> (ID {del.id})</>}
          url={`/gamesubcategories/delete/${del.id}/`}
          onClose={() => setDel(null)}
          onConfirm={() => doDelete(del)}
          cascade={[
            <>The <code>gamesubcategories</code> row.</>,
            <>Its <code>gamesubcategories_assoc</code> rows — every game↔subcategory link (controller L536).</>,
          ]}
          orphans={[
            del.skins.length
              ? <><b>{del.skins.length} <code>skins_subcategories</code> row{del.skins.length === 1 ? "" : "s"}</b> — the per-skin visibility records for {del.skins.map(hgtSkinName).join(", ")} — are <b>not</b> deleted and keep pointing at subcategory {del.id}.</>
              : <>No <code>skins_subcategories</code> rows exist for this one (it is published to all skins), so there is nothing to orphan there.</>,
            <><code>gamesubcategories_skin_assoc</code> — the per-skin game assignments read by the Games screen — are not touched either, so they survive keyed to subcategory {del.id}.</>,
          ]} />
      )}
    </HrsShell>
  );
};

/* =================================================================== *
 * 3 / 3 — Game labels · GET /gamelabels · GameLabelsController
 * =================================================================== */
const HgtLabelForm = ({ row, onClose, onSave }) => {
  const isNew = !row;
  const [internal, setInternal] = hgtUseState(row ? row.internal : "");
  const [pub, setPub] = hgtUseState(row ? row.pub : "");
  const [skins, setSkins] = hgtUseState(row ? row.skins.slice() : []);
  const [errs, setErrs] = hgtUseState({});
  const [banner, setBanner] = hgtUseState("");

  const save = () => {
    /* saveGamelabel L230+: internal_name required ("Insert internal name"), name required
       ("Insert public name") — both hardcoded English. selectSkins is array_filter'ed (L261). */
    const e = {};
    if (!internal.trim()) e.internal_name = "Insert internal name";
    if (!pub.trim()) e.name = "Insert public name";
    setErrs(e);
    const first = ["internal_name", "name"].map(k => e[k]).filter(Boolean)[0];
    if (first) { setBanner(first); return; }
    setBanner("");
    onSave({ id: isNew ? null : row.id, internal: internal.trim(), pub: pub.trim(), skins: skins.slice() });
    onClose();
  };
  const clear = (k) => { setErrs(x => { const n = { ...x }; delete n[k]; return n; }); setBanner(""); };

  return (
    <HgtModal wide
      title={isNew ? <>New label{/* label inferred */}</> : `Edit ${row.internal}`}
      sub={<>{isNew ? <>POST <code>/gamelabels/savelabel/</code></> : <>GET <code>/gamelabels/form/?id={row.id}</code> → POST <code>/gamelabels/savelabel/?id={row.id}</code></>}</>}
      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>
      </>}>

      {banner && <div className="hgt-err hgt-err--banner"><Icon name="alert" size={13} /> {banner}</div>}

      <HgtSection title="Data">
        <div className="hgt-2col">
          <HgtField label="Internal name" htmlFor="hgt-lab-int" required inferred error={errs.internal_name}
            hint={<>Back-office name — the column the list links from.</>}>
            <input id="hgt-lab-int" className={`input${errs.internal_name ? " hgt-invalid" : ""}`} style={{ width: "100%" }} autoFocus
              value={internal} onChange={e => { setInternal(e.target.value); clear("internal_name"); }} />
          </HgtField>
          <HgtField label="Public name" htmlFor="hgt-lab-pub" required inferred error={errs.name}
            hint={<>The badge text players see on the game tile — "Hot", "New", "Exclusive".</>}>
            <input id="hgt-lab-pub" className={`input${errs.name ? " hgt-invalid" : ""}`} style={{ width: "100%" }}
              value={pub} onChange={e => { setPub(e.target.value); clear("name"); }} />
          </HgtField>
        </div>
      </HgtSection>

      <HgtSection title="Skins & visibility">
        {/* The real form labels this field with a `for="tags_list"` that points at nothing (copy-paste
            from the subcategory form). Fixed silently here — the label points at its own control. */}
        <HgtField label="Skins">
          <HgtSkinPicker value={skins} onChange={setSkins} table="skins_labels" noun="label" />
        </HgtField>
      </HgtSection>

      <div className="hgt-note">
        <Icon name="info" size={12} />
        <span>
          There is nothing else on this form. Its trailing script initialises Tagify on <code>#tags_list</code> and
          <code> KTImageInput('img')</code>, but neither element exists here — leftovers from the subcategory form, harmless.
          <code> GameLabel::$fillable</code> also lists <code>priority</code>, <code>slug</code> and <code>img</code>;
          no form field and no save path ever writes them.
        </span>
      </div>
    </HgtModal>
  );
};

const GameLabels = () => {
  window.useLocale && window.useLocale();

  const feed = useHrsFetch(() => window.sb.list("gameLabels", { limit: 200 }), []);
  const rows = hgtUseMemo(() => (feed.data || []).map(hgtLabelRow), [feed.data]);
  const save = useHrsSave(feed);
  const blank = { q: "", skin: "" };
  const [draft, setDraft] = hgtUseState(blank);
  const [applied, setApplied] = hgtUseState(blank);
  const [page, setPage] = hgtUseState(0);
  const [form, setForm] = hgtUseState(null);
  const [del, setDel] = hgtUseState(null);

  const FIELDS = [
    {
      key: "q", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Internal or public name",
      tip: <>Matches <code>name LIKE %v%</code> OR <code>internal_name LIKE %v%</code> (controller L43-56). The id is not searchable here.</>,
    },
    {
      key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "All skins",   /* hardcoded label */
      options: HGT_SKINS.map(s => ({ value: String(s.id), label: s.name })),
      tip: <>Same OR-global semantics as the subcategories screen: labels assigned to the chosen skin <b>plus</b> every label with no <code>skins_labels</code> rows at all.</>,
    },
  ];

  const filtered = hgtUseMemo(() => {
    const q = hgtNorm(String(applied.q || "").trim());
    return rows.filter(r => {
      if (q && hgtNorm(r.internal).indexOf(q) === -1 && hgtNorm(r.pub).indexOf(q) === -1) return false;
      if (applied.skin && !(r.skins.length === 0 || r.skins.indexOf(Number(applied.skin)) !== -1)) return false;
      return true;
    });
  }, [rows, applied]);

  const sorted = hgtUseMemo(() => filtered.slice().sort((a, b) => b.id - a.id), [filtered]);   // fixed id DESC (L58)
  const pageCount = Math.max(1, Math.ceil(sorted.length / HGT_PAGE_SIZE));
  const safePage = Math.min(page, pageCount - 1);
  const paged = sorted.slice(safePage * HGT_PAGE_SIZE, safePage * HGT_PAGE_SIZE + HGT_PAGE_SIZE);

  /* Same split as subcategories: the base row here, the per-skin publication
     pivot on its own screen. */
  const onSave = (l) => {
    const body = {
      internal_name: l.internal,
      name: l.pub || l.internal,
      slug: hgtSlug(l.internal),
      priority: l.priority == null ? 0 : Number(l.priority),
    };
    if (l.id == null) {
      save.run(() => window.sb.create("gameLabels", body),
        { done: `Label "${l.internal}" created`, fail: "Create failed" });
    } else {
      save.run(() => window.sb.update("gameLabels", l.id, body),
        { done: `Label "${l.internal}" saved`, fail: "Save failed" });
    }
  };

  const doDelete = (r) => {
    save.run(() => window.sb.remove("gameLabels", r.id),
      { done: `Label "${r.internal}" deleted`, fail: "Delete failed" });
  };

  const columns = [
    { key: "id", label: <>ID <HgtOrderTip endpoint="/gamelabels/getLabelsTable" cols={<><code>id</code>, <code>public_name</code> and <code>internal_name</code></>} /></>, width: 84, render: r => <span className="hgt-id">{r.id}</span> },
    { key: "internal", label: "Internal name", render: r => <HgtNameLink onClick={() => setForm({ row: r })}>{r.internal}</HgtNameLink> },   /* label inferred */
    { key: "pub", label: "Public name", render: r => <span className="hgt-badge">{r.pub}</span> },   /* label inferred */
    { key: "skins", label: "Skins", render: r => <HgtSkinsCell skins={r.skins} table="skins_labels" /> },   /* hardcoded label */
    { key: "_acts", label: "Actions", align: "center", width: 120, render: r => <HgtActs onEdit={() => setForm({ row: r })} onDelete={() => setDel(r)} /> },
  ];

  return (
    <HrsShell
      title="Game Labels"
      subtitle={<>The "Hot" / "New" badges stuck on game tiles{/* label inferred — backend.game_labels has no committed en translation */}</>}
      gate={<HgtGate screen="GameLabelsController" />}
      gateNote={<HgtGateNote screen="GameLabelsController" table="getLabelsTable" form="gameLabelForm" save="saveGamelabel" />}
      explainer={{ title: "What this is, in plain English", bullets: [
        <>A <b>label</b> (<code>gamelabels</code>) is a badge attached to individual games through <code>gamelabels_assoc</code>, and per skin through <code>gamelabels_skin_assoc</code>. The links themselves are made on the Games screen, not here — this page only defines the badge.</>,
        <><b>Empty skin selection = every skin</b>, exactly as on subcategories: the picker syncs <code>skins_labels</code>, and no rows means the badge is available on all {HGT_SKINS.length} brands.</>,
        <><b>Delete is partial.</b> It removes the label and its <code>gamelabels_assoc</code> game links but leaves <code>skins_labels</code> and <code>gamelabels_skin_assoc</code> rows behind.</>,
        <>The form has two fields. <code>priority</code>, <code>slug</code> and <code>img</code> are on the model but nothing on this screen writes them, and the sidebar entry's <code>backend.game_labels</code> key has no committed English translation — every label here marked <i>label inferred</i> is one of those.</>,
      ] }}
      actions={
        <button className="hrs-btn hrs-btn--filters hgt-headbtn" onClick={() => setForm({ row: null })}>
          <Icon name="plus" size={14} /> New label{/* label inferred */}
        </button>
      }>

      <HrsFilters
        fields={FIELDS} values={draft}
        onChange={(k, v) => setDraft(d => ({ ...d, [k]: v }))}
        onSearch={(v) => { setApplied({ q: v.q || "", skin: v.skin || "" }); setPage(0); }}
        onReset={() => { setDraft(blank); setApplied(blank); setPage(0); }}
        resultLabel={hrsInt(sorted.length)} />

      <HrsAsync state={feed} skeletonRows={8} skeletonCols={5}
                empty="No game labels yet. They are created on this screen once the write path exists.">
        {() => (<>
      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={(r) => setForm({ row: r })}
        empty="No label matches these filters."
        renderCard={r => (
          <>
            <div className="hrs-card__top"><b>{r.internal}</b><span className="hgt-id">ID {r.id}</span></div>
            <div className="hgt-card__meta">
              <span className="hgt-badge">{r.pub}</span>
              <HgtSkinsCell skins={r.skins} table="skins_labels" />
            </div>
            <div className="hgt-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); setForm({ row: r }); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hgt-card__del" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />

      <HrsPager page={safePage} pageSize={HGT_PAGE_SIZE} total={sorted.length} onPage={setPage} />
        </>)}
      </HrsAsync>

      {form && <HgtLabelForm row={form.row} onClose={() => setForm(null)} onSave={onSave} />}

      {del && (
        <HgtDeleteDialog
          title="Delete game label"
          what={<><b>{del.internal}</b> (ID {del.id})</>}
          url={`/gamelabels/delete/${del.id}/`}
          onClose={() => setDel(null)}
          onConfirm={() => doDelete(del)}
          cascade={[
            <>The <code>gamelabels</code> row.</>,
            <>Its <code>gamelabels_assoc</code> rows — every game that wore this badge loses it (controller L454).</>,
          ]}
          orphans={[
            del.skins.length
              ? <><b>{del.skins.length} <code>skins_labels</code> row{del.skins.length === 1 ? "" : "s"}</b> for {del.skins.map(hgtSkinName).join(", ")} survive, still pointing at label {del.id}.</>
              : <>No <code>skins_labels</code> rows exist for this one (published to all skins), so there is nothing to orphan there.</>,
            <><code>gamelabels_skin_assoc</code> — the per-skin game↔label assignments used by the Games screen — are not touched and survive keyed to label {del.id}.</>,
          ]} />
      )}
    </HrsShell>
  );
};

/* app.jsx renders <GameCategories/> / <GameSubcategories/> / <GameLabels/> for route keys
   cms-game-cat / cms-game-subcat / cms-game-labels (/cms/game-categories, /cms/game-subcategories,
   /cms/game-labels in src/routes.jsx). These three names previously came from the legacy
   HostCmsGames.jsx bundle, which no longer exists. */
window.GameCategories = GameCategories;
window.GameSubcategories = GameSubcategories;
window.GameLabels = GameLabels;
