// NO PROD JSON API (bucket B — feed is a DataTables render payload or
//   {"html":...}, not data) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: GET /sportsettings · SportController::sportsettings + savesportsettings — see docs/ISYSTEM_REFERENCE.md §Batch 5 "Sport settings"
/* ======================================================================
   SPORT SETTINGS — automatic payout of sport winnings (Batch 5 long-tail)
   ======================================================================
   Real screen:
     read   GET  /sportsettings/      → SportController::sportsettings   (SportController.php:80-141)
     write  POST /savesportsettings/  → SportController::savesportsettings (:143-182)
   Both routes are UNNAMED — registered inside `Route::name('admin.')` but given no
   name (routes/admin.php:912-918). Middleware: outer ['admin','adminsettings']
   (routes/admin.php:15) + inner ['auth','admin','2fa','g2fa'] (:25).
   View: resources/views/admin/sport/sportsettings.blade.php (no partials; all JS inline).

   ROUTE: prototype route key `sport-settings` → path `/sport/settings` (as wired in
   src/routes.jsx alongside `sport-bet`; `settings-sport` → `/settings/sport` would be the
   alternative). Either way it must stay URL-ONLY, with NO sidebar entry — that is the real
   platform's behaviour. Worth knowing for whichever path wins: the live Settings ▾ group's
   highlight test includes request()->is('sportsettings*') (sidebar.blade.php:L507), so on
   the real platform visiting this URL lights up "Settings" while nothing in that menu
   points here.

   ---------------------------------------------------------------- WHAT IT IS
   The whole screen is auto-payout of SPORT WINNINGS and nothing else:
     1. AUTO_PAY_WINS            — 0 = NO / 1 = YES, the master switch. Defaults to 1
                                   when no row is stored (SportController.php:123-124, 133-134).
     2. AUTO_PAY_MAX_IMPORT[cur] — one cap per currency; 0 disables the cap.
     3. payout_criterias         — a JSON array of rows that describe tickets which must
                                   NOT be auto-paid (by sport / region / tournament / match /
                                   market, odd windows, and an exclude-cashout flag).
   Contrary to the task brief's guess there are NO coupon-template, tax, profit-formula or
   cancel-open-bets settings on this screen (reference §Batch 5 "Sport settings" → Notes).
   No KPIs, no export, no pagination, no sortable columns, no bulk actions exist here —
   none are added.

   ------------------------------------------------------- STATUS: NOT LIVE, AND 500s
   Two separate real-platform facts, both stated on-screen rather than papered over:
   - The sidebar entry is inside a raw-PHP comment block (opened `<?` + slash-star at L493,
     closed at L502), so nothing links here; the page is URL-only at
     /sportsettings. The commented gate is `isCustomCare() && checkUserBoPerm(...,
     'support_settings_sport')` — no isadmin() — so even if uncommented, super admins
     would not see the link although the controller does let them in. The dead block also
     carries a stray literal `{` after its @if (L494) and tests the wrong active path
     `sport/sportsettings` (L495) instead of the real URI.
   - As committed on `lat` the page CANNOT RENDER AT ALL: the blade calls
     route('admin.feed.sports.search') / .categories. / .tournaments. / .matches. (L812,
     L827, L848, L870) and all four routes are commented out (routes/admin.php:217-229 —
     "controllers do not exist, Admin\LogiqFeed\* never created"). route() on an unknown
     name throws RouteNotFoundException during Blade render, so GET /sportsettings 500s
     before a single field paints.
   This rebuild therefore shows the settings the controller ACTUALLY SAVES, and degrades
   the four select2 feed pickers to the form's own manual-entry mode — no feed-search
   backend is invented here.
   <!-- SUGGESTION: either restore the Admin\LogiqFeed\* controllers + their four search routes, or wrap the four route() calls in Route::has() the way admin/sport/scripts_feed_filters.blade.php already does on the coupons screen. Today a whole settings page is a guaranteed 500. -->

   ---------------------------------------------------------------- PERSISTENCE
   `settings` table via getSetting()/setSetting() (app/Helpers/utils.php:250-282; Setting
   model s_key/s_value, 2-min cache). Keys are global by default and get a `_SKIN_<id>`
   suffix in per-skin scope. The save also refreshes 10-minute cache keys
   (SportController.php:164-176). Nothing in this repository reads any of them back —
   the auto-payout enforcement lives on the sport-platform side.

   ------------------------------------- KNOWN BUGS → EVIDENT INTENT (CLAUDE.md policy)
   1. CACHE-KEY BUG. In the currency loop the settings key gets `_SKIN_<id>` but the
      10-minute cache key `currency_autopay_setting_<CUR>` does NOT (SportController.php:
      158-164), so saving one skin's cap clobbers the GLOBAL per-currency cache entry for
      10 minutes. This rebuild scopes the cache key like the settings key and shows the
      scoped key in the "what Save writes" panel. See hssCurCacheKey().
      <!-- SUGGESTION: append the same $skin_suffix to the currency_autopay_setting_<CUR> cache key that the settings key already gets (SportController.php:158-164) — today a per-skin save poisons the global cap for every skin for 10 minutes. -->
   2. UNVALIDATED current_skin_op. The GET checks that the requested skin belongs to the
      operator (SportController.php:100-105) but the POST never re-checks the hidden
      current_skin_op field (:157-176), so a gated customer-care user can save settings
      for ANY skin id by tampering with it. This rebuild re-checks the scope against the
      operator's own skins before writing. See hssOwnsScope() / the save handler.
      <!-- SUGGESTION: re-run the GET's skin-ownership check on current_skin_op inside savesportsettings before writing any setting — the hidden field is currently trusted verbatim. -->
   3. PHPUNIT FUNCTION IN A PRODUCTION VIEW. The blade guards the JSON echo with
      \PHPUnit\Framework\isJson($payout_criterias) (L452), which ignores its argument and
      returns an IsJson *constraint object* — always truthy. So the guard is really just
      !empty(), and a malformed stored blob breaks the inline JS. Worse, phpunit is a
      require-dev package (composer.json:41): a --no-dev install fatals on this line.
      This rebuild performs a real parse (hssParseCriteria) and renders an explicit
      "stored JSON is invalid" banner instead of silently breaking.
      <!-- SUGGESTION: replace \PHPUnit\Framework\isJson($payout_criterias) in sportsettings.blade.php:452 with json_decode($payout_criterias, true) !== null && json_last_error() === JSON_ERROR_NONE — the current guard always passes and a --no-dev composer install would fatal on the missing dev dependency. -->
   4. MISLABELLED CHECKBOX. In the Add form the "Exclude cashout" checkbox is labelled
      "Market ID" (blade L371) — a copy/paste of the field above it. Labelled correctly
      here (HssCriteriaModal).
      <!-- SUGGESTION: fix the copy/pasted <label> on the Add form's exclude-cashout checkbox (sportsettings.blade.php:371) — it currently reads "Market ID", the same label as the field above it. -->

   -------------------------------------------- REAL BEHAVIOUR KEPT, NOT "FIXED"
   - Criteria Edit/Delete are client-side only: nothing hits a route until Save posts the
     whole JSON blob. Delete has no confirmation on the real platform; kept immediate here
     because nothing is persisted until Save, and called out in the section note.
     <!-- SUGGESTION: add a confirm step to the criteria row delete (sportsettings.blade.php:654-658) — one mis-click silently drops a payout-blocking rule, and the row is gone the moment Save posts. -->
   - Switching the skin selector reloads /sportsettings?skinid=<id> on the real platform,
     which discards any unsaved edit. Modelled: changing scope drops the draft and says so.
   - Single-skin operators never see the selector at all — they get a "Website: <name>"
     heading (blade L122). Surfaced as a note on the scope field, not as a second widget.
   - No confirmation / no diff on Save: the real saveForm() posts everything at once.

   ------------------------------------------------------------ OTHER QUIRKS RECORDED
   - AUTO_PAY_WINS is a free-text input saved raw with no cast or whitelist (:157-176) —
     any string can land in the row that gates every automatic payout. Rendered as a 0/1
     switch here (the documented enum) and flagged below.
     <!-- SUGGESTION: validate AUTO_PAY_WINS server-side (in:0,1) and cast it — today savesportsettings stores whatever string arrives in the setting that decides whether winnings pay out automatically. -->
   - AUTO_PAY_MAX_IMPORT is only `(double)` cast, so a typo like "50.000" silently becomes
     50 and "abc" becomes 0 — which the screen's own copy defines as "no limit". Client-side
     numeric validation added here as the evident intent.
     <!-- SUGGESTION: validate AUTO_PAY_MAX_IMPORT entries as numeric|min:0 before the (double) cast (SportController.php:157) — a non-numeric value casts to 0, which this screen documents as "limit disabled". -->
   - payout_criterias is stored with no server-side JSON check at all (:157-176).
     <!-- SUGGESTION: json_decode + validate the payout_criterias payload server-side before setSetting() — the endpoint currently accepts any string into a settings row the sport platform parses. -->
   - A gate failure on POST returns an empty string with HTTP 200 (:146-149) — the operator
     sees a silent no-op, not an error.
     <!-- SUGGESTION: make savesportsettings answer 403 (or the ajaxError JSON the rest of the admin uses) instead of an empty 200 body when the permission check fails. -->
   - ORPHAN TABLE: migration 2026_06_24_155202_create_payout_criterias_table.php creates a
     relational payout_criterias table (sport_id, region_id, tournament_id, match_id,
     market_id, odd_from, odd_to, added_time, updated_time, last_mod_user) that has no
     model and no reader — the screen keeps a JSON blob in `settings` with a RICHER shape
     (separate event vs ticket odd windows + exclude_cashout).
     <!-- SUGGESTION: either migrate payout_criterias into its real table (it needs the extra event/ticket odd columns and exclude_cashout) or drop the unused migration — right now a settings-table JSON blob shadows a purpose-built table nobody reads. -->
   - The blade's @section('footer_scripts') opens with a stray literal `>` (L403) that
     renders into the page.

   ------------------------------------------------------------------ LABELS
   backend.sport_settings resolves NOWHERE in the committed lang files (public/default-lang
   and resources/lang; runtime storage/lang is gitignored) — the page's own <h3> hardcodes
   "Sport settings", which is what this rebuild uses. Every other string on the real screen
   is hardcoded English in the blade, so the column and row labels below are verbatim.
   ====================================================================== */

