// Represents: GET /promotions · PromotionsController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "Promotions"
/* CMS ▾ → Promotions. Screen actions: PromotionsController::index (L780 — 404 unless
   isAdmin()||isSkinAdmin(), then $this->authorize(Promotion::class,'viewAny') → PromotionPolicy),
   ::promotionForm (L907, GET /promotions/form/?id=), ::savePromotion (L993, POST
   /promotions/savePromotion?id=), ::toggleHidden (L935, POST /promotions/{id}/hidden — isadmin()
   only, silently returns otherwise), ::delete (L947, GET /promotions/delete/{id}/) and ::clone
   (L2047, POST /promotions/{id}/clone — isadmin() only). Route admin.promotions.index at
   routes/admin.php:1035 inside Route::name('promotions.')->prefix('promotions') (L1034), itself
   inside Route::name('admin.')->middleware(['auth','admin','2fa','g2fa']) (L25).
   Views: admin/promotions/index.blade.php + _paybo-head.blade.php + modals/promotion.blade.php
   (generaModalGestione → gestionePromotionModal) + forms/promotion.blade.php + modals/list.blade.php.
   JS: public/js/PromotionBonus.js. Form feeders: PromotionsBonusController::getProvidersByCategory /
   getGamesByProvider / loadProviders (routes 1673-1676), TTFreespinsController::fetchVendors /
   fetchCurrencies / fetchGamesWithLimits (routes 48-56), PromoTriggersController::search (L1131).

   Data model: `promotions` ⟷ pivots `promotions_providers` (carries the per-provider SPORT criteria
   columns), `promotions_games`, `promotions_triggers` → `promo_triggers`; activations live in
   `users_promotions` (FK promozione_id) and audit in `promotions_logs`; freespin bet amounts come
   from `fs_limits.limit_values` per (game_id, currency_code).

   NOT the same screen as "Promozioni Bonus" / Bonus programs (src/pages/BonusPrograms.jsx,
   /bonus/programs, Admin\BonusProgramController — routes/admin.php:1723-1740, route comment at
   L1722 calls it the "New Bonus System"). That one lives OUTSIDE the CMS menu (sidebar L202-208),
   has its own tables, its own activation stats screen and its own wizard, and is protected here.
   This screen is the older `promotions` table the player front end reads. They are linked only in
   the Explainer — nothing on this page reads or writes bonus-program data.

   Sibling screens sharing this family's rules: Provider promotions, Promoplay Promotions and Promo
   Triggers all carry the same expired-only delete guard (reference, Batch 4 batch-wide patterns).

   Known real-platform defect, handled per the repo's known-bug policy (CLAUDE.md):
   - REVERSED PER-PROVIDER SPORT CRITERIA. savePromotion L1105 does
       $providers_list_db = array_combine(array_keys($x), array_reverse(array_values($x)))
     which re-pairs the providersSettings[] blocks in reverse order, so with 2+ selected providers
     every provider is persisted with a DIFFERENT provider's odd/amount/count_events/event_odd
     criteria. Evident intent is identity pairing; this prototype saves each criteria block against
     its own provider. See HprSportCriteria + the DIVERGENCE comment in HprEditor.save().
   <!-- SUGGESTION: PromotionsController::savePromotion L1105 — drop the array_reverse and keep the
        association as posted (`$providers_list_db = $request->input('providersSettings', [])`).
        As written, any sport promotion with two or more providers silently stores each provider's
        criteria against another provider, so the promo qualifies players on the wrong conditions. -->

   Real behavior deliberately KEPT (not a bug):
   - EXPIRED-ONLY DELETE. delete() refuses while `end >= time()` ("Cannot delete an active
     (non-expired) promotion", L959-961) and only then removes promotions_games /
     promotions_providers / promotions_triggers / promotions_logs and the row itself in one
     transaction, flushing the provider + user promo caches (L963-990). Kept and explained in
     HprDeleteDialog rather than pre-disabled, because on the real platform the button is always
     rendered and the server is what refuses.
   - HIDDEN IS NOT PART OF THE FORM. savePromotion never persists `hidden` — the FormRequest
     normalises it (and `remove_image`) in prepareForValidation() but $datip omits both, so
     visibility changes only through the list's Hidden switch (POST /promotions/{id}/hidden), which
     is isadmin()-only. The editor therefore has no Hidden field, by design.
   <!-- SUGGESTION: SavePromotionRequest normalises `hidden` and `remove_image` but savePromotion
        persists neither — either drop them from prepareForValidation() or honour them. Today there
        is no way at all to clear a promotion's image once one has been uploaded. -->
   <!-- SUGGESTION: the date-overlap duplicate check in savePromotion (L1390-1406) is computed on
        every save and then thrown away — its rejection block is commented out. Delete the dead
        query or re-enable it; as committed it costs a query per save and misleads readers. -->
   <!-- SUGGESTION: the activations modal (admin.promotions.activationslist, GET
        /promotions/{id}/activationslist) is fully implemented — datatable, filters, cancel /
        reactivate / claim-freespins actions, XLSX export — but the redesigned index renders no
        trigger for it, so it is URL-only. Make the "{n} activations" subtitle the link. -->
   <!-- SUGGESTION: add Promotions to the non-admin CMS ▾ variant (sidebar.blade.php L646-690).
        PromotionPolicy::viewAny already passes for isSkinAdmin(), and index() scopes skin admins to
        their own skin_id — yet the only sidebar entry lives inside the isadmin() branch, so skin
        admins have to know and type the /promotions URL. -->

   Faithful to the real screen, deliberately NOT added: no KPI strip (the page's only totals are the
   Results count, the All/Visible/Hidden chip counts and the per-row activations count), no export
   (the list has none — only the activations modal exports XLSX), no bulk actions, no sortable
   columns (fixed ORDER BY promotions.id DESC, L829), no page-size selector (fixed paginate(25)),
   no link into the activations modal (see the SUGGESTION above), no `stato` column
   (statiPromotions() L876-885 is legacy and unused by this list), and no legacy promotypes in the
   picker — `cashback`, `affiliation`, `birthday`, `fidelity`, `anniversary` are commented out of
   promoTypes() (L46-50) even though save/assign still handle them. */

const { useState: hprUseState, useMemo: hprUseMemo } = React;

/* Deterministic PRNG (FNV-1a + mulberry32) — sibling-page convention, so every promotion's code,
   activation count, criteria and per-type amounts are identical on every load. */
/* NOT-A-GENERATOR: FNV-1a, the only survivor of the PRNG pair that built this
   screen. Its one remaining job is minting a SUGGESTED promo code from a seed
   string — a starting value the operator edits, which the editor re-checks
   against the codes already in use before it saves. Not a fact about anything.

   The mulberry32 it used to seed decided which providers each brand had, which
   games each provider offered, every sport criterion, every bonus term, and how
   many players had taken each offer. All of that is read now.

   tools/wiringstate.js flags any Math.imul with no *Rng name, since that is how
   a generator hides from its classifier. This marker is the declared way out and
   the tool only honours it on a file that also reads the database. */
const hprHash = (s) => { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };


/* SkinsController::getSkinsList() — super admin sees every skin; skin admins never see this filter
   at all (index L788 forces skin_id to their own). Ids/names/currencies match HRBZ_SKINS in
   src/pages/HostReportBusiness.jsx so the same skin means the same thing across the build. */
/* Auth::user()->getSkins(). Read from `skins` — it was six hardcoded ids, names
   and currencies, and the currency in particular is not decoration: the
   free-spin bet limits are chosen per currency, so an invented one picked
   invented limits. */
const hprSkinName = (skins, id) => {
  const k = (skins || []).find(x => String(x.id) === String(id));
  return k ? k.name : `#${id}`;
};

/* `gamecategories` minus poker — the blade hides id 5 (L187-189). Values per config/cats.php. */
/* game_categories. Was six hardcoded rows; the ids were the ones the seed used
   to decide which providers a promotion could reach. */
const hprCatLabel = (cats, id) => {
  const c = (cats || []).find(x => String(x.id) === String(id));
  return c ? (c.name || c.code) : `#${id}`;
};

/* getProvidersByCategory(category) → providers of that category, each suffixed "(Active)" /
   "(Inactive)" from the skins_providers join. Provider ids/names are mock operator data; the casino
   and casino-live entries reuse the ids already used by HostReportBusiness.jsx / HostSetVendorsGroups.jsx.
   `active` here stands for the skins_providers flag of the promotion's skin. */
/* providers, filtered by the promotion's game category — getProvidersByCategory.
   Was twenty-two hardcoded rows with a category each.

   THE "(Active)"/"(Inactive)" SUFFIX IS GONE, and that is the point of this
   block. It came from `hprProviderActive`, which hashed (skin, provider) and
   compared to 0.22 — so the editor labelled each provider enabled or disabled
   for the selected brand on no evidence whatever, and an operator picking
   providers for a promotion was reading a coin flip. The flag lives in
   `skin_providers`, which has no read resource in this build (src/supabase.js
   says the per-skin join tables are edited through a screen rather than listed
   by one). Until it does, the provider is listed unsuffixed — which is exactly
   what this screen already did before a skin was chosen.
   UNCLEAR-PROMO-2: expose skin_providers so this suffix can come back. */
const hprProvidersFor = (providers, cat) =>
  (providers || []).filter(p => String(p.category_id) === String(cat));
const hprProviderName = (providers, id) => {
  const p = (providers || []).find(x => String(x.id) === String(id));
  return p ? p.name : `#${id}`;
};

/* getGamesByProvider(provider_id) — an empty selection means "all games of that provider"
   (PromotionsBonusController L80). Titles are mock; the slice per provider is deterministic. */
/* The games of one provider, from `games`. Was a pool of twelve name fragments
   recombined by a PRNG seeded on the provider id, producing a plausible catalogue
   of titles that existed nowhere. */
const hprGamesFor = (games, providerId) =>
  (games || []).filter(g => String(g.provider_id) === String(providerId));



/* PromotionProvider::OPERATORS (app/Models/PromotionProvider.php:15). */
const HPR_OPERATORS = [">", ">=", "=", "<=", "<"];

/* promoTypes() L39-58 — only these four are selectable. */
const HPR_PROMOTYPES = [
  { value: "registration", label: "Registration" },  /* backend.promo_type_registration */
  { value: "deposit", label: "Deposit" },            /* backend.promo_type_deposit */
  { value: "instant", label: "Instant" },            /* hardcoded in promoTypes() */
  { value: "freespins", label: "Freespins" },        /* hardcoded in promoTypes() */
];
const hprTypeLabel = (v) => { const t = HPR_PROMOTYPES.find(x => x.value === v); return t ? t.label : v; };
/* Commented out of promoTypes() (L46-50) but still fully handled by save/assign and still present in
   the data model. Listed here for the honesty note in the editor — never offered as an option. */
