/* helpers.jsx — extracted verbatim from pages.jsx. IIFE-scoped; exports via window. */
(function(){
const { useState, useMemo, useEffect, useRef } = React;
// Style constant aliases — canonical definitions live in T (shared-blocks.jsx)
const T_MUTED    = T.muted;
const T_SOFT_CARD = T.softCard;
const _MONO       = T.mono;

// Cross-role consistency v2.5 · S4 — inline contact icon-buttons.
// Use _emailLink / _phoneLink for both Table cells and KeyValueGrid values
// so every email/phone surface gets the same mailto:/tel: affordance.
// Pass {muted:true} for table cells (uses T_MUTED ink-2), omit for KeyValueGrid.
const _emailLink = (v, opts) => {
  if (!v || v === "—") return v || "—";
  const muted = opts && opts.muted;
  return (
    <span style={{ display: "inline-flex", alignItems: "center", gap: 6, minWidth: 0 }}>
      <span style={muted ? T_MUTED : undefined}>{v}</span>
      <a href={`mailto:${v}`} className="contact-icon-btn" title="Email"
         onClick={e => e.stopPropagation()} aria-label={`Email ${v}`}>
        {Ic.mail(14)}
      </a>
    </span>
  );
};
const _phoneLink = (v, opts) => {
  if (!v || v === "—") return v || "—";
  const muted = opts && opts.muted;
  const tel = String(v).replace(/[^\d+]/g, "");
  return (
    <span style={{ display: "inline-flex", alignItems: "center", gap: 6, minWidth: 0 }}>
      <span style={muted ? T_MUTED : undefined}>{v}</span>
      <a href={`tel:${tel}`} className="contact-icon-btn" title="Call"
         onClick={e => e.stopPropagation()} aria-label={`Call ${v}`}>
        {Ic.phone(14)}
      </a>
    </span>
  );
};

// ── RBAC matrix (single source of truth, mirrors docs/specs/02-roles-permissions.md) ──
// When you add a role gate to a component, look up the capability here instead of
// hard-coding `if (!["GA"].includes(role)) return _notAuthorised`. If the spec says
// CM/AM can do something, the gate must allow them — otherwise fix THIS table first.
//
// Format: each capability key maps to the role tiers permitted by spec.
// CHECK 10 in audit-prototype.sh scans for role gates that don't reference this table.
const RBAC = {
  // Entity creation (spec §C, line 61–67)
  "country.create":      ["GA"],
  "franchisee.create":   ["GA"],
  "outlet.create":             ["GA", "CM", "AM"],   // spec line 63
  "outlet.edit":               ["GA", "CM", "AM"],   // umbrella — gates entry to OutletEdit
  "outlet.edit.identity":      ["GA", "CM", "AM"],   // concept name, location label
  "outlet.edit.address":       ["GA", "CM", "AM"],   // street, unit, postal
  "outlet.edit.hours":         ["GA", "CM", "AM"],   // spec line 64 — CM/AM full edit
  "outlet.edit.contact":       ["GA", "CM", "AM"],   // spec line 64 — CM/AM full edit
  "outlet.edit.assignment":    ["GA"],               // country/franchisee/area cascade — GA only
  "outlet.edit.pos":           ["GA"],               // pos_store_id — DX-issued, GA-only on UI
  "outlet.edit.billing":       ["GA"],               // platform fee — GA only
  "outlet.suspend":            ["GA", "CM"],         // spec line 65 — AM cannot suspend
  "user.create":         ["GA", "CM", "AM"],   // spec §C — CM creates AM+OM in country; AM creates OM in area
  "user.edit":           ["GA", "CM", "AM"],
  // Promotions / rewards (spec capability matrix line 114)
  // approve/reject are STATUS-gated at the page (only pending rows); propose is SHAPE-gated (only shape-enabled countries).
  // Gifts / Exchanges (spec §B-Gifts)
  "gift.create":        ["GA", "CM"],        // GA global; CM creates for own country
  "gift.delete":        ["GA"],              // hard delete — GA only
  "promotion.create":   ["GA"],              // GA-direct publish (global or any country)
  "promotion.propose":  ["CM"],              // CM drafts → pending_ga → GA approves (AM read-only; DX review 2026-06-14)
  "promotion.approve":  ["GA"],              // single-GA approval: CM proposes → pending_ga → GA approves
  "promotion.reject":   ["GA"],              // mirrors approve gate
  "promotion.archive":  ["GA", "CM"],        // CM author can archive own (AM no longer authors)
  "promotion.delete":   ["GA"],              // hard delete — GA only
  "reward.create":       ["GA"],
  "reward.propose":      ["GA", "CM", "AM"],  // AM proposes partner rewards
  "reward.approve":      ["GA"],               // GA approves pending rewards
  "automation.create":   ["GA"],               // GA-only authoring; CM/AM/OM view within scope
  "automation.delete":   ["GA"],               // hard/soft delete — GA only (danger zone)
  // Translations / AI / config (spec module overview)
  "translation.edit":    ["GA"],
  "ai.edit":             ["GA"],
  "country.config":      ["GA"],
  // Approvals (OM submits, AM/CM approve)
  "outlet.hours.approve":["GA", "CM", "AM"],
  "refund.approve":      ["GA", "CM", "AM"],   // OM self-serve up to limit; over-limit needs approval
};
window.RBAC = RBAC;
const _hasRbac = (role, capability) => (RBAC[capability] || []).includes(role);

// ── Delete-safety gate — 06-status.md §3 (uniform across modules) ─────
// Returns { ok, reason }. Referenced/in-use → blocked (any status). A `draft`
// row (never launched) is HARD-deletable by its creating tier (pass rbacOk).
// An `active`/`inactive` row (has been live) is SOFT-deletable — GA only, and
// only when unreferenced (the "special condition"). Pair with DeleteSection's
// `soft` prop: soft = status !== "draft".
const _canDelete = (role, { status, isReferenced = false, rbacOk = true } = {}) => {
  if (isReferenced)       return { ok: false, reason: "In use — remove linked records first." };
  if (status === "draft") return { ok: !!rbacOk, reason: rbacOk ? "" : "You don't have permission to delete this." };
  return role === "GA" ? { ok: true, reason: "" } : { ok: false, reason: "Only a Global Admin can delete a live record." };
};

// ── Wallet (SG-only) helpers ─────────────────────────────────────────
// _activeCountries(role, scope) → string[] of country_ids the role's scope covers
//   that are currently active. GA → every active country; CM/AM/OM → their home
//   country (from scope.countryId) if it's active, else []. Used to gate the
//   SG-only Wallet surface (a country must be active AND wallet_enabled to show it).
const _activeCountries = (role, scope) => {
  const countries = (typeof SB2_COUNTRIES !== "undefined" && SB2_COUNTRIES) || window.SB2_COUNTRIES || [];
  if (role === "GA") {
    return countries.filter(c => c.status === "active").map(c => c.country_id);
  }
  const home = scope && scope.countryId;
  if (!home) return [];
  const c = countries.find(x => x.country_id === home);
  return c && c.status === "active" ? [home] : [];
};

// _walletAmount(txn) → { text, color } — signed SGD money for the wallet ledger.
// Wallet is SG-only so currency is always S$. Signs both directions (the finance
// page only signs negatives); positives get a leading "+", negatives a "−" (U+2212).
const _walletAmount = (txn) => {
  const amt = (txn && txn.amount) || 0;
  const sign = amt >= 0 ? "+" : "−";
  const text = `${sign}S$${Math.abs(amt).toFixed(2)}`;
  return { text, color: amt >= 0 ? "var(--forest)" : "var(--berry)" };
};

// ── Shell / cross-cutting ────────────────────────────────────────────

// Tiny helper reused across SB3 screens
const _countryLabel = (id) => {
  const c = typeof SB2_COUNTRY_BY_ID !== "undefined" ? SB2_COUNTRY_BY_ID[id] : null;
  return c ? `${c.flag} ${c.country_name}` : (id || "—");
};
const _franchiseeLabel = (id) => { const f = SB3_FRANCHISEE_BY_ID[id]; return f ? f.display_name : (id || "—"); };
const _areaLabel = (id) => { const a = SB3_AREA_BY_ID[id]; return a ? a.name : (id || "—"); };

// ── Country feature-flag classification (DX review 2026-06-15, OQ-072) ──────────
// Only the 4 `optional` flags (Gifts/Rewards/Promotions/Partnerships) are toggleable.
// Wallet (`lockedTo`) is the SG-only special (OQ-008). Everything else is COMPULSORY —
// always-on for every country, never a live toggle.
// _flagState(f, countryId) → { toggleable, forced } where forced is the locked value
//   (true/false) for non-toggleable flags, or null when the GA controls it.
const _flagState = (f, countryId) => {
  if (f.optional)  return { toggleable: true,  forced: null };
  if (f.lockedTo)  return { toggleable: false, forced: f.lockedTo.includes(countryId) }; // Wallet → on iff SG
  return { toggleable: false, forced: true };                                            // compulsory → always on
};
// _resolveFlags(userFlags, countryId) → the effective flag map: optional flags from the
// user's choices, compulsory/wallet forced to their locked value.
const _resolveFlags = (userFlags, countryId) => {
  const defs = (typeof SB2_FLAG_DEFS !== "undefined" ? SB2_FLAG_DEFS : (window.SB2_FLAG_DEFS || []));
  const out = {};
  defs.forEach(f => { const st = _flagState(f, countryId); out[f.key] = st.toggleable ? !!(userFlags || {})[f.key] : st.forced; });
  return out;
};
const _go = (route, payload) => {
  if (typeof window === "undefined") return;
  // ISSUE-032 / ISSUE-037 (BUG): always overwrite payload (clear when none passed)
  // so "+ New X" entries to an edit screen don't inherit a stale id from a prior view.
  window.__sixhands_route_payload = payload || null;
  if (window.__sixhands_setRoute) window.__sixhands_setRoute(route);
};
const _notAuthorised = (
  <EmptyState icon={null} title="Not authorised" body="This screen is available to Global Admins only. Your current role can't view it."/>
);

// Prototype audit push helper (reused pattern from SB3 m-3).
const _pushAudit = (entry) => {
  if (typeof window === "undefined") return;
  if (!window.__sixhands_audit) window.__sixhands_audit = [];
  window.__sixhands_audit.push({ ts: new Date().toISOString(), ...entry });
};

// OQ-041 · Remarks helper — make a new remark + push to audit_logs.
// Returns [remarks, addRemark] tuple, mirroring the useState shape detail pages use.
const _useRemarks = (entityType, entityId, seed = []) => {
  const [remarks, setRemarks] = useState(seed);
  const addRemark = (content) => {
    const entry = {
      id: "rem_" + Math.random().toString(36).slice(2, 10),
      content,
      created_by_name: "You",
      created_by: "you",
      created_at: new Date().toISOString().replace("T", " ").slice(0, 16),
    };
    setRemarks(rs => [...rs, entry]);
    _pushAudit({ action: "remark_added", entity_type: entityType, entity_id: entityId, diff: { content } });
  };
  // edit-own-only: update a remark's content in place (RemarksSection gates the pencil to own rows)
  const editRemark = (id, content) => {
    setRemarks(rs => rs.map(r => r.id === id ? { ...r, content } : r));
    _pushAudit({ action: "remark_edited", entity_type: entityType, entity_id: entityId, diff: { id, content } });
  };
  // delete-own-only: remove a remark (RemarksSection gates + confirms; both on own rows)
  const deleteRemark = (id) => {
    setRemarks(rs => rs.filter(r => r.id !== id));
    _pushAudit({ action: "remark_deleted", entity_type: entityType, entity_id: entityId, diff: { id } });
  };
  return [remarks, addRemark, editRemark, deleteRemark];
};

// ── Canonical date-time DISPLAY format (03-ui convention 7) ──────────
// ONE format everywhere: table Date/Timestamp columns AND entity detail
// views render byte-identical. Canonical string: "YYYY-MM-DD HH:MM <TZ>"
// (e.g. "2026-06-03 09:14 SGT"), in the RECORD's local timezone (outlet→country),
// never the viewer's. _fmtDate / _fmtTime are the date-only / time-only column
// variants (no TZ suffix). NOTE: this is DISPLAY only — date FILTERING keeps its
// own per-page SGT helpers (page-wallet _sgtDate/_sgtTime/_sgtToday).
//
// TZ map: country_id → { offset (hours from UTC), label }. Fallback UTC.
const _TZ_MAP = {
  SG: { offset: 8, label: "SGT" },
  PH: { offset: 8, label: "PHT" },
  ID: { offset: 7, label: "WIB" },
  TH: { offset: 7, label: "ICT" },
};
const _tzInfo = (tz) => _TZ_MAP[tz] || { offset: 0, label: "UTC" };
// Parse naive ISO (no Z / no offset) as UTC — mirrors page-wallet _asUTC.
const _asUTCdt = (iso) => new Date(/Z$|[+-]\d\d:?\d\d$/.test(iso) ? iso : iso + "Z");
// Shift to the record's local frame, return ISO of that wall-clock time.
const _localISO = (iso, tz) => {
  const { offset } = _tzInfo(tz);
  return new Date(_asUTCdt(iso).getTime() + offset * 3600000).toISOString();
};
// "YYYY-MM-DD HH:MM <TZ>" — the one canonical datetime display string.
const _fmtDateTime = (iso, tz) => {
  if (!iso) return "—";
  const d = _asUTCdt(iso);
  if (isNaN(d.getTime())) return iso;
  return _localISO(iso, tz).slice(0, 16).replace("T", " ") + " " + _tzInfo(tz).label;
};
// "YYYY-MM-DD" — date-only column variant (no TZ suffix).
const _fmtDate = (iso, tz) => {
  if (!iso) return "—";
  const d = _asUTCdt(iso);
  if (isNaN(d.getTime())) return iso;
  return _localISO(iso, tz).slice(0, 10);
};
// "HH:MM" — time-only column variant (no TZ suffix).
const _fmtTime = (iso, tz) => {
  if (!iso) return "—";
  const d = _asUTCdt(iso);
  if (isNaN(d.getTime())) return iso;
  return _localISO(iso, tz).slice(11, 16);
};
// "YYYY-MM-DD HH:MM <TZ>" for a WALL-CLOCK SCHEDULE value (reward/promo window).
// Distinct from _fmtDateTime: a window's start/end is a naive LOCAL wall-clock
// (the same value the datetime-local editor holds), NOT a UTC instant — so we
// append the tz label WITHOUT shifting (no UTC→local conversion). This keeps the
// read view byte-identical to the edit field (§37 / 03-ui convention 14). Use for
// schedule windows; use _fmtDateTime for true event instants (approved_at, created_at).
const _fmtLocalDateTime = (naive, tz) => {
  if (!naive) return "—";
  return String(naive).slice(0, 16).replace("T", " ") + " " + _tzInfo(tz).label;
};

// ── Active-scope resolvers (header GA scope chip → ledger filters) ───────
// The GA header chip sets scope.countryId (Global → null). Ledger filter pages
// derive their time-of-day presets + baseline FILTER timezone from the active
// scope via these two helpers. Row DISPLAY tz stays record-local (_fmtDateTime).
//
// _scopeTz — baseline tz country for the active scope. Global (null) → the GA/HQ
// default tz (`_gaTz`, Singapore by default). A country scope → that country.
const _scopeTz = (scope) => (scope && scope.countryId) || _gaTz();

// _gaTz — the GA / HQ default timezone (country code), GA-configurable via the
// `hq_timezone` scalar Custom Option (scope "ALL"). Falls back to "SG" (HQ).
// Used as the tz frame for GA-global views + multi-country records with no single
// market (e.g. a multi-country reward window).
const _gaTz = (customOptions) => {
  const opts = customOptions || (typeof CUSTOM_OPTIONS !== "undefined" ? CUSTOM_OPTIONS : (window.CUSTOM_OPTIONS || []));
  const row = (opts || []).find(o => o.option_type === "hq_timezone" && o.scope === "ALL");
  return (row && row.tz) || "SG";
};
// _scopePresets — the time_of_day_range rows for the active scope.
//   country scope  → rows where o.scope === countryId OR o.scope === 'ALL'
//   Global (null)  → all time_of_day_range rows (every country + ALL)
// Returns [{ label, start, end }]. customOptions = CUSTOM_OPTIONS.
const _scopePresets = (scope, customOptions) => {
  const opts      = customOptions || [];
  const countryId = scope && scope.countryId;
  return opts
    .filter((o) => o.option_type === "time_of_day_range" &&
      (countryId ? (o.scope === countryId || o.scope === "ALL") : true))
    .map((o) => ({ label: o.label, start: o.start, end: o.end }));
};

// Tiny helpers used across SB5 pages.
// Delegates to the canonical _fmtDateTime; default tz="UTC" preserves the
// finance settlement convention (UTC is intentional) → "YYYY-MM-DD HH:MM UTC".
const _ISO_DATE = (iso, tz = "UTC") => _fmtDateTime(iso, tz);
// _SINCE — relative "x ago". Accepts an optional `now` anchor so prototype
// timestamps stay stable against MOCK_NOW (real Date.now() would drift the demo).
const _SINCE = (iso, now = Date.now()) => {
  if (!iso) return "—";
  const mins = Math.round((now - new Date(iso).getTime()) / 60000);
  if (isNaN(mins)) return iso;
  if (mins < 1) return "just now";
  if (mins < 60) return `${mins} min ago`;
  const h = Math.round(mins / 60);
  if (h < 48) return `${h}h ago`;
  return `${Math.round(h / 24)}d ago`;
};
// _syncCell — the canonical two-line timestamp cell: ISO datetime (consistency)
// over a muted relative line. `compact` drops the TZ suffix for narrow list cells
// (full TZ shown in detail drawers). Reuse repo-wide for any last-sync / updated_at.
const _syncCell = (iso, tz, now, compact) => {
  if (!iso) return <span style={{ ...T_MUTED }}>—</span>;
  const top = compact ? `${_fmtDate(iso, tz)} ${_fmtTime(iso, tz)}` : _fmtDateTime(iso, tz);
  return (
    <span style={{ display: "block", lineHeight: 1.35, whiteSpace: "nowrap" }}>
      <span style={{ fontVariantNumeric: "tabular-nums" }}>{top}</span>
      <span style={{ ...T_MUTED, display: "block", fontSize: 12 }}>{_SINCE(iso, now)}</span>
    </span>
  );
};
// _MONO and T_SOFT_CARD defined at top of file as aliases for T.mono / T.softCard
const _FILE_SIZE = (kb) => {
  if (kb == null || kb === 0) return "—";
  if (kb < 1024) return `${kb.toLocaleString()} KB`;
  return `${(kb / 1024).toFixed(1)} MB`;
};

// Messenger audience Scope·Name helper (US-053 / Messenger module).
// Returns e.g. "All markets" / "Country · 🇵🇭 Philippines" / "Area · Metro Manila" / "Outlet · BGC High Street"
const _notifAudienceLabel = (notif) => {
  if (!notif) return "—";
  if (notif.audience === "all") return "All markets";
  if (notif.audience === "country") {
    const c = SB2_COUNTRY_BY_ID[notif.country_id];
    return c ? `Country · ${c.flag} ${c.country_name}` : `Country · ${notif.country_id || "—"}`;
  }
  if (notif.audience === "area") {
    const a = SB3_AREA_BY_ID[notif.area_id];
    return a ? `Area · ${a.name}` : `Area · ${notif.area_id || "—"}`;
  }
  if (notif.audience === "outlet") {
    if (notif.outlet_ids && notif.outlet_ids.length === 1) {
      const o = SB3_OUTLET_BY_ID[notif.outlet_ids[0]];
      return o ? `Outlet · ${o.location}` : `Outlet · ${notif.outlet_ids[0]}`;
    }
    if (notif.outlet_ids && notif.outlet_ids.length > 1) {
      return `Outlet · ${notif.outlet_ids.length} outlets`;
    }
    return "Outlet · —";
  }
  return "—";
};

// ── _dtfMatch — shared DateTimeFilter row-match predicate (ledger SSOT) ────
// Does a row whose timestamp is `iso` match the DateTimeFilter `value`,
// evaluated in the RECORD-LOCAL timezone `tz` (country code → _TZ_MAP, default
// "SG")? The one predicate every ledger page (wallet, loyalty, orders,
// messenger, promotions, redemptions) filters by — no divergent per-page copies.
//
// value shape (DateTimeFilter v2): { mode:'date'|'range', date:'YYYY-MM-DD'|null,
//   range:{from,to}|null, time:{full:true}|{start?,end?} } | null.
//   null / default ⇒ Today · Full day.
//
// DATE  mode==='range' → localDate within [range.from, range.to] inclusive
//                        (missing bound = open). mode==='date'/default →
//                        localDate === (value.date || today-in-tz).
// TIME  time.full / no time → true. Else localTime within [time.start, time.end)
//                        — `>= start` and `< end`, each bound optional (missing =
//                        open). Exclusive end matches the wallet convention.
// Falsy iso → false.
const _dtfMatch = (value, iso, tz) => {
  if (!iso) return false;
  const z       = (tz && _TZ_MAP[tz]) ? tz : "SG";
  const mode    = value && value.mode === "range" ? "range" : "date";
  const range   = (value && value.range) ? value.range : { from: "", to: "" };
  const time    = (value && value.time && !value.time.full) ? value.time : null;
  const localD  = _localISO(iso, z).slice(0, 10);   // YYYY-MM-DD in tz
  // ── DATE ──
  if (mode === "range") {
    if ((range.from && localD < range.from) || (range.to && localD > range.to)) return false;
  } else {
    const target = (value && value.date) ? value.date : _localISO(new Date().toISOString(), z).slice(0, 10);
    if (localD !== target) return false;
  }
  // ── TIME ──
  if (time) {
    const localT = _localISO(iso, z).slice(11, 16); // HH:MM in tz
    if ((time.start && localT < time.start) || (time.end && localT >= time.end)) return false;
  }
  return true;
};

// ── _dtfWindowMatch — DateTimeFilter "active during" predicate for date-WINDOW records
//    (e.g. Promotions, which are a [start,end] window, not a point-in-time event).
//    Matches when the window OVERLAPS the filter selection (single day or range).
//    Date-level only — promo windows have no time-of-day, so the time component is ignored. ──
const _dtfWindowMatch = (value, start, end, tz) => {
  if (!start) return false;
  const z = (tz && _TZ_MAP[tz]) ? tz : "SG";
  const s = String(start).slice(0, 10);
  const e = String(end || start).slice(0, 10);
  const mode = value && value.mode === "range" ? "range" : "date";
  let selFrom, selTo;
  if (mode === "range") {
    const r = (value && value.range) ? value.range : {};
    selFrom = r.from || "0000-01-01";
    selTo   = r.to   || "9999-12-31";
  } else {
    selFrom = selTo = (value && value.date) ? value.date : _localISO(new Date().toISOString(), z).slice(0, 10);
  }
  return s <= selTo && e >= selFrom; // window overlaps the selected day/range
};

// ── _orderDisplayStatus ───────────────────────────────────────────────────
// Returns the display-facing lifecycle status for an order.
// - If the order has a succeeded full-refund record, returns 'refunded'
//   (POS status stays completed; refunded is a DERIVED display state per spec).
// - Otherwise normalises POS payment statuses to the orderLifecycle enum:
//   paid→completed, pending→received; other values pass through.
// Used for BOTH the list status column and the drawer status pill so display
// is consistent across surfaces.
const _orderDisplayStatus = (order) => {
  if (!order) return "received";
  // Derived refunded: payment.status==='refunded' OR a refund record is present
  if ((order.payment && order.payment.status === "refunded") || order.refund) return "refunded";
  // Normalise legacy POS status values to the orderLifecycle enum
  const s = order.status;
  if (s === "paid") return "completed";
  if (s === "pending") return "received";
  // Pass through known lifecycle values directly
  return s || "received";
};

// ── Refunds module helpers (02-rbac §5, launch_shape routing) ──────────────
// _refundLimitFor(country_id, tier) → numeric limit for the tier in that country.
// GA is uncapped (returns Infinity). cm/am/om read from REFUND_LIMITS_BY_COUNTRY.
const _refundLimitFor = (country_id, tier) => {
  if (!tier) return Infinity;
  const t = tier.toLowerCase();
  if (t === "ga") return Infinity;
  const limits = (typeof REFUND_LIMITS_BY_COUNTRY !== "undefined" && REFUND_LIMITS_BY_COUNTRY)
    || window.REFUND_LIMITS_BY_COUNTRY || {};
  return (limits[country_id] && limits[country_id][t]) || Infinity;
};

// _refundEscalationApprover(launch_shape, fromTier) → next approval tier string.
// Routing: the next tier ABOVE fromTier that is present in launch_shape, else 'ga'.
// launch_shape values: 'om' | 'om_cm' | 'om_am' | 'om_am_cm'.
// Tier order: om < am < cm < ga.
// Examples:
//   om_am_cm + om  → am
//   om_am_cm + am  → cm
//   om_am_cm + cm  → ga
//   om_cm    + om  → cm
//   om       + om  → ga
const _refundEscalationApprover = (launch_shape, fromTier) => {
  if (!fromTier) return "ga";
  const ft = fromTier.toLowerCase();
  if (ft === "ga") return "ga"; // already at top
  const shape = (launch_shape || "om").toLowerCase();
  const tierOrder = ["om", "am", "cm", "ga"];
  const fromIdx = tierOrder.indexOf(ft);
  if (fromIdx < 0) return "ga";
  // Walk up, find next tier present in launch_shape (ga always present as fallback)
  for (let i = fromIdx + 1; i < tierOrder.length; i++) {
    const t = tierOrder[i];
    if (t === "ga" || shape.includes(t)) return t;
  }
  return "ga";
};

// _promotionProposer(country) → 'CM' | 'GA'
// Returns which role drafts promotions in this country's launch_shape.
// CM-only authoring (DX review 2026-06-14): CM drafts → pending_ga → GA approves.
// AM is read-only (no longer authors). In a CM-less shape (om / om_am) there is no
// proposer tier → GA creates directly ('GA').
// Accepts EITHER a country object (reads country.launch_shape) OR a country_id string
// (resolves it against SB2_COUNTRIES) — callers pass both, so handle both.
const _promotionProposer = (country) => {
  const c = (typeof country === "string")
    ? ((typeof SB2_COUNTRIES !== "undefined" ? SB2_COUNTRIES : (window.SB2_COUNTRIES || [])).find(x => x.country_id === country) || {})
    : (country || {});
  return String(c.launch_shape || "").includes("cm") ? "CM" : "GA";
};

Object.assign(window, { RBAC, T_MUTED, T_SOFT_CARD, _FILE_SIZE, _ISO_DATE, _MONO, _SINCE, _syncCell, _activeCountries, _areaLabel, _canDelete, _countryLabel, _flagState, _resolveFlags, _dtfMatch, _dtfWindowMatch, _emailLink, _fmtDate, _fmtDateTime, _fmtLocalDateTime, _fmtTime, _gaTz, _franchiseeLabel, _go, _hasRbac, _notAuthorised, _notifAudienceLabel, _orderDisplayStatus, _phoneLink, _promotionProposer, _pushAudit, _refundEscalationApprover, _refundLimitFor, _scopePresets, _scopeTz, _useRemarks, _walletAmount });
})();
