/* Main App — TxDetail drawer is defined in src/drawer.jsx (loaded earlier). */

/* Toast host — subscribes to the PayBO toast bus and shows the latest
   To Confirm notifications. Click "Open" to jump into the drawer. */
const ToastHost = ({ onOpen }) => {
  const [toasts, setToasts] = useState([]);
  useEffect(() => {
    if (!window.PAYBO) return;
    return window.PAYBO.onToast(t => {
      // Only real transaction alerts belong in this shape. A Host-shaped
      // payload used to fall through here and render as "HOST 0.00 - Iwakiri
      // White Label" with an Open button that resolved nothing: the currency
      // marker printed as money and the title/detail landed in the wrong slots.
      if (!hostNoticeIsPayment(t)) return;
      setToasts(cur => [...cur, t]);
      setTimeout(() => setToasts(cur => cur.filter(x => x.id !== t.id)), 12_000);
    });
  }, []);
  const dismiss = (id) => setToasts(cur => cur.filter(x => x.id !== id));
  /* Open resolved the id against the mock transaction list. There is no
     equivalent lookup yet: `tx_id` on a payment toast is whatever the emitting
     screen put there, and nothing says whether it is a deposit_requests id, a
     withdrawal_requests id or a ledger entry id.

     UNCLEAR-15: give the payment toast an explicit `{ kind, id }` so Open can
     fetch the right row. Until then Open says why it cannot, rather than
     silently dismissing — a button that appears to work and does nothing is
     the failure this whole notice surface was built to fix. */
  const openTx = (t) => {
    if (onOpen && t && t.row) { onOpen(t.row); dismiss(t.id); return; }
    if (window.PAYBO && window.PAYBO.emitToast) {
      window.PAYBO.emitToast({
        id: `open-${t.id}`, tx_id: "Cannot open this transaction", amount: 0,
        currency: "ERR", player: "Notices",
        reason: `The alert carries the reference ${t.tx_id} but not which table it is in, so there is nothing to open yet.`,
      });
    }
    dismiss(t.id);
  };
  const curSym = (c) => c === "EUR" ? "€" : c === "USD" ? "$" : c === "GBP" ? "£" : c === "BRL" ? "R$" : c === "CAD" ? "C$" : c + " ";
  return (
    <div className="paybo-toasts">
      {toasts.map(t => (
        <div key={t.id} className={`paybo-toast ${t.reason === "High risk" ? "paybo-toast--high" : ""}`}>
          <div className="paybo-toast__icon"><Icon name="flag" size={13}/></div>
          <div className="paybo-toast__body">
            <div className="paybo-toast__head">New · To Confirm</div>
            <div className="paybo-toast__title">
              <span className="paybo-toast__amount">{curSym(t.currency)}{t.amount.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})}</span>
              {" · "}{t.player}
            </div>
            <div className="paybo-toast__sub"><span className="mono">{t.tx_id}</span> · {t.reason}</div>
            <div className="paybo-toast__actions">
              <button className="paybo-toast__btn" onClick={() => openTx(t)}>Open</button>
              <button className="paybo-toast__btn paybo-toast__btn--ghost" onClick={() => dismiss(t.id)}>Dismiss</button>
            </div>
          </div>
          <button className="paybo-toast__close" onClick={() => dismiss(t.id)}>
            <Icon name="x" size={11}/>
          </button>
        </div>
      ))}
    </div>
  );
};

/* Host notice surface.

   Host screens report back through the same PAYBO toast bus, but with a
   neutral payload: `tx_id` carries the title, `player` the source screen,
   `reason` the detail, `amount` is 0 and `currency` is a marker ("HOST" /
   "CMS" / "CSV" / "DEMO", or "ERR" for a validation failure) rather than money.

   One bus, two shapes, and `hostNoticeIsPayment` is the whole rule: a toast is
   a transaction alert iff its `tx_id` resolves to a real mock transaction —
   the same test ToastHost's own "Open" button uses. Each surface takes only
   its own shape and both mount on every route, so it no longer matters which
   page the operator is on when a notice fires.

   Two bugs this replaced, in order of discovery:
   · ToastHost used to mount ONLY under /payments, so every Host screen's
     feedback was published to a bus with no subscriber and vanished — Copy,
     Export, Save and Promo Code all looked like dead buttons.
   · Then, mounting by route, a Host notice fired from the shell (the mobile
     drawer, the brand switcher) while on a /payments page rendered through the
     PayBO shape as "HOST 0.00 · Iwakiri White Label": the currency marker
     printed as money, the title and detail landed in the wrong slots, and the
     Open button resolved nothing. */
/* THE DISCRIMINATOR IS THE PAYLOAD, NOT A DATASET.
   This used to ask whether `tx_id` resolved to a row in `window.MOCK.
   TRANSACTIONS`, which meant the shell could only tell the two toast shapes
   apart while a mock dataset existed — and once it did not, every Host notice
   would have rendered through the payment shape again, printing its currency
   marker as money. That is the second bug listed above, returning by a
   different route.
   
   The Host shape already announces itself: `currency` carries a marker rather
   than a currency, and `amount` is 0. Testing that is a property of the
   message, needs no data at all, and cannot rot. */
const HOST_NOTICE_MARKERS = new Set(["HOST", "CMS", "CSV", "DEMO", "ERR"]);
/* The brand shown before `skins` has answered. Deliberately unnamed: a
   placeholder that looks like a brand is indistinguishable from a real one at
   a glance, and this one has to be obvious. */
const PB_LOADING_BRAND = Object.freeze({
  id: null, name: "—", short: "··", color: "linear-gradient(135deg,#94a3b8,#64748b)",
  currency: "", market: "", isLoading: true,
});

