// Represents: GET /importgames · ImportGamesController; GET /providers · ProvidersController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "Game import" + "Providers"
/* CMS ▾ → Game import  and  CMS ▾ → Providers.

   Both components deliberately shadow the same-named ones still bundled in
   src/pages/HostCmsGames.jsx (that file loads first; Babel-standalone makes every
   top-level const a window global, so the later definition wins). Nothing in the
   legacy bundle is edited — the other four CMS leaves it carries (Game categories,
   Game subcategories, Game labels, OAuth Clients) keep working untouched.

   ── Game import ──────────────────────────────────────────────────────────────
   ImportGamesController (app/Http/Controllers/ImportGamesController.php):
     selectIntegration() :19  → GET /importgames/                 (integration picker)
     games(Request)      :59  → GET /importgames/games/           (admin.games.import)
     getGames(Request)   :97  → GET /importgames/getGames/        (DataTables JSON)
     updatedbgame()      :29  → GET /importgames/updatedbgame/    (inline edit — GET performing writes)
     getsubcategories()  :33  → GET /importgames/getsubcategories/
     syncgames($integ)   :85  → GET /importgames/syncgames/{integration}/ (admin.games.sync, legacy)
   Business logic in App\Services\ImportGameAdminService (buildGamesTable :26,
   formatRow :167, updateGame :237, subcategoriesFor :277, syncGames :293).
   Views: admin/importgames/selectintegration.blade.php + games.blade.php +
   _paybo-head.blade.php; JS public/js/pages/importgames/ajax.js.

   Two-stage pipeline. `games_temp` (model GameTemp, migration
   2026_06_24_155202_create_games_temp_table) is a STAGING table: the provider API is
   fetched into it, then SyncGamesWithTemp promotes rows into the live `games` table
   with Game::updateOrCreate keyed on (external_id, integration_id, ext_provider_id).
   The grid is games_temp LEFT JOIN games — "Imported" simply means a matching `games`
   row exists. Import never attaches games to a skin: a game reaches a skin's frontend
   only when its provider is linked through skins_providers (view = 1), games.enabled = 1,
   providers.hide_games = 0 and the game is not in skins_games_disabled. Per-skin
   curation (gamesubcategories_skin_assoc, gamelabels_skin_assoc, games_skin_priorities)
   lives on the Skin management screens; the inline editors here write the GLOBAL
   gamesubcategories_assoc / gamelabels_assoc tables.

   ── Providers ────────────────────────────────────────────────────────────────
   ProvidersController (app/Http/Controllers/ProvidersController.php): index() L95,
   getProvidersTable() L127, providerForm() L132, saveProvider() L142, delete() L159,
   search() L172, syncTTProviders() L214, stati() L116. Business logic in
   App\Services\ProviderAdminService (buildProvidersTable L22, orderBySql L100,
   prepareFormData L127, save L147, storeImage L194). Validation:
   App\Http\Requests\Admin\StoreProviderRequest. Routes routes/admin.php:1262-1279 +
   1621-1623 (delete) — only `admin.providers.search` and `admin.providers.sync_tt`
   are named; the CRUD routes are URL-only.

   Cross-screen: `providers.custom_launch_url_id` points at a `launch_urls` row, managed on
   CMS → Game Launch URL (src/pages/HostCmsLaunchUrls.jsx, /launchurls). Both screens read
   that table directly now, so there is no list to keep in step and no fallback constant —
   the earlier window.getLaunchUrls() hand-off is gone. Terminology is unchanged:
   "Launch URL environment", value 0 = "use the integration default host".

   ── Known real-platform defects, handled per the repo's known-bug policy (CLAUDE.md) ──
   1. IMPORT-STATUS FILTER IS BROKEN (ImportGameAdminService :59-65): it tests
      `games_temp.id`, the base table's own PK, which is never NULL — so "Imported"
      matches every row and "Not imported" matches none. Evident intent implemented
      here: test the JOINED `games.id`.
      <!-- SUGGESTION: in ImportGameAdminService::buildGamesTable change the stato_importazione branch to whereNotNull('games.id') / whereNull('games.id'). As written the filter is a no-op that silently lies to the operator instead of erroring. -->
   2. PROVIDERS HEADER/VALUE COLUMN-ORDER MISMATCH: ProvidersController::index() L101-111
      registers the headers as ID, Logo, Name, Integration, Category, Number of games,
      Parent Provider, Cost, Featured — but ProviderAdminService L73-84 builds each row as
      ID, Logo, Name, Integration, Number of games, Parent Provider, Cost, Category,
      Featured. DataTables maps by index, so on the live screen the Category / Number of
      games / Parent Provider / Cost headers sit over shifted values. Evident intent
      implemented here: every header sits over its own value, in the header order.
      <!-- SUGGESTION: reorder the array pushed by ProviderAdminService::buildProvidersTable to match the header order declared in ProvidersController::index (or move Category to 8th in index) — today four columns of this list are mislabelled for every operator. -->
   3. GAME-IMPORT PROVIDER FILTER (ImportGameAdminService :75-81 + games.blade.php:49):
      value `1` is hijacked to mean "any classified provider", so a real provider whose id
      is 1 can never be filtered; "Not classified" (-1) compiles to `games.provider_id = -1`
      and matches nothing; other values filter `games.provider_id`, so rows that were never
      imported can never match. Additionally the view calls getProviders(0, $integration)
      with SWAPPED arguments (signature getProviders($integration_id = '', $user_id = 0)),
      so a super admin gets every provider of every integration in the dropdown. Evident
      intent implemented here: the dropdown lists only this integration's providers, a
      provider value filters on the joined providers row (imported or not), and
      "Not classified" means "no matching providers row".
      <!-- SUGGESTION: fix the argument order at games.blade.php:49 (getProviders($integration)) and filter on providers.id, using NULL / NOT NULL for the "not classified" / "any provider" cases instead of the magic 1 and -1 sentinels. -->
   4. BROKEN DEFAULT ORDER BY (ImportGameAdminService :46, :147-148): the fallback is
      `importgames.id ASC` and `case "integration_id"` maps to `importgames.integration_id`
      — `importgames` is a stale alias that does not exist in the query. The DB category and
      Active headers are marked sortable but unhandled server-side, so clicking either one
      raises an SQL error. Evident intent implemented here: they sort on games.category_id
      and games.enabled.
      <!-- SUGGESTION: replace the `importgames` alias with `games_temp` in ImportGameAdminService::orderBySql and add db_category_id → games.category_id and enabled → games.enabled cases, or mark those two columns "sortable" => false in ImportGamesController::games. -->
   5. PROVIDER SAVE DROPS FIELDS ON CREATE: `banner_img` and `cost` are not in
      Provider::$fillable, so Provider::create() silently discards them; the edit path uses
      a query-builder update() that bypasses fillable, so the same two fields DO persist on
      edit. `bcw_new_callback` is mapped from the request (ProviderAdminService L162) but has
      no control on the form, so every save writes 0 and silently clears it. Evident intent
      implemented here: banner and cost persist on create too, and a stored
      bcw_new_callback survives a save.
      <!-- SUGGESTION: add banner_img and cost to Provider::$fillable, and drop bcw_new_callback from the save payload (or give it a form control) so saving a provider stops clearing a flag the operator never saw. -->

   ── Real findings surfaced honestly, NOT papered over and NOT turned into new UI ──
   · UNAUTHENTICATED IMPORT TRIGGER. The Sync button on the games grid calls
     `crons.games.import` → GET /crons/games/import/{integration_id} (routes/cronjobs.php:106),
     which is registered with the `cronjobs` middleware group only — throttle:api + route
     bindings, NO auth (RouteServiceProvider.php:52-54, Kernel.php:66-70). Anyone who knows
     the URL can queue the platform's game import. The button is kept because the real page
     has it; the finding is stated in the page Explainer and next to the button. No extra
     operator trigger, retry, cancel or "run now" control is invented here.
     <!-- SUGGESTION: move GET /crons/games/import/{integration_id} behind the same auth/admin/2fa middleware the rest of the import screen uses (or require a signed URL / cron token) — today it is an unauthenticated write endpoint that queues provider API traffic. -->
   · Only TimelessTech (40) and Slotomatica (50) have a fetch implementation
     (app/Classes/Integrations/TimelessTech.php::syncGames :178,
     Slotomatica.php::syncGames :81). ImportGamesFromProvider resolves
     App\Classes\Integrations\<name> (job :42); for the other five no class exists, so the
     job logs critical "integration class not found" and then fatals on `new $class`. All
     seven picker cards are still rendered — selectintegration.blade.php:17-27 renders one
     per integrations() entry — with the dead ones labelled for what they are.
   · Both queue jobs are ShouldBeUnique with a CONSTANT uniqueId(), so only one import and
     one sync can be queued platform-wide at a time, whichever integration asked for it.
   · The legacy admin.games.sync route (/importgames/syncgames/{integration}/) promotes
     existing games_temp rows in-request and, unlike the job, includes category_id in the
     update array — so it overwrites admin-set categories. It is still routed but no longer
     linked from the UI, so no button for it is added here.
   · updatedbgame performs writes over HTTP GET with no CSRF protection.
   · ProvidersController::delete() is a GET endpoint and a hard delete with no reference
     checks — games, skins_providers rows and child providers pointing at parent_id are all
     left dangling. Handled here the same way the sibling Launch URLs screen handles its own
     references: delete is blocked while a provider still has games or child providers.
     <!-- SUGGESTION: make provider delete a POST/DELETE route, refuse it while games.provider_id / skins_providers / child parent_id rows still reference the provider, and clean the pivot rows when it is allowed. -->
   · admin.providers.search logs the built SQL to the default log on every call
     (\Log::info(get_query_from_builder(...)), L206).

   ── Faithful to the real screens, deliberately NOT added ──
   No KPIs or totals (both screens have none), no export (neither screen has one), no bulk
   or row-selection actions, no create/edit form on Game import (all editing there is
   inline), no `stato` control on the Providers form (save always forces 1 and the column is
   never surfaced), no RTP / volatility / tags / themes columns on the import grid (they are
   imported onto `games` but not editable on this screen), no per-skin provider enablement
   (that is /skins/{id}/providers, SkinsController@showSkinProviders, a different screen).

   Untranslated keys (runtime lang path storage/lang/ is gitignored — AppServiceProvider.php:137):
   backend.label, importgames_select_integration_subtitle, importgames_games_subtitle,
   importgames_back_to_integrations, importgames_sync_button, importgames_search_id_placeholder,
   importgames_search_name_placeholder, job_queued, cost, insert_cost, banner,
   provider_search_id_placeholder, provider_search_name_placeholder, launch_url_unknown and all
   nine provider_*_desc toggle descriptions. Operator-facing English is written for each and
   marked "label inferred" per the repo's label policy. */

