// NO PROD JSON API (bucket C|E) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: admin.faq.index / admin.faq.categories.index · Admin/FaqController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "FAQ + FAQ Categories"
/* CMS ▾ → FAQ  and  CMS ▾ → FAQ Categories. Two sibling screens, one file, because the real
   platform serves both from ONE class: App\Http\Controllers\Admin\FaqController — index L37,
   create L83, store L106, edit L139, update L163, destroy L185, generateSlug L341, and for the
   categories screen categoriesIndex L203, createCategory L226, storeCategory L247, editCategory
   L279, updateCategory L301, destroyCategory L322.

   Routes (routes/admin.php, group prefix('faq')->name('faq.') L724; nested prefix('categories')
   ->name('categories.') L735):
     admin.faq.index         GET    /faq                 L726
     admin.faq.create        GET    /faq/create          L727
     admin.faq.store         POST   /faq                 L728
     admin.faq.edit          GET    /faq/{id}/edit       L729
     admin.faq.update        PUT    /faq/{id}            L730
     admin.faq.destroy       DELETE /faq/{id}            L731
     admin.faq.generate-slug POST   /faq/generate-slug   L732
     admin.faq.categories.index/create/store/edit/update/destroy   L736-L741
   Views: admin/faq/index|create|edit.blade.php and admin/faq/categories/index|create|edit.blade.php,
   both on the redesigned "Paybo" shell (partial admin/faq/_paybo-head.blade.php) — so the create and
   edit forms are FULL PAGES with a back link, not the legacy Metronic modals used elsewhere in this
   batch. That is why the editors below are sub-views of the page, not <HfqModal>s (the modal here is
   only ever the delete confirm, matching the real JS confirm).

   Architecture, per the reference notes: this is the ONLY admin controller on the clean service-layer
   pattern — Controller → FormRequest (StoreFaqRequest / UpdateFaqRequest / StoreFaqCategoryRequest /
   UpdateFaqCategoryRequest) → DTO (CreateFaqDto / UpdateFaqDto, read side FaqDto::fromModel) →
   FaqService → FaqRepository / FaqCategoryRepository → models. Deviations from the LaunchUrl
   blueprint it is otherwise closest to: concrete repositories injected (no interface binding), no
   events/listeners, NO audit-log table, no caching. The category write path skips the DTO entirely —
   storeCategory/updateCategory hand the raw validated array to the service (mass assignment through
   FaqCategory::$fillable).

   PERMISSION GATES: THERE ARE NONE. Stated plainly in the header Tip of both screens rather than
   dressed up. The only thing standing in front of these six-plus-six endpoints is the route
   middleware — ['admin','adminsettings'] (routes/admin.php:15) + ['auth','admin','2fa','g2fa']
   (routes/admin.php:25). Unlike its CMS siblings (BlogController.php:23-28, SlideshowsController.php:
   26-31, which abort(404) when !$can_manage_cms) FaqController performs no check at all, and all four
   FormRequests return authorize(): true. The sidebar entry IS gated — $can_manage_cms =
   isadmin() || (isSkinAdmin() && checkSkinSett(skin_id,'enable_cms')) || (isCustomCare() &&
   checkUserBoPerm(id,'support_cms_banners')), sidebar.blade.php:L18 — but that hides a menu item, it
   does not protect a URL. The single real limit is data scoping: every query runs against
   Auth::user()->getSkins() (User.php:95-112 — all skins for isadmin(), else own skin_id +
   multiple_skins, cached 24h).
   <!-- SUGGESTION: give Admin\FaqController the same $can_manage_cms guard its CMS siblings already have (BlogController.php:23-28), or move the check into the four FormRequests' authorize(). Today any authenticated, 2FA'd back-office user — a shop, a cashier, a promoter — can open /faq, create, edit and delete published player-facing help content on every skin they can see, and nothing is written to an audit trail because this feature has none. -->
   <!-- SUGGESTION: add a faq_logs / faq_category_logs timeline (the LaunchUrlLog pattern) — this is the only CMS surface with no history at all, and with no permission gate in front of it there is currently no way to answer "who deleted that FAQ". -->

   MULTI-SKIN FAN-OUT: create writes ONE ROW PER SELECTED SKIN in a single transaction (FaqController.
   php:114-122 for FAQs, L255-262 for categories) — the batch-wide pattern noted for FAQ, FAQ
   Categories, Blogs and Blog categories. The brief calls for that to be visible, so both editors
   render a live fan-out preview (<HfqFanout>) listing every row the save will write, with the
   per-skin slug-uniqueness check the service performs (FaqService.php:130-132 / L279-281) evaluated
   up front. Edit fans out to nothing: neither update path accepts skin_ids, so a row cannot be moved
   between skins after creation.

   Known real-platform behaviour handled per the repo's known-bug policy (CLAUDE.md):
   - Post-delete redirect DROPS the skin_id parameter, so deleting a FAQ (or a category) on skin 68
     bounces the operator back to the first skin's list. Evident intent implemented here: the current
     skin filter survives a delete. See the comment at hfqDeleteFaq / hfqDeleteCat.
     <!-- SUGGESTION: carry skin_id through the destroy()/destroyCategory() redirects (FaqController L185-201 / L322-339) — as written, deleting one FAQ on a non-first skin silently throws the operator back to another skin's list, which is how you end up deleting the wrong row next. -->
   - category_id is validated with a GLOBAL exists:faq_categories,id rule, NOT scoped to the skin, and
     create carries ONE category_id into every fanned-out row. Selecting a category and more than one
     skin therefore points rows on the other skins at a category that belongs to a different skin. The
     evident intent is ambiguous (scope the picker per skin, or null the category on the other rows) and
     inventing a per-skin category field would add a control the real form does not have — so the form
     stays faithful and the fan-out preview WARNS instead. See HfqFanout.
     <!-- SUGGESTION: scope the category_id rule to the submitted skin (Rule::exists('faq_categories','id')->where('skin_id',$skinId)) and, on a multi-skin create, resolve the category per skin by slug or leave it null — right now the fan-out can attach skin 47's "Payments" category to a FAQ that lives on skin 68. -->
   - generate-slug is single-skin by construction: its inline validation takes ONE skin_id
     (FaqController.php:343-347) and de-dups with -1/-2 suffixes against that skin only
     (FaqService.php:338-350), while the create form it serves can select many skins. Implemented
     faithfully (auto-fill de-dups against the context skin) with the remaining collisions surfaced by
     the fan-out preview instead of being discovered on submit.
     <!-- SUGGESTION: let generate-slug accept skin_ids[] and return a slug free on ALL of them; today the auto-filled slug can still explode in FaqService::create for the second skin of the fan-out, and the whole transaction rolls back after the operator has typed twelve translations. -->
   - FAQ Categories auto-slug client-side (categories/create.blade.php:143-155) WITHOUT any
     uniqueness check, unlike FAQs which call the endpoint. Evident intent implemented: the same
     collision check runs on both screens, before save.

   Faithful to the real screens, deliberately NOT added: no export (neither screen has one), no bulk
   actions, no sortable columns (FAQ is fixed created_at DESC — FaqRepository.php:89; categories are
   fixed name_en ASC — FaqService.php:230), no KPI strip beyond the hero "Results" card the real
   filter hero shows, no pagination on the categories screen (the real list is an unpaginated get()),
   no search/status/category filter on the categories screen (Skin is its only filter), no status
   chip counts on the FAQ chip strip (the Banners/Blogs chip rows carry counts; the FAQ one is a
   plain All/Active/Disabled link row, index.blade.php:131-141), and no restore/duplicate/preview
   action anywhere — the platform has none.

   Perf quirks recorded but invisible here: FaqService::getAllCategories (L222-235) queries
   FaqCategory directly and bypasses FaqCategoryRepository::getAllForSkin (a dead duplicate of the
   same query, FaqCategoryRepository.php:24-32), and FaqCategoryDto::fromModel issues faqs()->count()
   per row even though the list query already eager-loads withCount('faqs') (N+1).

   Label policy: the redesigned Paybo blades lean on backend.* keys that resolve only in the
   gitignored storage/lang — backend.faq_thumbnail, backend.created, backend.search,
   backend.faq_category_name, backend.faq_slug, backend.faq_count, backend.faq_category_has_faqs,
   backend.faq_back_to_*, results/apply/showing/prev/next/no_records. Those are written as sensible
   operator-facing English and marked with an inline "label inferred" JSX comment. backend.id, backend.title,
   backend.category, backend.status and backend.actions do resolve in the committed lang file and are
   used verbatim. */

const { useState: hfqUseState, useMemo: hfqUseMemo, useEffect: hfqUseEffect } = React;

/* NOT-A-GENERATOR: FNV-1a, kept for ONE remaining job — picking a stable colour
   for the thumbnail placeholder from a stored path, so the same file always gets
   the same tile. It used to seed a mulberry32 that generated this screen's
   entire contents (see the note above hfqUseDb). A hash that decides a hue is
   not a generator of facts; a hash that decides how many FAQs a brand has is.

   tools/wiringstate.js flags any Math.imul with no *Rng name, because that is
   how a generator hides from its classifier. This marker is the declared way
   out and it is conditional: the tool only honours it on a file that also reads
   the database, which this one now does. */
const hfqHash = (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; };
/* ------------------------------------------------------------------ *
 * Faq::getLanguageConfig (Faq.php:181-197) and FaqCategory::
 * getLanguageConfig (FaqCategory.php:106-122) — the SAME twelve
 * languages, every one of them required=false. Storage is FLAT COLUMNS
 * on the row (title_en…title_pt_br / content_* / name_en…name_hu), not a
 * translations table. Names are copied verbatim from the reference,
 * including "Deutsche", "Arabic", "Chinese" and "Português-Brasil".
 * The config carries a flag for `en`; the rest are not documented, so
 * nothing renders flags here — language codes only.
 * ------------------------------------------------------------------ */
const HFQ_LANGS = [
  { code: "en", name: "English" },
  { code: "es", name: "Español" },
  { code: "it", name: "Italiano" },
  { code: "de", name: "Deutsche" },
  { code: "tr", name: "Türkçe" },
  { code: "ar", name: "Arabic" },
  { code: "ro", name: "Română" },
  { code: "zh", name: "Chinese" },
  { code: "fr", name: "Français" },
  { code: "pt", name: "Português" },
  { code: "pt_br", name: "Português-Brasil" },
  { code: "hu", name: "Magyar" },
];
const HFQ_LANG_CODES = HFQ_LANGS.map(l => l.code);
const hfqLangName = (code) => (HFQ_LANGS.find(l => l.code === code) || { name: code }).name;

/* Auth::user()->getSkins(). Read from `skins` — every brand the operator's RLS
   lets them see, which is the whole platform for a super admin and one row for a
   skin admin. The scoping is the database's, not a filter this file applies.

   This was a hardcoded array of ten brand names copied from the Business report
   page. It looked authoritative precisely because two screens agreed. */
const hfqSkinName = (skins, id) => {
  const s = (skins || []).find(x => String(x.id) === String(id));
  return s ? s.name : `#${id}`;
};

