// Represents: GET /promotriggers · PromoTriggersController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "Promo Triggers"
/* CMS ▾ → Promo Triggers. Screen actions: PromoTriggersController::index (L63, aborts 404 unless
   isAdmin() || isSkinAdmin()), ::triggerForm (L286, GET /promotriggers/form/?id=), ::saveTrigger
   (L353, POST /promotriggers/saveTrigger/?id=), ::delete (L321, GET /promotriggers/delete/{id}/),
   ::logslist (L141, GET /promotriggers/{id}/logslist) + ::getLogsTable (L157, the logs DataTable
   feed), ::fetchPromotions (L498), ::toggleHidden (L309, POST /promotriggers/{id}/hidden) and
   ::testTrigger (L538, GET /testTrigger). Routes routes/admin.php:L1117-1153, all unnamed except
   the index (`admin.promotriggers.index`), inside Route::name('admin.')->middleware(['auth','admin',
   '2fa','g2fa']). Views: admin/promotriggers/index.blade.php + modals/trigger.blade.php
   (generaModalGestione) + forms/trigger.blade.php + modals/list.blade.php (the logs DataTable).

   WHAT A PROMO TRIGGER IS. A row in `promo_triggers` (skin_id, name, code, promotype, start, end)
   that binds a *code* to one side effect. Nothing schedules it: `executeTrigger($code, $skin_id,
   $player_id)` (L544) is called by PromotionsController::executeAssociatedTriggers() (L1746) after
   every successful promotion assignment, and by PromoPlayController (L270 casino prize, L350 trigger
   key). A trigger fires only while `now` sits inside its start/end window (L555-561) — the `stato`
   column exists, is fillable and is ignored. Four side effects (promoTypes(), L36-49):
     promotion       → PromotionsBonusController::associateToPromo() (L723)
     freespins       → games + providers.slug + TimelessTech POST /api/generic/campaigns/create (L588)
     promoplay_points→ raw cURL to the skin's PromoPlay API /api/players/depositPoints (L811-834)
     real_money      → TransferController::processTransfer(), payeer = the player's admin-skin
                       ancestor (L914-943). THIS MOVES REAL MONEY.
   Every execution appends a `promo_trigger_logs` row (status enum success/error; action values
   freespins_assigned / promotion_assigned / promoplay_points_assigned / real_money_assigned /
   trigger_executed). Triggers are attached to promotions from the *Promotions* edit form
   (select2 → /promotriggers/ajax/search), synced by PromotionsController::syncPromotionTriggers();
   there is no attach control on this screen, so none is drawn here.

   ────────────────────────────────────────────────────────────────────────────────────────────────
   DANGER SURFACED, NOT SOFTENED — `GET /testTrigger`
   routes/admin.php:L1135-1137 registers a bare `/testTrigger` that runs
       executeTrigger('testrealmoney1', 1, '4143049')
   against live data. It is NOT a dry run, NOT a sandbox and NOT a connectivity check: it looks up
   the real trigger whose code is `testrealmoney1` on skin 1 and, if that trigger is inside its
   start/end window, executes its side effect for real for the hardcoded player id 4143049 — which
   for a `real_money` trigger means TransferController::processTransfer() debits the player's
   admin-skin ancestor and credits the player, with no confirmation, no idempotency key and no undo.
   It carries NO role check: any authenticated back-office session that clears the auth + 2FA
   middleware can call it, super admin or not. No screen links to it — it is a dev leftover that
   exists only as a URL. It is surfaced on this page as an explicitly destructive action (see
   HptTestPanel / HptTestDialog) precisely so it stops being invisible; it is deliberately NOT
   relabelled "Test trigger", because nothing about it is a test.
   <!-- SUGGESTION: delete the /testTrigger route. It hardcodes a production player id (4143049) and a real-money trigger code, has no role check, and moves money on a GET — so a prefetching browser extension, a link scanner or a mistyped URL is enough to fire it. If a smoke test is genuinely wanted, gate it behind isadmin() + app()->environment('local','staging') and take code/skin/player as parameters instead of baking them in. -->

   ────────────────────────────────────────────────────────────────────────────────────────────────
   KNOWN REAL-PLATFORM DEFECTS, handled per the repo's known-bug policy (CLAUDE.md):
   1. `toggleHidden` (L309, POST /promotriggers/{id}/hidden, gated isadmin()) and its JS
      toggleHidden() (index.blade.php L237-243) exist, but NO rendered element calls them,
      `PromoTrigger` has no `hidden` in $fillable, and the committed migration for `promo_triggers`
      has no `hidden` column — a call would SQL-error against the committed schema. Evident intent =
      the Visible/Hidden switch of the sibling Promotions list this screen was copied from (checked =
      NOT hidden, isadmin()-only). Implemented here as the Hidden column, with the divergence stated
      on the column header. UNCLEAR (recorded, not papered over): whether the production dump has the
      column, and what a hidden *trigger* is supposed to mean — triggers have no player-facing
      surface, so the only defensible reading is a list-level "archived" marker; nothing in the
      execution engine reads such a flag.
      <!-- SUGGESTION: decide toggleHidden's fate in one commit. Either (a) add `hidden` to the promo_triggers migration + $fillable, have executeTrigger() skip hidden triggers, and render the switch; or (b) delete the route, the controller method and the dead JS. Shipping an isadmin()-gated endpoint that writes to a column the schema does not have is the worst of both. -->
   2. Logs modal, Player column sorting: getLogsTable (L212-236) orders by `players.username` while
      the query joins `users` — sorting by Player raises a SQL error on the real platform. Evident
      intent implemented here: the Player sort orders by the joined username.
      <!-- SUGGESTION: fix PromoTriggersController::getLogsTable's order-by map to `users.username` (the alias the leftJoin actually creates) so the Player column stops 500-ing. -->

   FAITHFUL ABSENCES — real, deliberately not built (see HptAbsences, which says so on screen):
   no export (the OpenSpout imports at the top of the controller are unused leftovers), no bulk
   actions, no sortable columns on the main list (fixed `ORDER BY promo_triggers.id DESC`, L106),
   no Active/Disabled control (statiTriggers() 0/1 is dead code — validity is the date window only),
   no `tipologieTriggers()` default/slick picker (dead, L273-285), no birthday/anniversary types
   (forms/trigger.blade.php L272,278 disables start/end for two promotypes that promoTypes() does not
   contain — vestigial copy from the Promotions form), no created-by/updated-by column
   (`addedByUser`/`updatedByUser` are fillable but never written by saveTrigger), and no
   attach-to-promotion control (that lives on the Promotions form).

   LABELS: `promo_triggers`, `promo_triggers_index_subtitle`, `new_trigger`, `trigger_search_
   placeholder`, `show_expired_triggers`, `trigger_type`, `apply`, `results`, `showing`, `prev`,
   `next`, `search` and `delete` resolve only to raw `backend.*` keys in the committed
   public/default-lang/en/backend.php (the runtime path storage/lang/ is gitignored) — operator-facing
   wording is written out here and marked "label inferred" at the point of use.

   <!-- SUGGESTION: name the eight sibling /promotriggers routes (admin.promotriggers.form/save/delete/logslist/logstable/search/fetchPromotions). Only the index is named today, so the blade JS concatenates URLs by hand off route('admin.promotriggers.index') (index.blade.php L239, 246). -->
   <!-- SUGGESTION: make delete a POST/DELETE with CSRF. GET /promotriggers/delete/{id}/ destroys the trigger, all its promo_trigger_logs rows and its promotions_triggers pivot rows inside one transaction, from a plain link. -->
   <!-- SUGGESTION: give saveTrigger, triggerForm, fetchPromotions, search, logslist and getLogsTable the same isAdmin()||isSkinAdmin() check index() already has. Today the list 404s for everyone else while the create/edit/execute endpoints behind it answer any authenticated back-office user. -->
   <!-- SUGGESTION: nothing in the documented validation checks that end > start, so a trigger can be saved with a window that can never open; add the comparison to saveTrigger. --> */

const { useState: hptUseState, useMemo: hptUseMemo } = React;

/* Deterministic PRNG (FNV-1a + mulberry32) — same convention as the sibling Host pages, so the
   list and its logs render identically on every load. */
const hptHash = (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; };
const hptRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
const hptPick = (rnd, arr) => arr[Math.floor(rnd() * arr.length)];

/* ---------------- enums (promoTypes(), controller L36-49) ---------------- */
const HPT_TYPES = [
  { value: "promotion", label: "Promotion" },
  { value: "freespins", label: "Freespins (No Wager)" },
  { value: "promoplay_points", label: "Promoplay Points" },
  { value: "real_money", label: "Real Money" },
];
const hptTypeLabel = (v) => (HPT_TYPES.find(t => t.value === v) || { label: v }).label;
/* real_money is the only type that moves cash out of an operator balance — it is chipped in the
   error ramp everywhere it appears so it never reads like the other three. */
const HPT_TYPE_CHIP = { promotion: "chip--info", freespins: "chip--purple", promoplay_points: "chip--gold", real_money: "chip--err" };

/* SkinsController::getSkinsList() — same operator skins the sibling Host screens mock, plus skin 1,
   the skin the hardcoded /testTrigger targets. */
const HPT_SKINS = [
  { id: 1, name: "Sinoplata", cur: "ARS" },
  { id: 47, name: "win24hs", cur: "ARS" },
  { id: 52, name: "apostando365", cur: "PYG" },
  { id: 55, name: "apuestadepana", cur: "CLP" },
  { id: 58, name: "PlaySpin", cur: "BOB" },
  { id: 60, name: "Anchodeespada", cur: "ARS" },
  { id: 62, name: "Jokerenvivo", cur: "ARS" },
  { id: 64, name: "Donjoker", cur: "ARS" },
  { id: 66, name: "Juegojoker", cur: "ARS" },
  { id: 68, name: "Tucasino", cur: "ARS" },
  { id: 70, name: "Jugaygana", cur: "ARS" },
];
const hptSkin = (id) => HPT_SKINS.find(s => s.id === Number(id)) || null;
const hptSkinName = (id) => { const s = hptSkin(id); return s ? s.name : `#${id}`; };
const hptSkinCur = (id) => { const s = hptSkin(id); return s ? s.cur : "ARS"; };
const hptInitials = (name) => String(name || "?").replace(/[^A-Za-z0-9 ]/g, " ").split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0].toUpperCase()).join("") || "?";

