// Represents: GET /helpcategories · HelpCategoriesController; GET /helppages · HelpPagesController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "Help categories + pages"
/* CMS ▾ → Help categories + Help pages — the two screens behind the site's Help/FAQ footer.
   Split out of the legacy CMS bundle src/pages/HostCmsBanners.jsx, which carried thin
   HostCmsHelpCategories / HostCmsHelpPages stubs alongside Slideshow, Blogs and Blog categories.
   That bundle was being split in parallel and is now src/pages/HostCmsBannersNew.jsx, which no
   longer defines either component — so these are the only definitions left. index.html still
   loads this file AFTER it, so the ordering holds either way. Neither bundle was edited here.

   Real screens
   -----------------------------------------------------------------------------------------
   Help categories · GET /helpcategories/ (unnamed route, routes/admin.php:1156-1158) →
     HelpCategoriesController::index L14. Siblings: getCategoriesTable L51 (legacy DataTables
     JSON, orphaned — no current view calls it), helpcategoryForm L164 (GET /helpcategories/form),
     saveHelpcategory L182 (POST /helpcategories/saveCategory?id=), delete L241
     (GET /helpcategories/delete/{id}/, routes/admin.php:1602-1604).
     Views: admin/helpcategories/index.blade.php + _paybo-head + modals/helpcategory.blade.php
     (generaModalGestione → admin/utils/modal.blade.php) + forms/helpcategory.blade.php.
     Model App\Models\HelpCategory → table `helpcategories` (unix addedTime/updateTime).

   Help pages · GET /helppages/ (unnamed, routes/admin.php:1174-1176) → HelpPagesController::index
     L15. Siblings: getPagesTable L60 (orphaned DataTables JSON), helppageForm L265,
     saveHelppage L283 (POST /helppages/savePage?id=), delete L366 (GET /helppages/delete/{id}/,
     routes/admin.php:1605-1607). Views: admin/helppages/index.blade.php + _paybo-head +
     modals/helppage.blade.php + forms/helppage.blade.php (TinyMCE 5.0.16 from cdnjs).
     Model App\Models\HelpPage → table `helppages`. Frontend consumer: ::getHelpPages L380.

   Three facts drive this UI
   -----------------------------------------------------------------------------------------
   1. GLOBAL DATA. Neither table has a `skinid` column, so one help tree is shared by every skin.
      Per-skin values reach the text through PLACEHOLDER TAGS substituted at frontend render time
      (helpPagesTags(), HelpPagesController L162-236) — which is why the tag legend is not
      decoration but the only way an operator knows what can be personalised.
   2. FOOTER VISIBILITY IS CATEGORY-LEVEL. `helppages` has NO status column at all; getHelpPages()
      (L391) selects categories with show_online = 1 AND type IN ('link','contacts'), ordered by
      helpcategories.priority DESC (the unqualified `priority` in that query resolves to the
      CATEGORY, since helppages has no priority column).
   3. …and `type` is NOT editable in the admin form, so every category created through this screen
      is stored with type '' and can never appear in a footer. See the divergence below.

   Known-bug / trap policy (CLAUDE.md "Build policies")
   -----------------------------------------------------------------------------------------
   - TRAP — non-editable `type` → permanently empty footers (reference note (3) on Help categories).
     DIVERGENCE: the category form here EXPOSES `type` with the two values the frontend actually
     consumes ('link' / 'contacts'), and the list flags every category that is marked Visible but
     still blocked by an empty type. The real form omits the field entirely.
     // <!-- SUGGESTION: add `type` to admin/helpcategories/forms/helpcategory.blade.php as a select of the two values getHelpPages() accepts ('link', 'contacts') and add it to saveHelpcategory's $datip. Today a category created in the back office is written with type '' and is invisible on every skin's footer forever — the only way to publish one is a manual UPDATE on helpcategories. -->
     UNCLEAR (reference records it too): whether production sets `type` by hand in the DB or whether
     a UI for it was removed. The seed below assumes by hand — 4 of 15 categories carry a value.
   - BUG — the placeholder-tag legend's captions are shifted in the legacy markup
     (HelpPagesController L182-192): [dominio-skin] is captioned "Email info", [email-info]
     "Email documenti", [email-documenti] "Dominio skin". Evident intent implemented: each tag is
     captioned with its own meaning (HHP_TAGS below).
     // <!-- SUGGESTION: fix the three swapped captions in HelpPagesController::helpPagesTags() L182-192 — an operator following the legend today writes [dominio-skin] where they wanted the info mailbox, and the mistake only surfaces on the live footer. -->
   - MISMATCH — `name_pt` on a help page is marked required by the form (obbligatorio()) but
     saveHelppage never validates it (L310). The form's own declaration is treated as the intent:
     required here, with the gap disclosed in the field hint.
     // <!-- SUGGESTION: either validate name_pt in saveHelppage alongside name / name_it / name_es, or drop its required marker from forms/helppage.blade.php — right now the form promises a check the server does not make, so a page can be saved with an empty PT name. -->
   - Faithfully reproduced, NOT "fixed": Search matches `name` OR `name_it` only on both screens
     (name_pt / name_es are never searched). The reference states this as behaviour, not as a bug,
     so it is implemented as-is and disclosed in the filter's tip.
     // <!-- SUGGESTION: extend both search clauses to name_pt (categories) and name_pt + name_es (pages). The columns are editable and displayed, so an operator reasonably expects to find a page by its Spanish name. -->
   - Delete is a CSRF-unprotected GET on both screens, and category delete does NOT cascade —
     HelpCategoriesController::delete L241-252 removes the row only, leaving its help pages with a
     dangling `category` id. Reproduced honestly: the delete dialog counts the pages that will be
     orphaned, and those pages then render "—" in the Category column of the Help pages screen.
     // <!-- SUGGESTION: make both deletes POST/DELETE with the CSRF token, and have HelpCategoriesController::delete either block while helppages still reference the category or reassign/remove them in a transaction. -->

   Deliberate omissions (present in the reference, intentionally not rendered)
   -----------------------------------------------------------------------------------------
   - stati() (0 Disabled / 1 Active, HelpCategoriesController L40 / HelpPagesController L49) and
     tipologieHelpcategories() / tipologieHelpPages() ('default' / 'slick') are defined but never
     used on these screens — no status column, no default/slick picker.
   - No sortable columns: both lists are a fixed ORDER BY id DESC. The legacy get*Table endpoints
     supported sorting, but they are dead code, so no sort affordance is offered.
   - No export (neither screen has one), no bulk actions (n/a), no KPI strip — the only total the
     real page shows is the filter-hero "Results" card, which is the HrsFilters resultLabel here.
   - `name_pt` is NOT a column on the Help pages list (it exists in the DB and in the form only);
     `addedByUser` / `updatedByUser` / `name_generico` are never exposed.
   - A third table `helpsubcategories` (App\Models\HelpSubcategory) has a migration but no route,
     controller or UI — dormant, so nothing here references it.

   Label policy: `backend.search`, `backend.results`, `backend.apply`, `backend.no_records`,
   `backend.showing`, `backend.prev`, `backend.next`, `backend.delete` are all missing from
   public/default-lang/en/backend.php and render as raw keys on the real screen; operator-facing
   English is written here and marked with a "label inferred" JSX comment. */

const { useState: hhpUseState, useMemo: hhpUseMemo, useRef: hhpUseRef } = React;

/* The FNV-1a hash and the mulberry32 it seeded are GONE, not marked as
   exempt. Unlike the FAQ and Blogs screens — which still hash a stored path to
   pick a colour for an image placeholder — nothing on this screen has a
   presentational use for either. They existed only to generate thirty-seven
   sport-rules pages and their bodies, so they leave with the seed data. */
/* paginate(25) on both controllers (categories L35, pages L42). Server-rendered Prev/Next — the
   page size is not operator-selectable, so HrsPager gets no onPageSize. */
const HHP_PAGE_SIZE = 25;

/* The four languages this feature stores. Categories have EN/IT/PT names only — there is no ES
   name at category level (reference note (5)); pages have all four names and all four bodies. */