const { useState: hgiUseState, useMemo: hgiUseMemo } = React;

/* ------------------------------------------------------------------ *
 * Reference data — all six lists are read, none are declared.
 *
 * This file used to open with ~250 lines of constants and generators: the seven
 * integrations, six categories, twelve subcategories, two labels, a 40-row
 * provider seed with RNG-assigned flags, six launch URLs, and a synthetic
 * `games_temp` builder that invented 214 staged games for TimelessTech and 96
 * for Slotomatica. Every one of those is a table now:
 *
 *   game_integrations · game_categories · game_subcategories · game_labels
 *   providers · launch_urls · game_import_staging
 *
 * The staging table is EMPTY, and that is the correct answer rather than a gap
 * to paper over: nothing has been fetched from a provider yet, so there is
 * nothing staged. The screen says so instead of showing 310 games that do not
 * exist.
 *
 * One fact stays in code because it is a fact about the codebase, not a row:
 * which integrations have an App\Classes\Integrations\<name> fetch class. The
 * database cannot know that, and it decides whether an empty grid means "not
 * fetched yet" or "can never be fetched".
 * ------------------------------------------------------------------ */
/* Provider boolean flags, as the form renders them.

   RESTORED. This was deleted along with the provider seed when this screen was
   first wired, and both use sites survived — so the New-provider form threw
   ReferenceError on open and the flags grid never rendered. It went unnoticed
   because nothing walks a screen far enough to open that form: the smoke
   harness renders a route, and this is behind a button on it.

   Keys are OUR column names, not isystem's. Three differ and silently would
   have written nothing:
     isystem is_sportbook  -> is_sportsbook
     isystem hide_guest    -> hide_from_guests
     isystem disabled      -> active (inverted; see below)

   `active` is deliberately absent from this list. isystem has `disabled`, and
   offering a toggle here that means the opposite of the column it writes is how
   a provider gets switched off by someone turning it on. The list is enable-
   shaped throughout. */
const HGI_PROV_FLAGS = [
  { key: "no_bonus",         label: "Disable bonus" },
  { key: "special_provider", label: "Special provider" },
  { key: "hide_games",       label: "Hide games", desc: <>Frontend visibility: a game shows on a skin only when its provider is linked to that skin, the game is enabled, <b><code>providers.hide_games</code> is false</b> and the game is not individually disabled. Turning this on hides every game of this provider on every skin at once.</> },
  { key: "hide_from_guests", label: "Hide games to guests" },
  { key: "is_sportsbook",    label: "Sportsbook" },
  { key: "featured",         label: "Featured" },
  { key: "fs_support",       label: "FS Support" },
  { key: "is_vip",           label: "VIP" },
];

const HGI_FETCH_CODES = ["timelesstech", "slotomatica"];

const hgiIntegRow = (r) => ({
  id: Number(r.id),
  name: String(r.name || ""),
  code: String(r.code || ""),
  active: r.active !== false,
  fetch: HGI_FETCH_CODES.indexOf(String(r.code || "")) !== -1,
});

const hgiCatRow2 = (r) => ({ id: Number(r.id), name: String(r.name || ""), code: r.code || "" });

const hgiSubRow2 = (r) => ({
  id: Number(r.id),
  name: String(r.internal_name || r.name || ""),
  category_id: r.category_id == null ? null : Number(r.category_id),
});

const hgiLabelRow2 = (r) => ({ id: Number(r.id), name: String(r.internal_name || r.name || "") });

const hgiLaunchRow = (r) => ({ id: Number(r.id), name: String(r.name || ""), url: String(r.url || "") });

/* PostgREST answers a count embed as [{count: n}]. */
const hgiCount = (v) => (Array.isArray(v) ? Number(v[0] && v[0].count) || 0 : Number(v) || 0);

/* providers row -> the shape both screens already render.
 *
 * Two column names differ from the flag keys the editor uses, and the third is
 * inverted. Mapping them here rather than renaming the form keys keeps the
 * divergence in one place:
 *   hide_guest   -> providers.hide_from_guests
 *   is_sportbook -> providers.is_sportsbook
 *   disabled     -> NOT providers.active   (isystem stores `disabled`, we store `active`)
 */
const hgiProvRow = (r) => ({
  id: Number(r.id),
  name: String(r.name || ""),
  slug: String(r.slug || ""),
  integration_id: Number(r.integration_id),
  category_id: r.category_id == null ? null : Number(r.category_id),
  parent_id: r.parent_id == null ? null : Number(r.parent_id),
  parent_name: r.parent ? r.parent.name : null,
  /* `cost` is free text on isystem; the column here is numeric(9,4). Rendered
     as stored, not reformatted — an operator comparing screens must see the
     same number. */
  cost: r.cost_rate == null ? "" : String(r.cost_rate),
  featured: r.featured ? 1 : 0,
  custom_launch_url: r.custom_launch_url_id == null ? 0 : Number(r.custom_launch_url_id),
  launch_url_name: r.launchUrl ? r.launchUrl.name : null,
  custom_frontend_name: r.frontend_name || "",
  img: r.logo_url || null,
  banner_img: r.banner_url || null,
  games: hgiCount(r.games),
  priority: Number(r.priority) || 0,
  /* stato is forced to 1 by ProviderAdminService::save L159 and never surfaced. */
  stato: 1,
  flags: {
    no_bonus: r.no_bonus ? 1 : 0,
    special_provider: r.special_provider ? 1 : 0,
    hide_games: r.hide_games ? 1 : 0,
    hide_guest: r.hide_from_guests ? 1 : 0,
    is_sportbook: r.is_sportsbook ? 1 : 0,
    featured: r.featured ? 1 : 0,
    fs_support: r.fs_support ? 1 : 0,
    disabled: r.active ? 0 : 1,
    is_vip: r.is_vip ? 1 : 0,
    bcw_new_callback: r.bcw_new_callback ? 1 : 0,
  },
});

/* game_import_staging row -> the games_temp shape the grid renders.
   `game_id`/`db_category_id`/`enabled` are the LEFT JOIN payload from `games`,
   which PostgREST returns as an embedded object rather than flattened columns. */
const hgiStageRow = (r) => ({
  id: Number(r.id),
  name: String(r.name || ""),
  category_id: r.category_id == null ? null : Number(r.category_id),
  external_id: r.external_id || "",
  ext_provider_id: r.external_provider_code || "",
  integration_id: Number(r.integration_id),
  provider_name: r.provider_slug || "",
  parent_provider_name: r.parent_provider_slug || "",
  is_desktop: r.is_desktop ? 1 : 0,
  is_mobile: r.is_mobile ? 1 : 0,
  is_online: r.is_online ? 1 : 0,
  thumbnail: r.thumbnail_url || "",
  tags: Array.isArray(r.tags) ? r.tags.join(", ") : (r.tags || ""),
  themes: Array.isArray(r.themes) ? r.themes.join(", ") : (r.themes || ""),
  groups: Array.isArray(r.groups) ? r.groups.join(", ") : (r.groups || ""),
  rtp: r.rtp == null ? "" : String(r.rtp),
  volatility: r.volatility || "",
  game_id: r.imported_game_id == null ? null : Number(r.imported_game_id),
  db_category_id: r.game && r.game.category_id != null ? Number(r.game.category_id) : null,
  enabled: r.game && r.game.enabled ? 1 : 0,
  /* Per-game subcategory and label links live in game_subcategory_assignments /
     game_label_assignments, keyed by games.id — not by the staging row. They are
     not read here yet, so the pickers open empty rather than showing a guess. */
  subcategories: [],
  labels: [],
});

/* One hook, six reads, one loading/error verdict. Both screens need most of
   this list, and fetching it per component would issue the same queries twice
   on a page that already renders two tables. */
const useHgiCatalogue = () => {
  const integ  = useHrsFetch(() => window.sb.list("gameIntegrations", { limit: 100 }), []);
  const cats   = useHrsFetch(() => window.sb.list("gameCategories", { limit: 200 }), []);
  const subs   = useHrsFetch(() => window.sb.list("gameSubcategories", { limit: 500 }), []);
  const labels = useHrsFetch(() => window.sb.list("gameLabels", { limit: 200 }), []);
  const provs  = useHrsFetch(() => window.sb.list("providers", { limit: 1000 }), []);
  const lus    = useHrsFetch(() => window.sb.list("launchUrls", { limit: 200 }), []);
  const all = [integ, cats, subs, labels, provs, lus];
  return {
    loading: all.some(f => f.loading),
    /* First error wins. Six separate error panels would be six copies of the
       same "you are signed out" message. */
    error: (all.find(f => f.error) || {}).error || null,
    retry: () => all.forEach(f => f.retry && f.retry()),
    integrations: hgiUseMemo(() => (integ.data  || []).map(hgiIntegRow),  [integ.data]),
    categories:   hgiUseMemo(() => (cats.data   || []).map(hgiCatRow2),   [cats.data]),
    subcategories:hgiUseMemo(() => (subs.data   || []).map(hgiSubRow2),   [subs.data]),
    labels:       hgiUseMemo(() => (labels.data || []).map(hgiLabelRow2), [labels.data]),
    providers:    hgiUseMemo(() => (provs.data  || []).map(hgiProvRow),   [provs.data]),
    launchUrls:   hgiUseMemo(() => (lus.data    || []).map(hgiLaunchRow), [lus.data]),
  };
};

/* Lookups take their list as an argument now — there is no module-level
   catalogue left to close over. */