const { useState: hssUseState, useMemo: hssUseMemo } = React;

/* ---------- Auth::user()->getSkins() — same mock ids / names / currencies as the rest of
   the Host rebuild (HostReportBusiness HRBZ_SKINS, HostSetDepositMethods HSD_SKINS) so one
   operator persona spans the whole prototype. Persona = super admin: sees every skin plus
   the admin-only "General" option. ---------- */
/* `HSS_SKINS` was ten transcribed brands with their currencies. Skins are
   fetched now, and filtered to what the signed-in operator may edit.

   The key-name helpers below stay because the SCREEN still explains what
   isystem stores — the `_SKIN_<id>` string suffix that supabase/060 replaces
   with a foreign key. They name a shape this build deliberately does not use,
   and the panel that prints them says so. */
const HSS_GENERAL = "";

const hssSuffix = (scope) => (scope === HSS_GENERAL ? "" : `_SKIN_${scope}`);
const hssMaxKey = (cur, scope) => `AUTO_PAY_MAX_IMPORT_${cur}${hssSuffix(scope)}`;
const hssWinsKey = (scope) => `AUTO_PAY_WINS${hssSuffix(scope)}`;
const hssCritKey = (scope) => `payout_criterias${hssSuffix(scope)}`;

/* 10-minute cache keys written alongside the settings rows (SportController.php:164-176).
   KNOWN BUG — DIVERGENCE: the real per-currency cache key carries NO skin suffix, so a
   per-skin save overwrites the global entry. Evident intent implemented: scope it. */
const hssCurCacheKey = (cur, scope) => `currency_autopay_setting_${cur}${hssSuffix(scope)}`;
const hssWinsCacheKey = (scope) => `global_autopay_setting${hssSuffix(scope)}`;
const hssCritCacheKey = (scope) => `payout_criterias${hssSuffix(scope)}`;

/* `hssCurrenciesFor` and `hssOwnsScope` moved into the component: both need
   the fetched skin list, and `hssOwnsScope` in particular was the ownership
   re-check the real POST skips. It is now enforced in the DATABASE
   (save_sport_payout_settings), which is the only place it holds for every
   caller rather than only for this button. */

/* ---------- payout_criterias JSON ----------
   Only `inputType`, `market_id` and the "[id] name" rendering of the four feed ids are
   documented in the reference; the remaining key names below are inferred from the column
   set (see the .md report's UNCLEAR section). `_k` is a presentation-only React key — the
   real blob has no row identity at all and Delete splices by index. */
const HSS_INPUT_TYPES = [
  { value: "feed", label: "Feed" },
  { value: "manual", label: "Manual" },
];

const HSS_BLANK_ROW = {
  inputType: "manual", sport_id: "", sport_name: "", region_id: "", region_name: "",
  tournament_id: "", tournament_name: "", match_id: "", match_name: "", market_id: "",
  min_event_odd: "", max_event_odd: "", min_ticket_odd: "", max_ticket_odd: "",
  exclude_cashout: 0,
};