const HPR_LEGACY_TYPES = ["cashback", "affiliation", "birthday", "fidelity", "anniversary"];

const HPR_WITHDRAW_LIMIT = [
  { value: "lock", label: "Lock" },
  { value: "no_lock", label: "No Lock" },
];

/* TTFreespinsController feeders: fetchVendors / fetchCurrencies / fetchGamesWithLimits. Bet amounts
   come from fs_limits.limit_values for the chosen (game_id, currency_code). All mock values. */
const HPR_FS_VENDORS = ["Pragmatic Play", "Amusnet", "3 Oaks"];
const HPR_FS_CURRENCIES = ["ARS", "PYG", "BOB", "LBP", "EUR", "CLP"];
const HPR_FS_GAMES = {
  "Pragmatic Play": ["Gates of Olympus", "Sweet Bonanza", "Big Bass Bonanza", "Sugar Rush"],
  "Amusnet": ["Shining Crown", "40 Super Hot", "Burning Hot", "Rise of Ra"],
  "3 Oaks": ["Sun of Egypt 3", "Aztec Fire 2", "Book of Sun", "Big Heist"],
};
const HPR_FS_LIMITS = {
  ARS: [20, 40, 60, 100, 200], PYG: [1000, 2000, 5000, 10000],
  BOB: [1, 2, 5, 10], LBP: [10000, 25000, 50000],
  EUR: [0.2, 0.4, 0.6, 1], CLP: [100, 200, 500, 1000],
};

/* promo_triggers rows reachable from GET /promotriggers/ajax/search, filtered by skin_id +
   promotion_id. Shape matches the Promo Triggers screen (id / skin / name / code / promotype). */
/* promo_triggers for a brand. Was four hardcoded rows across two skins, which
   meant the Triggers picker offered the same four to every operator and the
   "no trigger exists for this skin" empty state was a property of the array. */
const hprTriggersFor = (triggers, skinId) =>
  (triggers || []).filter(t => String(t.skin_id) === String(skinId));

/* generatePromotCode() L1926-1933 — 8 random chars from A-Z0-9, unique across promotions.code. */
const HPR_CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
/* A SUGGESTED code, deterministic from a seed so the same clone twice proposes
   the same string. The operator edits it and the editor re-checks it against the
   codes already in use — this is a starting value, not a fact. Was a PRNG walk;
   the same six characters now come from the hash directly, so the file needs no
   generator at all. */
const hprCode = (seed) => {
  let h = hprHash(String(seed));
  let out = "";
  for (let i = 0; i < 6; i++) {
    out += HPR_CODE_ALPHABET[h % HPR_CODE_ALPHABET.length];
    h = Math.floor(h / HPR_CODE_ALPHABET.length) + 7919;
  }
  return out;
};

/* date('j M Y, H:i', …) — the format both date columns use. */
const HPR_MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const hprPad2 = (n) => String(n).padStart(2, "0");
const hprFmtDT = (iso) => {
  if (!iso) return "—";
  const d = new Date(iso);
  if (isNaN(d.getTime())) return "—";
  return `${d.getDate()} ${HPR_MON[d.getMonth()]} ${d.getFullYear()}, ${hprPad2(d.getHours())}:${hprPad2(d.getMinutes())}`;
};
/* `end < now` is evaluated live against the wall clock, exactly like the controller's time()
   comparison — the seed dates sit far enough either side of today that the split is stable. */
const hprExpired = (row) => { const t = new Date(row.end).getTime(); return !isNaN(t) && t < Date.now(); };

/* ------------------------------------------------------------------ *
 * `promotions` rows. Explicit identity per row (id / skin / name / type / category / providers /
 * window / hidden); everything else is derived deterministically in hprBuildRows so the mock has the
 * real column set without 30 hand-written payloads.
 * ------------------------------------------------------------------ */


/* ------------------------------------------------------------------ *
 * THE DATA. Everything this screen showed was generated.
 *
 * `hprBuildRows()` produced sixteen promotions from a seed and a PRNG keyed on
 * each one's id, and it did not stop at the row. It invented the PROVIDER
 * CATALOGUE, the GAME LIST per provider, the promo CODES, the per-provider
 * sport CRITERIA (a minimum odd between 1.3 and 3.0, a stake, an event count,
 * and a comparison operator picked at random for each), the free-spin vendor
 * and its per-currency bet limits, the wagering requirement, the expiry — and
 * `nr_activations`, which is how many players had taken the offer.
 *
 * That last one is the sharpest. "412 activations" is a statement about the
 * business, and it came from `rnd() < 0.42 ? 0 : 1 + floor(rnd() * 900)`. It is
 * a correlated subquery over bonus_instances in the real screen and it is an
 * embedded count here.
 *
 * Replaced by one read of `promotions` with its four join tables embedded —
 * providers with their criteria, games, categories, triggers — plus the real
 * activation count. The conversion below is the only code that knows the
 * storage differs; the editor and every renderer still see `providers_list`,
 * `games_list`, `providersSettings` and `settings`.
 * ------------------------------------------------------------------ */

const HPR_FETCH_MAX = 500;

/* The `settings` keys the editor reads are flat columns here, one family per
   promo_type (supabase/047). Unpacked into the shape the form was written
   against so the two never have to agree about anything but this function. */
const hprSettingsFromDb = (r) => ({
  deposit_first: r.deposit_first_only ? 1 : 0,
  deposit_dep_massimo: r.deposit_max_amount == null ? "" : String(r.deposit_max_amount),
  deposit_perc_bonus: r.deposit_bonus_pct == null ? "" : String(r.deposit_bonus_pct),
  registration_importo: r.registration_amount == null ? "" : String(r.registration_amount),
  instant_amount: r.instant_amount == null ? "" : String(r.instant_amount),
  fs_vendor: r.freespin_vendor || "",
  fs_currency: r.freespin_currency || "",
  fs_freespins_per_player: r.freespins_per_player == null ? "" : String(r.freespins_per_player),
  fs_bet_amount: r.freespin_bet_amount == null ? "" : String(r.freespin_bet_amount),
  fs_game_id: r.freespin_game_id == null ? "" : String(r.freespin_game_id),
  fs_cap: r.freespin_cap == null ? "" : String(r.freespin_cap),
});

/* The inverse. `null` rather than `0` for an empty input on every numeric: an
   empty field is "not set", and storing 0 for it would be a term the operator
   did not choose — which is the defect supabase/047 exists to prevent, one
   layer up. */
const hprNum = (v) => (v === "" || v == null ? null : Number(v));
const hprSettingsToDb = (st) => ({
  deposit_first_only: !!st.deposit_first,
  deposit_max_amount: hprNum(st.deposit_dep_massimo),
  deposit_bonus_pct: hprNum(st.deposit_perc_bonus),
  registration_amount: hprNum(st.registration_importo),
  instant_amount: hprNum(st.instant_amount),
  freespin_vendor: st.fs_vendor || null,
  freespin_currency: st.fs_currency || null,
  freespins_per_player: hprNum(st.fs_freespins_per_player),
  freespin_bet_amount: hprNum(st.fs_bet_amount),
  freespin_game_id: hprNum(st.fs_game_id),
  freespin_cap: hprNum(st.fs_cap),
});

/* promotion_providers rows -> the editor's per-provider criteria map. The
   operator columns are `*_op` here and `*_operator` in the form; both hold one
   of > >= < <= =, so this is a rename rather than a translation. */
const hprCriteriaFromDb = (rows) => {
  const out = {};
  (rows || []).forEach(p => {
    out[p.provider_id] = {
      odd_value: p.min_odd_value == null ? "" : String(p.min_odd_value),
      odd_value_operator: p.odd_value_op || ">=",
      amount: p.min_amount == null ? "" : String(p.min_amount),
      amount_operator: p.amount_op || ">=",
      count_events: p.min_event_count == null ? "" : String(p.min_event_count),
      count_events_operator: p.event_count_op || ">=",
      event_odd: p.event_odd == null ? "" : String(p.event_odd),
      event_odd_operator: p.event_odd_op || "<=",
    };
  });
  return out;
};

const hprRowFromDb = (r) => {
  const provs = r.providers || [];
  /* An EMPTY game list for a provider means ALL of that provider's games — the
     upstream semantic of an empty games_list, and the reason this is built by
     grouping the rows that exist rather than by defaulting to every game. A
     provider with no promotion_games row keeps an empty array and means "all". */
  const games = {};
  provs.forEach(p => { games[p.provider_id] = []; });
  (r.games || []).forEach(g => {
    const pid = g.game && g.game.provider_id;
    if (pid == null) return;
    if (!games[pid]) games[pid] = [];
    games[pid].push(g.game_id);
  });
  const cats = (r.categories || []).map(c => c.category_id);
  return {
    id: r.id,
    skin_id: r.skin_id,
    name: r.name || "",
    promotype: r.promo_type || "",
    /* The form carries ONE game_category, which is promotions.category_id.
       promotion_categories is the many-side the real screen does not expose;
       both are read and the join is preserved on save, so a promotion
       configured elsewhere is not silently narrowed. */
    game_category: r.category_id == null ? (cats.length ? cats[0] : "") : r.category_id,
    _categories: cats,
    providers_list: provs.map(p => p.provider_id),
    games_list: games,
    providersSettings: hprCriteriaFromDb(provs),
    start: r.starts_at ? String(r.starts_at).slice(0, 16) : "",
    end: r.ends_at ? String(r.ends_at).slice(0, 16) : "",
    /* `visible` is the storefront flag and the screen calls its inverse
       `hidden`. `active` is the engine's and is NOT what this switch writes —
       two columns, two decisions, as 004 says. */
    hidden: !r.visible,
    active: !!r.active,
    code: r.code || "",
    /* THE REAL COUNT, from bonus_instances. Was a PRNG. */
    nr_activations: Array.isArray(r.activations) && r.activations[0]
      ? Number(r.activations[0].count) || 0 : 0,
    breve_descrizione: r.short_description || "",
    descrizione: r.description || "",
    button_url: r.button_url || "",
    join_days_limit: r.join_days_limit == null ? "" : String(r.join_days_limit),
    wagering_requirement: r.wagering_requirement == null ? "" : String(r.wagering_requirement),
    bonus_expire_days: r.bonus_expire_days == null ? "" : String(r.bonus_expire_days),
    withdraw_limit: r.withdraw_limit || "",
    _order: String(r.sort_order == null ? 0 : r.sort_order),
    triggers_list: (r.triggers || []).map(t => t.trigger_id),
    img: r.image_url || "",
    clone_of: r.clone_of_id,
    settings: hprSettingsFromDb(r),
    _games: r.games || [],
  };
};