/* The three values baked into GET /testTrigger (routes/admin.php:1135-1137). */
const HPT_TEST_CODE = "testrealmoney1";
const HPT_TEST_SKIN = 1;
const HPT_TEST_PLAYER = "4143049";

/* Freespins form feeders — ANY /tt_freespins/fetchVendors, /fetchCurrencies,
   /fetchGamesWithLimits (TTFreespinsController, routes/admin.php:48-56). Bet amounts come from
   fs_limits.limit_values for the chosen game + currency, which is why the picker cascades. */
const HPT_FS_VENDORS = ["Pragmatic Play", "BGaming", "Spribe", "Playson", "Amusnet", "3Oaks"];
const HPT_FS_CURRENCIES = ["ARS", "PYG", "CLP", "BOB", "USD"];
const HPT_FS_GAMES = [
  { id: "vs20fruitsw", name: "Sweet Bonanza", vendor: "Pragmatic Play" },
  { id: "vs20olympgate", name: "Gates of Olympus", vendor: "Pragmatic Play" },
  { id: "vs10bbbonanza", name: "Big Bass Bonanza", vendor: "Pragmatic Play" },
  { id: "bgm-elvis", name: "Elvis Frog in Vegas", vendor: "BGaming" },
  { id: "bgm-dice", name: "Dice Bonanza", vendor: "BGaming" },
  { id: "spribe-rocket", name: "Rocketman", vendor: "Spribe" },
  { id: "pls-solarqueen", name: "Solar Queen", vendor: "Playson" },
  { id: "amu-40shining", name: "40 Shining Jewels", vendor: "Amusnet" },
  { id: "3o-brutalsanta", name: "Brutal Santa", vendor: "3Oaks" },
];
/* fs_limits.limit_values per currency — deterministic per (game, currency) so the Bet amount select
   is stable between loads. */
const hptFsBets = (gameId, currency) => {
  if (!gameId || !currency) return [];
  const base = { ARS: 25, PYG: 700, CLP: 100, BOB: 1, USD: 0.1 }[currency] || 1;
  const rnd = hptRng(hptHash(`fslimit|${gameId}|${currency}`));
  const steps = rnd() > 0.5 ? [1, 2, 4, 8, 20] : [1, 2, 5, 10];
  return steps.map(m => (base * m).toFixed(2));
};

/* GET /promotriggers/fetchPromotions — skin-scoped, searches promotion name/code, 10 per page. */
const HPT_PROMOTIONS = [
  { id: 812, skin_id: 47, name: "Welcome 100% up to 50.000", code: "WELCOME100" },
  { id: 815, skin_id: 47, name: "Sport free bet", code: "SPORTFB" },
  { id: 818, skin_id: 47, name: "Second deposit 50%", code: "SECOND50" },
  { id: 823, skin_id: 52, name: "Registro + 20 giros", code: "REG20FS" },
  { id: 826, skin_id: 55, name: "Cashback semanal", code: "CBWEEK" },
  { id: 831, skin_id: 58, name: "Bono de bienvenida", code: "BIENV58" },
  { id: 834, skin_id: 60, name: "Amigo invitado", code: "REFER60" },
  { id: 839, skin_id: 62, name: "Cumple feliz", code: "BDAY62" },
  { id: 842, skin_id: 64, name: "VIP reload", code: "VIP64" },
  { id: 845, skin_id: 66, name: "Recarga viernes", code: "FRI66" },
  { id: 848, skin_id: 68, name: "Segundo depósito", code: "SEC68" },
  { id: 851, skin_id: 70, name: "Loyalty drop", code: "LOYAL70" },
  { id: 854, skin_id: 1, name: "Legacy migration promo", code: "LEGACY01" },
];
const hptPromotion = (id) => HPT_PROMOTIONS.find(p => p.id === Number(id)) || null;

/* ---------------- date helpers ----------------
   List cells use date('j M Y, H:i') and the logs modal date('d M Y, H:i:s') — the real formats
   (index.blade.php L104-115 / logslist L145-153). Windows are anchored to today's midnight so the
   Valid/Expired split stays truthful as the prototype ages while the row set stays identical. */
const HPT_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const HPT_DAY = 86400;
const hptMidnight = (() => { const d = new Date(); d.setHours(0, 0, 0, 0); return Math.floor(d.getTime() / 1000); })();
const hptNow = () => Math.floor(Date.now() / 1000);
const hptPad2 = (n) => String(n).padStart(2, "0");
const hptFmtTs = (ts) => { const d = new Date(ts * 1000); return `${d.getDate()} ${HPT_MONTHS[d.getMonth()]} ${d.getFullYear()}, ${hptPad2(d.getHours())}:${hptPad2(d.getMinutes())}`; };
const hptFmtLogTs = (ts) => { const d = new Date(ts * 1000); return `${hptPad2(d.getDate())} ${HPT_MONTHS[d.getMonth()]} ${d.getFullYear()}, ${hptPad2(d.getHours())}:${hptPad2(d.getMinutes())}:${hptPad2(d.getSeconds())}`; };
/* datetime-local <-> unix. The real form is a bootstrap-datetimepicker in dd/mm/yyyy hh:ii parsed by
   sistemadatatime() (utils.php:409); the native control carries the same value with less friction. */
const hptToInput = (ts) => { if (!ts) return ""; const d = new Date(ts * 1000); return `${d.getFullYear()}-${hptPad2(d.getMonth() + 1)}-${hptPad2(d.getDate())}T${hptPad2(d.getHours())}:${hptPad2(d.getMinutes())}`; };
const hptFromInput = (s) => { if (!s) return 0; const t = new Date(s).getTime(); return isNaN(t) ? 0 : Math.floor(t / 1000); };
const hptIsoDay = (ts) => { const d = new Date(ts * 1000); return `${d.getFullYear()}-${hptPad2(d.getMonth() + 1)}-${hptPad2(d.getDate())}`; };
const hptAmount = (n, cur) => `${Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${cur ? " " + cur : ""}`;

/* ---------------- mock `promo_triggers` ----------------
   Fields are the real column set: skin_id, name, code, promotype, start, end (unix ints) + the
   per-type payload saveTrigger persists. `hidden` exists here only as the evident-intent
   implementation of toggleHidden (divergence 1) — the committed migration has no such column. */
const HPT_SEED = [
  { id: 1, skin: HPT_TEST_SKIN, name: "TEST real money 1", code: HPT_TEST_CODE, type: "real_money", from: -420, to: 240, pay: { real_money_amount: 500 } },
  { id: 3, skin: 47, name: "Welcome freespins hook", code: "WELCOMEFS", type: "freespins", from: -90, to: 120, pay: { fs_vendor: "Spribe", fs_currency: "ARS", fs_freespins_per_player: 20, fs_game_id: "spribe-rocket", fs_bet_amount: "25.00" } },
  { id: 5, skin: 47, name: "Deposit boost add-on", code: "DEPBOOST", type: "promotion", from: -60, to: 90, pay: { promotion_id: 818 } },
  { id: 7, skin: 52, name: "PromoPlay points on signup", code: "PPSIGNUP", type: "promoplay_points", from: -150, to: 45, pay: { promoplay_points: 250 } },
  { id: 9, skin: 55, name: "Cashback cash add-on", code: "CBADDON", type: "real_money", from: -220, to: -30, pay: { real_money_amount: 1500 } },
  { id: 11, skin: 58, name: "Free spins Gates", code: "FSGATES", type: "freespins", from: -180, to: -14, pay: { fs_vendor: "Pragmatic Play", fs_currency: "BOB", fs_freespins_per_player: 15, fs_game_id: "vs20olympgate", fs_bet_amount: "2.00" } },
  { id: 13, skin: 60, name: "Referral chain step 2", code: "REFCHAIN", type: "promotion", from: -45, to: 150, pay: { promotion_id: 834 } },
  { id: 15, skin: 62, name: "Birthday cash drop", code: "BDAYCASH", type: "real_money", from: -300, to: 65, pay: { real_money_amount: 300 } },
  { id: 17, skin: 64, name: "VIP points top-up", code: "VIPPOINTS", type: "promoplay_points", from: -75, to: 30, pay: { promoplay_points: 1000 } },
  { id: 19, skin: 66, name: "Friday reload spins", code: "RELOADFS", type: "freespins", from: -260, to: -60, pay: { fs_vendor: "BGaming", fs_currency: "ARS", fs_freespins_per_player: 30, fs_game_id: "bgm-elvis", fs_bet_amount: "50.00" } },
  { id: 21, skin: 68, name: "Second deposit hook", code: "SECONDDEP", type: "promotion", from: -30, to: 200, pay: { promotion_id: 848 } },
  { id: 23, skin: 70, name: "Loyalty cash drop", code: "LOYALCASH", type: "real_money", from: -340, to: -95, pay: { real_money_amount: 750 } },
  { id: 25, skin: 47, name: "Sport welcome hook", code: "SPORTWELC", type: "promotion", from: -20, to: 175, hidden: 1, pay: { promotion_id: 815 } },
  { id: 27, skin: HPT_TEST_SKIN, name: "Legacy points migration", code: "LEGACYPP", type: "promoplay_points", from: -520, to: -180, hidden: 1, pay: { promoplay_points: 100 } },
];