/* ------------------------------------------------------------------ *
 * THE DATA. This screen used to carry its own database.
 *
 * `HFQ_DB = hfqSeedDb()` built seventeen FAQ topics across seven skins from a
 * seeded PRNG, with a module-level pub/sub so a category created on the
 * Categories page appeared instantly in the FAQ page's picker. The six Save and
 * Delete buttons all worked. They wrote to that object, the toast said
 * "Created", and the row was gone on reload — which is worse than a dead
 * button, because a dead button at least fails visibly.
 *
 * What replaces it is two reads and a shape conversion. Everything downstream —
 * `hfqFaqTitle`, `hfqMatchesSearch`, `hfqCatsForSkin`, both editors, every
 * column renderer — still sees `{ faqs, cats }` with `row.t` and `row.n`, so
 * the conversion is the only place that knows the storage differs.
 *
 * AND IT DOES DIFFER, IN THE ONE WAY THAT MATTERS HERE. isystem stores twelve
 * title_* and twelve content_* columns ON the faqs row. This schema stores one
 * faq_translations ROW PER LANGUAGE, which is why the search below is a client
 * filter and isystem's is a LIKE across twenty-four columns, and why saving a
 * FAQ writes N+1 rows rather than one.
 * ------------------------------------------------------------------ */

/* Fetch ceiling. Deliberately generous and deliberately NOT silent: a brand with
   more FAQs than this would page in the database and search only what was
   fetched, so the list says so rather than quietly showing a subset. */
const HFQ_FETCH_MAX = 500;

const hfqBlankT = () => Object.fromEntries(HFQ_LANG_CODES.map(c => [c, { title: "", content: "" }]));
const hfqBlankN = () => Object.fromEntries(HFQ_LANG_CODES.map(c => [c, ""]));

/* faq_translations rows -> the twelve-key map the editors already speak. A
   locale the platform has that this screen does not list is DROPPED here and
   would be silently lost on the next save; the two lists are the same twelve
   (006 seeds exactly HFQ_LANGS), and hfqUnknownLocales below is what says so if
   that ever stops being true. */
const hfqTFromRows = (rows) => {
  const t = hfqBlankT();
  (rows || []).forEach(r => {
    const c = String(r.locale_code || "").toLowerCase();
    if (t[c]) t[c] = { title: String(r.title || ""), content: String(r.content || "") };
  });
  return t;
};
const hfqNFromRows = (rows) => {
  const n = hfqBlankN();
  (rows || []).forEach(r => {
    const c = String(r.locale_code || "").toLowerCase();
    if (c in n) n[c] = String(r.name || "");
  });
  return n;
};
const hfqUnknownLocales = (rows) => [...new Set((rows || [])
  .map(r => String(r.locale_code || "").toLowerCase())
  .filter(c => c && HFQ_LANG_CODES.indexOf(c) === -1))];

/* `active` is a real boolean in this schema and 0/1 in isystem's. Kept as 0/1
   inside the screen because every renderer and both editors were written
   against that, and converted at the two edges — here and in the save. */
const hfqFaqFromDb = (r) => ({
  id: r.id,
  skin_id: r.skin_id,
  slug: r.slug || "",
  category_id: r.category_id == null ? null : r.category_id,
  is_active: r.active ? 1 : 0,
  thumbnail_path: r.thumbnail_url || "",
  image_path: r.image_url || "",
  created_at: r.created_at,
  t: hfqTFromRows(r.translations),
  _tr: r.translations || [],
});
const hfqCatFromDb = (r) => ({
  id: r.id,
  key: r.slug || String(r.id),
  skin_id: r.skin_id,
  slug: r.slug || "",
  is_active: r.active ? 1 : 0,
  n: hfqNFromRows(r.translations),
  _tr: r.translations || [],
  /* faqs(count) is the embedded aggregate the read resource asks for — the real
     number of FAQs filed under this category, INCLUDING ones on pages this
     screen has not fetched. Counting the fetched rows instead would under-report
     and make a category look deletable when it is not. */
  _faqCount: Array.isArray(r.faqs) && r.faqs[0] ? Number(r.faqs[0].count) || 0 : 0,
});

/* Both lists for one skin, plus the brand list. Returns the same
   `[db, version]` pair the pub/sub version did, so the memo dependency lists
   downstream did not have to change — `version` is now the fetch nonce rather
   than a mutation counter. */
const hfqUseDb = (skinId) => {
  const skinsFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const faqsFeed = useHrsFetch(
    () => window.sb.list("faqs", { limit: HFQ_FETCH_MAX, filters: skinId ? { skin: skinId } : {} }),
    [skinId]);
  const catsFeed = useHrsFetch(
    () => window.sb.list("faqCategories", { limit: HFQ_FETCH_MAX, filters: skinId ? { skin: skinId } : {} }),
    [skinId]);

  const db = hfqUseMemo(() => ({
    faqs: (faqsFeed.data || []).map(hfqFaqFromDb),
    cats: (catsFeed.data || []).map(hfqCatFromDb),
  }), [faqsFeed.data, catsFeed.data]);

  const skins = hfqUseMemo(
    () => (skinsFeed.data || []).map(s => ({ id: s.id, name: s.name, code: s.code })),
    [skinsFeed.data]);

  const truncated = [
    faqsFeed.meta && faqsFeed.meta.total > (faqsFeed.data || []).length ? "FAQs" : null,
    catsFeed.meta && catsFeed.meta.total > (catsFeed.data || []).length ? "categories" : null,
  ].filter(Boolean);

  return {
    db, skins,
    version: `${(faqsFeed.data || []).length}:${(catsFeed.data || []).length}`,
    loading: skinsFeed.loading || faqsFeed.loading || catsFeed.loading,
    error: skinsFeed.error || faqsFeed.error || catsFeed.error,
    retry: () => { skinsFeed.retry(); faqsFeed.retry(); catsFeed.retry(); },
    feeds: [faqsFeed, catsFeed],
    truncated,
    totals: {
      faqs: faqsFeed.meta ? faqsFeed.meta.total : null,
      cats: catsFeed.meta ? catsFeed.meta.total : null,
    },
  };
};

/* ---------- helpers ---------- */

/* Str::slug, near enough for the auto-fill both screens do. */
const hfqSlugify = (s) => String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "")
  .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 255);

/* A COURTESY, NOT A CONTROL, and the distinction is load-bearing now that the
   rows are real. `faqs_slug_uk` is a partial unique index on (skin_id, slug)
   where deleted_at is null, so the database is what actually decides. This tells
   the operator before they submit — and it can only see rows THIS SCREEN HAS
   FETCHED, which is one skin's first HFQ_FETCH_MAX. A clash it misses comes back
   as a 23505 from the save, mapped by sbWriteError to "the same thing is already
   recorded", which is correct and later.

   The `kind` argument stays because both screens share the checker; `db` is
   passed in now that there is no module-level store to reach for. */
const hfqSlugTaken = (db, kind, slug, skinId, excludeId) => {
  const rows = kind === "faq" ? db.faqs : db.cats;
  return rows.some(r => String(r.skin_id) === String(skinId) && r.slug === slug && String(r.id) !== String(excludeId));
};

/* FaqService::generateUniqueSlug (L338-350) — Str::slug then -1, -2, … until free ON ONE SKIN. */
const hfqUniqueSlug = (db, kind, base, skinId, excludeId) => {
  const root = hfqSlugify(base);
  if (!root) return "";
  if (!hfqSlugTaken(db, kind, root, skinId, excludeId)) return root;
  for (let i = 1; i < 200; i++) {
    const candidate = `${root}-${i}`;
    if (!hfqSlugTaken(db, kind, candidate, skinId, excludeId)) return candidate;
  }
  return root;
};

/* ------------------------------------------------------------------ *
 * The write half: a parent row and its translation rows, in that order.
 *
 * ONE SAVE IS N+1 WRITES and there is no transaction around them, because
 * app_write() takes one resource per call. So the order matters and the failure
 * reporting matters more: the parent goes first (a translation cannot exist
 * without a faq_id), and if a translation write fails afterwards the FAQ EXISTS
 * with some of its languages. Saying "Saved" there would be a lie of exactly the
 * kind this screen was full of.
 *
 * hfqSyncTr returns what happened rather than throwing, so the caller can report
 * a partial save as a partial save.
 * ------------------------------------------------------------------ */

/* Which of the twelve carry anything. An empty language is NOT a row: 006's
   faq_translations_not_empty CHECK refuses one, and a category name is NOT NULL
   outright. */
const hfqLiveLangs = (map, isCat) => HFQ_LANG_CODES.filter(c => {
  const v = map[c];
  if (isCat) return !!String(v || "").trim();
  return !!(String((v || {}).title || "").trim() || String((v || {}).content || "").trim());
});

/* Reconcile one parent's translations against what the editor holds.
   INSERT what is new, UPDATE what changed, DELETE what was emptied — the third
   is the one a naive save forgets, leaving a language the operator cleared still
   published to players. */
const hfqSyncTr = async ({ resource, fkColumn, parentId, existing, next, isCat }) => {
  const have = new Map((existing || []).map(r => [String(r.locale_code || "").toLowerCase(), r]));
  const want = new Set(hfqLiveLangs(next, isCat));
  const failed = [];
  let wrote = 0, removed = 0;

  for (const code of HFQ_LANG_CODES) {
    const prev = have.get(code);
    const body = isCat
      ? { name: String(next[code] || "").trim() }
      : { title: String((next[code] || {}).title || ""), content: String((next[code] || {}).content || "") };

    if (want.has(code)) {
      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(`${hfqLangName(code)}: ${(res && res.error && res.error.message) || "refused"}`);
    } else if (prev && prev.id) {
      /* Cleared in the editor. The row has no deleted_at (006 gave these tables
         none deliberately — a translation is not a record of anything that
         happened), so this is a hard delete and the language is gone. */
      const res = await window.sb.remove(resource, prev.id);
      if (res && res.ok) removed++;
      else failed.push(`${hfqLangName(code)} (removal): ${(res && res.error && res.error.message) || "refused"}`);
    }
  }
  return { wrote, removed, failed };
};

/* Faq.php:76-104 / FaqCategory.php:61-89 — requested language → en → the legacy base column → the
   first language that has anything. `base` mirrors what CreateFaqDto::toModelArray L82-88 writes:
   the first translation carrying BOTH title and content is copied into the legacy title/content
   columns. */
const hfqLocalized = (map, lang, field) => {
  const get = (code) => {
    const v = map[code];
    if (v == null) return "";
    return field ? String(v[field] || "") : String(v || "");
  };
  if (get(lang)) return get(lang);
  if (get("en")) return get("en");
  const legacy = HFQ_LANG_CODES.map(get).find(v => v);
  return legacy || "";
};
const hfqFaqTitle = (row, lang) => hfqLocalized(row.t, lang || "en", "title");
const hfqCatName = (row, lang) => hfqLocalized(row.n, lang || "en", null);

/* The row that toModelArray copies into the legacy title/content columns. */
const hfqBaseLang = (t) => HFQ_LANG_CODES.find(c => t[c] && t[c].title.trim() && String(t[c].content || "").trim()) || null;

const HFQ_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/* date('j M Y, H:i') on created_at — the Created column's exact format. */
const hfqDate = (iso) => {
  if (!iso) return "—";
  const d = new Date(iso);
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getUTCDate()} ${HFQ_MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}, ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
};

