// Represents: GET /messages/ · MessagesController — see docs/ISYSTEM_REFERENCE.md §Batch 1 "Messages"
/*
   Messages (Messaggi) — operator→player/user messaging.
   Replaces the HostMessages component bundled in HostFinanceMsg.jsx (this file
   loads after it, so this definition wins; HostFinanceMsg.jsx retires when its
   last screen is split out).

   Real surface implemented here:
   - Tab "Messages received" (default, GET /messages/ → getMessages JSON)
   - Tab "Messages sent"     (GET /messages/?tab=sent → getSent JSON)
   - View modal    (GET /messages/view/?id=…      → forms/viewMessage.blade.php)
   - Sent modal    (GET /messages/viewsent/?id=…  → forms/sentMessage.blade.php)
   - Compose modal (GET /messages/newMessage/ → POST /messages/sendNewMessage/)

   Label policy: nearly every label on the real screen renders as a raw
   backend.* key (only backend.status and backend.operation_ok exist in the
   sole lang file, resources/lang/it/backend.php). Every label below is an
   operator-facing English rendering of its backend.* key — label inferred
   throughout unless noted otherwise.

   Known real-platform behaviors intentionally NOT reproduced:
   - "Send to all" legacy path: if the first selected recipient id == 1,
     sendNewMessage calls objectToArray($allMix) with $allMix UNDEFINED →
     recipients end up empty → ajaxError("Errore sconosciuto"). Broken dead
     path; the mock directory has no root user so it cannot be reached.
   - newMessageForm's edit branch queries Message::where("id", " = ", $id) —
     the invalid " = " operator becomes the VALUE, so the query never matches
     and the modal always opens as act=new. There is no edit; none is built.
   - Export: DataTables buttons/pdfmake/print scripts are loaded in the real
     footer_scripts but no `buttons` config exists, so nothing renders. No
     export is built here either (no invented actions).
*/

/* deterministic PRNG so lists render identically each load */
const hmgRand = (seed) => { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; };

const hmgToast = (m) => window.PAYBO?.emitToast && window.PAYBO.emitToast({ id: `hmg-${Date.now()}`, tx_id: m, amount: 0, currency: "HOST", player: "Messages", reason: "Prototype state only \u2014 not persisted." });

/* local wall-clock timestamp builder + the real screen's date format
   date("d/m/Y G:i", addedTime) — day/month zero-padded, hour NOT padded */
const hmgTs = (d, m, y, h, mi) => Math.floor(new Date(y, m - 1, d, h, mi).getTime() / 1000);
const hmgFmtDT = (ts) => {
  if (!ts) return "--------"; // sentMessage.blade renders dashes for a null visualizationTime
  const dt = new Date(ts * 1000); const p = (n) => String(n).padStart(2, "0");
  return `${p(dt.getDate())}/${p(dt.getMonth() + 1)}/${dt.getFullYear()} ${dt.getHours()}:${p(dt.getMinutes())}`;
};

/* Wall-clock stamp for the Update links. It reports when the list was last re-read — a fact
   about something that really happened, so it can be shown without lying. */
const hmgClock = (ms) => { const d = new Date(ms), p = (n) => String(n).padStart(2, "0"); return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; };
const hmgSyncStamp = (ms) => (ms ? <span style={{ fontSize: 11.5, fontWeight: 500, color: "#9aa1b4", whiteSpace: "nowrap" }}>Updated {hmgClock(ms)}</span> : null);

const HMG_PAGE_SIZES = [5, 10, 25, 50, 100]; // DataTables lengthMenu; pageLength default 100 (messages.js/sent.js)

/* WHAT WAS HERE, AND WHY NONE OF IT SURVIVED

   HMG_ME       the signed-in operator, as the string "sinoplata". Rendered on
                every sent message as "Message sent to". The operator now comes
                from current_app_user().
   HMG_DIR      15 user records with ids, levels and ltree paths. It was the
                entire recipient directory AND the network-expansion tree, so
                every recipient anyone could pick was invented, and the subtree
                arithmetic ran over an invented hierarchy.
   HMG_INBOX    11 messages with subjects, Spanish bodies and read timestamps.
   HMG_SENT     5 sends, each carrying a nested array of per-recipient read
                receipts generated by hmgGenReadStates().
   HMG_PLAYERS_POOL  24 player usernames those receipts were attributed to.

   A read receipt is a claim about what a specific person did. Twenty-four
   invented players with invented read times is a compliance record of an event
   that did not happen, and there is nothing on the screen that says so. */

const hmgInboxFromDb = (r) => {
  const b = r.body || {};
  const sec = (t) => (t ? Math.floor(Date.parse(t) / 1000) : null);
  return {
    id: r.id,
    object: b.subject || "",
    /* body_html, rendered as text by this screen exactly as it always did —
       the column is html and the screen has never trusted it as markup. */
    text: b.body_html || "",
    pop_up: b.popup ? 1 : 0,
    /* message_statuses: the screen's `stato` is 0 unread / 1 read. */
    stato: Number(r.status_id) === 1 ? 1 : 0,
    addedTime: sec(b.created_at || r.created_at),
    visualizationTime: sec(r.read_at),
    from: (b.sender && b.sender.username) || "",
  };
};

const hmgSentFromDb = (r) => ({
  message_id: r.id,
  object: r.subject || "",
  text: r.body_html || "",
  pop_up: r.popup ? 1 : 0,
  addedTime: r.created_at ? Math.floor(Date.parse(r.created_at) / 1000) : null,
  /* Recipients are fetched separately and joined in by body id. The count embed
     on this resource is the fast path for the list column; the per-recipient
     read receipts only matter when the modal opens. */
  recipientCount: Array.isArray(r.recipients) && r.recipients[0] ? Number(r.recipients[0].count) : 0,
  recipients: [],
});

const hmgFilterRows = (rows, f, isSent) => rows.filter((r) => {
  if (f.object && !r.object.toLowerCase().includes(f.object.toLowerCase())) return false; // server: messages.object LIKE %v%
  if (f.from) { const t = Math.floor(new Date(f.from + "T00:00:00").getTime() / 1000); if (r.addedTime < t) return false; }
  if (f.to) { const t = Math.floor(new Date(f.to + "T23:59:59").getTime() / 1000); if (r.addedTime > t) return false; }
  if (!isSent && f.status === "1" && r.stato !== 1) return false;      // UI code 1 = readed
  if (!isSent && f.status === "3" && r.stato !== 0) return false;      // UI code 3 → server maps to stato = 0 (to read)
  return true;
});