const hprUseDb = () => {
  const feed = useHrsFetch(() => window.sb.list("promotions", { limit: HPR_FETCH_MAX }), []);
  const skinsFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const provFeed = useHrsFetch(() => window.sb.list("providers", { limit: 500 }), []);
  const catFeed = useHrsFetch(() => window.sb.list("gameCategories", { limit: 100 }), []);
  const gameFeed = useHrsFetch(() => window.sb.list("games", { limit: 2000 }), []);
  const trgFeed = useHrsFetch(() => window.sb.list("promoTriggers", { limit: 200 }), []);

  const rows = hprUseMemo(() => (feed.data || []).map(hprRowFromDb), [feed.data]);
  const skins = hprUseMemo(
    () => (skinsFeed.data || []).map(k => ({ id: k.id, name: k.name, cur: k.currency })),
    [skinsFeed.data]);
  const providers = hprUseMemo(
    () => (provFeed.data || []).map(p => ({ id: p.id, name: p.name, category_id: p.category_id })),
    [provFeed.data]);
  const cats = hprUseMemo(() => catFeed.data || [], [catFeed.data]);
  const games = hprUseMemo(
    () => (gameFeed.data || []).map(g => ({ id: g.id, name: g.name, provider_id: g.provider_id })),
    [gameFeed.data]);
  const triggers = hprUseMemo(() => trgFeed.data || [], [trgFeed.data]);

  const all = [feed, skinsFeed, provFeed, catFeed, gameFeed, trgFeed];
  return {
    rows, skins, providers, cats, games, triggers,
    /* Named individually rather than as a count: "more promotions than fetched"
       and "more games than fetched" have different consequences, and the second
       silently shortens a picker rather than a list. */
    truncated: [
      feed.meta && feed.meta.total > rows.length ? "promotions" : null,
      gameFeed.meta && gameFeed.meta.total > games.length ? "games (the per-provider picker is a subset)" : null,
    ].filter(Boolean),
    loading: all.some(f => f.loading),
    error: all.map(f => f.error).find(Boolean) || null,
    retry: () => all.forEach(f => f.retry()),
    feeds: [feed],
  };
};

/* Reconcile a promotion's join tables against what the editor holds.
   Composite keys, so a removal names BOTH halves — sb.remove(res, promoId,
   {other_id: x}). Passing only the promotion id would delete every link it has,
   which is the failure supabase/048's first verify rule exists to catch. */
const hprSyncLinks = async ({ resource, otherColumn, promotionId, before, after, patchOf }) => {
  const prev = new Set((before || []).map(String));
  const next = new Set((after || []).map(String));
  const failed = [];
  let added = 0, removed = 0, updated = 0;

  for (const id of next) {
    if (prev.has(id)) {
      const patch = patchOf ? patchOf(id) : null;
      if (!patch) continue;
      const res = await window.sb.update(resource, promotionId, patch, { [otherColumn]: id });
      if (res && res.ok) updated++;
      else failed.push(`${otherColumn} ${id}: ${(res && res.error && res.error.message) || "refused"}`);
      continue;
    }
    const body = Object.assign({ promotion_id: promotionId, [otherColumn]: Number(id) },
                               patchOf ? patchOf(id) || {} : {});
    const res = await window.sb.create(resource, body);
    if (res && res.ok) added++;
    else failed.push(`${otherColumn} ${id}: ${(res && res.error && res.error.message) || "refused"}`);
  }
  for (const id of prev) {
    if (next.has(id)) continue;
    const res = await window.sb.remove(resource, promotionId, { [otherColumn]: id });
    if (res && res.ok) removed++;
    else failed.push(`${otherColumn} ${id} (removal): ${(res && res.error && res.error.message) || "refused"}`);
  }
  return { added, removed, updated, failed };
};

const HPR_PAGE_SIZE = 25;  /* paginate(25), L829 — fixed, no page-size selector on the real page. */

/* ------------------------------------------------------------------ *
 * Modal chrome — shared .bp-modal scrim, full-screen on mobile (brief §11). The real modal is
 * generaModalGestione() (app/Helpers/modal.php:5) → gestionePromotionModal, body AJAX-loaded from
 * GET /promotions/form, saved with POST /promotions/savePromotion?id=<id>.
 * ------------------------------------------------------------------ */
const HprModal = ({ title, sub, onClose, children, footer, size }) => (
  <div className="bp-modal-scrim hpr-scrim" onClick={onClose}>
    <div className={`bp-modal hpr-modal${size ? ` hpr-modal--${size}` : ""}`} onClick={e => e.stopPropagation()}>
      <div className="hpr-modal__head">
        <div>
          <div className="hpr-modal__title">{title}</div>
          {sub && <div className="hpr-modal__sub">{sub}</div>}
        </div>
        <button className="hpr-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hpr-modal__body">{children}</div>
      {footer && <div className="hpr-modal__foot">{footer}</div>}
    </div>
  </div>
);

/* Sectioned config panel — the Settings.jsx shape applied to the promotion editor. */
const HprPanel = ({ title, sub, children }) => (
  <div className="hpr-panel">
    <div className="hpr-panel__head">
      <div className="hpr-panel__title">{title}</div>
      {sub && <div className="hpr-panel__sub">{sub}</div>}
    </div>
    <div className="hpr-panel__body">{children}</div>
  </div>
);

/* Explainer-style callout used inside the panels (same role as ui.jsx's <Explainer>, sized for a
   modal column). tone: "info" (default) | "warn" | "err". */
const HprCallout = ({ tone, icon, title, children }) => (
  <div className={`hpr-callout hpr-callout--${tone || "info"}`}>
    <Icon name={icon || "info"} size={13} />
    <div>
      {title && <b className="hpr-callout__t">{title}</b>}
      <div>{children}</div>
    </div>
  </div>
);

const HprField = ({ label, req, hint, err, locked, children, wide }) => (
  <div className={`hpr-field${wide ? " hpr-field--wide" : ""}`}>
    <div className="hpr-label">
      {label}{req && <span className="hpr-req">*</span>}
      {locked && (
        <span className="hpr-lockchip">
          skin-admin locked
          <Tip size={11}>Once this promotion has at least one <code>users_promotions</code> row, <code>$can_edit</code> is 0 for a Skin admin and this field is disabled and kept from the DB. Super Admin is never locked — <code>countSubscribedUsersToPromo()</code> returns 0 for <code>isadmin()</code> (L903-904).</Tip>
        </span>
      )}
    </div>
    {children}
    {hint && <div className="hpr-hint">{hint}</div>}
    {err && <div className="hpr-fielderr">{err}</div>}
  </div>
);

/* loumultiselect / select2 stand-in — search + checkable list + chips. Used for providers, per
   provider games and the trigger picker. */
const HprMulti = ({ options, value, onChange, placeholder, invalid, emptyNote }) => {
  const [q, setQ] = hprUseState("");
  const sel = (value || []).map(String);
  const shown = options.filter(o => !q || o.label.toLowerCase().indexOf(q.trim().toLowerCase()) !== -1);
  const toggle = (v) => {
    const k = String(v);
    onChange(sel.indexOf(k) === -1 ? [...sel, k] : sel.filter(x => x !== k));
  };
  return (
    <div className={`hpr-multi${invalid ? " hpr-multi--err" : ""}`}>
      <div className="hpr-multi__chips">
        {sel.length === 0 && <span className="hpr-multi__ph">{placeholder || "Nothing selected"}</span>}
        {sel.map(v => {
          const o = options.find(x => String(x.value) === v);
          return (
            <span key={v} className="hpr-chip2">
              {o ? o.label : v}
              <button type="button" onClick={() => toggle(v)} title="Remove"><Icon name="x" size={9} /></button>
            </span>
          );
        })}
      </div>
      {options.length > 6 && (
        <input className="input hpr-multi__search" placeholder="Filter…" value={q}
          onChange={e => setQ(e.target.value)} />
      )}
      <div className="hpr-multi__list">
        {shown.length === 0 && <div className="hpr-multi__none">No matches.</div>}
        {shown.map(o => (
          <label key={o.value} className={`hpr-multi__opt${sel.indexOf(String(o.value)) !== -1 ? " is-on" : ""}`}>
            <input type="checkbox" checked={sel.indexOf(String(o.value)) !== -1} onChange={() => toggle(o.value)} />
            <span>{o.label}</span>
          </label>
        ))}
      </div>
      {emptyNote && sel.length === 0 && <div className="hpr-hint">{emptyNote}</div>}
    </div>
  );
};

/* TinyMCE stand-in for `descrizione` — the real field is a rich-text editor; the toolbar here is
   decorative, the value is plain text/HTML. */
const HprRichText = ({ value, onChange, invalid }) => (
  <div className={`hpr-rt${invalid ? " hpr-rt--err" : ""}`}>
    <div className="hpr-rt__bar">
      {["B", "I", "U", "≡", "•", "1.", "🔗", "</>"].map(b => <span key={b}>{b}</span>)}
      <span className="hpr-rt__tag">TinyMCE</span>
    </div>
    <textarea className="hpr-rt__area" value={value} onChange={e => onChange(e.target.value)} />
  </div>
);

/* ------------------------------------------------------------------ *
 * providersSettings[{provider_id}][…] — rendered only for the sport category (blade L555-604).
 * DIVERGENCE from the real platform, per the known-bug policy: savePromotion L1105 re-pairs these
 * blocks with array_reverse before writing promotions_providers, so with 2+ providers each block
 * lands on the wrong provider. Here each block stays with its own provider.
 * ------------------------------------------------------------------ */
const HprSportCriteria = ({ providers, catalogue, value, onChange }) => {
  if (!providers.length) return <div className="hpr-empty2">Select at least one provider to set its criteria.</div>;
  const rows = [
    ["odd_value", "odd_value_operator", "Total odd"],       /* label inferred */
    ["amount", "amount_operator", "Bet amount"],            /* label inferred */
    ["count_events", "count_events_operator", "Events in the coupon"], /* label inferred */
    ["event_odd", "event_odd_operator", "Single event odd"],/* label inferred */
  ];
  const set = (pid, key, v) => {
    const cur = value[pid] || {};
    onChange({ ...value, [pid]: { ...cur, [key]: v } });
  };
  return (
    <div className="hpr-crit">
      {providers.map(pid => {
        const c = value[pid] || {};
        return (
          <div className="hpr-crit__block" key={pid}>
            <div className="hpr-crit__head">{hprProviderName(catalogue, pid)}<span className="hpr-crit__pid">provider #{pid}</span></div>
            {rows.map(([vk, ok, lab]) => (
              <div className="hpr-crit__row" key={vk}>
                <span className="hpr-crit__lab">{lab}{/* label inferred */}</span>
                <select className="select hpr-crit__op" value={c[ok] || ">"} onChange={e => set(pid, ok, e.target.value)}>
                  {HPR_OPERATORS.map(o => <option key={o} value={o}>{o}</option>)}
                </select>
                <input className="input hpr-crit__val" value={c[vk] || ""} onChange={e => set(pid, vk, e.target.value)} />
              </div>
            ))}
          </div>
        );
      })}
    </div>
  );
};