const hfqStrip = (html) => String(html || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();

/* FaqRepository.php:73-82 — LIKE %term% across all twelve title_* AND all twelve content_* columns. */
const hfqMatchesSearch = (row, term) => {
  const q = String(term || "").trim().toLowerCase();
  if (!q) return true;
  return HFQ_LANG_CODES.some(c => {
    const cell = row.t[c] || {};
    return String(cell.title || "").toLowerCase().includes(q) || hfqStrip(cell.content).toLowerCase().includes(q);
  });
};

const HFQ_PER_PAGE = 15;      // hardcoded, FaqController.php:62
const HFQ_MAX_THUMB_KB = 2048;  // nullable|image|max:2048
const HFQ_MAX_IMAGE_KB = 5120;  // nullable|image|max:5120

const hfqCatsForSkin = (db, skinId) => db.cats
  .filter(c => String(c.skin_id) === String(skinId))
  .slice()
  .sort((a, b) => String(a.n.en || "").localeCompare(String(b.n.en || "")));   // ORDER BY name_en ASC
/* THE EMBEDDED AGGREGATE, NOT A COUNT OF WHAT IS ON SCREEN.
   `faqCategories` asks PostgREST for faqs(count), so this is every FAQ filed
   under the category — including ones beyond HFQ_FETCH_MAX and ones the current
   filters exclude. Counting db.faqs instead would under-report, and this number
   is what decides whether the Delete button is blocked: a category that looks
   empty because the list is filtered is a category deleted out from under its
   FAQs. */
const hfqFaqCount = (cat) => (cat && cat._faqCount) || 0;

/* ------------------------------------------------------------------ *
 * Small presentational pieces
 * ------------------------------------------------------------------ */

/* Thumbnail cell — the real one is a 60×40 <img src="{{ asset('storage/'.$thumbnail_path) }}">,
   "—" when the column is empty. There is no media server behind this prototype, so the tile is a
   deterministic placeholder that still shows the stored path on hover. */
const HfqThumb = ({ path, size = "sm" }) => {
  if (!path) return <span className="hfq-dash">—</span>;
  const hue = hfqHash(path) % 360;
  const name = path.split("/").pop();
  return (
    <span className={`hfq-thumb hfq-thumb--${size}`} title={`storage/${path}`}
      style={{ background: `linear-gradient(135deg, hsl(${hue} 45% 62%), hsl(${(hue + 38) % 360} 48% 44%))` }}>
      <span className="hfq-thumb__ext">{(name.split(".").pop() || "").toUpperCase()}</span>
    </span>
  );
};

/* is_active cast (Faq.php:50-52 / FaqCategory.php:35-37): 1 → Active (green), 0 → Disabled (neutral). */
const HfqActive = ({ on }) => <span className={`chip ${on ? "chip--ok" : "chip--neutral"}`}>{on ? "Active" : "Disabled"}</span>;

/* index.blade.php:131-141 — a plain All / Active / Disabled link row duplicating the Status select.
   No counts on this screen (unlike the Banners and Blogs chip rows, which do carry them). */
const HfqStatusChips = ({ value, onChange }) => (
  <div className="hfq-chiprow">
    {[["", "All"], ["1", "Active"], ["0", "Disabled"]].map(([v, label]) => (
      <button key={v || "all"} type="button"
        className={`hfq-chipbtn${String(value || "") === v ? " hfq-chipbtn--on" : ""}`}
        onClick={() => onChange(v)}>{label}</button>
    ))}
  </div>
);

/* The only modal on these screens: the JS confirm behind the DELETE form
   (backend.are_you_sure → "Are you sure?"). */
const HfqModal = ({ title, onClose, children, footer }) => (
  <div className="bp-modal-scrim hfq-scrim" onClick={onClose}>
    <div className="bp-modal hfq-modal" onClick={e => e.stopPropagation()}>
      <div className="hfq-modal__head">
        <div className="hfq-modal__title">{title}</div>
        <button className="hfq-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hfq-modal__body">{children}</div>
      {footer && <div className="hfq-modal__foot">{footer}</div>}
    </div>
  </div>
);

/* Label + control + inline error + hint. The FormRequests answer with per-field messages; the Paybo
   forms print them under the input. */
const HfqField = ({ label, required, htmlFor, error, hint, children, inferred }) => (
  <div className={`hfq-field${error ? " hfq-field--err" : ""}`}>
    <label className="hfq-label" htmlFor={htmlFor}>
      {label}{required && <span className="hfq-req">*</span>}
      {inferred && <>{/* label inferred */}</>}
    </label>
    {children}
    {error && <div className="hfq-fielderr"><Icon name="alert" size={11} /> {error}</div>}
    {hint && <div className="hfq-hint">{hint}</div>}
  </div>
);

/* A form section — Settings.jsx-style sectioned panel, headed by HrsSection. */
const HfqPanel = ({ title, sub, actions, children }) => (
  <HrsSection title={title} sub={sub} actions={actions}>
    <div className="panel hfq-panel">{children}</div>
  </HrsSection>
);

/* skin_ids[] — select2 multiple in the real create form. Checkbox grid here so the fan-out is
   readable at a glance; edit never renders it (neither update path accepts a skin change). */
const HfqSkinPicker = ({ skins, value, onChange, invalid }) => {
  const all = (skins || []).map(s => s.id);
  if (!all.length) {
    return <div className="hfq-hint">No brand is visible to this account, so there is nowhere to create a FAQ.</div>;
  }
  return (
    <div className={`hfq-skins${invalid ? " hfq-skins--err" : ""}`}>
      <div className="hfq-skins__grid">
        {skins.map(s => {
          const on = value.indexOf(s.id) !== -1;
          return (
            <button key={s.id} type="button" className={`hfq-skin${on ? " hfq-skin--on" : ""}`}
              onClick={() => onChange(on ? value.filter(x => x !== s.id) : [...value, s.id])}>
              <span className="hfq-skin__box">{on && <Icon name="check" size={10} />}</span>
              <span className="hfq-skin__n">{s.name}</span>
              <span className="hfq-skin__id">#{s.id}</span>
            </button>
          );
        })}
      </div>
      <div className="hfq-skins__acts">
        <button type="button" className="hfq-linkbtn" onClick={() => onChange(all)}>Select all</button>
        <button type="button" className="hfq-linkbtn" onClick={() => onChange([])}>Clear</button>
        <span className="hfq-skins__count">{value.length} of {skins.length} selected</span>
      </div>
    </div>
  );
};

/* ------------------------------------------------------------------ *
 * Fan-out preview — the whole point of the brief's "multi-skin fan-out
 * must be visible" requirement. store()/storeCategory() loop the
 * submitted skin_ids and create ONE ROW PER SKIN inside a single
 * transaction (FaqController L114-122 / L255-262), so this lists every
 * row the Save button is about to write and runs the per-skin slug
 * uniqueness check the service performs (FaqService L130-132 / L279-281)
 * before the operator submits.
 * ------------------------------------------------------------------ */
const HfqFanout = ({ kind, skinIds, slug, categoryId, contextSkinId, db, skins }) => {
  const cat = categoryId ? db.cats.find(c => String(c.id) === String(categoryId)) : null;
  const rows = skinIds.map(id => ({
    id,
    name: hfqSkinName(skins, id),
    taken: !!slug && hfqSlugTaken(db, kind, slug, id, null),
    foreignCat: !!cat && String(cat.skin_id) !== String(id),
  }));
  const collisions = rows.filter(r => r.taken).length;
  const foreign = rows.filter(r => r.foreignCat).length;
  const noun = kind === "faq" ? "FAQ" : "category";

  return (
    <div className={`hfq-fan${collisions ? " hfq-fan--err" : ""}`}>
      <div className="hfq-fan__head">
        <Icon name="grid" size={13} />
        <b>{rows.length === 0 ? "No rows will be written" : `Save writes ${rows.length} row${rows.length === 1 ? "" : "s"} — one ${noun} per selected skin`}</b>
      </div>
      {rows.length > 0 && (
        <ul className="hfq-fan__list">
          {rows.map(r => (
            <li key={r.id} className={r.taken ? "hfq-fan__row hfq-fan__row--err" : "hfq-fan__row"}>
              <span className="hfq-fan__skin">{r.name} <span className="hfq-fan__sid">#{r.id}</span></span>
              <span className="hfq-fan__slug">{slug ? <code>{slug}</code> : <i>slug not set yet</i>}</span>
              <span className="hfq-fan__state">
                {r.taken
                  ? <><Icon name="alert" size={11} /> Slug already exists for this skin</>
                  : <><Icon name="check" size={11} /> new row</>}
              </span>
            </li>
          ))}
        </ul>
      )}
      <div className="hfq-fan__note">
        All rows are written in <b>one transaction</b>. A slug collision on any single skin throws
        {" "}<code>Slug already exists for this skin</code> and rolls the whole fan-out back — nothing is created.
        {rows.length > 1 && <> The rows are independent afterwards: editing one never touches its siblings, and a row cannot be moved between skins later.</>}
      </div>
      {foreign > 0 && (
        /* Documented gap, surfaced rather than silently "fixed": category_id is validated with a
           GLOBAL exists rule and the same value is written to every fanned-out row. */
        <div className="hfq-fan__warn">
          <Icon name="alert" size={12} />
          <span>
            <b>{foreign} of these rows will point at another skin's category.</b> The category you picked
            (<b>{hfqCatName(cat, "en")}</b>, id {cat.id}) belongs to <b>{hfqSkinName(skins, cat.skin_id)}</b>, and create carries one
            {" "}<code>category_id</code> into every row. The rule is <code>exists:faq_categories,id</code> — global, not scoped to the skin —
            so the platform accepts it. The form is left faithful; pick "No category" if you do not want the cross-skin link.
          </span>
        </div>
      )}
      {kind === "faq" && (
        <div className="hfq-fan__note hfq-fan__note--muted">
          Slug auto-fill (<code>POST /faq/generate-slug</code>) de-dups against <b>{hfqSkinName(skins, contextSkinId)}</b> only — its
          validation takes a single <code>skin_id</code>. Collisions on the other selected skins show up above.
        </div>
      )}
    </div>
  );
};

/* ------------------------------------------------------------------ *
 * Language editor — the twelve flat translation columns. Tabs for FAQs
 * (title + content per language is too tall for a flat list); the
 * categories screen uses a plain grid because it is one short field.
 * The dot on each tab is filled when that language has content.
 * ------------------------------------------------------------------ */
const HfqLangTabs = ({ lang, onLang, state }) => (
  <div className="hfq-langtabs" role="tablist">
    {HFQ_LANGS.map(l => {
      const s = state(l.code);   // "full" | "part" | "empty"
      return (
        <button key={l.code} type="button" role="tab" aria-selected={l.code === lang}
          className={`hfq-langtab${l.code === lang ? " hfq-langtab--on" : ""} hfq-langtab--${s}`}
          onClick={() => onLang(l.code)}>
          <span className="hfq-langtab__dot" />
          <span className="hfq-langtab__code">{l.code.toUpperCase()}</span>
          <span className="hfq-langtab__name">{l.name}</span>
        </button>
      );
    })}
  </div>
);

/* thumbnail / image — nullable|image|max:<kb>. Edit adds the remove_<field> hidden flag that the
   trash button on the preview flips to "1" (edit.blade.php:178-191); the service then unlinks the
   old file from the public disk on save (FaqService L162-184). */
const HfqImageField = ({ label, name, maxKb, path, picked, removed, onPick, onRemove, onUndo, error, inferred, disabledReason }) => (
  <HfqField label={label} inferred={inferred} error={error}
    hint={<>Column <code>{name}_url</code> · <code>nullable|image|max:{maxKb}</code> ({(maxKb / 1024).toFixed(0)} MB). isystem stores it as <code>faqs/{name === "thumbnail" ? "thumbnails" : "images"}/&lt;time&gt;_&lt;random10&gt;.&lt;ext&gt;</code> on the public disk.</>}>
    <div className="hfq-img">
      {picked ? (
        <div className="hfq-img__row">
          <HfqThumb path={picked.path} />
          <div className="hfq-img__meta">
            <b>{picked.name}</b>
            <span>{picked.kb.toLocaleString("en-US")} KB · will replace the stored file</span>
          </div>
          <button type="button" className="hfq-iconbtn" title="Discard this file" onClick={onUndo}><Icon name="x" size={12} /></button>
        </div>
      ) : path && !removed ? (
        <div className="hfq-img__row">
          <HfqThumb path={path} />
          <div className="hfq-img__meta">
            <b>{path.split("/").pop()}</b>
            <span><code>storage/{path}</code></span>
          </div>
          <button type="button" className="hfq-iconbtn hfq-iconbtn--danger" title="Remove image" onClick={onRemove}><Icon name="trash" size={12} /></button>
        </div>
      ) : removed ? (
        <div className="hfq-img__row hfq-img__row--removed">
          <span className="hfq-dash">—</span>
          <div className="hfq-img__meta">
            <b>Marked for removal</b>
            <span><code>remove_{name} = 1</code> — the stored file is unlinked on save</span>
          </div>
          <button type="button" className="hfq-iconbtn" title="Undo" onClick={onUndo}><Icon name="refresh" size={12} /></button>
        </div>
      ) : (
        <div className="hfq-img__row hfq-img__row--empty"><span className="hfq-dash">—</span><div className="hfq-img__meta"><span>No file</span></div></div>
      )}
      {/* INERT: disabled, with the reason stated where the control is. There is
          no object storage in this build, so there is nothing to upload to —
          and the version of this control that accepted a file and invented a
          stored path is what the disabling replaces. */}
      <input type="file" className="input input--sm hfq-file" accept="image/*"
        disabled={!!disabledReason} title={disabledReason || undefined} onChange={onPick} />
      {disabledReason && <div className="hfq-hint hfq-hint--block"><Icon name="alert" size={11} /> {disabledReason}</div>}
    </div>
  </HfqField>
);

/* Shared delete confirm. `blocked` renders the server-side guard instead of the confirm. */
const HfqDeleteDialog = ({ title, question, detail, blocked, confirmLabel, onClose, onConfirm }) => (
  <HfqModal title={title} onClose={onClose}
    footer={blocked
      ? <button className="btn btn--secondary" onClick={onClose}>Close</button>
      : <>
        <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
        <button className="btn btn--danger" onClick={onConfirm}><Icon name="trash" size={13} /> {confirmLabel || "Delete"}</button>
      </>}>
    {blocked
      ? <div className="hfq-err"><Icon name="alert" size={13} /> {blocked}</div>
      : <div className="hfq-dlgq">{question}</div>}
    {detail && <div className="hfq-hint">{detail}</div>}
  </HfqModal>
);

/* ================================================================== *
 * FAQ create / edit — GET /faq/create and GET /faq/{id}/edit, both full
 * pages. store() → StoreFaqRequest → CreateFaqDto → FaqService::create
 * (fan-out); update() → UpdateFaqRequest → UpdateFaqDto →
 * FaqService::update (single row, skin fixed).
 * ================================================================== */
const HfqFaqEditor = ({ row, contextSkinId, db, skins, busy, onCancel, onSaved }) => {
  const isNew = !row;
  const [skinIds, setSkinIds] = hfqUseState(isNew ? [Number(contextSkinId)] : [Number(row.skin_id)]);
  const [slug, setSlug] = hfqUseState(isNew ? "" : row.slug);
  const [categoryId, setCategoryId] = hfqUseState(isNew ? "" : (row.category_id == null ? "" : String(row.category_id)));
  const [isActive, setIsActive] = hfqUseState(isNew ? true : !!row.is_active);   // create defaults checked
  const [t, setT] = hfqUseState(() => {
    const base = hfqBlankT();
    if (!isNew) HFQ_LANG_CODES.forEach(c => { base[c] = { title: row.t[c].title, content: row.t[c].content }; });
    return base;
  });
  const [lang, setLang] = hfqUseState("en");
  const [thumb, setThumb] = hfqUseState(null);
  const [image, setImage] = hfqUseState(null);
  const [rmThumb, setRmThumb] = hfqUseState(false);
  const [rmImage, setRmImage] = hfqUseState(false);
  const [errs, setErrs] = hfqUseState({});

  /* The category select is fed ONE skin's categories (FaqService::getAllCategories, active AND
     inactive). On create that is the skin the operator came from — the page is rendered before any
     skin_ids are ticked, so the server cannot know which skins the fan-out will hit.
     UNCLEAR in the reference: which skin's list the real create page loads. */
  const optionSkin = isNew ? contextSkinId : row.skin_id;
  const catOptions = hfqUseMemo(() => hfqCatsForSkin(db, optionSkin), [db, db.cats.length, optionSkin]);

  const setPair = (code, field, v) => setT(x => ({ ...x, [code]: { ...x[code], [field]: v } }));
  const langState = (code) => {
    const cell = t[code] || {};
    const hasT = !!String(cell.title || "").trim();
    const hasC = !!String(cell.content || "").trim();
    return hasT && hasC ? "full" : (hasT || hasC ? "part" : "empty");
  };
  const complete = HFQ_LANG_CODES.filter(c => langState(c) === "full");
  const partial = HFQ_LANG_CODES.filter(c => langState(c) === "part");
  const baseLang = hfqBaseLang(t);

  /* POST /faq/generate-slug on EN-title blur, only when slug is still empty (FaqService L338-350). */
  const maybeGenerateSlug = () => {
    if (slug.trim()) return;
    const title = String(t.en.title || "").trim();
    if (!title) return;
    const next = hfqUniqueSlug(db, "faq", title, optionSkin, isNew ? null : row.id);
    if (next) { setSlug(next); clearErr("slug"); }
  };

  const clearErr = (k) => setErrs(x => { const n = { ...x }; delete n[k]; return n; });

  /* IMAGE UPLOAD IS NOT IMPLEMENTED, AND IT USED TO LOOK AS THOUGH IT WERE.
     ------------------------------------------------------------------
     This function used to accept a file, check its size, and then MAKE UP a
     stored path — `faqs/thumbnails/<a timestamp>_<a hash of the filename>.jpg`
     — imitating the shape PHP's uniqid() produces. Nothing was uploaded
     anywhere; there is no object storage in this build (no bucket, no signed
     URL, nothing in src/supabase.js that speaks to one). While the row lived in
     a module-level object that was merely decorative.

     It is not decorative now. Writing that string into faqs.thumbnail_url would
     put a path to a file that does not exist into the database, and the player
     site would render a broken image for a FAQ an operator was told had one.
     An invented path is an invented fact.

     So the pickers are DISABLED with the reason named, rather than removed: the
     field, its size rule and the stored value all still show, because that is
     the screen isystem has and hiding it would misrepresent the parity gap.
     Any thumbnail_url already in the database displays normally.
     UNCLEAR-FAQ-2: which object store this build should use. Supabase Storage
     is the obvious answer and is not configured; guessing a bucket name would
     produce exactly the broken-path problem above, one layer down. */
  const pickFile = () => () => {};
  const HFQ_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 save = () => {
    const e = {};
    /* StoreFaqRequest / UpdateFaqRequest. Messages are backend.* keys that resolve only in the
       gitignored storage/lang — written here as sensible operator-facing English (label inferred). */
    if (isNew && skinIds.length === 0) e.skin_ids = "Select at least one skin.";
    if (!slug.trim()) e.slug = "The slug is required.";
    else if (slug.trim().length > 255) e.slug = "The slug may not be greater than 255 characters.";
    HFQ_LANG_CODES.forEach(c => {
      if (String(t[c].title || "").length > 255) e[`title_${c}`] = `${hfqLangName(c)} title may not be greater than 255 characters.`;
    });
    /* StoreFaqRequest::withValidator L39-55 — at least one language with BOTH title and content. */
    if (complete.length === 0) e.translations = "At least one language must have both a title and content.";
    /* Per-skin slug uniqueness, checked by the service inside the transaction. */
    const clash = (isNew ? skinIds : [row.skin_id]).filter(id => hfqSlugTaken(db, "faq", slug.trim(), id, isNew ? null : row.id));
    if (!e.slug && clash.length) e.slug = `Slug already exists for this skin (${clash.map(id => hfqSkinName(skins, id)).join(", ")}).`;

    setErrs(e);
    if (Object.keys(e).length) {
      const firstLangErr = HFQ_LANG_CODES.find(c => e[`title_${c}`]);
      if (firstLangErr) setLang(firstLangErr);
      hrsToast("The form has errors", Object.values(e)[0]);
      return;
    }

    const payload = {
      slug: slug.trim(),
      category_id: categoryId === "" ? null : Number(categoryId),
      is_active: isActive ? 1 : 0,
      t: JSON.parse(JSON.stringify(t)),
    };
    onSaved(payload, {
      skinIds: isNew ? skinIds.slice() : [row.skin_id],
      thumb, image, rmThumb, rmImage,
    });
  };

  const cell = t[lang] || { title: "", content: "" };

  return (
    <>
      <div className="hfq-sub">
        <button className="btn btn--ghost btn--sm" onClick={onCancel}>
          <Icon name="chevron_left" size={13} /> Back to FAQs{/* label inferred — backend.faq_back_to_list */}
        </button>
        {!isNew && (
          <span className="hfq-crumb">
            <b>#{row.id}</b>
            <code>{row.slug}</code>
            <span className="chip chip--neutral">{hfqSkinName(skins, row.skin_id)}</span>
            <HfqActive on={row.is_active} />
          </span>
        )}
      </div>

      <Explainer compact title={isNew ? "Creating a FAQ, in plain English" : "Editing a FAQ, in plain English"}
        bullets={isNew ? [
          <>One submit writes <b>one row per selected skin</b> — same slug, same category, same status, same twelve translation columns. The rows are independent from that moment on.</>,
          <>Content lives in <b>flat columns</b> on the <code>faqs</code> table (<code>title_en</code>…<code>title_pt_br</code>, <code>content_*</code>), not in a translations table. Every language is optional; the only rule is that <b>at least one language carries both a title and content</b>.</>,
          <>The first language that has both is also copied into the legacy <code>title</code>/<code>content</code> columns (<code>CreateFaqDto::toModelArray</code> L82-88) — that is the last stop of the fallback chain the player-facing API uses.</>,
        ] : [
          <>Update touches <b>this row only</b>. There is no skin field on the edit form, so a FAQ cannot be moved to another skin — and its siblings on other skins, if it was created as a fan-out, do not change.</>,
          <>An unticked <b>Active</b> toggle disables the FAQ: <code>UpdateFaqDto</code> treats an absent <code>is_active</code> as <code>false</code>.</>,
          <>Replacing or removing an image unlinks the previous file from the public disk on save (<code>FaqService</code> L162-184). There is no version history and no audit log for this screen.</>,
        ]} />

      <HfqPanel title="Data" sub={isNew ? "Where this FAQ is created and how players reach it" : "Identity and placement of this FAQ"}>
        {isNew ? (
          <HfqField label="Skins" required error={errs.skin_ids} inferred
            hint={<><code>skin_ids[]</code> · <code>required|array|min:1</code>, each <code>exists:skins,id</code>. The list is scoped to <code>Auth::user()-&gt;getSkins()</code>.</>}>
            <HfqSkinPicker skins={skins} value={skinIds} onChange={(v) => { setSkinIds(v); clearErr("skin_ids"); }} invalid={!!errs.skin_ids} />
          </HfqField>
        ) : (
          <HfqField label="Skin"
            hint={<>Fixed after creation — the edit form carries no skin field, so <code>faqs.skin_id</code> cannot change.</>}>
            <div className="hfq-static"><span className="chip chip--neutral">{hfqSkinName(skins, row.skin_id)}</span> <span className="hfq-muted">#{row.skin_id}</span></div>
          </HfqField>
        )}

        <HfqField label="Slug" required htmlFor="hfq-slug" error={errs.slug}
          hint={<><code>required|string|max:255</code> on create, <code>nullable</code> in the update rules but still <code>required</code> in the HTML. Unique <b>per skin</b> — the check lives in <code>FaqService</code>, not in the FormRequest. Auto-filled from the English title when you leave it empty.</>}>
          <input id="hfq-slug" className={`input hfq-w-full${errs.slug ? " hfq-invalid" : ""}`} maxLength={255}
            value={slug} placeholder="account-registration"
            onChange={e => { setSlug(e.target.value); clearErr("slug"); }} />
        </HfqField>

        <HfqField label="Category" htmlFor="hfq-cat"
          hint={<>
            <code>nullable|exists:faq_categories,id</code> — the rule is <b>global, not skin-scoped</b>. Options are
            {" "}<b>{hfqSkinName(skins, optionSkin)}</b>'s categories, active <b>and</b> inactive ones (<code>FaqService::getAllCategories</code>).
          </>}>
          <select id="hfq-cat" className="select hfq-w-full" value={categoryId} onChange={e => setCategoryId(e.target.value)}>
            <option value="">No category</option>
            {catOptions.map(c => (
              <option key={c.id} value={c.id}>{hfqCatName(c, "en")}{c.is_active ? "" : " — disabled"}</option>
            ))}
          </select>
        </HfqField>

        <HfqField label="Status"
          hint={<><code>is_active</code> · <code>boolean</code>, normalised in <code>prepareForValidation</code>. New FAQs start checked.</>}>
          <Toggle value={isActive} onChange={setIsActive} onLabel="Active" offLabel="Disabled" size="sm" />
        </HfqField>

        {isNew && (
          <HfqFanout kind="faq" skinIds={skinIds} slug={slug.trim()} categoryId={categoryId}
            contextSkinId={optionSkin} db={db} skins={skins} />
        )}
      </HfqPanel>

      <HfqPanel title="Images" sub="Both optional — the list only ever shows the thumbnail">
        {/* Removal IS wired — clearing a column needs no storage — and upload is
            not. The asymmetry is stated rather than smoothed over. */}
        <HfqImageField label="Thumbnail" inferred name="thumbnail" maxKb={HFQ_MAX_THUMB_KB}
          disabledReason={HFQ_NO_UPLOAD}
          path={isNew ? "" : row.thumbnail_path} picked={thumb} removed={rmThumb} error={errs.thumbnail}
          onPick={pickFile()}
          onRemove={() => setRmThumb(true)}
          onUndo={() => { setThumb(null); setRmThumb(false); }} />
        <HfqImageField label="Image" inferred name="image" maxKb={HFQ_MAX_IMAGE_KB}
          disabledReason={HFQ_NO_UPLOAD}
          path={isNew ? "" : row.image_path} picked={image} removed={rmImage} error={errs.image}
          onPick={pickFile()}
          onRemove={() => setRmImage(true)}
          onUndo={() => { setImage(null); setRmImage(false); }} />
      </HfqPanel>

      <HfqPanel title="Translations" sub="Twelve flat columns per row — every language optional"
        actions={<span className="hfq-langsum">
          <b>{complete.length}</b> complete{partial.length > 0 && <> · <b>{partial.length}</b> half-filled</>}
        </span>}>
        {errs.translations && <div className="hfq-err"><Icon name="alert" size={13} /> {errs.translations}</div>}

        <HfqLangTabs lang={lang} onLang={setLang} state={langState} />

        <div className="hfq-tr">
          <HfqField label={`Title — ${hfqLangName(lang)}`} htmlFor="hfq-title" error={errs[`title_${lang}`]}
            hint={<>Column <code>title_{lang}</code> · <code>nullable|string|max:255</code></>}>
            <input id="hfq-title" className={`input hfq-w-full${errs[`title_${lang}`] ? " hfq-invalid" : ""}`} maxLength={255}
              value={cell.title}
              onChange={e => { setPair(lang, "title", e.target.value); clearErr(`title_${lang}`); clearErr("translations"); }}
              onBlur={lang === "en" ? maybeGenerateSlug : undefined} />
          </HfqField>
          <HfqField label={`Content — ${hfqLangName(lang)}`} htmlFor="hfq-content"
            hint={<>Column <code>content_{lang}</code> · <code>nullable|string</code>. The player-facing API renders it as HTML.</>}>
            <textarea id="hfq-content" className="input hfq-w-full hfq-ta" rows={7} value={cell.content}
              onChange={e => { setPair(lang, "content", e.target.value); clearErr("translations"); }} />
          </HfqField>
        </div>

        <div className="hfq-hint hfq-hint--block">
          <b>Rule:</b> at least one language must carry <b>both</b> a title and content
          (<code>StoreFaqRequest::withValidator</code> L39-55). Currently satisfied by{" "}
          {complete.length ? <b>{complete.map(hfqLangName).join(", ")}</b> : <b className="hfq-danger">no language</b>}.
          {baseLang && <> The legacy <code>title</code>/<code>content</code> columns will be written from <b>{hfqLangName(baseLang)}</b> — the first language with both.</>}
          {" "}Missing languages fall back at read time: requested → English → the legacy column → any language that has something.
        </div>
      </HfqPanel>

      <div className="hfq-savebar">
        <button className="btn btn--secondary" onClick={onCancel} disabled={busy}>Cancel</button>
        {/* Disabled while the save is in flight. A fan-out is N sequential
            writes, so a second click during the first one would start a second
            fan-out over rows the first is still creating. */}
        <button className="btn btn--primary" onClick={save} disabled={busy}>
          <Icon name="check" size={13} /> {busy ? "Saving…" : (isNew ? `Create${skinIds.length > 1 ? ` on ${skinIds.length} skins` : ""}` : "Save changes")}
        </button>
      </div>
    </>
  );
};

/* ================================================================== *
 * FAQ Category create / edit — GET /faq/categories/create and
 * /faq/categories/{id}/edit. storeCategory/updateCategory pass the raw
 * validated array to the service; there is no DTO on this write path.
 * ================================================================== */
const HfqCatEditor = ({ row, contextSkinId, db, skins, busy, onCancel, onSaved }) => {
  const isNew = !row;
  const [skinIds, setSkinIds] = hfqUseState(isNew ? [Number(contextSkinId)] : [Number(row.skin_id)]);
  const [slug, setSlug] = hfqUseState(isNew ? "" : row.slug);
  const [isActive, setIsActive] = hfqUseState(isNew ? true : !!row.is_active);
  const [n, setN] = hfqUseState(() => {
    const base = hfqBlankN();
    if (!isNew) HFQ_LANG_CODES.forEach(c => { base[c] = row.n[c] || ""; });
    return base;
  });
  const [errs, setErrs] = hfqUseState({});
  const clearErr = (k) => setErrs(x => { const nx = { ...x }; delete nx[k]; return nx; });

  const filled = HFQ_LANG_CODES.filter(c => String(n[c] || "").trim());

  /* categories/create.blade.php:143-155 slugs client-side from the EN name on blur — lowercase,
     strip non-word, spaces → dashes — and, unlike the FAQ screen, never asks the server whether the
     result is free. Known-bug policy: the same uniqueness pass runs here (and the fan-out preview
     shows the per-skin result) instead of letting the operator discover the clash on submit. */
  const maybeSlug = () => {
    if (slug.trim()) return;
    const name = String(n.en || "").trim();
    if (!name) return;
    const next = hfqUniqueSlug(db, "cat", name, isNew ? (skinIds[0] != null ? skinIds[0] : contextSkinId) : row.skin_id, isNew ? null : row.id);
    if (next) { setSlug(next); clearErr("slug"); }
  };

  const save = () => {
    const e = {};
    if (isNew && skinIds.length === 0) e.skin_ids = "Select at least one skin.";
    if (!slug.trim()) e.slug = "The slug is required.";
    else if (slug.trim().length > 255) e.slug = "The slug may not be greater than 255 characters.";
    HFQ_LANG_CODES.forEach(c => {
      if (String(n[c] || "").length > 255) e[`name_${c}`] = `${hfqLangName(c)} name may not be greater than 255 characters.`;
    });
    /* StoreFaqCategoryRequest::withValidator L39-50 — at least one name_<lang>, error attached to name_en. */
    if (filled.length === 0) e.name_en = "At least one language name is required.";
    const clash = (isNew ? skinIds : [row.skin_id]).filter(id => hfqSlugTaken(db, "cat", slug.trim(), id, isNew ? null : row.id));
    if (!e.slug && clash.length) e.slug = `Slug already exists for this skin (${clash.map(id => hfqSkinName(skins, id)).join(", ")}).`;

    setErrs(e);
    if (Object.keys(e).length) { hrsToast("The form has errors", Object.values(e)[0]); return; }

    onSaved({ slug: slug.trim(), is_active: isActive ? 1 : 0, n: { ...n } }, { skinIds: isNew ? skinIds.slice() : [row.skin_id] });
  };

  return (
    <>
      <div className="hfq-sub">
        <button className="btn btn--ghost btn--sm" onClick={onCancel}>
          <Icon name="chevron_left" size={13} /> Back to categories{/* label inferred — backend.faq_back_to_categories */}
        </button>
        {!isNew && (
          <span className="hfq-crumb">
            <b>#{row.id}</b>
            <code>{row.slug}</code>
            <span className="chip chip--neutral">{hfqSkinName(skins, row.skin_id)}</span>
            <HfqActive on={row.is_active} />
            <span className="hfq-muted">{hfqFaqCount(row)} FAQ(s)</span>
          </span>
        )}
      </div>

      <Explainer compact title={isNew ? "Creating a category, in plain English" : "Editing a category, in plain English"}
        bullets={isNew ? [
          <>A category is just a per-skin label with twelve name columns. One submit writes <b>one category row per selected skin</b>, each with its own id — categories are never shared between skins.</>,
          <>The slug is auto-filled from the English name and must be unique <b>within a skin</b>. On the real platform that uniqueness is only checked when you press Save (this screen checks as you type — see the code comment).</>,
          <>A category cannot be deleted while any FAQ points at it, so creating one is cheap and removing one may not be.</>,
        ] : [
          <>Update touches this row only, and the skin is fixed — the edit form has no skin field.</>,
          <>Disabling a category does <b>not</b> hide the FAQs inside it; it only marks the category. The FAQ list's Category filter keeps offering disabled categories on purpose.</>,
          <>There is no audit trail on this screen: no events, no listeners, no <code>*_logs</code> table.</>,
        ]} />

      <HfqPanel title="Data" sub={isNew ? "Where the category is created and how it is addressed" : "Identity of this category"}>
        {isNew ? (
          <HfqField label="Skins" required error={errs.skin_ids} inferred
            hint={<><code>skin_ids[]</code> · <code>required|array|min:1</code>, each <code>exists:skins,id</code>.</>}>
            <HfqSkinPicker skins={skins} value={skinIds} onChange={(v) => { setSkinIds(v); clearErr("skin_ids"); }} invalid={!!errs.skin_ids} />
          </HfqField>
        ) : (
          <HfqField label="Skin" hint={<>Fixed after creation — <code>updateCategory</code> accepts no skin change.</>}>
            <div className="hfq-static"><span className="chip chip--neutral">{hfqSkinName(skins, row.skin_id)}</span> <span className="hfq-muted">#{row.skin_id}</span></div>
          </HfqField>
        )}

        <HfqField label="Slug" required htmlFor="hfq-cslug" error={errs.slug}
          hint={<><code>required|string|max:255</code> on create (<code>nullable</code> in the update rules, still <code>required</code> in the HTML), unique per skin. Auto-filled from the English name when left empty.</>}>
          <input id="hfq-cslug" className={`input hfq-w-full${errs.slug ? " hfq-invalid" : ""}`} maxLength={255}
            value={slug} placeholder="payments"
            onChange={e => { setSlug(e.target.value); clearErr("slug"); }} />
        </HfqField>

        <HfqField label="Status" hint={<><code>is_active</code> · <code>boolean</code>. New categories start checked.</>}>
          <Toggle value={isActive} onChange={setIsActive} onLabel="Active" offLabel="Disabled" size="sm" />
        </HfqField>

        {isNew && <HfqFanout kind="cat" skinIds={skinIds} slug={slug.trim()} categoryId="" contextSkinId={contextSkinId} db={db} skins={skins} />}
      </HfqPanel>

      <HfqPanel title="Names" sub="Twelve flat name columns — all optional, at least one required"
        actions={<span className="hfq-langsum"><b>{filled.length}</b> of {HFQ_LANGS.length} filled</span>}>
        {errs.name_en && <div className="hfq-err"><Icon name="alert" size={13} /> {errs.name_en}</div>}
        <div className="hfq-namegrid">
          {HFQ_LANGS.map(l => (
            <div key={l.code} className={`hfq-namecell${String(n[l.code] || "").trim() ? " hfq-namecell--on" : ""}`}>
              <label className="hfq-label hfq-label--sm" htmlFor={`hfq-n-${l.code}`}>
                <span className="hfq-langcode">{l.code.toUpperCase()}</span> {l.name}
              </label>
              <input id={`hfq-n-${l.code}`} className={`input input--sm hfq-w-full${errs[`name_${l.code}`] ? " hfq-invalid" : ""}`}
                maxLength={255} value={n[l.code]}
                onChange={e => { setN(x => ({ ...x, [l.code]: e.target.value })); clearErr(`name_${l.code}`); clearErr("name_en"); }}
                onBlur={l.code === "en" ? maybeSlug : undefined} />
              {errs[`name_${l.code}`] && <div className="hfq-fielderr">{errs[`name_${l.code}`]}</div>}
            </div>
          ))}
        </div>
        <div className="hfq-hint hfq-hint--block">
          Column <code>name_&lt;lang&gt;</code> · <code>nullable|string|max:255</code> each. The list and the FAQ pickers read
          the English name with the same fallback chain the FAQs use, and sort on <code>name_en</code> — a category with no
          English name sorts to the top and shows its fallback.
        </div>
      </HfqPanel>

      <div className="hfq-savebar">
        <button className="btn btn--secondary" onClick={onCancel} disabled={busy}>Cancel</button>
        {/* Disabled while the save is in flight. A fan-out is N sequential
            writes, so a second click during the first one would start a second
            fan-out over rows the first is still creating. */}
        <button className="btn btn--primary" onClick={save} disabled={busy}>
          <Icon name="check" size={13} /> {busy ? "Saving…" : (isNew ? `Create${skinIds.length > 1 ? ` on ${skinIds.length} skins` : ""}` : "Save changes")}
        </button>
      </div>
    </>
  );
};

/* ================================================================== *
 * SCREEN 1 — CMS ▾ → FAQ · admin.faq.index · GET /faq
 * ================================================================== */
const HostCmsFaq = () => {
  window.useLocale && window.useLocale();

  /* Defaults: the skin filter defaults to the FIRST available skin (FaqController.php:44-46) — it is
     never "all skins"; everything else defaults empty/all. WHICH skin is first is now the
     database's answer, so it cannot be known before the brands load. Empty until then, and the
     effect below picks it up once. */
  const [skinId, setSkinId] = hfqUseState("");
  const { db, skins, version: dbv, loading, error, retry, feeds, truncated, totals } = hfqUseDb(skinId);
  const save = useHrsSave(feeds);

  hfqUseEffect(() => {
    if (!skinId && skins.length) setSkinId(String(skins[0].id));
  }, [skins]);

  const [draft, setDraft] = hfqUseState({ search: "", category_id: "", is_active: "" });
  const [applied, setApplied] = hfqUseState({ search: "", category_id: "", is_active: "" });
  const [page, setPage] = hfqUseState(0);
  const [view, setView] = hfqUseState({ name: "list" });    // list | create | edit
  const [del, setDel] = hfqUseState(null);

  const cats = hfqUseMemo(() => hfqCatsForSkin(db, skinId), [db, dbv, skinId]);

  const rows = hfqUseMemo(() => {
    const list = db.faqs.filter(f => String(f.skin_id) === String(skinId));
    return list
      .filter(f => hfqMatchesSearch(f, applied.search))
      .filter(f => !applied.category_id || String(f.category_id) === String(applied.category_id))
      /* FaqController.php:54-59 keeps is_active='0' alive through a custom array_filter callback, so
         "Disabled" really does filter instead of being dropped as an empty value. */
      .filter(f => applied.is_active === "" || String(f.is_active) === String(applied.is_active))
      .sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : b.id - a.id));  // fixed created_at DESC
  }, [db, dbv, skinId, applied]);

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

  /* Skin / Category / Status auto-submit on change (the real selects carry an onchange submit);
     only the free-text Search waits for the Apply button — hence the draft/applied split. */
  const commit = (patch) => { const next = { ...draft, ...patch }; setDraft(next); setApplied(next); setPage(0); };
  const onChange = (k, v) => {
    if (k === "skin_id") {
      /* The real page just re-submits with whatever category_id is still in the query string, which
         then matches nothing on the new skin. Cleared here because the option no longer exists in
         the list the new skin renders. */
      setSkinId(v);
      commit({ category_id: "" });
      return;
    }
    if (k === "search") { setDraft(d => ({ ...d, search: v })); return; }
    commit({ [k]: v });
  };

  const FIELDS = [
    { key: "search", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Title or content, any language…",
      tip: <>LIKE <code>%term%</code> across <b>all twelve</b> <code>title_*</code> columns <b>and</b> all twelve <code>content_*</code> columns (<code>FaqRepository</code> L73-82) — a Hungarian answer matches even when you are reading the English list.</> },
    /* defaultValue = the first available skin, exactly as FaqController.php:44-46 picks it — so the
       shell's pill/"Clear all" affordances reset to the real default instead of to an empty skin. */
    { key: "skin_id", label: "Skin", type: "select", icon: "flag", width: 190,
      defaultValue: skins.length ? String(skins[0].id) : "",
      options: skins.map(s => ({ value: String(s.id), label: s.name })),
      tip: <>Options come from <code>Auth::user()-&gt;getSkins()</code> — every skin for super admin, own skin + <code>multiple_skins</code> otherwise. There is no "all skins" option: the list always shows exactly one skin, defaulting to the first available one.</> },
    { key: "category_id", label: "Category", type: "select", icon: "tag", width: 200, placeholder: "All categories",
      options: cats.map(c => ({ value: String(c.id), label: `${hfqCatName(c, "en")}${c.is_active ? "" : " — disabled"}` })),
      tip: <>Every category of the selected skin, <b>active and inactive</b>, ordered by <code>name_en</code>.</> },
    { key: "is_active", label: "Status", type: "select", icon: "shield", width: 160, placeholder: "All",
      options: [{ value: "1", label: "Active" }, { value: "0", label: "Disabled" }] },
  ];

  /* DIVERGENCE (known-bug policy): destroy() redirects without skin_id, so the real platform drops
     the operator back onto the first skin's list after every delete. The selected skin is kept here. */
  // <!-- SUGGESTION: append skin_id to the redirect in FaqController::destroy (L185-201) and destroyCategory (L322-339). -->
  const doDeleteFaq = (row) => {
    /* SOFT delete: `faqs` carries deleted_at, so sb.remove stamps it and the row
       and all twelve of its translations survive. That is deliberate — a FAQ
       deleted by mistake is recoverable, and the translations are the expensive
       part to recreate. The child rows are NOT stamped (they have no deleted_at)
       and do not need to be: the read resource filters on the parent. */
    save.run(() => window.sb.remove("faqs", row.id), {
      done: `FAQ "${hfqFaqTitle(row, "en") || row.slug}" deleted`,
      fail: `FAQ #${row.id} was not deleted`,
    }).then(() => setDel(null));
  };

  /* CREATE IS A FAN-OUT AND A FAN-OUT IS NOT ATOMIC HERE.
     isystem loops the submitted skin_ids inside ONE database transaction, so
     either every skin gets the FAQ or none does. app_write() takes one resource
     per call, so this is N sequential inserts and then N translation batches —
     an interruption partway leaves some skins with the FAQ and some without.

     Not hidden: the toast reports exactly which skins were written and which
     were not, and the list refreshes from the database so what is on screen is
     what is stored.
     UNCLEAR-FAQ-1: whether a multi-skin content create deserves its own RPC to
     regain the transaction. It is not a money path, and a partial fan-out is
     visible and re-runnable, so it is recorded rather than guessed at. */
  const saveFaq = (payload, meta) => {
    save.run(async () => {
      const targets = view.name === "create" ? meta.skinIds : [view.row.skin_id];
      const ok = [], bad = [], partial = [];

      for (const sid of targets) {
        const body = {
          skin_id: Number(sid),
          slug: payload.slug,
          category_id: payload.category_id,
          active: !!payload.is_active,
        };
        /* Clearing a column needs no object storage, so removal is wired while
           upload is not — see HFQ_NO_UPLOAD. Sent only when the operator asked,
           so a save that touches nothing image-related leaves both alone. */
        if (meta.rmThumb) body.thumbnail_url = null;
        if (meta.rmImage) body.image_url = null;
        let parentId, res;
        if (view.name === "create") {
          res = await window.sb.create("faqs", body);
          parentId = res && res.ok && res.data ? res.data.id : null;
        } else {
          /* skin_id is NOT sent on update — the edit form carries no skin field
             and faqs.skin_id cannot change, exactly as isystem has it. */
          delete body.skin_id;
          res = await window.sb.update("faqs", view.row.id, body);
          parentId = view.row.id;
        }
        if (!res || !res.ok || !parentId) {
          bad.push(`${hfqSkinName(skins, sid)}: ${(res && res.error && res.error.message) || "refused"}`);
          continue;
        }

        const tr = await hfqSyncTr({
          resource: "faqTranslations", fkColumn: "faq_id", parentId,
          existing: view.name === "create" ? [] : (view.row._tr || []),
          next: payload.t, isCat: false,
        });
        if (tr.failed.length) partial.push(`${hfqSkinName(skins, sid)} — the FAQ row saved but ${tr.failed.length} language(s) did not: ${tr.failed.join("; ")}`);
        else ok.push(`${hfqSkinName(skins, sid)} (#${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: view.name === "create"
        ? `FAQ created on ${meta.skinIds.length} skin${meta.skinIds.length === 1 ? "" : "s"}`
        : `FAQ #${view.row.id} saved`,
      fail: view.name === "create" ? "The FAQ was not created everywhere" : "The FAQ was not fully saved",
    }).then(res => { if (res && res.ok) { setView({ name: "list" }); setPage(0); } });
  };

  /* ---- editors ---- */
  if (view.name === "create" || view.name === "edit") {
    return (
      <HrsShell
        title={view.name === "create" ? "New FAQ" : `Edit FAQ #${view.row.id}`}
        subtitle={view.name === "create"
          ? "GET /faq/create → POST /faq — one row per selected skin"
          : `GET /faq/${view.row.id}/edit → PUT /faq/${view.row.id}`}
        gate={<>Same as the list: <b>no permission check of any kind</b>. <code>StoreFaqRequest</code> and <code>UpdateFaqRequest</code> both return <code>authorize(): true</code>, and the controller adds nothing.</>}>
        <HfqFaqEditor row={view.name === "edit" ? view.row : null} contextSkinId={skinId} db={db}
          skins={skins} busy={save.busy}
          onCancel={() => setView({ name: "list" })} onSaved={saveFaq} />
      </HrsShell>
    );
  }

  /* ---- list ---- */
  const columns = [
    { key: "id", label: "ID", width: 84, render: r => <span className="hfq-id">{r.id}</span> },
    /* backend.faq_thumbnail — label inferred */
    { key: "thumbnail_path", label: "Thumbnail", width: 96, render: r => <HfqThumb path={r.thumbnail_path} /> },
    { key: "title", label: "Title", render: r => (
      <button className="hfq-rowlink" title="Edit" onClick={(e) => { e.stopPropagation(); setView({ name: "edit", row: r }); }}>
        <span className="hfq-rowlink__t">{hfqFaqTitle(r, "en") || <i>untitled</i>}</span>
        <span className="hfq-rowlink__s">{r.slug}</span>
      </button>
    ) },
    { key: "category_id", label: "Category", width: 180, render: r => {
      const c = db.cats.find(x => String(x.id) === String(r.category_id));
      return c ? <span className="hfq-cat">{hfqCatName(c, "en")}</span> : <span className="hfq-dash">—</span>;
    } },
    { key: "is_active", label: "Status", width: 120, render: r => <HfqActive on={r.is_active} /> },
    /* backend.created — label inferred */
    { key: "created_at", label: "Created", width: 170, render: r => <span className="hfq-when">{hfqDate(r.created_at)}</span> },
    { key: "_acts", label: "Actions", align: "center", width: 110, render: r => (
      <div className="hfq-acts">
        <button className="hfq-act hfq-act--edit" title="Edit" onClick={(e) => { e.stopPropagation(); setView({ name: "edit", row: r }); }}><Icon name="edit" size={13} /></button>
        <button className="hfq-act hfq-act--danger" title="Delete" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={13} /></button>
      </div>
    ) },
  ];

  const filterActive = applied.search || applied.category_id || applied.is_active !== "";

  return (
    <HrsShell
      title="FAQ"
      subtitle="Player-facing help entries, per skin, in up to twelve languages"
      gate={<>
        <b>There is no permission gate on this screen.</b> The only thing in front of{" "}
        <code>GET /faq</code> and its create/store/edit/update/destroy siblings is the route middleware —{" "}
        <code>admin</code> + <code>adminsettings</code> and <code>auth</code> + <code>admin</code> + <code>2fa</code> + <code>g2fa</code>.
        {" "}<code>Admin\FaqController</code> performs no check at all (its CMS siblings <code>BlogController</code> and{" "}
        <code>SlideshowsController</code> both <code>abort(404)</code> here), and all four FormRequests return <code>authorize(): true</code>.{" "}
      </>}
      gateNote={<>
        What <i>is</i> gated is the sidebar <b>link</b>: <code>$can_manage_cms</code> ={" "}
        <code>isadmin()</code> · or skin admin + <code>enable_cms</code> · or customer care +{" "}
        <code>support_cms_banners</code>. That hides a menu item, it does not protect a URL — any authenticated,
        2FA'd back-office user who types <code>/faq</code> gets in. The one real limit is data scoping:
        every query runs against <code>Auth::user()-&gt;getSkins()</code>.
      </>}
      explainer={{ title: "What this screen is, in plain English", bullets: [
        <>One row per FAQ <b>per skin</b>. Translations are twelve flat column pairs on the row (<code>title_en</code>…<code>content_pt_br</code>), all optional; readers fall back requested language → English → the legacy column → any language that has something.</>,
        <><b>Creating fans out.</b> One submit writes one row per selected skin inside a single transaction — the create form shows exactly which rows it will write before you press Save. Editing never fans out: a row's skin is fixed for life.</>,
        <>The list is always scoped to <b>one skin</b> (there is no "all skins" view) and is fixed-sorted by <b>newest first</b>; no column is sortable and there is no export.</>,
        <>Deleting unlinks the stored thumbnail and image first, then the row. There is no audit log, no events and no cache on this feature — the change is immediately live for players, because the same <code>FaqService</code> serves the frontend API.</>,
      ] }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setView({ name: "create" })}>
          <Icon name="plus" size={14} /> New FAQ{/* label inferred */}
        </button>
      }>

      <HrsFilters
        fields={FIELDS}
        values={{ ...draft, skin_id: String(skinId) }}
        onChange={onChange}
        onSearch={(v) => { setApplied({ search: v.search || "", category_id: v.category_id || "", is_active: v.is_active == null ? "" : v.is_active }); setPage(0); }}
        /* Reset returns every filter to its documented default — including the skin, whose default
           is the first available one rather than "none". */
        onReset={() => { const empty = { search: "", category_id: "", is_active: "" }; setDraft(empty); setApplied(empty); setSkinId(skins.length ? String(skins[0].id) : ""); setPage(0); }}
        resultLabel={hrsInt(rows.length)} />

      <HfqStatusChips value={applied.is_active} onChange={(v) => commit({ is_active: v })} />

      {/* THE SEARCH IS A CLIENT FILTER AND ISYSTEM'S IS NOT.
          isystem LIKEs %term% across twenty-four flat columns in the query, so
          it searches the whole table. Here the text lives in faq_translations,
          one row per language, and PostgREST cannot filter a parent by an
          embedded child's contents — so the search runs over what was fetched.
          For every brand under HFQ_FETCH_MAX those are the same answer. When
          they are not, this says so rather than quietly returning a subset. */}
      {truncated.length > 0 && (
        <div className="hfq-err">
          <Icon name="alert" size={13} /> This brand has more {truncated.join(" and ")} than the {hrsInt(HFQ_FETCH_MAX)} this
          screen fetches{totals.faqs != null ? ` (${hrsInt(totals.faqs)} FAQs in total)` : ""}. The list and the search below
          cover the fetched rows only.
        </div>
      )}

      {/* HrsAsync takes a state object and a render function, which does not fit
          a table that already owns its own empty state and its own card layout.
          The two states it would supply are supplied directly instead — same
          components, same look, no second empty. */}
      {loading ? <HrsSkeleton rows={6} cols={7} /> : error ? <HrsError error={error} onRetry={retry} /> : (
      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={(r) => setView({ name: "edit", row: r })}
        empty={filterActive
          ? "No FAQ matches these filters on this skin."
          : `No FAQs on ${hfqSkinName(skins, skinId)} yet — create one with New FAQ (it can be fanned out to several skins at once).`}
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{hfqFaqTitle(r, "en") || "untitled"}</b>
              <HfqActive on={r.is_active} />
            </div>
            <div className="hfq-card__sub"><code>{r.slug}</code> · #{r.id}</div>
            <div className="hrs-card__grid">
              <span>Category</span><b>{(() => { const c = db.cats.find(x => String(x.id) === String(r.category_id)); return c ? hfqCatName(c, "en") : "—"; })()}</b>
              <span>Created</span><b>{hfqDate(r.created_at)}</b>
              <span>Thumbnail</span><b>{r.thumbnail_path ? r.thumbnail_path.split("/").pop() : "—"}</b>
            </div>
            <div className="hfq-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); setView({ name: "edit", row: r }); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hfq-card__del" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />

      )}

      {/* 15 per page, hardcoded in the controller — the real footer is a Prev/Next pair with
          "Showing x–y of N · Page a / b" and no page-size control, so no onPageSize here. */}
      <HrsPager page={safePage} pageSize={HFQ_PER_PAGE} total={rows.length} onPage={setPage} />

      {del && (
        <HfqDeleteDialog
          title="Delete FAQ"
          question={<>Are you sure?</>}
          detail={<>
            <b>{hfqFaqTitle(del, "en") || del.slug}</b> (<code>{del.slug}</code>, id {del.id}) on <b>{hfqSkinName(skins, del.skin_id)}</b>.{" "}
            <code>DELETE /faq/{del.id}</code> unlinks the stored thumbnail and image from the public disk first, then removes the row
            (<code>FaqService</code> L199-212). No permission is checked, nothing is logged, and the entry disappears from the player-facing
            API immediately. Only this row is affected — copies of the same FAQ on other skins are separate rows.
          </>}
          onClose={() => setDel(null)}
          onConfirm={() => doDeleteFaq(del)} />
      )}
    </HrsShell>
  );
};