let hssKeySeq = 0;
const hssNormalizeRow = (r) => ({ ...HSS_BLANK_ROW, ...(r || {}), _k: `c${++hssKeySeq}` });

/* Real guard on the stored blob (blade L452) is \PHPUnit\Framework\isJson(), which always
   passes — see header bug #3. This is the parse the page should have been doing. */
const hssParseCriteria = (raw) => {
  const s = String(raw == null ? "" : raw).trim();
  if (s === "") return { ok: true, rows: [] };
  try {
    const parsed = JSON.parse(s);
    if (!Array.isArray(parsed)) return { ok: false, rows: [], error: "Stored value parsed as JSON but is not an array." };
    return { ok: true, rows: parsed.map(hssNormalizeRow) };
  } catch (e) {
    return { ok: false, rows: [], error: String(e && e.message ? e.message : e) };
  }
};

const hssSerializeCriteria = (rows) => JSON.stringify(rows.map((r) => {
  const out = { ...r };
  delete out._k;
  return out;
}));

/* Client rules ported verbatim from the blade: at least one of sport/region/tournament/
   match/market (L594-603, L772-781) and no exact-duplicate combination (L605-625, L752-770). */
const HSS_TARGET_FIELDS = ["sport_id", "region_id", "tournament_id", "match_id", "market_id"];
const hssSignature = (r) => HSS_TARGET_FIELDS.map((f) => String(r[f] == null ? "" : r[f]).trim()).join("|");
const hssHasTarget = (r) => HSS_TARGET_FIELDS.some((f) => String(r[f] == null ? "" : r[f]).trim() !== "");

/* The four odd fields go through parseFloat with an alert() on NaN (blade L594-625). */
const HSS_ODD_FIELDS = [
  { key: "min_event_odd", label: "Min event Odd" },
  { key: "max_event_odd", label: "Max event Odd" },
  { key: "min_ticket_odd", label: "Min ticket Odd" },
  { key: "max_ticket_odd", label: "Max ticket Odd" },
];

const hssIsBlank = (v) => String(v == null ? "" : v).trim() === "";
const hssIsNumeric = (v) => Number.isFinite(parseFloat(String(v).trim())) && /^-?\d*(\.\d+)?$/.test(String(v).trim());

/* ------------------------------------------------------------------ *
 * Cell renderers
 * ------------------------------------------------------------------ */
const HssIdCell = ({ id, name }) => {
  const v = String(id == null ? "" : id).trim();
  if (v === "") return <span className="hss-dash">—</span>;
  return (
    <span className="hss-idcell">
      <span className="hss-idcell__id">{name ? `[${v}]` : v}</span>
      {name && <span className="hss-idcell__nm">{name}</span>}
    </span>
  );
};

const HssNumCell = ({ value }) => {
  const v = String(value == null ? "" : value).trim();
  return v === "" ? <span className="hss-dash">—</span> : <span className="hss-num">{v}</span>;
};

/* inputType: feed → "Feed", manual → "Manual" (reference Enums). */
const HssTypeChipS = ({ type }) => (
  <span className={`hss-chip hss-chip--${type === "feed" ? "feed" : "manual"}`}>
    {type === "feed" ? "Feed" : "Manual"}
  </span>
);

/* exclude_cashout: 1 → "Yes", anything else → "No" (blade renderTable L458-483). */
const HssYesNo = ({ on }) => (
  <span className={`hss-chip hss-chip--${on ? "yes" : "no"}`}>{on ? "Yes" : "No"}</span>
);

/* ------------------------------------------------------------------ *
 * Criteria Add / Edit modal ≙ the blade's #edit-form (L209-293) and Add
 * form (L294-377). Both are client-side only — nothing is posted until
 * the page's Save button ships the whole JSON blob.
 * ------------------------------------------------------------------ */