/* Per-promotype settings — shown/hidden by the real form's showTipologia() JS; inline validation
   lives in savePromotion L1125-1296. Only the four selectable types are represented. */
const HprTypeSettings = ({ type, settings, onChange, errs, locked, currency }) => {
  const set = (k, v) => onChange({ ...settings, [k]: v });
  if (type === "deposit") {
    return (
      <>
        <label className="hpr-check">
          <input type="checkbox" checked={String(settings.deposit_first) === "1"}
            onChange={e => set("deposit_first", e.target.checked ? 1 : 0)} />
          First deposit{/* backend.condition_first_deposit */}
          <Tip size={12}>Stored as 1/0. When on, the promotion only fires on the player's <b>first</b> deposit.</Tip>
        </label>
        <div className="hpr-grid2">
          <HprField label="Maximum amount" req err={errs.deposit_dep_massimo}
            hint={<>Cap of the bonus credited ({currency}). Required and must not be <code>0.00</code>.</>}>
            <input className={`input${errs.deposit_dep_massimo ? " hpr-invalid" : ""}`} value={settings.deposit_dep_massimo}
              onChange={e => set("deposit_dep_massimo", e.target.value)} />
          </HprField>
          <HprField label="Bonus percentage" req err={errs.deposit_perc_bonus}
            hint={<>Percentage of the deposit credited as bonus. Required and must not be <code>0.00</code>.</>}>
            <input className={`input${errs.deposit_perc_bonus ? " hpr-invalid" : ""}`} value={settings.deposit_perc_bonus}
              onChange={e => set("deposit_perc_bonus", e.target.value)} />
          </HprField>
        </div>
      </>
    );
  }
  if (type === "registration") {
    return (
      <HprField label="Amount" req err={errs.registration_importo} /* label inferred */
        hint={<>Credited on registration ({currency}). Required and must not be <code>0.00</code>.</>}>
        <input className={`input${errs.registration_importo ? " hpr-invalid" : ""}`} value={settings.registration_importo}
          onChange={e => set("registration_importo", e.target.value)} />
      </HprField>
    );
  }
  if (type === "instant") {
    return (
      <HprField label="Amount" req err={errs.instant_amount} /* label inferred */
        hint={<>Credited immediately on claim ({currency}). Required and must not be <code>0.00</code>.</>}>
        <input className={`input${errs.instant_amount ? " hpr-invalid" : ""}`} value={settings.instant_amount}
          onChange={e => set("instant_amount", e.target.value)} />
      </HprField>
    );
  }
  if (type === "freespins") {
    const games = HPR_FS_GAMES[settings.fs_vendor] || [];
    const limits = HPR_FS_LIMITS[settings.fs_currency] || [];
    return (
      <>
        <HprCallout icon="zap" title="Cascading selects fed by TimelessTech">
          Vendor → currency → game come from <code>/tt_freespins/fetchVendors</code>,
          <code> /tt_freespins/fetchCurrencies</code> and <code>/tt_freespins/fetchGamesWithLimits</code>. The bet
          amounts offered are the <code>fs_limits.limit_values</code> rows for the chosen game + currency, not free
          text. Claiming a freespins activation later calls the TT campaign-create API.
        </HprCallout>
        <div className="hpr-grid2">
          <HprField label="Vendor" req err={errs.fs_vendor} locked={locked}>{/* label inferred */}
            <select className={`select${errs.fs_vendor ? " hpr-invalid" : ""}`} value={settings.fs_vendor}
              onChange={e => onChange({ ...settings, fs_vendor: e.target.value, fs_game_id: "" })}>
              <option value="">- Select -</option>
              {HPR_FS_VENDORS.map(v => <option key={v} value={v}>{v}</option>)}
            </select>
          </HprField>
          <HprField label="Currency" req err={errs.fs_currency} locked={locked}>{/* label inferred */}
            <select className={`select${errs.fs_currency ? " hpr-invalid" : ""}`} value={settings.fs_currency}
              onChange={e => onChange({ ...settings, fs_currency: e.target.value, fs_bet_amount: "" })}>
              <option value="">- Select -</option>
              {HPR_FS_CURRENCIES.map(v => <option key={v} value={v}>{v}</option>)}
            </select>
          </HprField>
          <HprField label="Game" req err={errs.fs_game_id} locked={locked}>{/* label inferred */}
            <select className={`select${errs.fs_game_id ? " hpr-invalid" : ""}`} value={settings.fs_game_id}
              disabled={!settings.fs_vendor} onChange={e => set("fs_game_id", e.target.value)}>
              <option value="">- Select -</option>
              {games.map(g => <option key={g} value={g}>{g}</option>)}
            </select>
          </HprField>
          <HprField label="Bet amount" req err={errs.fs_bet_amount} locked={locked}
            hint={<>From <code>fs_limits.limit_values</code> for {settings.fs_currency || "the chosen currency"}. Must be greater than 0.</>}>
            <select className={`select${errs.fs_bet_amount ? " hpr-invalid" : ""}`} value={settings.fs_bet_amount}
              disabled={!settings.fs_currency} onChange={e => set("fs_bet_amount", e.target.value)}>
              <option value="">- Select -</option>
              {limits.map(v => <option key={v} value={String(v)}>{v}</option>)}
            </select>
          </HprField>
          <HprField label="Freespins per player" req err={errs.fs_freespins_per_player} locked={locked}
            hint="Must be greater than 0.">
            <input type="number" className={`input${errs.fs_freespins_per_player ? " hpr-invalid" : ""}`}
              value={settings.fs_freespins_per_player} onChange={e => set("fs_freespins_per_player", e.target.value)} />
          </HprField>
          <HprField label="Cap" locked={locked} /* label inferred */
            hint="Optional — the only freespins field with no server-side requirement.">
            <input className="input" value={settings.fs_cap} onChange={e => set("fs_cap", e.target.value)} />
          </HprField>
        </div>
      </>
    );
  }
  return <div className="hpr-empty2">Choose a promotion type to configure its settings.</div>;
};

/* ------------------------------------------------------------------ *
 * Create / Edit — GET /promotions/form/?id=<id> then POST /promotions/savePromotion?id=<id>.
 * Validation = SavePromotionRequest (name / breve_descrizione / descrizione /
 * wagering_requirement / bonus_expire_days / join_days_limit / withdraw_limit) plus everything
 * inline in savePromotion. Errors come back as ajaxError(message, params.campierrati=[fields]),
 * which the shared modal turns into red field outlines — mirrored here.
 * ------------------------------------------------------------------ */