const HPT_PLAYERS = [
  { id: "4143049", u: "mlopez1987" }, { id: "4180221", u: "carlagz" }, { id: "4192877", u: "j.ramirez" },
  { id: "4201564", u: "eldiego10" }, { id: "4218903", u: "sofi_p" }, { id: "4233117", u: "nachoo" },
  { id: "4247550", u: "vale.mendez" }, { id: "4259018", u: "rodri_bet" }, { id: "4266342", u: "luchi88" },
  { id: "4271985", u: "gaston.f" }, { id: "4288460", u: "mariel_q" }, { id: "4295773", u: "tincho" },
];

/* promo_trigger_logs rows. status is the MySQL enum success/error; `details` is the pretty-printed
   JSON the modal renders; `error` carries the failure text (empty on success). */
const HPT_ERRORS = {
  freespins: ["TimelessTech campaigns/create HTTP 500 — campaign not created", "Vendor slug not found for provider id 179", "fs_limits has no limit_values for BOB on this game"],
  promotion: ["associateToPromo: promotion 818 already active for this player", "Promotion not assignable — hidden = 1", "users_promotions insert failed: duplicate key"],
  promoplay_points: ["PromoPlay depositPoints: missing pp_secret_key for this skin", "cURL error 28 — connection timed out after 10001 ms", "PromoPlay answered {status:'ERROR', message:'player not registered'}"],
  real_money: ["processTransfer: insufficient balance on payeer 1042", "No admin-skin ancestor found for player — payeer unresolved", "processTransfer returned KO — transaction rolled back"],
};

const hptLogDetails = (trg, player, ok) => {
  const cur = hptSkinCur(trg.skin_id);
  if (trg.promotype === "real_money") return { action: ok ? "real_money_assigned" : "trigger_executed", trigger_code: trg.code, skin_id: trg.skin_id, player_id: player.id, amount: Number(trg.pay.real_money_amount || 0).toFixed(2), currency: cur, payeer_user_id: 1042, transfer_id: ok ? 88000 + (Number(player.id) % 997) : null };
  if (trg.promotype === "freespins") return { action: ok ? "freespins_assigned" : "trigger_executed", trigger_code: trg.code, skin_id: trg.skin_id, player_id: player.id, vendor: trg.pay.fs_vendor, game_id: trg.pay.fs_game_id, freespins: trg.pay.fs_freespins_per_player, bet_amount: trg.pay.fs_bet_amount, currency: trg.pay.fs_currency, campaign_id: ok ? `tt-${trg.code.toLowerCase()}-${(Number(player.id) % 9973)}` : null };
  if (trg.promotype === "promotion") { const p = hptPromotion(trg.pay.promotion_id); return { action: ok ? "promotion_assigned" : "trigger_executed", trigger_code: trg.code, skin_id: trg.skin_id, player_id: player.id, promotion_id: trg.pay.promotion_id, promotion_name: p ? p.name : null, user_promotion_id: ok ? 55000 + (Number(player.id) % 4001) : null }; }
  return { action: ok ? "promoplay_points_assigned" : "trigger_executed", trigger_code: trg.code, skin_id: trg.skin_id, player_id: player.id, points: trg.pay.promoplay_points, pp_response: ok ? { status: "OK", data: { balance: 1200 + (Number(player.id) % 800) } } : { status: "ERROR" } };
};

/* hptBuildTriggers() and hptBuildLogs() built 14 fabricated triggers and up to
   14 invented execution-log rows each — player names, timestamps, success and
   failure states, and error text picked from a vocabulary per action type. A
   promo-trigger log is an audit trail; inventing one produces a record of
   things that did not happen to people who do not exist.

   Both are now reads. The shapes below are what the screen renders, mapped
   from promo_triggers and promo_trigger_logs. */

const hptRowFromDb = (r) => ({
  id: r.id,
  skin_id: r.skin_id,
  name: r.name,
  code: r.code,
  /* The column is action_type; the screen has always called it promotype,
     which is isystem's name for the same thing. Renaming the screen's field
     would touch every render site for no gain, so the mapping carries it. */
  promotype: r.action_type,
  /* Epoch seconds, as the screen's date helpers expect. A null window end is a
     real state — a trigger with no expiry — and stays null rather than
     becoming a date far in the future, which would read as a decision. */
  start: r.starts_at ? Math.floor(Date.parse(r.starts_at) / 1000) : null,
  end: r.ends_at ? Math.floor(Date.parse(r.ends_at) / 1000) : null,
  /* isystem stores `visible`; the screen asks `hidden`. Inverting here rather
     than at each render site keeps one place where the polarity can be wrong. */
  hidden: r.visible ? 0 : 1,
  active: !!r.active,
  pay: {
    promotion_id: r.promotion_id,
    currency: r.freespin_currency,
    freespins: r.freespins_per_player,
    game_id: r.freespin_game_id,
    bet: r.freespin_bet_amount,
    vendor: r.freespin_vendor,
    points: r.promoplay_points,
    amount: r.real_money_amount,
  },
});

const hptLogFromDb = (r) => ({
  id: r.id,
  trigger_id: r.trigger_id,
  player_id: r.user_id,
  player_username: (r.user && r.user.username) || "",
  created_at: r.created_at ? Math.floor(Date.parse(r.created_at) / 1000) : null,
  status: r.status,
  details: r.details,
  /* Empty string, not null: the screen renders the error cell unconditionally
     and null would print "null". */
  error: r.error || "",
});

/* ------------------------------------------------------------------ *
 * Modal chrome — shared .bp-modal scrim, full-screen on mobile (§11).
 * The real modals are generaModalGestione() (app/Helpers/modal.php:5)
 * for the edit form and modals/list.blade.php for the logs DataTable.
 * ------------------------------------------------------------------ */
const HptModal = ({ title, sub, onClose, children, footer, size, tone }) => (
  <div className="bp-modal-scrim hpt-scrim" onClick={onClose}>
    <div className={`bp-modal hpt-modal${size ? " hpt-modal--" + size : ""}${tone ? " hpt-modal--" + tone : ""}`} onClick={e => e.stopPropagation()}>
      <div className="hpt-modal__head">
        <div className="hpt-modal__titles">
          <div className="hpt-modal__title">{title}</div>
          {sub && <div className="hpt-modal__sub">{sub}</div>}
        </div>
        <button className="hpt-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hpt-modal__body">{children}</div>
      {footer && <div className="hpt-modal__foot">{footer}</div>}
    </div>
  </div>
);

const HptTypeChip = ({ type }) => (
  <span className={`chip ${HPT_TYPE_CHIP[type] || "chip--neutral"} hpt-typechip`}>
    {type === "real_money" && <Icon name="alert" size={10} />}
    {hptTypeLabel(type)}
  </span>
);

/* `Valid` column (backend.is_valid): end < time() → Expired (backend.promo_expired), else Valid
   (backend.promo_valid). Nothing else feeds it — `stato` is ignored by the engine. */
const HptValidChip = ({ trg, now }) => trg.end < now
  ? <span className="chip chip--neutral"><span className="dot" />Expired</span>
  : <span className="chip chip--ok"><span className="dot" />Valid</span>;

const HptSkinCell = ({ skinId }) => (
  <span className="hpt-skin">
    <span className="hpt-skin__av">{hptInitials(hptSkinName(skinId))}</span>
    <span className="hpt-skin__n">{hptSkinName(skinId)}</span>
  </span>
);

const HptJson = ({ value }) => <pre className="hpt-json">{JSON.stringify(value, null, 2)}</pre>;

/* ------------------------------------------------------------------ *
 * Logs modal — GET /promotriggers/{id}/logslist (chrome + filters) and
 * GET /promotriggers/{id}/getLogsTable (DataTables server-side feed).
 * The filter row is modal-local by design: the real modal carries its
 * own inline row (modals/list.blade.php L11-47), not the page's hero
 * filter strip. The table and pager are the shared Hrs* chrome.
 * ------------------------------------------------------------------ */
