// NO PROD JSON API (bucket C) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: admin.blog / admin.blog.category · BlogController + BlogCategoryController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "Blogs + Blog categories"
/* CMS ▾ → Blogs and CMS ▾ → Blog categories. Two screens, one file, because they are one
   feature: `blogs.category_id` → `blog_categories.id`, both indexes cross-link ("Manage
   categories" / "Back to blogs"), and a category cannot be deleted while a blog points at it.

   REAL ROUTES / CONTROLLERS
   - Blogs      `admin.blog` GET /blog/ (routes/admin.php:L1015-1017) · BlogController::index L21,
                ::blogForm L54 (GET /blog/form/), ::saveBlog L66 (POST /blog/save), ::delete L88
                (GET /blog/delete/{id}/, plus a duplicate un-named legacy copy at L1628-1630).
   - Categories `admin.blog.category` GET /blog/category/ (L979-981) · BlogCategoryController::
                index L23, ::categoryForm L54, ::saveCategory L66, ::delete L87 (duplicate legacy
                delete route L1631-1633).
   - Logic      BlogAdminService (listForTable L30, summarizeStatus L49, prepareFormData L69,
                saveBlog L193, listCategoriesForTable L335, summarizeCategoryStatus L355,
                prepareCategoryFormData L376, saveCategory L411).
   - Requests   SaveBlogRequest, SaveBlogCategoryRequest.
   - Views      admin/blog/index.blade.php · form-page → form.blade.php → partials/
                translation-language-section.blade.php (one stacked section PER LANGUAGE) +
                components/admin/blog/image-upload-field.blade.php; admin/blog/category/
                index.blade.php · category/form.blade.php (inline select2 + tab JS, no TinyMCE).
                Both modals/*.blade.php files are referenced by nothing — dead code.

   THE 12-LANGUAGE PROBLEM (the one real IA decision on this screen)
   The real blog form renders the per-language block as a STACKED section per language — twelve
   copies of {title, body, image, thumbnail} down one page, 48 fields, with no way to see which
   locales are actually filled. The category form already uses tab JS for the same list. This
   rebuild puts BOTH behind one language rail (HbgLangTabs) with a per-language translated /
   partial / untranslated indicator and a coverage counter, plus an "All languages" overview:
   read-only coverage matrix for blogs (4 fields × 12), the twelve editable title inputs for
   categories (1 field × 12). No field is added, removed or renamed — this is navigation over
   exactly the fields SaveBlogRequest / SaveBlogCategoryRequest validate.

   The tab list is NOT hardcoded on the real platform: blogs read BlogAdminService::adminLanguages
   L633 and categories read LanguagesController::getLanguages() (Language::all, cached 24h,
   English sorted first), so the real count is whatever production's `languages` table holds.
   HBG_LANGS below carries the 12-language content set the reference documents for this platform's
   multilingual content (ISYSTEM_REFERENCE.md §Batch 6 "Multi-language CONTENT") in the order the
   legacy prototype bundle observed on the real screen. See UNCLEAR #1 in the phase report: the
   sibling Languages screen's mock `languages` table (src/pages/HostSetLanguages.jsx) holds a
   different 13 rows (pl/nl/br present, ar/ro absent) — the two lists cannot both be production.

   PER-SKIN SLUG UNIQUENESS (validated here exactly as documented, and it differs per screen)
   - blogs: UNIQUE (skin_id, slug). There is NO slug field on the form. Blog::generateSlug L85
     derives it from master_title (fallback title_{default locale}) and appends -1, -2 … until it
     is free WITHIN THAT SKIN, and never regenerates it on edit. Since create fans out one row per
     selected skin, one title can produce a different slug per skin — so the form shows the
     resolved slug per selected skin instead of pretending there is one.
   - blog_categories: UNIQUE (skin_id, slug) too, but slug IS an input. Empty → auto-generated
     from the first non-empty language title; a collision is a hard error
     (backend.slug_already_exists = "Slug already exists", surfaced behind
     backend.operation_error). Checked per selected skin on create, against the target skin on
     edit (the skin can be changed there).

   FAITHFUL ABSENCES — deliberately NOT built
   - No sortable columns anywhere: both lists are fixed `id DESC` (BlogAdminService L39 / L345).
   - No export (neither screen has one), no bulk actions, no KPI strip beyond the documented
     "Results" total and the status chip counts.
   - No page-size select: `$perPage = 25` is hardcoded, so the pager is Prev/Next only.
   - No slug input on the blog form, no "regenerate slug" action, no restore/duplicate/preview
     action — none exist upstream.
   - Blogs' Category column shows `blog_categories.slug` (leftJoin), while the Category FILTER and
     the form's picker label the same rows by `getTitle() ?? slug`. That inconsistency is real and
     is reproduced rather than smoothed over.

   KNOWN-BUG POLICY: the reference records no behavioural bug for these two screens (the batch-wide
   bug list covers Promotions, Game import and Slideshow), so nothing here diverges from the real
   platform. What it does record are permission and data-integrity asymmetries, surfaced honestly
   in the header Tip / Explainer instead of being papered over:
   - Customer Care with `support_cms_banners` SEES both entries in the sidebar (sidebar.blade.php
     L18) but 404s at the controller — a menu/controller mismatch.
   - Blog delete re-checks skin ownership for skin admins; category delete does NOT (only
     isadmin/isSkinAdmin, BlogCategoryController L87-91).
   - Changing a category's skin on edit does not move the blogs pointing at it, so a blog can end
     up in a category owned by another skin. HBG_BLOG_SEED contains one such row on purpose.

   LABEL POLICY: keys that resolve in the committed public/default-lang/en/backend.php are used
   verbatim ("Blogs", "New Blog", "Master Fields", "Master Title", "Master Body", "Master Image",
   "Fallback if translation empty", "Translations", "Promotion Settings", "Is Promotion",
   "Promotion Text 1/2", "Valid Until", "Published", "Draft", "Promotion", "Select Multiple",
   "No categories available", "Please create a category first", "If empty, will use master …",
   "Blog Categories", "New Category", "Data", "Slug", "Active", "Disabled",
   "Cannot delete category that has blogs", "Slug already exists",
   "Slug will be auto-generated if empty", "Select at least one skin"), as are the strings
   hardcoded in English in the blades ("Master Thumbnail Image", "Button Text", "Button Link",
   "Casinos (Select Multiple)", "Casino", "Select one or more casinos…", "Select the casino this
   category belongs to.", "Category Title"). Everything else on these pages resolves only in the
   gitignored storage/lang — page subtitles, "Back to list", "Back to blogs", "Back to categories",
   "Edit blog", search placeholders, "All categories", "Apply", "Results", "Showing", "Prev",
   "Next", "Created", "No records", "Delete", "Remove current image", "Slug or title is required" —
   and is written as a sensible operator-facing label, marked "label inferred" at the point of use.

   <!-- SUGGESTION: give the blog form the same slug affordance the category form has — today the operator cannot see, let alone choose, the slug that generateSlug() will mint, even though it is the public URL and silently gains a -1/-2 suffix per skin on fan-out. Read-only per-skin preview (as prototyped here) would already remove the surprise. -->
   <!-- SUGGESTION: add the missing own-skin check to BlogCategoryController::delete (L87-91). Blog delete has it; category delete lets any skin admin delete another skin's category, and the "has blogs" guard only protects the deleter's own skin when the skin_id column exists. -->
   <!-- SUGGESTION: when a category's skin_id changes on edit, either move (or refuse to move) the blogs that point at it. Today they are silently left behind in a category belonging to another skin, which is why the blog form has to disambiguate categories as "Title (SkinName)". -->
   <!-- SUGGESTION: `blogs.valid_until` is a plain string column fed by a datetime-local input with rule `nullable|string` — no format, no timezone, no comparison possible in SQL. Make it a real datetime (or at least validate the format) before anything tries to expire promotions by date. -->
   <!-- SUGGESTION: blog_categories/index.blade.php re-fetches every row inside the loop with BlogCategory::with('translations')->find($row->id) just to call getTitle() — a textbook N+1. Eager-load the translations once in listCategoriesForTable (as this prototype's data shape assumes). -->
   <!-- SUGGESTION: delete on both screens is a GET with no CSRF token, and each has a duplicate un-named legacy route (admin.php L1628-1630 / L1631-1633) pointing at the same controller method. Make them DELETE routes and drop the duplicates. --> */

const { useState: hbgUseState, useMemo: hbgUseMemo } = React;

/* NOT-A-GENERATOR: FNV-1a, and all that survives of the PRNG pair that used to
   build this screen's contents. Its one remaining job is picking a stable colour
   for an image placeholder from a stored path, so the same file always gets the
   same tile. The mulberry32 it used to seed — which decided how many of a post's
   twelve languages existed, and whether each had a body — is gone with the seed
   data it fed.

   tools/wiringstate.js flags any Math.imul with no *Rng name, since that is how
   a generator hides from its classifier. This marker is the declared way out and
   the tool only honours it on a file that also reads the database. */