/* ================================================================== *
 * SCREEN 2 — CMS ▾ → FAQ Categories · admin.faq.categories.index
 * GET /faq/categories
 * ================================================================== */
const HostCmsFaqCategories = () => {
  window.useLocale && window.useLocale();

  const [skinId, setSkinId] = hfqUseState("");
  const { db, skins, version: dbv, loading, error, retry, feeds, truncated } = hfqUseDb(skinId);
  const save = useHrsSave(feeds);

  hfqUseEffect(() => {
    if (!skinId && skins.length) setSkinId(String(skins[0].id));
  }, [skins]);

  const [view, setView] = hfqUseState({ name: "list" });
  const [del, setDel] = hfqUseState(null);

  const rows = hfqUseMemo(() => hfqCatsForSkin(db, skinId), [db, dbv, skinId]);   // ORDER BY name_en ASC, unpaginated

  const FIELDS = [
    { key: "skin_id", label: "Skin", type: "select", icon: "flag", width: 220,
      defaultValue: skins.length ? String(skins[0].id) : "",
      options: skins.map(s => ({ value: String(s.id), label: s.name })),
      tip: <>The only filter on this screen — no search, no status, no category filter. Options come from <code>Auth::user()-&gt;getSkins()</code>, defaulting to the first available skin.</> },
  ];

  /* Same fan-out caveat as the FAQ screen: N inserts, not one transaction, and
     the toast names every skin that did and did not get the row. */
  const saveCat = (payload, meta) => {
    save.run(async () => {
      const targets = view.name === "create" ? meta.skinIds : [view.row.skin_id];
      const ok = [], bad = [], partial = [];

      for (const sid of targets) {
        const body = { skin_id: Number(sid), slug: payload.slug, active: !!payload.is_active };
        let parentId, res;
        if (view.name === "create") {
          res = await window.sb.create("faqCategories", body);
          parentId = res && res.ok && res.data ? res.data.id : null;
        } else {
          delete body.skin_id;
          res = await window.sb.update("faqCategories", view.row.id, body);
          parentId = view.row.id;
        }
        if (!res || !res.ok || !parentId) {
          bad.push(`${hfqSkinName(skins, sid)}: ${(res && res.error && res.error.message) || "refused"}`);
          continue;
        }

        const tr = await hfqSyncTr({
          resource: "faqCategoryTranslations", fkColumn: "faq_category_id", parentId,
          existing: view.name === "create" ? [] : (view.row._tr || []),
          next: payload.n, isCat: true,
        });
        if (tr.failed.length) partial.push(`${hfqSkinName(skins, sid)} — the category saved but ${tr.failed.length} name(s) did not: ${tr.failed.join("; ")}`);
        else ok.push(`${hfqSkinName(skins, sid)} (#${parentId}, ${tr.wrote} name${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: view.name === "create"
        ? `Category created on ${meta.skinIds.length} skin${meta.skinIds.length === 1 ? "" : "s"}`
        : `Category #${view.row.id} saved`,
      fail: view.name === "create" ? "The category was not created everywhere" : "The category was not fully saved",
    }).then(res => { if (res && res.ok) setView({ name: "list" }); });
  };

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

  if (view.name === "create" || view.name === "edit") {
    return (
      <HrsShell
        title={view.name === "create" ? "New FAQ category" : `Edit category #${view.row.id}`}
        subtitle={view.name === "create"
          ? "GET /faq/categories/create → POST /faq/categories — one row per selected skin"
          : `GET /faq/categories/${view.row.id}/edit → PUT /faq/categories/${view.row.id}`}
        gate={<>Same as the list: <b>no permission check of any kind</b> — <code>StoreFaqCategoryRequest</code> and <code>UpdateFaqCategoryRequest</code> both return <code>authorize(): true</code>.</>}>
        <HfqCatEditor row={view.name === "edit" ? view.row : null} contextSkinId={skinId} db={db}
          skins={skins} busy={save.busy}
          onCancel={() => setView({ name: "list" })} onSaved={saveCat} />
      </HrsShell>
    );
  }

  const columns = [
    { key: "id", label: "ID", width: 84, render: r => <span className="hfq-id">{r.id}</span> },
    /* backend.faq_category_name — label inferred */
    { key: "name", label: "Name", render: r => (
      <button className="hfq-rowlink" title="Edit" onClick={(e) => { e.stopPropagation(); setView({ name: "edit", row: r }); }}>
        <span className="hfq-rowlink__t">{hfqCatName(r, "en") || <i>unnamed</i>}</span>
        <span className="hfq-rowlink__s">{HFQ_LANG_CODES.filter(c => String(r.n[c] || "").trim()).length} of {HFQ_LANGS.length} languages named</span>
      </button>
    ) },
    /* backend.faq_slug — label inferred */
    { key: "slug", label: "Slug", width: 200, render: r => <code className="hfq-slug">{r.slug}</code> },
    /* backend.faq_count — label inferred */
    { key: "faq_count", label: "FAQs", align: "right", width: 100, render: r => {
      const n = hfqFaqCount(r);
      return <span className={n ? "hfq-count" : "hfq-count hfq-count--zero"}>{hrsInt(n)}</span>;
    } },
    { key: "is_active", label: "Status", width: 120, render: r => <HfqActive on={r.is_active} /> },
    { key: "_acts", label: "Actions", align: "center", width: 110, render: r => {
      const n = hfqFaqCount(r);
      return (
        <div className="hfq-acts">
          <button className="hfq-act hfq-act--edit" title="Edit" onClick={(e) => { e.stopPropagation(); setView({ name: "edit", row: r }); }}><Icon name="edit" size={13} /></button>
          {/* categories/index.blade.php:103-116 — the delete button only renders enabled when
              faq_count == 0; otherwise it is a disabled button with the tooltip
              backend.faq_category_has_faqs (label inferred "Category has FAQs"). The service
              re-checks it anyway (FaqService L322-325). */}
          <button className={`hfq-act hfq-act--danger${n ? " hfq-act--off" : ""}`} disabled={!!n}
            title={n ? "Category has FAQs" : "Delete"}
            onClick={(e) => { e.stopPropagation(); if (!n) setDel(r); }}><Icon name="trash" size={13} /></button>
        </div>
      );
    } },
  ];

  return (
    <HrsShell
      title="FAQ Categories"
      subtitle="Per-skin grouping for FAQ entries — twelve name columns, no content of its own"
      gate={<>
        <b>There is no permission gate on this screen either.</b> <code>GET /faq/categories</code> and its
        create/store/edit/update/destroy siblings sit behind the route middleware only —{" "}
        <code>admin</code> + <code>adminsettings</code> and <code>auth</code> + <code>admin</code> + <code>2fa</code> + <code>g2fa</code>.
        {" "}<code>Admin\FaqController::categoriesIndex</code> is the same class that serves the FAQ list and checks nothing;
        both category FormRequests return <code>authorize(): true</code>.{" "}
      </>}
      gateNote={<>
        The sidebar link is gated by <code>$can_manage_cms</code> (<code>isadmin()</code> · skin admin +{" "}
        <code>enable_cms</code> · customer care + <code>support_cms_banners</code>) and nothing else; the URL is open to any
        authenticated back-office user. Data is scoped to <code>Auth::user()-&gt;getSkins()</code>. Cosmetic but real: in the
        non-admin sidebar variant this very entry is <b>malformed HTML</b> — its <code>&lt;/a&gt;&lt;/li&gt;</code> are missing
        (sidebar.blade.php L671-674) and only browser error-recovery keeps the menu intact.
      </>}
      explainer={{ title: "What this screen is, in plain English", bullets: [
        <>A category is a label that belongs to <b>one skin</b>. The same name on two skins is two independent rows with two ids — which is why creating one fans out across the skins you tick.</>,
        <>Categories carry <b>no content</b>: twelve optional <code>name_&lt;lang&gt;</code> columns, a slug that must be unique within its skin, and an active flag. At least one language name is required.</>,
        <>The list is <b>unpaginated</b> and fixed-sorted by <code>name_en</code>. Skin is its only filter — no search, no status filter.</>,
        <><b>Delete is blocked while the category holds FAQs.</b> The button is disabled in the list and the service refuses it anyway ("Cannot delete category with existing FAQs"). Empty the category — or move its FAQs — first.</>,
      ] }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setView({ name: "create" })}>
          <Icon name="plus" size={14} /> New category{/* label inferred */}
        </button>
      }>

      <HrsFilters
        fields={FIELDS}
        values={{ skin_id: String(skinId) }}
        onChange={(k, v) => { if (k === "skin_id") setSkinId(v); }}
        resultLabel={hrsInt(rows.length)} />

      {truncated.indexOf("categories") !== -1 && (
        <div className="hfq-err">
          <Icon name="alert" size={13} /> This brand has more categories than the {hrsInt(HFQ_FETCH_MAX)} this screen
          fetches. The list below covers the fetched rows only.
        </div>
      )}

      {loading ? <HrsSkeleton rows={6} cols={5} /> : error ? <HrsError error={error} onRetry={retry} /> : (
      <HrsTable
        columns={columns} rows={rows} rowKey="id"
        onRowClick={(r) => setView({ name: "edit", row: r })}
        empty={`No categories on ${hfqSkinName(skins, skinId)} yet — FAQs there can still be saved without one (the Category column shows "—").`}
        renderCard={r => {
          const n = hfqFaqCount(r);
          return (
            <>
              <div className="hrs-card__top">
                <b>{hfqCatName(r, "en") || "unnamed"}</b>
                <HfqActive on={r.is_active} />
              </div>
              <div className="hfq-card__sub"><code>{r.slug}</code> · #{r.id}</div>
              <div className="hrs-card__grid">
                <span>FAQs</span><b>{hrsInt(n)}</b>
                <span>Languages</span><b>{HFQ_LANG_CODES.filter(c => String(r.n[c] || "").trim()).length} of {HFQ_LANGS.length}</b>
              </div>
              <div className="hfq-card__acts">
                <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); setView({ name: "edit", row: r }); }}><Icon name="edit" size={12} /> Edit</button>
                <button className="btn btn--ghost btn--sm hfq-card__del" disabled={!!n}
                  title={n ? "Category has FAQs" : "Delete"}
                  onClick={(e) => { e.stopPropagation(); if (!n) setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
              </div>
            </>
          );
        }} />
      )}

      {del && (
        <HfqDeleteDialog
          title="Delete category"
          question={<>Are you sure?</>}
          blocked={hfqFaqCount(del) ? "Cannot delete category with existing FAQs" : null}
          detail={<>
            <b>{hfqCatName(del, "en") || del.slug}</b> (<code>{del.slug}</code>, id {del.id}) on <b>{hfqSkinName(skins, del.skin_id)}</b> —{" "}
            <b>{hrsInt(hfqFaqCount(del))}</b> FAQ(s) point at it. <code>DELETE /faq/categories/{del.id}</code> removes the
            category row only; it never touches FAQs, which is exactly why the platform refuses the delete while any still
            reference it (<code>FaqService::deleteCategory</code> L322-325). Copies of this category on other skins are separate rows
            and are unaffected.
          </>}
          onClose={() => setDel(null)}
          onConfirm={() => doDeleteCat(del)} />
      )}
    </HrsShell>
  );
};

window.HostCmsFaq = HostCmsFaq;
window.HostCmsFaqCategories = HostCmsFaqCategories;