const hostNoticeIsPayment = (t) =>
  !HOST_NOTICE_MARKERS.has(String((t && t.currency) || "").toUpperCase());

const HostNotices = () => {
  const [items, setItems] = useState([]);
  useEffect(() => {
    if (!window.PAYBO) return;
    return window.PAYBO.onToast(t => {
      if (hostNoticeIsPayment(t)) return;   // ToastHost owns those
      // Cap the stack — a bulk action can fire several in a row and a tall
      // column of notices would cover the table it is reporting on.
      setItems(cur => [...cur, t].slice(-4));
      setTimeout(() => setItems(cur => cur.filter(x => x.id !== t.id)), 7000);
    });
  }, []);
  const dismiss = (id) => setItems(cur => cur.filter(x => x.id !== id));
  if (!items.length) return null;
  return (
    <div className="host-notices" role="status" aria-live="polite">
      {items.map(t => (
        <div key={t.id} className={`host-notice ${t.currency === "ERR" ? "host-notice--err" : ""}`}>
          <div className="host-notice__icon">
            <Icon name={t.currency === "ERR" ? "alert" : "check"} size={13}/>
          </div>
          <div className="host-notice__body">
            <div className="host-notice__title">{t.tx_id}</div>
            {t.reason && <div className="host-notice__sub">{t.reason}</div>}
            {t.player && <div className="host-notice__src">{t.player}</div>}
          </div>
          <button className="host-notice__close" onClick={() => dismiss(t.id)} aria-label="Dismiss">
            <Icon name="x" size={11}/>
          </button>
        </div>
      ))}
    </div>
  );
};

const TweaksPanel = ({ visible, navMode, setNavMode, density, setDensity, accent, setAccent, theme, setTheme, onClose }) => (
  <div className={`tweaks ${visible?"visible":""}`}>
    <div className="tweaks__head">
      <Icon name="sliders" size={14}/>
      <div className="tweaks__title">Tweaks</div>
      <button className="btn btn--ghost btn--icon btn--sm" style={{marginLeft:"auto"}} onClick={onClose}><Icon name="x" size={13}/></button>
    </div>
    <div className="tweaks__body">
      <div>
        <div className="tweaks__label">Navigation pattern</div>
        <div className="tweaks__options tweaks__options--nav">
          {[
            ["top","Top bar","Horizontal menu across the top"],
            ["sidebar","Sidebar","Classic left nav with labels"],
            ["collapsed","Icon rail","Icons-only left nav"],
            ["hybrid","Hybrid","Icon rail + subnav"],
            ["dual-top","Dual top","Header + centered tabs"],
            ["floating-rail","Floating rail","Pill-shaped floating left"],
            ["command","Command bar","Header with ⌘K palette"],
            ["mega-top","Mega top","Top + expandable category tray"],
          ].map(([k,label,sub]) => (
            <button key={k} className={`tweaks__option tweaks__nav-opt ${navMode===k?"active":""}`} onClick={()=>setNavMode(k)}>
              <div className={`tweaks__navpreview tweaks__navpreview--${k}`}>
                <span className="p-hdr"/>
                {(k==="sidebar"||k==="collapsed"||k==="hybrid"||k==="floating-rail") && <span className="p-side"/>}
                {(k==="top"||k==="hybrid"||k==="dual-top"||k==="mega-top") && <span className="p-sub"/>}
                <span className="p-main"/>
              </div>
              <div className="tweaks__nav-text">
                <div className="tweaks__nav-label">{label}</div>
                <div className="tweaks__nav-sub">{sub}</div>
              </div>
            </button>
          ))}
        </div>
      </div>
      <div>
        <div className="tweaks__label">Accent color</div>
        <div className="tweaks__options tweaks__options--accent">
          {[
            ["iwakiri","Iwakiri blue","#2a3fd0"],
            ["paybo","PayBO blue","#1e40af"],
            ["red","Iwakiri red","#e2011a"],
            ["indigo","Indigo","#3b55f0"],
            ["midnight","Midnight","#0b1d4f"],
            ["teal","Teal","#0d9488"],
            ["green","Emerald","#1f9d57"],
            ["gold","Gold","#c48a14"],
            ["orange","Orange","#ea580c"],
            ["rose","Rose","#e11d74"],
            ["purple","Purple","#7c3aed"],
            ["slate","Slate","#475569"],
          ].map(([name,label,col])=>(
            <button key={name} className={`tweaks__option tweaks__accent-opt ${accent===name?"active":""}`} onClick={()=>setAccent(name)}>
              <span className="tweaks__swatch" style={{background:col}}/>
              <span>{label}</span>
            </button>
          ))}
        </div>
      </div>
      <div>
        <div className="tweaks__label">Density</div>
        <div className="tweaks__options tweaks__options--density">
          {[
            ["compact","Compact","Max info density"],
            ["cozy","Cozy","Balanced spacing"],
            ["comfy","Comfy","Relaxed reading"],
            ["spacious","Spacious","Generous padding"],
          ].map(([k,label,sub]) => (
            <button key={k} className={`tweaks__option tweaks__density-opt ${density===k?"active":""}`} onClick={()=>setDensity(k)}>
              <div className={`tweaks__density-bars tweaks__density-bars--${k}`}>
                <span/><span/><span/>
              </div>
              <div className="tweaks__density-text">
                <div className="tweaks__density-label">{label}</div>
                <div className="tweaks__density-sub">{sub}</div>
              </div>
            </button>
          ))}
        </div>
      </div>
    </div>
  </div>
);