const hbgHash = (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 hbgToast = (title, detail) => window.hrsToast
  ? window.hrsToast(title, detail)
  : (window.PAYBO && window.PAYBO.emitToast && window.PAYBO.emitToast({ id: `hbg-${Date.now()}`, tx_id: title, amount: 0, currency: "HOST", player: "Blogs", reason: detail || "Prototype state only \u2014 not persisted." }));

/* Cross-page navigation — push the target route's canonical path and let app.jsx's popstate
   handler resolve it (the hdNavTo convention from HostDashboard.jsx). Used by the two cross-links
   the real indexes carry: Blogs → "Manage categories", Categories → "Back to blogs". */
const hbgNavTo = (routeId) => {
  try {
    const path = window.pathForActive && window.pathForActive(routeId);
    if (path) {
      if (window.location.pathname !== path) window.history.pushState({ active: routeId }, "", path);
      window.dispatchEvent(new PopStateEvent("popstate"));
    }
  } catch (_e) { /* no-op */ }
};

/* blogs.addedTime / blog_categories.addedTime are unix ints (models override getDateFormat()='U').
   Index display convention is date('j M Y, H:i'), "—" when empty. */
const HBG_MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const hbgUnix = (y, m, d, h, mi) => Math.floor(new Date(y, m - 1, d, h, mi).getTime() / 1000);
const hbgPad2 = (n) => String(n).padStart(2, "0");
const hbgFmtTs = (ts) => {
  if (!ts) return "—";
  const d = new Date(ts * 1000);
  return `${d.getDate()} ${HBG_MON[d.getMonth()]} ${d.getFullYear()}, ${hbgPad2(d.getHours())}:${hbgPad2(d.getMinutes())}`;
};

/* Str::slug stand-in — strips accents, emoji and punctuation, collapses to dashes. */
const hbgSlugify = (s) => String(s || "")
  .toLowerCase()
  .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
  .replace(/[^a-z0-9]+/g, "-")
  .replace(/^-+|-+$/g, "");

/* Blog::generateSlug L85 — base slug, then -1, -2 … until free WITHIN THE SKIN. */
const hbgNextSlug = (base, takenInSkin) => {
  if (!base) return "";
  if (takenInSkin.indexOf(base) === -1) return base;
  let i = 1;
  while (takenInSkin.indexOf(`${base}-${i}`) !== -1) i++;
  return `${base}-${i}`;
};

/* ------------------------------------------------------------------ *
 * `languages` rows (see the 12-language note in the header comment).
 * English is force-sorted first by both services; the remaining order is
 * the one the legacy prototype bundle observed on the real screen.
 * ------------------------------------------------------------------ */
const HBG_LANGS = [
  { code: "en", name: "English" },
  { code: "it", name: "Italiano" },
  { code: "de", name: "Deutsche" },
  { code: "tr", name: "Türkçe" },
  { code: "ar", name: "Arabic" },
  { code: "ro", name: "Romana" },
  { code: "zh", name: "Chinese" },
  { code: "es", name: "Español" },
  { code: "fr", name: "Français" },
  { code: "pt", name: "Português" },
  { code: "pt_br", name: "Português-Brasil" },
  { code: "hu", name: "Magyar" },
];
/* config/app.php locale AND fallback_locale are both 'en', so "title_{fallback_locale}" in
   SaveBlogRequest::withValidator L85-94 is title_en, and BlogCategory::getTitle()'s chain is
   locale → en → slug. */
const HBG_FALLBACK = "en";
const HBG_LANG_LABEL = (code) => { const l = HBG_LANGS.find(x => x.code === code); return l ? `${l.name} (${code.toUpperCase().replace("_", "-")})` : code; };

/* Auth::user()->getSkins() / SkinsController::getSkinsList(). Read from `skins`
   — every brand this operator's RLS lets them see. It was six hardcoded ids and
   names copied from the Business report page, which is exactly the arrangement
   that makes an invented list look verified: two screens agreeing with each
   other about brands neither of them had read. */
const hbgSkinName = (skins, id) => {
  const s = (skins || []).find(x => String(x.id) === String(id));
  return s ? s.name : null;
};
const hbgInitials = (name) => String(name || "?").replace(/[^A-Za-z0-9 ]/g, "").split(" ").filter(Boolean).slice(0, 2).map(w => w[0].toUpperCase()).join("") || "?";

/* ------------------------------------------------------------------ *
 * THE DATA. This screen used to carry its own database too.
 *
 * `HBG_DB = { blogs: hbgSeedSlugs(hbgBuildBlogs()), cats: hbgBuildCats() }`
 * built thirty-four posts across six brands, minted their slugs through the
 * same -1/-2 de-dup the real generateSlug uses so two "Bonus"-titled posts on
 * one brand collided exactly as they would in production, and generated each
 * post's twelve-language translation coverage from a PRNG seeded on its id —
 * English at 95%, the rest thinning out, "the realistic shape of a 12-locale
 * post". Both screens mirrored it into local state and wrote back on mutation,
 * so an edit made on one was still there after navigating to the other.
 *
 * That care is why it was convincing. It is also why nothing about it was true.
 *
 * Replaced by reads of `blogs` and `blog_categories` with their translations
 * embedded. The conversion below is the only code that knows the storage
 * differs: isystem keeps a post's twelve titles and bodies as flat columns,
 * this schema keeps one blog_translations row per language, so saving a post is
 * N+1 writes rather than one.
 * ------------------------------------------------------------------ */

/* BlogCategory::getTitle() — requested locale → fallback locale (en) → slug. */
const hbgCatTitle = (cat, locale) => {
  if (!cat) return "—";
  const t = cat.t || {};
  return t[locale || HBG_FALLBACK] || t[HBG_FALLBACK] || cat.slug;
};

/* Fetch ceiling, and NOT silent: a brand with more posts than this pages in the
   database while this screen searches only what it holds, so the list says so. */
const HBG_FETCH_MAX = 500;

/* blog_translations rows -> the per-language map both forms already speak. */
const hbgTrFromRows = (rows) => {
  const t = {};
  (rows || []).forEach(r => {
    const c = String(r.locale_code || "").toLowerCase();
    if (!HBG_LANGS.some(l => l.code === c)) return;
    t[c] = {
      title: String(r.title || ""),
      body: String(r.body || ""),
      image: String(r.image_url || ""),
      thumb: String(r.thumbnail_url || ""),
    };
  });
  return t;
};
const hbgCatTrFromRows = (rows) => {
  const t = {};
  (rows || []).forEach(r => {
    const c = String(r.locale_code || "").toLowerCase();
    if (HBG_LANGS.some(l => l.code === c)) t[c] = String(r.title || "");
  });
  return t;
};

/* `published`/`active` are real booleans here and 0/1 upstream; timestamps are
   timestamptz here and unix ints upstream (the models override getDateFormat()
   to 'U'). Both are converted at this edge so every renderer and both forms
   keep working against the shape they were written for. */
const hbgUnixOf = (iso) => (iso ? Math.floor(new Date(iso).getTime() / 1000) : 0);
const hbgBlogFromDb = (r) => ({
  id: r.id,
  skin_id: r.skin_id,
  category_id: r.category_id,
  slug: r.slug || "",
  master_title: r.master_title || "",
  master_body: r.master_body || "",
  master_image: r.master_image_url || "",
  master_thumb: r.master_thumbnail_url || "",
  is_published: r.published ? 1 : 0,
  published_at: hbgUnixOf(r.published_at) || null,
  is_promotion: r.is_promotion ? 1 : 0,
  promotion_text_1: r.promotion_text_1 || "",
  promotion_text_2: r.promotion_text_2 || "",
  promotion_button_text: r.promotion_button_text || "",
  promotion_button_link: r.promotion_button_link || "",
  /* The form's datetime-local input wants YYYY-MM-DDTHH:mm, which is the first
     16 characters of the ISO string. Anything else and the field renders blank
     while still holding a value — an edit that silently clears a date. */
  valid_until: r.valid_until ? String(r.valid_until).slice(0, 16) : "",
  addedTime: hbgUnixOf(r.created_at),
  tr: hbgTrFromRows(r.translations),
  _tr: r.translations || [],
});
const hbgCatFromDb = (r) => ({
  id: r.id,
  skin_id: r.skin_id,
  slug: r.slug || "",
  is_active: r.active ? 1 : 0,
  t: hbgCatTrFromRows(r.translations),
  _tr: r.translations || [],
  addedTime: hbgUnixOf(r.created_at),
  /* blogs(count) — every post filed under this category, including ones this
     screen has not fetched. Counting fetched rows would under-report and make a
     category look deletable when posts still point at it. */
  _blogCount: Array.isArray(r.blogs) && r.blogs[0] ? Number(r.blogs[0].count) || 0 : 0,
});

/* Both lists plus the brand list. Shared by the two screens, so a category
   created on one is visible on the other after its refetch — which is what the
   module-level store was for, done by asking the database instead. */
const hbgUseDb = () => {
  const skinsFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const blogsFeed = useHrsFetch(() => window.sb.list("blogs", { limit: HBG_FETCH_MAX }), []);
  const catsFeed = useHrsFetch(() => window.sb.list("blogCategories", { limit: HBG_FETCH_MAX }), []);

  const blogs = hbgUseMemo(() => (blogsFeed.data || []).map(hbgBlogFromDb), [blogsFeed.data]);
  const cats = hbgUseMemo(() => (catsFeed.data || []).map(hbgCatFromDb), [catsFeed.data]);
  const skins = hbgUseMemo(
    () => (skinsFeed.data || []).map(k => ({ id: k.id, name: k.name, code: k.code })),
    [skinsFeed.data]);

  const truncated = [
    blogsFeed.meta && blogsFeed.meta.total > blogs.length ? "posts" : null,
    catsFeed.meta && catsFeed.meta.total > cats.length ? "categories" : null,
  ].filter(Boolean);

  return {
    blogs, cats, skins, truncated,
    loading: skinsFeed.loading || blogsFeed.loading || catsFeed.loading,
    error: skinsFeed.error || blogsFeed.error || catsFeed.error,
    retry: () => { skinsFeed.retry(); blogsFeed.retry(); catsFeed.retry(); },
    feeds: [blogsFeed, catsFeed],
  };
};

/* ------------------------------------------------------------------ *
 * The write half. Same contract as the FAQ screen's hfqSyncTr, and the
 * same reason it cannot simply be shared: the child tables differ
 * (title+body+two images here, title+content there) and a helper
 * parameterised over both would take more arguments than it saves.
 *
 * A post's parent row goes first — a translation needs a blog_id — and a
 * failure afterwards leaves the post EXISTING with some of its languages.
 * Reported as PARTIAL rather than as saved.
 * ------------------------------------------------------------------ */
const hbgTrHasContent = (v) => !!(v && (String(v.title || "").trim() || String(v.body || "").trim()
  || String(v.image || "").trim() || String(v.thumb || "").trim()));

const hbgSyncTr = async ({ resource, fkColumn, parentId, existing, next, isCat }) => {
  const have = new Map((existing || []).map(r => [String(r.locale_code || "").toLowerCase(), r]));
  const failed = [];
  let wrote = 0, removed = 0;

  for (const l of HBG_LANGS) {
    const code = l.code;
    const prev = have.get(code);
    const src = (next || {})[code];
    const keep = isCat ? !!String(src || "").trim() : hbgTrHasContent(src);
    /* 006 refuses a blog_translations row that is entirely empty, and
       blog_category_translations.title is NOT NULL — so "no content" is not a
       row with blank fields, it is no row. */
    const body = isCat
      ? { title: String(src || "").trim() }
      : { title: String((src || {}).title || "") || null,
          body: String((src || {}).body || "") || null,
          image_url: String((src || {}).image || "") || null,
          thumbnail_url: String((src || {}).thumb || "") || null };

    if (keep) {
      const res = prev && prev.id
        ? await window.sb.update(resource, prev.id, body)
        : await window.sb.create(resource, Object.assign({ [fkColumn]: parentId, locale_code: code }, body));
      if (res && res.ok) wrote++;
      else failed.push(`${l.name}: ${(res && res.error && res.error.message) || "refused"}`);
    } else if (prev && prev.id) {
      /* Emptied in the form. Hard delete — these tables carry no deleted_at,
         which is 006 saying a translation is not a record of anything that
         happened. Skipping this is how a language the operator removed stays
         published to players. */
      const res = await window.sb.remove(resource, prev.id);
      if (res && res.ok) removed++;
      else failed.push(`${l.name} (removal): ${(res && res.error && res.error.message) || "refused"}`);
    }
  }
  return { wrote, removed, failed };
};

const HBG_PER_PAGE = 25;   /* BlogAdminService::listForTable $perPage = 25 (L30) */
const HBG_MAX_IMG_KB = 5120; /* nullable|image|max:5120 */

/* ================================================================== *
 * Small shared pieces
 * ================================================================== */

/* Skin cell: initials avatar + name, "—" when the leftJoin found nothing. */
const HbgSkinCell = ({ skinId, skins }) => {
  const name = hbgSkinName(skins, skinId);
  if (!name) return <span className="hbg-dash">—</span>;
  return (
    <span className="hbg-skin">
      <span className="hbg-skin__av">{hbgInitials(name)}</span>
      <span className="hbg-skin__n">{name}</span>
    </span>
  );
};

/* Status chip strip. Counts come from summarizeStatus / summarizeCategoryStatus, which ignore the
   status filter itself so the numbers stay stable while you switch tabs; picking a chip sets the
   same is_published / is_active param the select does, preserving every other filter. */
const HbgChips = ({ items, value, onPick }) => (
  <div className="hbg-chips" role="tablist">
    {items.map(it => (
      <button key={String(it.value)} role="tab" aria-selected={value === it.value}
        className={`hbg-chip${value === it.value ? " is-on" : ""} hbg-chip--${it.tone || "all"}`}
        onClick={() => onPick(it.value)}>
        <span className="hbg-chip__lab">{it.label}</span>
        <span className="hbg-chip__n">{it.count}</span>
      </button>
    ))}
  </div>
);

const HbgStatusPill = ({ on, onLabel, offLabel }) => (
  <span className={`hbg-pill ${on ? "hbg-pill--ok" : "hbg-pill--muted"}`}>
    <span className="hbg-pill__dot" />{on ? onLabel : offLabel}
  </span>
);

/* Sectioned config panel — the Settings.jsx shape (title + optional Explainer callout + body). */
const HbgPanel = ({ title, sub, icon, aside, children }) => (
  <section className="hbg-panel">
    <header className="hbg-panel__head">
      <div className="hbg-panel__id">
        {icon && <span className="hbg-panel__icon"><Icon name={icon} size={13} /></span>}
        <div>
          <div className="hbg-panel__title">{title}</div>
          {sub && <div className="hbg-panel__sub">{sub}</div>}
        </div>
      </div>
      {aside && <div className="hbg-panel__aside">{aside}</div>}
    </header>
    <div className="hbg-panel__body">{children}</div>
  </section>
);

const HbgField = ({ label, req, hint, error, tip, children, wide }) => (
  <div className={`hbg-field${wide ? " hbg-field--wide" : ""}${error ? " hbg-field--err" : ""}`}>
    {label && (
      <label className="hbg-lab">
        {req && <span className="hbg-req">*</span>}{label}
        {tip && <Tip size={12}>{tip}</Tip>}
      </label>
    )}
    {children}
    {hint && !error && <div className="hbg-hint">{hint}</div>}
    {error && <div className="hbg-err">{error}</div>}
  </div>
);

/* Validation banner — the real save redirects back withInput and flashes
   backend.operation_error + the message list. */
const HbgErrors = ({ items }) => {
  if (!items || !items.length) return null;
  return (
    <div className="hbg-banner hbg-banner--err">
      <Icon name="alert" size={14} />
      <div>
        <b>Operation error</b>{/* backend.operation_error */}
        <ul>{items.map((m, i) => <li key={i}>{m}</li>)}</ul>
      </div>
    </div>
  );
};

/* TinyMCE 5.0.16 stand-in (loaded from CDN by public/js/pages/blog/form.js). The toolbar is
   decorative — the prototype stores the raw HTML string the editor would post. */
const HbgRichText = ({ value, onChange, minH = 150, placeholder }) => (
  <div className="hbg-rt">
    <div className="hbg-rt__bar" aria-hidden="true">
      {["↶", "↷", "B", "I", "U", "≡", "•", "1.", "🔗", "<>"].map((b, i) => <span key={i}>{b}</span>)}
    </div>
    <textarea className="hbg-rt__area" style={{ minHeight: minH }} placeholder={placeholder}
      value={value || ""} onChange={e => onChange(e.target.value)} />
  </div>
);

/* components/admin/blog/image-upload-field.blade.php — current file preview + a trash icon that
   flips the hidden remove_* flag to "1" (the service then unlinks the file), plus the upload
   input itself. `nullable|image|max:5120` is enforced client-side here so the operator learns
   about an oversized file before the round-trip. */
/* UPLOAD IS DISABLED, AND THE REASON IS NEXT TO THE CONTROL.
   This used to accept a file, validate its size and type, and then call
   `onPick(f.name)` — putting the LOCAL FILENAME into the row as though it were a
   stored path. Nothing was uploaded; there is no object storage in this build.
   Harmless while the row lived in a module-level object, and not harmless now:
   `master_image_url = "holiday-promo.png"` is a link the player site will try to
   fetch and fail on, written into the database as a fact.

   The field, its size rule and any stored URL still render — that is the screen
   isystem has, and hiding it would misrepresent the gap rather than state it.
   Removal stays wired, because clearing a column needs no storage.
   UNCLEAR-BLOG-2: which object store this build should use. Same open question
   as the FAQ screen's images; guessing a bucket name produces the same broken
   path one layer down. */
const HBG_NO_UPLOAD = "No object storage is configured in this build, so a file cannot be stored " +
  "and a path to one would be invented. Any image already saved on this row still shows.";

const HbgImageField = ({ label, value, removed, onPick, onRemove, onUndoRemove, hint }) => {
  const [err, setErr] = hbgUseState("");
  const pick = () => {};
  return (
    <div className="hbg-img">
      <div className="hbg-lab">{label}</div>
      {value && !removed && (
        <div className="hbg-img__cur">
          <span className="hbg-img__thumb" aria-hidden="true"><Icon name="grid" size={14} /></span>
          <span className="hbg-img__name mono">{value}</span>
          <button type="button" className="hbg-img__del" title="Remove current image" onClick={onRemove}>
            <Icon name="trash" size={12} />
          </button>
        </div>
      )}
      {value && removed && (
        <div className="hbg-img__cur hbg-img__cur--gone">
          <span className="hbg-img__name mono">{value}</span>
          <span className="hbg-img__flag">will be deleted on save</span>
          <button type="button" className="hbg-img__undo" onClick={onUndoRemove}>Undo</button>
        </div>
      )}
      {/* INERT: disabled, with the reason stated where the control is. */}
      <input type="file" className="hbg-file" accept="image/*" disabled title={HBG_NO_UPLOAD} onChange={pick} />
      <div className={err ? "hbg-err" : "hbg-hint"}>{err || hint || `PNG / JPG · max ${HBG_MAX_IMG_KB} KB`}</div>
      <div className="hbg-hint"><Icon name="alert" size={11} /> {HBG_NO_UPLOAD}</div>
    </div>
  );
};

/* ------------------------------------------------------------------ *
 * The language rail — the answer to "12 stacked translation sections".
 * One pill per `languages` row, English first, each carrying its own
 * translated / partial / untranslated state, plus a coverage counter and
 * an "All languages" overview. Horizontally scrollable on mobile (§11).
 * ------------------------------------------------------------------ */
const HBG_ALL_TAB = "__all";
const HbgLangTabs = ({ active, onPick, stateOf, countLabel }) => {
  const done = HBG_LANGS.filter(l => stateOf(l.code) === "done").length;
  const partial = HBG_LANGS.filter(l => stateOf(l.code) === "partial").length;
  return (
    <div className="hbg-langs">
      <div className="hbg-langs__top">
        <div className="hbg-langs__cov">
          <div className="hbg-cov">
            <span className="hbg-cov__fill" style={{ width: `${Math.round((done / HBG_LANGS.length) * 100)}%` }} />
            <span className="hbg-cov__fill hbg-cov__fill--partial" style={{ width: `${Math.round(((done + partial) / HBG_LANGS.length) * 100)}%` }} />
          </div>
          <span className="hbg-langs__count">
            <b>{done}</b> of {HBG_LANGS.length} {countLabel || "languages translated"}
            {partial > 0 && <span className="hbg-langs__partial"> · {partial} partial</span>}
          </span>
        </div>
        <div className="hbg-legend">
          <span><i className="hbg-dot hbg-dot--done" /> translated</span>
          <span><i className="hbg-dot hbg-dot--partial" /> partial</span>
          <span><i className="hbg-dot hbg-dot--empty" /> empty</span>
        </div>
      </div>
      <div className="hbg-langrail" role="tablist">
        {HBG_LANGS.map(l => {
          const st = stateOf(l.code);
          return (
            <button key={l.code} role="tab" aria-selected={active === l.code}
              className={`hbg-langpill${active === l.code ? " is-on" : ""} is-${st}`}
              title={HBG_LANG_LABEL(l.code)} onClick={() => onPick(l.code)}>
              <i className={`hbg-dot hbg-dot--${st}`} />
              <span className="hbg-langpill__code">{l.code.toUpperCase().replace("_", "-")}</span>
              <span className="hbg-langpill__name">{l.name}</span>
              {l.code === HBG_FALLBACK && <span className="hbg-langpill__tag">fallback</span>}
            </button>
          );
        })}
        <button role="tab" aria-selected={active === HBG_ALL_TAB}
          className={`hbg-langpill hbg-langpill--all${active === HBG_ALL_TAB ? " is-on" : ""}`}
          onClick={() => onPick(HBG_ALL_TAB)}>
          <Icon name="grid" size={11} /> All languages
        </button>
      </div>
    </div>
  );
};

/* ================================================================== *
 * BLOG FORM — GET /blog/form/?id= → POST /blog/save
 * ================================================================== */
const hbgBlankBlog = () => ({
  id: null, skin_id: null, skin_ids: [], category_id: "",
  master_title: "", master_body: "", master_image: "", master_thumb: "",
  is_published: 0, is_promotion: 0,
  promotion_text_1: "", promotion_text_2: "", promotion_button_text: "", promotion_button_link: "",
  valid_until: "", tr: {}, slug: "",
});

const HbgBlogForm = ({ row, cats, blogs, skins, busy, onBack, onSave }) => {
  const creating = !row || row.id == null;
  const [f, setF] = hbgUseState(() => {
    const base = row ? JSON.parse(JSON.stringify(row)) : hbgBlankBlog();
    return Object.assign(hbgBlankBlog(), base, { skin_ids: row ? [row.skin_id] : [] });
  });
  const [lang, setLang] = hbgUseState(HBG_FALLBACK);
  const [removed, setRemoved] = hbgUseState({});   /* remove_master_image / remove_image_{code} … */
  const [errs, setErrs] = hbgUseState([]);
  const [touched, setTouched] = hbgUseState(false);

  const set = (k, v) => setF(s => Object.assign({}, s, { [k]: v }));
  const trOf = (code) => (f.tr && f.tr[code]) || { title: "", body: "", image: "", thumb: "" };
  const setTr = (code, k, v) => setF(s => {
    const tr = Object.assign({}, s.tr);
    tr[code] = Object.assign({ title: "", body: "", image: "", thumb: "" }, tr[code], { [k]: v });
    return Object.assign({}, s, { tr });
  });

  /* A language counts as translated when it has a title (the field the fallback chain keys on);
     "partial" = it has a body/image but no title, i.e. content the frontend will never surface
     under its own locale heading. */
  const stateOf = (code) => {
    const t = trOf(code);
    if (String(t.title || "").trim()) return "done";
    if (String(t.body || "").trim() || t.image || t.thumb) return "partial";
    return "empty";
  };

  /* prepareFormData: on new + admin the picker holds ALL active categories, labelled
     "Title (SkinName)" so a category owned by another skin is visible as such. */
  const catOptions = cats
    .filter(c => c.is_active || String(c.id) === String(f.category_id))
    .map(c => ({ value: String(c.id), label: `${hbgCatTitle(c)} (${hbgSkinName(skins, c.skin_id) || "—"})`, skin_id: c.skin_id }));

  const targetSkins = creating ? f.skin_ids : (f.skin_id != null ? [f.skin_id] : []);

  /* Slug preview — Blog::generateSlug per selected skin. On edit the stored slug is shown as-is
     because the service never regenerates it. */
  const slugPreview = hbgUseMemo(() => {
    const base = hbgSlugify(f.master_title) || hbgSlugify(trOf(HBG_FALLBACK).title);
    return targetSkins.map(sid => {
      const taken = blogs.filter(b => b.skin_id === Number(sid) && b.id !== f.id).map(b => b.slug);
      const slug = hbgNextSlug(base, taken);
      return { skin_id: Number(sid), base, slug, bumped: !!base && slug !== base };
    });
  }, [f.master_title, f.tr, f.skin_ids, f.skin_id, blogs]);

  const validate = () => {
    const e = [];
    if (!String(f.category_id || "").trim()) e.push("Category is required.");                     /* required|exists:blog_categories,id */
    if (creating && (!f.skin_ids || !f.skin_ids.length)) e.push("Select at least one casino.");   /* required|array|min:1 */
    if (!String(f.master_title || "").trim() && !String(trOf(HBG_FALLBACK).title || "").trim())
      e.push("Master Title or the English title is required.");                                   /* withValidator L85-94 */
    if (String(f.master_title || "").length > 255) e.push("Master Title may not be greater than 255 characters.");
    HBG_LANGS.forEach(l => {
      if (String(trOf(l.code).title || "").length > 255) e.push(`${HBG_LANG_LABEL(l.code)}: title may not be greater than 255 characters.`);
    });
    if (f.is_promotion) {                                                                          /* withValidator L97-104 */
      if (!String(f.promotion_text_1 || "").trim()) e.push("Promotion Text 1 is required when Is Promotion is on.");
      if (!String(f.promotion_text_2 || "").trim()) e.push("Promotion Text 2 is required when Is Promotion is on.");
    }
    if (String(f.promotion_button_text || "").length > 255) e.push("Button Text may not be greater than 255 characters.");
    if (String(f.promotion_button_link || "").length > 1024) e.push("Button Link may not be greater than 1024 characters.");
    return e;
  };

  const save = () => {
    setTouched(true);
    const e = validate();
    setErrs(e);
    if (e.length) { hbgToast("Save failed", `${e.length} validation error(s) — the real save redirects back withInput and flashes the Operation error banner.`); return; }
    onSave(f, slugPreview, removed);
  };

  const catErr = touched && !String(f.category_id || "").trim() ? "Category is required." : "";
  const skinErr = touched && creating && !f.skin_ids.length ? "Select at least one casino." : "";
  const titleErr = touched && !String(f.master_title || "").trim() && !String(trOf(HBG_FALLBACK).title || "").trim()
    ? "Master Title or the English title is required." : "";

  return (
    <HrsShell
      title={creating ? "New Blog" : "Edit blog"}   /* backend.new_blog resolves; "Edit blog" label inferred */
      subtitle={creating
        ? "One blog row is created per selected casino, in a single transaction"
        : `Blog #${f.id} · ${hbgSkinName(skins, f.skin_id) || "—"} · slug ${f.slug}`}
      gate={<>Same gate as the list: <code>isadmin()</code> or <code>isSkinAdmin()</code> + skin setting <code>enable_cms</code>, else <code>404</code>. </>}
      gateNote={<>The form and save endpoints inherit that check from <code>BlogController</code>, unlike several sibling CMS controllers whose form/save routes are open.</>}
      actions={<button className="hrs-btn hrs-btn--search" onClick={onBack}><Icon name="chevron_left" size={14} /> Back to list{/* label inferred */}</button>}>

      <HbgErrors items={errs} />

      <HbgPanel title="Data" icon="sliders"          /* backend.generic_data = "Data" */
        sub="Where the post lives and whether players can see it">
        <div className="hbg-grid2">
          <HbgField label="Category" req error={catErr}
            tip={<>Options are every <b>active</b> category across <b>all</b> skins, labelled <code>Title (SkinName)</code> — a category can belong to another skin than the post, because moving a category between skins never moves its blogs.</>}>
            {catOptions.length === 0 ? (
              <div className="hbg-empty-inline">
                No categories available{/* backend.no_categories_available */} — Please create a category first{/* backend.create_category_first */}
              </div>
            ) : (
              <select className="hbg-ctl" value={f.category_id || ""} onChange={e => set("category_id", e.target.value)}>
                <option value="">- Select -</option>
                {catOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
              </select>
            )}
          </HbgField>

          <HbgField label={creating ? "Casinos (Select Multiple)" : "Casino"} req={creating} error={skinErr}
            hint={creating
              ? "One independent blog row is created per selected casino, inside one transaction. Uploaded images are stored once and shared."
              : "Fixed on edit — the form posts it as a hidden skin_id and the save only touches this one row."}
            tip={creating ? <>Validated as <code>required|array|min:1</code>, each id <code>exists:skins,id</code>.</> : null}>
            {creating ? (
              <div className="hbg-skinpick">
                {skins.map(s => {
                  const on = f.skin_ids.indexOf(s.id) !== -1;
                  return (
                    <button type="button" key={s.id} className={`hbg-skinchip${on ? " is-on" : ""}`}
                      onClick={() => set("skin_ids", on ? f.skin_ids.filter(x => x !== s.id) : f.skin_ids.concat([s.id]))}>
                      <span className="hbg-skin__av">{hbgInitials(s.name)}</span>{s.name}
                      {on && <Icon name="check" size={11} />}
                    </button>
                  );
                })}
              </div>
            ) : (
              <div className="hbg-readonly"><HbgSkinCell skinId={f.skin_id} skins={skins} /><span className="hbg-readonly__tag">hidden skin_id</span></div>
            )}
          </HbgField>
        </div>

        <div className="hbg-row">
          <label className="hbg-switch">
            <Toggle value={!!f.is_published} onChange={v => set("is_published", v ? 1 : 0)} onLabel="" offLabel="" size="sm" />
            <span>Published{/* backend.published */}</span>
          </label>
          <span className="hbg-hint">
            {f.is_published
              ? "published_at is stamped with the save time."
              : "published_at is cleared. Only published posts reach the frontend API."}
          </span>
        </div>

        {/* Per-skin slug uniqueness, exactly as generateSlug resolves it. There is no slug field
            on the real form — this is a read-only preview of what the save will mint. */}
        <div className="hbg-slugs">
          <div className="hbg-slugs__head">
            <Icon name="tag" size={12} /> Slug{/* backend.slug */}
            <span className="hbg-hint">
              {creating
                ? "Generated from the Master Title (or the English title), then de-duplicated per casino."
                : "Generated once on create and never regenerated on edit."}
            </span>
          </div>
          {!creating ? (
            <div className="hbg-slugrow">
              <HbgSkinCell skinId={f.skin_id} skins={skins} />
              <code className="hbg-slugval">{f.slug}</code>
              <span className="hbg-slugtag">unchanged</span>
            </div>
          ) : slugPreview.length === 0 ? (
            <div className="hbg-hint">Select at least one casino to see the slug that will be created.</div>
          ) : slugPreview.map(p => (
            <div className="hbg-slugrow" key={p.skin_id}>
              <HbgSkinCell skinId={p.skin_id} skins={skins} />
              <code className="hbg-slugval">{p.slug || <span className="hbg-dash">— title required —</span>}</code>
              {p.bumped && (
                <span className="hbg-slugtag hbg-slugtag--warn">
                  <Icon name="info" size={10} /> "{p.base}" is taken on this casino
                </span>
              )}
            </div>
          ))}
        </div>
      </HbgPanel>

      <HbgPanel title="Master Fields" icon="star"    /* backend.master_fields */
        sub="Fallback if translation empty"          /* backend.fallback_if_translation_empty */
        aside={<span className="hbg-badge">used by every locale that has no translation row</span>}>
        <Explainer compact title="How the master fields are used">
          The frontend delivery service resolves a post per locale and falls back field-by-field to
          these master values whenever the requested locale has no translation — so a post with only
          master fields is still fully readable in all 12 languages, and a locale that fills only its
          title still inherits the master body and images.
        </Explainer>
        <HbgField label="Master Title" req={!String(trOf(HBG_FALLBACK).title || "").trim()} error={titleErr}
          hint="Max 255 characters. Required unless the English title is filled — the slug is derived from whichever is present."
          wide>
          <input className="hbg-ctl" maxLength={255} value={f.master_title || ""}
            onChange={e => set("master_title", e.target.value)} placeholder="Master Title" />
        </HbgField>
        <HbgField label="Master Body" wide>
          <HbgRichText value={f.master_body} onChange={v => set("master_body", v)} minH={170} />
        </HbgField>
        <div className="hbg-grid2">
          <HbgImageField label="Master Image" value={f.master_image} removed={!!removed.master_image}
            onPick={n => { set("master_image", n); setRemoved(r => Object.assign({}, r, { master_image: false })); }}
            onRemove={() => setRemoved(r => Object.assign({}, r, { master_image: true }))}
            onUndoRemove={() => setRemoved(r => Object.assign({}, r, { master_image: false }))} />
          <HbgImageField label="Master Thumbnail Image" value={f.master_thumb} removed={!!removed.master_thumb}
            onPick={n => { set("master_thumb", n); setRemoved(r => Object.assign({}, r, { master_thumb: false })); }}
            onRemove={() => setRemoved(r => Object.assign({}, r, { master_thumb: true }))}
            onUndoRemove={() => setRemoved(r => Object.assign({}, r, { master_thumb: false }))} />
        </div>
      </HbgPanel>

      <HbgPanel title="Translations" icon="globe"    /* backend.translations */
        sub="One blog_translations row per locale (blog_id + locale is unique)">
        <HbgLangTabs active={lang} onPick={setLang} stateOf={stateOf} countLabel="languages have a title" />

        {lang === HBG_ALL_TAB ? (
          /* Overview — the nearest honest equivalent of the real form's twelve stacked sections:
             what is filled where, with a jump into the language that needs work. Read-only. */
          <div className="hbg-matrixwrap">
            <table className="hbg-matrix">
              <thead>
                <tr><th>Language</th><th>Title</th><th>Body</th><th>Image</th><th>Thumbnail</th><th /></tr>
              </thead>
              <tbody>
                {HBG_LANGS.map(l => {
                  const t = trOf(l.code);
                  const cell = (v, masterFilled) => v
                    ? <span className="hbg-yes"><Icon name="check" size={11} /></span>
                    : <span className="hbg-no">{masterFilled ? "master" : "—"}</span>;
                  return (
                    <tr key={l.code} className={`is-${stateOf(l.code)}`}>
                      <th scope="row">
                        <i className={`hbg-dot hbg-dot--${stateOf(l.code)}`} />
                        {l.name} <code>{l.code}</code>
                        {l.code === HBG_FALLBACK && <span className="hbg-langpill__tag">fallback</span>}
                      </th>
                      <td>{cell(String(t.title || "").trim(), !!f.master_title)}</td>
                      <td>{cell(String(t.body || "").trim(), !!f.master_body)}</td>
                      <td>{cell(t.image, !!f.master_image)}</td>
                      <td>{cell(t.thumb, !!f.master_thumb)}</td>
                      <td className="hbg-matrix__go">
                        <button type="button" onClick={() => setLang(l.code)}>Edit <Icon name="chevron_right" size={11} /></button>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
            <div className="hbg-hint">"master" means the locale inherits the master field at delivery time; "—" means neither is set.</div>
          </div>
        ) : (
          <div className="hbg-langbody">
            <div className="hbg-langbody__head">
              <b>{HBG_LANG_LABEL(lang)}</b>
              <span className={`hbg-statetag is-${stateOf(lang)}`}>
                {stateOf(lang) === "done" ? "translated" : stateOf(lang) === "partial" ? "partial — no title" : "not translated"}
              </span>
            </div>
            <HbgField label="Title" wide
              hint={f.master_title ? `If empty, will use master title — "${f.master_title}"` : "If empty, will use master title"}>
              {/* backend.if_empty_will_use_master_title */}
              <input className="hbg-ctl" maxLength={255} value={trOf(lang).title}
                onChange={e => setTr(lang, "title", e.target.value)} placeholder={`Title (${lang})`} />
            </HbgField>
            <HbgField label="Body" wide hint="If empty, will use master body">
              <HbgRichText value={trOf(lang).body} onChange={v => setTr(lang, "body", v)} />
            </HbgField>
            <div className="hbg-grid2">
              <HbgImageField label="Image" value={trOf(lang).image} removed={!!removed[`image_${lang}`]}
                hint="If empty, will use master image"
                onPick={n => { setTr(lang, "image", n); setRemoved(r => Object.assign({}, r, { [`image_${lang}`]: false })); }}
                onRemove={() => setRemoved(r => Object.assign({}, r, { [`image_${lang}`]: true }))}
                onUndoRemove={() => setRemoved(r => Object.assign({}, r, { [`image_${lang}`]: false }))} />
              <HbgImageField label="Thumbnail Image" value={trOf(lang).thumb} removed={!!removed[`thumbnail_image_${lang}`]}
                hint="If empty, will use master thumbnail"
                onPick={n => { setTr(lang, "thumb", n); setRemoved(r => Object.assign({}, r, { [`thumbnail_image_${lang}`]: false })); }}
                onRemove={() => setRemoved(r => Object.assign({}, r, { [`thumbnail_image_${lang}`]: true }))}
                onUndoRemove={() => setRemoved(r => Object.assign({}, r, { [`thumbnail_image_${lang}`]: false }))} />
            </div>
          </div>
        )}
      </HbgPanel>

      <HbgPanel title="Promotion Settings" icon="zap"   /* backend.promotion_settings */
        sub="Turns the post into a promotion card on the frontend"
        aside={
          <label className="hbg-switch">
            <Toggle value={!!f.is_promotion} onChange={v => set("is_promotion", v ? 1 : 0)} onLabel="" offLabel="" size="sm" />
            <span>Is Promotion{/* backend.is_promotion */}</span>
          </label>
        }>
        {!f.is_promotion ? (
          <div className="hbg-hint">Off — the promotion fields are not posted and are not validated.</div>
        ) : (
          <div className="hbg-grid2">
            <HbgField label="Promotion Text 1" req wide
              error={touched && !String(f.promotion_text_1 || "").trim() ? "Required when Is Promotion is on." : ""}>
              <HbgRichText value={f.promotion_text_1} onChange={v => set("promotion_text_1", v)} minH={120} />
            </HbgField>
            <HbgField label="Promotion Text 2" req wide
              error={touched && !String(f.promotion_text_2 || "").trim() ? "Required when Is Promotion is on." : ""}>
              <HbgRichText value={f.promotion_text_2} onChange={v => set("promotion_text_2", v)} minH={120} />
            </HbgField>
            <HbgField label="Button Text" hint="Max 255 characters.">
              <input className="hbg-ctl" maxLength={255} value={f.promotion_button_text || ""}
                onChange={e => set("promotion_button_text", e.target.value)} placeholder="Play Now!" />
            </HbgField>
            <HbgField label="Button Link" hint="Max 1024 characters.">
              <input className="hbg-ctl" maxLength={1024} value={f.promotion_button_link || ""}
                onChange={e => set("promotion_button_link", e.target.value)} placeholder="https://" />
            </HbgField>
            <HbgField label="Valid Until"
              tip={<>Stored in a <b>string</b> column with rule <code>nullable|string</code> — no format or timezone is enforced and nothing expires the promotion automatically.</>}>
              <input type="datetime-local" className="hbg-ctl" value={f.valid_until || ""}
                onChange={e => set("valid_until", e.target.value)} />
            </HbgField>
          </div>
        )}
      </HbgPanel>

      <div className="hbg-formbar">
        <span className="hbg-formbar__note">
          {creating
            ? `Creates ${targetSkins.length || 0} blog row${targetSkins.length === 1 ? "" : "s"} in one transaction.`
            : "Updates this row only."}
        </span>
        <div className="hbg-formbar__btns">
          <button className="hrs-btn hrs-btn--search" onClick={onBack}>Cancel</button>
          {/* Disabled while the save is in flight: a fan-out is N sequential writes,
              and a second click during the first would start a second fan-out over
              rows the first is still creating. */}
          <button className="hrs-btn hrs-btn--filters" onClick={save} disabled={busy}><Icon name="check" size={14} /> {busy ? "Saving…" : "Save"}</button>
        </div>
      </div>
    </HrsShell>
  );
};

/* ================================================================== *
 * BLOGS LIST — GET /blog/
 * ================================================================== */
const HBG_BLOG_FILTER_DEFAULTS = { q: "", skin: "", category: "", promotion: "", published: "" };

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

  const { blogs, cats, skins, truncated, loading, error, retry, feeds } = hbgUseDb();
  const save = useHrsSave(feeds);
  const [draft, setDraft] = hbgUseState(HBG_BLOG_FILTER_DEFAULTS);
  const [applied, setApplied] = hbgUseState(HBG_BLOG_FILTER_DEFAULTS);
  const [page, setPage] = hbgUseState(0);
  const [del, setDel] = hbgUseState(null);
  const [editing, setEditing] = hbgUseState(null);
  /* The list/form swap gets its own URL (/cms/blogs vs /cms/blogs/form — the real screens are two
     routes, admin.blog and admin.blog.form). The row stays in memory, so a cold load of
     /cms/blogs/form opens the create form, the same "no record id in the path" limitation as
     Players/Users. */
  const [view, setView] = window.useUrlTab("/cms/blogs", [["list", "List", ""], ["form", "Form", "form"]], "list");

  const catById = hbgUseMemo(() => { const m = {}; cats.forEach(c => { m[c.id] = c; }); return m; }, [cats]);

  /* summarizeStatus (L49) — counts ignore the is_published filter but honour the others. */
  const scoped = hbgUseMemo(() => blogs.filter(b => {
    const q = String(applied.q || "").trim().toLowerCase();
    if (q) {
      const digits = /^\d+$/.test(q);
      const hitTr = HBG_LANGS.some(l => b.tr[l.code] && String(b.tr[l.code].title || "").toLowerCase().indexOf(q) !== -1);
      const hit = digits ? String(b.id) === q
        : (String(b.master_title || "").toLowerCase().indexOf(q) !== -1 || String(b.slug || "").toLowerCase().indexOf(q) !== -1 || hitTr);
      if (!hit) return false;
    }
    if (applied.skin && String(b.skin_id) !== String(applied.skin)) return false;
    if (applied.category && String(b.category_id) !== String(applied.category)) return false;
    if (applied.promotion !== "" && String(b.is_promotion) !== String(applied.promotion)) return false;
    return true;
  }), [blogs, applied.q, applied.skin, applied.category, applied.promotion]);

  const rowsAll = hbgUseMemo(() => {
    const r = applied.published === "" ? scoped : scoped.filter(b => String(b.is_published) === String(applied.published));
    return r.slice().sort((a, b) => b.id - a.id);   /* fixed blogs.id DESC — no sortable columns */
  }, [scoped, applied.published]);

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

  const openForm = (row) => { setEditing(row); setView("form"); };

  /* CREATE FANS OUT ACROSS BRANDS AND IS NOT ATOMIC HERE.
     isystem loops the selected skin_ids inside one transaction. app_write()
     takes one resource per call, so this is N inserts each followed by its own
     translation batch, and an interruption partway leaves some brands with the
     post and some without. Not hidden: the toast names every brand that was and
     was not written, and the list refreshes from the database.
     UNCLEAR-BLOG-1: whether a multi-brand content create deserves its own RPC to
     regain the transaction. Not a money path, visible, and re-runnable — so it
     is recorded rather than guessed at.

     `published_at` IS SENT WITH `published`, because 006 refuses the pair to
     disagree: `check (not published or published_at is not null)`. A screen that
     sent the flag alone would raise a check violation naming a column it never
     touched. */
  const onSave = (form, slugPreview, removedFlags) => {
    save.run(async () => {
      const creating = form.id == null;
      const targets = creating
        ? slugPreview.map(p => ({ skin_id: p.skin_id, slug: p.slug }))
        : [{ skin_id: form.skin_id, slug: form.slug }];
      const ok = [], bad = [], partial = [];
      const nowIso = new Date().toISOString();

      for (const t of targets) {
        const body = {
          category_id: form.category_id == null ? null : Number(form.category_id),
          slug: t.slug,
          published: !!form.is_published,
          published_at: form.is_published
            ? (!creating && form.published_at ? new Date(form.published_at * 1000).toISOString() : nowIso)
            : null,
          master_title: form.master_title || null,
          master_body: form.master_body || null,
          master_image_url: form.master_image || null,
          master_thumbnail_url: form.master_thumb || null,
          is_promotion: !!form.is_promotion,
          /* The four promotion columns travel WITH the flag. Sending
             is_promotion alone trips blogs_promotion_needs_copy, which is what
             supabase/044 was written for. */
          promotion_text_1: form.is_promotion ? (form.promotion_text_1 || "") : null,
          promotion_text_2: form.is_promotion ? (form.promotion_text_2 || "") : null,
          promotion_button_text: form.promotion_button_text || null,
          promotion_button_link: form.promotion_button_link || null,
          valid_until: form.valid_until ? new Date(form.valid_until).toISOString() : null,
        };

        let parentId, res;
        if (creating) {
          body.skin_id = Number(t.skin_id);
          res = await window.sb.create("blogs", body);
          parentId = res && res.ok && res.data ? res.data.id : null;
        } else {
          /* No skin_id and no slug regeneration on edit — the real form posts
             skin_id as a hidden field and never re-mints the slug. */
          delete body.slug;
          res = await window.sb.update("blogs", form.id, body);
          parentId = form.id;
        }
        if (!res || !res.ok || !parentId) {
          bad.push(`${hbgSkinName(skins, t.skin_id) || ("#" + t.skin_id)}: ${(res && res.error && res.error.message) || "refused"}`);
          continue;
        }

        const tr = await hbgSyncTr({
          resource: "blogTranslations", fkColumn: "blog_id", parentId,
          existing: creating ? [] : ((blogs.find(b => b.id === form.id) || {})._tr || []),
          next: form.tr, isCat: false,
        });
        const label = hbgSkinName(skins, t.skin_id) || ("#" + t.skin_id);
        if (tr.failed.length) partial.push(`${label} — the post saved but ${tr.failed.length} language(s) did not: ${tr.failed.join("; ")}`);
        else ok.push(`${label} (#${parentId}, ${tr.wrote} language${tr.wrote === 1 ? "" : "s"}${tr.removed ? `, ${tr.removed} removed` : ""})`);
      }

      if (bad.length || partial.length) {
        return { ok: false, error: { kind: "server", message:
          [ok.length ? `Saved: ${ok.join(", ")}.` : "Nothing saved.",
           bad.length ? `Refused: ${bad.join(" · ")}.` : "",
           partial.length ? `PARTIAL: ${partial.join(" · ")}.` : ""].filter(Boolean).join(" ") } };
      }
      return { ok: true, data: ok, meta: {} };
    }, {
      done: form.id == null
        ? `${slugPreview.length} blog row${slugPreview.length === 1 ? "" : "s"} created`
        : `Blog #${form.id} saved`,
      fail: form.id == null ? "The post was not created everywhere" : "The post was not fully saved",
    }).then(res => { if (res && res.ok) { setEditing(null); setView("list"); } });
  };

  /* SOFT delete: `blogs` carries deleted_at, so the row and its translations
     survive and a mistaken delete is recoverable. The old toast claimed the
     translation rows "go with it" — under a soft delete they do not, and saying
     so was the mock describing a cascade it never performed. */
  const doDelete = (row) => {
    save.run(() => window.sb.remove("blogs", row.id), {
      done: `Blog #${row.id} deleted`,
      fail: `Blog #${row.id} was not deleted`,
    }).then(() => setDel(null));
  };

  if (view === "form") {
    return <HbgBlogForm row={editing} cats={cats} blogs={blogs} skins={skins} busy={save.busy}
      onBack={() => { setEditing(null); setView("list"); }} onSave={onSave} />;
  }

  const skinOpts = skins.slice().sort((a, b) => a.name.localeCompare(b.name)).map(s => ({ value: String(s.id), label: s.name }));
  /* BlogController L42 — the Category filter lists ALL ACTIVE categories across ALL skins,
     labelled getTitle() ?? slug (unlike the Category column, which prints the slug). */
  const catOpts = cats.filter(c => c.is_active).map(c => ({ value: String(c.id), label: `${hbgCatTitle(c)} — ${hbgSkinName(skins, c.skin_id) || "—"}` }));

  const FIELDS = [
    {
      key: "q", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Title, slug or ID",
      tip: <>Matches <code>master_title</code>, <code>slug</code> and any <code>blog_translations.title</code>; an all-digits term is an exact <code>blogs.id</code> match instead.</>,
    },
    {
      key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "All", options: skinOpts,
      tip: <>Rendered for super admins only — a skin admin is hard-scoped to their own <code>skin_id</code> server-side and never sees this control. Auto-submits on the real page.</>,
    },
    {
      key: "category", label: "Category", type: "select", icon: "list", placeholder: "All categories", options: catOpts,
      tip: <>Every <b>active</b> category across <b>all</b> skins, so it can list a category no post on the selected skin can use.</>,
    },
    { key: "promotion", label: "Promotion", type: "select", icon: "star", placeholder: "All", options: [{ value: "1", label: "Yes" }, { value: "0", label: "No" }] },
    { key: "published", label: "Published", type: "select", icon: "eye", placeholder: "All", options: [{ value: "1", label: "Published" }, { value: "0", label: "Draft" }] },
  ];

  const setStatus = (v) => { setDraft(d => Object.assign({}, d, { published: v })); setApplied(a => Object.assign({}, a, { published: v })); setPage(0); };

  const columns = [
    { key: "id", label: "ID", width: 84, render: r => <CopyableId value={r.id} className="hbg-id" /> },
    { key: "skin", label: "Skin", render: r => <HbgSkinCell skinId={r.skin_id} skins={skins} /> },
    {
      key: "title", label: "Title", render: r => (
        <button className="hbg-titlelink" title="Edit" onClick={(e) => { e.stopPropagation(); openForm(r); }}>
          <span className="hbg-titlelink__t">{r.master_title || r.slug}</span>
          <span className="hbg-titlelink__s mono">{r.slug}</span>
        </button>
      ),
    },
    {
      key: "category", label: "Category", render: r => {
        const c = catById[r.category_id];
        if (!c) return <span className="hbg-dash">—</span>;
        const foreign = c.skin_id !== r.skin_id;
        return (
          <span className="hbg-catcell mono">
            {c.slug}
            {foreign && <Tip size={11}>This category belongs to <b>{hbgSkinName(skins, c.skin_id) || "—"}</b>, not to this post's skin — the result of moving a category between skins on edit, which never moves the blogs pointing at it.</Tip>}
          </span>
        );
      },
    },
    { key: "promotion", label: "Promotion", align: "center", render: r => <span className={`hbg-pill ${r.is_promotion ? "hbg-pill--promo" : "hbg-pill--muted"}`}><span className="hbg-pill__dot" />{r.is_promotion ? "Yes" : "No"}</span> },
    { key: "status", label: "Status", align: "center", render: r => <HbgStatusPill on={r.is_published} onLabel="Published" offLabel="Draft" /> },
    { key: "created", label: "Created", render: r => <span className="hbg-when">{hbgFmtTs(r.addedTime)}</span> },
    {
      key: "_acts", label: "Actions", align: "center", width: 110, render: r => (
        <div className="hbg-acts">
          <button className="hbg-act hbg-act--edit" title="Edit" onClick={(e) => { e.stopPropagation(); openForm(r); }}><Icon name="edit" size={13} /></button>
          <button className="hbg-act hbg-act--danger" title="Delete" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={13} /></button>
        </div>
      ),
    },
  ];

  const pubCount = scoped.filter(b => b.is_published).length;

  return (
    <HrsShell
      title="Blogs"                                  /* backend.blogs */
      subtitle="Multilingual blog posts and promotion cards, one row per skin"   /* label inferred */
      gate={<>Real-platform access: <code>isadmin()</code>, <b>or</b> <code>isSkinAdmin()</code> with the skin setting <code>enable_cms</code> — anything else aborts <code>404</code> at <code>BlogController::index</code>. Delete additionally requires <code>isadmin() || isSkinAdmin()</code> and, for a skin admin, that the row's <code>skin_id</code> is their own. </>}
      gateNote={<>Menu/controller mismatch, honestly: the sidebar also shows this entry to Customer Care holding <code>support_cms_banners</code>, but the controller has no such branch — those users land on the 404.</>}
      explainer={{
        title: "What this screen is, in plain English",
        bullets: [
          <>A blog row is <b>per skin</b>. Creating one fans out an independent row for every selected casino inside a single transaction (images are uploaded once and shared), so the same article on four brands is four rows with four ids.</>,
          <>Each row has <b>master</b> fields plus one <code>blog_translations</code> row per locale. The frontend resolves a post per locale and falls back field-by-field to the master values, so a locale with no translation still renders.</>,
          <>The <b>slug</b> is derived from the master title and de-duplicated <b>within the skin</b> (<code>-1</code>, <code>-2</code>…). It is minted once and never regenerated, so renaming a post does not change its public URL.</>,
          <><b>Promotion</b> posts additionally carry two rich-text blocks, a button and a "valid until" string — that is what turns an article into a promotion card.</>,
          <>The list is fixed to <code>blogs.id DESC</code>: no column is sortable, there is no export and there are no bulk actions on the real screen.</>,
        ],
      }}
      actions={
        <>
          <button className="hrs-btn hrs-btn--search" onClick={() => hbgNavTo("cms-blog-cat")}>
            <Icon name="list" size={14} /> Manage categories{/* label inferred */}
          </button>
          <button className="hrs-btn hrs-btn--filters" onClick={() => openForm(null)}>
            <Icon name="plus" size={14} /> New Blog{/* backend.new_blog */}
          </button>
        </>
      }>

      <HbgChips
        value={applied.published} onPick={setStatus}
        items={[
          { value: "", label: "All", count: scoped.length },
          { value: "1", label: "Published", count: pubCount, tone: "ok" },
          { value: "0", label: "Draft", count: scoped.length - pubCount, tone: "muted" },
        ]} />

      <HrsFilters
        fields={FIELDS} values={draft}
        onChange={(k, v) => setDraft(d => Object.assign({}, d, { [k]: v }))}
        onSearch={(v) => { setApplied(Object.assign({}, v)); setPage(0); }}
        onReset={() => { setDraft(HBG_BLOG_FILTER_DEFAULTS); setApplied(HBG_BLOG_FILTER_DEFAULTS); setPage(0); }}
        resultLabel={`${hrsInt(rowsAll.length)} of ${hrsInt(blogs.length)}`} />

      {truncated.length > 0 && (
        <div className="hbg-err">
          <Icon name="alert" size={13} /> There are more {truncated.join(" and ")} than the {HBG_FETCH_MAX} this
          screen fetches. The list and the search below cover the fetched rows only.
        </div>
      )}

      {loading ? <HrsSkeleton rows={6} cols={7} /> : error ? <HrsError error={error} onRetry={retry} /> : (
      <HrsTable
        columns={columns} rows={paged} rowKey="id" onRowClick={openForm}
        empty="No records."                          /* backend.no_records — label inferred */
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.master_title || r.slug}</b>
              <HbgStatusPill on={r.is_published} onLabel="Published" offLabel="Draft" />
            </div>
            <div className="hbg-card__sub mono">{r.slug}</div>
            <div className="hrs-card__grid">
              <span>Skin</span><b>{hbgSkinName(skins, r.skin_id) || "—"}</b>
              <span>Category</span><b>{catById[r.category_id] ? catById[r.category_id].slug : "—"}</b>
              <span>Promotion</span><b>{r.is_promotion ? "Yes" : "No"}</b>
              <span>Created</span><b>{hbgFmtTs(r.addedTime)}</b>
            </div>
            <div className="hbg-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); openForm(r); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hbg-card__del" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />
      )}

      {/* $perPage is hardcoded to 25 — no "show N entries" select exists upstream, so none here. */}
      <HrsPager page={safePage} pageSize={HBG_PER_PAGE} total={rowsAll.length} onPage={setPage} />

      {del && (
        <HbgConfirm
          title="Delete blog"
          onClose={() => setDel(null)}
          confirm={{ label: "Delete", onClick: () => doDelete(del) }}>
          <p>Delete <b>{del.master_title || del.slug}</b> (#{del.id}) from <b>{hbgSkinName(skins, del.skin_id) || "—"}</b>?</p>
          <p className="hbg-hint">
            The row, its <code>blog_translations</code> rows and its stored images are removed. Other
            casinos' copies of the same article are independent rows and are not affected.
          </p>
        </HbgConfirm>
      )}
    </HrsShell>
  );
};

/* ================================================================== *
 * CATEGORY FORM — GET /blog/category/form/?id= → POST /blog/category/save
 * ================================================================== */
const HbgCatForm = ({ row, cats, blogs, skins, busy, onBack, onSave }) => {
  const creating = !row || row.id == null;
  const [f, setF] = hbgUseState(() => ({
    id: row ? row.id : null,
    skin_id: row ? row.skin_id : (skins[0] ? skins[0].id : null),
    skin_ids: [],
    slug: row ? row.slug : "",
    is_active: row ? row.is_active : 1,          /* switch defaults to checked on new */
    t: row ? Object.assign({}, row.t) : {},
  }));
  const [lang, setLang] = hbgUseState(HBG_FALLBACK);
  const [errs, setErrs] = hbgUseState([]);
  const [touched, setTouched] = hbgUseState(false);

  const set = (k, v) => setF(s => Object.assign({}, s, { [k]: v }));
  const setTitle = (code, v) => setF(s => { const t = Object.assign({}, s.t); if (v) t[code] = v; else delete t[code]; return Object.assign({}, s, { t }); });
  const stateOf = (code) => (String(f.t[code] || "").trim() ? "done" : "empty");

  const firstTitle = () => { const l = HBG_LANGS.find(x => String(f.t[x.code] || "").trim()); return l ? f.t[l.code] : ""; };
  const effectiveSlug = String(f.slug || "").trim() || hbgSlugify(firstTitle());
  const targetSkins = creating ? f.skin_ids : (f.skin_id != null ? [Number(f.skin_id)] : []);

  /* UNIQUE (skin_id, slug) — checked per target skin, excluding this row on edit. The real save
     throws backend.slug_already_exists inside the transaction and redirects back. */
  const clashes = hbgUseMemo(() => targetSkins.filter(sid =>
    cats.some(c => c.skin_id === Number(sid) && c.slug === effectiveSlug && c.id !== f.id)
  ), [cats, effectiveSlug, f.skin_ids, f.skin_id, f.id]);

  /* Blogs left behind when a category is moved between skins on edit. */
  const strandedIfMoved = hbgUseMemo(() => (creating || Number(f.skin_id) === (row ? row.skin_id : null))
    ? [] : blogs.filter(b => b.category_id === f.id), [f.skin_id, blogs, f.id]);

  const validate = () => {
    const e = [];
    if (creating && !f.skin_ids.length) e.push("Select at least one skin.");        /* backend.select_at_least_one_skin */
    if (!String(f.slug || "").trim() && !firstTitle()) e.push("Enter a slug or at least one language title."); /* backend.slug_or_title_required — label inferred */
    if (String(f.slug || "").length > 255) e.push("Slug may not be greater than 255 characters.");
    HBG_LANGS.forEach(l => { if (String(f.t[l.code] || "").length > 255) e.push(`${HBG_LANG_LABEL(l.code)}: title may not be greater than 255 characters.`); });
    clashes.forEach(sid => e.push(`Slug already exists — "${effectiveSlug}" is already used on ${hbgSkinName(skins, sid) || ("#" + sid)}.`)); /* backend.slug_already_exists */
    return e;
  };

  const save = () => {
    setTouched(true);
    const e = validate();
    setErrs(e);
    if (e.length) { hbgToast("Save failed", `${e.length} validation error(s) — the real save aborts the transaction and redirects back with the "Operation error" prefix.`); return; }
    onSave(Object.assign({}, f, { slug: effectiveSlug }), targetSkins);
  };

  return (
    <HrsShell
      title={creating ? "New Category" : "Edit category"}   /* backend.new_category resolves; "Edit category" inferred */
      subtitle={creating
        ? "One category row is created per selected casino"
        : `Category #${f.id} · ${hbgSkinName(skins, row.skin_id) || "—"} · slug ${row.slug}`}
      gate={<>Same CMS gate as the list: <code>isadmin()</code> or <code>isSkinAdmin()</code> + <code>enable_cms</code>, else <code>404</code>. </>}
      actions={<button className="hrs-btn hrs-btn--search" onClick={onBack}><Icon name="chevron_left" size={14} /> Back to categories{/* label inferred */}</button>}>

      <HbgErrors items={errs} />

      <HbgPanel title="Data" icon="sliders" sub="Slug, visibility and which casino owns the category">
        <div className="hbg-grid2">
          <HbgField label="Slug" hint="Slug will be auto-generated if empty"   /* backend.slug_auto_generated_if_empty */
            error={touched && clashes.length ? `Slug already exists on ${clashes.map(hbgSkinName).join(", ")}.` : ""}
            tip={<>Unique per casino: the table's UNIQUE key is <code>(skin_id, slug)</code>, so the same slug on two different casinos is fine — a second copy on the <b>same</b> casino is rejected.</>}>
            <input className="hbg-ctl" maxLength={255} value={f.slug}
              onChange={e => set("slug", e.target.value)} placeholder="bonus" />
            {!String(f.slug || "").trim() && (
              <div className="hbg-hint">
                {effectiveSlug
                  ? <>Will be generated as <code>{effectiveSlug}</code> from the first non-empty language title.</>
                  : "Fill a slug or at least one language title."}
              </div>
            )}
          </HbgField>

          <HbgField label="Status">
            <label className="hbg-switch">
              <Toggle value={!!f.is_active} onChange={v => set("is_active", v ? 1 : 0)} onLabel="" offLabel="" size="sm" />
              <span>{f.is_active ? "Active" : "Disabled"}</span>{/* backend.active / backend.disabled */}
            </label>
            <div className="hbg-hint">Only active categories appear in the blog form's picker and in the Blogs category filter.</div>
          </HbgField>
        </div>

        {creating ? (
          <HbgField label="Casinos (Select Multiple)" req
            hint="Select one or more casinos. A separate category will be created for each selected casino."
            error={touched && !f.skin_ids.length ? "Select at least one skin." : ""}>
            <div className="hbg-skinpick">
              {skins.map(s => {
                const on = f.skin_ids.indexOf(s.id) !== -1;
                const clash = cats.some(c => c.skin_id === s.id && c.slug === effectiveSlug);
                return (
                  <button type="button" key={s.id}
                    className={`hbg-skinchip${on ? " is-on" : ""}${on && clash ? " is-clash" : ""}`}
                    onClick={() => set("skin_ids", on ? f.skin_ids.filter(x => x !== s.id) : f.skin_ids.concat([s.id]))}>
                    <span className="hbg-skin__av">{hbgInitials(s.name)}</span>{s.name}
                    {on && clash && <span className="hbg-skinchip__warn"><Icon name="alert" size={10} /> slug taken</span>}
                    {on && !clash && <Icon name="check" size={11} />}
                  </button>
                );
              })}
            </div>
          </HbgField>
        ) : (
          <HbgField label="Casino" req hint="Select the casino this category belongs to.">
            <select className="hbg-ctl hbg-ctl--narrow" value={f.skin_id || ""} onChange={e => set("skin_id", Number(e.target.value))}>
              {skins.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </select>
            {strandedIfMoved.length > 0 && (
              <div className="hbg-banner hbg-banner--warn">
                <Icon name="alert" size={13} />
                <div>
                  Moving this category leaves <b>{strandedIfMoved.length} blog{strandedIfMoved.length === 1 ? "" : "s"}</b> behind:
                  the save never repoints the blogs already using it, so they end up in a category owned by another casino.
                </div>
              </div>
            )}
          </HbgField>
        )}
      </HbgPanel>

      <HbgPanel title="Translations" icon="globe"
        sub="One blog_category_translations row per locale (title is NOT NULL, so an empty title stores no row)">
        <Explainer compact title="How a category title resolves">
          <code>BlogCategory::getTitle()</code> tries the requested locale, then the fallback locale
          (<b>en</b>), then gives up and shows the raw slug. A category with no translation rows at
          all is therefore listed by its slug — which is exactly what the list column does.
        </Explainer>
        <HbgLangTabs active={lang} onPick={setLang} stateOf={stateOf} countLabel="languages have a title" />

        {lang === HBG_ALL_TAB ? (
          /* One short field per language, so the whole set fits on one screen — the fastest way to
             fill a new category. Same twelve inputs as the per-language tabs, nothing added. */
          <div className="hbg-alltitles">
            {HBG_LANGS.map(l => (
              <div className="hbg-alltitles__row" key={l.code}>
                <label className={`hbg-alltitles__lab is-${stateOf(l.code)}`}>
                  <i className={`hbg-dot hbg-dot--${stateOf(l.code)}`} />
                  {l.name} <code>{l.code}</code>
                </label>
                <input className="hbg-ctl" maxLength={255} value={f.t[l.code] || ""}
                  onChange={e => setTitle(l.code, e.target.value)} placeholder="Category Title" />
              </div>
            ))}
          </div>
        ) : (
          <div className="hbg-langbody">
            <div className="hbg-langbody__head">
              <b>{HBG_LANG_LABEL(lang)}</b>
              <span className={`hbg-statetag is-${stateOf(lang)}`}>{stateOf(lang) === "done" ? "translated" : "not translated"}</span>
            </div>
            <HbgField label="Title" req wide
              tip={<>The UI marks this required on every tab, but the rule is <code>nullable|string|max:255</code> — the real constraint is "a slug <b>or</b> at least one language title".</>}
              hint={lang === HBG_FALLBACK
                ? "Used as the fallback for every locale with no title of its own."
                : `If empty, falls back to the English title${f.t[HBG_FALLBACK] ? ` — "${f.t[HBG_FALLBACK]}"` : ", then to the slug"}.`}>
              <input className="hbg-ctl" maxLength={255} value={f.t[lang] || ""}
                onChange={e => setTitle(lang, e.target.value)} placeholder="Category Title" />
            </HbgField>
          </div>
        )}
      </HbgPanel>

      <div className="hbg-formbar">
        <span className="hbg-formbar__note">
          {creating
            ? `Creates ${targetSkins.length || 0} category row${targetSkins.length === 1 ? "" : "s"} · slug ${effectiveSlug ? `"${effectiveSlug}"` : "—"}.`
            : `Saves as "${effectiveSlug}" on ${hbgSkinName(skins, f.skin_id) || "—"}.`}
        </span>
        <div className="hbg-formbar__btns">
          <button className="hrs-btn hrs-btn--search" onClick={onBack}>Cancel</button>
          {/* Disabled while the save is in flight: a fan-out is N sequential writes,
              and a second click during the first would start a second fan-out over
              rows the first is still creating. */}
          <button className="hrs-btn hrs-btn--filters" onClick={save} disabled={busy}><Icon name="check" size={14} /> {busy ? "Saving…" : "Save"}</button>
        </div>
      </div>
    </HrsShell>
  );
};

/* ================================================================== *
 * Confirm / blocked dialogs (deleteConfirm() stand-in). Full-screen ≤860px.
 * ================================================================== */
const HbgConfirm = ({ title, tone, children, confirm, onClose }) => (
  <div className="bp-modal-scrim hbg-scrim" onClick={onClose}>
    <div className="bp-modal hbg-modal" onClick={e => e.stopPropagation()}>
      <div className={`hbg-modal__head${tone ? ` hbg-modal__head--${tone}` : ""}`}>
        <div className="hbg-modal__title">{title}</div>
        <button className="hbg-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hbg-modal__body">{children}</div>
      <div className="hbg-modal__foot">
        <button className="hrs-btn hrs-btn--search" onClick={onClose}>{confirm ? "Cancel" : "Close"}</button>
        {confirm && <button className="hrs-btn hrs-btn--reset" onClick={confirm.onClick}><Icon name="trash" size={13} /> {confirm.label}</button>}
      </div>
    </div>
  </div>
);

/* ================================================================== *
 * BLOG CATEGORIES LIST — GET /blog/category/
 * ================================================================== */
const HBG_CAT_FILTER_DEFAULTS = { q: "", skin: "", status: "" };

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

  const { blogs, cats, skins, truncated, loading, error, retry, feeds } = hbgUseDb();
  const save = useHrsSave(feeds);
  const [draft, setDraft] = hbgUseState(HBG_CAT_FILTER_DEFAULTS);
  const [applied, setApplied] = hbgUseState(HBG_CAT_FILTER_DEFAULTS);
  const [page, setPage] = hbgUseState(0);
  const [del, setDel] = hbgUseState(null);
  const [editing, setEditing] = hbgUseState(null);
  const [view, setView] = window.useUrlTab("/cms/blog-categories", [["list", "List", ""], ["form", "Form", "form"]], "list");

  /* blogs.category_id references — the delete guard, and the reason a category count is worth
     showing at all. Skin-matched, like BlogCategoryController L93-103 when the column exists. */
  /* THE EMBEDDED AGGREGATE, NOT A COUNT OF WHAT IS ON SCREEN.
     `blogCategories` asks PostgREST for blogs(count), so this is every post
     filed under the category — including ones beyond HBG_FETCH_MAX and ones the
     current filters exclude. Counting the fetched `blogs` array instead would
     under-report, and this number is what blocks the Delete button: a category
     that looks empty because the list is filtered is a category deleted out
     from under its posts. */
  const usage = hbgUseMemo(() => {
    const m = {};
    cats.forEach(c => { m[c.id] = c._blogCount; });
    return m;
  }, [cats]);

  const scoped = hbgUseMemo(() => cats.filter(c => {
    const q = String(applied.q || "").trim().toLowerCase();
    if (q) {
      const digits = /^\d+$/.test(q);
      const hitTr = HBG_LANGS.some(l => String(c.t[l.code] || "").toLowerCase().indexOf(q) !== -1);
      const hit = digits ? String(c.id) === q : (String(c.slug).toLowerCase().indexOf(q) !== -1 || hitTr);
      if (!hit) return false;
    }
    if (applied.skin && String(c.skin_id) !== String(applied.skin)) return false;
    return true;
  }), [cats, applied.q, applied.skin]);

  const rowsAll = hbgUseMemo(() => {
    const r = applied.status === "" ? scoped : scoped.filter(c => String(c.is_active) === String(applied.status));
    return r.slice().sort((a, b) => b.id - a.id);   /* fixed blog_categories.id DESC */
  }, [scoped, applied.status]);

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

  const openForm = (row) => { setEditing(row); setView("form"); };

  /* Same fan-out caveat as the posts: N inserts, not one transaction, and every
     brand that did and did not get the row is named.

     DIVERGENCE, and a deliberate one. isystem's edit form lets a category be
     MOVED between skins, and moving it does not move the blogs pointing at it —
     which is how a post ends up filed under another brand's category (this
     screen renders a warning for exactly that case). The schema forbids it: the
     composite FK (skin_id, category_id) means such a move would orphan every
     post under the category, so `skin_id` is not sent on update here.
     <!-- SUGGESTION: block the skin change on BlogCategoryController's edit form,
          or move the blogs with it. Silently re-parenting is neither. --> */
  const onSave = (form, targetSkins) => {
    save.run(async () => {
      const creating = form.id == null;
      const targets = creating ? targetSkins : [form.skin_id];
      const ok = [], bad = [], partial = [];

      for (const sid of targets) {
        const body = { slug: form.slug, active: !!form.is_active };
        let parentId, res;
        if (creating) {
          body.skin_id = Number(sid);
          res = await window.sb.create("blogCategories", body);
          parentId = res && res.ok && res.data ? res.data.id : null;
        } else {
          res = await window.sb.update("blogCategories", form.id, body);
          parentId = form.id;
        }
        const label = hbgSkinName(skins, sid) || ("#" + sid);
        if (!res || !res.ok || !parentId) {
          bad.push(`${label}: ${(res && res.error && res.error.message) || "refused"}`);
          continue;
        }

        const tr = await hbgSyncTr({
          resource: "blogCategoryTranslations", fkColumn: "blog_category_id", parentId,
          existing: creating ? [] : ((cats.find(c => c.id === form.id) || {})._tr || []),
          next: form.t, isCat: true,
        });
        if (tr.failed.length) partial.push(`${label} — the category saved but ${tr.failed.length} title(s) did not: ${tr.failed.join("; ")}`);
        else ok.push(`${label} (#${parentId}, ${tr.wrote} title${tr.wrote === 1 ? "" : "s"}${tr.removed ? `, ${tr.removed} removed` : ""})`);
      }

      if (bad.length || partial.length) {
        return { ok: false, error: { kind: "server", message:
          [ok.length ? `Saved: ${ok.join(", ")}.` : "Nothing saved.",
           bad.length ? `Refused: ${bad.join(" · ")}.` : "",
           partial.length ? `PARTIAL: ${partial.join(" · ")}.` : ""].filter(Boolean).join(" ") } };
      }
      return { ok: true, data: ok, meta: {} };
    }, {
      done: form.id == null
        ? `${targetSkins.length} categor${targetSkins.length === 1 ? "y" : "ies"} created`
        : `Category #${form.id} saved`,
      fail: form.id == null ? "The category was not created everywhere" : "The category was not fully saved",
    }).then(res => { if (res && res.ok) { setEditing(null); setView("list"); } });
  };

  /* SOFT delete — 042: "a category with posts under it must not vanish". The
     posts keep their category_id pointing at a soft-deleted row, which is why
     the confirm below shows the reference count first. */
  const doDelete = (row) => {
    save.run(() => window.sb.remove("blogCategories", row.id), {
      done: `Category #${row.id} deleted`,
      fail: `Category #${row.id} was not deleted`,
    }).then(() => setDel(null));
  };

  if (view === "form") {
    return <HbgCatForm row={editing} cats={cats} blogs={blogs} skins={skins} busy={save.busy}
      onBack={() => { setEditing(null); setView("list"); }} onSave={onSave} />;
  }

  const skinOpts = skins.slice().sort((a, b) => a.name.localeCompare(b.name)).map(s => ({ value: String(s.id), label: s.name }));
  const FIELDS = [
    {
      key: "q", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Slug, title or ID",
      tip: <>Matches <code>blog_categories.slug</code> and any <code>blog_category_translations.title</code>; an all-digits term is an exact id match.</>,
    },
    {
      key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "All", options: skinOpts,
      tip: <>Super admins only — a skin admin is forced to their own skin. The whole Skin column and filter are additionally wrapped in a runtime <code>Schema::hasColumn('blog_categories','skin_id')</code> guard upstream; with the committed migrations that column always exists.</>,
    },
    { key: "status", label: "Status", type: "select", icon: "eye", placeholder: "All", options: [{ value: "1", label: "Active" }, { value: "0", label: "Disabled" }] },
  ];

  const setStatus = (v) => { setDraft(d => Object.assign({}, d, { status: v })); setApplied(a => Object.assign({}, a, { status: v })); setPage(0); };

  const columns = [
    { key: "id", label: "ID", width: 84, render: r => <CopyableId value={r.id} className="hbg-id" /> },
    { key: "skin", label: "Skin", render: r => <HbgSkinCell skinId={r.skin_id} skins={skins} /> },
    { key: "slug", label: "Slug", render: r => <span className="hbg-catcell mono">{r.slug}</span> },
    {
      key: "title", label: "Title", render: r => (
        <button className="hbg-titlelink" title="Edit" onClick={(e) => { e.stopPropagation(); openForm(r); }}>
          <span className="hbg-titlelink__t">{hbgCatTitle(r)}</span>
          <span className="hbg-titlelink__s">
            {Object.keys(r.t).length === 0
              ? "no translations — falling back to the slug"
              : `${Object.keys(r.t).length} of ${HBG_LANGS.length} languages`}
          </span>
        </button>
      ),
    },
    { key: "status", label: "Status", align: "center", render: r => <HbgStatusPill on={r.is_active} onLabel="Active" offLabel="Disabled" /> },
    {
      key: "_acts", label: "Actions", align: "center", width: 110, render: r => (
        <div className="hbg-acts">
          <button className="hbg-act hbg-act--edit" title="Edit" onClick={(e) => { e.stopPropagation(); openForm(r); }}><Icon name="edit" size={13} /></button>
          <button className="hbg-act hbg-act--danger" title="Delete" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={13} /></button>
        </div>
      ),
    },
  ];

  const activeCount = scoped.filter(c => c.is_active).length;
  const blocked = del ? (usage[del.id] || 0) : 0;

  return (
    <HrsShell
      title="Blog Categories"                        /* backend.blog_categories */
      subtitle="Groups blog posts, one category row per casino"   /* label inferred */
      gate={<>Identical CMS gate to Blogs: <code>isadmin()</code> or <code>isSkinAdmin()</code> + <code>enable_cms</code>, else <code>404</code> at <code>BlogCategoryController::index</code>. </>}
      gateNote={<>Permission asymmetry, honestly: delete here checks only <code>isadmin() || isSkinAdmin()</code> — it has <b>no own-skin check</b>, unlike blog delete, so a skin admin can delete another casino's category. Sidebar visibility again admits Customer Care with <code>support_cms_banners</code>, who then 404.</>}
      explainer={{
        title: "What this screen is, in plain English",
        bullets: [
          <>A category is <b>per casino</b>, keyed <code>(skin_id, slug)</code>. Creating one fans out a row per selected casino, so "Bonus" on four brands is four category ids.</>,
          <>The title is not a column: it lives in <code>blog_category_translations</code>, one row per locale. The list shows the requested locale, then English, then the raw slug — the last case is visible below on the category with no translations.</>,
          <>A category <b>cannot be deleted while a blog points at it</b>; the delete is refused with "Cannot delete category that has blogs". Nothing here cascades.</>,
          <>Editing a category can <b>move it to another casino</b> — but the blogs already using it stay where they are, which is why the blog form labels every category "Title (SkinName)".</>,
          <>Fixed <code>blog_categories.id DESC</code>: no sortable columns, no export, no bulk actions.</>,
        ],
      }}
      actions={
        <>
          <button className="hrs-btn hrs-btn--search" onClick={() => hbgNavTo("cms-blogs")}>
            <Icon name="chevron_left" size={14} /> Back to blogs{/* label inferred */}
          </button>
          <button className="hrs-btn hrs-btn--filters" onClick={() => openForm(null)}>
            <Icon name="plus" size={14} /> New Category{/* backend.new_category */}
          </button>
        </>
      }>

      <HbgChips
        value={applied.status} onPick={setStatus}
        items={[
          { value: "", label: "All", count: scoped.length },
          { value: "1", label: "Active", count: activeCount, tone: "ok" },
          { value: "0", label: "Disabled", count: scoped.length - activeCount, tone: "muted" },
        ]} />

      <HrsFilters
        fields={FIELDS} values={draft}
        onChange={(k, v) => setDraft(d => Object.assign({}, d, { [k]: v }))}
        onSearch={(v) => { setApplied(Object.assign({}, v)); setPage(0); }}
        onReset={() => { setDraft(HBG_CAT_FILTER_DEFAULTS); setApplied(HBG_CAT_FILTER_DEFAULTS); setPage(0); }}
        resultLabel={`${hrsInt(rowsAll.length)} of ${hrsInt(cats.length)}`} />

      {truncated.length > 0 && (
        <div className="hbg-err">
          <Icon name="alert" size={13} /> There are more {truncated.join(" and ")} than the {HBG_FETCH_MAX} this
          screen fetches. The list and the search below cover the fetched rows only.
        </div>
      )}

      {loading ? <HrsSkeleton rows={6} cols={7} /> : error ? <HrsError error={error} onRetry={retry} /> : (
      <HrsTable
        columns={columns} rows={paged} rowKey="id" onRowClick={openForm}
        empty="No records."
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{hbgCatTitle(r)}</b>
              <HbgStatusPill on={r.is_active} onLabel="Active" offLabel="Disabled" />
            </div>
            <div className="hbg-card__sub mono">{r.slug}</div>
            <div className="hrs-card__grid">
              <span>ID</span><b>{r.id}</b>
              <span>Skin</span><b>{hbgSkinName(skins, r.skin_id) || "—"}</b>
              <span>Languages</span><b>{Object.keys(r.t).length} / {HBG_LANGS.length}</b>
              <span>Blogs</span><b>{usage[r.id] || 0}</b>
            </div>
            <div className="hbg-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); openForm(r); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hbg-card__del" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />

      )}

      <HrsPager page={safePage} pageSize={HBG_PER_PAGE} total={rowsAll.length} onPage={setPage} />

      {del && (blocked > 0 ? (
        <HbgConfirm title="Cannot delete" tone="warn" onClose={() => setDel(null)}>
          {/* backend.cannot_delete + backend.cannot_delete_category_with_blogs */}
          <p><b>Cannot delete category that has blogs.</b></p>
          <p className="hbg-hint">
            <b>{hbgCatTitle(del)}</b> (#{del.id}, {hbgSkinName(skins, del.skin_id) || "—"}) is referenced by{" "}
            <b>{blocked} blog{blocked === 1 ? "" : "s"}</b>. Move or delete those posts first — the
            delete endpoint refuses rather than nulling <code>blogs.category_id</code>.
          </p>
        </HbgConfirm>
      ) : (
        <HbgConfirm title="Delete category" onClose={() => setDel(null)}
          confirm={{ label: "Delete", onClick: () => doDelete(del) }}>
          <p>Delete <b>{hbgCatTitle(del)}</b> (#{del.id}) from <b>{hbgSkinName(skins, del.skin_id) || "—"}</b>?</p>
          <p className="hbg-hint">
            No blog references it. Its <code>blog_category_translations</code> rows go with it; the
            same-named category on other casinos is a separate row and is unaffected.
          </p>
        </HbgConfirm>
      ))}
    </HrsShell>
  );
};

/* Split out of the legacy src/pages/HostCmsBanners.jsx bundle, which defined stub HostCmsBlogs /
   HostCmsBlogCategories components alongside four other CMS screens; this file loads after the
   bundle's successors and owns those two globals. app.jsx renders them for route keys "cms-blogs"
   and "cms-blog-cat" — URLs /cms/blogs and /cms/blog-categories. */
window.HostCmsBlogs = HostCmsBlogs;
window.HostCmsBlogCategories = HostCmsBlogCategories;