const HHP_LANGS = [
  { id: "en", label: "EN", full: "English", nameCol: "name", bodyCol: "content" },
  { id: "it", label: "IT", full: "Italiano", nameCol: "name_it", bodyCol: "content_it" },
  { id: "pt", label: "PT", full: "Português", nameCol: "name_pt", bodyCol: "content_pt" },
  { id: "es", label: "ES", full: "Español", nameCol: "name_es", bodyCol: "content_es" },
];

/* The only two `type` values getHelpPages() (HelpPagesController L391) accepts. The legacy
   tipologieHelpcategories() enum ('default' / 'slick') is NOT what the footer reads. */
const HHP_TYPE_OPTIONS = [
  { value: "", label: "— not set" },
  { value: "link", label: "link" },
  { value: "contacts", label: "contacts" },
];

/* ------------------------------------------------------------------ *
 * Placeholder tags — helpPagesTags(), HelpPagesController L162-236.
 * Captions are the corrected meanings (see the swapped-caption bug at
 * the top of this file); the Italian originals are label-inferred into
 * operator English.
 * ------------------------------------------------------------------ */
const HHP_TAGS = [
  { tag: "[nome-skin]", cap: "Skin name" },
  { tag: "[nome-societa]", cap: "Company name" },
  { tag: "[email-assistenza]", cap: "Support email" },
  { tag: "[dominio-skin]", cap: "Skin domain", fixed: "captioned “Email info” in the legacy legend" },
  { tag: "[email-info]", cap: "Info email", fixed: "captioned “Email documenti” in the legacy legend" },
  { tag: "[email-documenti]", cap: "Documents email", fixed: "captioned “Dominio skin” in the legacy legend" },
  { tag: "[sede-legale]", cap: "Registered office" },
  { tag: "[numero-licenza]", cap: "Licence number" },
  { tag: "[responsabile]", cap: "Responsible person" },
  { tag: "[indirizzo-responsabile]", cap: "Responsible person's address" },
  { tag: "[company-number]", cap: "Company number" },
  { tag: "[payment-processor]", cap: "Payment processor" },
  { tag: "[regolamento-licenza]", cap: "Licence regulation" },
];
/* helpPagesTags() also appends one dynamic entry per configured skin field. The pattern is shown
   rather than invented keys, because which fields exist is per-installation. */
const HHP_DYNAMIC_TAG = "[field-<key>]";

const HHP_TAG_RE = new RegExp(`(${HHP_TAGS.map(t => t.tag.replace(/[[\]]/g, "\\$&")).join("|")}|\\[field-[a-z0-9_-]+\\])`, "gi");
/* Wraps every recognised tag so the preview shows what will be substituted per skin. */
const hhpMarkTags = (html) => String(html || "").replace(HHP_TAG_RE, '<span class="hhp-ph">$1</span>');

/* ------------------------------------------------------------------ *
 * Mock data — `helpcategories`. Values fake, shape real: id, name,
 * name_it, name_pt, priority, type, show_online. Ids skip 9 (a
 * "Payments" category deleted at some point) so the orphaned-pages
 * behaviour of the non-cascading delete is visible in the seed itself.
 * ------------------------------------------------------------------ */
const HHP_CAT_SEED = [
  { id: 1, name: "Terms and Conditions", name_it: "Termini e condizioni", name_pt: "Termos e condições", priority: 90, type: "link", show_online: 1 },
  { id: 2, name: "Privacy Policy", name_it: "Informativa sulla privacy", name_pt: "Política de privacidade", priority: 85, type: "link", show_online: 1 },
  { id: 3, name: "Responsible Gaming", name_it: "Gioco responsabile", name_pt: "Jogo responsável", priority: 80, type: "link", show_online: 1 },
  { id: 4, name: "Getting Started", name_it: "Come iniziare", name_pt: "Como começar", priority: 70, type: "", show_online: 1 },
  { id: 5, name: "Bonus Terms", name_it: "Condizioni bonus", name_pt: "Termos de bónus", priority: 65, type: "", show_online: 1 },
  { id: 6, name: "Casino Rules", name_it: "Regolamento casinò", name_pt: "Regras do casino", priority: 60, type: "", show_online: 1 },
  { id: 7, name: "Complaints", name_it: "Reclami", name_pt: "Reclamações", priority: 55, type: "link", show_online: 0 },
  { id: 8, name: "Affiliate Program", name_it: "Programma affiliati", name_pt: "Programa de afiliados", priority: 50, type: "", show_online: 0 },
  { id: 10, name: "Money Laundering", name_it: "Norma Antiriciclaggio", name_pt: "Branqueamento de capitais", priority: 45, type: "", show_online: 1 },
  { id: 11, name: "Contact Us", name_it: "Contattaci", name_pt: "Contacte-nos", priority: 40, type: "contacts", show_online: 1 },
  { id: 12, name: "Cashier", name_it: "Cassa", name_pt: "Caixa", priority: 35, type: "", show_online: 1 },
  { id: 13, name: "My Account", name_it: "Account", name_pt: "A minha conta", priority: 30, type: "", show_online: 1 },
  { id: 14, name: "AML & KYC", name_it: "Norma Antiriciclaggio", name_pt: "AML e KYC", priority: 25, type: "", show_online: 1 },
  { id: 15, name: "Sport Rules and Betting Options Pregame", name_it: "Regole sportive e opzioni di scommessa Pregame", name_pt: "Regras desportivas e opções de aposta Pré-jogo", priority: 20, type: "", show_online: 1 },
  { id: 16, name: "Sport Rules and betting Options Live", name_it: "Regole sportive e opzioni di scommessa Live", name_pt: "Regras desportivas e opções de aposta Ao vivo", priority: 15, type: "", show_online: 1 },
];

/* ------------------------------------------------------------------ *
 * THE DATA. This screen carried its own database too — and a bigger one
 * than either of its siblings.
 *
 * `hhpStore` built fifteen categories by hand and then GENERATED the pages:
 * thirty Pregame sport rules and seven Live ones from a table of sports in four
 * languages, each page's body assembled by `hhpBody` from a lead, one or two
 * randomly chosen middle paragraphs and a tail, all seeded on the page id so
 * every reload produced the same text. Both routes shared the store, so a
 * category deleted on one screen really did orphan its pages on the other —
 * a faithful reproduction of a cascade the platform does not perform.
 *
 * Replaced by reads of `help_pages` and `help_categories` with their
 * translations embedded. Everything downstream still sees the flat-column shape
 * the two forms were written against (`name`, `name_it`, `content_pt` …), so the
 * conversion here is the only code that knows the storage differs.
 *
 * THREE MAPPINGS WORTH STATING, because the schema is not a copy of isystem's:
 *
 *   type       -> placement, and 006 constrains it to link/contacts/none where
 *                 isystem stored '' for "not set". Empty becomes 'none'.
 *   show_online -> visible, a real boolean rather than 0/1.
 *   (nothing)  -> slug, which is NOT NULL UNIQUE here and does not exist
 *                 upstream. Minted from the English name on create; never
 *                 regenerated on edit, because a slug is an address.
 * ------------------------------------------------------------------ */

const HHP_FETCH_MAX = 500;

/* Str::slug, and the same de-dup ladder the sibling screens use. `help_pages`
   and `help_categories` both carry a GLOBAL unique index on slug — not
   per-tenant, since there is no tenant — so the check is across everything. */
const hhpSlugify = (v) => String(v || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "")
  .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 200);
const hhpUniqueSlug = (rows, base, excludeId) => {
  const root = hhpSlugify(base) || "page";
  const taken = (rows || []).filter(r => String(r.id) !== String(excludeId)).map(r => r.slug);
  if (taken.indexOf(root) === -1) return root;
  for (let i = 1; i < 500; i++) if (taken.indexOf(`${root}-${i}`) === -1) return `${root}-${i}`;
  return root;
};

const hhpTrGet = (rows, code, field) => {
  const r = (rows || []).find(x => String(x.locale_code || "").toLowerCase() === code);
  return r ? String(r[field] || "") : "";
};