const ALL_BRAND = {
  id: "all",
  name: "All brands",
  short: "ALL",
  color: "linear-gradient(135deg,#2a3040,#0f1420)",
  currency: "Multi",
  market: "Aggregate",
  isAll: true,
};

/* Error boundary — prevents a single component crash from taking down
   the whole app (the Dashboard donut already did this once). Captures
   the error, shows a contained message + Retry button, and keeps the
   host chrome (navy strip / nav / subnav / rail / Tweaks) interactive. */
class PageErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { error: null }; }
  static getDerivedStateFromError(error) { return { error }; }
  componentDidCatch(error, info) {
    if (typeof console !== "undefined") console.error("[PayBO] page crashed:", error, info);
  }
  componentDidUpdate(prevProps) {
    if (prevProps.routeKey !== this.props.routeKey && this.state.error) {
      this.setState({ error: null });
    }
  }
  render() {
    if (!this.state.error) return this.props.children;
    return (
      <div className="page">
        <div className="page__header">
          <div>
            <div className="page__title" style={{color:"var(--err, #dc2626)"}}>Something went wrong on this page</div>
            <div className="page__subtitle">The rest of the app is still usable — click another section in the top menu or the rail, or retry below.</div>
          </div>
        </div>
        <div className="panel" style={{padding:16}}>
          <div style={{fontSize:12, color:"var(--text-tertiary)", marginBottom:10}}>
            <strong>Error:</strong> <code>{String(this.state.error && this.state.error.message || this.state.error)}</code>
          </div>
          <button className="paybo-btn paybo-btn--primary"
            onClick={() => this.setState({ error: null })}>
            <Icon name="refresh" size={12}/> Retry
          </button>
        </div>
      </div>
    );
  }
}