const HprEditor = ({ row, existingCodes, skins, cats, providers, games, triggers, busy, onClose, onSave }) => {
  const isNew = !row;
  const seedSkin = row ? String(row.skin_id) : "";
  const [f, setF] = hprUseState(() => row ? { ...row, providers_list: row.providers_list.slice(), games_list: { ...row.games_list }, providersSettings: { ...row.providersSettings }, triggers_list: row.triggers_list.slice(), settings: { ...row.settings } } : {
    id: null, skin_id: "", name: "", code: hprCode(`new|${Date.now()}`), img: "",
    breve_descrizione: "", descrizione: "", game_category: "", providers_list: [], games_list: {},
    providersSettings: {}, button_url: "", promotype: "", join_days_limit: "7",
    wagering_requirement: "", bonus_expire_days: "5", withdraw_limit: "", _order: "0",
    triggers_list: [], start: "", end: "", hidden: false, nr_activations: 0, clone_of: null,
    settings: {
      deposit_first: 0, deposit_dep_massimo: "", deposit_perc_bonus: "", registration_importo: "",
      instant_amount: "", fs_vendor: "", fs_currency: "", fs_game_id: "", fs_bet_amount: "",
      fs_freespins_per_player: "", fs_cap: "",
    },
  });
  const [errs, setErrs] = hprUseState({});
  const [banner, setBanner] = hprUseState("");

  const set = (k, v) => { setF(x => ({ ...x, [k]: v })); setErrs(e => { const n = { ...e }; delete n[k]; return n; }); setBanner(""); };
  const setSet = (v) => { setF(x => ({ ...x, settings: v })); setErrs({}); setBanner(""); };

  /* $can_edit — 0 once the promo has ≥1 activation, but only for non-admins:
     countSubscribedUsersToPromo() returns 0 for isadmin() (L903-904). This prototype's operator is
     Super Admin (it renders the super-admin-only Skin column), so nothing is actually disabled;
     the affected fields are flagged instead so the rule stays visible. */
  const locked = !isNew && f.nr_activations > 0;

  const skin = (skins || []).find(k => String(k.id) === String(f.skin_id)) || null;
  const currency = skin ? skin.cur : "—";
  const catProviders = f.game_category ? hprProvidersFor(providers, f.game_category) : [];
  /* Games arrive as one list for every provider on the platform and are split
     per provider here. The alternative — a fetch per selected provider — is a
     request storm on a form where the operator ticks five providers in a row. */
  const provOptions = catProviders.map(p => ({
    value: String(p.id),
    /* The "(Active)"/"(Inactive)" suffix is the skins_providers flag — it only means anything once a
       skin is chosen, so before that the provider is listed unsuffixed rather than mislabelled. */
    /* No "(Active)"/"(Inactive)" suffix — see hprProvidersFor. It was a hash
       of (skin, provider) against 0.22, i.e. a coin flip presented as a fact
       about the brand's catalogue. */
    label: p.name,
  }));
  const selectedProviders = f.providers_list.map(Number);
  const triggerOptions = hprTriggersFor(triggers, f.skin_id).map(t => ({ value: String(t.id), label: `${t.name} · ${t.code}` }));

  const setCategory = (v) => {
    /* Changing the category re-feeds getProvidersByCategory, so the old selection cannot survive. */
    setF(x => ({ ...x, game_category: v, providers_list: [], games_list: {}, providersSettings: {} }));
    setErrs({}); setBanner("");
  };
  const setProviders = (list) => {
    const ids = list.map(Number);
    const games = {}; const crit = {};
    ids.forEach(id => { games[id] = f.games_list[id] || []; crit[id] = f.providersSettings[id] || {}; });
    setF(x => ({ ...x, providers_list: list.slice(), games_list: games, providersSettings: crit }));
    setErrs(e => { const n = { ...e }; delete n.providers_list; return n; }); setBanner("");
  };

  const nonZero = (v) => v !== "" && v != null && Number(v) !== 0 && !isNaN(Number(v));

  const save = () => {
    const e = {};
    /* SavePromotionRequest rules (messages are backend.* keys — operator-facing text inferred). */
    if (!String(f.name).trim()) e.name = "Name is required.";
    else if (String(f.name).length > 255) e.name = "Name must not exceed 255 characters.";
    if (!String(f.breve_descrizione).trim()) e.breve_descrizione = "Short description is required.";
    if (!String(f.descrizione).trim()) e.descrizione = "Complete description is required.";
    if (!String(f.wagering_requirement).trim()) e.wagering_requirement = "Wagering requirement is required.";
    if (!String(f.bonus_expire_days).trim()) e.bonus_expire_days = "Bonus expire days is required.";
    if (!String(f.join_days_limit).trim()) e.join_days_limit = "This field is required.";
    if (!String(f.withdraw_limit).trim()) e.withdraw_limit = "Withdraw limit is required.";
    /* Inline in savePromotion, all conditional on $can_edit. */
    if (!f.skin_id) e.skin_id = "Select a skin.";
    if (!String(f.code).trim()) e.code = "Code is required.";
    else if (existingCodes.some(c => c.code === String(f.code).trim().toUpperCase() && c.id !== f.id)) e.code = "Code is already in the database."; /* backend.code_is_already_in_db — label inferred */
    if (!f.game_category) e.game_category = "Select a category.";
    if (!f.providers_list.length) e.providers_list = "Select at least one provider.";
    if (!f.promotype) e.promotype = "Select a promotion type.";
    if (!f.start) e.start = "Start date is required.";
    if (!f.end) e.end = "End date is required.";
    const s = f.settings;
    if (f.promotype === "deposit") {
      if (!nonZero(s.deposit_dep_massimo)) e.deposit_dep_massimo = "Required and must not be 0.00.";
      if (!nonZero(s.deposit_perc_bonus)) e.deposit_perc_bonus = "Required and must not be 0.00.";
    }
    if (f.promotype === "registration" && !nonZero(s.registration_importo)) e.registration_importo = "Required and must not be 0.00.";
    if (f.promotype === "instant" && !nonZero(s.instant_amount)) e.instant_amount = "Required and must not be 0.00.";
    if (f.promotype === "freespins") {
      if (!s.fs_vendor) e.fs_vendor = "Select a vendor.";
      if (!s.fs_currency) e.fs_currency = "Select a currency.";
      if (!s.fs_game_id) e.fs_game_id = "Select a game.";
      if (!(Number(s.fs_freespins_per_player) > 0)) e.fs_freespins_per_player = "Must be greater than 0.";
      if (!(Number(s.fs_bet_amount) > 0)) e.fs_bet_amount = "Must be greater than 0.";
    }
    setErrs(e);
    const first = Object.keys(e).length ? e[Object.keys(e)[0]] : "";
    if (first) { setBanner(first); return; }
    setBanner("");
    /* KNOWN BUG — DIVERGENCE (see the file header): the real savePromotion rebuilds
       providersSettings with array_combine(array_keys(...), array_reverse(array_values(...)))
       (L1105) before writing promotions_providers, so each provider is stored with another
       provider's criteria. The evident intent is identity pairing, which is what happens here:
       f.providersSettings is written through unchanged, keyed by its own provider id. */
    /* onSave closes the editor on success. It used to close here, before the
       write had been attempted, so a refusal arrived as a toast with no form
       left to correct and everything typed discarded. */
    /* CLOSE ON SUCCESS, NOT ON SUBMIT. `onClose()` used to run on the next line,
       so the editor was gone before the write had even been attempted — and a
       refusal arrived as a toast with no form left to correct and every field
       the operator had filled in discarded. onSave closes it when the promotion
       and all four of its join tables have landed. */
    Promise.resolve(onSave({ ...f, code: String(f.code).trim().toUpperCase() }))
      .then(res => { if (res && !res.ok && res.error) setBanner(res.error.message); });
  };

  const typeNote = f.promotype ? null : (
    <>Only four types are selectable (<code>promoTypes()</code> L39-58). <code>{HPR_LEGACY_TYPES.join("</code>, <code>")}</code> are commented out of the picker but still handled by the save/assign code, so historic rows of those types can exist in <code>promotions</code>.</>
  );

  return (
    <HprModal
      size="wide"
      title={isNew ? <>New promotion</> /* backend.new_promotion */ : `Edit ${row.name}`}
      sub={isNew
        ? <>POST <code>/promotions/savePromotion</code></>
        : <>ID {row.id} · <code>{row.code}</code> · POST <code>/promotions/savePromotion?id={row.id}</code></>}
      onClose={onClose}
      footer={<>
        <span className="hpr-foot__note">
          {Object.keys(errs).length > 0
            ? <><Icon name="alert" size={12} /> {Object.keys(errs).length} field(s) flagged by <code>campierrati</code></>
            : <>Saving syncs <code>promotions_providers</code>, <code>promotions_games</code> and <code>promotions_triggers</code>, then flushes the provider + user promo caches.</>}
        </span>
        <button className="btn btn--secondary" onClick={onClose}>Close</button>
        {/* A save here is the promotion plus up to four join-table batches. Disabled
            while it is in flight so a second click cannot start a second sequence
            over links the first is still writing. */}
        <button className="btn btn--primary" onClick={save} disabled={busy}>
          <Icon name="check" size={13} /> {busy ? "Saving…" : "Save"}</button>
      </>}>

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

      {locked && (
        <HprCallout tone="warn" icon="lock" title={`${hrsInt(f.nr_activations)} activations recorded`}>
          On the real platform a Skin admin editing this promotion would find <b>Skin</b>, <b>Code</b>, <b>Category</b>,
          <b> Providers</b>, <b>Games</b>, <b>Promotion type</b> and the <b>freespins</b> fields disabled and kept from the
          database (<code>$can_edit = 0</code> once <code>users_promotions</code> has a row). Super Admin is exempt —
          <code> countSubscribedUsersToPromo()</code> returns 0 for <code>isadmin()</code> — so they stay editable here.
        </HprCallout>
      )}

      <HprPanel title="Data" sub="Identity and player-facing copy">{/* backend.generic_data */}
        <div className="hpr-grid2">
          <HprField label="Name" req err={errs.name} hint="Max 255 characters.">
            <input className={`input${errs.name ? " hpr-invalid" : ""}`} autoFocus value={f.name} onChange={e => set("name", e.target.value)} />
          </HprField>
          <HprField label="Skin" req err={errs.skin_id} locked={locked}
            hint={<>Shown to Super Admin only — every other role has <code>skin_id</code> forced to <code>Auth::user()-&gt;skin-&gt;id</code> (L1025).</>}>
            <select className={`select${errs.skin_id ? " hpr-invalid" : ""}`} value={f.skin_id} onChange={e => { set("skin_id", e.target.value); set("triggers_list", []); }}>
              <option value="">- Select -</option>
              {(skins || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </select>
          </HprField>
          <HprField label="Code" req err={errs.code} locked={locked}
            hint={<>Prefilled with <code>generatePromotCode()</code> — 8 random A-Z0-9 characters, unique across <code>promotions.code</code>.</>}>
            <input className={`input mono${errs.code ? " hpr-invalid" : ""}`} value={f.code} onChange={e => set("code", e.target.value.toUpperCase())} />
          </HprField>
          <HprField label="Image" /* label inferred */
            hint={<>.png / .jpg / .jpeg → <code>storage/public/promotions/logo</code>. Marked required on the form but there is <b>no server-side rule</b> — and no way to clear one once uploaded.</>}>
            <div className="hpr-file">
              <input type="file" className="input" accept=".png,.jpg,.jpeg" onChange={e => set("img", e.target.files && e.target.files[0] ? e.target.files[0].name : f.img)} />
              {f.img && <span className="hpr-file__cur"><Icon name="eye" size={11} /> {f.img}</span>}
            </div>
          </HprField>
        </div>
        <HprField label="Short description" req err={errs.breve_descrizione} wide>{/* backend.short_description */}
          <textarea className={`input hpr-ta${errs.breve_descrizione ? " hpr-invalid" : ""}`} value={f.breve_descrizione} onChange={e => set("breve_descrizione", e.target.value)} />
        </HprField>
        <HprField label="Complete description" req err={errs.descrizione} wide>{/* backend.complete_description */}
          <HprRichText value={f.descrizione} onChange={v => set("descrizione", v)} invalid={!!errs.descrizione} />
        </HprField>
      </HprPanel>

      <HprPanel title="On which categories to activate?" sub="Category → providers → games; empty game lists mean the whole provider">{/* backend.promo_categories_activate */}
        <div className="hpr-grid2">
          <HprField label="Category" req err={errs.game_category} locked={locked}
            hint={<>Poker (id 5) is hidden by the form. Values per <code>config/cats.php</code>.</>}>
            <select className={`select${errs.game_category ? " hpr-invalid" : ""}`} value={f.game_category} onChange={e => setCategory(e.target.value)}>
              <option value="">- Select -</option>
              {(cats || []).map(c => <option key={c.id} value={c.id}>{c.name || c.code}</option>)}
            </select>
          </HprField>
          <HprField label="Providers" req err={errs.providers_list} locked={locked}
            hint={<>Fed by <code>getProvidersByCategory</code>; the (Active)/(Inactive) suffix is the <code>skins_providers</code> flag for the selected skin.</>}>
            <HprMulti options={provOptions} value={f.providers_list} onChange={setProviders}
              invalid={!!errs.providers_list}
              placeholder={f.game_category ? "No provider selected" : "Pick a category first"} />
          </HprField>
        </div>

        {selectedProviders.length > 0 && (
          <HprField label="Games per provider" wide locked={locked} /* label inferred */
            hint={<>Leaving a provider's list empty means <b>all</b> of its games qualify — that is the real meaning of an empty <code>games_list[provider]</code>, not "none".</>}>
            <div className="hpr-pergrid">
              {selectedProviders.map(pid => (
                <div className="hpr-perprov" key={pid}>
                  <div className="hpr-perprov__head">{hprProviderName(providers, pid)}</div>
                  <HprMulti options={hprGamesFor(games, pid).map(g => ({ value: String(g.id), label: g.name }))}
                    value={f.games_list[pid] || []}
                    onChange={(v) => setF(x => ({ ...x, games_list: { ...x.games_list, [pid]: v } }))}
                    placeholder="All games"
                    emptyNote={<>All games of {hprProviderName(providers, pid)} qualify.</>} />
                </div>
              ))}
            </div>
          </HprField>
        )}

        {String(f.game_category) === "6" && (
          <>
            <HprCallout tone="warn" icon="alert" title="Divergence from the real platform — per-provider criteria pairing">
              <code>savePromotion</code> L1105 rebuilds this block with
              <code> array_combine(array_keys($x), array_reverse(array_values($x)))</code>, so with two or more
              providers each provider is written to <code>promotions_providers</code> with <b>another provider's</b>
              criteria. This screen saves every block against its own provider — the evident intent. See the
              <code> SUGGESTION</code> at the top of the file.
            </HprCallout>
            <HprField label="Sport criteria per provider" wide /* label inferred */
              hint={<>Operators come from <code>PromotionProvider::OPERATORS</code>. All eight columns are optional — leave a row empty to not test it.</>}>
              <HprSportCriteria providers={selectedProviders} catalogue={providers} value={f.providersSettings}
                onChange={(v) => setF(x => ({ ...x, providersSettings: v }))} />
            </HprField>
          </>
        )}
      </HprPanel>

      <HprPanel title="Promotion settings" sub="Type, bonus mechanics and wagering">
        <div className="hpr-grid2">
          <HprField label="Promotion type" req err={errs.promotype} locked={locked} hint={typeNote}>{/* backend.promotion_type */}
            <select className={`select${errs.promotype ? " hpr-invalid" : ""}`} value={f.promotype} onChange={e => set("promotype", e.target.value)}>
              <option value="">- Select -</option>
              {HPR_PROMOTYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
            </select>
          </HprField>
          <HprField label="Button URL" /* label inferred */ hint="Where the front-end promo card links to.">
            <input className="input" value={f.button_url} onChange={e => set("button_url", e.target.value)} />
          </HprField>
        </div>

        <div className="hpr-sub">{f.promotype ? `${hprTypeLabel(f.promotype)} settings` : "Type settings"}</div>
        <HprTypeSettings type={f.promotype} settings={f.settings} onChange={setSet} errs={errs} locked={locked} currency={currency} />

        <div className="hpr-grid2 hpr-grid2--top">
          <HprField label="How many days does the user have to deposit?" req err={errs.join_days_limit} hint="Default 7.">{/* backend.days_deposit_condition */}
            <input type="number" className={`input${errs.join_days_limit ? " hpr-invalid" : ""}`} value={f.join_days_limit} onChange={e => set("join_days_limit", e.target.value)} />
          </HprField>
          <HprField label="Wagering Requirement" req err={errs.wagering_requirement} hint="Multiplier the bonus must be wagered by before it can be withdrawn.">{/* backend.wagering_requirement */}
            <input className={`input${errs.wagering_requirement ? " hpr-invalid" : ""}`} value={f.wagering_requirement} onChange={e => set("wagering_requirement", e.target.value)} />
          </HprField>
          <HprField label="Bonus Expire Days" req err={errs.bonus_expire_days} hint="Default 5.">{/* backend.bonus_expire_days */}
            <input type="number" className={`input${errs.bonus_expire_days ? " hpr-invalid" : ""}`} value={f.bonus_expire_days} onChange={e => set("bonus_expire_days", e.target.value)} />
          </HprField>
          <HprField label="Withdraw limit" req err={errs.withdraw_limit} /* label inferred */
            hint={<><b>Lock</b> means a withdrawal cancels the player's active/pending lock bonuses (<code>cancelAllPlayerLockBonus</code>).</>}>
            <select className={`select${errs.withdraw_limit ? " hpr-invalid" : ""}`} value={f.withdraw_limit} onChange={e => set("withdraw_limit", e.target.value)}>
              <option value="">- Select -</option>
              {HPR_WITHDRAW_LIMIT.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
            </select>
          </HprField>
          <HprField label="Order" hint="Default 0 — display order on the front end.">{/* label inferred */}
            <input type="number" className="input" value={f._order} onChange={e => set("_order", e.target.value)} />
          </HprField>
        </div>

        <HprCallout icon="eye" title="Visibility is not part of this form">
          <code>savePromotion</code> never writes <code>hidden</code> — the request normalises it, then drops it. A
          promotion's visibility changes only through the <b>Hidden</b> switch on the list, which is
          <code> isadmin()</code>-only. Hidden promotions are also excluded from
          <code> getAssignablePromotions()</code>.
        </HprCallout>
      </HprPanel>

      <HprPanel title="Promo triggers" sub="Extra triggers executed when this promotion is assigned">
        <HprField label="Triggers" locked={false} /* label inferred */
          hint={<>Searched through <code>/promotriggers/ajax/search</code>, filtered by <code>skin_id</code> and <code>promotion_id</code>; add/remove is synced by <code>syncPromotionTriggers()</code> into <code>promotions_triggers</code>.</>}>
          <HprMulti options={triggerOptions} value={f.triggers_list} onChange={v => set("triggers_list", v)}
            placeholder={f.skin_id ? "No trigger attached" : "Pick a skin first"}
            emptyNote={f.skin_id && triggerOptions.length === 0 ? <>No <code>promo_triggers</code> row exists for {hprSkinName(skins, f.skin_id)}.</> : null} />
        </HprField>
      </HprPanel>

      <HprPanel title="Schedule" sub="The window the promotion is claimable in">
        <div className="hpr-grid2">
          <HprField label="Start date" req err={errs.start} hint="Real form uses a dd/mm/yyyy hh:ii picker.">{/* backend.start_date */}
            <input type="datetime-local" className={`input${errs.start ? " hpr-invalid" : ""}`} value={f.start} onChange={e => set("start", e.target.value)} />
          </HprField>
          <HprField label="End date" req err={errs.end}
            hint={<>Also the delete guard: a promotion can only be deleted once <code>end</code> is in the past.</>}>{/* backend.end_date */}
            <input type="datetime-local" className={`input${errs.end ? " hpr-invalid" : ""}`} value={f.end} onChange={e => set("end", e.target.value)} />
          </HprField>
        </div>
        <HprCallout icon="info" title="Overlapping promotions are allowed">
          The duplicate-window check in <code>savePromotion</code> is computed and then discarded (its rejection block
          is commented out: <i>"we can create other promotions, but player can't claim it"</i>). What actually blocks a
          player is assignment time — <code>checkProvidersPromo()</code> refuses a second promo sharing a provider.
        </HprCallout>
      </HprPanel>
    </HprModal>
  );
};

/* ------------------------------------------------------------------ *
 * Clone — POST admin.promotions.clone, isadmin() only. Each clone: replicate(), name
 * "Clone #<i> <original>", clone_of = source id, hidden = true, a fresh unique 8-char code, and a
 * copy of the games + providers pivots including the per-provider criteria.
 * ------------------------------------------------------------------ */
const HprCloneModal = ({ row, onClose, onClone }) => {
  const [qty, setQty] = hprUseState("1");
  const [err, setErr] = hprUseState("");
  const n = Number(qty);
  const run = () => {
    if (!qty || isNaN(n) || n < 1 || Math.floor(n) !== n) { setErr("Invalid quantity."); return; } /* backend.invalid_qtty — label inferred */
    onClone(row, n);
    onClose();
  };
  return (
    <HprModal title={`Clone ${row.name}`} sub={<>POST <code>/promotions/{row.id}/clone</code></>} onClose={onClose}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
        <button className="btn btn--primary" onClick={run}><Icon name="copy" size={13} /> Clone</button>
      </>}>
      {err && <div className="hpr-err"><Icon name="alert" size={13} /> {err}</div>}
      <HprField label="How many clones?" req err={err} /* backend.clones_count — label inferred */
        hint="The only input the clone modal asks for.">
        <input type="number" min="1" className={`input${err ? " hpr-invalid" : ""}`} value={qty}
          onChange={e => { setQty(e.target.value); setErr(""); }} />
      </HprField>
      <HprCallout icon="copy" title="What a clone copies">
        Each clone is a <code>replicate()</code> of this promotion named <b>Clone #n {row.name}</b>, with
        <code> clone_of = {row.id}</code>, a fresh unique 8-character code, and copies of the
        <code> promotions_games</code> and <code>promotions_providers</code> pivots — <b>including</b> the per-provider
        sport criteria. Clones are created <b>hidden</b>, so nothing reaches players until the Hidden switch is
        flipped. Triggers are <b>not</b> copied.
      </HprCallout>
      <HprCallout icon="shield" title="Super Admin only">
        <code>PromotionsController::clone</code> checks <code>isadmin()</code> (L2049-2050) — a Skin admin who owns the
        promotion can edit it but cannot clone it.
      </HprCallout>
    </HprModal>
  );
};

/* ------------------------------------------------------------------ *
 * Delete — GET /promotions/delete/{id}/. Policy `delete` (admin, or skin admin on own skin) AND the
 * expired-only guard. This is real behavior, not a bug: it is what stops an operator from pulling a
 * promotion out from under live player activations.
 * ------------------------------------------------------------------ */
const HprDeleteDialog = ({ row, onClose, onDelete }) => {
  const expired = hprExpired(row);
  return (
    <HprModal title={expired ? "Delete promotion" : "Delete refused"} onClose={onClose}
      sub={<>GET <code>/promotions/delete/{row.id}/</code></>}
      footer={expired
        ? <>
          <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
          <button className="btn btn--danger" onClick={() => { onDelete(row); onClose(); }}><Icon name="trash" size={13} /> Delete</button>
        </>
        : <button className="btn btn--secondary" onClick={onClose}>Close</button>}>
      {expired ? (
        <>
          <div className="hpr-dlgq">Delete <b>{row.name}</b> (ID {row.id})?</div>
          <HprCallout tone="warn" icon="alert" title="This cascades">
            One transaction removes the promotion's <code>promotions_games</code>,
            <code> promotions_providers</code>, <code>promotions_triggers</code> and <code>promotions_logs</code> rows
            and then the promotion itself, then flushes
            <code> promotion_providers_and_games:{row.id}</code> and the per-player
            <code> user_promotions:*</code> caches.
            {row.nr_activations > 0 && <> Its {hrsInt(row.nr_activations)} <code>users_promotions</code> activation rows are <b>not</b> in that list — they are left behind pointing at a deleted <code>promozione_id</code>.</>}
          </HprCallout>
        </>
      ) : (
        <>
          <div className="hpr-err"><Icon name="alert" size={13} /> Cannot delete an active (non-expired) promotion</div>
          <HprCallout icon="lock" title="Expired-only delete — real behavior, kept">
            <b>{row.name}</b> ends <b>{hprFmtDT(row.end)}</b>, which is still in the future, so
            <code> delete()</code> refuses before it touches anything (<code>end &gt;= time()</code>, L959-961).
            The same guard protects the whole promotions family — Provider promotions, Promoplay Promotions and
            Promo Triggers. To retire this promotion now, either move its <b>End date</b> into the past or hide it
            with the Hidden switch; hidden promotions stop being offered
            (<code>getAssignablePromotions</code>) while their live activations keep running.
          </HprCallout>
        </>
      )}
    </HprModal>
  );
};

/* Status chip strip — All / Visible / Hidden, counts computed BEFORE the hidden filter is applied
   (L834-842) so they stay stable while you switch between them. */
const HprChips = ({ counts, value, onChange }) => (
  <div className="hpr-chips">
    {[["", "All", counts.all, "n"], ["0", "Visible", counts.visible, "ok"], ["1", "Hidden", counts.hidden, "warn"]].map(([v, lab, c, tone]) => (
      <button key={lab} className={`hpr-chipbtn hpr-chipbtn--${tone}${String(value) === v ? " is-on" : ""}`} onClick={() => onChange(v)}>
        <span className="hpr-dot" />{lab}{/* backend.visible for "Visible" */}
        <span className="hpr-chipbtn__c">{hrsInt(c)}</span>
      </button>
    ))}
  </div>
);

/* ------------------------------------------------------------------ */
const HostCmsPromotions = () => {
  window.useLocale && window.useLocale();

  const { rows, skins, providers, cats, games, triggers, truncated, loading, error, retry, feeds } = hprUseDb();
  const save = useHrsSave(feeds);
  /* Paybo filter hero: draft vs applied, committed on Search (the chips commit immediately, like the
     real chip links which just re-submit the form). */
  const HPR_BLANK = { q: "", skin: "", expired: "0", hidden: "" };
  const [draft, setDraft] = hprUseState(HPR_BLANK);
  const [applied, setApplied] = hprUseState(HPR_BLANK);
  const [page, setPage] = hprUseState(0);
  const [editing, setEditing] = hprUseState(null);   // null | { row: row|null }
  const [cloning, setCloning] = hprUseState(null);   // null | row
  const [deleting, setDeleting] = hprUseState(null); // null | row

  const FIELDS = [
    { key: "q", label: "Search", type: "text", icon: "search", grow: true,  /* backend.search — label inferred */
      placeholder: "Promotion name or ID…",
      tip: <>Matches <code>promotions.name LIKE %term%</code>, and additionally <code>promotions.id = term</code> when the term is all digits (L805-813).</> },
    { key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "- Select -", width: 190,
      options: skins.map(s => ({ value: String(s.id), label: s.name })),
      tip: <>Super Admin only. Skin admins never see this control — <code>index()</code> hard-forces <code>skin_id</code> to their own skin (L788), so their list is already scoped.</> },
    { key: "expired", label: "Show expired promotions", type: "select", width: 210, defaultValue: "0", /* backend.show_expired_promotions — label inferred */
      options: [{ value: "0", label: "No" }, { value: "1", label: "Yes" }],
      tip: <>Defaults to <b>No</b>, which hides every row whose <code>end</code> is already in the past (L814-816). Expired promotions are the only ones that can be deleted, so switch this to Yes to clean up.</> },
    { key: "hidden", label: "Hidden", type: "select", placeholder: "All", width: 170, /* backend.hidden — label inferred */
      options: [{ value: "0", label: "Visible" }, { value: "1", label: "Hidden" }],
      tip: <>Same filter the All / Visible / Hidden chips drive. <code>hidden = 1</code> keeps a promotion out of the player front end and out of <code>getAssignablePromotions()</code>.</> },
  ];

  /* Chip counts are computed on everything the OTHER filters left, before `hidden` is applied. */
  const preHidden = hprUseMemo(() => {
    const q = String(applied.q || "").trim().toLowerCase();
    const digits = q.length > 0 && /^[0-9]+$/.test(q);
    return rows.filter(r => {
      if (applied.skin && String(r.skin_id) !== String(applied.skin)) return false;
      if (String(applied.expired) !== "1" && hprExpired(r)) return false;
      if (q && !(r.name.toLowerCase().indexOf(q) !== -1 || (digits && String(r.id) === q))) return false;
      return true;
    });
  }, [rows, applied]);

  const counts = hprUseMemo(() => ({
    all: preHidden.length,
    visible: preHidden.filter(r => !r.hidden).length,
    hidden: preHidden.filter(r => r.hidden).length,
  }), [preHidden]);

  /* Fixed ORDER BY promotions.id DESC (L829) — this list has no sortable columns. */
  const filtered = hprUseMemo(() => preHidden
    .filter(r => applied.hidden === "" || (applied.hidden === "1" ? r.hidden : !r.hidden))
    .slice()
    .sort((a, b) => b.id - a.id), [preHidden, applied.hidden]);

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

  const onSearch = (v) => { setApplied({ ...HPR_BLANK, ...v }); setPage(0); };
  const onReset = () => { setDraft(HPR_BLANK); setApplied(HPR_BLANK); setPage(0); };
  const setChip = (v) => { setDraft(d => ({ ...d, hidden: v })); setApplied(a => ({ ...a, hidden: v })); setPage(0); };

  /* ONE SAVE IS THE PROMOTION PLUS FOUR JOIN TABLES, and there is no transaction
     around them — app_write takes one resource per call. The parent goes first
     (a link needs a promotion_id), then providers with their criteria, then
     games, categories and triggers.

     A failure after the parent leaves the promotion EXISTING with part of its
     reach, and that is reported as PARTIAL rather than as saved: a promotion
     whose provider list saved and whose minimum-odds criteria did not is an
     offer that pays out on a single 1.01 selection.

     The TERMS, though, are all on the parent row and are written atomically with
     it — so a partial save can produce narrower reach than intended, never wrong
     terms. That is why this is recorded rather than blocked on an RPC.
     UNCLEAR-PROMO-1. */
  const onSaveRaw = async (v) => {
    const creating = v.id == null;
    const prev = creating ? null : rows.find(r => r.id === v.id);

    const body = Object.assign({
      name: v.name,
      code: v.code || null,
      promo_type: v.promotype,
      starts_at: v.start ? new Date(v.start).toISOString() : null,
      ends_at: v.end ? new Date(v.end).toISOString() : null,
      description: v.descrizione || null,
      short_description: v.breve_descrizione || null,
      image_url: v.img || null,
      button_url: v.button_url || null,
      sort_order: hprNum(v._order) || 0,
      category_id: hprNum(v.game_category),
      wagering_requirement: hprNum(v.wagering_requirement),
      bonus_expire_days: hprNum(v.bonus_expire_days),
      join_days_limit: hprNum(v.join_days_limit),
      withdraw_limit: v.withdraw_limit || null,
    }, hprSettingsToDb(v.settings || {}));

    if (creating) {
      body.skin_id = Number(v.skin_id);
      /* savePromotion omits the visibility flags upstream, so a new row would
         keep the column defaults. Sent explicitly instead: `visible = false` is
         the screen's "hidden", and `active = false` keeps the engine out of it
         until somebody turns it on deliberately. A promotion that goes live
         because a default said so is not a decision anybody made. */
      body.visible = false;
      body.active = false;
    }

    const res = creating
      ? await window.sb.create("promotions", body)
      : await window.sb.update("promotions", v.id, body);
    const pid = creating ? (res && res.ok && res.data ? res.data.id : null) : v.id;
    if (!res || !res.ok || !pid) {
      return { ok: false, error: { kind: "server",
        message: (res && res.error && res.error.message) || "The promotion was refused." } };
    }

    const failed = [];
    const provSync = await hprSyncLinks({
      resource: "promotionProviders", otherColumn: "provider_id", promotionId: pid,
      before: creating ? [] : (prev ? prev.providers_list : []),
      after: v.providers_list || [],
      patchOf: (id) => {
        const c = (v.providersSettings || {})[id];
        if (!c) return null;
        return {
          min_odd_value: hprNum(c.odd_value),
          odd_value_op: c.odd_value_operator || ">=",
          min_amount: hprNum(c.amount),
          amount_op: c.amount_operator || ">=",
          min_event_count: hprNum(c.count_events),
          event_count_op: c.count_events_operator || ">=",
          event_odd: hprNum(c.event_odd),
          event_odd_op: c.event_odd_operator || "<=",
        };
      },
    });
    failed.push(...provSync.failed);

    /* Games flatten across providers: promotion_games is keyed on the game, not
       on (provider, game). An empty list for a provider means all of its games
       and is stored as no rows. */
    const wantGames = [].concat(...Object.keys(v.games_list || {}).map(k => (v.games_list[k] || [])));
    const haveGames = creating ? [] : (prev ? (prev._games || []).map(g => g.game_id) : []);
    failed.push(...(await hprSyncLinks({
      resource: "promotionGames", otherColumn: "game_id", promotionId: pid,
      before: haveGames, after: wantGames,
    })).failed);

    failed.push(...(await hprSyncLinks({
      resource: "promotionCategories", otherColumn: "category_id", promotionId: pid,
      before: creating ? [] : (prev ? prev._categories || [] : []),
      after: v.game_category === "" || v.game_category == null ? [] : [Number(v.game_category)],
    })).failed);

    failed.push(...(await hprSyncLinks({
      resource: "promoTriggerPromotions", otherColumn: "trigger_id", promotionId: pid,
      before: creating ? [] : (prev ? prev.triggers_list : []),
      after: v.triggers_list || [],
    })).failed);

    if (failed.length) {
      return { ok: false, error: { kind: "server", message:
        `The promotion saved as #${pid} but ${failed.length} link(s) did not — its reach is narrower than you set: ${failed.join("; ")}` } };
    }
    return { ok: true, data: { id: pid }, meta: {} };
  };

  const onSave = (v) => save.run(() => onSaveRaw(v), {
    done: v.id == null ? `Promotion "${v.name}" created` : `Promotion "${v.name}" saved`,
    fail: v.id == null ? "The promotion was not created" : "The promotion was not fully saved",
  }).then(res => { if (res && res.ok) setEditing(null); return res; });

  /* CLONE reuses onSaveRaw rather than duplicating it — one place knows how a
     promotion and its four join tables are written. Triggers are NOT copied
     (upstream does not) and every clone is created hidden.

     `clone_of_id` is deliberately NOT set: supabase/047 keeps it out of the
     column list because a client that can write it can claim a promotion
     descends from one it does not. The link is lost until a server-side clone
     exists, and the toast does not pretend otherwise.
     <!-- SUGGESTION: a clone_promotion(id, n) RPC would set clone_of_id, copy
          the pivots in one transaction and mint the codes server-side. --> */
  const onClone = (row, n) => save.run(async () => {
    const made = [], failed = [];
    for (let i = 1; i <= n; i++) {
      const res = await onSaveRaw(Object.assign({}, row, {
        id: null,
        name: `Clone #${i} ${row.name}`,
        code: hprCode(`clone|${row.id}|${i}|${row.name}`),
        triggers_list: [],
      }));
      if (res && res.ok) made.push(res.data.id);
      else failed.push(`clone ${i}: ${(res && res.error && res.error.message) || "refused"}`);
    }
    if (failed.length) {
      return { ok: false, error: { kind: "server", message:
        `${made.length} of ${n} clone(s) created${made.length ? ` (#${made.join(", #")})` : ""}. ${failed.join("; ")}` } };
    }
    return { ok: true, data: made, meta: {} };
  }, {
    done: `${n} clone(s) created from #${row.id}`,
    fail: `The clones of #${row.id} were not all created`,
  }).then(() => setCloning(null));

  /* SOFT delete: `promotions` carries deleted_at, so the row survives and its
     join rows are untouched. The old toast claimed four tables were "cleared in
     one transaction" — under a soft delete none of them are, and saying so was
     the mock narrating a cascade it never performed. The links staying is what
     makes an accidental delete recoverable. */
  const onDelete = (row) => save.run(() => window.sb.remove("promotions", row.id), {
    done: `Promotion #${row.id} deleted`,
    fail: `Promotion #${row.id} was not deleted`,
  }).then(() => setDeleting(null));

  /* The screen's "hidden" is the inverse of promotions.visible. This switch owns
     only the storefront flag, exactly as upstream's single-purpose /hidden
     endpoint does; `active` is the engine's and is not touched here. */
  const toggleHidden = (row) => save.run(
    () => window.sb.update("promotions", row.id, { visible: row.hidden }), {
      done: `${row.name} is now ${row.hidden ? "visible" : "hidden"}`,
      fail: `${row.name}'s visibility did not change`,
    });

  const columns = [
    { key: "id", label: "ID", width: 78, render: r => <span className="hpr-id">{r.id}</span> },
    /* Skin column is rendered for Super Admin only (index.blade.php) — initials avatar + name. */
    {
      key: "skin_id", label: <>Skin <Tip size={12}>Rendered for Super Admin only. Skin admins see a list already restricted to their own skin, so the column would be a constant.</Tip></>,
      width: 170,
      /* The initials avatar was a hardcoded `short` on each seeded skin. Derived
         from the real name now — presentation, and it says so. */
      render: r => { const k = skins.find(x => String(x.id) === String(r.skin_id));
        return k ? <span className="hpr-skin"><span className="hpr-skin__av">{String(k.name || "?").replace(/[^A-Za-z0-9]/g, "").slice(0, 2).toUpperCase()}</span>{k.name}</span> : "—"; },
    },
    {
      key: "name", label: "Name",
      render: r => (
        <div className="hpr-namecell">
          <button className="hpr-namelink" title="Edit" onClick={(e) => { e.stopPropagation(); setEditing({ row: r }); }}>{r.name}</button>
          {/* The index renders this count as plain text — admin.promotions.activationslist exists but
              nothing on the page links to it. Kept as text, per "no invented actions". */}
          {r.nr_activations > 0 && (
            <div className="hpr-acts2">
              {hrsInt(r.nr_activations)} activations{/* backend.activations — label inferred */}
              <Tip size={11}>Correlated <code>COUNT(users_promotions)</code>. The activations screen (<code>GET /promotions/{r.id}/activationslist</code>) is fully built — filters, cancel / reactivate / claim-freespins, XLSX export — but the redesigned index renders no link to it, so it is reachable by URL only. Not linked here either.</Tip>
            </div>
          )}
        </div>
      ),
    },
    { key: "start", label: "Start date", width: 150, render: r => <span className="hpr-date">{hprFmtDT(r.start)}</span> },   /* backend.start_date */
    { key: "end", label: "End date", width: 150, render: r => <span className="hpr-date">{hprFmtDT(r.end)}</span> },         /* backend.end_date */
    {
      key: "_valid", label: "Valid", align: "center", width: 110,  /* backend.is_valid */
      render: r => hprExpired(r)
        ? <span className="chip chip--err"><span className="dot" />Expired</span>   /* backend.promo_expired */
        : <span className="chip chip--ok"><span className="dot" />Valid</span>,     /* backend.promo_valid */
    },
    {
      key: "hidden", align: "center", width: 130,
      label: <>Hidden <Tip size={12}>Checked = <b>not</b> hidden, matching the real switch. Writes through <code>POST /promotions/{"{id}"}/hidden</code>, which is <code>isadmin()</code>-only — a Skin admin sees the switch, flips it, and the server returns without saving.</Tip></>,
      render: r => (
        <span className="hpr-toggle" onClick={e => e.stopPropagation()}>
          <Toggle value={!r.hidden} onChange={() => toggleHidden(r)} onLabel="" offLabel="" size="sm" />
        </span>
      ),
    },
    {
      key: "_acts", label: "Actions", align: "center", width: 132,  /* backend.actions */
      render: r => (
        <div className="hpr-rowacts" onClick={e => e.stopPropagation()}>
          <button className="hpr-act hpr-act--edit" title="Edit" onClick={() => setEditing({ row: r })}><Icon name="edit" size={13} /></button>
          <button className="hpr-act" title="Clone (Super Admin only)" onClick={() => setCloning(r)}><Icon name="copy" size={13} /></button>
          <button className="hpr-act hpr-act--danger" title={hprExpired(r) ? "Delete" : "Delete — refused while the promotion has not expired"} onClick={() => setDeleting(r)}><Icon name="trash" size={13} /></button>
        </div>
      ),
    },
  ];

  const codes = rows.map(r => ({ id: r.id, code: r.code }));

  return (
    <HrsShell
      title="Promotions"  /* backend.promotions */
      subtitle="Front-end promotions players can claim — the `promotions` table, per skin"
      gate={<>Real-platform access: <code>PromotionsController::index</code> 404s unless <code>isAdmin() || isSkinAdmin()</code>, then defers to <code>PromotionPolicy::viewAny</code>. Editing and deleting need <code>update</code>/<code>delete</code> — Super Admin always, Skin admin only on their own <code>skin_id</code>. The <b>Hidden</b> switch and <b>Clone</b> are <code>isadmin()</code>-only. </>}
      gateNote={<>Sidebar caveat: the CMS ▾ entry sits inside the <code>isadmin()</code> branch, and the non-admin CMS variant (skin admin + <code>enable_cms</code>, or Customer Care + <code>support_cms_banners</code>) has <b>no</b> Promotions link — so a Skin admin who legitimately passes the policy still has to reach <code>/promotions</code> by URL. <code>enable_promotions_management</code> does <b>not</b> gate this screen; it only unlocks "Reactivate" on canceled player activations. The form endpoint is listed among this batch's ungated ones.</>}
      explainer={{
        title: "What this screen is, in plain English",
        bullets: [
          <>Each row is one claimable promotion for one skin: a window (<b>Start</b>/<b>End</b>), a <b>type</b> that decides how the bonus is calculated, and a set of <b>providers and games</b> the play has to happen on. Players activate them; each activation becomes a <code>users_promotions</code> row.</>,
          <>Four types are selectable: <b>Registration</b> (credit on signup), <b>Deposit</b> (percentage of a deposit, optionally first-deposit-only), <b>Instant</b> (fixed credit on claim) and <b>Freespins</b> (a TimelessTech campaign — vendor, game, currency and bet amount from <code>fs_limits</code>).</>,
          <><b>Deleting is only possible after a promotion has expired.</b> While <code>end</code> is in the future the server refuses outright — the same guard the whole promotions family uses. Hide it instead: hidden promotions stop being offered while their live activations keep running.</>,
          <><b>Hidden is not part of the editor.</b> The save endpoint never writes it; only the switch in the list does, and only for Super Admin.</>,
          <>Not the same thing as <b>Promozioni Bonus</b> (Bonus programs, <code>/bonus/programs</code>) — that is a separate, newer bonus system with its own tables, its own wizard and its own activation stats, and it lives outside the CMS menu. Nothing on this page reads or writes it.</>,
        ],
      }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setEditing({ row: null })}>
          <Icon name="plus" size={14} /> New promotion{/* backend.new_promotion */}
        </button>
      }>

      <HrsFilters
        fields={FIELDS} values={draft}
        onChange={(k, v) => setDraft(d => ({ ...d, [k]: v }))}
        onSearch={onSearch} onReset={onReset}
        resultLabel={`${hrsInt(filtered.length)} of ${hrsInt(rows.length)}`} />

      <HprChips counts={counts} value={applied.hidden} onChange={setChip} />

      {truncated.length > 0 && (
        <div className="hpr-warn">
          <Icon name="alert" size={13} /> There are more {truncated.join(" and ")} than this screen
          fetches ({HPR_FETCH_MAX} promotions, 2000 games). What is listed and searched below is a subset.
        </div>
      )}

      {loading ? <HrsSkeleton rows={6} cols={8} /> : error ? <HrsError error={error} onRetry={retry} /> : (
      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={(r) => setEditing({ row: r })}
        empty={rows.length === 0
          ? "No promotions yet — create one with New promotion."
          : (String(applied.expired) !== "1"
            ? "No promotion matches these filters. Expired promotions are hidden by default — set “Show expired promotions” to Yes to include them."
            : "No promotion matches these filters.")}
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.name}</b>
              {hprExpired(r)
                ? <span className="chip chip--err"><span className="dot" />Expired</span>
                : <span className="chip chip--ok"><span className="dot" />Valid</span>}
            </div>
            <div className="hrs-card__grid">
              <span>ID</span><b>{r.id}</b>
              <span>Skin</span><b>{hprSkinName(skins, r.skin_id)}</b>
              <span>Start date</span><b>{hprFmtDT(r.start)}</b>
              <span>End date</span><b>{hprFmtDT(r.end)}</b>
              <span>Hidden</span><b>{r.hidden ? "Hidden" : "Visible"}</b>
              {r.nr_activations > 0 && <><span>Activations</span><b>{hrsInt(r.nr_activations)}</b></>}
            </div>
            <div className="hpr-card__acts" onClick={e => e.stopPropagation()}>
              <button className="btn btn--secondary btn--sm" onClick={() => setEditing({ row: r })}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--secondary btn--sm" onClick={() => setCloning(r)}><Icon name="copy" size={12} /> Clone</button>
              <button className="btn btn--ghost btn--sm hpr-card__del" onClick={() => setDeleting(r)}><Icon name="trash" size={12} /> Delete</button>
              <button className="btn btn--ghost btn--sm" onClick={() => toggleHidden(r)}><Icon name="eye" size={12} /> {r.hidden ? "Show" : "Hide"}</button>
            </div>
          </>
        )} />

      )}

      <HrsPager page={safePage} pageSize={HPR_PAGE_SIZE} total={filtered.length} onPage={setPage} />

      {/* Honest absences: the real list has no sortable columns, no page-size selector and no export. */}
      <div className="hpr-foot">
        Fixed order: newest first (<code>promotions.id DESC</code>) — no column on this list is sortable, the page
        size is fixed at 25, and the list itself has no export. Only the activations screen exports (XLSX).
      </div>

      {editing && <HprEditor row={editing.row} existingCodes={codes}
        skins={skins} cats={cats} providers={providers} games={games} triggers={triggers} busy={save.busy}
        onClose={() => setEditing(null)} onSave={onSave} />}
      {cloning && <HprCloneModal row={cloning} onClose={() => setCloning(null)} onClone={onClone} />}
      {deleting && <HprDeleteDialog row={deleting} onClose={() => setDeleting(null)} onDelete={onDelete} />}
    </HrsShell>
  );
};

window.HostCmsPromotions = HostCmsPromotions;