const hhpCatFromDb = (r) => ({
  id: r.id,
  slug: r.slug || "",
  name: hhpTrGet(r.translations, "en", "name"),
  name_it: hhpTrGet(r.translations, "it", "name"),
  name_pt: hhpTrGet(r.translations, "pt", "name"),
  priority: r.priority || 0,
  /* 'none' is this schema's word for isystem's empty string; the form's
     "— not set" option keeps the upstream spelling. */
  type: r.placement === "none" ? "" : (r.placement || ""),
  show_online: r.visible ? 1 : 0,
  _tr: r.translations || [],
});

const hhpPageFromDb = (r) => ({
  id: r.id,
  slug: r.slug || "",
  category: r.category_id,
  generic_name: r.generic_name || "",
  position: r.position || 0,
  name: hhpTrGet(r.translations, "en", "name"),
  name_it: hhpTrGet(r.translations, "it", "name"),
  name_pt: hhpTrGet(r.translations, "pt", "name"),
  name_es: hhpTrGet(r.translations, "es", "name"),
  /* content_en lives in the column `content` upstream (the form field is
     content_en); the other three keep their own names. Preserved so both forms
     and every renderer keep working unchanged. */
  content: hhpTrGet(r.translations, "en", "content"),
  content_it: hhpTrGet(r.translations, "it", "content"),
  content_pt: hhpTrGet(r.translations, "pt", "content"),
  content_es: hhpTrGet(r.translations, "es", "content"),
  _tr: r.translations || [],
});

/* Both lists, shared by the two routes. A category deleted on one screen is
   gone from the other after its refetch — which is what the module store was
   for, asked of the database instead. */
const hhpUseDb = () => {
  const catsFeed = useHrsFetch(() => window.sb.list("helpCategories", { limit: HHP_FETCH_MAX }), []);
  const pagesFeed = useHrsFetch(() => window.sb.list("helpPages", { limit: HHP_FETCH_MAX }), []);

  const cats = hhpUseMemo(() => (catsFeed.data || []).map(hhpCatFromDb), [catsFeed.data]);
  const pages = hhpUseMemo(() => (pagesFeed.data || []).map(hhpPageFromDb), [pagesFeed.data]);

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

  return {
    cats, pages, truncated,
    loading: catsFeed.loading || pagesFeed.loading,
    error: catsFeed.error || pagesFeed.error,
    retry: () => { catsFeed.retry(); pagesFeed.retry(); },
    feeds: [catsFeed, pagesFeed],
  };
};

/* Reconcile one parent's translations. Same contract as the FAQ and Blog
   versions: INSERT what is new, UPDATE what changed, DELETE what was emptied —
   the third being the one a naive save forgets, leaving a language the operator
   cleared still served to players. */
const hhpSyncTr = async ({ resource, fkColumn, parentId, existing, values, 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 HHP_LANGS) {
    /* Categories have EN/IT/PT names and no ES one — reference note (5). Not a
       gap to fill in: a Spanish category name is a field the platform has no
       input for, and inventing the row would put text nobody typed in front of
       players. */
    if (isCat && l.id === "es") continue;
    const prev = have.get(l.id);
    const name = String((values.names || {})[l.id] || "").trim();
    const content = isCat ? "" : String((values.bodies || {})[l.id] || "");
    const keep = isCat ? !!name : !!(name || content.trim());
    const body = isCat ? { name } : { name: name || null, content: content || 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: l.id }, body));
      if (res && res.ok) wrote++;
      else failed.push(`${l.full}: ${(res && res.error && res.error.message) || "refused"}`);
    } else if (prev && prev.id) {
      const res = await window.sb.remove(resource, prev.id);
      if (res && res.ok) removed++;
      else failed.push(`${l.full} (removal): ${(res && res.error && res.error.message) || "refused"}`);
    }
  }
  return { wrote, removed, failed };
};

/* getHelpPages() L391: a category reaches a frontend footer only when show_online = 1 AND
   type IN ('link','contacts'). Pages inherit this — they have no visibility flag of their own. */
const hhpFooterOk = (c) => !!c && c.show_online === 1 && (c.type === "link" || c.type === "contacts");
const hhpCatById = (cats, id) => cats.find(c => c.id === Number(id)) || null;
const hhpPageCount = (pages, catId) => pages.filter(p => Number(p.category) === Number(catId)).length;

/* The header cross-links ("Help Pages" ⇄ "Categories") are plain anchors on the real screens.
   app.jsx resolves `active` from the URL on popstate, so pushState + a popstate event is the
   in-app equivalent; if the route table is unavailable the link degrades to a toast. */
const hhpGoto = (routeKey, label) => {
  try {
    const path = window.pathForActive && window.pathForActive(routeKey);
    if (path) {
      if (window.location.pathname !== path) window.history.pushState({ active: routeKey }, "", path);
      window.dispatchEvent(new PopStateEvent("popstate"));
      return;
    }
  } catch (_e) { /* fall through to the toast */ }
  hrsToast(`Open ${label}`, `Route "${routeKey}" is not registered in src/routes.jsx.`);
};

/* ------------------------------------------------------------------ *
 * Shared bits
 * ------------------------------------------------------------------ */
const HhpModal = ({ title, sub, onClose, children, footer, wide }) => (
  <div className="bp-modal-scrim hhp-scrim" onClick={onClose}>
    <div className={`bp-modal hhp-modal${wide ? " hhp-modal--wide" : ""}`} onClick={e => e.stopPropagation()}>
      <div className="hhp-modal__head">
        <div>
          <div className="hhp-modal__title">{title}</div>
          {sub && <div className="hhp-modal__sub">{sub}</div>}
        </div>
        <button className="hhp-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hhp-modal__body">{children}</div>
      {footer && <div className="hhp-modal__foot">{footer}</div>}
    </div>
  </div>
);

/* `show_online` chip (index.blade.php renders Yes / No). The warning marker is the trap made
   visible: the flag says Yes but the empty `type` still keeps the category out of every footer. */
const HhpVisibleChip = ({ cat }) => (
  <span className="hhp-viscell">
    <span className={`chip ${cat.show_online ? "chip--ok" : "chip--neutral"} hhp-chip`}>{cat.show_online ? "Yes" : "No"}</span>
    {cat.show_online === 1 && !hhpFooterOk(cat) && (
      <span className="hhp-flag">
        <Icon name="alert" size={12} />
        <Tip size={12}>
          Marked visible, but <b>not reachable from any footer</b>: the frontend reader selects categories with
          <code> show_online = 1</code> <b>and</b> <code>type IN ('link','contacts')</code>, and this row's
          <code> type</code> is {cat.type ? <code>{cat.type}</code> : <>empty</>}. The real admin form cannot set
          <code> type</code> at all — it is editable here as a deliberate divergence.
        </Tip>
      </span>
    )}
  </span>
);

/* Category cell on the Help pages list: chip, or "—" when the parent row is gone. */
const HhpCatCell = ({ cat, rawId }) => {
  if (!cat) return (
    <span className="hhp-viscell">
      <span className="hhp-dash">—</span>
      <span className="hhp-flag hhp-flag--err">
        <Icon name="alert" size={12} />
        <Tip size={12}>
          Orphan: <code>helppages.category = {rawId}</code> points at a category row that no longer exists.
          Deleting a category does not cascade (<code>HelpCategoriesController::delete</code> L241-252), so its
          pages survive unreachable — no footer can ever render them.
        </Tip>
      </span>
    </span>
  );
  return (
    <span className="hhp-viscell">
      <span className="chip chip--neutral hhp-chip">{cat.name}</span>
      {!hhpFooterOk(cat) && (
        <span className="hhp-flag">
          <Icon name="alert" size={12} />
          <Tip size={12}>
            Not published: a help page has no status of its own, it inherits its category's.
            <b> {cat.name}</b> is {cat.show_online ? <>visible but typed <code>{cat.type || "''"}</code></> : <>hidden (<code>show_online = 0</code>)</>},
            so this page renders on no skin.
          </Tip>
        </span>
      )}
    </span>
  );
};

/* ------------------------------------------------------------------ *
 * Placeholder-tag legend. The real form shows it read-only above the
 * editors (helpPagesTags() L162-236). Kept in the same place, but the
 * entries are clickable: an operator must be able to see WHICH tags
 * exist and get one into the body without retyping it by hand.
 * ------------------------------------------------------------------ */