const hgiNameIn = (list, id) => { const h = (list || []).find(x => x.id === Number(id)); return h ? h.name : null; };
const hgiFindIn = (list, id) => (list || []).find(x => x.id === Number(id)) || null;
const hgiSubsOf = (subs, categoryId) => (subs || []).filter(s => s.category_id === Number(categoryId));

/* ImportGamesFromProvider / SyncGamesWithTemp are ShouldBeUnique with a CONSTANT
   uniqueId(), so exactly one of each can sit in the queue platform-wide regardless of
   which integration queued it. Module-level so the lock survives switching integrations. */
const HGI_JOB_LOCK = { integration: null };


const HGI_PAGE_SIZES = [5, 10, 25, 50, 100, 500, 1000]; // ajax.js lengthMenu; pageLength 100

/* ------------------------------------------------------------------ *
 * Small presentational atoms
 * ------------------------------------------------------------------ */
const HgiBadge = ({ tone = "muted", title, children }) => (
  <span className={`hgi-badge hgi-badge--${tone}`} title={title}>{children}</span>
);

const HgiChips = ({ items, empty = "—" }) => {
  if (!items || !items.length) return <span className="hgi-dash">{empty}</span>;
  const shown = items.slice(0, 2);
  return (
    <span className="hgi-chips">
      {shown.map(t => <span key={t} className="hgi-chip">{t}</span>)}
      {items.length > shown.length && <span className="hgi-chip hgi-chip--more">+{items.length - shown.length}</span>}
    </span>
  );
};

/* Shared .bp-modal chrome, full-screen on mobile (brief §11). The real platform builds its
   modals with generaModalGestione() (app/Helpers/modal.php:5 → admin/utils/modal.blade.php):
   the body is AJAX-loaded from a /…/form endpoint and the save POSTs the serialized form to
   the action URL, rendering server errors into #<form_id>Errori from `campierrati`. */
const HgiModal = ({ title, sub, onClose, children, footer, wide }) => (
  <div className="bp-modal-scrim hgi-scrim" onClick={onClose}>
    <div className={`bp-modal hgi-modal${wide ? " hgi-modal--wide" : ""}`} onClick={e => e.stopPropagation()}>
      <div className="hgi-modal__head">
        <div>
          <div className="hgi-modal__title">{title}</div>
          {sub && <div className="hgi-modal__sub">{sub}</div>}
        </div>
        <button className="hgi-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hgi-modal__body">{children}</div>
      {footer && <div className="hgi-modal__foot">{footer}</div>}
    </div>
  </div>
);

/* Checkbox picker standing in for the inline select2 multi-selects on the import grid
   (Subcategory → setSubcategories(id), Label → setLabels(id)). A dialog rather than an
   in-cell popover because the table body is a horizontal scroll container that would clip
   one; the request it stands for is the same single updatedbgame call. */
const HgiPickerDialog = ({ title, sub, options, value, onClose, onApply }) => {
  const [sel, setSel] = hgiUseState(value.slice());
  const toggle = (id) => setSel(s => s.indexOf(id) === -1 ? [...s, id] : s.filter(x => x !== id));
  return (
    <HgiModal title={title} sub={sub} onClose={onClose}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
        <button className="btn btn--primary" onClick={() => { onApply(sel); onClose(); }}><Icon name="check" size={13} /> Apply</button>
      </>}>
      {options.length === 0 && (
        <div className="hgi-hint">No options available for this game's DB category. Subcategories are listed per category by <code>GameSubcategoriesController::getSubcategoriesList($category_id)</code>.</div>
      )}
      <div className="hgi-picklist">
        {options.map(o => (
          <label key={o.id} className="hgi-pickopt">
            <input type="checkbox" checked={sel.indexOf(o.id) !== -1} onChange={() => toggle(o.id)} />
            <span>{o.name}</span>
            <span className="hgi-pickid">#{o.id}</span>
          </label>
        ))}
      </div>
    </HgiModal>
  );
};

/* ================================================================== *
 * GAME IMPORT — integration picker
 * selectintegration.blade.php:17-27 renders one card per integrations() entry.
 * ================================================================== */
const HgiIntegrationPicker = ({ integrations, onPick }) => (
  <>
    <div className="hgi-pickhead">
      <div className="hgi-pickhead__t">Select Integration</div>
      <div className="hgi-pickhead__s">Each integration has its own staging table of fetched games{/* label inferred — backend.importgames_select_integration_subtitle is untranslated */}</div>
    </div>
    <div className="hgi-integgrid">
      {integrations.map(i => (
        <button key={i.id} className={`hgi-integ${i.fetch ? "" : " hgi-integ--dead"}`} onClick={() => onPick(i)}>
          <div className="hgi-integ__row">
            <span className="hgi-integ__n">{i.name}</span>
            <span className="hgi-integ__id">ID {i.id}</span>
          </div>
          <div className="hgi-integ__meta">
            {i.fetch
              ? <><Icon name="check" size={11} /> <code>App\Classes\Integrations\{i.name}</code></>
              : <><Icon name="alert" size={11} /> no fetch class — import job fails</>}
          </div>
          {i.code && <div className="hgi-integ__meta hgi-integ__meta--dim">integrations() slug <code>{i.code}</code></div>}
        </button>
      ))}
    </div>
    <div className="hgi-note">
      One card per <code>game_integrations</code> row, because the real picker renders one per <code>integrations()</code> entry — but only
      <b> TimelessTech</b> and <b>Slotomatica</b> have a fetch implementation. For the other five,
      <code> ImportGamesFromProvider</code> cannot resolve <code>App\Classes\Integrations\&lt;name&gt;</code>, logs
      <code> critical "integration class not found"</code> and then fatals, so their staging tables stay empty and their
      Sync buttons are dead. Nothing was removed here and nothing was invented — the cards are simply labelled for what they are.
    </div>
  </>
);

/* ================================================================== *
 * GAME IMPORT — games grid (games_temp LEFT JOIN games)
 * ================================================================== */
const HGI_GRID_SELECTS = ["provider", "category", "imported", "active"];