const HptLogsModal = ({ trg, logs, onClose }) => {
  const [f, setF] = hptUseState({ from: "", to: "", player: "", status: "" });
  /* DataTables server-side ordering on all six columns; default promo_trigger_logs.id.
     The Player entry is the fixed version of the real one (divergence 2). */
  const [sort, setSort] = hptUseState({ key: "id", dir: "desc" });
  const [page, setPage] = hptUseState(0);
  const [pageSize, setPageSize] = hptUseState(10);

  const rows = hptUseMemo(() => {
    const pq = f.player.trim().toLowerCase();
    return (logs || []).filter(l => {
      if (f.from && hptIsoDay(l.created_at) < f.from) return false;
      if (f.to && hptIsoDay(l.created_at) > f.to) return false;
      /* Player box is a prefix match on the real platform (username LIKE 'x%'). */
      if (pq && l.player_username.toLowerCase().indexOf(pq) !== 0) return false;
      if (f.status && l.status !== f.status) return false;
      return true;
    });
  }, [logs, f]);

  const sorted = hptUseMemo(() => {
    const dir = sort.dir === "asc" ? 1 : -1;
    const val = (l) => sort.key === "player" ? l.player_username
      : sort.key === "created_at" ? l.created_at
        : sort.key === "status" ? l.status
          : sort.key === "error" ? (l.error || "")
            : sort.key === "details" ? JSON.stringify(l.details)
              : l.id;
    return rows.slice().sort((a, b) => { const x = val(a), y = val(b); return (typeof x === "number" ? x - y : String(x).localeCompare(String(y))) * dir; });
  }, [rows, sort]);

  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);

  const columns = [
    { key: "id", label: "ID", sortable: true, firstDir: "desc", width: 92, render: l => <span className="hpt-mono">{l.id}</span> },
    /* Player → users.username via leftJoin, linked to /players/{id} on the real platform. */
    {
      key: "player", label: "Player", sortable: true, firstDir: "asc", render: l => (
        <span className="hpt-player">
          <span className="hpt-player__u" title={`Opens /players/${l.player_id} on the real platform`}>{l.player_username}</span>
          <CopyableId value={l.player_id} className="hpt-player__id" />
        </span>
      )
    },
    { key: "created_at", label: "Date", sortable: true, firstDir: "desc", width: 190, render: l => <span className="hpt-dim">{hptFmtLogTs(l.created_at)}</span> },
    {
      key: "status", label: "Status", sortable: true, align: "center", width: 110, render: l => l.status === "success"
        ? <span className="chip chip--ok"><Icon name="check" size={10} /> Success</span>
        : <span className="chip chip--err"><Icon name="x" size={10} /> Error</span>
    },
    { key: "details", label: "Details", sortable: true, render: l => <HptJson value={l.details} /> },
    { key: "error", label: "Error", sortable: true, render: l => l.error ? <span className="hpt-errtext">{l.error}</span> : <span className="hpt-dim">—</span> },
  ];

  return (
    <HptModal
      size="wide"
      title={<>Execution logs{/* hardcoded modal title on the real platform */}</>}
      sub={<>{trg.name} · <span className="hpt-mono">{trg.code || "—"}</span> · trigger #{trg.id} · {hptSkinName(trg.skin_id)}</>}
      onClose={onClose}
      footer={<button className="btn btn--secondary" onClick={onClose}>Close</button>}>

      <div className="hpt-lf">
        <div className="hpt-lf__f">
          <label>Date from</label>
          <input type="date" className="input" value={f.from} onChange={e => { setF(x => ({ ...x, from: e.target.value })); setPage(0); }} />
        </div>
        <div className="hpt-lf__f">
          <label>Date to</label>
          <input type="date" className="input" value={f.to} onChange={e => { setF(x => ({ ...x, to: e.target.value })); setPage(0); }} />
        </div>
        <div className="hpt-lf__f hpt-lf__f--grow">
          <label>Player</label>
          <input className="input" placeholder="Username starts with…" value={f.player} onChange={e => { setF(x => ({ ...x, player: e.target.value })); setPage(0); }} />
        </div>
        <div className="hpt-lf__f">
          <label>Status</label>
          <select className="select" value={f.status} onChange={e => { setF(x => ({ ...x, status: e.target.value })); setPage(0); }}>
            <option value="">All</option>
            <option value="success">Success</option>
            <option value="error">Error</option>
          </select>
        </div>
        <div className="hpt-lf__count">{hrsInt(sorted.length)} of {hrsInt((logs || []).length)}</div>
      </div>

      <div className="hpt-hint hpt-hint--tight">
        The real modal&rsquo;s date boxes are <code>dd/mm/yyyy</code> datepickers and the Player box matches
        <code> username LIKE &lsquo;x%&rsquo;</code> — a prefix, not a contains. Sorting is server-side on all six columns
        (default <code>promo_trigger_logs.id</code>); the Player sort is the fixed version of one that SQL-errors on the
        real platform because <code>getLogsTable</code> orders by <code>players.username</code> while the query joins
        <code> users</code>.
      </div>

      <HrsTable
        columns={columns} rows={paged} rowKey="id" dense
        sort={sort} onSort={(s) => { setSort(s); setPage(0); }}
        empty={(logs || []).length === 0
          ? "This trigger has never executed — no promo_trigger_logs rows."
          : "No log rows match these filters."}
        renderCard={l => (
          <>
            <div className="hrs-card__top">
              <b>{l.player_username}</b>
              {l.status === "success"
                ? <span className="chip chip--ok"><Icon name="check" size={10} /> Success</span>
                : <span className="chip chip--err"><Icon name="x" size={10} /> Error</span>}
            </div>
            <div className="hrs-card__grid">
              <span>Log ID</span><b className="hpt-mono">{l.id}</b>
              <span>Player ID</span><b className="hpt-mono">{l.player_id}</b>
              <span>Date</span><b>{hptFmtLogTs(l.created_at)}</b>
            </div>
            {l.error && <div className="hpt-errtext hpt-errtext--card">{l.error}</div>}
            <details className="hpt-cardmore"><summary>Details</summary><HptJson value={l.details} /></details>
          </>
        )} />

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

/* ------------------------------------------------------------------ *
 * Create / Edit form — GET /promotriggers/form/?id=<id> into the modal,
 * POST /promotriggers/saveTrigger/?id=<id> to save. Validation =
 * SavePromoTriggerRequest (name, promotype only) + inline checks in
 * saveTrigger (L353-480); the server answers ajaxError(message,
 * {campierrati: [fields]}) which the shared modal turns into red field
 * outlines, so errors are modelled the same way here.
 * Neither endpoint has any role check.
 * ------------------------------------------------------------------ */
const HptFormModal = ({ trg, rows, onClose, onSave }) => {
  const isNew = !trg;
  const [name, setName] = hptUseState(trg ? trg.name : "");
  const [skinId, setSkinId] = hptUseState(trg ? String(trg.skin_id) : "");
  const [promotype, setPromotype] = hptUseState(trg ? trg.promotype : "");
  const [code, setCode] = hptUseState(trg ? trg.code : "");
  const [start, setStart] = hptUseState(trg ? hptToInput(trg.start) : "");
  const [end, setEnd] = hptUseState(trg ? hptToInput(trg.end) : "");
  const [pay, setPay] = hptUseState(trg ? Object.assign({}, trg.pay) : {});
  const [promoQ, setPromoQ] = hptUseState("");
  const [errs, setErrs] = hptUseState({});
  const [banner, setBanner] = hptUseState("");

  const setP = (k, v) => { setPay(p => ({ ...p, [k]: v })); clearErr(k); };
  const clearErr = (k) => { setErrs(x => { const n = { ...x }; delete n[k]; return n; }); setBanner(""); };

  const games = HPT_FS_GAMES.filter(g => !pay.fs_vendor || g.vendor === pay.fs_vendor);
  const betOptions = hptFsBets(pay.fs_game_id, pay.fs_currency);
  /* fetchPromotions is skin-scoped and pages 10 at a time. */
  const promoMatches = HPT_PROMOTIONS
    .filter(p => String(p.skin_id) === String(skinId))
    .filter(p => { const q = promoQ.trim().toLowerCase(); return !q || p.name.toLowerCase().includes(q) || p.code.toLowerCase().includes(q); })
    .slice(0, 10);

  const save = () => {
    const e = {};
    if (!name.trim()) e.name = "Insert name";                       /* backend.insert_name */
    else if (name.trim().length > 255) e.name = "Name is too long (max 255)"; /* label inferred */
    if (!skinId) e.skin_id = "Select skin";                          /* backend.select_skin */
    if (!promotype) e.promotype = "Select typology";                 /* backend.select_typology */
    else if (!HPT_TYPES.some(t => t.value === promotype)) e.promotype = "Invalid promotype";
    if (!code.trim()) e.code = "Insert code";
    else if (rows.some(r => r.code.toLowerCase() === code.trim().toLowerCase() && String(r.skin_id) === String(skinId) && (isNew || r.id !== trg.id))) e.code = "Code already exists for this skin"; /* label inferred — the uniqueness check is on (code, skin_id), L459-470 */
    if (!start) e.start = "Fill in start date";                      /* backend.fill_in_start_date */
    if (!end) e.end = "Fill in end date";                            /* backend.fill_in_end_date */
    if (promotype === "freespins") {
      if (!pay.fs_vendor) e.fs_vendor = "Vendor is required";
      if (!pay.fs_currency) e.fs_currency = "Currency is required";
      if (!(Number(pay.fs_freespins_per_player) >= 1)) e.fs_freespins_per_player = "Freespins per player is required";
      if (!pay.fs_game_id) e.fs_game_id = "Game is required";
      if (!pay.fs_bet_amount) e.fs_bet_amount = "Bet amount is required";
    }
    if (promotype === "promotion" && !pay.promotion_id) e.promotion_id = "Promotion is required";
    if (promotype === "promoplay_points" && !(Number(pay.promoplay_points) >= 1)) e.promoplay_points = "Points are required";
    /* sic — "Real money amount are required" is the string the controller returns (L446-452). */
    if (promotype === "real_money" && !(parseFloat(pay.real_money_amount) > 0)) e.real_money_amount = "Real money amount are required";
    setErrs(e);
    const first = Object.keys(e).map(k => e[k])[0];
    if (first) { setBanner(first); return; }
    setBanner("");
    const clean = {};
    if (promotype === "freespins") ["fs_vendor", "fs_currency", "fs_freespins_per_player", "fs_game_id", "fs_bet_amount"].forEach(k => { clean[k] = pay[k]; });
    if (promotype === "promotion") clean.promotion_id = Number(pay.promotion_id);
    if (promotype === "promoplay_points") clean.promoplay_points = Number(pay.promoplay_points);
    if (promotype === "real_money") clean.real_money_amount = parseFloat(pay.real_money_amount);
    onSave({
      id: isNew ? null : trg.id, skin_id: Number(skinId), name: name.trim(), code: code.trim(),
      promotype, start: hptFromInput(start), end: hptFromInput(end),
      hidden: trg ? trg.hidden : 0, pay: clean,
    });
    onClose();
  };

  const cur = hptSkinCur(skinId);

  return (
    <HptModal
      size="wide"
      /* backend.new_trigger resolves nowhere in the committed lang file — label inferred. Edit
         titles come from the JS opener gestioneTrigger(id, 'Edit <name>'). */
      title={isNew ? <>New trigger{/* label inferred */}</> : `Edit ${trg.name}`}
      sub={isNew ? "POST /promotriggers/saveTrigger/" : `POST /promotriggers/saveTrigger/?id=${trg.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="hpt-err hpt-err--banner"><Icon name="alert" size={13} /> {banner}</div>}

      <div className="hpt-hint">
        A trigger has no schedule of its own. <code>executeTrigger(code, skin_id, player_id)</code> runs it when a
        promotion assignment fires it (<code>PromotionsController::executeAssociatedTriggers</code>) or when PromoPlay
        reports a casino prize or trigger key — and only while <b>now</b> sits inside the window below. Attaching this
        trigger to a promotion is done on the <b>Promotions</b> form, not here.
      </div>

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

      <div className="hpt-grid2">
        <div className="hpt-field">
          <label className="hpt-label" htmlFor="hpt-name">Name <span className="hpt-req">*</span></label>
          <input id="hpt-name" className={`input${errs.name ? " hpt-invalid" : ""}`} maxLength={255} autoFocus
            value={name} onChange={e => { setName(e.target.value); clearErr("name"); }} />
          {errs.name && <div className="hpt-fielderr">{errs.name}</div>}
        </div>

        <div className="hpt-field">
          {/* Skin select is rendered for super admins only; a skin admin's save is silently forced to
              Auth::user()->skin_id by saveTrigger L375 regardless of what is posted. */}
          <label className="hpt-label" htmlFor="hpt-skin">Skin <span className="hpt-req">*</span></label>
          <select id="hpt-skin" className={`select${errs.skin_id ? " hpt-invalid" : ""}`} value={skinId}
            onChange={e => { setSkinId(e.target.value); clearErr("skin_id"); if (pay.promotion_id) setP("promotion_id", ""); }}>
            <option value="">Select skin</option>
            {HPT_SKINS.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
          </select>
          {errs.skin_id && <div className="hpt-fielderr">{errs.skin_id}</div>}
        </div>

        <div className="hpt-field">
          <label className="hpt-label" htmlFor="hpt-code">Code <span className="hpt-req">*</span></label>
          <input id="hpt-code" className={`input hpt-mono${errs.code ? " hpt-invalid" : ""}`}
            value={code} onChange={e => { setCode(e.target.value); clearErr("code"); }} />
          {errs.code && <div className="hpt-fielderr">{errs.code}</div>}
          <div className="hpt-hint">The key callers pass to <code>executeTrigger()</code>. Unique per <code>(code, skin_id)</code> — two skins may reuse the same code.</div>
        </div>

        <div className="hpt-field">
          <label className="hpt-label" htmlFor="hpt-type">Trigger type <span className="hpt-req">*</span></label>{/* backend.trigger_type — label inferred */}
          <select id="hpt-type" className={`select${errs.promotype ? " hpt-invalid" : ""}`} value={promotype}
            onChange={e => { setPromotype(e.target.value); setPay({}); clearErr("promotype"); }}>
            <option value="">Select typology</option>
            {HPT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
          </select>
          {errs.promotype && <div className="hpt-fielderr">{errs.promotype}</div>}
        </div>

        <div className="hpt-field">
          <label className="hpt-label" htmlFor="hpt-start">Start date <span className="hpt-req">*</span></label>
          <input id="hpt-start" type="datetime-local" className={`input${errs.start ? " hpt-invalid" : ""}`}
            value={start} onChange={e => { setStart(e.target.value); clearErr("start"); }} />
          {errs.start && <div className="hpt-fielderr">{errs.start}</div>}
        </div>

        <div className="hpt-field">
          <label className="hpt-label" htmlFor="hpt-end">End date <span className="hpt-req">*</span></label>
          <input id="hpt-end" type="datetime-local" className={`input${errs.end ? " hpt-invalid" : ""}`}
            value={end} onChange={e => { setEnd(e.target.value); clearErr("end"); }} />
          {errs.end && <div className="hpt-fielderr">{errs.end}</div>}
        </div>
      </div>
      <div className="hpt-hint">
        Stored as unix ints. The real form uses a <code>dd/mm/yyyy hh:ii</code> datetimepicker parsed by
        <code> sistemadatatime()</code>; the window is the <b>only</b> thing that decides whether a trigger fires —
        the <code>stato</code> (Active/Disabled) column is never read.
      </div>

      {promotype === "freespins" && (
        <>
          <div className="hpt-sectitle">Freespins settings</div>{/* #freespins_settings */}
          <div className="hpt-grid2">
            <div className="hpt-field">
              <label className="hpt-label">Vendor <span className="hpt-req">*</span></label>
              <select className={`select${errs.fs_vendor ? " hpt-invalid" : ""}`} value={pay.fs_vendor || ""}
                onChange={e => { setP("fs_vendor", e.target.value); setP("fs_game_id", ""); setP("fs_bet_amount", ""); }}>
                <option value="">Select vendor</option>
                {HPT_FS_VENDORS.map(v => <option key={v} value={v}>{v}</option>)}
              </select>
              {errs.fs_vendor && <div className="hpt-fielderr">{errs.fs_vendor}</div>}
            </div>
            <div className="hpt-field">
              <label className="hpt-label">Currency <span className="hpt-req">*</span></label>
              <select className={`select${errs.fs_currency ? " hpt-invalid" : ""}`} value={pay.fs_currency || ""}
                onChange={e => { setP("fs_currency", e.target.value); setP("fs_bet_amount", ""); }}>
                <option value="">Select currency</option>
                {HPT_FS_CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
              </select>
              {errs.fs_currency && <div className="hpt-fielderr">{errs.fs_currency}</div>}
            </div>
            <div className="hpt-field">
              <label className="hpt-label">Game <span className="hpt-req">*</span></label>
              <select className={`select${errs.fs_game_id ? " hpt-invalid" : ""}`} value={pay.fs_game_id || ""}
                onChange={e => { setP("fs_game_id", e.target.value); setP("fs_bet_amount", ""); }}>
                <option value="">Select game</option>
                {games.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
              </select>
              {errs.fs_game_id && <div className="hpt-fielderr">{errs.fs_game_id}</div>}
            </div>
            <div className="hpt-field">
              <label className="hpt-label">Bet amount <span className="hpt-req">*</span></label>
              <select className={`select${errs.fs_bet_amount ? " hpt-invalid" : ""}`} value={pay.fs_bet_amount || ""}
                onChange={e => setP("fs_bet_amount", e.target.value)} disabled={betOptions.length === 0}>
                <option value="">{betOptions.length ? "Select bet amount" : "Pick a game and a currency first"}</option>
                {betOptions.map(b => <option key={b} value={b}>{b}</option>)}
              </select>
              {errs.fs_bet_amount && <div className="hpt-fielderr">{errs.fs_bet_amount}</div>}
            </div>
            <div className="hpt-field">
              <label className="hpt-label">Freespins per player <span className="hpt-req">*</span></label>
              <input type="number" min={1} className={`input${errs.fs_freespins_per_player ? " hpt-invalid" : ""}`}
                value={pay.fs_freespins_per_player || ""} onChange={e => setP("fs_freespins_per_player", e.target.value)} />
              {errs.fs_freespins_per_player && <div className="hpt-fielderr">{errs.fs_freespins_per_player}</div>}
            </div>
          </div>
          <div className="hpt-hint">
            The four selects cascade off <code>/tt_freespins/fetchVendors</code>, <code>/fetchCurrencies</code> and
            <code> /fetchGamesWithLimits</code>; the bet amounts are the game&rsquo;s <code>fs_limits.limit_values</code> for
            the chosen currency. Execution posts a campaign to TimelessTech
            (<code>/api/generic/campaigns/create</code>) — an upstream failure is written to the trigger&rsquo;s logs as an
            <code> error</code> row, not raised to whoever assigned the promotion.
          </div>
        </>
      )}

      {promotype === "promotion" && (
        <>
          <div className="hpt-sectitle">Promotion</div>
          <div className="hpt-field">
            <label className="hpt-label">Promotion <span className="hpt-req">*</span></label>
            <input className="input hpt-promoq" placeholder="Search by promotion name or code…"
              value={promoQ} onChange={e => setPromoQ(e.target.value)} disabled={!skinId} />
            <select className={`select${errs.promotion_id ? " hpt-invalid" : ""}`} value={pay.promotion_id || ""}
              onChange={e => setP("promotion_id", e.target.value)} disabled={!skinId}>
              <option value="">{skinId ? "Select promotion" : "Select a skin first"}</option>
              {promoMatches.map(p => <option key={p.id} value={p.id}>{`#${p.id} · ${p.name} (${p.code})`}</option>)}
            </select>
            {errs.promotion_id && <div className="hpt-fielderr">{errs.promotion_id}</div>}
            <div className="hpt-hint">
              Scoped to the selected skin and paged 10 at a time by <code>fetchPromotions</code>, which searches
              promotion name and code. Execution runs
              <code> PromotionsBonusController::associateToPromo()</code> — the same path a manual assignment takes.
            </div>
          </div>
        </>
      )}

      {promotype === "promoplay_points" && (
        <>
          <div className="hpt-sectitle">Promoplay points</div>
          <div className="hpt-field">
            <label className="hpt-label">Points <span className="hpt-req">*</span></label>
            <input type="number" min={1} className={`input${errs.promoplay_points ? " hpt-invalid" : ""}`} style={{ maxWidth: 220 }}
              value={pay.promoplay_points || ""} onChange={e => setP("promoplay_points", e.target.value)} />
            {errs.promoplay_points && <div className="hpt-fielderr">{errs.promoplay_points}</div>}
            <div className="hpt-hint">
              Credited by a raw cURL call to the skin&rsquo;s PromoPlay API
              (<code>skins.pp_api_url</code> + <code>/api/players/depositPoints</code>, signed with
              <code> pp_username</code>/<code>pp_secret_key</code>). A skin without those credentials fails at execution
              time with a 500 from the upstream, logged as an <code>error</code> row.
            </div>
          </div>
        </>
      )}

      {promotype === "real_money" && (
        <>
          <div className="hpt-sectitle hpt-sectitle--danger">Real money</div>
          <div className="hpt-field">
            <label className="hpt-label">Amount <span className="hpt-req">*</span></label>
            <div className="hpt-amountrow">
              <input className={`input${errs.real_money_amount ? " hpt-invalid" : ""}`} style={{ maxWidth: 220 }}
                value={pay.real_money_amount == null ? "" : pay.real_money_amount}
                onChange={e => setP("real_money_amount", e.target.value)} />
              <span className="hpt-curtag">{cur}</span>
            </div>
            {errs.real_money_amount && <div className="hpt-fielderr">{errs.real_money_amount}</div>}
          </div>
          <div className="hpt-danger-note">
            <Icon name="alert" size={14} />
            <div>
              <b>This type pays cash, not bonus.</b> Every time this trigger fires,
              <code> executeRealMoney()</code> calls <code>TransferController::processTransfer()</code> and credits the
              player {pay.real_money_amount ? <b>{hptAmount(pay.real_money_amount, cur)}</b> : "the amount above"}, debiting
              the player&rsquo;s admin-skin ancestor as payeer. There is no cap, no per-player limit, no daily ceiling and
              no reversal — the only brake is the start/end window. A trigger attached to a high-traffic promotion pays
              this out once per successful assignment.
            </div>
          </div>
        </>
      )}
    </HptModal>
  );
};

/* ------------------------------------------------------------------ *
 * Delete — GET /promotriggers/delete/{id}/ (a state-changing GET with
 * no CSRF token, the legacy convention across these screens). Refused
 * unless the trigger is already expired (L336-338); on success it drops
 * the promo_trigger_logs rows, the promotions_triggers pivot rows and
 * the trigger itself in one transaction (L340-350).
 * ------------------------------------------------------------------ */
const HptDeleteDialog = ({ trg, logCount, onClose, onConfirm }) => {
  const now = hptNow();
  const expired = trg.end < now;
  return (
    <HptModal
      title={expired ? "Delete trigger" : "Delete refused"}
      sub={`#${trg.id} · ${trg.name}`}
      onClose={onClose}
      footer={expired
        ? <>
          <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
          <button className="btn btn--danger" onClick={onConfirm}><Icon name="trash" size={13} /> Delete</button>
        </>
        : <button className="btn btn--secondary" onClick={onClose}>Close</button>}>
      {expired ? (
        <>
          <div className="hpt-dlgq">Delete <b>{trg.name}</b> (<span className="hpt-mono">{trg.code || "—"}</span>)?</div>
          <div className="hpt-hint">
            One transaction removes <b>{logCount}</b> <code>promo_trigger_logs</code> row{logCount === 1 ? "" : "s"}, every
            <code> promotions_triggers</code> pivot row that attaches this trigger to a promotion, and the trigger itself.
            Promotions that referenced it keep working — they simply stop firing this side effect, with no notice on their
            own screen.
          </div>
          <div className="hpt-hint">
            Sent as a plain <code>GET /promotriggers/delete/{trg.id}/</code> with no CSRF token. Skin admins may only
            delete triggers belonging to their own skin; super admins may delete any.
          </div>
        </>
      ) : (
        <>
          <div className="hpt-err"><Icon name="alert" size={13} /> Cannot delete an active (non-expired) trigger</div>
          <div className="hpt-hint">
            That is the server&rsquo;s own answer, not a client-side guard: <code>delete()</code> refuses while
            <code> end &ge; time()</code>. This window closes <b>{hptFmtTs(trg.end)}</b>. To retire it now, edit the
            trigger and move its end date into the past — then delete it.
          </div>
        </>
      )}
    </HptModal>
  );
};

/* ------------------------------------------------------------------ *
 * GET /testTrigger — the destructive dev leftover. Surfaced, labelled
 * for what it does, and put behind a typed confirmation. See the file
 * header for the full write-up and the SUGGESTION.
 * ------------------------------------------------------------------ */
const HptTestPanel = ({ target, onRun }) => (
  <div className="hpt-danger">
    <div className="hpt-danger__head">
      <Icon name="alert" size={15} />
      <div>
        <div className="hpt-danger__title">Live endpoint: <span className="hpt-mono">GET /testTrigger</span></div>
        <div className="hpt-danger__sub">Registered at routes/admin.php:1135-1137 · no role check · no button anywhere in the real back office</div>
      </div>
    </div>
    <div className="hpt-danger__body">
      <p>
        This route executes <code>executeTrigger(&lsquo;{HPT_TEST_CODE}&rsquo;, {HPT_TEST_SKIN}, &lsquo;{HPT_TEST_PLAYER}&rsquo;)</code> against
        live data. It is <b>not</b> a dry run, a sandbox or a connectivity check — it runs the same engine a real
        promotion assignment runs, for the hardcoded player <span className="hpt-mono">{HPT_TEST_PLAYER}</span> on skin{" "}
        <span className="hpt-mono">{HPT_TEST_SKIN}</span> ({hptSkinName(HPT_TEST_SKIN)}).
      </p>
      <p>
        {target ? (
          <>Trigger <span className="hpt-mono">{HPT_TEST_CODE}</span> currently exists on that skin as a{" "}
            <b>{hptTypeLabel(target.promotype)}</b> trigger
            {target.promotype === "real_money" && <> for <b>{hptAmount(target.pay.real_money_amount, hptSkinCur(target.skin_id))}</b></>},
            and its window is {target.end < hptNow() ? <b>closed</b> : <b>open</b>} ({hptFmtTs(target.start)} → {hptFmtTs(target.end)}).{" "}
            {target.end < hptNow()
              ? "While the window is closed the call is a no-op — but any edit that reopens it makes the URL live again."
              : target.promotype === "real_money"
                ? "While the window is open, calling this URL transfers real money: TransferController::processTransfer() debits the player's admin-skin ancestor and credits the player, and a real_money_assigned row lands in this trigger's logs."
                : "While the window is open, calling this URL runs that side effect for real against the hardcoded player — and if the trigger is ever switched back to Real Money, the same URL starts paying cash again."}
          </>
        ) : (
          <>No trigger with code <span className="hpt-mono">{HPT_TEST_CODE}</span> exists on skin {HPT_TEST_SKIN} right now, so the call
            would find nothing to run — until someone creates one with that code, at which point this URL starts paying it out.</>
        )}
      </p>
      <p className="hpt-danger__who">
        Anyone with a back-office session that clears the auth + 2FA middleware can call it — <code>testTrigger</code>
        {" "}checks no role at all, unlike <code>index</code> (super admin / skin admin) or <code>delete</code>.
      </p>
    </div>
    <div className="hpt-danger__foot">
      <button className="btn btn--danger hpt-runbtn" onClick={onRun}>
        <Icon name="zap" size={13} /> Run /testTrigger — moves real money
      </button>
      <span className="hpt-danger__hintline">Opens a confirmation that spells out exactly what is about to happen.</span>
    </div>
  </div>
);

const HptTestDialog = ({ target, onClose, onConfirm }) => {
  const [typed, setTyped] = hptUseState("");
  const armed = typed.trim() === HPT_TEST_CODE;
  const open = target && target.end >= hptNow();
  const cur = target ? hptSkinCur(target.skin_id) : "ARS";
  return (
    <HptModal
      tone="danger"
      title={<><Icon name="alert" size={15} /> Execute a live real-money trigger</>}
      sub={<span className="hpt-mono">GET /testTrigger</span>}
      onClose={onClose}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
        <button className="btn btn--danger" disabled={!armed} onClick={onConfirm}>
          <Icon name="zap" size={13} /> Execute — pay player {HPT_TEST_PLAYER}
        </button>
      </>}>
      <div className="hpt-err"><Icon name="alert" size={13} /> This is not a test. It runs the production execution path.</div>
      <div className="hpt-what">
        <div className="hpt-what__h">What this call actually does</div>
        <ul>
          <li>Looks up the trigger with code <span className="hpt-mono">{HPT_TEST_CODE}</span> on skin <span className="hpt-mono">{HPT_TEST_SKIN}</span> ({hptSkinName(HPT_TEST_SKIN)}) — all three values are hardcoded in the route, none can be changed at call time.</li>
          <li>If that trigger&rsquo;s window is open, executes its side effect for player <span className="hpt-mono">{HPT_TEST_PLAYER}</span>.
            {target && target.promotype === "real_money" && <> Here that means <code>executeRealMoney()</code> → <code>TransferController::processTransfer()</code>: <b>{hptAmount(target.pay.real_money_amount, cur)}</b> credited to the player, debited from the player&rsquo;s admin-skin ancestor.</>}</li>
          <li>Writes a <code>promo_trigger_logs</code> row (<code>real_money_assigned</code> on success) — the only record that it happened.</li>
          <li>No confirmation prompt, no idempotency key, no reversal. Calling it twice pays twice.</li>
        </ul>
      </div>
      {!open && (
        <div className="hpt-hint hpt-hint--warn">
          <Icon name="info" size={13} /> Right now that trigger&rsquo;s window is closed, so the call would log nothing and
          move nothing. This is timing, not a safeguard — the endpoint has no guard of its own.
        </div>
      )}
      <div className="hpt-field">
        <label className="hpt-label" htmlFor="hpt-arm">Type <span className="hpt-mono">{HPT_TEST_CODE}</span> to enable the button</label>
        <input id="hpt-arm" className="input hpt-mono" style={{ maxWidth: 280 }} value={typed} autoFocus
          placeholder={HPT_TEST_CODE} onChange={e => setTyped(e.target.value)} />
      </div>
      <div className="hpt-hint">
        In this prototype nothing is transferred — confirming appends the log row the real call would produce, so the
        trail it leaves is visible. On the real platform the transfer is already committed by the time the response
        renders.
      </div>
    </HptModal>
  );
};