const HhpTagPalette = ({ onInsert, disabled }) => (
  <div className="hhp-tags">
    <div className="hhp-tags__head">
      <span><Icon name="tag" size={12} /> Placeholder tags{/* label inferred */}</span>
      <span className="hhp-tags__count">{HHP_TAGS.length} fixed + dynamic</span>
    </div>
    <div className="hhp-tags__list">
      {HHP_TAGS.map(t => (
        <button key={t.tag} type="button" className="hhp-tag" disabled={disabled}
          title={disabled ? "Switch the editor to Source to insert a tag" : `Insert ${t.tag}`}
          onClick={() => onInsert(t.tag)}>
          <code className="hhp-tag__code">{t.tag}</code>
          <span className="hhp-tag__cap">
            {t.cap}
            {/* The three captions the legacy legend swaps — see the known-bug note at the top. */}
            {t.fixed && <Tip size={11}>Caption corrected: {t.fixed} (HelpPagesController L182-192).</Tip>}
          </span>
        </button>
      ))}
      <button type="button" className="hhp-tag hhp-tag--dyn" disabled title="Generated per installation">
        <code className="hhp-tag__code">{HHP_DYNAMIC_TAG}</code>
        <span className="hhp-tag__cap">
          Dynamic
          <Tip size={11}>
            <code>helpPagesTags()</code> appends one <code>[field-&lt;key&gt;]</code> entry per skin field configured on the
            platform, so the exact list differs per installation. The pattern is shown rather than invented keys.
          </Tip>
        </span>
      </button>
    </div>
    <div className="hhp-tags__note">
      Tags are substituted when the <b>frontend</b> renders the page, per skin — which is how one global help tree
      shows the right company, licence and mailbox on every brand. They stay raw in this editor and in its preview.
    </div>
  </div>
);

/* ------------------------------------------------------------------ *
 * Body editor. The real field is a TinyMCE 5.0.16 textarea (height 200,
 * no menubar, plugins advlist/autolink/lists/link/image/…/code, toolbar
 * carrying the `code` button). The exact toolbar string is not recorded
 * in the reference, so no formatting buttons are invented here: the
 * documented Source view is paired with a live preview instead.
 * ------------------------------------------------------------------ */
const HhpRichText = ({ value, onChange, mode, onMode, taRef, invalid, id }) => (
  <div className={`hhp-ed${invalid ? " hhp-ed--err" : ""}`}>
    <div className="hhp-ed__bar">
      <div className="hhp-ed__modes">
        {[["source", "Source"], ["preview", "Preview"]].map(([m, lab]) => (
          <button key={m} type="button" className={`hhp-mode${mode === m ? " hhp-mode--on" : ""}`} onClick={() => onMode(m)}>{lab}</button>
        ))}
      </div>
      <span className="hhp-ed__count">{String(value || "").length} chars</span>
    </div>
    {mode === "source" ? (
      <textarea id={id} ref={taRef} className="hhp-ed__ta" spellCheck={false}
        value={value || ""} onChange={e => onChange(e.target.value)} />
    ) : (
      <div className="hhp-ed__prev" dangerouslySetInnerHTML={{ __html: hhpMarkTags(value) }} />
    )}
  </div>
);

/* ------------------------------------------------------------------ *
 * Help category — create / edit. One shared modal form for both, opened
 * with id=0 for new (forms/helpcategory.blade.php). Inline validation
 * only; saveHelpcategory L199-225 answers ajaxError(message, campierrati).
 * ------------------------------------------------------------------ */
const HhpCategoryForm = ({ cat, pageCount, busy, onClose, onSave }) => {
  const isNew = !cat;
  const [f, setF] = hhpUseState(() => ({
    name: cat ? cat.name : "",
    name_it: cat ? cat.name_it : "",
    name_pt: cat ? cat.name_pt : "",
    priority: cat ? String(cat.priority) : "0",     // form default 0, min="0", no server validation
    show_online: cat ? cat.show_online === 1 : true, // DB default true (migration L23)
    type: cat ? cat.type : "",
  }));
  const [errs, setErrs] = hhpUseState({});
  const [banner, setBanner] = hhpUseState("");
  const set = (k, v) => { setF(x => Object.assign({}, x, { [k]: v })); setErrs(e => { const n = Object.assign({}, e); delete n[k]; return n; }); setBanner(""); };

  const save = () => {
    /* saveHelpcategory L199-210: name, name_it, name_pt each required-not-empty. Nothing else is
       validated — priority and show_online are written straight through. */
    const e = {};
    if (!String(f.name).trim()) e.name = "Insert name";                     // backend.insert_name
    if (!String(f.name_it).trim()) e.name_it = "Insert name [IT]";          // label inferred
    if (!String(f.name_pt).trim()) e.name_pt = "Insert name [PT]";          // label inferred
    setErrs(e);
    const first = ["name", "name_it", "name_pt"].map(k => e[k]).filter(Boolean)[0];
    if (first) { setBanner(first); return; }
    /* CLOSE ON SUCCESS, NOT ON SUBMIT. This used to call onSave() and onClose()
       back to back, so the modal was gone before the write had been attempted —
       and when it failed, the operator got a toast about a save that no longer
       had a form to go back to, with everything they had typed discarded. */
    Promise.resolve(onSave({
      id: isNew ? null : cat.id,
      name: f.name.trim(), name_it: f.name_it.trim(), name_pt: f.name_pt.trim(),
      priority: Math.max(0, parseInt(f.priority || "0", 10) || 0),
      show_online: f.show_online ? 1 : 0,
      type: f.type,
    })).then(res => {
      if (res && res.ok) onClose();
      else if (res && res.error) setBanner(res.error.message);
    });
  };

  const willShow = f.show_online && (f.type === "link" || f.type === "contacts");

  return (
    <HhpModal wide onClose={onClose}
      title={isNew ? <>New Category{/* label inferred */}</> : `Edit ${cat.name}`}
      sub={isNew ? "helpcategories · global, shared by every skin" : `helpcategories.id ${cat.id} · ${pageCount} page${pageCount === 1 ? "" : "s"}`}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Close</button>
        {/* A save here is a parent write followed by up to four translation writes.
            Disabled while it is in flight so a second click cannot start a second
            sequence over rows the first is still creating. */}
        <button className="btn btn--primary" onClick={save} disabled={busy}><Icon name="check" size={13} /> {busy ? "Saving…" : "Save"}</button>
      </>}>

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

      <div className="hhp-sectitle">Names</div>{/* label inferred — the real modal has no section headers */}
      <div className="hhp-grid3">
        {[["name", "Name [EN]"], ["name_it", "Name [IT]"], ["name_pt", "Name [PT]"]].map(([k, lab]) => (
          <div className="hhp-field" key={k}>
            <label className="hhp-label" htmlFor={`hhp-c-${k}`}>{lab} <span className="hhp-req">*</span></label>
            <input id={`hhp-c-${k}`} className={`input${errs[k] ? " hhp-invalid" : ""}`} style={{ width: "100%" }}
              autoFocus={k === "name"} value={f[k]} onChange={e => set(k, e.target.value)} />
            {errs[k] && <div className="hhp-fielderr">{errs[k]}</div>}
          </div>
        ))}
      </div>
      <div className="hhp-hint">
        A category has no Spanish name — <code>helpcategories</code> stores EN/IT/PT only, while its pages also carry
        an ES name and an ES body. Search on the list matches <b>EN and IT only</b>.
      </div>

      <div className="hhp-sectitle">Frontend visibility</div>{/* label inferred */}
      <div className="hhp-grid2">
        <div className="hhp-field">
          <label className="hhp-label" htmlFor="hhp-c-priority">Priority</label>
          <input id="hhp-c-priority" type="number" min="0" className="input" style={{ width: "100%" }}
            value={f.priority} onChange={e => set("priority", e.target.value)} />
          <div className="hhp-hint">Orders the categories on the frontend (<code>ORDER BY priority DESC</code>), not this list — the back-office table is always <code>id DESC</code>. Not validated server-side.</div>
        </div>
        <div className="hhp-field">
          {/* KNOWN TRAP — DIVERGENCE: `type` does not exist in the real form. Exposed here per the
              known-bug policy, because without it every category saved from the back office is
              written with type '' and is invisible on every skin forever. */}
          <label className="hhp-label" htmlFor="hhp-c-type">
            Type <span className="hhp-badge">not in the real form</span>
          </label>
          <select id="hhp-c-type" className="select" style={{ width: "100%" }} value={f.type} onChange={e => set("type", e.target.value)}>
            {HHP_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
          </select>
          <div className="hhp-hint">
            The only two values <code>getHelpPages()</code> accepts. The legacy <code>tipologieHelpcategories()</code>
            enum (<code>default</code> / <code>slick</code>) is <b>not</b> what the footer reads. On the real platform this
            column can only be set with a manual DB update.
          </div>
        </div>
      </div>

      <label className="hhp-check">
        <input type="checkbox" checked={f.show_online} onChange={e => set("show_online", e.target.checked)} />
        <span>Visible online</span>{/* hardcoded label in the real form */}
      </label>

      <div className={`hhp-trap${willShow ? " hhp-trap--ok" : ""}`}>
        <Icon name={willShow ? "check" : "alert"} size={13} />
        {willShow
          ? <span>Reachable: <code>show_online = 1</code> and <code>type = '{f.type}'</code> — this category and its pages will render in the footer, ordered by priority {Math.max(0, parseInt(f.priority || "0", 10) || 0)}.</span>
          : <span>Not reachable from any footer with these settings — the frontend needs <code>show_online = 1</code> <b>and</b> <code>type</code> set to <code>link</code> or <code>contacts</code>. Its pages inherit that, so they stay unpublished too.</span>}
      </div>
    </HhpModal>
  );
};