/* sortable per reference — received: id, addedTime, stato, message, pop_up
   (object NOT sortable); sent: id (message_id), message, pop_up, addedTime.
   Default id DESC: both real JS files hardcode order [[0,"desc"]], overriding
   the controller's messages.id ASC default. */
const hmgSortRows = (rows, s, isSent) => {
  const dir = s.dir === "asc" ? 1 : -1;
  return [...rows].sort((a, b) => {
    if (s.key === "message") { const va = a.text.toLowerCase(), vb = b.text.toLowerCase(); return va < vb ? -dir : va > vb ? dir : 0; }
    const va = s.key === "id" ? (isSent ? a.message_id : a.id) : a[s.key];
    const vb = s.key === "id" ? (isSent ? b.message_id : b.id) : b[s.key];
    return (va - vb) * dir;
  });
};

const HmgSortTh = ({ label, k, sort, setSort, sortable = true, tip, style }) => (
  <th className={sortable ? "hmg-th-sort" : ""} style={style}
      onClick={sortable ? () => setSort(s => ({ key: k, dir: s.key === k && s.dir === "asc" ? "desc" : "asc" })) : undefined}>
    {label}
    {tip && <Tip>{tip}</Tip>}
    {sortable && sort.key === k && <span className="hmg-th-arrow">{sort.dir === "asc" ? "▲" : "▼"}</span>}
  </th>
);

/* DataTables-style length menu + pager (server-side pagination on the real screen) */
const HmgPager = ({ total, page, setPage, size, setSize }) => {
  const pages = Math.max(1, Math.ceil(total / size));
  const from = total === 0 ? 0 : page * size + 1;
  const to = Math.min(total, (page + 1) * size);
  return (
    <div className="hp-pager">
      <div className="hp-pager__show">
        Show
        <select className="select" value={size} onChange={e => { setSize(Number(e.target.value)); setPage(0); }}>
          {HMG_PAGE_SIZES.map(n => <option key={n} value={n}>{n}</option>)}
        </select>
        entries
        <span className="hp-pager__range">Showing {from} to {to} of {total} entries</span>
      </div>
      {pages > 1 && (
        <div className="hp-pager__pages">
          <button className="hp-pg-arrow" disabled={page === 0} onClick={() => setPage(p => p - 1)}><Icon name="chevron_left" size={13} /></button>
          {Array.from({ length: pages }, (_, i) => (
            <button key={i} className={`hp-pg-num ${i === page ? "active" : ""}`} onClick={() => setPage(i)}>{i + 1}</button>
          ))}
          <button className="hp-pg-arrow" disabled={page === pages - 1} onClick={() => setPage(p => p + 1)}><Icon name="chevron_right" size={13} /></button>
        </div>
      )}
    </div>
  );
};

/* status chip: messages.stato 0 → backend.to_read / 1 → backend.readed (labels inferred) */
const HmgStatusChip = ({ stato }) => stato === 1
  ? <span className="chip chip--ok">Read</span>
  : <span className="chip chip--warn">To read</span>;

/* ---- Filter fields (shared between the desktop gray-inset bar and the
   mobile bottom sheet). Received: object / date range / read status; sent has
   NO status filter. Dates: the real datepickers are text inputs hardcoded to
   Italian locale dd/mm/yyyy (messages.js) — native date inputs are the
   elevated equivalent. */
const HmgFilterFields = ({ isSent, draft, setDraft }) => (
  <>
    <div className="rpt-field" style={{ flex: 1, minWidth: 200 }}>
      <label>Object{/* label inferred: backend.object */}</label>
      <input className="input" style={{ width: "100%" }} placeholder="Object" value={draft.object}
        onChange={e => setDraft(d => ({ ...d, object: e.target.value }))} />
    </div>
    <div className="rpt-field">
      <label>{isSent ? "Send date" : "Received date"}{/* label inferred: backend.send_date / backend.received_date */}</label>
      <div className="rpt-daterow">
        {/* label inferred: backend.date_from / backend.date_to */}
        <input className="input rpt-date" type="date" title="From" value={draft.from} onChange={e => setDraft(d => ({ ...d, from: e.target.value }))} />
        <input className="input rpt-date" type="date" title="To" value={draft.to} onChange={e => setDraft(d => ({ ...d, to: e.target.value }))} />
      </div>
    </div>
    {!isSent && (
      <div className="rpt-field">
        <label>Read status{/* label inferred: backend.read_status */}</label>
        <select className="select" value={draft.status} onChange={e => setDraft(d => ({ ...d, status: e.target.value }))}>
          <option value="">Select option{/* label inferred: backend.select_option */}</option>
          <option value="1">Read{/* label inferred: backend.readed */}</option>
          <option value="3">To read{/* label inferred: backend.to_read; UI code 3 → server stato = 0 */}</option>
        </select>
      </div>
    )}
  </>
);

/* mobile full-height filter sheet (brief §11: filter bars collapse to a
   Filters button + sheet on narrow viewports) */
const HmgSheet = ({ isSent, draft, setDraft, onSearch, onClose }) => (
  <>
    <div className="hmg-sheet-scrim" onClick={onClose} />
    <div className="hmg-sheet">
      <div className="hmg-sheet-head">
        <div className="hmg-sheet-title">Filters</div>
        <button className="cg-link" onClick={onClose}><Icon name="x" size={14} /> Close</button>
      </div>
      <HmgFilterFields isSent={isSent} draft={draft} setDraft={setDraft} />
      <button className="rpt-btn rpt-btn--blue" style={{ width: "100%" }} onClick={() => { onSearch(); onClose(); }}>
        <Icon name="search" size={14} /> Search{/* label inferred: backend.search_button */}
      </button>
    </div>
  </>
);

/* ---- View modal (received) — GET /messages/view/?id=<messages.id>.
   Renders forms/viewMessage.blade.php: image with download link when
   messages_root.image is set (legacy data — the compose form never sets it),
   object, message, pop_up yes/no, and backend.message_sent_to = the recipient
   username (the viewing operator). Close-only (onlyclose=1). Opening marks
   the message read server-side (stato=1 + visualizationTime=time()). */