const HgiGamesGrid = ({ integ, cat }) => {
  /* One read per integration. The filters below stay client-side because the
     real screen filters client-side too (DataTables over a single payload) —
     and because the staging table for one integration is a catalogue fetch, not
     an unbounded log. */
  const feed = useHrsFetch(
    () => window.sb.list("importStaging", { limit: 2000, filters: { integration: integ.id } }),
    [integ.id]);
  const rows = hgiUseMemo(() => (feed.data || []).map(hgiStageRow), [feed.data]);
  const blank = { id: "", name: "", provider: "", category: "", imported: "", active: "" };
  const [draft, setDraft] = hgiUseState(blank);
  const [applied, setApplied] = hgiUseState(blank);
  /* DataTables sends Name DESC on load (ajax.js:33 order [[1,"desc"]]). */
  const [sort, setSort] = hgiUseState({ key: "name", dir: "desc" });
  const [page, setPage] = hgiUseState(0);
  const [pageSize, setPageSize] = hgiUseState(100); // ajax.js pageLength 100
  const [picker, setPicker] = hgiUseState(null); // null | { row, kind }
  const [queued, setQueued] = hgiUseState(HGI_JOB_LOCK.integration);

  const provs = hgiUseMemo(() => cat.providers.filter(p => p.integration_id === integ.id), [cat.providers, integ.id]);

  /* DIVERGENCE (defect 3): the real dropdown is fed getProviders(0, $integration) — the
     arguments are swapped against the signature getProviders($integration_id = '', $user_id = 0),
     so a super admin sees every provider of every integration. Evident intent: this
     integration's providers only. "Not classified" keeps the real -1 sentinel value. */
  const FIELDS = [
    { key: "id", label: "ID", type: "text", icon: "tag", width: 130, placeholder: "Exact ID",
      tip: <>Exact match on <code>games_temp.id</code> — the staging row id, not the internal <code>games.id</code> shown under the name.</> },
    { key: "name", label: "Name", type: "text", icon: "search", grow: true, placeholder: "Game name",
      tip: <>Contains match on <code>games_temp.name</code>.</> },
    { key: "provider", label: "Provider", type: "select", icon: "grid", placeholder: "Select",
      options: [...provs.map(p => ({ value: String(p.id), label: p.name })), { value: "-1", label: "Not classified" }],
      tip: <>Fed by <code>ProvidersController::getProviders()</code>. On the real platform the arguments are swapped, so this list shows every provider of every integration and the values <code>1</code>/<code>-1</code> are hijacked as sentinels — here it lists only {integ.name}'s providers and <b>Not classified</b> means "no <code>providers</code> row matches the staged vendor slug".</> },
    { key: "category", label: "Category", type: "select", icon: "list", placeholder: "Select",
      options: cat.categories.map(c => ({ value: String(c.id), label: c.name })),
      tip: <>Filters <code>games.category_id</code> — the <b>DB category</b> of the imported game, not the provider category from the payload. Rows that were never imported therefore never match.</> },
    /* DIVERGENCE (defect 1): the server tests games_temp.id, the base-table PK, which is
       never NULL — "Imported" matches everything and "Not imported" nothing. Evident
       intent implemented: test the joined games.id. */
    { key: "imported", label: "Import status", type: "select", icon: "check", placeholder: "Select",
      options: [{ value: "1", label: "Imported" }, { value: "0", label: "Not imported" }],
      tip: <>Broken on the real platform: it tests <code>games_temp.id</code> (the base table's own primary key, never NULL) instead of the joined <code>games.id</code>, so "Imported" returns every row and "Not imported" returns none. Implemented here as the intended <code>games.id IS (NOT) NULL</code> test.</> },
    { key: "active", label: "Active", type: "select", icon: "zap", placeholder: "Select",
      options: [{ value: "1", label: "Active" }, { value: "0", label: "Inactive" }],
      tip: <>Filters <code>games.enabled</code>. The real dropdown labels these "Attivo" / "Non attivo" — hardcoded Italian in <code>ImportGamesController::statiGiochi()</code> with no translation call; English is used here.</> },
  ];

  /* Text boxes apply on Search/Enter, dropdowns apply the moment they change — the real
     page's behaviour (games.blade.php filter cards + ajax.js). */
  const onChange = (k, v) => {
    const next = { ...draft, [k]: v };
    setDraft(next);
    if (HGI_GRID_SELECTS.indexOf(k) !== -1) { setApplied(next); setPage(0); }
  };
  const onSearch = (v) => { setApplied({ ...blank, ...v }); setPage(0); };
  const onReset = () => { setDraft(blank); setApplied(blank); setPage(0); };

  const filtered = hgiUseMemo(() => {
    const f = applied;
    const nq = String(f.name || "").trim().toLowerCase();
    return rows.filter(r => {
      if (f.id && String(r.id) !== String(f.id).trim()) return false;
      if (nq && r.name.toLowerCase().indexOf(nq) === -1) return false;
      if (f.provider) {
        const prov = provs.find(p => p.slug === r.provider_name) || null;
        if (f.provider === "-1") { if (prov) return false; }
        else if (!prov || String(prov.id) !== f.provider) return false;
      }
      if (f.category && String(r.db_category_id || "") !== f.category) return false;
      if (f.imported === "1" && r.game_id == null) return false;
      if (f.imported === "0" && r.game_id != null) return false;
      if (f.active && String(r.enabled) !== f.active) return false;
      return true;
    });
  }, [rows, applied, provs]);

  /* orderBySql (service :136-160) maps id → games_temp.id, game_name → games_temp.name,
     provider → providers.name, is_mobile / is_desktop, category_id → gamecategories.name,
     stato_importazione → games.id. db_category / enabled are unhandled server-side and fall
     through to the non-existent `importgames.id` alias (defect 4) — sorted properly here. */
  const sorted = hgiUseMemo(() => {
    const dir = sort.dir === "asc" ? 1 : -1;
    const provName = (r) => { const p = provs.find(x => x.slug === r.provider_name); return p ? p.name : r.provider_name; };
    const val = (r) => {
      switch (sort.key) {
        case "name": return r.name.toLowerCase();
        case "provider": return provName(r).toLowerCase();
        case "is_desktop": return r.is_desktop;
        case "is_mobile": return r.is_mobile;
        case "provider_category": return (hgiNameIn(cat.categories, r.category_id) || "").toLowerCase();
        case "db_category": return (hgiNameIn(cat.categories, r.db_category_id) || "").toLowerCase();
        case "status": return r.game_id == null ? 0 : 1;
        case "enabled": return r.enabled;
        default: return r.id;
      }
    };
    return filtered.slice().sort((a, b) => {
      const va = val(a), vb = val(b);
      if (va === vb) return (a.id - b.id) * dir;
      return (va > vb ? 1 : -1) * dir;
    });
  }, [filtered, sort, provs, cat.categories]);

  const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
  const safePage = Math.min(page, pageCount - 1);
  const paged = sorted.slice(safePage * pageSize, safePage * pageSize + pageSize);

  /* GET /importgames/updatedbgame/?id=&param=&value= — writes over GET, no CSRF, no
     per-action permission beyond the route-group middleware. Only reachable for rows that
     already have a `games` row. */
  /* Every inline editor on this grid writes to `games`, and no write path
     exists yet. They report what they would do and change nothing — a select
     that visibly moves while the row does not is worse than one that does not
     move, because the operator believes the first one. */
  const patch = (row, what) => hrsToast("Not saved — no write path yet",
    `Would ${what} for "${row.name}" (games.id ${row.game_id}). Reads are live; writes land in stage 7.`);

  const setDbCategory = (row, catId) => {
    /* updateGame(param=category_id) also calls updateGameSubcategories($id, array()) —
       changing the DB category CLEARS every subcategory link, then the JS reloads the
       options for the new category (service :253-256). Reproduced, not a bug. */
    patch(row, `set games.category_id = ${catId === "" ? "NULL" : Number(catId)} (${hgiNameIn(cat.categories, catId) || "—"}) and clear its game_subcategory_assignments rows`);
  };

  const onSync = () => {
    if (HGI_JOB_LOCK.integration) {
      hrsToast("Already queued",
        `ImportGamesFromProvider / SyncGamesWithTemp are ShouldBeUnique with a constant uniqueId(), so only one of each can be queued platform-wide — the run for ${HGI_JOB_LOCK.integration} is still holding the lock.`);
      return;
    }
    HGI_JOB_LOCK.integration = integ.name;
    setQueued(integ.name);
    if (integ.fetch) {
      hrsToast(`Import Games Job for integration ${integ.name} queued. Will be processed soon...`,
        "Bus::chain([ImportGamesFromProvider, SyncGamesWithTemp]) — fetch the provider catalogue into games_temp, then promote it into games. Logged to the gamesync channel.");
    } else {
      hrsToast(`Import Games Job for integration ${integ.name} queued. Will be processed soon...`,
        `The chain will fail: no App\\Classes\\Integrations\\${integ.name} class exists, so ImportGamesFromProvider logs critical "integration class not found" and then fatals on new $class.`);
    }
  };

  const providerCell = (r) => {
    const p = provs.find(x => x.slug === r.provider_name);
    return p
      ? <span className="hgi-prov">{p.name}</span>
      : <HgiBadge tone="err" title={`No providers row with slug "${r.provider_name}" for integration ${integ.id}. SyncGamesWithTemp skips rows whose provider_name has no match, so this game can never be imported.`}>{r.provider_name}</HgiBadge>;
  };

  const subNames = (r) => r.subcategories.map(id => hgiNameIn(cat.subcategories, id)).filter(Boolean);
  const labelNames = (r) => r.labels.map(id => hgiNameIn(cat.labels, id)).filter(Boolean);

  const columns = [
    { key: "id", label: "ID", sortable: true, firstDir: "asc", width: 96, render: r => <span className="hgi-id">{r.id}</span> },
    { key: "name", label: "Name", sortable: true, firstDir: "desc", render: r => (
      <div className="hgi-namecell">
        <span className="hgi-gamename">{r.name}</span>
        {r.game_id != null && <span className="hgi-internal">(Internal ID: {r.game_id})</span>}
      </div>
    ) },
    { key: "provider", label: "Provider", sortable: true, firstDir: "asc", render: providerCell },
    { key: "is_desktop", label: "Desktop", sortable: true, align: "center", width: 104,
      render: r => <HgiBadge tone={r.is_desktop ? "ok" : "err"}>Desktop</HgiBadge> },
    { key: "is_mobile", label: "Mobile", sortable: true, align: "center", width: 100,
      render: r => <HgiBadge tone={r.is_mobile ? "ok" : "err"}>Mobile</HgiBadge> },
    { key: "provider_category", label: "Provider category", sortable: true, firstDir: "asc",
      render: r => hgiNameIn(cat.categories, r.category_id) || <span className="hgi-dash">—</span> },
    /* Inline editors below are only rendered for rows that already have a `games` row —
       exactly like formatRow() (service :167-231), which prints a plain dash otherwise. */
    { key: "db_category", label: "DB category", sortable: true, firstDir: "asc", width: 168, render: r => (
      r.game_id == null
        ? <span className="hgi-dash" title="Editable only once the game has been imported into the games table.">—</span>
        : <select className="hrs-fctl hgi-cellsel" value={r.db_category_id || ""} onChange={e => setDbCategory(r, e.target.value)}>
            <option value="">—</option>
            {cat.categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
          </select>
    ) },
    { key: "subcategories", label: <>Subcategory <Tip size={12}>Rewrites the <b>global</b> <code>gamesubcategories_assoc</code> rows for this game — not the per-skin <code>gamesubcategories_skin_assoc</code> table, which is curated on the Skin management screens. Options are the subcategories of the game's <b>DB category</b>, reloaded from <code>/importgames/getsubcategories/</code> whenever that category changes.</Tip></>,
      width: 190, render: r => (
      r.game_id == null
        ? <span className="hgi-dash">—</span>
        : <button className="hgi-cellbtn" onClick={() => setPicker({ row: r, kind: "sub" })}>
            <HgiChips items={subNames(r)} empty="Set…" />
            <Icon name="chevron_down" size={11} />
          </button>
    ) },
    { key: "labels", label: <>Label{/* label inferred — backend.label is absent from the committed lang file */} <Tip size={12}>Rewrites the <b>global</b> <code>gamelabels_assoc</code> rows — the "Hot" / "New" style tags from the Game labels screen. Per-skin label assignment lives in <code>gamelabels_skin_assoc</code> and is not edited here.</Tip></>,
      width: 170, render: r => (
      r.game_id == null
        ? <span className="hgi-dash">—</span>
        : <button className="hgi-cellbtn" onClick={() => setPicker({ row: r, kind: "label" })}>
            <HgiChips items={labelNames(r)} empty="Set…" />
            <Icon name="chevron_down" size={11} />
          </button>
    ) },
    { key: "status", label: "Status", sortable: true, align: "center", width: 130,
      render: r => r.game_id != null
        ? <HgiBadge tone="ok">Imported</HgiBadge>
        : <HgiBadge tone="warn" title="No matching games row yet — SyncGamesWithTemp has not promoted this staged row.">Not imported</HgiBadge> },
    { key: "enabled", label: "Active", sortable: true, align: "center", width: 110, render: r => (
      r.game_id == null
        ? <span className="hgi-dash" title="Editable only once the game has been imported.">—</span>
        : <Toggle value={!!r.enabled} size="sm" onLabel="" offLabel=""
            onChange={(v) => patch(r, `set games.enabled = ${v ? 1 : 0}`)} />
    ) },
  ];

  const pickerOptions = picker
    ? (picker.kind === "sub" ? hgiSubsOf(cat.subcategories, picker.row.db_category_id) : cat.labels)
    : [];

  return (
    <>
      {!integ.fetch && (
        <div className="hgi-alert">
          <Icon name="alert" size={14} />
          <div>
            <b>{integ.name} has no fetch implementation.</b> No <code>App\Classes\Integrations\{integ.name}</code> class exists, so
            <code> ImportGamesFromProvider</code> logs <code>critical "integration class not found"</code> and then fatals — nothing has
            ever been written to <code>game_import_staging</code> for integration {integ.id}. The grid below is empty for that reason, not because a filter is set.
          </div>
        </div>
      )}

      <HrsFilters fields={FIELDS} values={draft} onChange={onChange} onSearch={onSearch} onReset={onReset}
        resultLabel={`${hrsInt(sorted.length)} of ${hrsInt(rows.length)}`} />

      <div className="hgi-syncrow">
        <button className="hrs-btn hrs-btn--filters hgi-syncbtn" onClick={onSync}>
          <Icon name="refresh" size={14} /> Update and sync games{/* label inferred — backend.importgames_sync_button is untranslated */}
        </button>
        <span className="hgi-syncnote">
          Queues <code>Bus::chain([ImportGamesFromProvider, SyncGamesWithTemp])</code> for {integ.name}.
          <Tip size={12}>
            <b>Security finding, stated as found:</b> this button calls <code>crons.games.import</code> →
            <code> GET /crons/games/import/{integ.id}</code>, declared in <code>routes/cronjobs.php:106</code> under the
            <code> cronjobs</code> middleware group — <code>throttle:api</code> plus route bindings and <b>no authentication at all</b>.
            Anyone who knows the URL can queue the platform's game import. Nothing extra is exposed here; the button simply mirrors the real one.
          </Tip>
        </span>
        {queued && (
          <span className="hgi-queued">
            <Icon name="check" size={12} /> Queued for {queued}
            <Tip size={12}>Both jobs are <code>ShouldBeUnique</code> with a constant <code>uniqueId()</code>, so a second import or sync cannot be queued anywhere on the platform until this one clears.</Tip>
          </span>
        )}
      </div>

      {/* Three reasons this grid can be empty and they are not the same answer:
          the read failed, the integration has no fetch class, or nothing has
          been fetched yet. HrsAsync separates the first from the other two;
          `empty` below separates those two from each other. */}
      <HrsAsync state={feed} skeletonRows={10} skeletonCols={8}
                empty={integ.fetch
                  ? `Nothing staged for ${integ.name}. game_import_staging is filled by the import job — until it has run, this table is empty by definition, not by filter.`
                  : `game_import_staging holds no rows for ${integ.name}, and never will: the integration has no fetch class for the import job to call.`}>
        {() => (<>
      <HrsTable
        columns={columns} rows={paged} rowKey="id" sort={sort} onSort={(s) => { setSort(s); setPage(0); }}
        empty={integ.fetch
          ? "No staged game matches these filters."
          : `game_import_staging holds no rows for ${integ.name} — the integration has no fetch class, so the import job has never been able to write any.`}
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.name}</b>
              {r.game_id != null ? <HgiBadge tone="ok">Imported</HgiBadge> : <HgiBadge tone="warn">Not imported</HgiBadge>}
            </div>
            <div className="hrs-card__grid">
              <span>ID</span><b>{r.id}</b>
              <span>Provider</span><b>{providerCell(r)}</b>
              <span>Platform</span><b>
                <HgiBadge tone={r.is_desktop ? "ok" : "err"}>Desktop</HgiBadge>{" "}
                <HgiBadge tone={r.is_mobile ? "ok" : "err"}>Mobile</HgiBadge>
              </b>
            </div>
            <details className="hgi-cardmore">
              <summary>More</summary>
              <div className="hrs-card__grid">
                <span>Internal ID</span><b>{r.game_id != null ? r.game_id : "—"}</b>
                <span>Provider category</span><b>{hgiNameIn(cat.categories, r.category_id) || "—"}</b>
                <span>DB category</span><b>{hgiNameIn(cat.categories, r.db_category_id) || "—"}</b>
                <span>Subcategory</span><b><HgiChips items={subNames(r)} /></b>
                <span>Label</span><b><HgiChips items={labelNames(r)} /></b>
              </div>
              {r.game_id != null && (
                <div className="hgi-cardacts">
                  <select className="hrs-fctl" value={r.db_category_id || ""} onChange={e => setDbCategory(r, e.target.value)}>
                    <option value="">—</option>
                    {cat.categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
                  </select>
                  <button className="btn btn--secondary btn--sm" onClick={() => setPicker({ row: r, kind: "sub" })}>Subcategories</button>
                  <button className="btn btn--secondary btn--sm" onClick={() => setPicker({ row: r, kind: "label" })}>Labels</button>
                  <Toggle value={!!r.enabled} size="sm" onLabel="Active" offLabel="Inactive"
                    onChange={(v) => patch(r, `set games.enabled = ${v ? 1 : 0}`)} />
                </div>
              )}
            </details>
          </>
        )} />

      <HrsPager page={safePage} pageSize={pageSize} total={sorted.length} sizes={HGI_PAGE_SIZES}
        onPage={setPage} onPageSize={(n) => { setPageSize(n); setPage(0); }} />
        </>)}
      </HrsAsync>

      {picker && (
        <HgiPickerDialog
          title={picker.kind === "sub" ? "Subcategories" : "Labels"}
          sub={<>{picker.row.name} · games.id {picker.row.game_id} · writes the global {picker.kind === "sub" ? <code>gamesubcategories_assoc</code> : <code>gamelabels_assoc</code>} table</>}
          options={pickerOptions}
          value={picker.kind === "sub" ? picker.row.subcategories : picker.row.labels}
          onClose={() => setPicker(null)}
          onApply={(sel) => {
            patch(picker.row, `replace its ${picker.kind === "sub" ? "game_subcategory_assignments" : "game_label_assignments"} rows with ${sel.length} link(s)`);
          }} />
      )}

      <div className="hgi-note">
        <b>Not editable here:</b> RTP, volatility, tags and themes are imported from the provider payload onto <code>games</code>
        but this screen only edits DB category, subcategories, labels and <code>enabled</code>. <b>Not linked here:</b> the legacy
        <code> /importgames/syncgames/{"{"}integration{"}"}/</code> route still exists and promotes staged rows in-request, but unlike the queued job it
        includes <code>category_id</code> in its update array and so overwrites admin-set categories — it is no longer reachable from the
        real UI, so no button for it was added.
      </div>
    </>
  );
};

/* ================================================================== *
 * GameImport — picker ⇄ grid, each with its own URL
 * ================================================================== */
/* The tab routes are derived from game_integrations, so they cannot be built
   until that read lands. useUrlTab resolves a deep link ONCE, in a lazy
   useState initialiser, against the tab list it is handed at mount — hand it an
   empty list because the fetch is still in flight and /cms/game-import/timelesstech
   silently resolves to no tab. Hence the split: GameImport owns the read and the
   shell, and the inner component (which owns useUrlTab) does not mount until the
   list is real. */
const GameImport = () => {
  window.useLocale && window.useLocale();
  const cat = useHgiCatalogue();
  if (cat.loading) return <HgiImportShell><HrsSkeleton rows={7} cols={4} /></HgiImportShell>;
  if (cat.error) return <HgiImportShell><HrsError error={cat.error} onRetry={cat.retry} /></HgiImportShell>;
  return <HgiImportTabs cat={cat} />;
};

/* The shell chrome — title, gate, explainer — for all three states. The title
   and the back button are the only things that differ between the picker view
   and an integration's grid, so they are props rather than a second shell. */
const HgiImportShell = ({ integ, onBack, children }) => {
  return (
    <HrsShell
      title={integ ? <>Game import — {integ.name}</> : "Game import"}
      subtitle={integ
        ? <>Staged catalogue for integration {integ.id} — <code>game_import_staging</code> joined to the live <code>games</code> table</>
        : "Fetch a provider's catalogue into staging, then promote it into the live games table"}
      gate={<>Real-platform access: the whole CMS ▾ menu is wrapped in <code>@if (isadmin())</code> (sidebar.blade.php:733), so the link is <b>Super Admin only</b> (user_level 0). The reduced non-admin CMS menu (<code>$can_manage_cms</code>) does not contain Game import. </>}
      gateNote={<>Permission asymmetry, honestly: only <code>selectIntegration()</code> re-checks the role server-side (<code>abort(404)</code>, ImportGamesController :21-23). <code>games</code>, <code>getGames</code>, <code>updatedbgame</code>, <code>getsubcategories</code> and <code>syncgames</code> carry <b>no role check</b> beyond the route-group middleware, so any authenticated, 2FA'd back-office user who knows the URLs can list and edit the catalogue. Worse, the Sync button's own endpoint <code>crons.games.import</code> sits in <code>routes/cronjobs.php</code> behind <code>throttle:api</code> alone — <b>no authentication at all</b>.</>}
      explainer={{ title: "How the import pipeline works, in plain English", bullets: [
        <><b>Two stages, two tables.</b> <code>ImportGamesFromProvider</code> fetches the provider's catalogue into the staging table <code>games_temp</code>; <code>SyncGamesWithTemp</code> then promotes those rows into the live <code>games</code> table with <code>updateOrCreate</code> keyed on (<code>external_id</code>, <code>integration_id</code>, <code>ext_provider_id</code>). This grid is <code>games_temp</code> LEFT JOIN <code>games</code> — "Imported" just means the live row exists.</>,
        <><b>Categories survive a re-sync.</b> The job sets <code>category_id</code> only on newly created games, so a DB category set here is not overwritten the next time the provider is fetched.</>,
        <><b>Unmatched vendors are dropped.</b> A staged row whose <code>provider_name</code> has no <code>providers</code> row with the same slug and integration is skipped silently by the sync — it shows a red provider badge here and can never be imported until the provider exists (create it on CMS → Providers).</>,
        <><b>Importing does not publish.</b> A game reaches a skin's frontend only when its provider is linked through <code>skins_providers</code> (<code>view = 1</code>), <code>games.enabled = 1</code>, <code>providers.hide_games = 0</code> and the game is not in <code>skins_games_disabled</code>. The subcategory and label editors here write the <b>global</b> assoc tables; per-skin curation lives on the Skin management screens.</>,
        <><b>Edits are GET requests.</b> Every inline change posts through <code>GET /importgames/updatedbgame/</code> — a state change over GET with no CSRF protection.</>,
      ] }}
      actions={onBack
        ? <button className="hrs-btn hrs-btn--search" onClick={onBack}>
            <Icon name="chevron_left" size={14} /> Integrations{/* label inferred — backend.importgames_back_to_integrations is untranslated */}
          </button>
        : null}>
      {children}
    </HrsShell>
  );
};

/* Owns the URL tab state. Mounted only once `cat.integrations` is populated. */
const HgiImportTabs = ({ cat }) => {
  const routes = hgiUseMemo(
    () => cat.integrations.map(i => [i.name, i.name, window.slugifyTab(i.name)]),
    [cat.integrations]);
  const [tab, setTab] = window.useUrlTab("/cms/game-import", routes, null);
  const integ = tab ? cat.integrations.find(i => i.name === tab) : null;

  /* A deep link to an integration that no longer exists in game_integrations
     lands here. Saying so beats silently showing the picker as if the URL had
     been the bare path. */
  if (tab && !integ) {
    return (
      <HgiImportShell onBack={() => setTab(null)}>
        <HrsEmpty>No integration named "{tab}" in <code>game_integrations</code>.</HrsEmpty>
      </HgiImportShell>
    );
  }

  return (
    <HgiImportShell integ={integ} onBack={integ ? () => setTab(null) : null}>
      {integ
        ? <HgiGamesGrid key={integ.id} integ={integ} cat={cat} />
        : <HgiIntegrationPicker integrations={cat.integrations} onPick={(i) => setTab(i.name)} />}
    </HgiImportShell>
  );
};

/* ================================================================== *
 * PROVIDERS — create / edit modal
 * GET /providers/form/?id= → POST /providers/saveProvider/?id=
 * Sectioned "Data" / "Settings" panels with Explainer callouts, the shape Settings.jsx uses.
 * ================================================================== */
const HgiProviderModal = ({ provider, allProviders, cat, onClose, onSave }) => {
  const isNew = !provider;
  const [d, setD] = hgiUseState(() => provider ? {
    name: provider.name, integration_id: String(provider.integration_id),
    custom_frontend_name: provider.custom_frontend_name || "", category_id: provider.category_id ? String(provider.category_id) : "",
    slug: provider.slug, parent_id: provider.parent_id ? String(provider.parent_id) : "",
    custom_launch_url: String(provider.custom_launch_url), cost: provider.cost || "",
    flags: { ...provider.flags },
  } : {
    /* The Integration select has no empty option — the first entry is preselected
       (forms/provider.blade.php), so a new provider defaults to Novusbet (15). */
    /* The Integration select has no empty option on the real form, so a new
       provider defaults to the first row of game_integrations rather than to a
       hardcoded 15. An empty catalogue leaves it blank and the required-field
       check below catches it. */
    name: "", integration_id: String((cat.integrations[0] || {}).id || ""), custom_frontend_name: "",
    category_id: "", slug: "", parent_id: "", custom_launch_url: "0", cost: "",
    flags: HGI_PROV_FLAGS.reduce((o, f) => { o[f.key] = 0; return o; }, { bcw_new_callback: 0 }),
  });
  const [errs, setErrs] = hgiUseState({});   // "campierrati" — the field list the server flags
  const [banner, setBanner] = hgiUseState("");

  const set = (k, v) => { setD(x => ({ ...x, [k]: v })); setErrs(e => { const n = { ...e }; delete n[k]; return n; }); setBanner(""); };
  const setFlag = (k, v) => setD(x => ({ ...x, flags: { ...x.flags, [k]: v ? 1 : 0 } }));

  const launchUrls = cat.launchUrls;
  const selectedLaunch = d.custom_launch_url === "0" ? null : hgiFindIn(launchUrls, d.custom_launch_url);

  const save = () => {
    /* StoreProviderRequest rules L18-26: name required|string, integration_id required,
       custom_launch_url nullable|integer + closure (0 always allowed, else must exist in
       launch_urls). Everything else carries a UI-only required marker and no server rule. */
    const e = {};
    if (!d.name.trim()) e.name = "Insert name"; // backend.insert_name
    if (!d.integration_id) e.integration_id = "Fill in the Integration field"; /* label inferred — the real message is hardcoded Italian "Compila il campo Integration" */
    if (d.custom_launch_url !== "0" && !hgiFindIn(launchUrls, d.custom_launch_url)) e.custom_launch_url = "Unknown launch URL environment"; /* label inferred — backend.launch_url_unknown has no committed EN translation */
    setErrs(e);
    const first = ["name", "integration_id", "custom_launch_url"].map(k => e[k]).filter(Boolean)[0];
    if (first) { setBanner(first); return; }
    onSave({
      id: isNew ? null : provider.id,
      name: d.name.trim(),
      integration_id: Number(d.integration_id),
      custom_frontend_name: d.custom_frontend_name.trim(),
      category_id: d.category_id ? Number(d.category_id) : null,
      /* ProviderAdminService L172: an empty slug is auto-generated with altnome(name). */
      slug: d.slug.trim() || d.name.trim().toLowerCase().replace(/[^a-z0-9]+/g, ""),
      parent_id: d.parent_id ? Number(d.parent_id) : null,
      custom_launch_url: Number(d.custom_launch_url),
      cost: d.cost.trim(),
      featured: d.flags.featured,
      flags: { ...d.flags },
    });
    onClose();
  };

  const parentOptions = allProviders.filter(p => !provider || p.id !== provider.id);

  return (
    <HgiModal wide
      title={isNew ? <>New provider{/* label inferred — the real modal opens with the leftover Italian title "Nuovo provider" */}</> : `Edit ${provider.name}`}
      sub={isNew ? "POST /providers/saveProvider/" : <>POST /providers/saveProvider/?id={provider.id} · providers.id {provider.id}</>}
      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="hgi-err hgi-err--banner"><Icon name="alert" size={13} /> {banner}</div>}

      <Explainer compact title="What this form actually validates">
        Only three rules exist server-side (<code>StoreProviderRequest</code>): <b>Name</b> is required, <b>Integration</b> is required, and
        <b> Custom Launch URL</b> must be <code>0</code> or an existing <code>launch_urls</code> row. Logo, Banner, Games category and Parent
        Provider all show a required marker in the real form but the server accepts them empty. <code>stato</code> is forced to <code>1</code> on
        every save and has no control — a provider saved from this screen is always active.
      </Explainer>

      <div className="hgi-sectitle">Data</div>{/* backend.generic_data */}

      <div className="hgi-grid2">
        <div className="hgi-field">
          <label className="hgi-label" htmlFor="hgi-p-name">Name <span className="hgi-req">*</span></label>
          <input id="hgi-p-name" className={`input${errs.name ? " hgi-invalid" : ""}`} value={d.name} autoFocus
            onChange={e => set("name", e.target.value)} />
          {errs.name && <div className="hgi-fielderr">{errs.name}</div>}
        </div>
        <div className="hgi-field">
          <label className="hgi-label" htmlFor="hgi-p-integ">Integration <span className="hgi-req">*</span></label>
          <select id="hgi-p-integ" className={`select${errs.integration_id ? " hgi-invalid" : ""}`} value={d.integration_id}
            onChange={e => set("integration_id", e.target.value)}>
            {cat.integrations.map(i => <option key={i.id} value={i.id}>{i.name}</option>)}
          </select>
          <div className="hgi-hint">From <code>integrations()</code> (app/Helpers/utils.php:2037). The select has no empty option, so a new provider silently defaults to the first entry.</div>
          {errs.integration_id && <div className="hgi-fielderr">{errs.integration_id}</div>}
        </div>
      </div>

      <div className="hgi-field">
        <label className="hgi-label" htmlFor="hgi-p-front">Custom Frontend Name</label>
        <input id="hgi-p-front" className="input" value={d.custom_frontend_name} onChange={e => set("custom_frontend_name", e.target.value)} />
      </div>

      <div className="hgi-grid2">
        <div className="hgi-field">
          <label className="hgi-label">Logo <span className="hgi-req">*</span></label>
          <div className="hgi-imgbox">
            <button className="hgi-imgbtn" onClick={() => hrsToast("Logo upload", "Stored as public/providers/logo/{altnome(name)}_{uniqid}.{ext} and referenced as /storage/providers/logo/… (needs the storage symlink). Accepts .png / .jpg / .jpeg; no server-side validation despite the required marker.")}>
              <Icon name="upload" size={13} /> Upload
            </button>
            <span className="hgi-imgph" aria-hidden="true" />
          </div>
          <div className="hgi-hint">Rendered in the list on a black tile, 50px; falls back to <code>/img/noimg.jpg</code>.</div>
        </div>
        <div className="hgi-field">
          <label className="hgi-label">Banner{/* label inferred — backend.banner has no committed EN translation */} <span className="hgi-req">*</span></label>
          <div className="hgi-imgbox hgi-imgbox--wide">
            <button className="hgi-imgbtn" onClick={() => hrsToast("Banner upload", "Stored under public/providers/banner/. Not in Provider::$fillable — on the real platform it is dropped on create and only persists on edit.")}>
              <Icon name="upload" size={13} /> Upload
            </button>
            <span className="hgi-imgph" aria-hidden="true" />
          </div>
          {/* DIVERGENCE (defect 5): banner_img is missing from Provider::$fillable, so
              Provider::create() discards it while the edit path's query-builder update()
              keeps it. Evident intent implemented — the banner persists on create too. */}
          <div className="hgi-hint">Not in <code>Provider::$fillable</code>: on the real platform a banner chosen while creating a provider is silently discarded and only sticks when you re-open and save the row. Kept on create here.</div>
        </div>
      </div>

      <div className="hgi-grid2">
        <div className="hgi-field">
          <label className="hgi-label" htmlFor="hgi-p-cat">Games category <span className="hgi-req">*</span></label>
          <select id="hgi-p-cat" className="select" value={d.category_id} onChange={e => set("category_id", e.target.value)}>
            <option value="">Select</option>
            {cat.categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
          </select>
          <div className="hgi-hint">Left empty the list shows a red italic <i>Missing</i> in the Category column — the server does not enforce this field.</div>
        </div>
        <div className="hgi-field">
          <label className="hgi-label" htmlFor="hgi-p-slug">Slug</label>
          <input id="hgi-p-slug" className="input" value={d.slug} placeholder={d.name ? d.name.toLowerCase().replace(/[^a-z0-9]+/g, "") : "auto-generated from the name"}
            onChange={e => set("slug", e.target.value)} />
          <div className="hgi-hint">Left empty it is generated with <code>altnome(name)</code>. This is the value the import grid joins on: a staged game's <code>provider_name</code> must equal this slug (same integration) or the game can never be imported.</div>
        </div>
      </div>

      <div className="hgi-field">
        <label className="hgi-label" htmlFor="hgi-p-parent">Parent Provider <span className="hgi-req">*</span></label>
        <select id="hgi-p-parent" className="select" value={d.parent_id} onChange={e => set("parent_id", e.target.value)}>
          <option value="">Select</option>
          {parentOptions.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
        </select>
        <div className="hgi-hint">Self-join on <code>providers.parent_id</code>; the list column shows an italic <i>No parent</i> when empty. Optional server-side despite the marker.</div>
      </div>

      <div className="hgi-grid2">
        <div className="hgi-field">
          <label className="hgi-label" htmlFor="hgi-p-launch">Custom Launch URL <span className="hgi-req">*</span></label>
          <select id="hgi-p-launch" className={`select${errs.custom_launch_url ? " hgi-invalid" : ""}`} value={d.custom_launch_url}
            onChange={e => set("custom_launch_url", e.target.value)}>
            <option value="0">Use the integration default host (0)</option>{/* label inferred — the real option reads "Select" */}
            {launchUrls.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
          </select>
          <div className="hgi-hint">
            Launch URL environments are managed on <b>CMS → Game Launch URL</b> (<code>/launchurls</code>, admin-only) and read here through
            <code> customLaunchUrl()</code> → <code>LaunchUrlService::list()</code> (cached 1h, falling back to
            <code> LaunchUrlEnvironment</code> when the table is empty).
            {selectedLaunch && <> Selected: <code>{selectedLaunch.url}</code></>}
          </div>
          {errs.custom_launch_url && <div className="hgi-fielderr">{errs.custom_launch_url}</div>}
        </div>
        <div className="hgi-field">
          <label className="hgi-label" htmlFor="hgi-p-cost">Cost{/* label inferred — backend.cost has no committed EN translation */}</label>
          <input id="hgi-p-cost" className="input" value={d.cost} placeholder="Insert cost" onChange={e => set("cost", e.target.value)} />
          {/* DIVERGENCE (defect 5): same $fillable gap as banner_img — dropped on create,
              persisted on edit. Evident intent implemented: kept on create. */}
          <div className="hgi-hint">Free text on <code>providers.cost</code>. Like the banner it is missing from <code>Provider::$fillable</code>, so on the real platform a cost typed while creating a provider is thrown away. Kept on create here.</div>
        </div>
      </div>

      <div className="hgi-sectitle">Settings</div>

      <Explainer compact title="About these switches">
        Nine <code>0/1</code> columns on <code>providers</code>. Their help text comes from <code>backend.provider_*_desc</code> keys that are
        absent from the committed language file, so the real form shows raw keys where a description should be — only <b>Hide games</b> has
        behaviour documented elsewhere in the platform, and nothing is invented for the other eight.
      </Explainer>

      <div className="hgi-flags">
        {HGI_PROV_FLAGS.map(f => (
          <div key={f.key} className="hgi-flag">
            <Toggle value={!!d.flags[f.key]} size="sm" onLabel="" offLabel="" onChange={(v) => setFlag(f.key, v)} />
            <div>
              <div className="hgi-flag__l">
                {f.label}
                {f.desc && <Tip size={12}>{f.desc}</Tip>}
              </div>
              <code className="hgi-flag__c">{f.key}</code>
            </div>
          </div>
        ))}
      </div>

      {/* DIVERGENCE (defect 5): ProviderAdminService L162 maps bcw_new_callback from the
          request although no control posts it, so every real save writes 0 and clears the
          flag. Evident intent implemented — the stored value is carried through untouched. */}
      <div className="hgi-hint hgi-hint--warn">
        <b>Hidden write:</b> <code>providers.bcw_new_callback</code> has no control on this form, but the real save maps it from the request —
        so saving any provider writes <code>0</code> and silently clears a flag nobody was shown{provider && provider.flags.bcw_new_callback ? <> (this provider currently has it set to <code>1</code>)</> : null}.
        Saving here leaves the stored value alone.
      </div>
    </HgiModal>
  );
};

/* Delete — GET /providers/delete/{id}/, gated by isadmin(), a hard delete with no reference
   checks. Blocked here while games or child providers still point at the row, matching how the
   sibling Launch URLs screen guards its own references. */
const HgiProviderDelete = ({ provider, childProviders, onClose, onConfirm }) => (
  <HgiModal title="Delete provider" 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="hgi-dlgq">Delete <b>{provider.name}</b> (ID {provider.id})?</div>
    <div className="hgi-hint">
      The real UI sends a plain <code>GET /providers/delete/{provider.id}/</code> behind a JS confirm. It is a hard delete
      (<code>Provider::where('id','=',$id)-&gt;delete()</code>) with <b>no reference checks</b> — <code>games.provider_id</code> rows,
      <code> skins_providers</code> links and any child provider's <code>parent_id</code> are all left dangling.
    </div>
    <div className="hgi-hint">
      This provider currently has <b>{hrsInt(provider.games)}</b> game(s) and <b>{childProviders}</b> child provider(s).
      Deletion is only offered here once both are zero.
    </div>
  </HgiModal>
);

/* ================================================================== *
 * PROVIDERS — list
 * ================================================================== */
const HGI_PROV_SELECTS = ["integration", "category"];

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

  const cat = useHgiCatalogue();
  const rows = cat.providers;
  /* HrsAsync reads {loading, error, data}; the catalogue hook is six feeds, so
     hand it the one list this screen is about. Without `data` it would treat
     every state as empty. */
  const provFeed = { loading: cat.loading, error: cat.error, retry: cat.retry, data: rows };
  const save = useHrsSave(provFeed);
  const blank = { id: "", name: "", integration: "", category: "" };
  const [draft, setDraft] = hgiUseState(blank);
  const [applied, setApplied] = hgiUseState(blank);
  const [sort, setSort] = hgiUseState({ key: "id", dir: "desc" }); // ajax.js order [[0,"desc"]]
  const [page, setPage] = hgiUseState(0);
  const [form, setForm] = hgiUseState(null); // null | { provider: row|null }
  const [del, setDel] = hgiUseState(null);   // null | row
  const pageSize = 50;                        // ajax.js pageLength 50, no length menu

  const FIELDS = [
    { key: "id", label: "ID", type: "text", icon: "tag", width: 130, placeholder: "Exact ID",
      tip: <>Exact match on <code>providers.id</code>.</> },
    { key: "name", label: "Name", type: "text", icon: "search", grow: true, placeholder: "Provider name",
      tip: <>Contains match on <code>providers.name</code>.</> },
    { key: "integration", label: "Integration", type: "select", icon: "grid", placeholder: "Select",
      options: cat.integrations.map(i => ({ value: String(i.id), label: i.name })) },
    { key: "category", label: "Category", type: "select", icon: "list", placeholder: "Select",
      options: cat.categories.map(c => ({ value: String(c.id), label: c.name })) },
  ];

  const onChange = (k, v) => {
    const next = { ...draft, [k]: v };
    setDraft(next);
    if (HGI_PROV_SELECTS.indexOf(k) !== -1) { setApplied(next); setPage(0); }
  };
  const onSearch = (v) => { setApplied({ ...blank, ...v }); setPage(0); };
  const onReset = () => { setDraft(blank); setApplied(blank); setPage(0); };

  const childCount = (id) => rows.filter(p => p.parent_id === id).length;

  const filtered = hgiUseMemo(() => {
    const nq = String(applied.name || "").trim().toLowerCase();
    return rows.filter(r => {
      if (applied.id && String(r.id) !== String(applied.id).trim()) return false;
      if (nq && r.name.toLowerCase().indexOf(nq) === -1) return false;
      if (applied.integration && String(r.integration_id) !== applied.integration) return false;
      if (applied.category && String(r.category_id || "") !== applied.category) return false;
      return true;
    });
  }, [rows, applied]);

  const sorted = hgiUseMemo(() => {
    const dir = sort.dir === "asc" ? 1 : -1;
    const val = (r) => {
      switch (sort.key) {
        case "name": return r.name.toLowerCase();
        case "integration": return r.integration_id;
        case "category": return (hgiNameIn(cat.categories, r.category_id) || "").toLowerCase();
        case "games": return r.games;
        /* orderBySql sorts this column by providers.parent_id, NOT by the parent's name —
           reproduced, since the reference records it as a quirk rather than a defect. */
        case "parent": return r.parent_id || 0;
        case "featured": return r.featured;
        default: return r.id;
      }
    };
    return filtered.slice().sort((a, b) => {
      const va = val(a), vb = val(b);
      if (va === vb) return (a.id - b.id) * dir;
      return (va > vb ? 1 : -1) * dir;
    });
  }, [filtered, sort, cat.categories]);

  const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
  const safePage = Math.min(page, pageCount - 1);
  const paged = sorted.slice(safePage * pageSize, safePage * pageSize + pageSize);

  /* Column names differ from the form's flag keys in three places, and the
     third is inverted — see hgiProvRow for the read side of the same map.
     DIVERGENCE (defect 5): bcw_new_callback is PRESERVED rather than reset to
     0. The real save writes 0 on every update because the form has no control
     for it, silently clearing a flag an operator never touched. */
  const onSave = (p) => {
    const f = p.flags || {};
    const body = {
      integration_id: Number(p.integration_id),
      category_id: p.category_id == null ? null : Number(p.category_id),
      parent_id: p.parent_id == null ? null : Number(p.parent_id),
      custom_launch_url_id: Number(p.custom_launch_url) || null,
      name: p.name,
      slug: p.slug,
      frontend_name: p.custom_frontend_name || null,
      cost_rate: p.cost === "" || p.cost == null ? null : Number(p.cost),
      featured: !!f.featured,
      is_vip: !!f.is_vip,
      is_sportsbook: !!f.is_sportbook,
      special_provider: !!f.special_provider,
      no_bonus: !!f.no_bonus,
      hide_games: !!f.hide_games,
      hide_from_guests: !!f.hide_guest,
      fs_support: !!f.fs_support,
      bcw_new_callback: !!f.bcw_new_callback,
      /* isystem forces stato = 1 on every save, so a provider saved from this
         screen is always active. Reproduced. */
      active: true,
    };
    if (p.id == null) {
      save.run(() => window.sb.create("providers", body),
        { done: `Provider "${p.name}" created`, fail: "Create failed" });
    } else {
      save.run(() => window.sb.update("providers", p.id, body),
        { done: `Provider "${p.name}" saved`, fail: "Save failed" });
    }
  };

  const onSyncTt = () => hrsToast("Not queued — no write path yet",
    "TimelessTech::syncProviders('gamesync') pulls the full TT games list and firstOrCreate()s a providers row per vendor slug (integration 40), including parent providers from vendorGroups. Gated by isadmin(); redirects back with a session flash.");

  const logoCell = (r) => (
    <span className="hgi-logo" title={r.img || "/img/noimg.jpg"}>{r.name.slice(0, 2).toUpperCase()}</span>
  );

  const acts = (r) => {
    const kids = childCount(r.id);
    const blocked = r.games > 0 || kids > 0;
    return (
      <div className="hgi-acts">
        <button className="hgi-act hgi-act--danger" disabled={blocked}
          title={blocked
            ? `Cannot delete — ${hrsInt(r.games)} game(s) and ${kids} child provider(s) still reference this row. The real endpoint would delete it anyway and leave them dangling.`
            : "Delete"}
          onClick={(e) => { e.stopPropagation(); if (!blocked) setDel(r); }}>
          <Icon name="trash" size={13} />
        </button>
        <button className="hgi-act hgi-act--edit" title="Edit" onClick={(e) => { e.stopPropagation(); setForm({ provider: r }); }}>
          <Icon name="edit" size={13} />
        </button>
      </div>
    );
  };

  /* DIVERGENCE (defect 2): header order here is ProvidersController::index()'s declared
     order, and every value sits under its own header. On the real screen the service builds
     the row array as ID, Logo, Name, Integration, Number of games, Parent, Cost, Category,
     Featured, so four columns render under the wrong heading. */
  const columns = [
    { key: "id", label: "ID", sortable: true, firstDir: "desc", width: 88, render: r => <span className="hgi-id">{r.id}</span> },
    { key: "logo", label: "Logo", width: 76, align: "center", render: logoCell },
    { key: "name", label: "Name", sortable: true, firstDir: "asc", render: r => (
      <button className="hgi-namelink" title="Edit" onClick={() => setForm({ provider: r })}>
        <span>{r.name}</span>
        <Icon name="chevron_right" size={13} />
      </button>
    ) },
    { key: "integration", label: "Integration", sortable: true, firstDir: "asc",
      render: r => hgiNameIn(cat.integrations, r.integration_id) || `#${r.integration_id}` },
    { key: "category", label: "Category", sortable: true, firstDir: "asc",
      render: r => hgiNameIn(cat.categories, r.category_id) || <i className="hgi-missing">Missing</i> },
    { key: "games", label: "Number of games", sortable: true, align: "right", width: 150,
      render: r => hrsInt(r.games) },
    { key: "parent", label: "Parent Provider", sortable: true, firstDir: "asc",
      render: r => { const p = rows.find(x => x.id === r.parent_id); return p ? <b>{p.name}</b> : <i className="hgi-noparent">No parent</i>; } },
    { key: "cost", label: "Cost", align: "right", width: 96,
      render: r => r.cost ? r.cost : <span className="hgi-dash">—</span> },
    { key: "featured", label: "Featured", sortable: true, align: "center", width: 110,
      render: r => r.featured ? <HgiBadge tone="ok">Yes</HgiBadge> : <span className="hgi-dash">—</span> },
    { key: "_acts", label: "Actions", align: "center", width: 118, render: acts }, // backend.actions
  ];

  return (
    <HrsShell
      title="Providers"
      subtitle="The global game-provider catalogue every integration, game and skin hangs off"
      gate={<>Real-platform access: the CMS ▾ group is wrapped in <code>@if (isadmin())</code>, and <code>ProvidersController::index</code> re-checks with <code>abort(404)</code> — <b>Super Admin only</b> (user_level 0). <code>delete()</code> and <code>syncTTProviders()</code> re-check it too. </>}
      gateNote={<>Permission asymmetry, honestly: <code>getProvidersTable</code>, <code>providerForm</code> and <code>saveProvider</code> carry <b>no role check</b> — <code>StoreProviderRequest::authorize()</code> simply returns <code>true</code> — so any authenticated, 2FA'd back-office user who calls those URLs directly can list, create and edit providers. The <code>404</code> on the index page protects nothing.</>}
      explainer={{ title: "What a provider row controls, in plain English", bullets: [
        <><b>Global, not per skin.</b> This is the platform-wide catalogue. Per-skin enablement lives in <code>skins_providers</code> and is edited at <code>/skins/{"{"}id{"}"}/providers</code> — a different screen, gated by <code>support_skin_providers</code>.</>,
        <><b>Slug + integration is the join key.</b> The Game import grid matches a staged game to a provider on (<code>providers.slug</code> = <code>games_temp.provider_name</code>, same <code>integration_id</code>). Rename a slug and every staged game of that vendor stops matching — and <code>SyncGamesWithTemp</code> silently skips unmatched rows.</>,
        <><b>Custom Launch URL</b> points at a <code>launch_urls</code> row managed on <b>CMS → Game Launch URL</b>. <code>0</code> means "use the integration's default host".</>,
        <><b>Cost</b> is free text on <code>providers.cost</code>; the percentages that actually price a provider live on <code>users_providers</code> / <code>profili_cobanco_providers</code> and are read by the Business, Provider Costs and Net Win reports, not set here.</>,
        <><b>Delete is destructive.</b> The real endpoint hard-deletes over GET with no reference checks, leaving games, skin links and child providers dangling — it is offered here only once nothing references the row.</>,
      ] }}
      actions={<>
        <button className="hrs-btn hrs-btn--search" onClick={onSyncTt}>
          <Icon name="refresh" size={14} /> Sync TLT Providers
        </button>
        <button className="hrs-btn hrs-btn--filters" onClick={() => setForm({ provider: null })}>
          <Icon name="plus" size={14} /> New provider
        </button>
      </>}>

      <HrsFilters fields={FIELDS} values={draft} onChange={onChange} onSearch={onSearch} onReset={onReset}
        resultLabel={`${hrsInt(sorted.length)} of ${hrsInt(rows.length)}`} />

      {/* Signed out, RLS returns zero providers — which would render as "the
          platform has no game providers". That is never true and never
          actionable, so the error branch owns it. */}
      <HrsAsync state={provFeed} skeletonRows={10} skeletonCols={9}
                empty="No providers in the catalogue yet. They arrive either from Sync TLT Providers or from New provider.">
        {() => (<>
      <HrsTable
        columns={columns} rows={paged} rowKey="id" sort={sort} onSort={(s) => { setSort(s); setPage(0); }}
        empty="No provider matches these filters."
        renderCard={r => {
          const parent = rows.find(x => x.id === r.parent_id);
          const kids = childCount(r.id);
          const blocked = r.games > 0 || kids > 0;
          return (
            <>
              <div className="hrs-card__top">
                <b>{r.name}</b>
                <span className="hgi-id">ID {r.id}</span>
              </div>
              <div className="hrs-card__grid">
                <span>Integration</span><b>{hgiNameIn(cat.integrations, r.integration_id) || `#${r.integration_id}`}</b>
                <span>Category</span><b>{hgiNameIn(cat.categories, r.category_id) || <i className="hgi-missing">Missing</i>}</b>
                <span>Games</span><b>{hrsInt(r.games)}</b>
              </div>
              <details className="hgi-cardmore">
                <summary>More</summary>
                <div className="hrs-card__grid">
                  <span>Parent</span><b>{parent ? parent.name : "No parent"}</b>
                  <span>Cost</span><b>{r.cost || "—"}</b>
                  <span>Featured</span><b>{r.featured ? "Yes" : "—"}</b>
                  <span>Slug</span><b>{r.slug}</b>
                  <span>Launch URL</span><b>{r.custom_launch_url === 0 ? "Integration default" : (r.launch_url_name || `#${r.custom_launch_url}`)}</b>
                </div>
              </details>
              <div className="hgi-card__acts">
                <button className="btn btn--secondary btn--sm" onClick={() => setForm({ provider: r })}><Icon name="edit" size={12} /> Edit</button>
                <button className="btn btn--ghost btn--sm hgi-card__del" disabled={blocked}
                  title={blocked ? `Cannot delete — ${hrsInt(r.games)} game(s) and ${kids} child provider(s) still reference this row.` : "Delete"}
                  onClick={() => { if (!blocked) setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
              </div>
            </>
          );
        }} />

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

      {form && <HgiProviderModal provider={form.provider} allProviders={rows} cat={cat} onClose={() => setForm(null)} onSave={onSave} />}
      {del && (
        <HgiProviderDelete provider={del} childProviders={childCount(del.id)} onClose={() => setDel(null)}
          /* The button only reaches here when nothing references the row, but
             the foreign keys are the real guard: upstream this is a hard delete
             over GET with no reference checks, which leaves games, skin links
             and child providers dangling. Here it is refused. */
          onConfirm={() => { setDel(null); save.run(() => window.sb.remove("providers", del.id),
            { done: `Provider "${del.name}" deleted`, fail: "Delete failed" }); }} />
      )}
    </HrsShell>
  );
};

/* Loads after src/pages/HostCmsGames.jsx, deliberately replacing the GameImport and
   CmsProviders globals that file defines (app.jsx:484-485 renders them for route keys
   "cms-game-import" and "cms-providers"). Its other four CMS components are untouched. */
window.GameImport = GameImport;
window.CmsProviders = CmsProviders;