/* ------------------------------------------------------------------ *
 * Help page — create / edit. Same fields as forms/helppage.blade.php,
 * regrouped into one tab per language so the four names and four bodies
 * stay side by side with the tag legend instead of stacking into eight
 * scroll-lengths. Nothing added, nothing dropped.
 * ------------------------------------------------------------------ */
const HhpPageForm = ({ page, cats, busy, onClose, onSave }) => {
  const isNew = !page;
  const [f, setF] = hhpUseState(() => ({
    name: page ? page.name : "", name_it: page ? page.name_it : "",
    name_pt: page ? page.name_pt : "", name_es: page ? page.name_es : "",
    category: page ? String(page.category) : "",
    content: page ? page.content : "", content_it: page ? page.content_it : "",
    content_pt: page ? page.content_pt : "", content_es: page ? page.content_es : "",
  }));
  const [lang, setLang] = hhpUseState("en");
  const [mode, setMode] = hhpUseState("source");
  const [errs, setErrs] = hhpUseState({});
  const [banner, setBanner] = hhpUseState("");
  const taRef = hhpUseRef(null);

  const set = (k, v) => { setF(x => Object.assign({}, x, { [k]: v })); setErrs(e => { const n = Object.assign({}, e); delete n[k]; return n; }); setBanner(""); };
  const L = HHP_LANGS.find(x => x.id === lang);

  /* Insert at the caret of the body currently on screen (Source view only — the preview has no
     caret). Mirrors what the read-only legend forces operators to do by hand today. */
  const insertTag = (tag) => {
    const cur = String(f[L.bodyCol] || "");
    const ta = taRef.current;
    if (!ta) { set(L.bodyCol, cur + tag); return; }
    const s = ta.selectionStart == null ? cur.length : ta.selectionStart;
    const e = ta.selectionEnd == null ? s : ta.selectionEnd;
    set(L.bodyCol, cur.slice(0, s) + tag + cur.slice(e));
    setTimeout(() => { try { ta.focus(); ta.setSelectionRange(s + tag.length, s + tag.length); } catch (_x) {} }, 0);
  };

  const save = () => {
    /* saveHelppage L300-350: name, name_it, name_es, category, content(EN), content_it,
       content_pt, content_es required. name_pt is assigned but never validated — the form marks it
       required, and that declared intent is what is enforced here. */
    const e = {};
    if (!String(f.name).trim()) e.name = "Insert name";                       // backend.insert_name
    if (!String(f.name_it).trim()) e.name_it = "Insert name [IT]";            // label inferred
    if (!String(f.name_pt).trim()) e.name_pt = "Insert name [PT]";            // label inferred
    if (!String(f.name_es).trim()) e.name_es = "Insert name [ES]";            // label inferred
    if (!String(f.category)) e.category = "Select category";                  // backend.select_category
    HHP_LANGS.forEach(l => { if (!String(f[l.bodyCol] || "").trim()) e[l.bodyCol] = "Insert content"; }); // backend.insert_content
    setErrs(e);
    const order = ["name", "name_it", "name_pt", "name_es", "category", "content", "content_it", "content_pt", "content_es"];
    const firstKey = order.filter(k => e[k])[0];
    if (firstKey) {
      setBanner(e[firstKey]);
      const hit = HHP_LANGS.find(l => l.nameCol === firstKey || l.bodyCol === firstKey);
      if (hit) setLang(hit.id);
      return;
    }
    /* Same as the category form: the modal stays open until the write lands, so
       a refusal leaves the operator's work on screen instead of throwing it
       away behind a toast. */
    Promise.resolve(onSave({
      id: isNew ? null : page.id, category: Number(f.category),
      name: f.name.trim(), name_it: f.name_it.trim(), name_pt: f.name_pt.trim(), name_es: f.name_es.trim(),
      content: f.content, content_it: f.content_it, content_pt: f.content_pt, content_es: f.content_es,
    })).then(res => {
      if (res && res.ok) onClose();
      else if (res && res.error) setBanner(res.error.message);
    });
  };

  const cat = hhpCatById(cats, f.category);
  const langErr = (l) => !!errs[l.nameCol] || !!errs[l.bodyCol];

  return (
    <HhpModal wide onClose={onClose}
      title={isNew ? <>New page{/* label inferred */}</> : `Edit ${page.name}`}
      sub={isNew ? "helppages · global, shared by every skin" : `helppages.id ${page.id}`}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Close</button>
        {/* A save here is a parent write followed by up to four translation writes.
            Disabled while it is in flight so a second click cannot start a second
            sequence over rows the first is still creating. */}
        <button className="btn btn--primary" onClick={save} disabled={busy}><Icon name="check" size={13} /> {busy ? "Saving…" : "Save"}</button>
      </>}>

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

      <div className="hhp-field">
        <label className="hhp-label" htmlFor="hhp-p-cat">Category <span className="hhp-req">*</span></label>
        <select id="hhp-p-cat" className={`select${errs.category ? " hhp-invalid" : ""}`} style={{ width: "100%" }}
          value={f.category} onChange={e => set("category", e.target.value)}>
          <option value="">Select</option>{/* backend.select_option = "Select" */}
          {cats.slice().sort((a, b) => a.name.localeCompare(b.name)).map(c => (
            <option key={c.id} value={c.id}>{c.name}{hhpFooterOk(c) ? "" : " — not published"}</option>
          ))}
        </select>
        {errs.category && <div className="hhp-fielderr">{errs.category}</div>}
        <div className="hhp-hint">
          A page has <b>no status of its own</b> — <code>helppages</code> has no visibility column. Whether it appears on
          the frontend is decided entirely by the category picked here.
        </div>
      </div>

      {f.category && !hhpFooterOk(cat) && (
        <div className="hhp-trap">
          <Icon name="alert" size={13} />
          <span>
            {cat
              ? <><b>{cat.name}</b> is not reachable from any footer ({cat.show_online ? <>visible, but <code>type = '{cat.type}'</code></> : <><code>show_online = 0</code></>}), so this page will be stored but never rendered.</>
              : <>That category no longer exists — the page would be saved as an orphan.</>}
          </span>
        </div>
      )}

      <HhpTagPalette onInsert={insertTag} disabled={mode !== "source"} />

      <div className="hhp-langs" role="tablist">
        {HHP_LANGS.map(l => (
          <button key={l.id} type="button" role="tab" aria-selected={lang === l.id}
            className={`hhp-lang${lang === l.id ? " hhp-lang--on" : ""}${langErr(l) ? " hhp-lang--err" : ""}`}
            onClick={() => setLang(l.id)}>
            {l.label}<span className="hhp-lang__full">{l.full}</span>
            {langErr(l) && <span className="hhp-lang__dot" title="Required field missing" />}
          </button>
        ))}
      </div>

      <div className="hhp-field">
        <label className="hhp-label" htmlFor={`hhp-p-${L.nameCol}`}>
          Name [{L.label}] <span className="hhp-req">*</span>
          {L.id === "pt" && <Tip size={12}>The form marks this required, but <code>saveHelppage</code> never validates <code>name_pt</code> (L310) — the server would accept it empty. The form's own declaration is enforced here.</Tip>}
        </label>
        <input id={`hhp-p-${L.nameCol}`} className={`input${errs[L.nameCol] ? " hhp-invalid" : ""}`} style={{ width: "100%" }}
          value={f[L.nameCol]} onChange={e => set(L.nameCol, e.target.value)} />
        {errs[L.nameCol] && <div className="hhp-fielderr">{errs[L.nameCol]}</div>}
      </div>

      <div className="hhp-field">
        <label className="hhp-label" htmlFor={`hhp-p-body-${L.id}`}>
          Content [{L.label}] <span className="hhp-req">*</span>
          <span className="hhp-col">column <code>{L.bodyCol}</code></span>
        </label>
        <HhpRichText id={`hhp-p-body-${L.id}`} value={f[L.bodyCol]} onChange={v => set(L.bodyCol, v)}
          mode={mode} onMode={setMode} taRef={taRef} invalid={!!errs[L.bodyCol]} />
        {errs[L.bodyCol] && <div className="hhp-fielderr">{errs[L.bodyCol]}</div>}
        <div className="hhp-hint">
          The real editor is TinyMCE 5.0.16 (height 200, no menubar, plugins advlist / autolink / lists / link / image / … /
          code). Its documented <code>code</code> view is the Source tab above; the preview renders the same HTML with the
          placeholder tags highlighted.
          {L.id === "en" && <> The English body is stored in the column <code>content</code>, not <code>content_en</code>.</>}
          {(L.id === "pt" || L.id === "es") && <> <b>Stored but unused today:</b> the frontend reader <code>getHelpPages()</code> only switches the <i>name</i> between EN and IT and returns the row as-is, so the shipped footer templates never select the {L.full} body.</>}
        </div>
      </div>
    </HhpModal>
  );
};