/* ------------------------------------------------------------------ *
 * Honest absences + the two implemented divergences, stated on screen
 * rather than left for a reader to discover.
 * ------------------------------------------------------------------ */
const HptAbsences = () => (
  <div className="hpt-absence">
    <div className="hpt-absence__h"><Icon name="info" size={13} /> What this screen does not have — and why</div>
    <ul className="hpt-absence__l">
      <li><b>No export.</b> The controller imports OpenSpout at the top and never uses it; no export button exists, so none was added.</li>
      <li><b>No sorting and no bulk actions.</b> The list is a fixed <code>ORDER BY promo_triggers.id DESC</code> with <code>paginate(25)</code>. Only the logs modal sorts.</li>
      <li><b>No Active/Disabled switch.</b> <code>promo_triggers.stato</code> exists and is fillable, but nothing displays, edits or reads it — a trigger&rsquo;s only on/off is its start/end window.</li>
      <li><b>No attach-to-promotion control.</b> The <code>promotions_triggers</code> pivot is written from the Promotions edit form; this screen only shows the result through the logs.</li>
      <li><b>Hidden is a divergence, not a feature.</b> The switch below the header exists because <code>POST /promotriggers/{"{id}"}/hidden</code> and its JS exist — but no element calls them, <code>hidden</code> is not in <code>$fillable</code>, and the committed migration has no such column, so on the real platform that endpoint would SQL-error. It is drawn here as the evident intent of the sibling Promotions list.</li>
      <li><b>The Player sort in the logs modal is a fix.</b> The real one orders by <code>players.username</code> while the query joins <code>users</code>, which raises a SQL error.</li>
    </ul>
  </div>
);

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

  const trigFeed = useHrsFetch(() => window.sb.list("promoTriggers", { limit: 500 }), []);
  const rows = hptUseMemo(() => (trigFeed.data || []).map(hptRowFromDb), [trigFeed.data]);

  /* Logs, grouped by trigger — the screen indexes them that way. One fetch
     rather than one per trigger: the list is small and N+1 over an audit table
     is how a screen with 14 triggers issues 15 requests to render. */
  const logFeed = useHrsFetch(() => window.sb.list("promoTriggerLogs", { limit: 1000 }), []);
  const logs = hptUseMemo(() => {
    const out = {};
    (logFeed.data || []).forEach(r => {
      const m = hptLogFromDb(r);
      (out[m.trigger_id] = out[m.trigger_id] || []).push(m);
    });
    return out;
  }, [logFeed.data]);

  const save = useHrsSave([trigFeed, logFeed]);
  /* Filters are a GET form applied on Apply, not on keyup — hence the draft/applied split.
     `show_expired` defaults to No: rows whose end is in the past are hidden (L95-97). */
  const [draft, setDraft] = hptUseState({ search: "", skin: "", show_expired: "0" });
  const [applied, setApplied] = hptUseState({ search: "", skin: "", show_expired: "0" });
  const [page, setPage] = hptUseState(0);
  const [form, setForm] = hptUseState(null);   // null | { trg: row|null }
  const [del, setDel] = hptUseState(null);     // null | row
  const [logsFor, setLogsFor] = hptUseState(null);
  const [testOpen, setTestOpen] = hptUseState(false);
  const now = hptNow();

  const FIELDS = [
    {
      key: "search", label: "Search", type: "text", icon: "search", grow: true,
      placeholder: "Name, code or exact ID…", /* backend.trigger_search_placeholder — label inferred */
      tip: <>Matches <code>name LIKE %x%</code> OR <code>code LIKE %x%</code>, plus an exact <code>id</code> match when the term is all digits.</>,
    },
    {
      key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "Select", width: 190,
      options: HPT_SKINS.map(s => ({ value: String(s.id), label: s.name })),
      tip: <>Empty means every skin. Super admins only: a skin admin reaching this page by URL is hard-forced to their own <code>skin_id</code> and sees neither this filter nor the Skin column.</>,
    },
    {
      key: "show_expired", label: "Show expired triggers", type: "select", icon: "calendar", width: 190,
      defaultValue: "0", options: [{ value: "0", label: "No" }, { value: "1", label: "Yes" }],
      tip: <>Default <b>No</b> — rows whose end date has passed are hidden. <b>Yes</b> shows valid and expired together; there is no expired-only view.</>,
    },
  ];

  /* Search + skin only — the chip counts are computed before the expired filter, the way the
     controller's summary is (L111-115). `all` is computed there too and never displayed; it is not
     displayed here either. */
  const preExpiry = hptUseMemo(() => {
    const q = String(applied.search || "").trim().toLowerCase();
    const digits = q && /^\d+$/.test(q);
    return rows.filter(r => {
      if (applied.skin && String(r.skin_id) !== String(applied.skin)) return false;
      if (q) {
        const hit = r.name.toLowerCase().includes(q) || (r.code || "").toLowerCase().includes(q) || (digits && String(r.id) === q);
        if (!hit) return false;
      }
      return true;
    });
  }, [rows, applied]);

  const counts = hptUseMemo(() => ({
    valid: preExpiry.filter(r => r.end >= now).length,
    expired: preExpiry.filter(r => r.end < now).length,
  }), [preExpiry, now]);

  /* Fixed ORDER BY promo_triggers.id DESC (L106) — the list has no sortable columns at all, so the
     order is not user-controllable here either. */
  const filtered = hptUseMemo(
    () => (applied.show_expired === "1" ? preExpiry : preExpiry.filter(r => r.end >= now)).slice().sort((a, b) => b.id - a.id),
    [preExpiry, applied.show_expired, now]);

  const PAGE_SIZE = 25; // paginate(25), L106 — the real page has no page-size control
  const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
  const safePage = Math.min(page, pageCount - 1);
  const paged = filtered.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE);

  const onSearch = (v) => { setApplied({ search: v.search || "", skin: v.skin || "", show_expired: v.show_expired || "0" }); setPage(0); };
  const onReset = () => { const d = { search: "", skin: "", show_expired: "0" }; setDraft(d); setApplied(d); setPage(0); };
  const setExpired = (v) => { const next = { ...applied, show_expired: v }; setDraft(next); setApplied(next); setPage(0); };

  /* The screen's field names map back to the columns here rather than at the
     form, so the form keeps talking about `promotype`, `start`, `end` and
     `hidden` the way every render site in this file does. `visible` is the
     column and `hidden` is the screen's word: one inversion, one place. */
  const toColumns = (t) => ({
    skin_id: t.skin_id,
    name: t.name,
    code: t.code,
    action_type: t.promotype,
    starts_at: t.start ? new Date(t.start * 1000).toISOString() : null,
    ends_at: t.end ? new Date(t.end * 1000).toISOString() : null,
    visible: !t.hidden,
    promotion_id: t.pay ? t.pay.promotion_id : null,
    freespin_currency: t.pay ? t.pay.currency : null,
    freespins_per_player: t.pay ? t.pay.freespins : null,
    freespin_game_id: t.pay ? t.pay.game_id : null,
    freespin_bet_amount: t.pay ? t.pay.bet : null,
    freespin_vendor: t.pay ? t.pay.vendor : null,
    promoplay_points: t.pay ? t.pay.points : null,
    real_money_amount: t.pay ? t.pay.amount : null,
  });

  const onSave = (t) => {
    const body = toColumns(t);
    return t.id == null
      ? save.run(() => window.sb.create("promoTriggers", body))
      : save.run(() => window.sb.update("promoTriggers", t.id, body));
  };

  const onDelete = () => {
    const t = del;
    setDel(null);
    /* promo_trigger_logs references the trigger. The FK decides what happens to
       them — cascade or restrict — not this screen, and not a toast that claims
       a row count it did not verify. The old handler asserted "N log rows and
       its pivot rows went with it, in one transaction" while deleting nothing
       but a local array. */
    return save.run(() => window.sb.remove("promoTriggers", t.id));
  };

  /* Divergence 1 — evident intent of toggleHidden (isadmin()-gated on the real endpoint). */
  const onToggleHidden = (t) => save.run(
    /* isystem's endpoint writes a `hidden` column its own migration never
       created, so the call SQL-errors there. This schema has `visible`, which
       is the same switch stated positively — so the button works here, and the
       divergence is that it works at all. */
    () => window.sb.update("promoTriggers", t.id, { visible: !!t.hidden }));

  const testTarget = rows.find(r => r.code === HPT_TEST_CODE && r.skin_id === HPT_TEST_SKIN) || null;

  const runTestTrigger = () => {
    setTestOpen(false);
    if (!testTarget) {
      hrsToast("/testTrigger executed — nothing to run", `No trigger with code ${HPT_TEST_CODE} exists on skin ${HPT_TEST_SKIN}; executeTrigger() found no row and logged nothing.`);
      return;
    }
    const open = testTarget.end >= hptNow();
    const player = HPT_PLAYERS[0];
    const entry = {
      id: Math.max(0, ...(logs[testTarget.id] || []).map(l => l.id)) + 1,
      player_id: HPT_TEST_PLAYER,
      player_username: player.u,
      created_at: hptNow(),
      status: open ? "success" : "error",
      details: hptLogDetails(testTarget, { id: HPT_TEST_PLAYER, u: player.u }, open),
      error: open ? "" : "Trigger window closed — executeTrigger() refused to run",
    };
    setLogs(l => ({ ...l, [testTarget.id]: [entry, ...(l[testTarget.id] || [])] }));
    const cash = testTarget.promotype === "real_money";
    hrsToast(
      open ? (cash ? "/testTrigger executed — real-money transfer written" : "/testTrigger executed — side effect applied") : "/testTrigger executed — window closed, nothing ran",
      open
        ? (cash
          ? `${hptAmount(testTarget.pay.real_money_amount, hptSkinCur(testTarget.skin_id))} to player ${HPT_TEST_PLAYER} via processTransfer(); a real_money_assigned row is now on trigger #${testTarget.id}. Prototype only — no money moved here.`
          : `${hptTypeLabel(testTarget.promotype)} applied to player ${HPT_TEST_PLAYER}; a log row is now on trigger #${testTarget.id}. Prototype only — nothing was sent upstream.`)
        : `executeTrigger('${HPT_TEST_CODE}', ${HPT_TEST_SKIN}, '${HPT_TEST_PLAYER}') found the trigger outside its start/end window and did nothing.`);
  };

  const columns = [
    { key: "id", label: "ID", width: 78, render: r => <span className="hpt-mono hpt-dim">{r.id}</span> },
    /* Skin column renders only for super admins ($canPickSkin) — the canonical view, since the
       sidebar entry itself is inside @if (isadmin()). */
    { key: "skin", label: "Skin", width: 170, render: r => <HptSkinCell skinId={r.skin_id} /> },
    {
      key: "name", label: "Name", render: r => (
        <button className="hpt-namelink" title="Edit" onClick={() => setForm({ trg: r })}>
          <span>{r.name}</span>
          {r.hidden === 1 && <span className="hpt-hiddenchip">Hidden</span>}
          <Icon name="chevron_right" size={13} />
        </button>
      )
    },
    { key: "code", label: "Code", width: 150, render: r => r.code ? <CopyableId value={r.code} /> : <span className="hpt-dim">—</span> },
    { key: "type", label: "Trigger type", width: 165, render: r => <HptTypeChip type={r.promotype} /> }, /* backend.trigger_type — label inferred */
    { key: "start", label: "Start date", width: 165, render: r => <span className="hpt-dim">{hptFmtTs(r.start)}</span> },
    { key: "end", label: "End date", width: 165, render: r => <span className="hpt-dim">{hptFmtTs(r.end)}</span> },
    { key: "valid", label: "Valid", align: "center", width: 110, render: r => <HptValidChip trg={r} now={now} /> },
    {
      /* DIVERGENCE 1 — see the file header. Column drawn as the evident intent of an endpoint that
         exists but is called by nothing and writes to a column the committed schema lacks. */
      key: "hidden", label: <>Hidden <Tip size={12}>Divergence, flagged: <code>POST /promotriggers/{"{id}"}/hidden</code> and its JS exist and are <code>isadmin()</code>-gated, but nothing on the real screen calls them, <code>hidden</code> is not in <code>PromoTrigger::$fillable</code>, and the committed <code>promo_triggers</code> migration has no <code>hidden</code> column — the call would SQL-error. Drawn here as the Visible/Hidden switch of the sibling Promotions list this screen was copied from. Nothing in the execution engine reads it.</Tip></>,
      align: "center", width: 110,
      render: r => (
        <span className="hpt-togglecell" onClick={(e) => e.stopPropagation()}>
          <Toggle size="sm" value={r.hidden !== 1} onChange={() => onToggleHidden(r)} onLabel="" offLabel="" />
        </span>
      )
    },
    {
      key: "logs", label: "Logs", align: "center", width: 110, render: r => {
        const n = (logs[r.id] || []).length;
        const bad = (logs[r.id] || []).some(l => l.status === "error");
        return (
          <button className={`hpt-logsbtn${n === 0 ? " hpt-logsbtn--empty" : ""}${bad ? " hpt-logsbtn--bad" : ""}`}
            title={n === 0 ? "Never executed" : `${n} execution log row${n === 1 ? "" : "s"}`}
            onClick={(e) => { e.stopPropagation(); setLogsFor(r); }}>
            <Icon name="list" size={12} /> {n}
          </button>
        );
      }
    },
    {
      key: "_acts", label: "Actions", align: "center", width: 110, render: r => (
        <div className="hpt-acts">
          <button className="hpt-act hpt-act--edit" title="Edit" onClick={(e) => { e.stopPropagation(); setForm({ trg: r }); }}>
            <Icon name="edit" size={13} />
          </button>
          <button className="hpt-act hpt-act--danger" title={r.end < now ? "Delete" : "Delete — refused while the trigger is not expired"}
            onClick={(e) => { e.stopPropagation(); setDel(r); }}>
            <Icon name="trash" size={13} />
          </button>
        </div>
      )
    },
  ];

  return (
    <HrsShell
      title="Promo Triggers" /* backend.promo_triggers resolves nowhere in the committed lang file — label inferred; the sidebar string is hardcoded English */
      subtitle="Codes that pay something out when a promotion assignment fires them"/* backend.promo_triggers_index_subtitle — label inferred */
      gate={<>Real-platform access: <code>PromoTriggersController::index</code> aborts <code>404</code> unless <code>isAdmin()</code> (super admin) or <code>isSkinAdmin()</code> (skin admin). The CMS sidebar link sits inside <code>@if (isadmin())</code>, so a skin admin has to reach the page by URL — and when they do, the skin is forced to their own, with no Skin column and no Skin filter. No skin feature flag gates this screen. </>}
      gateNote={<>Permission asymmetry, honestly: only <code>index</code>, <code>delete</code> (<code>isadmin()</code>/<code>isSkinAdmin()</code>, own skin only) and <code>toggleHidden</code> (<code>isadmin()</code>) check a role. <code>saveTrigger</code>, <code>triggerForm</code>, <code>fetchPromotions</code>, <code>search</code>, <code>logslist</code>, <code>getLogsTable</code> and <code>testTrigger</code> check nothing beyond the auth + 2FA middleware — so any authenticated back-office user who knows the URLs can create, edit and <b>execute</b> triggers, including the real-money ones.</>}
      explainer={{
        title: "What this screen does, in plain English",
        bullets: [
          <>A <b>promo trigger</b> is a code plus one side effect. Nothing here runs on a schedule: <code>executeTrigger(code, skin_id, player_id)</code> is called after a successful promotion assignment and by PromoPlay (casino prize / trigger key), and it acts only while <b>now</b> is inside the trigger&rsquo;s start/end window.</>,
          <>Four side effects: assign a <b>Promotion</b>, grant <b>Freespins</b> through TimelessTech, deposit <b>Promoplay Points</b> through the skin&rsquo;s PromoPlay API, or transfer <b>Real Money</b> from the player&rsquo;s admin-skin ancestor into the player&rsquo;s balance. Real Money is cash, not bonus, and there is no cap or reversal.</>,
          <>Every run appends a <code>promo_trigger_logs</code> row — success or error, with the payload as JSON. Failures are recorded here and <b>not</b> raised to whoever assigned the promotion, so this list is the only place a broken trigger shows up. Open <b>Logs</b> on a row to read them.</>,
          <>Triggers are attached to promotions from the <b>Promotions</b> edit form, not here. Deleting one is refused until it has expired, and then takes its logs and its promotion links with it.</>,
        ]
      }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setForm({ trg: null })}>
          <Icon name="plus" size={14} /> New trigger{/* backend.new_trigger — label inferred */}
        </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)}`} />

      {/* Status chips (index.blade.php L93-100): counts that re-submit the same filters with
          show_expired flipped. The controller also computes an `all` count that no view renders —
          not rendered here either. */}
      <div className="hpt-chips">
        <button className={`hpt-chip hpt-chip--valid${applied.show_expired === "0" ? " hpt-on" : ""}`} onClick={() => setExpired("0")}>
          <span className="hpt-chip__dot" /> Valid <b>{hrsInt(counts.valid)}</b>
        </button>
        <button className={`hpt-chip hpt-chip--expired${applied.show_expired === "1" ? " hpt-on" : ""}`} onClick={() => setExpired("1")}>
          <span className="hpt-chip__dot" /> Expired <b>{hrsInt(counts.expired)}</b>
        </button>
        <span className="hpt-chips__note">Expired includes the valid rows too — the filter only decides whether past-window triggers are hidden.</span>
      </div>

      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={(r) => setForm({ trg: r })}
        empty={applied.search || applied.skin
          ? "No trigger matches these filters."
          : applied.show_expired === "0"
            ? "No trigger is inside its window right now. Switch “Show expired triggers” to Yes to see past ones."
            : "No triggers yet — create one with New trigger."}
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.name}</b>
              <HptValidChip trg={r} now={now} />
            </div>
            <div className="hrs-card__grid">
              <span>ID</span><b className="hpt-mono">{r.id}</b>
              <span>Code</span><b className="hpt-mono">{r.code || "—"}</b>
              <span>Type</span><b><HptTypeChip type={r.promotype} /></b>
              <span>Skin</span><b>{hptSkinName(r.skin_id)}</b>
              <span>Start</span><b>{hptFmtTs(r.start)}</b>
              <span>End</span><b>{hptFmtTs(r.end)}</b>
              <span>Hidden</span><b>{r.hidden === 1 ? "Yes" : "No"}</b>
            </div>
            <div className="hpt-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); setForm({ trg: r }); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm" onClick={(e) => { e.stopPropagation(); setLogsFor(r); }}><Icon name="list" size={12} /> Logs ({(logs[r.id] || []).length})</button>
              <button className="btn btn--ghost btn--sm hpt-card__del" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />

      {/* paginate(25) with Prev/Next + "Showing x–y of N, page a/b"; no page-size control exists. */}
      <HrsPager page={safePage} pageSize={PAGE_SIZE} total={filtered.length} onPage={setPage} />

      <HptAbsences />

      <HptTestPanel target={testTarget} onRun={() => setTestOpen(true)} />

      {form && <HptFormModal trg={form.trg} rows={rows} onClose={() => setForm(null)} onSave={onSave} />}
      {del && <HptDeleteDialog trg={del} logCount={(logs[del.id] || []).length} onClose={() => setDel(null)} onConfirm={onDelete} />}
      {logsFor && <HptLogsModal trg={logsFor} logs={logs[logsFor.id] || []} onClose={() => setLogsFor(null)} />}
      {testOpen && <HptTestDialog target={testTarget} onClose={() => setTestOpen(false)} onConfirm={runTestTrigger} />}
    </HrsShell>
  );
};

window.HostCmsPromoTriggers = HostCmsPromoTriggers;