const App = () => {
  const [navMode, setNavMode] = useState(() => pbStore.get("pb-nav", "top") || "top");
  // Initial active page — the URL path is the primary source (so a
  // direct link or a refresh on e.g. /players lands on Players), then
  // the legacy `?view=...` param (old bookmarks to the preview pages),
  // then whatever was last open, then the default.
  const initialActive = (() => {
    try {
      const fromPath = window.activeForPath && window.activeForPath(window.location.pathname);
      if (fromPath) return fromPath;
      const u = new URL(window.location.href);
      const v = u.searchParams.get("view");
      if (v) return v;
    } catch (_e) {}
    return pbStore.get("pb-active", "dashboard") || "dashboard";
  })();
  const [active, _setActive] = useState(initialActive);
  // Every page gets its own URL (see src/routes.jsx). Wrapper around
  // setActive that (a) opens the two informational preview pages
  // (Frontend, Development) in a new browser tab so the operator keeps
  // their main back-office work intact, and (b) pushes the matching URL
  // for every other page so the address bar always reflects what's on
  // screen and back/forward/refresh/deep-links all work.
  const setActive = (next) => {
    const path = window.pathForActive && window.pathForActive(next);
    if (next === "frontend" || next === "devphase") {
      if (path) { window.open(window.location.origin + path, "_blank", "noopener"); return; }
      try {
        const u = new URL(window.location.href);
        u.searchParams.set("view", next);
        window.open(u.toString(), "_blank", "noopener");
        return;
      } catch (_e) {
        // Fall through to in-app navigation if the URL build fails.
      }
    }
    if (path && window.location.pathname !== path) {
      window.history.pushState({ active: next }, "", path);
    }
    _setActive(next);
  };
  // Brands live in state so Settings can mutate them
  const [brands, setBrands] = useState(() => {
    // pb-brands is CONTENT, not a preference (src/store.jsx). It is one of the
    // five keys pbStore.clearContent() drops at backend cutover, because
    // otherwise a brand somebody created in the prototype outlives the API.
    /* THIS INITIALISER READ `brands` — THE VARIABLE IT IS INITIALISING.
       ----------------------------------------------------------------
       The line was `new Set(brands.map(b => b.id))`, a self-reference inside
       the useState factory. In-browser Babel makes every top-level `const` a
       loose assignment, so a use-before-declaration reads `undefined` instead
       of raising a TDZ ReferenceError — but this one is not top-level, it is a
       closure over a `const` in TDZ, and it threw:

         TypeError: Cannot read properties of undefined (reading 'map')
           at App → mountIndeterminateComponent

       Every route, on any browser that had `pb-brands` in localStorage. It only
       reproduced on the SECOND page load of a session, because the cache has to
       exist for the branch to be reached at all — which is why the route smoke
       test (one fresh load per route) was green throughout and sbcheck, which
       boots the same page repeatedly, printed three of these after every boot.

       It came from replacing `window.MOCK.BRANDS.map(...)` with `brands.map(
       ...)` when the seed list was deleted. The comparison it was doing —
       "is this cached list still one of the canonical brands?" — HAS NO ANSWER
       AT MOUNT any more. The canonical list is `skins`, which has not been
       fetched yet. There is nothing to validate against, so validating is not
       a thing this can do.

       What it does instead: trust the cache provisionally and let the loader
       reconcile. That is safe because the effect below REPLACES the list the
       first time real skins arrive and re-selects the brand, and because the
       cached entry is a brand this operator was genuinely looking at moments
       ago rather than an invented one — `pbStore.clearContent()` drops
       `pb-brands` at cutover for exactly that reason. */
    const parsed = pbStore.get("pb-brands", null);
    if (Array.isArray(parsed) && parsed.length) return parsed;
    /* No invented fallback. An empty list is the honest state before `skins`
       loads; the effect below fills it and re-selects. A seeded brand list here
       meant every screen briefly rendered a brand that does not exist. */
    return [];
  });
  const [brand, setBrand] = useState(() => {
    const id = pbStore.get("pb-brand-id", null);
    if (id === "all") return ALL_BRAND;
    /* PB_LOADING_BRAND until `skins` returns — not an invented first brand,
       and not null either: the shell reads brand.color and brand.currency
       unconditionally, so null crashes every route (it did).

       The sentinel carries no name, no currency and no market. An empty
       currency means fxConvert refuses rather than converting, so figures
       render "—" during the one frame before the real skins arrive instead of
       being attributed to a brand that does not exist. */
    const found = (brands || []).find(b => b.id === id);
    return found || PB_LOADING_BRAND;
  });
  const [brandSwitch, setBrandSwitch] = useState(false);
  // Per-brand "view in" currency override for the Payments section — lets
  // an operator look at the selected brand's numbers converted into a
  // currency of their choosing. Null means "use the brand's own currency"
  // (the default everywhere). Lives next to the brand switcher because it
  // only makes sense once a specific brand (not "All brands") is picked.
  const [viewCurrency, setViewCurrency] = useState(() => {
    return pbStore.get("pb-view-currency", null) || null;
  });
  useEffect(() => {
    if (viewCurrency) pbStore.set("pb-view-currency", viewCurrency);
    else pbStore.remove("pb-view-currency");
  }, [viewCurrency]);

  /* THE BRAND SWITCHER READS REAL SKINS.

     It was seeded from window.MOCK.BRANDS — eight invented brands with
     invented currencies and markets. Every Payments screen scopes its rows on
     `brand.id`, so wiring one of those screens to the database while this list
     stayed invented produced a screen that was correct and EMPTY: the selected
     brand id existed nowhere in the data. That is worse than the mock it
     replaced, because "0 accounts" reads as a fact about the business.

     So this is the keystone, and it runs once per session after sign-in.
     Deliberate details:

       · It REPLACES rather than merges. A merged list would leave the invented
         brands sitting next to the real ones with no way to tell which is
         which.
       · The cached pb-brands list is dropped the first time real skins arrive,
         because a cache of invented brands is exactly what pbStore.clearContent
         exists to remove at cutover.
       · If the read fails or returns nothing, the list is left ALONE rather
         than emptied. An operator whose session dropped should see the
         switcher they had, not a switcher with nothing in it.
       · `color` is presentation and the schema has no column for it, so the
         palette is assigned by position. It is the one invented field left and
         it cannot mislead: nobody reads a gradient as a fact. */
  const [brandsAreReal, setBrandsAreReal] = useState(false);
  /* Re-run when a session appears. `window.sb.signedIn` is read at render time,
     and nothing re-renders this component when a sign-in completes — the gate
     listens to the `sb:session` event, app.jsx did not. So the effect below ran
     once, signed out, found no session and gave up, and the brand switcher kept
     its invented brands for the whole session. */
  const [sessionTick, setSessionTick] = useState(0);
  useEffect(() => {
    const fn = () => setSessionTick(n => n + 1);
    window.addEventListener("sb:session", fn);
    return () => window.removeEventListener("sb:session", fn);
  }, []);
  useEffect(() => {
    if (brandsAreReal) return;
    if (!window.sb || !window.sb.live || !window.sb.signedIn) return;
    let alive = true;
    const PALETTE = [
      "linear-gradient(135deg,#e6a82c,#c48a14)", "linear-gradient(135deg,#1f9d57,#0c5a30)",
      "linear-gradient(135deg,#3b82f6,#1d4ed8)", "linear-gradient(135deg,#db2777,#9d174d)",
      "linear-gradient(135deg,#f97316,#c2410c)", "linear-gradient(135deg,#7c3aed,#4c1d95)",
      "linear-gradient(135deg,#0891b2,#0e7490)", "linear-gradient(135deg,#f2c257,#9a6b0c)",
    ];
    Promise.resolve(window.sb.list("skins", { limit: 200 })).then(r => {
      if (!alive || !r || !r.ok || !Array.isArray(r.data) || r.data.length === 0) return;
      const real = r.data.map((sk, i) => ({
        id: sk.id,
        name: sk.name || sk.code || `Skin ${sk.id}`,
        short: String(sk.code || sk.name || "??").slice(0, 2).toUpperCase(),
        color: PALETTE[i % PALETTE.length],
        currency: sk.currency || "",
        /* No market column on `skins`. Left blank rather than guessed from the
           currency — ARS is Argentina AND several other things. */
        market: "",
      }));
      setBrands(real);
      setBrandsAreReal(true);
      setBrand(cur => (cur && cur.isAll) ? cur : (real.find(b => b.id === (cur && cur.id)) || real[0]));
    });
    return () => { alive = false; };
  }, [brandsAreReal, sessionTick]);

  /* FX RATES, ONCE, FOR EVERY SCREEN THAT RENDERS MONEY.
     ----------------------------------------------------------------------
     `fxConvert` in ui.jsx has no static table any more; it reads this one and
     returns null until it is filled. The navy strip's balances, the Dashboard
     and Methods all convert through it, so an empty table means those figures
     render "—" rather than the unconverted number wearing another currency's
     symbol.

     THE INVERSION IS THE POINT. `currency_latest_rate.rate` is units PER EUR
     (007: convert = amount / rate[from] * rate[to]); fxConvert wants EUR per
     unit, so each rate is reciprocated here, once, rather than at eleven call
     sites that would each have to remember. */
  useEffect(() => {
    if (!window.sb || !window.sb.live || !window.sb.signedIn) return;
    let alive = true;
    Promise.resolve(window.sb.list("currencies", { limit: 200, filters: { active: true } })).then(r => {
      if (!alive || !r || !r.ok || !Array.isArray(r.data)) return;
      const t = {};
      r.data.forEach(c => {
        const per = Number(c.rate);
        if (c.code && per > 0) t[c.code] = 1 / per;   // units-per-EUR -> EUR-per-unit
      });
      /* EUR is the base and is 1 by definition; a rate row for it is not
         required and must not be able to make it something else. */
      t.EUR = 1;
      window.FX_RATES = t;
      window.dispatchEvent(new CustomEvent("sb:fx"));
    });
    return () => { alive = false; };
  }, [sessionTick]);

  useEffect(()=> { pbStore.set("pb-brands", brands); }, [brands]);
  useEffect(()=> {
    /* Still published for the screens that have not been wired yet — they read
       the brand list through MOCK, and pointing that at the REAL skins is what
       lets them be migrated one at a time instead of all at once. It is a
       bridge, and it comes out with the last reader. */
    if (window.MOCK) window.MOCK.BRANDS = brands;
    // Keep tx + activity records pointing at the current brand slots, so
    // renames/adds/deletes don't leave the Transactions page empty.
    if (typeof window.resolveTransactionBrands === "function") {
      window.resolveTransactionBrands();
    }
  }, [brands]);
  useEffect(()=> { pbStore.set("pb-brand-id", brand.id); }, [brand]);
  // Keep the selected brand's fields fresh when the brand list is edited
  useEffect(()=>{
    if (brand.isAll) return;
    const fresh = brands.find(b => b.id === brand.id);
    if (fresh && fresh !== brand) setBrand(fresh);
    else if (!fresh && brands[0]) setBrand(brands[0]);
  }, [brands]);
  const [txDetail, setTxDetail] = useState(null);
  const [tweaks, setTweaks] = useState(false);
  const [density, setDensity] = useState(() => pbStore.get("pb-density", "compact") || "compact");
  /* One accent for the whole platform (Arbi, Aug 2026). PayBO used to default
     to its own blue while Host screens were pinned to Iwakiri red, so the two
     halves of the same product looked like two products — and the Tweaks accent
     picker only ever repainted one of them. Now the accent applies everywhere
     and Tweaks is a genuine platform-wide theme switch.

     The stored "paybo" value is upgraded on read so anyone carrying the old
     default from a previous session lands on red rather than being stuck blue
     forever; an accent they picked deliberately is left alone. */
  const [accent, setAccent] = useState(() => {
    const stored = pbStore.get("pb-accent", null);
    /* Old DEFAULTS are upgraded; a deliberate pick is left alone. "paybo" and
       "red" were each the platform default in turn, so a browser carrying
       either is far more likely to be carrying an old default than a choice —
       the same trade the paybo upgrade already made. Every other stored value
       was chosen from the picker and survives. */
    if (!stored || stored === "paybo" || stored === "red") return "iwakiri";
    return stored;
  });
  const theme = "iwakiri";
  const setTheme = ()=>{};

  useEffect(()=>{
    document.documentElement.setAttribute("data-theme", "iwakiri");
  }, []);

  useEffect(()=>{
    document.documentElement.setAttribute("data-density", density);
    pbStore.set("pb-density", density);
  }, [density]);

  useEffect(()=> { pbStore.set("pb-nav", navMode); }, [navMode]);
  useEffect(()=> { pbStore.set("pb-active", active); }, [active]);
  useEffect(()=> { pbStore.set("pb-accent", accent); }, [accent]);

  // Normalize the address bar once on mount — e.g. a bare "/" resolves
  // `active` from localStorage above, so this brings the URL in line
  // with whatever actually ended up on screen (replace, not push: this
  // isn't a new navigation, just the initial URL catching up). Skipped
  // when the current path already belongs to this page — e.g. a direct
  // load of a nested useUrlTab sub-path (/cms/game-import/mondogaming,
  // /players/history, …) — so it doesn't stomp that back to the bare
  // top-level path.
  useEffect(() => {
    try {
      const current = window.activeForPath && window.activeForPath(window.location.pathname);
      if (current === active) return;
      const path = window.pathForActive && window.pathForActive(active);
      if (path) window.history.replaceState({ active }, "", path);
    } catch (_e) {}
  }, []);

  // Browser Back/Forward — resolve the path we've landed on back to a
  // page id and render it directly (bypassing setActive so we don't
  // push a duplicate history entry for a navigation that already happened).
  useEffect(() => {
    const onPopState = () => {
      const fromPath = window.activeForPath && window.activeForPath(window.location.pathname);
      if (fromPath) _setActive(fromPath);
    };
    window.addEventListener("popstate", onPopState);
    return () => window.removeEventListener("popstate", onPopState);
  }, []);

  // Accent swap — overrides Iwakiri red when a different accent is chosen
  useEffect(()=>{
    const r = document.documentElement;
    const palettes = {
      /* THE PLATFORM DEFAULT (Arbi, Aug 2026). #2a3fd0 is the 500; the rest of
         the ramp is built around it by lightness at the same hue, so tints and
         shades stay in family instead of drifting toward purple at the light
         end the way a hand-picked ramp usually does. */
      iwakiri:  { 50:"#eef0fd", 100:"#dbe0fa", 200:"#b8c1f5", 300:"#8e9bee", 400:"#5d6ee2", 500:"#2a3fd0", 600:"#2334ae", 700:"#1c298c", 800:"#141d69", 900:"#0d1246" },
      paybo:    { 50:"#eff6ff", 100:"#dbeafe", 200:"#bfdbfe", 300:"#93c5fd", 400:"#60a5fa", 500:"#1e40af", 600:"#1d4ed8", 700:"#1e3a8a", 800:"#172554", 900:"#0b1742" },
      red:      { 50:"#ffe8eb", 100:"#ffc8cf", 200:"#ff929d", 300:"#ff5c6d", 400:"#f02c42", 500:"#e2011a", 600:"#c4001a", 700:"#a3001b", 800:"#82001a", 900:"#5a0012" },
      indigo:   { 50:"#eef2ff", 100:"#dbe3ff", 200:"#b9c7ff", 300:"#8da0ff", 400:"#5e76ff", 500:"#3b55f0", 600:"#2b3fd1", 700:"#1f30a6", 800:"#162180", 900:"#0d1557" },
      midnight: { 50:"#e6ecf7", 100:"#bfcdea", 200:"#8da4d6", 300:"#5c7cc1", 400:"#2e5aac", 500:"#1b3d8c", 600:"#112c6c", 700:"#0b1d4f", 800:"#060f30", 900:"#03081a" },
      teal:     { 50:"#e6f7f5", 100:"#ccecea", 200:"#99d9d4", 300:"#5ec3bb", 400:"#2aaea4", 500:"#0d9488", 600:"#0a776d", 700:"#075a52", 800:"#053f39", 900:"#032723" },
      green:    { 50:"#e6f8ee", 100:"#c3eed4", 200:"#97d9b1", 300:"#6bc490", 400:"#3eb672", 500:"#1f9d57", 600:"#147a42", 700:"#0c5a30", 800:"#083d21", 900:"#042313" },
      gold:     { 50:"#fef7e6", 100:"#fceccb", 200:"#f8d98e", 300:"#f0c257", 400:"#e6a82c", 500:"#c48a14", 600:"#9a6b0c", 700:"#6e4d07", 800:"#4a3305", 900:"#2a1d02" },
      orange:   { 50:"#fff3ea", 100:"#ffdfc6", 200:"#ffbe8b", 300:"#ff9b54", 400:"#fb7b2f", 500:"#ea580c", 600:"#c24408", 700:"#953305", 800:"#672303", 900:"#3c1402" },
      rose:     { 50:"#ffe7f1", 100:"#ffc4db", 200:"#ff8bb9", 300:"#fb5a99", 400:"#ee3380", 500:"#e11d74", 600:"#b80f5d", 700:"#8b0846", 800:"#5f042f", 900:"#35011a" },
      purple:   { 50:"#f3eaff", 100:"#e2ccff", 200:"#c59dff", 300:"#a56dff", 400:"#8b4bff", 500:"#7c3aed", 600:"#6020d4", 700:"#4811a6", 800:"#310975", 900:"#1c0344" },
      slate:    { 50:"#f0f2f5", 100:"#dde1e8", 200:"#bac2cf", 300:"#94a0b3", 400:"#6b7a92", 500:"#475569", 600:"#374250", 700:"#28303b", 800:"#1a1f28", 900:"#0d1015" },
    };
    const p = palettes[accent] || palettes.iwakiri;
    Object.entries(p).forEach(([k,v])=> r.style.setProperty(`--p-${k}`, v));
  }, [accent]);

  // Edit mode from host
  useEffect(()=>{
    const handler = (e)=>{
      if (!e.data) return;
      if (e.data.type === "__activate_edit_mode") setTweaks(true);
      if (e.data.type === "__deactivate_edit_mode") setTweaks(false);
    };
    window.addEventListener("message", handler);
    window.parent.postMessage({type:"__edit_mode_available"}, "*");
    return ()=> window.removeEventListener("message", handler);
  }, []);

  // Keyboard shortcut: Cmd/Ctrl+K opens Tweaks, Esc closes it
  useEffect(()=>{
    const kbd = (e)=>{
      const mod = e.metaKey || e.ctrlKey;
      if (mod && (e.key === "k" || e.key === "K")) {
        e.preventDefault();
        setTweaks(v => !v);
      } else if (e.key === "Escape" && tweaks) {
        setTweaks(false);
      }
    };
    window.addEventListener("keydown", kbd);
    return ()=> window.removeEventListener("keydown", kbd);
  }, [tweaks]);

  // Selected player for drill-in from the Players list
  const [selectedPlayer, setSelectedPlayer] = useState(null);
  // Leaving the Players route resets the drill-in
  useEffect(() => { if (active !== "players") setSelectedPlayer(null); }, [active]);

  const titles = {
    dashboard:"Dashboard", transactions:"Transactions", deposits:"Deposits", withdrawals:"Withdrawals",
    methods:"Payment methods", players: selectedPlayer ? "Player 360" : "Players",
    rules:"Auto-approval rules", fees:"Fees & commissions", reports:"Reports", brands:"Brands",
    settings:"Settings", activity:"Activity feed", devphase:"Development Phase",
    frontend:"Frontend preview",
    "bonus-retention":"Bonus Retention", "report-conversion":"Conversion",
    "report-summary":"Summary", "bonus-programs":"Bonus Programs", "bonus-promo-codes":"Promo Codes",
    "host-players":"Players", "host-dashboard":"Dashboard",
    "host-users":"Users", "host-sportcoupons":"Sport coupons", "host-deposit":"Deposit",
    "report-players":"Players Report", "report-betting":"Betting", "report-bettype":"Bet type",
    "report-daily":"Daily Report", "report-transactions":"Transactions", "report-credittx":"Credit Transactions",
    "report-withdrawals":"My withdrawal requests", "report-commissions":"Commissions", "report-costs":"Cost report",
    "report-netwin":"NetWin", "report-dailyperf":"Daily Performance", "report-business":"Business Report",
    "host-vouchers":"Vouchers",
    "settings-languages":"Languages", "settings-deposit-methods":"Deposit methods", "settings-withdrawal-methods":"Withdrawal methods",
    "settings-currencies":"Currencies", "settings-deleted-users":"Deleted Users", "settings-jobs":"Jobs", "settings-change-domain":"Change Domain", "settings-support":"Support users", "host-myaccount":"My account", "report-network-liab":"Network liabilities", "report-affiliates":"Affiliates report", "host-withdraw-to":"Withdraw", "to-confirm":"To Confirm", "settings-vendors":"Vendors Groups", "dev-connection":"Connection",
    "comm-sport-profiles":"Sport profiles", "comm-profiles":"Commission profiles", "comm-payments":"Commission Payments", "comm-cashback":"Commission Cashback", "comm-cashback-payments":"Cashback Payments", "cms-faq":"FAQ", "cms-faq-cat":"FAQ Categories", "cms-promotions":"Promotions", "cms-provider-promo":"Provider Promotions", "cms-promoplay":"Promoplay Promotions", "cms-promo-triggers":"Promo Triggers", "sport-bet":"Bet from backoffice", "sport-settings":"Sport settings",
    "cms-banners":"Slideshow", "cms-blogs":"Blogs", "cms-blog-cat":"Blog categories",
    "cms-help-cat":"Help categories", "cms-help-pages":"Help pages",
    "host-skins":"Skins",
    "cms-game-cat":"Game categories", "cms-game-subcat":"Game subcategories", "cms-game-labels":"Game Labels",
    "cms-game-import":"Game import", "cms-providers":"Providers", "cms-launch-urls":"Game Launch URL", "cms-oauth":"OAuth Clients",
    "host-deposits":"Deposits", "host-withdraws":"Withdrawal requests", "host-messages":"Messages",
  };
  const title = titles[active] || "Dashboard";

  const PageView = () => {
    switch(active){
      case "dashboard": return <Dashboard brand={brand} viewCurrency={viewCurrency} onNav={setActive}/>;
      case "transactions":
        return <Transactions brand={brand} onOpenDetail={setTxDetail} initialTab="all"/>;
      case "deposits":
        return <Transactions brand={brand} onOpenDetail={setTxDetail} initialTab="deposits"/>;
      case "withdrawals":
        return <Transactions brand={brand} onOpenDetail={setTxDetail} initialTab="withdrawals"/>;
      case "activity":
        return <Activity brand={brand}/>;
      case "methods": return <Methods brand={brand}/>;
      case "players":
        return selectedPlayer
          ? <Player360 brand={brand} player={selectedPlayer} onBack={() => {
              try { if (window.location.pathname !== "/payments/players") window.history.pushState(null, "", "/payments/players"); } catch (_e) {}
              setSelectedPlayer(null);
            }}/>
          : <PlayersList brand={brand} onOpen={setSelectedPlayer}/>;
      case "rules": return <Methods brand={brand}/>;
      // Fees lives inside Reports as a tab now. Keep alias route so deep
      // links / localStorage references still land on the right page.
      case "fees":
      case "reports": return <Reports brand={brand}
        onOpenPlayer={(p) => { setSelectedPlayer(p); setActive("players"); }}/>;
      case "devphase": return <DevPhase brand={brand}/>;
      case "frontend": return <Frontend brand={brand} brands={brands}/>;
      // Host reports & promotions (reached from the blue host nav)
      case "bonus-retention": return <BonusRetention brand={brand}/>;
      case "report-conversion": return <Conversion brand={brand}/>;
      case "report-summary": return <Summary brand={brand}/>;
      case "bonus-programs": return <BonusPrograms brand={brand}/>;
      case "bonus-promo-codes": return <HostPromoCodes/>;
      case "host-dashboard": return <HostDashboard brand={brand}/>;
      case "host-players": return <HostPlayers brand={brand}/>;
      case "host-users": return <HostUsers brand={brand}/>;
      case "host-sportcoupons": return <HostSportCoupons/>;
      case "host-deposit": return <HostDeposit brand={brand}/>;
      case "report-players": return <PlayersReport/>;
      case "report-betting": return <Betting/>;
      case "report-bettype": return <BetType/>;
      case "report-daily": return <DailyReport/>;
      case "report-transactions": return <RTransactions/>;
      case "report-credittx": return <RCreditTransactions/>;
      case "report-withdrawals": return <WithdrawalRequests/>;
      case "report-commissions": return <CommissionsReport/>;
      case "report-costs": return <CostReport/>;
      case "report-netwin": return <NetWinReport/>;
      case "report-dailyperf": return <DailyPerformance/>;
      case "report-business": return <BusinessReport/>;
      case "host-vouchers": return <HostVouchers brand={brand}/>;
      case "settings-languages": return <SetLanguages/>;
      case "settings-deposit-methods": return <SetDepositMethods/>;
      case "settings-withdrawal-methods": return <SetWithdrawalMethods/>;
      case "settings-currencies": return <SetCurrencies/>;
      case "settings-deleted-users": return <SetDeletedUsers/>;
      case "settings-jobs": return <SetJobs/>;
      case "settings-change-domain": return <SetChangeDomain/>;
      case "settings-support": return <SetSupportUsers/>;
      case "host-myaccount": return <HostMyAccount/>;
      case "report-network-liab": return <HostReportNetworkLiab/>;
      case "report-affiliates": return <HostReportAffiliates/>;
      case "host-withdraw-to": return <HostWithdrawTo/>;
      case "to-confirm": return <PayboToConfirm/>;
      case "settings-vendors": return <SetVendorsGroups/>;
      case "dev-connection": return <DevSupabase/>;
      case "comm-sport-profiles": return <SportProfiles/>;
      case "comm-profiles": return <CommissionProfiles/>;
      case "comm-payments": return <CommissionPayments/>;
      case "comm-cashback": return <CommissionCashback/>;
      case "comm-cashback-payments": return <CommissionCashbackPayments/>;
      case "cms-faq": return <HostCmsFaq/>;
      case "cms-faq-cat": return <HostCmsFaqCategories/>;
      case "cms-promotions": return <HostCmsPromotions/>;
      case "cms-provider-promo": return <HostCmsProviderPromotions/>;
      case "cms-promoplay": return <HostCmsPromoplayPromotions/>;
      case "cms-promo-triggers": return <HostCmsPromoTriggers/>;
      case "sport-bet": return <HostSportBet/>;
      case "sport-settings": return <HostSportSettings/>;
      case "cms-banners": return <HostCmsBanners brand={brand}/>;
      case "cms-blogs": return <HostCmsBlogs brand={brand}/>;
      case "cms-blog-cat": return <HostCmsBlogCategories brand={brand}/>;
      case "cms-help-cat": return <HostCmsHelpCategories brand={brand}/>;
      case "cms-help-pages": return <HostCmsHelpPages brand={brand}/>;
      // CMS > Skins — the white-label skin manager (per-skin Home/Settings/
      // Sidebar/Provider/Games/Subcategories/Graphic/Domains/… editor).
      case "host-skins": return <HostSkinsUnified/>;
      case "cms-game-cat": return <GameCategories/>;
      case "cms-game-subcat": return <GameSubcategories/>;
      case "cms-game-labels": return <GameLabels/>;
      case "cms-game-import": return <GameImport/>;
      case "cms-providers": return <CmsProviders/>;
      case "cms-launch-urls": return <LaunchUrls/>;
      case "cms-oauth": return <OAuthClients/>;
      case "host-deposits": return <HostDeposits/>;
      case "host-withdraws": return <HostWithdrawals/>;
      case "host-messages": return <HostMessages/>;
      // Legacy standalone Skins manager (PSP brand config) kept for back-compat.
      case "settings": return <Settings brand={brand} brands={brands} setBrands={setBrands}/>;
      default:
        return <div className="page"><div className="page__title">{title}</div><div className="page__subtitle">Placeholder — designed on request.</div></div>;
    }
  };

  const NO_SIDE_MODES = ["top", "dual-top", "mega-top", "command"];
  const SHOW_SUBNAV = ["top", "hybrid", "dual-top", "mega-top"];
  const showSide = !NO_SIDE_MODES.includes(navMode);
  const showSubnav = SHOW_SUBNAV.includes(navMode);

  // force a re-render after tx state transitions so KPIs + queue reflect changes
  const [txTick, setTxTick] = useState(0);
  const onTxChanged = () => setTxTick(v => v + 1);

  return (
    <PayboHost
      brand={brand}
      active={active}
      navMode={navMode}
      onNav={setActive}
      onOpenBrandSwitch={() => setBrandSwitch(true)}
      onOpenTweaks={() => setTweaks(true)}
      viewCurrency={viewCurrency}
      onSetViewCurrency={setViewCurrency}>
      {/* The sign-in gate. Rendered before the app, not inside a page: the
          reason is the same on all 69 screens, and answering it 69 times is 69
          places for the answer to drift. */}
      {window.SbAuthGate && <window.SbAuthGate />}
      {/* Same argument, for the same reason: while an impersonation is open
          every screen shows somebody else's rows, correctly and without a hint
          that they are not yours. Saying so once, above the app, beats 69
          screens each remembering to. */}
      {window.SbImpersonationBar && <window.SbImpersonationBar />}
      <div className="app" data-nav={navMode} data-screen-label={`01 ${title}`}>
        <main className="main">
          {/* Renders only when there is no valid Supabase session. Above the
              page, not inside it: the cause is the same on all 69 screens, so
              answering it 69 times is 69 places for the answer to drift. */}
          <PageErrorBoundary routeKey={active}>
            <PageView/>
          </PageErrorBoundary>
        </main>
      </div>
      {brandSwitch && <BrandSwitcher brands={brands} all={ALL_BRAND} active={brand} onPick={setBrand} onClose={()=>setBrandSwitch(false)} onManage={()=>setActive("settings")}/>}
      {txDetail && <TxDetail tx={txDetail} onClose={()=>setTxDetail(null)} onStatusChange={onTxChanged}/>}
      {/* Toasts (including the simulated "New · To Confirm" feed) are a
          PayBO/Payments concept — a payment-transaction alert makes no
          sense while editing a CMS page or a bonus program, so ToastHost
          only mounts (and only then subscribes to the toast bus) inside
          the Payments section. */}
      {/* Both surfaces mount everywhere and each takes only its own shape:
          ToastHost renders transaction alerts, HostNotices renders neutral
          feedback. Previously they were swapped by route, so a Host notice
          fired from the shell (the mobile drawer, the brand switcher) while the
          operator was on a /payments page rendered through the wrong one. */}
      <ToastHost onOpen={setTxDetail}/>
      <HostNotices/>
      <TweaksPanel visible={tweaks} navMode={navMode} setNavMode={setNavMode} density={density} setDensity={setDensity} accent={accent} setAccent={setAccent} theme={theme} setTheme={setTheme} onClose={()=>setTweaks(false)}/>
    </PayboHost>
  );
};

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App/>);