/* ------------------------------------------------------------------ *
 * Delete — deleteConfirm() then a plain GET /…/delete/{id}/. Both
 * controllers gate this with isadmin(); neither sends a CSRF token.
 * ------------------------------------------------------------------ */
const HhpDeleteDialog = ({ kind, row, orphanCount, onClose, onConfirm }) => {
  const isCat = kind === "category";
  const url = isCat ? `/helpcategories/delete/${row.id}/` : `/helppages/delete/${row.id}/`;
  return (
    <HhpModal title={isCat ? "Delete help category" : "Delete help page"} onClose={onClose}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
        <button className="btn btn--danger" onClick={() => { onConfirm(); onClose(); }}><Icon name="trash" size={13} /> Delete</button>
      </>}>
      <div className="hhp-dlgq">Are you sure?{/* backend.are_you_sure */} Delete <b>{row.name}</b> (ID {row.id})?</div>
      {isCat && orphanCount > 0 && (
        <div className="hhp-trap">
          <Icon name="alert" size={13} />
          <span>
            <b>{orphanCount} help page{orphanCount === 1 ? "" : "s"} will be orphaned.</b> The delete removes the
            <code> helpcategories</code> row only — its pages keep <code>category = {row.id}</code>, show “—” on the Help pages
            list and can never be published again until they are reassigned.
          </span>
        </div>
      )}
      <div className="hhp-hint">
        The real UI issues a plain <code>GET {url}</code> behind a JS confirm — no CSRF token, gated by
        <code> isadmin()</code> (user_level 0) in the controller.
      </div>
    </HhpModal>
  );
};

/* Shared honesty text for both screens' permission Tip. */
const HHP_GATE = (
  <>
    Real-platform access: <b>Super Admin only</b> — <code>index()</code> aborts <code>404</code> unless
    <code> isadmin()</code> (<code>user_level === 0</code>), and the whole CMS ▾ group is wrapped in
    <code> @if (isadmin())</code> (sidebar.blade.php:733). <code>delete()</code> re-checks <code>isadmin()</code>.{" "}
  </>
);
const HHP_GATENOTE = (
  <>
    Permission asymmetry, honestly: the form, save and legacy table endpoints
    (<code>helpcategoryForm</code> / <code>saveHelpcategory</code> / <code>getCategoriesTable</code> and their
    <code> helppage</code> twins) carry <b>no controller-level check at all</b> — any authenticated, 2FA'd back-office user
    who calls those URLs directly can create and edit help content. Delete is a CSRF-unprotected <code>GET</code>.
  </>
);

/* ================================================================== *
 * CMS ▾ → Help categories
 * ================================================================== */