const HssCriteriaModal = ({ row, rows, onClose, onSave }) => {
  const editing = !!row;
  const [f, setF] = hssUseState(() => ({ ...HSS_BLANK_ROW, ...(row || {}) }));
  const [errs, setErrs] = hssUseState({});
  const [formErr, setFormErr] = hssUseState("");

  const set = (k, v) => { setF((s) => ({ ...s, [k]: v })); setErrs((e) => ({ ...e, [k]: "" })); setFormErr(""); };

  const submit = () => {
    const nextErrs = {};
    /* parseFloat + NaN check on each odd field (blade L594-603 / L772-781). */
    HSS_ODD_FIELDS.forEach((o) => {
      const v = f[o.key];
      if (!hssIsBlank(v) && !hssIsNumeric(v)) nextErrs[o.key] = `${o.label} must be a number.`;
    });
    /* Market ID is a number input on the real form. */
    if (!hssIsBlank(f.market_id) && !/^\d+$/.test(String(f.market_id).trim())) nextErrs.market_id = "Market ID must be a whole number.";
    if (Object.keys(nextErrs).length) { setErrs(nextErrs); return; }

    if (!hssHasTarget(f)) {
      setFormErr("Fill at least one of Sport ID, Region ID, Tournament ID, Match ID or Market ID — a criteria row with no target would block every ticket.");
      return;
    }
    const sig = hssSignature(f);
    const clash = rows.some((r) => r._k !== f._k && hssSignature(r) === sig);
    if (clash) {
      setFormErr("A criteria row with this exact sport / region / tournament / match / market combination already exists.");
      return;
    }
    onSave({ ...f, exclude_cashout: f.exclude_cashout ? 1 : 0 });
  };

  const idField = (key, label) => (
    <div className="hss-field">
      <label className="hss-label" htmlFor={`hss-${key}`}>{label}</label>
      <input id={`hss-${key}`} className="hss-input" inputMode="numeric" value={f[key] || ""}
        placeholder="numeric id" onChange={(e) => set(key, e.target.value)} />
    </div>
  );

  const oddField = (o) => (
    <div className="hss-field" key={o.key}>
      <label className="hss-label" htmlFor={`hss-${o.key}`}>{o.label}</label>
      <input id={`hss-${o.key}`} className={`hss-input${errs[o.key] ? " hss-input--err" : ""}`} inputMode="decimal"
        placeholder="e.g. 1.05" value={f[o.key] || ""} onChange={(e) => set(o.key, e.target.value)} />
      {errs[o.key] && <div className="hss-err"><Icon name="alert" size={11} /> {errs[o.key]}</div>}
    </div>
  );

  return (
    <div className="hss-modal-scrim" onClick={onClose}>
      <div className="hss-modal" onClick={(e) => e.stopPropagation()}>
        <div className="hss-modal__head">
          <div>
            <div className="hss-modal__title">{editing ? "Edit payout criteria" : "Add payout criteria"}</div>
            <div className="hss-modal__sub">Client-side only — the row joins the <code>payout_criterias</code> JSON and is persisted when you press Save.</div>
          </div>
          <button className="hrs-x" onClick={onClose} title="Close"><Icon name="x" size={13} /></button>
        </div>

        <div className="hss-modal__body">
          <div className="hss-field">
            <label className="hss-label" htmlFor="hss-inputtype">
              Input Type
              <Tip size={12}>Stored verbatim as <code>inputType</code> on the row. On the real form it only decides which widget paints: <b>Feed</b> renders the four select2 pickers, <b>Manual</b> renders plain id boxes (<code>toggleInputFields()</code>, blade L917-937). The saved row is the same shape either way.</Tip>
            </label>
            <select id="hss-inputtype" className="hss-input" value={f.inputType}
              onChange={(e) => set("inputType", e.target.value)}>
              {HSS_INPUT_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
            </select>
          </div>

          {/* Honest degrade — no feed-search backend is invented. */}
          {f.inputType === "feed" && (
            <div className="hss-warn">
              <Icon name="alert" size={13} />
              <div>
                <b>The Feed pickers have no backend.</b> On the real form these four fields are select2 boxes that
                query <code>admin.feed.sports.search</code>, <code>…categories.search</code>, <code>…tournaments.search</code> and
                <code> …matches.search</code>. All four routes are commented out (routes/admin.php:217-229) and their
                controllers were never written, so the widgets cannot load — in fact the whole page 500s on those
                <code> route()</code> calls before it paints. Enter the ids by hand below, exactly as Manual mode does;
                rows already stored as Feed keep the name their picker cached at creation time.
              </div>
            </div>
          )}

          <div className="hss-grid2">
            {idField("sport_id", "Sport ID")}
            {idField("region_id", "Region ID")}
            {idField("tournament_id", "Tournament ID")}
            {idField("match_id", "Match ID")}
          </div>

          <div className="hss-field">
            <label className="hss-label" htmlFor="hss-market_id">Market ID</label>
            <input id="hss-market_id" className={`hss-input${errs.market_id ? " hss-input--err" : ""}`} inputMode="numeric"
              placeholder="numeric id" value={f.market_id || ""} onChange={(e) => set("market_id", e.target.value)} />
            {errs.market_id && <div className="hss-err"><Icon name="alert" size={11} /> {errs.market_id}</div>}
          </div>

          <div className="hss-grid2">{HSS_ODD_FIELDS.map(oddField)}</div>

          {/* KNOWN BUG — DIVERGENCE: the real Add form labels this checkbox "Market ID"
              (blade L371), a copy/paste of the field above. Labelled correctly here. */}
          <label className="hss-check">
            <input type="checkbox" checked={!!f.exclude_cashout}
              onChange={(e) => set("exclude_cashout", e.target.checked ? 1 : 0)} />
            <span>
              Exclude cashout
              <Tip size={12}>Stored as <code>exclude_cashout</code> 1/0 and rendered "Yes"/"No" in the table. The real Add form paints this checkbox with the label "Market ID" — a copy/paste of the field above it; corrected here per the repo's known-bug policy.</Tip>
            </span>
          </label>

          <div className="hss-note">
            <Icon name="info" size={12} />
            <span>
              At least one of Sport / Region / Tournament / Match / Market is required, and an exact duplicate of an
              existing combination is rejected — both rules are the real form's, and both run in the browser only.
              Nothing on the server validates this payload.
            </span>
          </div>

          {formErr && <div className="hss-err hss-err--form"><Icon name="alert" size={12} /> {formErr}</div>}
        </div>

        <div className="hss-modal__foot">
          <button className="hrs-btn hrs-btn--reset" onClick={onClose}>Cancel</button>
          <button className="hrs-btn hrs-btn--search" onClick={submit}>
            <Icon name="check" size={14} /> {editing ? "Update row" : "Add row"}
          </button>
        </div>
      </div>
    </div>
  );
};

/* ------------------------------------------------------------------ *
 * "What Save writes" — the real POST payload, the settings rows it
 * touches and the cache keys it refreshes, for the selected scope.
 * ------------------------------------------------------------------ */
const HssPayloadPanel = ({ scope, currencies, criteriaJson, jsonOk, jsonError }) => (
  <div className="hss-payload">
    <div className="hss-payload__grid">
      <div className="hss-payload__col">
        <div className="hss-payload__h"><Icon name="upload" size={11} /> POST /savesportsettings</div>
        <ul className="hss-payload__list">
          <li><code>AUTO_PAY_MAX_IMPORT[&lt;currency&gt;]</code> <span>array · each value <code>(double)</code> cast, nothing else</span></li>
          <li><code>AUTO_PAY_WINS</code> <span>saved raw — no cast, no whitelist</span></li>
          <li><code>payout_criterias</code> <span>hidden field · JSON string, no server-side check</span></li>
          <li><code>current_skin_op</code> <span>hidden field = <code>{scope === HSS_GENERAL ? "(empty → global keys)" : scope}</code></span></li>
        </ul>
      </div>

      <div className="hss-payload__col">
        {/* WHAT ISYSTEM WRITES, not what this build writes. Kept because the key
            names are the shape an operator sees upstream, and because the
            `_SKIN_<id>` suffix is exactly what supabase/060 replaced with a
            foreign key — naming both is how the difference stays visible. */}
        <div className="hss-payload__h"><Icon name="list" size={11} /> settings rows isystem writes</div>
        <ul className="hss-payload__list hss-payload__list--keys">
          {currencies.map((c) => <li key={c}><code>{hssMaxKey(c, scope)}</code></li>)}
          <li><code>{hssWinsKey(scope)}</code></li>
          <li><code>{hssCritKey(scope)}</code></li>
        </ul>
      </div>

      <div className="hss-payload__col">
        <div className="hss-payload__h"><Icon name="zap" size={11} /> cache keys refreshed (10 min)</div>
        <ul className="hss-payload__list hss-payload__list--keys">
          {currencies.map((c) => <li key={c}><code>{hssCurCacheKey(c, scope)}</code></li>)}
          <li><code>{hssWinsCacheKey(scope)}</code></li>
          <li><code>{hssCritCacheKey(scope)}</code></li>
        </ul>
        {scope !== HSS_GENERAL && (
          <div className="hss-payload__flag">
            <Icon name="alert" size={11} />
            <span>
              The real save writes the per-currency cache key <b>without</b> the <code>_SKIN_{scope}</code> suffix
              (SportController.php:158-164), so saving this skin's cap overwrites the <b>global</b> cached cap for
              10 minutes. Scoped correctly above.
            </span>
          </div>
        )}
      </div>
    </div>

    <details className="hss-json">
      <summary>
        Hidden field payload — <code>payout_criterias</code>
        <span className={`hss-valid hss-valid--${jsonOk ? "ok" : "bad"}`}>
          <Icon name={jsonOk ? "check" : "alert"} size={10} /> {jsonOk ? "valid JSON" : "invalid JSON"}
        </span>
      </summary>
      <pre className="hss-json__pre">{criteriaJson || "(empty — no criteria row stored for this scope)"}</pre>
      {!jsonOk && <div className="hss-err"><Icon name="alert" size={11} /> {jsonError}</div>}
      <div className="hss-json__note">
        Shown read-only because it is exactly the value the form posts. The real page keeps it in a hidden input built
        by <code>window.populateextradata()</code> right before submit, and echoes the stored blob into inline JS behind
        a guard that never actually validates it.
      </div>
    </details>

    <div className="hss-payload__foot">
      <Icon name="info" size={11} />
      <span>
        Persisted through <code>getSetting()</code>/<code>setSetting()</code> into the <code>settings</code> table
        (<code>s_key</code>/<code>s_value</code>, 2-minute cache). A separate <code>payout_criterias</code> <b>table</b> exists
        from migration <code>2026_06_24_155202</code> — it has no model and no reader, and its columns cannot even hold this
        shape (one <code>odd_from</code>/<code>odd_to</code> pair, no exclude-cashout flag). Nothing in this codebase reads any of
        these keys back: the auto-payout enforcement lives on the sport-platform side.
      </span>
    </div>
  </div>
);

/* ================================================================== *
 * Page component
 * ================================================================== */
const HostSportSettings = () => {
  window.useLocale && window.useLocale();

  /* THE WHOLE STORE WAS SEEDED. Three scopes of hand-written settings, five
     invented exclusion rules naming real tournaments and a real Boca–River
     fixture, and per-currency caps in four currencies. On a settings screen
     that is the most convincing shape invented data can take: it looks like
     somebody configured it.

     supabase/060 gives these three things real tables. They could not live in
     `skin_settings` — that table is presence-based and never reads `value`, so
     a stored `auto_pay_wins = 0` would still read as ON to every caller that
     asks "is this setting enabled?", and every winning ticket would pay itself
     out. */
  const [scope, setScope] = hssUseState(HSS_GENERAL);

  /* Which scopes this operator may edit. A skin admin edits their own brand and
     NEVER the general default — that one is the platform's, and a brand
     changing it changes every other brand. The RPC refuses it either way; this
     just stops the option being offered. */
  const [hssMe, setHssMe] = hssUseState(null);
  React.useEffect(() => {
    let alive = true;
    Promise.resolve(window.sb.me()).then(r => { if (alive && r && r.ok) setHssMe(r.data); });
    return () => { alive = false; };
  }, []);
  const hssIsAdmin = !!hssMe && Number(hssMe.user_level) === 0;

  const hssSkinFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const hssSkins = hssUseMemo(() => (hssSkinFeed.data || [])
    .map(sk => ({ id: sk.id, name: sk.name, cur: sk.currency }))
    .filter(sk => hssIsAdmin || (hssMe && sk.id === hssMe.skin_id)),
    [hssSkinFeed.data, hssIsAdmin, hssMe]);

  /* One fetch per scope rather than one for everything: the screen edits a
     single scope at a time and the real page reloads when you change it. */
  const hssScopeFilter = scope === HSS_GENERAL ? { general: true } : { skin: scope };
  const hssSetFeed  = useHrsFetch(() => window.sb.list("sportPayoutSettings",
    { limit: 5, filters: hssScopeFilter }), [scope]);
  const hssCapFeed  = useHrsFetch(() => window.sb.list("sportPayoutCaps",
    { limit: 100, filters: hssScopeFilter }), [scope]);
  const hssExclFeed = useHrsFetch(() => window.sb.list("sportPayoutExclusions",
    { limit: 300, filters: hssScopeFilter }), [scope]);

  const hssBusy = hssSetFeed.loading || hssCapFeed.loading || hssExclFeed.loading || hssSkinFeed.loading;
  const hssFeedErr = hssSetFeed.error || hssCapFeed.error || hssExclFeed.error || hssSkinFeed.error;

  /* WHICH CURRENCIES THIS SCOPE HAS. A skin has one; General has every distinct
     currency in use across the brands, which is what
     Skin::select('currency')->groupBy('currency') returns. */
  const currencies = hssUseMemo(() => {
    if (scope === HSS_GENERAL) {
      return Array.from(new Set(hssSkins.map(sk => sk.cur).filter(Boolean))).sort();
    }
    const sk = hssSkins.filter(x => String(x.id) === String(scope))[0];
    return sk && sk.cur ? [sk.cur] : [];
  }, [scope, hssSkins]);

  const hssScopeName = (sc) => {
    if (sc === HSS_GENERAL) return "General";
    const sk = hssSkins.filter(x => String(x.id) === String(sc))[0];
    return sk ? `${sk.name} (#${sk.id})` : `#${sc}`;
  };

  /* The stored scope, in the shape the editor already speaks. NULL and absent
     are kept apart: no row at all means the controller's default (auto-pay ON,
     no caps), and a stored cap of NULL means "no cap" — which is not the same
     as a cap of 0, and 060 exists partly to keep those two different. */
  const hssStored = hssUseMemo(() => {
    const row = (hssSetFeed.data || [])[0] || null;
    const caps = {};
    (hssCapFeed.data || []).forEach(c => {
      caps[c.currency] = c.max_amount == null ? "" : String(c.max_amount);
    });
    const rows = (hssExclFeed.data || []).map(e => hssNormalizeRow({
      id: e.id,
      inputType: e.sport_name || e.region_name || e.tournament_name || e.match_name ? "feed" : "manual",
      sport_id: e.sport_id || "", sport_name: e.sport_name || "",
      region_id: e.region_id || "", region_name: e.region_name || "",
      tournament_id: e.tournament_id || "", tournament_name: e.tournament_name || "",
      match_id: e.match_id || "", match_name: e.match_name || "",
      market_id: e.market_id || "",
      min_event_odd: e.min_event_odd == null ? "" : String(e.min_event_odd),
      max_event_odd: e.max_event_odd == null ? "" : String(e.max_event_odd),
      min_ticket_odd: e.min_ticket_odd == null ? "" : String(e.min_ticket_odd),
      max_ticket_odd: e.max_ticket_odd == null ? "" : String(e.max_ticket_odd),
      exclude_cashout: e.exclude_cashout ? 1 : 0,
    }));
    return { row, caps, rows };
  }, [hssSetFeed.data, hssCapFeed.data, hssExclFeed.data]);

  const buildDraft = () => ({
    /* No row means auto-pay ON, matching SportController's treatment of a
       missing key — and stated as a fallback rather than shown as a stored
       choice, because "nobody has configured this" and "somebody turned it on"
       are different facts. */
    wins: hssStored.row ? (hssStored.row.auto_pay_wins ? "1" : "0") : "1",
    stored: !!hssStored.row,
    max: Object.fromEntries(currencies.map(c => [c, hssStored.caps[c] != null ? hssStored.caps[c] : ""])),
    rows: hssStored.rows,
    jsonOk: true,
    jsonError: "",
  });

  const [draft, setDraft] = hssUseState(buildDraft);
  const [modal, setModal] = hssUseState(null); // null | { row: rowOrNull }
  const [maxErrs, setMaxErrs] = hssUseState({});

  /* Reseed the editor whenever the stored scope changes underneath it — on
     first load, after a scope change, and after a save refetches. */
  const hssSeedKey = JSON.stringify([scope, hssStored.row && hssStored.row.id,
                                     hssStored.caps, hssStored.rows.length, currencies]);
  React.useEffect(() => { setDraft(buildDraft()); setMaxErrs({}); }, [hssSeedKey]);

  const baseline = hssUseMemo(buildDraft, [hssSeedKey]);
  const dirty = hssUseMemo(() => (
    draft.wins !== baseline.wins ||
    JSON.stringify(draft.max) !== JSON.stringify(baseline.max) ||
    hssSerializeCriteria(draft.rows) !== hssSerializeCriteria(baseline.rows)
  ), [draft, baseline]);

  /* The real selector does `location = '/sportsettings?skinid=' + id` — a full
     reload, so any unsaved edit is discarded. Modelled rather than silently
     kept. */
  const changeScope = (v) => {
    const next = String(v == null ? "" : v);
    if (next === scope) return;
    const lost = dirty;
    setScope(next);
    if (lost) hrsToast("Scope changed — unsaved edits discarded", "The real skin selector reloads /sportsettings?skinid=<id>, so anything not saved is lost. Same here, deliberately.");
  };

  const setMax = (cur, v) => {
    setDraft((d) => ({ ...d, max: { ...d.max, [cur]: v } }));
    setMaxErrs((e) => ({ ...e, [cur]: "" }));
  };

  const saveCriteria = (row) => {
    setDraft((d) => {
      const rows = row._k
        ? d.rows.map((r) => (r._k === row._k ? { ...row } : r))
        : [...d.rows, hssNormalizeRow(row)];
      return { ...d, rows, jsonOk: true, jsonError: "" };
    });
    setModal(null);
  };

  /* Real Delete is payout_criterias_arr.splice() with NO confirmation (blade L654-658).
     Kept immediate — nothing is persisted until Save — and called out in the section note. */
  const deleteCriteria = (row) => {
    setDraft((d) => ({ ...d, rows: d.rows.filter((r) => r._k !== row._k) }));
    hrsToast("Criteria row removed", "Client-side only, exactly like the real page: the row is gone from the draft JSON and is persisted only when you press Save.");
  };

  const hssSave = useHrsSave([hssSetFeed, hssCapFeed, hssExclFeed]);

  const doSave = () => {
    /* Evident intent for the (double) cast: reject values that would silently
       become 0 — which upstream defines as "no limit". Here blank is no cap and
       0 is a cap of zero, so the two are separable; the check stays because a
       typo should still be a refusal rather than a silent zero. */
    const errs = {};
    currencies.forEach((c) => {
      const v = draft.max[c];
      if (!hssIsBlank(v) && !hssIsNumeric(v)) errs[c] = "Must be a number. Leave it blank for no cap — 0 means a cap of zero, i.e. auto-pay nothing.";
      else if (!hssIsBlank(v) && parseFloat(v) < 0) errs[c] = "Cannot be negative.";
    });
    if (Object.keys(errs).length) { setMaxErrs(errs); return; }

    /* THE OWNERSHIP CHECK MOVED INTO THE DATABASE. savesportsettings trusts
       current_skin_op verbatim; this build refuses in
       save_sport_payout_settings, which holds for every caller rather than for
       whoever went through this button. Nothing is checked here beyond what the
       operator can see. */
    hssSave.run(() => window.sb.saveSportPayoutSettings({
      skinId: scope === HSS_GENERAL ? null : Number(scope),
      autoPayWins: draft.wins === "1",
      caps: currencies.map(c => ({
        currency: c,
        /* BLANK IS NULL, NOT ZERO. Sending 0 for an empty box would store a cap
           of zero — auto-pay nothing — for a brand that simply has no cap. */
        max_amount: hssIsBlank(draft.max[c]) ? null : parseFloat(draft.max[c]),
      })),
      exclusions: draft.rows.map(r => ({
        sport_id: r.sport_id, sport_name: r.sport_name,
        region_id: r.region_id, region_name: r.region_name,
        tournament_id: r.tournament_id, tournament_name: r.tournament_name,
        match_id: r.match_id, match_name: r.match_name,
        market_id: r.market_id,
        min_event_odd: r.min_event_odd, max_event_odd: r.max_event_odd,
        min_ticket_odd: r.min_ticket_odd, max_ticket_odd: r.max_ticket_odd,
        exclude_cashout: Number(r.exclude_cashout) === 1,
      })),
    }), {
      done: `Sport settings saved · ${hssScopeName(scope)}`,
      fail: `Sport settings were not saved · ${hssScopeName(scope)}`,
    });
  };

  /* Scope selector ≙ the portlet-header <select name="skinsel"> (blade L110-123). Not a
     search filter: changing it reloads the page on the real platform. */
  const SCOPE_FIELD = [{
    key: "scope", label: "Website", type: "select", icon: "flag", width: 300,
    /* GENERAL IS ADMIN-ONLY, and it is not merely hidden: the RPC refuses a
       skin admin who names it, because a brand changing the platform default
       changes every other brand. */
    options: (hssIsAdmin ? [{ value: HSS_GENERAL, label: "General (platform default)" }] : []).concat(
      hssSkins.map((sk) => ({ value: String(sk.id), label: `${sk.name} — #${sk.id} (${sk.cur})` }))
    ),
    tip: <>Chooses which settings rows you are editing: <b>General</b> writes the un-suffixed global keys and is <b>admin-only</b> on the real screen; a skin writes the same keys with a <code>_SKIN_&lt;id&gt;</code> suffix. Changing it reloads <code>/sportsettings?skinid=&lt;id&gt;</code>, so unsaved edits are lost. Operators with a single skin never see this control at all — the real page prints a fixed <b>Website: &lt;skin name&gt;</b> heading instead, and a non-admin asking for a skin outside their own gets a JSON 404 <code>Skin not found</code>.</>,
  }];

  const columns = [
    { key: "inputType", label: "Input Type", width: 104, render: (r) => <HssTypeChipS type={r.inputType} /> },
    { key: "sport_id", label: "Sport ID", render: (r) => <HssIdCell id={r.sport_id} name={r.sport_name} /> },
    { key: "region_id", label: "Region ID", render: (r) => <HssIdCell id={r.region_id} name={r.region_name} /> },
    { key: "tournament_id", label: "Tournament ID", render: (r) => <HssIdCell id={r.tournament_id} name={r.tournament_name} /> },
    { key: "match_id", label: "Match ID", render: (r) => <HssIdCell id={r.match_id} name={r.match_name} /> },
    { key: "market_id", label: "Market ID", align: "right", render: (r) => <HssNumCell value={r.market_id} /> },
    { key: "min_event_odd", label: "Min event Odd", align: "right", render: (r) => <HssNumCell value={r.min_event_odd} /> },
    { key: "max_event_odd", label: "Max event Odd", align: "right", render: (r) => <HssNumCell value={r.max_event_odd} /> },
    { key: "min_ticket_odd", label: "Min ticket Odd", align: "right", render: (r) => <HssNumCell value={r.min_ticket_odd} /> },
    { key: "max_ticket_odd", label: "Max ticket Odd", align: "right", render: (r) => <HssNumCell value={r.max_ticket_odd} /> },
    { key: "exclude_cashout", label: "Exclude Cashout", align: "center", width: 128, render: (r) => <HssYesNo on={Number(r.exclude_cashout) === 1} /> },
    {
      key: "_acts", label: "Actions", align: "center", width: 108, render: (r) => (
        <div className="hss-acts">
          <button className="hss-act" title="Edit" onClick={() => setModal({ row: r })}><Icon name="edit" size={13} /></button>
          <button className="hss-act hss-act--danger" title="Delete" onClick={() => deleteCriteria(r)}><Icon name="trash" size={13} /></button>
        </div>
      ),
    },
  ];

  const criteriaJson = draft.rows.length ? hssSerializeCriteria(draft.rows) : "";

  return (
    <HrsShell
      title="Sport settings" /* the page's own hardcoded <h3>; backend.sport_settings resolves nowhere */
      subtitle="Automatic payout of sport winnings — master switch, per-currency cap, and the criteria that hold a ticket back"
      gate={<>Real-platform access: <code>isadmin()</code> <b>or</b> Customer Care holding <code>support_settings_sport</code> (SportController.php:83-86 on read, :146-149 on save), behind <code>auth</code> + <code>admin</code> + <code>2fa</code> + <code>g2fa</code> and the <code>adminsettings</code> middleware. </>}
      gateNote={<>Two asymmetries worth knowing: the (dead) sidebar entry's own gate is Customer-Care-only with <b>no <code>isadmin()</code></b>, so re-enabling the menu would still hide it from super admins the controller happily admits; and a failed gate on <b>save</b> returns an empty body with <b>HTTP 200</b>, so an unauthorised save looks like a successful one.</>}
      explainer={{
        title: "What this screen is, in plain English",
        bullets: [
          <>It governs one thing: whether a winning sport ticket pays out <b>automatically</b>. Three settings do that — a master switch (<code>AUTO_PAY_WINS</code>), a per-currency ceiling (<code>AUTO_PAY_MAX_IMPORT</code>), and a list of <b>payout criteria</b> describing tickets that must be held back for a human. There are no coupon-template, tax, profit-formula or cancel-bet settings here.</>,
          <><b>Nothing links to this page.</b> Its sidebar entry sits inside a commented-out block, so it is reachable only by typing <code>/sportsettings</code>. Visiting it highlights the <b>Settings</b> group in the sidebar even though no item in that group points here.</>,
          <><b>And as committed, it cannot open at all.</b> The view calls four <code>route('admin.feed.*.search')</code> names whose routes are commented out and whose controllers were never written, so <code>route()</code> throws and <code>GET /sportsettings</code> answers <b>500</b> before rendering. This rebuild shows the settings the controller actually saves; the four feed pickers degrade to the manual id entry the same form already supports.</>,
          <><b>Scope matters.</b> <i>General</i> edits the global keys; picking a skin edits the same keys with a <code>_SKIN_&lt;id&gt;</code> suffix. A skin with no stored row simply inherits the controller's default of <code>AUTO_PAY_WINS = 1</code> and no cap.</>,
          <><b>Nothing in the platform reads these values back.</b> No code in this repository consumes <code>AUTO_PAY_WINS</code>, <code>AUTO_PAY_MAX_IMPORT_*</code> or <code>payout_criterias</code> — the sport platform is assumed to read the <code>settings</code> rows or the shared cache. The enforcement is not in this codebase, so this screen is a control panel for a consumer you cannot see from here.</>,
        ],
      }}
    >

      <HrsFilters
        fields={SCOPE_FIELD}
        values={{ scope }}
        onChange={(k, v) => changeScope(v)}
        resultLabel={<span className="hss-scoperes">{scope === HSS_GENERAL ? "platform default" : hssScopeName(scope)}</span>}
      />

      {hssBusy && <HrsSkeleton rows={6} cols={4} />}
      {!hssBusy && hssFeedErr && (
        <HrsError error={hssFeedErr}
          onRetry={() => { hssSetFeed.retry(); hssCapFeed.retry(); hssExclFeed.retry(); hssSkinFeed.retry(); }} />
      )}


      <HrsSection
        title="Automatic payout of winnings"
        sub={<>Editing <b>{hssScopeName(scope)}</b>{draft.stored ? "" : " — no settings row stored yet for this scope, so the controller's defaults are shown"}.</>}
      >
        <div className="panel hss-settings">
          <div className="hss-settings__head">
            <span>Setting description</span><span>Parameter</span><span>Value</span>
          </div>

          {/* Row 2 of the real table (blade L183) — moved first: the switch decides whether
              the cap below it means anything. Field order elsewhere is untouched. */}
          <div className="hss-setting">
            <div className="hss-setting__desc">
              Automatic payout of winnings: 0 = NO. 1 = YES.
              <div className="hss-setting__extra">Unset means <b>on</b> — the controller substitutes 1 when no row exists.</div>
            </div>
            <div className="hss-setting__param">
              <code className="hss-keychip" title="The key isystem writes. Here it is sport_payout_settings.auto_pay_wins.">{hssWinsKey(scope)}</code>
            </div>
            <div className="hss-setting__val">
              {/* The real control is a free-text box whose value is saved raw with no cast or
                  whitelist. Rendered as the documented 0/1 enum; see header SUGGESTION. */}
              <Toggle value={String(draft.wins) === "1"} onChange={(v) => setDraft((d) => ({ ...d, wins: v ? "1" : "0" }))}
                onLabel="Yes (1)" offLabel="No (0)" />
              <div className="hss-hint">
                Stored value: <code>{String(draft.wins)}</code>.
                <Tip size={12}>The real field is a plain text input and <code>savesportsettings</code> stores whatever arrives — no cast, no <code>in:0,1</code> rule. A stray string would land in the setting that gates every automatic payout, so this rebuild only ever emits <code>0</code> or <code>1</code>.</Tip>
              </div>
            </div>
          </div>

          {/* Row 1 of the real table (blade L168-172): one text input per currency. */}
          <div className="hss-setting">
            <div className="hss-setting__desc">
              Maximum amount that the automatic payment of winnings authorizes. 0 to disable this limit
              <div className="hss-setting__extra">
                {scope === HSS_GENERAL
                  ? <>One box per <b>distinct currency across every skin</b> — General mode lists them all.</>
                  : <>One box for this skin's own currency.</>}
              </div>
            </div>
            <div className="hss-setting__param"><code className="hss-keychip">AUTO_PAY_MAX_IMPORT</code></div>
            <div className="hss-setting__val">
              <div className="hss-curgrid">
                {currencies.map((c) => (
                  <div className="hss-cur" key={c}>
                    <label className="hss-cur__k" htmlFor={`hss-max-${c}`}>{c}</label>
                    <input id={`hss-max-${c}`} className={`hss-input hss-cur__in${maxErrs[c] ? " hss-input--err" : ""}`}
                      inputMode="decimal" placeholder="0" value={draft.max[c] == null ? "" : draft.max[c]}
                      onChange={(e) => setMax(c, e.target.value)} />
                    <div className="hss-cur__key"><code>{hssMaxKey(c, scope)}</code></div>
                    {maxErrs[c] && <div className="hss-err"><Icon name="alert" size={11} /> {maxErrs[c]}</div>}
                  </div>
                ))}
                {currencies.length === 0 && <div className="hss-dash">No currency resolved for this scope.</div>}
              </div>
              <div className="hss-hint">
                Each value is <code>(double)</code> cast on save and nothing else.
                <Tip size={12}>A non-numeric entry casts to <code>0</code> — which this screen's own description defines as "limit disabled". This rebuild refuses to save such a value rather than silently removing the ceiling.</Tip>
              </div>
            </div>
          </div>
        </div>
      </HrsSection>

      <HrsSection
        title="Payout criteria"
        sub={<>Tickets matching a row here are held back from automatic payout. Add, edit and delete are <b>client-side only</b> — nothing reaches the server until Save posts the whole JSON blob, and Delete asks for no confirmation.</>}
        actions={
          <button className="hrs-btn hrs-btn--filters" onClick={() => setModal({ row: null })}>
            <Icon name="plus" size={14} /> Add criteria
          </button>
        }
      >
        {/* The real page echoes the stored blob behind a guard that never validates it, so a
            malformed row breaks the inline JS with no message. Real parse, real message. */}
        {!draft.jsonOk && (
          <div className="hss-warn hss-warn--block">
            <Icon name="alert" size={13} />
            <div>
              <b>The stored <code>{hssCritKey(scope)}</code> value is not valid JSON.</b> {draft.jsonError} On the real page this
              blob is echoed into inline JavaScript behind <code>\PHPUnit\Framework\isJson()</code>, which always returns
              truthy — so instead of this message the script would simply break.
            </div>
          </div>
        )}

        <HrsTable
          columns={columns}
          rows={draft.rows}
          rowKey="_k"
          empty="No payout criteria for this scope — every winning ticket is eligible for automatic payout, subject to the switch and cap above."
          renderCard={(r) => (
            <>
              <div className="hrs-card__top">
                <HssTypeChipS type={r.inputType} />
                <HssYesNo on={Number(r.exclude_cashout) === 1} />
              </div>
              <div className="hss-card__ids">
                {[["Sport", r.sport_id, r.sport_name], ["Region", r.region_id, r.region_name],
                  ["Tournament", r.tournament_id, r.tournament_name], ["Match", r.match_id, r.match_name],
                  ["Market", r.market_id, ""]]
                  .filter(([, id]) => String(id || "").trim() !== "")
                  .map(([lab, id, nm]) => (
                    <div className="hss-card__id" key={lab}>
                      <span>{lab}</span><b><HssIdCell id={id} name={nm} /></b>
                    </div>
                  ))}
              </div>
              <details className="hss-card__more">
                <summary>Odd windows</summary>
                <div className="hss-card__grid">
                  {HSS_ODD_FIELDS.map((o) => (
                    <div key={o.key}><span>{o.label}</span><b><HssNumCell value={r[o.key]} /></b></div>
                  ))}
                </div>
              </details>
              <div className="hss-card__acts">
                <button className="btn btn--secondary btn--sm" onClick={() => setModal({ row: r })}><Icon name="edit" size={12} /> Edit</button>
                <button className="btn btn--ghost btn--sm hss-card__del" onClick={() => deleteCriteria(r)}><Icon name="trash" size={12} /> Delete</button>
              </div>
            </>
          )}
        />
      </HrsSection>

      <HrsSection title="What Save writes" sub="The exact payload, settings rows and cache keys this screen touches for the selected scope.">
        <HssPayloadPanel scope={scope} currencies={currencies} criteriaJson={criteriaJson}
          jsonOk={draft.jsonOk} jsonError={draft.jsonError} />
      </HrsSection>

      <div className="hss-savebar">
        <div className={`hss-savebar__state${dirty ? " hss-savebar__state--dirty" : ""}`}>
          <Icon name={dirty ? "alert" : "check"} size={12} />
          {dirty ? "Unsaved changes" : "No changes"}
          <span className="hss-savebar__scope">· {hssScopeName(scope)}</span>
        </div>
        <button className="hrs-btn hrs-btn--search" onClick={doSave} disabled={!dirty}>
          <Icon name="check" size={14} /> Save settings
        </button>
      </div>

      {modal && (
        <HssCriteriaModal row={modal.row} rows={draft.rows} onClose={() => setModal(null)} onSave={saveCriteria} />
      )}
    </HrsShell>
  );
};

window.HostSportSettings = HostSportSettings;
