/* =============================================================
   app.jsx — Application shell, routing, workbook loader, roles
   ============================================================= */
const { useState, useEffect, useMemo, useCallback, useRef } = React;

const WORKBOOK_FILENAME = "Master Deployment Tracker v2.xlsx";
const PR_LOGO_SRC = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzdmcgd2lkdGg9IjEwMjQiIGhlaWdodD0iMzc3IiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAxMDI0IDM3NyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+DQogPHVzZSB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1MTguNCAxZS00KSIgeGxpbms6aHJlZj0iI2EiLz4NCiA8dXNlIHRyYW5zZm9ybT0idHJhbnNsYXRlKDcwNi41LDJlLTQpIiB4bGluazpocmVmPSIjYiIvPg0KIDx1c2UgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTAyMS4xIDllLTQpIiB4bGluazpocmVmPSIjYyIvPg0KIDxnIGlkPSJjIiBzdHJva2Utd2lkdGg9IjIiPg0KICA8ZyBmaWxsPSIjNzZhMGNkIj4NCiAgIDxwYXRoIGQ9Im00MzAuMiAxMTEuMTljMTkuNjAxIDcuMzgxMSAzMi44MzQgMjAuMTg4IDMyLjgzNCAyMC4xODggMC40NTcyLTYuNDAzNSAxLjk1OTUtMTIuODA3IDMuOTE4OS0xNy43MjggMCAwLTYuNDAwOS0xMi4zMDgtMjQuOTk2LTI1LjEwOS0yMC4wODUtMTMuNzg0LTQzLjEwOS0xNy43MjEtNDMuMTA5LTE3LjcyMS00LjkwNTMgMTAuMzMzLTguMzM0NCAyMS4xNjYtOS44MDM5IDMyLjQ5IDAgMCAxNi42NjItMS40NzYyIDQxLjE1NiA3Ljg3OTd6Ii8+DQogICA8cGF0aCBkPSJtNDQ0LjQgNzAuMzljMTkuMDc5IDE4LjcwNSAyNS45MzcgMzcuNDE3IDI1LjkzNyAzNy40MTcgMy40NjE4LTUuOTExNCA3LjgzNzktMTAuODMyIDEzLjcxNi0xNC43NzUgMCAwLTcuMzE1NC0yMi4xNS0xOC42MTUtMzguNDAxLTExLjIzNC0xNi43MzctMjkuODYzLTMwLjAzLTI5Ljg2My0zMC4wMy05LjMwNzUgNi44ODkxLTE3LjE0NSAxNS4yNjEtMjQuMDA0IDI0LjEyNSAwIDAgMTguNjE1IDguMzY1MSAzMi44MjggMjEuNjY0eiIvPg0KICAgPHBhdGggZD0ibTQ3Ny43IDQ3LjE5YzguODE3NyAyMi4xNSAxMC43NzcgNDMuMzIyIDEwLjc3NyA0My4zMjIgNS44Nzg0LTIuOTUyNSAxMy4xOTQtNS40MTI4IDE5LjU5NS01LjkwNDkgMCAwIDAuNDU3Mi0yMS4xNzItNC40NDE1LTQzLjMyOC01LjM1NTktMjUuNjAxLTExLjIzNC00MC44NjItMTEuMjM0LTQwLjg2Mi0xMS4zIDAuOTg0MS0yMi4wNzcgNC40Mjg2LTMyLjMzMiA4Ljg2MzggMCAwIDExLjc1NyAyMi42NDIgMTcuNjM1IDM3LjkwOXoiLz4NCiAgPC9nPg0KICA8cGF0aCBkPSJtNDQ2LjQgMTg2LjA5YzIzLjk3OC0xMS4zMjQgNDguMDE0LTguODYzOCA0OC4wMTQtOC44NjM4LTguODE3Ny0zLjQ0NDYtMTYuMTk4LTguODU3My0yMS41NTQtMTUuNzUzIDAgMC0yMS4wOTctMi40NjAzLTM3LjI0MyAwLjQ5Mi0yNC45ODMgNS40MTI3LTM1Ljc2MSAxMy43ODQtMzUuNzYxIDEzLjc4NCA0Ljg5ODcgMTAuMzQgMTEuMjY3IDIwLjE4OCAxOS4xMDUgMjguNTYgMCAwIDkuNzk3NC05Ljg0OCAyNy40MzktMTguMjJ6IiBmaWxsPSIjMTIzZjc1Ii8+DQogIDxwYXRoIGQ9Im00MjguMiAxNTAuMDljMjQuMDE3LTAuOTg0MSA0MC42NzIgNS40MTk0IDQwLjY3MiA1LjQxOTQtMi45MzkyLTUuNDE5My00Ljg5ODctMTEuODE2LTUuNDIxMy0xOC4yMiAwIDAtMTkuNTY5LTEwLjgzMi0zNy4yMTEtMTMuMjkyLTI0LjQ5My0zLjQ0NDUtMzguNzA2IDEuNDc2Mi0zOC43MDYgMS40NzYyIDAuNDkgMTEuMzI0IDEuOTU5NSAyMi42NDggNS4zODg2IDMzLjQ4IDAgMCAxNS42ODItNy44Nzk3IDM1LjI3Ny04Ljg2Mzh6IiBmaWxsPSIjMTIzZjc1Ii8+DQogPC9nPg0KIDxnIGZpbGw9IiMxMjNmNzUiIHN0cm9rZS13aWR0aD0iMiI+DQogIDxwYXRoIGQ9Im01MTAuNSAyMDQuNzljMzQuODE0IDAgNTAuMDMyIDMwLjAyOSA1MC4wMzIgMzAuMDI5IDEwLjc3Ny00LjkyMDYgMjAuNTc1LTEwLjgzMiAyOS44NDktMTguNzEyIDAgMC0yOS4zOTItMzYuOTI1LTc5Ljg4Mi0zNi45MjUtNDkuOTY3IDAtNzkuMzM5IDM2LjkyNS03OS4zMzkgMzYuOTI1IDguODE3NyA3Ljg3OTcgMTkuMTEyIDEzLjc5MSAyOS44OTUgMTguNzEyIDAgMCAxNS4xNTMtMzAuMDMgNDkuNDQ0LTMwLjAyOXoiLz4NCiAgPHBhdGggZD0ibTI0LjUgMjgxLjU5djQ0LjMxM2gxOC4xMjVjMTkuNjAxIDFlLTQgMjguNDE5LTcuODc5NyAyOC40MTktMjIuMTU2LTFlLTQgLTE0LjI3Ny04LjMyNzktMjIuMTU2LTI4LjQxOS0yMi4xNTZ6bTAgODMuMjA2aDEyLjczN3YxMC4zNGgtMzcuMjM3di0xMC4zNGgxMi43MzdsLTFlLTQgLTgzLjY5OGgtMTIuNzM3di05Ljg0OGw0Mi42MjUgMWUtNGMyMy41MiAwIDM5LjY4NiAxMC4zNCAzOS42ODYgMzIuNDk2IDFlLTQgMjIuMTU2LTE2LjE2NiAzMi40OS0zOS42ODYgMzIuNDloLTE4LjEyNXoiLz4NCiAgPHBhdGggZD0ibTE1MC45IDMzMy43NWMwLTEzLjc4NC0xMC4yODctMjQuNjE3LTI0LjQ5My0yNC42MTctMTQuMjA2LTFlLTQgLTI0LjUgMTAuODMyLTI0LjUgMjQuNjE3em04LjgyNDMgMjAuMTg4Yy0zLjQyOTEgMTIuOC0xNS4xOTMgMjIuNjQ4LTMzLjMxOCAyMi42NDgtMjIuMDUxIDAtMzUuNzY3LTEzLjc4NS0zNS43NjctMzguOSAwLTIzLjYyNiAxNC4yMDYtMzguODkzIDM1Ljc2Ny0zOC44OTMgMjMuNTE0IDAgMzguMjE2IDE4LjIyIDM1LjI3NyA0NC44MDVsLTU5Ljc3Ny0xZS00YzAuOTggMTEuMzI0IDguODI0MiAyMy4xNCAyNC41IDIzLjE0MSAxMC4yODcgMCAxNy42MzUtNC40MzUyIDIxLjU1NC0xMi44eiIvPg0KICA8cGF0aCBpZD0iYiIgZD0ibTE5OS45IDM2NS4yNmgxMS43NjN2OS44NDhsLTM0Ljc4Ny0xZS00di05Ljg0OGgxMS43NTd2LTU1LjE0NWgtMTEuNzU3di05LjM1NTloMjIuMDQ0djEwLjM0YzQuNDA4OC03Ljg3MzEgMTUuMTkzLTE1LjI2MSAyOS44ODktMTAuMzR2MTAuODMyYy0xNy42MzUtNC45MjA4LTI4LjkwOSA0LjQzNTEtMjguOTA5IDIxLjY2NHoiIGZpbGw9IiMxMjNmNzUiIHN0cm9rZS13aWR0aD0iMiIvPg0KICA8cGF0aCBkPSJtMzE1IDM2NS4yNmgxMS43NTdsMWUtNCA5Ljg0OGgtMzQuMjk4di05Ljg0OGgxMS43NjNsLTFlLTQgLTMxLjUxMmMwLTE3LjIyOS0xMC4yOTQtMjQuNjE3LTIyLjA1MS0yNC42MTctMTEuMjY3IDAtMjEuMDY0IDcuODc5Ni0yMS4wNjQgMjUuNjAxdjMwLjUyOGgxMS43NTd2OS44NDhoLTM0LjI5OHYtOS44NDhoMTEuNzU3di01NS4xNDVoLTExLjc1N3YtOS4zNTU5bDIyLjA1MSAxZS00djEwLjM0YzMuOTE5LTUuOTA0OCAxMi4yNDctMTEuODE2IDI0LjAwNC0xMS44MTYgMTYuMTcyIDAgMzAuMzc5IDkuMzU1OSAzMC4zNzkgMzQuNDY1eiIvPg0KICA8cGF0aCBkPSJtMzcyLjkgMzY2Ljc0YzE0LjY5NiAwIDI0Ljk5LTEwLjM0IDI0Ljk5LTI5LjA1MiAwLTE4LjcwNS0xMC4yOTQtMjguNTUzLTI0Ljk5LTI4LjU1My0xNC4yMDYgMC0yNC45ODMgOS44NDc5LTI0Ljk4MyAyOC41NTMgMWUtNCAxOC43MTIgMTAuNzc3IDI5LjA1MiAyNC45ODQgMjkuMDUyem0tMWUtNCAtNjcuOTQ1YzIzLjAzIDAgMzYuMjU3IDE2Ljc0NCAzNi4yNTcgMzguODkzIDAgMjIuNjQ4LTEzLjIyNyAzOC45LTM2LjI1NyAzOC45LTIyLjUzNCAwLTM1Ljc2Ny0xNi4yNTEtMzUuNzY3LTM4LjkgMC0yMi4xNSAxMy4yMzMtMzguODkzIDM1Ljc2Ny0zOC44OTN6Ii8+DQogIDxwYXRoIGlkPSJhIiBkPSJtNDMzLjEgMzM3LjY4YzAgMTkuMjA0IDExLjI3NCAyOS4wNTIgMjQuOTk2IDI5LjA1MiAxMy43MTYgMCAyNC45NTEtOS44NDggMjQuOTUxLTI5LjA1MiAwLTE4LjcwNS0xMS4yMzQtMjguNTUzLTI0Ljk1MS0yOC41NTMtMTMuNzIzIDAtMjQuOTk2IDkuODQ3OS0yNC45OTYgMjguNTUzem02MC4yNjcgMjcuNTc2aDExLjc1N3Y5Ljg0OGgtMjIuMDc3di0xMC44MzJjLTQuMzc2MiA2Ljg4OS0xNC4xNzQgMTIuMzA4LTI1LjkzIDEyLjMwOC0yMS4wNzcgMC0zNC43OTQtMTYuNzQzLTM0Ljc5NC0zOC45IDAtMjIuMTUgMTMuNzE2LTM4Ljg5MyAzNC43OTQtMzguODkzIDExLjIzNCAxZS00IDIwLjU3NSA0LjkyNzMgMjUuNDczIDEyLjMwOHYtMzAuNTIxbC0xMS43NTctMWUtNHYtOS44NDc5aDIyLjUzNHoiIGZpbGw9IiMxMjNmNzUiIHN0cm9rZS13aWR0aD0iMiIvPg0KICA8cGF0aCBkPSJtNTc5LjEgMjgxLjU5djM4LjQwMWgyMy45NzFjMTUuNjc2IDAgMjIuMDc3LTcuMzg3NiAyMi4wNzctMTkuMjA0IDAtMTEuMzE4LTYuNDAwOS0xOS4xOTctMjIuMDc3LTE5LjE5N3ptLTI0LjQ5NC0xMC44MzJoNDguNDY1YzE5LjU5NSAwIDMzLjgzNCAxMC44MzIgMzMuODM0IDMwLjAyOSAwIDE2Ljc0NC0xMC4zMiAyNi41OTItMjUuNDczIDI5LjU0NGwxNi42NTYgMzQuNDY1aDEzLjcxNnYxMC4zNGgtMjEuNTU0bC0yMS41NTQtNDQuMzEzaC0xOS41OTV2MzMuOTczaDExLjIzNHYxMC4zNGwtMzUuNzI4LTFlLTR2LTEwLjM0aDEyLjczN2wtMWUtNCAtODMuNjk4aC0xMi43Mzd6Ii8+DQogIDxwYXRoIGQ9Im02NjUuOCAyODQuOTloMTIuMjc5di0xNC4yNzdoLTEyLjI3OXptMTIuMjc5IDgwLjI1M2gxMS43NTd2OS44NDhoLTM0LjgxNHYtOS44NDhoMTEuNzU3di01NS4xNDVoLTExLjc1N3YtOS4zNTU4aDIzLjA1N3oiLz4NCiAgPHBhdGggZD0ibTc3MS43IDM0OC4wMmMtMy45MTg5IDE2Ljc0NC0xNS4yMTkgMjguNTYtMzUuNzkzIDI4LjU2LTIyLjUzNCAwLTM1Ljc5My0xNi4yNTEtMzUuNzkzLTM4LjktMWUtNCAtMjIuMTUgMTMuMjU5LTM4Ljg5MyAzNS43OTMtMzguODkzIDEwLjc3NyAwIDIwLjU3NSA1LjQxOTQgMjUuMDE2IDEyLjMwOHYtMTAuMzRoMTAuMjU1djI3LjA4NGgtMTEuNzU3Yy0zLjM5NjQtMTAuMzQtMTAuMzItMTguNzEyLTIzLjUxNC0xOC43MTItMTQuMjM5IDAtMjUuMDE2IDkuODQ3OS0yNS4wMTYgMjguNTUzIDAgMTguNzEyIDEwLjc3NyAyOS4wNTIgMjUuMDE2IDI5LjA1MiAxMi43MzcgMCAyMS4wOTctNy44Nzk3IDI0LjQ5NC0xOC43MTJ6Ii8+DQogIDxwYXRoIGQ9Im03OTYuMiAzMzcuNjhjMCAxOS4yMDQgMTAuNzc3IDI5LjA1MiAyNC40OTMgMjkuMDUyczI0Ljk1MS05Ljg0OCAyNC45NTEtMjkuMDUyYzAtMTguNzA1LTExLjIzNC0yOC41NTMtMjQuOTUxLTI4LjU1My0xMy43MTYgMC0yNC40OTMgOS44NDc5LTI0LjQ5MyAyOC41NTN6bTQ4Ljk4Ny0zNi45MjUgMjIuNTM0IDFlLTR2OS4zNTU4aC0xMS43NTd2NTUuMTQ1aDExLjc1N3Y5Ljg0OGgtMjIuNTM0di0xMC44MzJjLTQuNDQxNSA3LjM4MS0xNC4yMzkgMTIuMzA4LTI1LjQ3MyAxMi4zMDgtMjEuMDk3IDAtMzQuODE0LTE2Ljc0My0zNC44MTQtMzguOSAwLTIyLjE1IDEzLjcxNi0zOC44OTMgMzQuODEzLTM4Ljg5MyAxMS4yMzQgMCAyMS4wMzIgNC45MjcyIDI1LjQ3MyAxMi4zMDh6Ii8+DQogPC9nPg0KPC9zdmc+DQo=";