const HostCmsHelpCategories = ({ brand }) => {
  window.useLocale && window.useLocale();

  const { cats, pages, truncated, loading, error, retry, feeds } = hhpUseDb();
  const save = useHrsSave(feeds);

  /* Filters apply on the hero Search button, as on the server-rendered form. */
  const [draft, setDraft] = hhpUseState({ q: "" });
  const [applied, setApplied] = hhpUseState({ q: "" });
  const [page, setPage] = hhpUseState(0);
  const [form, setForm] = hhpUseState(null);  // null | { cat: row|null }
  const [del, setDel] = hhpUseState(null);    // null | row

  const FIELDS = [
    { key: "q", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Category name…",
      tip: <>Free text, <code>LIKE %…%</code> against <code>name</code> <b>or</b> <code>name_it</code> only (controller L28-33) — the <b>PT name is never searched</b>, even though it has its own column on this list.</> },
  ];

  /* No sort UI: the query is a fixed ORDER BY helpcategories.id DESC (controller L26). The legacy
     getCategoriesTable did support sorting on id / name / name_it / count_pages, but nothing calls it. */
  const rows = hhpUseMemo(() => {
    const q = String(applied.q || "").trim().toLowerCase();
    return cats
      .filter(c => !q || c.name.toLowerCase().indexOf(q) !== -1 || c.name_it.toLowerCase().indexOf(q) !== -1)
      .slice().sort((a, b) => b.id - a.id);
  }, [cats, applied]);

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

  const blocked = cats.filter(c => c.show_online === 1 && !hhpFooterOk(c)).length;
  const live = cats.filter(hhpFooterOk).length;

  const onSave = (c) => save.run(async () => {
      const creating = c.id == null;
      const body = {
        /* '' is isystem's "not set"; 006 constrains placement to
           link/contacts/none, so the empty option maps to 'none' rather than
           failing a check constraint the operator never saw. */
        placement: c.type === "" || c.type == null ? "none" : c.type,
        priority: Number(c.priority) || 0,
        visible: !!c.show_online,
      };
      /* A slug is an address. Minted from the English name on create and never
         regenerated on edit, even when the name changes — the same rule the
         Blogs screen follows, and the reason `slug` is absent from the update. */
      if (creating) body.slug = hhpUniqueSlug(cats, c.name, null);

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

      const tr = await hhpSyncTr({
        resource: "helpCategoryTranslations", fkColumn: "help_category_id", parentId,
        existing: creating ? [] : ((cats.find(x => x.id === c.id) || {})._tr || []),
        values: { names: { en: c.name, it: c.name_it, pt: c.name_pt } },
        isCat: true,
      });
      if (tr.failed.length) {
        return { ok: false, error: { kind: "server", message:
          `The category saved as #${parentId} but ${tr.failed.length} name(s) did not: ${tr.failed.join("; ")}` } };
      }
      return { ok: true, data: { id: parentId }, meta: {} };
    }, {
      done: c.id == null ? `Category "${c.name}" created` : `Category "${c.name}" saved`,
      fail: c.id == null ? "The category was not created" : "The category was not fully saved",
    });

  /* SOFT delete — help_categories carries deleted_at.
     DIVERGENCE, and this one goes the other way from the usual. isystem's
     delete removes the row and nothing else, so its help pages stay behind
     pointing at a category that no longer exists; this screen used to reproduce
     that faithfully, orphans and all. The schema refuses: help_pages.category_id
     is ON DELETE RESTRICT precisely because "an orphaned help page renders
     nowhere, since visibility is inherited from the category" (006). Under a
     soft delete the FK is not violated and the pages keep resolving — so the
     orphan is impossible rather than merely discouraged, and the confirm below
     still shows the count so the operator knows what they are hiding. */
  const onDelete = (c) => {
    save.run(() => window.sb.remove("helpCategories", c.id), {
      done: `Category "${c.name}" deleted`,
      fail: `Category "${c.name}" was not deleted`,
    });
  };

  const acts = (c) => (
    <div className="hhp-acts">
      <button className="hhp-act hhp-act--edit" title="Edit" onClick={e => { e.stopPropagation(); setForm({ cat: c }); }}><Icon name="edit" size={13} /></button>
      <button className="hhp-act hhp-act--danger" title="Delete" onClick={e => { e.stopPropagation(); setDel(c); }}><Icon name="trash" size={13} /></button>
    </div>
  );

  const columns = [
    { key: "id", label: "ID", width: 76, render: c => <span className="hhp-id">{c.id}</span> },
    /* The EN name is the edit link — gestioneHelpcategory(id) opens the shared modal. */
    { key: "name", label: "Name EN", render: c => (   // hardcoded header on the real screen
      <button className="hhp-namelink" title="Edit" onClick={e => { e.stopPropagation(); setForm({ cat: c }); }}>
        <span>{c.name}</span><Icon name="chevron_right" size={13} />
      </button>
    ) },
    { key: "name_it", label: "Name IT", render: c => <span className="hhp-sub">{c.name_it}</span> },
    { key: "name_pt", label: "Name PT", render: c => <span className="hhp-sub">{c.name_pt}</span> },
    /* SQL subquery on the real screen: SELECT COUNT(*) FROM helppages WHERE category = id. */
    { key: "_pages", label: "Number of pages", align: "center", width: 150,
      render: c => { const n = hhpPageCount(pages, c.id); return <span className={n ? "hhp-count" : "hhp-count hhp-count--zero"}>{n}</span>; } },
    { key: "_vis", label: "Visible", align: "center", width: 120, render: c => <HhpVisibleChip cat={c} /> },
    { key: "_acts", label: "Actions", align: "center", width: 110, render: acts },
  ];

  return (
    <HrsShell
      title="Help categories"                       /* backend.help_categories */
      subtitle="Global help tree shared by every skin — the categories a footer can link to"
      gate={HHP_GATE} gateNote={HHP_GATENOTE}
      explainer={{ title: "What this screen is, in plain English", bullets: [
        <>The buckets behind the site's help/FAQ footer. <b>Global data</b> — <code>helpcategories</code> has no <code>skinid</code> column, so this one list serves every brand; per-skin wording is injected into the <i>pages</i> through placeholder tags at render time.</>,
        <><b>The trap:</b> a category reaches a footer only when <code>show_online = 1</code> <b>and</b> <code>type</code> is <code>link</code> or <code>contacts</code> — but the real admin form never exposes <code>type</code>, so anything created here is saved with an empty type and stays invisible forever. Right now <b>{blocked} of {cats.length}</b> categories are marked Visible yet blocked this way; only <b>{live}</b> actually render. The Type field below is a deliberate divergence from the real form.</>,
        <><b>Delete does not cascade.</b> Removing a category leaves its help pages behind with a dangling <code>category</code> id — they show “—” on the Help pages screen and cannot be published again until they are reassigned.</>,
        <><b>Priority</b> orders the categories on the frontend, not here: this table is always <code>id DESC</code> and has no sortable columns. Search matches the EN and IT names only.</>,
      ] }}
      actions={<>
        {/* Header shortcut to the sibling screen — a plain anchor on the real page. */}
        <button className="hhp-linkbtn" onClick={() => hhpGoto("cms-help-pages", "Help pages")}>
          <Icon name="list" size={14} /> Help Pages
        </button>
        <button className="hrs-btn hrs-btn--filters" onClick={() => setForm({ cat: null })}>
          <Icon name="plus" size={14} /> New Category
        </button>
      </>}>

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

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

      {loading ? <HrsSkeleton rows={6} cols={6} /> : error ? <HrsError error={error} onRetry={retry} /> : (
      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={(c) => setForm({ cat: c })}   /* whole row is click-to-edit (index.blade.php:144-150) */
        empty={applied.q ? "No category matches this search." : "No help categories yet — create one with New Category."}
        renderCard={c => (
          <>
            <div className="hrs-card__top"><b>{c.name}</b><HhpVisibleChip cat={c} /></div>
            <div className="hrs-card__grid">
              <span>ID</span><b>{c.id}</b>
              <span>Name IT</span><b>{c.name_it}</b>
              <span>Name PT</span><b>{c.name_pt}</b>
              <span>Pages</span><b>{hhpPageCount(pages, c.id)}</b>
            </div>
            <div className="hhp-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={e => { e.stopPropagation(); setForm({ cat: c }); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hhp-card__del" onClick={e => { e.stopPropagation(); setDel(c); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />

      )}

      <HrsPager page={safePage} pageSize={HHP_PAGE_SIZE} total={rows.length} onPage={setPage} />

      {form && <HhpCategoryForm cat={form.cat} pageCount={form.cat ? hhpPageCount(pages, form.cat.id) : 0}
        busy={save.busy} onClose={() => setForm(null)} onSave={onSave} />}
      {del && <HhpDeleteDialog kind="category" row={del} orphanCount={hhpPageCount(pages, del.id)}
        onClose={() => setDel(null)} onConfirm={() => onDelete(del)} />}
    </HrsShell>
  );
};

/* ================================================================== *
 * CMS ▾ → Help pages
 * ================================================================== */
const HostCmsHelpPages = ({ brand }) => {
  window.useLocale && window.useLocale();

  const { cats, pages, truncated, loading, error, retry, feeds } = hhpUseDb();
  const save = useHrsSave(feeds);

  const [draft, setDraft] = hhpUseState({ q: "", category: "" });
  const [applied, setApplied] = hhpUseState({ q: "", category: "" });
  const [page, setPage] = hhpUseState(0);
  const [form, setForm] = hhpUseState(null);
  const [del, setDel] = hhpUseState(null);

  const FIELDS = [
    { key: "q", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Page name…",
      tip: <>Free text, <code>LIKE %…%</code> against <code>name</code> <b>or</b> <code>name_it</code> only (controller L31-36) — the ES name shown in the list and the PT name are <b>never searched</b>.</> },
    { key: "category", label: "Category", type: "select", icon: "list", placeholder: "Select", width: 260,
      options: cats.slice().sort((a, b) => a.name.localeCompare(b.name)).map(c => ({ value: String(c.id), label: c.name })),
      tip: <>All <code>helpcategories</code> ordered by name. Applies immediately — the real select auto-submits <code>onchange</code>, unlike the Search box which waits for the button.</> },
  ];

  /* The real filter is `if (!empty($category))`, so a category with id 0 could never be selected —
     latent quirk only: helpcategories ids start at 1. */
  const rows = hhpUseMemo(() => {
    const q = String(applied.q || "").trim().toLowerCase();
    const c = String(applied.category || "");
    return pages
      .filter(p => !q || p.name.toLowerCase().indexOf(q) !== -1 || p.name_it.toLowerCase().indexOf(q) !== -1)
      .filter(p => !c || String(p.category) === c)
      .slice().sort((a, b) => b.id - a.id);   // fixed ORDER BY helppages.id DESC (controller L29)
  }, [pages, applied]);

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

  const orphans = pages.filter(p => !hhpCatById(cats, p.category)).length;
  const unpublished = pages.filter(p => !hhpFooterOk(hhpCatById(cats, p.category))).length;

  const onSave = (p) => save.run(async () => {
      const creating = p.id == null;
      const prev = creating ? null : pages.find(x => x.id === p.id);
      const body = {
        category_id: Number(p.category),
        /* NEITHER FIELD IS ON THE FORM, so an edit must not silently reset
           them. `generic_name` (isystem name_generico) and `position` are
           carried through from the stored row; only a CREATE supplies defaults.
           Sending `position: 0` on every save would quietly re-order the whole
           help menu each time somebody fixed a typo. */
        generic_name: creating ? (p.name || null) : (prev ? prev.generic_name || null : null),
        position: creating ? 0 : (prev ? Number(prev.position) || 0 : 0),
      };
      /* Minted once, from the English name, and never regenerated — a page slug
         is the URL players have bookmarked. */
      if (creating) body.slug = hhpUniqueSlug(pages, p.name, null);

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

      const tr = await hhpSyncTr({
        resource: "helpPageTranslations", fkColumn: "help_page_id", parentId,
        existing: creating ? [] : ((prev || {})._tr || []),
        values: {
          names: { en: p.name, it: p.name_it, pt: p.name_pt, es: p.name_es },
          /* `content` is the English body's column upstream; the other three
             keep their own names. Unpacked here so the storage difference stops
             at this line. */
          bodies: { en: p.content, it: p.content_it, pt: p.content_pt, es: p.content_es },
        },
        isCat: false,
      });
      if (tr.failed.length) {
        return { ok: false, error: { kind: "server", message:
          `The page saved as #${parentId} but ${tr.failed.length} language(s) did not: ${tr.failed.join("; ")}` } };
      }
      return { ok: true, data: { id: parentId }, meta: {} };
    }, {
      done: p.id == null ? `Page "${p.name}" created` : `Page "${p.name}" saved`,
      fail: p.id == null ? "The page was not created" : "The page was not fully saved",
    });

  const acts = (p) => (
    <div className="hhp-acts">
      {/* The real row renders Delete first, then Edit (index.blade.php) — order preserved. */}
      <button className="hhp-act hhp-act--danger" title="Delete" onClick={e => { e.stopPropagation(); setDel(p); }}><Icon name="trash" size={13} /></button>
      <button className="hhp-act hhp-act--edit" title="Edit" onClick={e => { e.stopPropagation(); setForm({ page: p }); }}><Icon name="edit" size={13} /></button>
    </div>
  );

  const columns = [
    { key: "id", label: "ID", width: 76, render: p => <span className="hhp-id">{p.id}</span> },
    { key: "name", label: "Name EN", render: p => (   // hardcoded header on the real screen
      <button className="hhp-namelink" title="Edit" onClick={e => { e.stopPropagation(); setForm({ page: p }); }}>
        <span>{p.name}</span><Icon name="chevron_right" size={13} />
      </button>
    ) },
    { key: "name_it", label: "Name IT", render: p => <span className="hhp-sub">{p.name_it}</span> },
    { key: "name_es", label: "Name ES", render: p => <span className="hhp-sub">{p.name_es}</span> },
    /* leftJoin helpcategories → cat_name (controller L27-28); "—" when the join misses. */
    { key: "_cat", label: "Category", width: 240, render: p => <HhpCatCell cat={hhpCatById(cats, p.category)} rawId={p.category} /> },
    { key: "_acts", label: "Actions", align: "center", width: 110, render: acts },
  ];

  return (
    <HrsShell
      title="Help pages"                            /* backend.help_pages */
      subtitle="The bodies behind each help category — four languages, one global copy per skin"
      gate={HHP_GATE} gateNote={HHP_GATENOTE}
      explainer={{ title: "What this screen is, in plain English", bullets: [
        <>Each row is one help article: names in EN/IT/PT/ES and rich-text bodies in EN (<code>content</code>) / IT / PT / ES. <b>Global</b> — like categories, <code>helppages</code> has no <code>skinid</code>.</>,
        <><b>Placeholder tags</b> are how one global article says the right thing on every brand: <code>[nome-skin]</code>, <code>[numero-licenza]</code>, <code>[email-assistenza]</code> and the rest are substituted when the frontend renders the page. The full list is in the editor, one click to insert.</>,
        <><b>A page has no publish switch.</b> There is no status column on <code>helppages</code> at all — visibility is inherited from the parent category (<code>show_online = 1</code> and <code>type</code> in <code>link</code>/<code>contacts</code>), ordered by the category's priority. Today <b>{unpublished} of {pages.length}</b> pages sit under a category no footer reaches{orphans > 0 && <>, including <b>{orphans}</b> orphaned by a deleted category</>}.</>,
        <><b>Honest limitation:</b> the frontend reader only locale-switches the <i>name</i> between EN and IT and returns the row as-is, so the PT and ES <i>bodies</i> are stored faithfully here but the shipped footer templates never select them.</>,
        <>Fixed order <code>id DESC</code>, 25 per page, no sortable columns and no export — Search matches the EN and IT names only, while the Category filter applies as soon as you pick it.</>,
      ] }}
      actions={<>
        {/* Header shortcut to the sibling screen — a plain anchor on the real page. */}
        <button className="hhp-linkbtn" onClick={() => hhpGoto("cms-help-cat", "Help categories")}>
          <Icon name="grid" size={14} /> Categories
        </button>
        <button className="hrs-btn hrs-btn--filters" onClick={() => setForm({ page: null })}>
          <Icon name="plus" size={14} /> New page
        </button>
      </>}>

      <HrsFilters
        fields={FIELDS} values={draft}
        onChange={(k, v) => {
          const next = Object.assign({}, draft, { [k]: v });
          setDraft(next);
          /* Category auto-submits onchange on the real screen; Search waits for the button. */
          if (k === "category") { setApplied({ q: applied.q, category: v }); setPage(0); }
        }}
        onSearch={(v) => { setApplied({ q: v.q || "", category: v.category || "" }); setPage(0); }}
        onReset={() => { setDraft({ q: "", category: "" }); setApplied({ q: "", category: "" }); setPage(0); }}
        resultLabel={`${hrsInt(rows.length)} of ${hrsInt(pages.length)}`} />

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

      {loading ? <HrsSkeleton rows={6} cols={6} /> : error ? <HrsError error={error} onRetry={retry} /> : (
      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={(p) => setForm({ page: p })}
        empty={applied.q || applied.category ? "No help page matches these filters." : "No help pages yet — create one with New page."}
        renderCard={p => (
          <>
            <div className="hrs-card__top"><b>{p.name}</b><HhpCatCell cat={hhpCatById(cats, p.category)} rawId={p.category} /></div>
            <div className="hrs-card__grid">
              <span>ID</span><b>{p.id}</b>
              <span>Name IT</span><b>{p.name_it}</b>
              <span>Name ES</span><b>{p.name_es}</b>
            </div>
            <div className="hhp-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={e => { e.stopPropagation(); setForm({ page: p }); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hhp-card__del" onClick={e => { e.stopPropagation(); setDel(p); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />

      )}

      <HrsPager page={safePage} pageSize={HHP_PAGE_SIZE} total={rows.length} onPage={setPage} />

      {form && <HhpPageForm page={form.page} cats={cats} busy={save.busy} onClose={() => setForm(null)} onSave={onSave} />}
      {del && <HhpDeleteDialog kind="page" row={del} orphanCount={0}
        onClose={() => setDel(null)}
        onConfirm={() => save.run(() => window.sb.remove("helpPages", del.id), {
          done: `Page "${del.name}" deleted`,
          fail: `Page "${del.name}" was not deleted`,
        }).then(() => setDel(null))} />}
    </HrsShell>
  );
};

/* Loads after the legacy CMS bundle (index.html), deliberately replacing the thinner versions that
   used to live there — app.jsx:486-487 renders these for route keys "cms-help-cat" /
   "cms-help-pages" (URLs /cms/help-categories and /cms/help-pages, src/routes.jsx:86-87). */
window.HostCmsHelpCategories = HostCmsHelpCategories;
window.HostCmsHelpPages = HostCmsHelpPages;