const HmgViewModal = ({ row, onClose }) => (
  <div className="bp-modal-scrim hmg-scrim" onClick={onClose}>
    <div className="bp-modal cg-modal hmg-modal" onClick={e => e.stopPropagation()}>
      <div className="cg-modal-title">View message{/* label inferred: modal title */}</div>
      <div className="cg-modal-body">
        {row.image && (
          <div className="hmg-imgrow">
            {/* real path: Storage img/messages/ + MEDIA_DOMAIN /messages_images/ */}
            <span className="hmg-imgthumb"><Icon name="grid" size={20} /></span>
            <span style={{ fontSize: 12.5, color: "#5a6172" }}>{row.image}</span>
            {/* The attachment is a real file on the media host; the prototype has no copy of
                it, so the link stays visible (it documents the real form) but is inert. */}
            <button className="cg-link" disabled aria-disabled="true"
              style={{ opacity: .55, cursor: "not-allowed" }}
              title="Not wired in the prototype — requires the media host: MEDIA_DOMAIN/messages_images/{file} (stored via Storage::disk('public') img/messages/)">
              <Icon name="download" size={13} /> Download
            </button>
            <Tip>Serves <code>messages_root.image</code> from <strong>MEDIA_DOMAIN/messages_images/{"{file}"}</strong> (<code>config('media.MESSAGES_IMG_WEB_PATH')</code>); the file is written to <code>Storage::disk('public')</code> under <code>img/messages/</code>. Legacy data only — the compose form never sets an image.</Tip>
          </div>
        )}
        <div className="hmg-vrow"><span className="k">Object</span><span className="v" style={{ fontWeight: 700 }}>{row.object}</span></div>
        <div className="hmg-vrow"><span className="k">Message</span><span className="v" style={{ whiteSpace: "pre-line" }}>{row.text}</span></div>
        <div className="hmg-vrow"><span className="k">Popup</span><span className="v">{row.pop_up ? "Yes" : "No"}</span></div>
        <div className="hmg-vrow"><span className="k">Message sent to{/* label inferred: backend.message_sent_to */}</span><span className="v">{me || "—"}</span></div>
      </div>
      <div className="cg-modal-foot">
        <button className="cg-btn cg-btn--close" onClick={onClose}>Close</button>
      </div>
    </div>
  </div>
);

/* ---- Sent modal — GET /messages/viewsent/?id=<message_id>.
   forms/sentMessage.blade.php: message summary + read-receipt recipients
   table (Username / Status / Reading date) with in-modal filters that the
   real page passes back as extra query params (search_users csv + stato).
   Close-only. */