const NAV_ITEMS_BY_ROLE = {
  management: ["overview", "map", "chat"],
  engineer:   ["overview", "map", "underlay", "overlay", "isp", "risk", "sites", "batches", "chat", "pm"],
  po:         ["overview", "map", "underlay", "overlay", "isp", "risk", "sites", "batches", "chat", "pm"],
  admin:      ["overview", "map", "underlay", "overlay", "isp", "risk", "sites", "batches", "chat", "pm"],
};

const ALL_PAGES = ["overview","map","underlay","overlay","isp","risk","sites","batches","chat","pm"];

const DEFAULT_LOCAL_USER = {
  email: "local.demo@1net",
  displayName: "Local Demo",
  role: "engineer",
};

/* ---------- Inline loader (replaces full-screen splash) ----------
   The dashboard chrome (sidebar + topbar) renders immediately. The
   main panel shows this loader until the model is ready. */
function InlineLoader({ status, error, onRetry }) {
  return (
    <div className="page" style={{ display: "grid", placeItems: "center", minHeight: "60vh" }}>
      <div style={{ textAlign: "center", maxWidth: 520 }}>
        <div className="mark" style={{
          width: 56, height: 56, fontSize: 18, margin: "0 auto 14px",
          background: "var(--accent)", color: "white",
          borderRadius: 12, display: "grid", placeItems: "center",
          fontFamily: "var(--font-mono)", fontWeight: 600,
        }}>1N</div>
        {error ? (
          <>
            <h2 style={{ margin: "0 0 6px" }}>Couldn't load.</h2>
            <div className="mono" style={{ fontSize: 12, color: "var(--ink-500)", marginBottom: 12 }}>{error}</div>
            <button className="btn primary" onClick={onRetry}>
              <Glyph name="refresh" size={13}/> Retry
            </button>
          </>
        ) : (
          <>
            <h2 style={{ margin: "0 0 6px", fontSize: 18, fontWeight: 600 }}>Loading the dashboard</h2>
            <div className="muted" style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 8, justifyContent: "center" }}>
              <span className="pm-saving-spinner"/> {status || "Reading embedded workbook…"}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

/* ---------- RolePill in topbar ---------- */
function RolePill({ user, onChangeUser }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, []);
  return (
    <div className="pm-rolepill-wrap" ref={ref}>
      <button className={"pm-rolepill pm-role-" + (user.role || "engineer")} onClick={() => setOpen((v) => !v)}>
        <span className="pm-rolepill-avatar">{(user.displayName || user.email || "?").slice(0, 2).toUpperCase()}</span>
        <span className="pm-rolepill-text">
          <span className="pm-rolepill-name">{user.displayName || user.email || "anon"}</span>
          <span className="pm-rolepill-role">{user.role}</span>
        </span>
      </button>
      {open && (
        <div className="pm-rolepill-pop">
          <div className="pm-field">
            <label className="pm-field-label">Display name</label>
            <input className="pm-input" value={user.displayName || ""}
                   onChange={(e) => onChangeUser({ ...user, displayName: e.target.value })}/>
          </div>
          <div className="pm-field">
            <label className="pm-field-label">Email</label>
            <input className="pm-input" value={user.email || ""}
                   onChange={(e) => onChangeUser({ ...user, email: e.target.value })}/>
          </div>
          <div className="pm-field">
            <label className="pm-field-label">Role</label>
            <div className="pm-role-radio">
              {["management","engineer","po","admin"].map((r) => (
                <button key={r}
                        className={"pm-role-btn pm-role-" + r + (user.role === r ? " active" : "")}
                        onClick={() => onChangeUser({ ...user, role: r })}>
                  {r}
                </button>
              ))}
            </div>
          </div>
          <div className="muted" style={{ fontSize: 11, marginTop: 6 }}>UI-only gate — Azure migration enforces server-side.</div>
        </div>
      )}
    </div>
  );
}

/* ---------- Sidebar nav ---------- */
function Sidebar({ active, onNav, model, source, loadedAt, role }) {
  const counts = useMemo(() => PRData.modelCounts(model), [model]);

  const allItems = [
    { id: "overview", label: "Overview", glyph: "overview" },
    { id: "map", label: "PR Global Entity", glyph: "map" },
    { id: "underlay", label: "Underlay tracker", glyph: "underlay", count: counts.underlay },
    { id: "overlay", label: "Overlay tracker", glyph: "overlay", count: counts.overlay },
    { id: "isp", label: "ISP termination", glyph: "ext", count: counts.isp },
    { id: "risk", label: "Risk & blockers", glyph: "risk", count: counts.risk },
    { id: "sites", label: "Sites", glyph: "sites", count: counts.sites },
    { id: "batches", label: "Batches", glyph: "batch" },
    { id: "chat", label: "Communication", glyph: "chat" },
    { id: "pm", label: "Project management", glyph: "pm" },
  ];
  const allowed = NAV_ITEMS_BY_ROLE[role] || NAV_ITEMS_BY_ROLE.engineer;
  const items = allItems.filter((i) => allowed.includes(i.id));

  return (
    <aside className="sidebar">
      <div className="brand">
        <img className="brand-pr-logo" src={PR_LOGO_SRC} alt="Pernod Ricard"/>
        <div className="brand-project">
          <div className="name">1NET Project</div>
          <div className="sub">Executive view</div>
        </div>
      </div>
      <div className="nav-section-title">Dashboard</div>
      {items.map((it) => (
        <div key={it.id}
             className={"nav-item" + (active === it.id ? " active" : "")}
             onClick={() => onNav(it.id)}
             tabIndex={0}
             role="button"
             onKeyDown={(e) => { if (e.key === "Enter") onNav(it.id); }}>
          <span className="ni-glyph"><Glyph name={it.glyph} size={14}/></span>
          <span>{it.label}</span>
          {it.count !== undefined && <span className="ni-count">{it.count}</span>}
        </div>
      ))}
      <div className="sidebar-footer">
        <div>Acting as <span className="mono">{role}</span></div>
        <div>Workbook source</div>
        <div className="src">{source || "—"}</div>
        {loadedAt && <div>Loaded {loadedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>}
      </div>
    </aside>
  );
}

/* ---------- Topbar ---------- */
function Topbar({ page, model, source, onRefresh, onPickWorkbook, refreshing, loadedAt, user, onChangeUser, justRefreshed, onOpenNotification }) {
  const workbookInputRef = useRef(null);
  const titles = {
    overview: "Overview",
    map: "PR Global Entity",
    underlay: "Underlay tracker",
    overlay: "Overlay tracker",
    isp: "ISP termination",
    risk: "Risk & blockers",
    sites: "Sites",
    batches: "Batches",
    chat: "Communication",
    pm: "Project management",
  };
  const sourceMode = source && source.startsWith("Embedded")
    ? "Standalone snapshot"
    : source && source.startsWith("Picked")
      ? "Imported workbook"
      : "Source dashboard reload";
  return (
    <header className="topbar">
      <div className="crumbs">
        1NET Project <span style={{margin: "0 8px", color: "var(--ink-300)"}}>/</span>
        <span className="crumb-current">{titles[page] || "—"}</span>
      </div>
      <div className="spacer"/>
      <div className="meta">
        <span><span className="pulse"/>{sourceMode} · {WORKBOOK_FILENAME}</span>
        {loadedAt && <span className={justRefreshed ? "pm-refreshed-flash" : ""}>Refreshed {loadedAt.toLocaleString([], { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "short" })}</span>}
        <button className="btn" onClick={onRefresh} disabled={refreshing}>
          <Glyph name="refresh" size={13}/>
          {refreshing ? "Refreshing…" : "Reload data"}
        </button>
        <input
          ref={workbookInputRef}
          type="file"
          accept=".xlsx,.xls"
          style={{ display: "none" }}
          onChange={(e) => {
            const file = e.target.files && e.target.files[0];
            if (file && onPickWorkbook) onPickWorkbook(file);
            e.target.value = "";
          }}
        />
        <button className="btn" onClick={() => workbookInputRef.current && workbookInputRef.current.click()}>
          <Glyph name="file" size={13}/>
          Import workbook
        </button>
        {typeof NotificationBell === "function" && (
          <NotificationBell user={user} onOpenEntity={onOpenNotification}/>
        )}
        <RolePill user={user} onChangeUser={onChangeUser}/>
      </div>
    </header>
  );
}

/* ---------- Per-page error boundary ----------
   A render exception in one page must not unmount the whole app to a blank
   document. App keys this boundary by route, so a thrown page shows a
   recoverable panel while the sidebar/topbar stay live; navigating to another
   page mounts a fresh boundary (new key) and recovers. The error is surfaced
   in the panel and logged to the console — never swallowed silently. */
class PageErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }
  static getDerivedStateFromError(error) {
    return { error };
  }
  componentDidCatch(error, info) {
    console.error("[PageErrorBoundary] page render failed:", error,
      info && info.componentStack ? info.componentStack : "");
  }
  render() {
    const { error } = this.state;
    if (error) {
      const msg = error.message || String(error) || "Unknown render error";
      return (
        <div className="page" data-page-error="1" style={{ display: "grid", placeItems: "center", minHeight: "60vh" }}>
          <div style={{ textAlign: "center", maxWidth: 560 }}>
            <h2 style={{ margin: "0 0 6px" }}>This page hit an error.</h2>
            <div className="mono" style={{ fontSize: 12, color: "var(--ink-500)", marginBottom: 14, whiteSpace: "pre-wrap" }}>{msg}</div>
            <div style={{ display: "flex", gap: 8, justifyContent: "center" }}>
              <button className="btn primary" onClick={() => window.location.reload()}>
                <Glyph name="refresh" size={13}/> Reload
              </button>
              <button className="btn" onClick={() => { if (this.props.onGoOverview) this.props.onGoOverview(); }}>
                Go to overview
              </button>
            </div>
          </div>
        </div>
      );
    }
    return this.props.children;
  }
}