const HmgSentModal = ({ row, onClose, onRefresh }) => {
  const [uSel, setUSel] = React.useState([]);     // staged username multi-select
  const [stSel, setStSel] = React.useState("5");  // staged read-status; 5 = "no filter" sentinel (real select)
  const [applied, setApplied] = React.useState({ users: [], stato: "5" });
  const [syncedAt, setSyncedAt] = React.useState(null);
  /* backend.update — the real link re-fetches this message's read receipts. The prototype's
     receipts ARE the local sent store, so the honest equivalent is a genuine re-read of it:
     the parent re-resolves the row by message_id and hands it back, the in-modal filters are
     re-applied to whatever it now holds, and the time of the re-read is stamped. No claim of
     a server round-trip is made. */
  const doRefresh = () => { onRefresh && onRefresh(); setSyncedAt(Date.now()); };

  const filtered = row.recipients.filter((r) => {
    // Real quirk (MessagesController L123-133): when a username filter is
    // present the status filter is IGNORED — the status branch is the else.
    // Implemented faithfully.
    // <!-- SUGGESTION: apply both filters (AND) in viewSentMessage instead of
    //      the either/or else-branch, so username + read-status can combine. -->
    if (applied.users.length) return applied.users.includes(r.username);
    if (applied.stato === "1") return r.stato === 1;   // filter matches stato=1 only — a stato=2 row displays as read but is not matched (real behavior)
    if (applied.stato === "3") return r.stato === 0;   // UI code 3 → stato = 0
    return true;
  });

  const readCount = row.recipients.filter(r => r.stato === 1 || r.stato === 2).length;

  return (
    <div className="bp-modal-scrim hmg-scrim" onClick={onClose}>
      <div className="bp-modal cg-modal cg-modal--wide hmg-modal" onClick={e => e.stopPropagation()}>
        <div className="cg-modal-title" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
          <span>Sent message{/* label inferred: modal title */}</span>
          <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
            {hmgSyncStamp(syncedAt)}
            {/* backend.update — in-modal re-fetch link on the real form; here a real re-read */}
            <button className="cg-link" onClick={doRefresh} title="Re-reads the read receipts and re-applies the filters below"><Icon name="refresh" size={13} /> Update</button>
          </span>
        </div>
        <div className="cg-modal-body">
          {row.image && (
            <div className="hmg-imgrow">
              <span className="hmg-imgthumb"><Icon name="grid" size={20} /></span>
              <span style={{ fontSize: 12.5, color: "#5a6172" }}>{row.image}</span>
            </div>
          )}
          <div className="hmg-vrow"><span className="k">Object</span><span className="v" style={{ fontWeight: 700 }}>{row.object}</span></div>
          <div className="hmg-vrow"><span className="k">Message</span><span className="v" style={{ whiteSpace: "pre-line" }}>{row.text}</span></div>
          <div className="hmg-vrow"><span className="k">Popup</span><span className="v">{row.pop_up ? "Yes" : "No"}</span></div>

          <div className="cg-section" style={{ display: "flex", alignItems: "center", gap: 8 }}>
            Recipients — read receipts
            <span style={{ fontSize: 12, fontWeight: 500, color: "#9aa1b4" }}>{readCount}/{row.recipients.length} read</span>
          </div>

          <div className="hmg-recipfilters">
            <div className="rpt-field" style={{ flex: 1, minWidth: 200 }}>
              <label style={{ fontSize: 12.5 }}>Username{/* label inferred: backend.username; real widget is a bootstrap-select multi fed by getUsersMessages(message_id) */}</label>
              <div className="hmg-recipbox">
                {row.recipients.map(r => (
                  <label key={r.username} className="hmg-recip-row">
                    <input type="checkbox" checked={uSel.includes(r.username)}
                      onChange={() => setUSel(s => s.includes(r.username) ? s.filter(x => x !== r.username) : [...s, r.username])} />
                    {r.username}
                  </label>
                ))}
              </div>
            </div>
            <div className="rpt-field">
              <label style={{ fontSize: 12.5 }}>
                Read status{/* label inferred: backend.read_status */}
                <Tip>When a username filter is active the read-status filter is <strong>ignored</strong> — faithful to <code>viewSentMessage</code>'s else-branch on the live platform.</Tip>
              </label>
              <select className="select" value={stSel} onChange={e => setStSel(e.target.value)}>
                <option value="5">Select option{/* 5 = no-filter sentinel */}</option>
                <option value="1">Read</option>
                <option value="3">To read</option>
              </select>
              <div className="hmg-recipbtns">
                {/* labels inferred: backend.filter_button / backend.remove_filters */}
                <button className="rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 36, padding: "0 14px", fontSize: 13 }}
                  onClick={() => setApplied({ users: uSel, stato: stSel })}><Icon name="filter" size={13} /> Filter</button>
                <button className="rpt-btn rpt-btn--reset" style={{ minWidth: 0, height: 36, padding: "0 14px", fontSize: 13 }}
                  onClick={() => { setUSel([]); setStSel("5"); setApplied({ users: [], stato: "5" }); }}><Icon name="x" size={13} /> Remove filters</button>
              </div>
            </div>
          </div>

          <div style={{ overflowX: "auto" }}>
            <table className="data-table hp-list cg-list" style={{ width: "100%" }}>
              <thead><tr>
                <th style={{ textAlign: "left" }}>Username</th>
                <th>Status{/* backend.status — one of the two keys that actually resolves in resources/lang/it/backend.php */}</th>
                <th>Reading date{/* label inferred: backend.reading_date (visualizationTime) */}</th>
              </tr></thead>
              <tbody>
                {filtered.length === 0 && <tr><td colSpan={3} style={{ padding: 22, color: "#9aa1b4" }}>No data available in table</td></tr>}
                {filtered.map(r => (
                  <tr key={r.username}>
                    <td style={{ textAlign: "left", fontWeight: 600 }}>{r.username}</td>
                    {/* stato 1 OR 2 → read, else to read (sentMessage.blade L99) */}
                    <td style={{ textAlign: "center" }}><HmgStatusChip stato={(r.stato === 1 || r.stato === 2) ? 1 : 0} /></td>
                    <td style={{ textAlign: "center", whiteSpace: "nowrap" }}>{hmgFmtDT(r.visualizationTime)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
        <div className="cg-modal-foot">
          <button className="cg-btn cg-btn--close" onClick={onClose}>Close</button>
        </div>
      </div>
    </div>
  );
};

/* ---- Compose modal — GET /messages/newMessage/ → POST /messages/sendNewMessage/.
   Fields per forms/newMessage.blade.php: object (required), text (required,
   RichTextEditor mirrored HTML-encoded into a hidden input; server re-runs
   htmlspecialchars(html_entity_decode(...)) on both), receiver radio 3=Users
   (default) / 2=Players (presence-only validation — the value just switches
   the select2 user_types filter; a dead JS case '1' = all levels has no
   matching radio), users_aviable[] multi (required), players_network /
   users_network checkboxes, pop_up checkbox. Inline validation errors map to
   backend.insert_object / insert_message / select_recipients /
   no_users_available (labels inferred), returned as ajaxError + campierrati. */
const HmgCompose = ({ onClose, onSend }) => {
  const [obj, setObj] = React.useState("");
  const [text, setText] = React.useState("");
  const [receiver, setReceiver] = React.useState("3"); // real radio values: 3 = Users (default checked), 2 = Players
  const [sel, setSel] = React.useState([]);            // selected HMG_DIR entries
  const [q, setQ] = React.useState("");
  const [netPlayers, setNetPlayers] = React.useState(false);
  const [netUsers, setNetUsers] = React.useState(false);
  const [popup, setPopup] = React.useState(false);
  const [errs, setErrs] = React.useState({});

  // Users radio → user_types [2,8,10,15,20] (ADMIN/MASTER/AGENT/PROMOTER/SHOP); Players → [30]
  /* The recipient directory is the caller's own subtree, which RLS already
     limits — so "who may I message" is answered by the same rule as "who may I
     see", rather than by a 15-row array that answered neither. */
  const dirFeed = useHrsFetch(() => window.sb.list("networkUsers", { limit: 2000 }), []);
  const directory = React.useMemo(
    () => (dirFeed.data || []).map(u => ({
      id: u.id, u: u.username, level: Number(u.user_level),
      role: (u.role && u.role.label) || "", path: String(u.path || ""),
    })), [dirFeed.data]);
  const pool = directory.filter(d => receiver === "2" ? d.level === 30 : d.level !== 30);
  // searchUsers2: prefix username search `q`; after the first pick the real
  // form passes parent_id = already-selected ids, so subsequent results are
  // constrained to the selected users' subtrees (and always to the sender's
  // own descendants via getChilds(true)).
  const matches = q.trim() === "" ? [] : pool.filter((d) => {
    if (sel.some(s => s.id === d.id)) return false;
    if (!d.u.toLowerCase().startsWith(q.trim().toLowerCase())) return false;
    /* ltree is DOTTED, and a subtree is descendant-OR-SELF. The slash came
       from isystem's /1/5/12 paths; against real ltree it matched nothing, so
       the recipient search silently returned no one. */
    if (sel.length && !sel.some(s => d.path === s.path || String(d.path).startsWith(s.path + "."))) return false;
    return true;
  }).slice(0, 8);

  // JS guard (newMessage.blade L116-144): network checkboxes are disabled and
  // unchecked unless at least one selected recipient has user_level != 30.
  const canNetwork = sel.some(s => s.level !== 30);
  React.useEffect(() => { if (!canNetwork) { setNetPlayers(false); setNetUsers(false); } }, [canNetwork]);

  const send = () => {
    const e = {};
    if (!obj.trim()) e.object = "Insert the object";        // label inferred: backend.insert_object
    if (!text.trim()) e.text = "Insert the message";        // label inferred: backend.insert_message
    if (!receiver) e.receiver = "Select the recipients";    // label inferred: backend.select_recipients (radio presence — always set here)
    if (!sel.length) e.recipients = "No users available — select at least one recipient"; // label inferred: backend.no_users_available
    setErrs(e);
    if (Object.keys(e).length) return;

    // Network expansion — players_network: every descendant PLAYER (level 30)
    // of each selected recipient; users_network: descendants whereNotIn
    // user_level [1,4,6,30]. Real code walks User::getChilds(true) chunked 1000.
    //
    // Real-platform BUG (ISYSTEM_REFERENCE §Messages, sendNewMessage L548/557):
    // network-expanded rows use Message::updateOrCreate(['message_to' => $row->id], $datip)
    // keyed ONLY on message_to, so a network send OVERWRITES the descendant's
    // existing message row (object/message_id/submit_from/addedTime all
    // replaced) instead of inserting a new one; only direct recipients get a
    // proper Message::create (L540). The prototype implements the evident
    // intent: every recipient — direct or network-expanded — receives a NEW
    // per-recipient message row (deduped within this send).
    // <!-- SUGGESTION: change sendNewMessage's network expansion to
    //      Message::create per descendant (or updateOrCreate keyed on
    //      message_id + message_to) so descendants keep their inbox history. -->
    const extra = [];
    for (const s of sel) {
      for (const d of directory) {
        /* Same fix, and this one decides who a message is actually sent to:
           a slash here meant the network expansion reached nobody. */
        if (!(d.path === s.path || String(d.path).startsWith(s.path + "."))) continue;
        const isPlayer = d.level === 30;
        if ((netPlayers && isPlayer) || (netUsers && ![1, 4, 6, 30].includes(d.level))) {
          if (!sel.some(x => x.id === d.id) && !extra.some(x => x.id === d.id)) extra.push(d);
        }
      }
    }
    onSend({ object: obj.trim(), text: text.trim(), popup, recipients: [...sel, ...extra] });
  };

  return (
    <div className="bp-modal-scrim hmg-scrim" onClick={onClose}>
      <div className="bp-modal cg-modal cg-modal--wide hmg-modal" onClick={e => e.stopPropagation()}>
        <div className="cg-modal-title">New message{/* label inferred: backend.new_message */}</div>
        <div className="cg-modal-body">
          <div className="cg-section">Message</div>
          <div className="sk-field">
            <label className="form-label">* Object</label>
            <input className={`input ${errs.object ? "hmg-input-err" : ""}`} style={{ width: "100%" }} placeholder="Insert object" value={obj} onChange={e => setObj(e.target.value)} />
            {errs.object && <div className="hmg-err">{errs.object}</div>}
          </div>
          <div className="sk-field">
            <label className="form-label">* Message</label>
            {/* real widget: richtexteditor/rte.js, mirrored HTML-encoded into hidden name="text" on change */}
            <div className="sk-rte" style={errs.text ? { borderColor: "#e2011a" } : undefined}>
              <div className="sk-rte-bar">
                <button type="button" title="Bold"><b>B</b></button>
                <button type="button" title="Italic"><i>I</i></button>
                <button type="button" title="Underline"><u>U</u></button>
                <span className="sk-rte-sep" />
                <button type="button" title="List"><Icon name="list" size={14} /></button>
                <button type="button" title="Link"><Icon name="external" size={14} /></button>
                <button type="button" title="Source">&lt;/&gt;</button>
              </div>
              <textarea className="sk-rte-area" rows={6} value={text} onChange={e => setText(e.target.value)} />
            </div>
            {errs.text && <div className="hmg-err">{errs.text}</div>}
          </div>

          <div className="cg-section">Recipients</div>
          <div className="sk-field">
            <label className="form-label">
              * Select the user(s) you want to send the message to
              <Tip>Recipient search runs against <strong>your own network only</strong> (<code>getChilds(true)</code>, via <code>GET /users2</code> → <code>searchUsers2</code>, prefix match). After the first pick, further results are constrained to the <strong>subtrees of the already-selected recipients</strong> — the form passes their ids as <code>parent_id</code>.</Tip>
            </label>
            <div className="fm-seg2" style={{ maxWidth: 320 }}>
              {/* labels inferred: backend.users / backend.players */}
              <button type="button" className={receiver === "3" ? "active" : ""} onClick={() => { setReceiver("3"); setQ(""); }}>Users</button>
              <button type="button" className={receiver === "2" ? "active" : ""} onClick={() => { setReceiver("2"); setQ(""); }}>Players</button>
            </div>
            <div className="hmg-dd" style={{ marginTop: 8 }}>
              <div className={`hmg-chips ${errs.recipients ? "hmg-input-err" : ""}`}>
                {sel.map(s => (
                  <span key={s.id} className="hmg-chip">
                    {s.u} <span className="hmg-chip-role">({s.role})</span>
                    <button type="button" onClick={() => setSel(cur => cur.filter(x => x.id !== s.id))}><Icon name="x" size={11} /></button>
                  </span>
                ))}
                <input className="hmg-chip-input" placeholder={sel.length ? "Add another…" : `Search ${receiver === "2" ? "players" : "users"}…`}
                  value={q} onChange={e => setQ(e.target.value)} />
              </div>
              {q.trim() !== "" && (
                <div className="hmg-dd-list">
                  {matches.length === 0 && (
                    <div className="hmg-dd-empty">
                      No users available{sel.length ? " below the selected recipients (search is scoped to their subtrees)" : ""}
                    </div>
                  )}
                  {matches.map(d => (
                    <button type="button" key={d.id} className="hmg-dd-item" onClick={() => { setSel(cur => [...cur, d]); setQ(""); }}>
                      <span>{d.u}</span><span className="hmg-dd-role">{d.role} · level {d.level}</span>
                    </button>
                  ))}
                </div>
              )}
            </div>
            {errs.recipients && <div className="hmg-err">{errs.recipients}</div>}
          </div>
          <div className="fm-checks" style={{ flexWrap: "wrap" }}>
            <span style={{ display: "inline-flex", alignItems: "center" }}>
              <label style={!canNetwork ? { opacity: .5 } : undefined}>
                <input type="checkbox" disabled={!canNetwork} checked={netPlayers} onChange={e => setNetPlayers(e.target.checked)} />
                Network Players
              </label>
              <Tip>Also delivers to <strong>every descendant player</strong> (level 30) of each selected recipient, in chunks of 1000. Both network options unlock only when at least one selected recipient is <strong>not</strong> a player.</Tip>
            </span>
            <span style={{ display: "inline-flex", alignItems: "center" }}>
              <label style={!canNetwork ? { opacity: .5 } : undefined}>
                <input type="checkbox" disabled={!canNetwork} checked={netUsers} onChange={e => setNetUsers(e.target.checked)} />
                Network Users
              </label>
              <Tip>Also delivers to every descendant user <strong>except</strong> AFFILIATE(1), CUSTOMER_CARE(4), ADMINISTRATION(6) and PLAYER(30).</Tip>
            </span>
          </div>

          <div className="cg-section">Options</div>
          <div className="fm-checks" style={{ marginTop: 0 }}>
            <span style={{ display: "inline-flex", alignItems: "center" }}>
              <label>
                <input type="checkbox" checked={popup} onChange={e => setPopup(e.target.checked)} />
                Popup
              </label>
              <Tip>Stored on <code>messages_root.pop_up</code>. The same controller serves the player-facing inbox twin (<code>viewMessageFrontend</code>, routes/web.php), which surfaces popup messages on the frontend.</Tip>
            </span>
          </div>
        </div>
        <div className="cg-modal-foot">
          <button className="cg-btn cg-btn--close" onClick={onClose}>Close</button>
          <button className="cg-btn cg-btn--save" onClick={send}>Send message</button>
        </div>
      </div>
    </div>
  );
};

/* ---- mobile stacked-card list (brief §11) — object/status/date visible,
   message body + popup + id behind the expand toggle */
const HmgCardList = ({ rows, isSent, expanded, onToggle, onOpen }) => (
  <div className="hmg-cards">
    {rows.length === 0 && <div className="hmg-card" style={{ color: "#9aa1b4", textAlign: "center" }}>No data available in table</div>}
    {rows.map((r) => {
      const key = isSent ? r.message_id : r.id;
      const open = expanded.includes(key);
      return (
        <div key={key} className={`hmg-card ${!isSent && r.stato === 0 ? "hmg-card--unread" : ""}`}>
          <div className="hmg-card-top">
            <div>
              <button type="button" className="hmg-card-obj" onClick={() => onOpen(r)}>{r.object}</button>
              <div className="hmg-card-date">{hmgFmtDT(r.addedTime)}</div>
            </div>
            {!isSent && <HmgStatusChip stato={r.stato} />}
          </div>
          {open && (
            <div className="hmg-card-body">
              <div className="hmg-card-kv"><span className="k">Message</span><span className="v" style={{ whiteSpace: "pre-line" }}>{r.text}</span></div>
              <div className="hmg-card-kv"><span className="k">Popup</span><span className="v">{r.pop_up ? "Yes" : "No"}</span></div>
              <div className="hmg-card-kv"><span className="k">ID</span><span className="v">{key}</span></div>
            </div>
          )}
          <button type="button" className="hmg-card-expand" onClick={() => onToggle(key)}>
            {open ? "Hide details" : "Details"} <Icon name={open ? "chevron_down" : "chevron_right"} size={12} />
          </button>
        </div>
      );
    })}
  </div>
);

const HMG_TABS = [["received", "Messages received", ""], ["sent", "Messages sent", "sent"]];
const HMG_EMPTY_FILTERS = { object: "", from: "", to: "", status: "" };

const HostMessages = () => {
  window.useLocale && window.useLocale();
  const [tab, setTab] = window.useUrlTab("/messages", HMG_TABS, "received");
  const isSent = tab === "sent";

  /* Inbox = message_recipients scoped to me. Sent = message_bodies I sent.
     Both scopes are the DATABASE's: `recipient` and `sender` filter on columns
     that RLS already restricts, so a caller cannot widen either by editing a
     filter. The old version scoped by comparing to a hardcoded username. */
  const meFeed = useHrsFetch(() => window.sb.me(), []);
  const meId = meFeed.data && meFeed.data.id;
  const me = (meFeed.data && meFeed.data.username) || "";

  const inboxFeed = useHrsFetch(
    () => (meId ? window.sb.list("messageRecipients", { limit: 500, filters: { recipient: meId } })
                : Promise.resolve({ ok: true, data: [] })), [meId]);
  const inbox = React.useMemo(() => (inboxFeed.data || []).map(hmgInboxFromDb), [inboxFeed.data]);

  const sentFeed = useHrsFetch(
    () => (meId ? window.sb.list("messageBodies", { limit: 500, filters: { sender: meId } })
                : Promise.resolve({ ok: true, data: [] })), [meId]);
  const sent = React.useMemo(() => (sentFeed.data || []).map(hmgSentFromDb), [sentFeed.data]);

  /* Recipient read receipts, loaded only when a sent message is opened. Every
     one is a claim about what a named person did, so it comes from the row that
     records it or it is not shown. */
  const [receipts, setReceipts] = React.useState({});
  const loadReceipts = async (bodyId) => {
    const r = await window.sb.list("messageRecipients", { limit: 1000, filters: { body: bodyId } });
    if (r && r.ok) {
      setReceipts(m => ({ ...m, [bodyId]: (r.data || []).map(x => ({
        username: (x.recipient && x.recipient.username) || "",
        level: x.recipient ? x.recipient.user_level : null,
        stato: Number(x.status_id) === 1 ? 1 : 0,
        visualizationTime: x.read_at ? Math.floor(Date.parse(x.read_at) / 1000) : null,
      })) }));
    }
  };

  const save = useHrsSave([inboxFeed, sentFeed]);

  // Filters are staged and applied only on the Search click (messages.js L52-70).
  const [draft, setDraft] = React.useState({ received: { ...HMG_EMPTY_FILTERS }, sent: { ...HMG_EMPTY_FILTERS } });
  const [applied, setApplied] = React.useState({ received: { ...HMG_EMPTY_FILTERS }, sent: { ...HMG_EMPTY_FILTERS } });
  const setTabDraft = (fn) => setDraft(d => ({ ...d, [tab]: fn(d[tab]) }));
  const applySearch = () => { setApplied(a => ({ ...a, [tab]: { ...draft[tab] } })); setPage(0); };

  const [sort, setSort] = React.useState({ received: { key: "id", dir: "desc" }, sent: { key: "id", dir: "desc" } });
  const setTabSort = (fn) => { setSort(s => ({ ...s, [tab]: fn(s[tab]) })); };

  const [size, setSize] = React.useState(100); // DataTables pageLength default 100
  const [page, setPage] = React.useState(0);
  React.useEffect(() => { setPage(0); }, [tab]);

  const [view, setView] = React.useState(null);       // received view modal row
  const [sentView, setSentView] = React.useState(null); // sent read-receipt modal row
  const [compose, setCompose] = React.useState(false);
  const [sheet, setSheet] = React.useState(false);
  const [expanded, setExpanded] = React.useState([]); // mobile card expand state
  const toggleCard = (k) => setExpanded(cur => cur.includes(k) ? cur.filter(x => x !== k) : [...cur, k]);

  const rowsAll = isSent ? sent : inbox; // inbox scope: message_to = auth id AND stato <> 2 (mock contains no stato=2 rows)
  const rowsFiltered = hmgSortRows(hmgFilterRows(rowsAll, applied[tab], isSent), sort[tab], isSent);
  const rowsPage = rowsFiltered.slice(page * size, (page + 1) * size);

  /* backend.update → refreshMessages(): the real link re-fetches the inbox and redraws the
     DataTable. The prototype's inbox IS the local store, so the honest equivalent is a real
     re-read of it — take the store fresh, re-run the applied filters + sort over it, drop
     back to the first page, and stamp when the re-read happened. It genuinely picks up the
     delayed read-state write scheduled by openView. Nothing is asserted that did not occur. */
  const [inboxSyncedAt, setInboxSyncedAt] = React.useState(null);
  const refreshInbox = () => {
    /* A real re-read now, not a slice() of a local array. The old version
       re-sorted the same in-memory rows and stamped a time, which looked
       exactly like a refresh and could not pick up anything. */
    inboxFeed.retry && inboxFeed.retry();
    setPage(0);
    setInboxSyncedAt(Date.now());
  };

  // Opening the view modal marks the message read (stato=1 +
  // visualizationTime=time(), MessagesController L67-69); the real table
  // re-fetches after 1s to reflect it — mirrored with the delayed state write.
  const openView = (row) => {
    setView(row);
    /* Marking read is a write to the caller's own recipient row, and read_at is
       coalesced server-side so a second open does not move the receipt. The old
       version set local state on a 1s timer, which mimicked the real screen's
       re-fetch delay without doing anything. */
    if (row.stato === 0) {
      Promise.resolve(window.sb.rpc("mark_message_read", { p_recipient_row_id: row.id }))
        .then(() => inboxFeed.retry && inboxFeed.retry());
    }
  };

  // Success path: one messages_root row + one messages row per recipient
  // (stato 0 unread), ajaxSuccess(backend.operation_ok). See HmgCompose for
  // the network-expansion overwrite-bug divergence note.
  const handleSend = (payload) => save.run(
    /* One RPC. The body and every recipient row land in one transaction, and
       the recipient list is re-checked against the sender's subtree
       server-side — the browser expands the network tree for the picker, but
       what it produces is a proposal, not an authorisation. */
    () => window.sb.rpc("send_message", {
      p_subject: payload.object,
      p_body_html: payload.text,
      p_recipients: payload.recipients.map(d => d.id).filter(Boolean),
      p_popup: !!payload.popup,
      p_image_url: null,
    }),
    { done: () => setCompose(false) });

  const appliedCount = Object.values(applied[tab]).filter(Boolean).length;
  const clearFilter = (k) => {
    setDraft(d => ({ ...d, [tab]: { ...d[tab], [k]: "" } }));
    setApplied(a => ({ ...a, [tab]: { ...a[tab], [k]: "" } }));
    setPage(0);
  };

  return (
    <div className="page report-page host-players host-skins host-fm hmg-page">
      <div className="page__header hmg-headrow">
        <div className="page__title" style={{ color: "var(--p-700)", display: "flex", alignItems: "center", gap: 4 }}>
          {isSent ? "Messages sent" : "Messages received"}
          {/* labels inferred: backend.messages_sent / backend.messages_received */}
          <Tip>
            Reachable only with the <code>support_messages</code> back-office permission and never for regulators: the sidebar gate is <code>checkUserBoPerm(id, "support_messages") &amp;&amp; !isRegulator()</code> and the index route adds <code>abort_if(isRegulator(), 403)</code>. <code>checkUserBoPerm</code> passes every role automatically except AFFILIATE(1), CUSTOMER_CARE(4) and ADMINISTRATION(6), which need an explicit <code>permissions</code> row. The six sibling AJAX routes carry no per-route check beyond the admin group middleware.
          </Tip>
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <button type="button" className="hmg-filterbtn rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 38 }} onClick={() => setSheet(true)}>
            <Icon name="filter" size={13} /> Filters{appliedCount > 0 ? ` (${appliedCount})` : ""}
          </button>
          {isSent
            /* backend.new_message — compose lives on the Sent tab ONLY; the
               real Received tab has no compose button */
            ? <button type="button" className="rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 38 }} onClick={() => setCompose(true)}><Icon name="plus" size={13} /> New message</button>
            /* backend.update → refreshMessages() ajax reload; here a real re-read (see refreshInbox) */
            : <>
              {hmgSyncStamp(inboxSyncedAt)}
              <button type="button" className="cg-link" onClick={refreshInbox} title="Re-reads the inbox and re-applies the current filters"><Icon name="refresh" size={13} /> Update</button>
            </>}
        </div>
      </div>

      <div className="hp-tabs" style={{ marginBottom: 18 }}>
        {HMG_TABS.map(([id, label]) => (
          <button key={id} type="button" className={`hp-tab ${tab === id ? "active" : ""}`} onClick={() => setTab(id)}>{label}</button>
        ))}
      </div>

      <div className="rpt-filters hmg-deskfilters">
        <HmgFilterFields isSent={isSent} draft={draft[tab]} setDraft={setTabDraft} />
        <div className="rpt-actions"><div className="rpt-actions-row">
          {/* backend.search_button — the ONLY trigger; filters never apply on change */}
          <button type="button" className="rpt-btn rpt-btn--blue" onClick={applySearch}><Icon name="search" size={14} /> Search</button>
        </div></div>
      </div>

      {appliedCount > 0 && (
        <div className="hmg-pills">
          {applied[tab].object && <button type="button" className="filter-chip active" onClick={() => clearFilter("object")}>Object: “{applied[tab].object}” <Icon name="x" size={11} /></button>}
          {applied[tab].from && <button type="button" className="filter-chip active" onClick={() => clearFilter("from")}>From {applied[tab].from} <Icon name="x" size={11} /></button>}
          {applied[tab].to && <button type="button" className="filter-chip active" onClick={() => clearFilter("to")}>To {applied[tab].to} <Icon name="x" size={11} /></button>}
          {!isSent && applied[tab].status && <button type="button" className="filter-chip active" onClick={() => clearFilter("status")}>{applied[tab].status === "1" ? "Read" : "To read"} <Icon name="x" size={11} /></button>}
        </div>
      )}

      <div className="panel hmg-tablewrap" style={{ overflow: "hidden" }}>
        <div style={{ overflowX: "auto" }}>
          {!isSent ? (
            /* Received columns (MessagesController L33-39): ID · Object (link →
               view modal) · Message (messages_root.text) · Popup (yes_no) ·
               Status (stato) · Received date (d/m/Y G:i). The payload's
               nome_completo (sender) is a hidden extra, not a column. */
            <table className="data-table hp-list cg-list" style={{ width: "100%" }}>
              <thead><tr>
                <HmgSortTh label="ID" k="id" sort={sort.received} setSort={setTabSort} style={{ width: 70 }} />
                <HmgSortTh label="Object" k="object" sortable={false} sort={sort.received} setSort={setTabSort} style={{ textAlign: "left" }} />
                <HmgSortTh label="Message" k="message" sort={sort.received} setSort={setTabSort} style={{ textAlign: "left" }} />
                <HmgSortTh label="Popup" k="pop_up" sort={sort.received} setSort={setTabSort} />
                <HmgSortTh label="Status" k="stato" sort={sort.received} setSort={setTabSort} />
                <HmgSortTh label="Received date" k="addedTime" sort={sort.received} setSort={setTabSort} />
              </tr></thead>
              <tbody>
                {rowsPage.length === 0 && <tr><td colSpan={6} style={{ padding: 28, color: "#9aa1b4", textAlign: "center" }}>No data available in table</td></tr>}
                {rowsPage.map(r => (
                  <tr key={r.id} className={r.stato === 0 ? "hmg-unread" : ""}>
                    <td>{r.id}</td>
                    <td style={{ textAlign: "left" }}>
                      {r.stato === 0 && <span className="hmg-dot" />}
                      <button type="button" className="cg-namebtn" onClick={() => openView(r)}>{r.object}</button>
                    </td>
                    <td style={{ textAlign: "left" }}><span className="hmg-msgtext">{r.text}</span></td>
                    <td>{r.pop_up ? "Yes" : "No"}</td>
                    <td style={{ textAlign: "center" }}><HmgStatusChip stato={r.stato} /></td>
                    <td className="fm-date" style={{ textAlign: "center" }}>{hmgFmtDT(r.addedTime)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          ) : (
            /* Sent columns (MessagesController L43-47): ID (message_id — the
               messages_root id) · Object (link → sent modal) · Message ·
               Popup · Send date. One row per send (DISTINCT collapse). */
            <table className="data-table hp-list cg-list" style={{ width: "100%" }}>
              <thead><tr>
                <HmgSortTh label="ID" k="id" sort={sort.sent} setSort={setTabSort} style={{ width: 70 }} />
                <HmgSortTh label="Object" k="object" sortable={false} sort={sort.sent} setSort={setTabSort} style={{ textAlign: "left" }} />
                <HmgSortTh label="Message" k="message" sort={sort.sent} setSort={setTabSort} style={{ textAlign: "left" }} />
                <HmgSortTh label="Popup" k="pop_up" sort={sort.sent} setSort={setTabSort} />
                <HmgSortTh label="Send date" k="addedTime" sort={sort.sent} setSort={setTabSort} />
              </tr></thead>
              <tbody>
                {rowsPage.length === 0 && <tr><td colSpan={5} style={{ padding: 28, color: "#9aa1b4", textAlign: "center" }}>No data available in table</td></tr>}
                {rowsPage.map(r => (
                  <tr key={r.message_id}>
                    <td>{r.message_id}</td>
                    <td style={{ textAlign: "left" }}>
                      <button type="button" className="cg-namebtn" onClick={() => { setSentView(r); loadReceipts(r.message_id); }}>{r.object}</button>
                    </td>
                    <td style={{ textAlign: "left" }}><span className="hmg-msgtext">{r.text}</span></td>
                    <td>{r.pop_up ? "Yes" : "No"}</td>
                    <td className="fm-date" style={{ textAlign: "center" }}>{hmgFmtDT(r.addedTime)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>
      </div>

      <HmgCardList rows={rowsPage} isSent={isSent} expanded={expanded} onToggle={toggleCard}
        onOpen={(r) => { if (isSent) { setSentView(r); loadReceipts(r.message_id); } else openView(r); }} />

      <HmgPager total={rowsFiltered.length} page={page} setPage={setPage} size={size} setSize={setSize} />

      {sheet && <HmgSheet isSent={isSent} draft={draft[tab]} setDraft={setTabDraft} onSearch={applySearch} onClose={() => setSheet(false)} />}
      {view && <HmgViewModal row={view} onClose={() => setView(null)} />}
      {sentView && <HmgSentModal row={{ ...sentView, recipients: receipts[sentView.message_id] || [] }} onClose={() => setSentView(null)}
        onRefresh={() => setSentView(sent.find(s => s.message_id === sentView.message_id) || sentView)} />}
      {compose && <HmgCompose onClose={() => setCompose(false)} onSend={handleSend} />}
    </div>
  );
};

window.HostMessages = HostMessages;