/* ---------- Root ---------- */
function App() {
  const [page, setPage] = useState(() => {
    const h = window.location.hash.replace("#", "");
    return ALL_PAGES.includes(h) ? h : "overview";
  });
  const [model, setModel] = useState(null);
  const [loading, setLoading] = useState(true);
  const [status, setStatus] = useState("Loading workbook…");
  const [error, setError] = useState(null);
  const [refreshing, setRefreshing] = useState(false);
  const [source, setSource] = useState(null);
  const [pickedFile, setPickedFile] = useState(null);
  const [siteRef, setSiteRef] = useState(null);
  const [sitePanelOpen, setSitePanelOpen] = useState(false);
  // PROP-12: deep-link payload from a tracker to PM Editor.
  // Shape: { entity: 'underlay'|'overlay'|'isp'|'newUnderlay'|'site', idRef: string,
  //          tab?: 'comments', commentId?: string }  (GA-11 adds tab/commentId so a
  //          notification can open the row's Discussion and highlight one comment).
  // Cleared by PM page after consumption.
  const [pmFocusPayload, setPmFocusPayload] = useState(null);

  // PM identity / role
  const [user, setUser] = useState(DEFAULT_LOCAL_USER);
  const [justRefreshed, setJustRefreshed] = useState(false);

  // Per-page filter state
  const [underlayFilters, setUnderlayFilters] = useState({});
  const [overlayFilters, setOverlayFilters] = useState({});

  // Toast
  const [toast, setToast] = useState({ msg: "", show: false });
  const showToast = useCallback((msg) => {
    setToast({ msg, show: true });
    setTimeout(() => setToast((t) => ({ ...t, show: false })), 2200);
  }, [source]);

  // Cached workbook bytes for the export workflow
  const workbookBytesRef = useRef(null);
  const workbookRef = useRef(null);

  /* ---------- Identity bootstrap ---------- */
  useEffect(() => {
    PMClient.getUser()
      .then((u) => {
        if (u && u.email) setUser(u);
        else {
          setUser(DEFAULT_LOCAL_USER);
          PMClient.setUser(DEFAULT_LOCAL_USER).catch((e) => {
            console.warn("Could not persist default local user", e);
          });
        }
      })
      .catch((err) => {
        // IndexedDB unavailable (private browsing / sandboxed file://). Fall
        // back to an in-memory anonymous engineer so the dashboard still works.
        console.warn("PMClient identity unavailable, falling back:", err);
        setUser(DEFAULT_LOCAL_USER);
        showToast("Storage unavailable — running read-only.");
      });
  }, [showToast]);
  const persistUser = useCallback(async (next) => {
    setUser(next);
    try { await PMClient.setUser(next); }
    catch (e) { console.warn("Could not persist user", e); }
  }, []);

  /* ---------- Workbook load + model build ---------- */
  const buildFromWorkbook = useCallback(async (workbook, sourceLabel) => {
    setStatus("Parsing tabs and normalizing…");
    const m = PRData.buildEffectiveModel
      ? await PRData.buildEffectiveModel(workbook, { client: PMClient, source: sourceLabel, allowWorkbookFallback: true })
      : await PRData.buildModel(workbook);
    if (typeof PRData.logWorkbookCompatibilityReport === "function") {
      PRData.logWorkbookCompatibilityReport(m, { source: sourceLabel || source || WORKBOOK_FILENAME });
    }
    setModel(m);
    setError(null);
    setJustRefreshed(true);
    setTimeout(() => setJustRefreshed(false), 700);
  }, [source]);

  const tryLoad = useCallback(async (file, options = {}) => {
    setLoading(true);
    setError(null);
    try {
      let workbook;
      let bytes = null;
      let sourceLabel = "";
      const preferExternal = !!options.preferExternal;
      if (file) {
        setStatus(`Reading ${file.name}…`);
        bytes = new Uint8Array(await file.arrayBuffer());
        workbook = XLSX.read(bytes, { type: "array", cellDates: true });
        sourceLabel = `Picked: ${file.name}`;
        setSource(sourceLabel);
        setPickedFile(file);
      } else if (window.EMBEDDED_WORKBOOK_B64 && !preferExternal) {
        setStatus("Reading embedded workbook…");
        const bin = atob(window.EMBEDDED_WORKBOOK_B64);
        bytes = new Uint8Array(bin.length);
        for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
        workbook = XLSX.read(bytes, { type: "array", cellDates: true });
        sourceLabel = window.EMBEDDED_WORKBOOK_LABEL || `Embedded · ${WORKBOOK_FILENAME}`;
        setSource(sourceLabel);
        setPickedFile(null);
      } else {
        try {
          setStatus(`Fetching ./${WORKBOOK_FILENAME}…`);
          const resp = await fetch("./" + WORKBOOK_FILENAME + "?t=" + Date.now(), { cache: "no-store" });
          if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
          const buf = await resp.arrayBuffer();
          bytes = new Uint8Array(buf);
          workbook = XLSX.read(bytes, { type: "array", cellDates: true });
          sourceLabel = `./${WORKBOOK_FILENAME}`;
          setSource(sourceLabel);
          setPickedFile(null);
        } catch (externalError) {
          if (!window.EMBEDDED_WORKBOOK_B64) throw externalError;
          setStatus("Reading embedded workbook snapshot…");
          const bin = atob(window.EMBEDDED_WORKBOOK_B64);
          bytes = new Uint8Array(bin.length);
          for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
          workbook = XLSX.read(bytes, { type: "array", cellDates: true });
          sourceLabel = window.EMBEDDED_WORKBOOK_LABEL || `Embedded - ${WORKBOOK_FILENAME}`;
          setSource(sourceLabel);
          setPickedFile(null);
        }
      }
      workbookRef.current = workbook;
      workbookBytesRef.current = bytes;
      await buildFromWorkbook(workbook, sourceLabel);
    } catch (e) {
      console.error(e);
      setError(e.message || String(e));
    } finally {
      setLoading(false);
    }
  }, [buildFromWorkbook]);

  useEffect(() => { tryLoad(null); }, []);

  /* ---------- Live model rebuild on PMClient writes ---------- */
  const rebuildFromCurrentWorkbook = useCallback(async () => {
    if (!workbookRef.current) return;
    try {
      const m = PRData.buildEffectiveModel
        ? await PRData.buildEffectiveModel(workbookRef.current, { client: PMClient, source, allowWorkbookFallback: true })
        : await PRData.buildModel(workbookRef.current);
      setModel(m);
      setJustRefreshed(true);
      setTimeout(() => setJustRefreshed(false), 700);
    } catch (e) {
      console.error("Rebuild after mutation failed", e);
    }
  }, [source]);

  useEffect(() => {
    if (!PMClient || !PMClient.subscribe) return;
    let timer = null;
    const debounced = (kind) => {
      // Pull the latest identity whenever the user store changes.
      if (kind === "user" || kind === "import" || kind === "remote") {
        PMClient.getUser().then((u) => { if (u && u.email) setUser(u); }).catch(() => {});
      }
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => { rebuildFromCurrentWorkbook(); }, 80);
    };
    const unsub = PMClient.subscribe(debounced);
    return () => { unsub && unsub(); if (timer) clearTimeout(timer); };
  }, [rebuildFromCurrentWorkbook]);

  /* ---------- Focus / visibility reload ---------- */
  useEffect(() => {
    let lastReloadAt = Date.now();
    const cooldownMs = 4000;
    const trigger = () => {
      if (loading) return;
      const now = Date.now();
      if (now - lastReloadAt < cooldownMs) return;
      lastReloadAt = now;
      if (pickedFile) return;
      if (window.EMBEDDED_WORKBOOK_B64) return; // bundle is baked in
      tryLoad(null, { preferExternal: true });
    };
    const onVis = () => { if (document.visibilityState === "visible") trigger(); };
    window.addEventListener("focus", trigger);
    document.addEventListener("visibilitychange", onVis);
    return () => {
      window.removeEventListener("focus", trigger);
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [tryLoad, loading, pickedFile]);

  /* ---------- Hash routing ---------- */
  useEffect(() => {
    const onHash = () => {
      const h = window.location.hash.replace("#", "");
      if (ALL_PAGES.includes(h)) setPage(h);
    };
    window.addEventListener("hashchange", onHash);
    return () => window.removeEventListener("hashchange", onHash);
  }, []);
  useEffect(() => { window.location.hash = "#" + page; }, [page]);

  // If role changes and active page is now forbidden, redirect to overview
  useEffect(() => {
    if (!user) return;
    const allowed = NAV_ITEMS_BY_ROLE[user.role] || NAV_ITEMS_BY_ROLE.engineer;
    if (!allowed.includes(page)) setPage("overview");
  }, [user, page]);

  const refresh = useCallback(async () => {
    setRefreshing(true);
    try {
      if (pickedFile) await tryLoad(pickedFile);
      else await tryLoad(null);
      showToast(window.EMBEDDED_WORKBOOK_B64 && !pickedFile
        ? "Standalone snapshot reloaded. Rebuild or import a workbook for new Excel changes."
        : "Workbook reloaded.");
    } finally {
      setRefreshing(false);
    }
  }, [pickedFile, tryLoad, showToast]);

  // PROP-12 + fix: open the PM Editor focused on a specific tracker row.
  // Always navigate to PM when entity is set; if idRef is missing (e.g. an
  // unmatched ISP row with no linkRef/circuitId), still navigate but skip the
  // focus payload so the user lands on PM rather than getting silently dropped.
  const openInPM = useCallback((entity, idRef) => {
    if (!entity) return;
    if (idRef) setPmFocusPayload({ entity, idRef });
    setPage("pm");
  }, []);
  const consumePmFocus = useCallback(() => {
    const v = pmFocusPayload;
    setPmFocusPayload(null);
    return v;
  }, [pmFocusPayload]);

  const openSite = useCallback((ref) => {
    setSiteRef(ref);
    setSitePanelOpen(true);
  }, []);

  // GA-11: deep-link a notification to its source discussion. Mention
  // notifications carry sourceEntity / sourceIdRef / commentId; route to the
  // right surface, open its Discussion, and highlight the referenced comment.
  // The bell marks the notification read before invoking this. Stale/missing
  // targets fall back gracefully (toast + the entity's surface).
  const openNotification = useCallback((n) => {
    if (!n) { setPage("pm"); return; }
    const entity = n.sourceEntity || "";
    const idRef = n.sourceIdRef || "";
    const commentId = n.commentId || "";
    // Site-drawer discussion (GA-09 stores comments under entity="site").
    if (entity === "site") {
      const sites = (model && ((model.canonicalSites && model.canonicalSites.length) ? model.canonicalSites : model.sites)) || [];
      const low = String(idRef).toLowerCase();
      const found = sites.find((s) => s.id === idRef || s.siteCode === idRef || (s.siteName || "").toLowerCase() === low);
      if (found) {
        setSiteRef({ site: found, openDiscussion: true, discussionCommentId: commentId });
        setSitePanelOpen(true);
      } else {
        showToast("That discussion's site is no longer in the workbook.");
      }
      return;
    }
    // PM-editor entity discussions (overlay / underlay / isp / newUnderlay).
    if (idRef && (entity === "overlay" || entity === "underlay" || entity === "isp" || entity === "newUnderlay")) {
      setPmFocusPayload({ entity, idRef, tab: "comments", commentId });
      setPage("pm");
      return;
    }
    // Other sources (task / change request / plan): land on PM with a hint.
    setPage("pm");
    if (entity) showToast("Opened Project Management — open the " + entity + " to see this discussion.");
  }, [model, showToast]);

  const role = user.role || "engineer";
  // Render-time role gate: even if `page` is briefly set to a forbidden hash
  // (direct #hash entry, history.back into a now-disallowed page after a role
  // downgrade), `effectivePage` clamps the rendered route to an allowed one.
  // The useEffect above still rewrites `page` and `location.hash`; this guard
  // closes the one-frame window between hash change and effect run.
  const allowedForRole = NAV_ITEMS_BY_ROLE[role] || NAV_ITEMS_BY_ROLE.engineer;
  const effectivePage = allowedForRole.includes(page) ? page : "overview";

  // Always render the portal shell — never a full-screen splash.
  // If the workbook hasn't finished parsing yet, the main panel shows
  // an inline loader; everything else (sidebar, topbar, RolePill,
  // NotificationBell) renders immediately.
  return (
    <div className="app-shell">
      <Sidebar active={effectivePage} onNav={setPage} model={model} source={source} loadedAt={model && model.loadedAt} role={role}/>
      <div className="main">
        <Topbar page={effectivePage} model={model} onRefresh={refresh} refreshing={refreshing}
                onPickWorkbook={(file) => tryLoad(file)}
                source={source} loadedAt={model && model.loadedAt} user={user} onChangeUser={persistUser}
                justRefreshed={justRefreshed}
                onOpenNotification={openNotification}/>
        {!model ? (
          <InlineLoader status={status} error={error} onRetry={() => tryLoad(null)}/>
        ) : (
          <PageErrorBoundary key={effectivePage} onGoOverview={() => setPage("overview")}>
            {effectivePage === "overview" && <PageOverview model={model} onNav={setPage} onOpenInPM={openInPM}/>}
            {effectivePage === "map" && <PageMap model={model} onOpenSite={openSite} onOpenInPM={openInPM} role={user.role || "engineer"}/>}
            {effectivePage === "underlay" && <PageUnderlay model={model} filters={underlayFilters} setFilters={setUnderlayFilters} onOpenSite={openSite} onOpenInPM={openInPM} role={user.role || "engineer"}/>}
            {effectivePage === "overlay" && <PageOverlay model={model} filters={overlayFilters} setFilters={setOverlayFilters} onOpenSite={openSite} onOpenInPM={openInPM} role={user.role || "engineer"}/>}
            {effectivePage === "isp" && <PageIsp model={model} onOpenSite={openSite} onOpenInPM={openInPM} role={user.role || "engineer"}/>}
            {effectivePage === "risk" && <PageRisk model={model} onOpenSite={openSite} onOpenInPM={openInPM} role={user.role || "engineer"}/>}
            {effectivePage === "sites" && <PageSites model={model} onOpenSite={openSite}/>}
            {effectivePage === "batches" && <PageBatches model={model} onOpenSite={openSite}/>}
            {effectivePage === "chat" && <PageChat model={model}/>}
            {effectivePage === "pm" && <PageProjectManagement model={model} role={role} user={user} workbookBytesRef={workbookBytesRef} onModelDirty={rebuildFromCurrentWorkbook} consumePmFocus={consumePmFocus}/>}
          </PageErrorBoundary>
        )}
      </div>
      {model && <SiteDetail siteRef={siteRef} model={model} open={sitePanelOpen} onClose={() => setSitePanelOpen(false)} onOpenInPM={openInPM} role={role}/>}
      <Toast msg={toast.msg} show={toast.show}/>
    </div>
  );
}

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