/* ==================================================================
   KinetiX Evaluation Cockpit — Sidebar + Topbar + screen router
   Loads after data.js
   ================================================================== */

const { useState, useEffect, useRef, useMemo, useCallback } = React;
const { TESTS, ACTIVE_TEST, QUESTIONS, RUBRIC_Q3, EVALUATIONS_Q3 } = window.KX;

/* ---------- Icons (inline SVG) ---------- */
const Icon = ({ name, size = 14, className = "" }) => {
  const s = size;
  const paths = {
    library: <><path d="M4 19V5a2 2 0 0 1 2-2h8l4 4v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M14 3v4h4"/><path d="M8 13h6M8 17h4"/></>,
    inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11Z"/></>,
    cohort: <><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></>,
    student: <><circle cx="12" cy="7" r="4"/><path d="M4 21v-1a8 8 0 0 1 16 0v1"/></>,
    upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></>,
    plus: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>,
    search: <><circle cx="11" cy="11" r="7"/><line x1="20" y1="20" x2="16.65" y2="16.65"/></>,
    chevronRight: <polyline points="9 6 15 12 9 18"/>,
    chevronLeft: <polyline points="15 6 9 12 15 18"/>,
    chevronDown: <polyline points="6 9 12 15 18 9"/>,
    check: <polyline points="20 6 9 17 4 12"/>,
    x: <><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></>,
    edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></>,
    settings: <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .69.28 1.31.75 1.76.47.45 1.09.74 1.76.74H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></>,
    sparkle: <><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/></>,
    zap: <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>,
    rubric: <><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 8h8M9 12h8M9 16h6M5 8h.01M5 12h.01M5 16h.01"/></>,
    filter: <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>,
    report: <><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="14" y2="13"/><line x1="8" y1="17" x2="14" y2="17"/></>,
    expand: <><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></>,
    keyboard: <><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M6 14h.01M18 14h.01M10 14h4"/></>,
    bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></>,
    move: <><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></>,
    sort: <><path d="M3 6h13M3 12h9M3 18h5"/><path d="M17 16l4 4 4-4M21 8v12" transform="translate(-2 0)"/></>,
    flag: <><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></>,
    warn: <><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></>,
    copy: <><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></>,
    list: <><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></>,
    trash: <><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></>,
    // Section marks for the mobile bottom bar.
    home: <><path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M9.5 21v-6h5v6"/></>,
    building: <><path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 21v-6h6v6"/></>,
    users: <><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></>,
    cap: <><path d="M22 10 12 5 2 10l10 5 10-5z"/><path d="M6 12v5c0 1.7 2.7 3 6 3s6-1.3 6-3v-5"/></>,
    grid: <><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></>,
    menu: <><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></>,
  };
  return (
    <svg xmlns="http://www.w3.org/2000/svg" width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={className}>
      {paths[name]}
    </svg>
  );
};

/* ---------- Confidence dots ---------- */
const Confidence = ({ value }) => {
  const filled = Math.round(value * 5);
  const tier = value >= 0.8 ? "high" : value >= 0.5 ? "mid" : "low";
  return (
    <div className="confidence-dots" title={`Confidence ${(value * 100).toFixed(0)}%`}>
      {[0,1,2,3,4].map(i => (
        <span key={i} className={`d ${i < filled ? "on " + tier : ""}`}></span>
      ))}
    </div>
  );
};

/* ---------- Status pill ---------- */
// A test with a future scheduled_date displays as "Upcoming" regardless of the
// stored status (the row is still 'live' in the DB until the date passes, but
// the dashboard/library should signal it as not-yet-open).
const isFutureSchedule = (scheduledDate) => {
  if (!scheduledDate) return false;
  const d = new Date(scheduledDate);
  if (isNaN(d.getTime())) return false;
  const startOfToday = new Date();
  startOfToday.setHours(0, 0, 0, 0);
  return d.getTime() > startOfToday.getTime();
};

// Tests auto-expire ~48h after their scheduled date (server-derived via the
// backend's `is_expired` flag on /tests). Until the test is closed/completed,
// an expired live row shows the muted "Expired" pill in the library.
const effectiveStatus = (status, scheduledDate, isExpired) => {
  const liveLike = ["live", "published", "ready_for_review", "optimised", "grading"];
  if (isExpired && !["completed", "closed"].includes(status) && liveLike.includes(status)) {
    return "expired";
  }
  if (liveLike.includes(status) && isFutureSchedule(scheduledDate)) return "upcoming";
  return status;
};

const StatusPill = ({ status, scheduledDate, isExpired }) => {
  const map = {
    draft:            { cls: "pill", label: "Draft" },
    live:             { cls: "pill amber", label: "Live" },
    completed:        { cls: "pill green", label: "Completed" },
    // Intermediate / legacy states
    ready_for_review: { cls: "pill amber", label: "Live" },
    published:        { cls: "pill amber", label: "Live" },
    optimised:        { cls: "pill amber", label: "Live" },
    upcoming:         { cls: "pill", label: "Upcoming" },
    grading:          { cls: "pill amber", label: "Grading" },
    closed:           { cls: "pill green", label: "Completed" },
    parsing:          { cls: "pill", label: "Parsing…" },
    rubric_generating:{ cls: "pill", label: "Drafting rubrics…" },
    expired:          { cls: "pill", label: "Expired" },
  };
  const effective = effectiveStatus(status, scheduledDate, isExpired);
  const s = map[effective] || map.draft;
  return <span className={s.cls}><span className="swatch"></span>{s.label}</span>;
};

/* ---------- Navigation model ----------
   Six top-level sections, each an overview screen plus the deep screens that
   belong under it. This is the single source of truth for BOTH rows of the
   nav: the pills in the top bar and the sub-nav beneath it. It also answers
   "which section owns this screen", which is how the correct pill stays lit
   when something navigates straight to a deep screen (a notification opening
   Doubts must light KinetiX, not leave every pill dark).

   `roles` gates a section; `roles` on a child gates just that entry. Absent
   means everyone who can reach the cockpit at all. */
const NAV_SECTIONS = [
  {
    id: "dashboard", label: "Dashboard", icon: "home", overview: "dashboard",
    children: [],
  },
  {
    id: "academics", label: "Academics", icon: "cap", overview: "academics",
    children: [
      { id: "academics",        label: "Overview" },
      { id: "students",         label: "Students",          roles: ["admin"] },
      { id: "teachers",         label: "Teachers",          roles: ["admin"] },
      { id: "staff-attendance", label: "Staff Attendance" },
      { id: "personnel",        label: "Personnel",         roles: ["admin"] },
      { id: "taxonomy",         label: "Classes & Subjects", roles: ["admin"] },
      { id: "chapters",         label: "Chapters",          roles: ["admin"] },
      { id: "topics",           label: "Topics",            roles: ["admin"] },
      { id: "college-students", label: "College",           roles: ["admin"] },
    ],
  },
  {
    id: "hostel", label: "Hostel", icon: "building", overview: "hostel",
    children: [
      { id: "hostel",         label: "Overview" },
      { id: "hostel-report",  label: "Reports" },
    ],
  },
  {
    id: "kinetix", label: "KinetiX", icon: "zap", overview: "kinetix-overview",
    children: [
      { id: "kinetix-overview", label: "Overview" },
      { id: "library",          label: "Test Library" },
      { id: "kinetix",          label: "Online Tests" },
      { id: "evaluate",         label: "Paper Evaluation" },
      { id: "report",           label: "Class Reports" },
      { id: "answer-key",       label: "Answer Keys" },
      { id: "doubts",           label: "Doubts" },
      { id: "ai-review",        label: "AI Test Review" },
      { id: "kinetix-access",   label: "Access",   roles: ["admin"] },
      { id: "kinetix-activity", label: "Activity", roles: ["admin"] },
    ],
  },
  {
    id: "parents", label: "Parents", icon: "users", overview: "parents-overview",
    children: [
      { id: "parents-overview", label: "Overview" },
      { id: "parents",          label: "Parent Accounts", roles: ["admin"] },
      { id: "announcements",    label: "Announcements",   roles: ["admin"] },
      { id: "notifications",    label: "Notifications" },
    ],
  },
  {
    id: "operations", label: "Operations", icon: "grid", overview: "finance",
    children: [
      { id: "finance",      label: "Finance" },
      { id: "admissions",   label: "Intent to Apply" },
      { id: "forms",        label: "Forms & Vacancies" },
      { id: "bus-tracking", label: "Bus Tracking", roles: ["admin", "hod", "coordinator"] },
    ],
  },
];

// Screens reachable from inside another screen rather than from the nav
// (authoring flows, drill-downs). Mapped so the right pill stays lit.
const SCREEN_SECTION_OVERRIDES = {
  manual: "kinetix",
  "answer-key": "kinetix",
};

const visibleChildren = (section, role) =>
  (section.children || []).filter((c) => !c.roles || c.roles.includes(role));

const visibleSections = (role) =>
  NAV_SECTIONS.filter((s) => {
    if (s.roles && !s.roles.includes(role)) return false;
    // A section with no reachable children and no overview is dead weight.
    return s.overview || visibleChildren(s, role).length > 0;
  });

const sectionForScreen = (screen, role) => {
  if (SCREEN_SECTION_OVERRIDES[screen]) return SCREEN_SECTION_OVERRIDES[screen];
  const hit = NAV_SECTIONS.find(
    (s) => s.overview === screen || (s.children || []).some((c) => c.id === screen),
  );
  return hit ? hit.id : visibleSections(role)[0]?.id || "dashboard";
};

/* ---------- Shared scroll state ----------
   Two pieces of chrome react to scrolling — the top bar condenses, the nav
   widget shrinks — so they read one hook rather than each installing their
   own listener with its own slightly different threshold.

   Three things this has to get right:

   1. rAF-throttled. A scroll handler that calls setState on every event
      re-renders the whole shell dozens of times a second on a phone.
   2. A DEAD ZONE on direction. Without it, the one or two pixels of jitter
      from a finger resting on the screen flip the direction repeatedly and
      the widget pulses. 6px of travel is required before direction flips.
   3. Never "down" near the top of the page, so the widget is always at full
      size where a user starts. Bounce-scrolling past the top on iOS reports
      a negative scrollY, which would otherwise read as a downward move. */
const useScrollState = ({ condenseAt = 8, dirThreshold = 6 } = {}) => {
  const [state, setState] = useState({ scrolled: false, down: false });
  const last = useRef(0);
  const ticking = useRef(false);

  useEffect(() => {
    const read = () => {
      ticking.current = false;
      const y = Math.max(0, window.scrollY || 0);
      const delta = y - last.current;
      setState((prev) => {
        const scrolled = y > condenseAt;
        let down = prev.down;
        if (Math.abs(delta) >= dirThreshold) down = delta > 0;
        // Anywhere near the top the widget is always full size.
        if (y <= condenseAt) down = false;
        if (scrolled === prev.scrolled && down === prev.down) return prev;
        return { scrolled, down };
      });
      if (Math.abs(delta) >= dirThreshold) last.current = y;
    };
    const onScroll = () => {
      if (ticking.current) return;
      ticking.current = true;
      window.requestAnimationFrame(read);
    };
    last.current = Math.max(0, window.scrollY || 0);
    read();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, [condenseAt, dirThreshold]);

  return state;
};

/* ---------- Cockpit top bar ---------- */
const CockpitTopbar = ({ screen, setScreen }) => {
  const user = window.KX.CURRENT_USER;
  const school = window.KX.CURRENT_SCHOOL;
  const role = user?.role;
  const sections = visibleSections(role);
  const activeId = sectionForScreen(screen, role);
  const active = NAV_SECTIONS.find((s) => s.id === activeId);
  const children = active ? visibleChildren(active, role) : [];

  const initials = user?.initials
    || user?.name?.split(/\s+/).map((p) => p[0]).slice(0, 2).join("").toUpperCase()
    || "—";

  const [menuOpen, setMenuOpen] = useState(false);
  const { scrolled } = useScrollState();

  return (
    <>
    {/* Only the BAR is sticky. The sub-nav below scrolls away with the page:
        pinning both would cost ~100px of an 844px screen permanently, and the
        bar is the half that has to stay reachable. */}
    <div className={`cx-shellpad cx-topwrap${scrolled ? " scrolled" : ""}`}>
      <div className={`cx-topbar${scrolled ? " condensed" : ""}`}>
        <div className="cx-brand">
          {/* The KinetiX mark. logo-dark.svg is the near-black artwork meant to
              sit directly on the white pill — no tile behind it. */}
          <div className="cx-brand-mark">
            <img src="assets/logo-dark.svg" alt="KinetiX"/>
          </div>
          <div className="cx-brand-text">
            <div className="cx-brand-name">{school?.name || "Adarshabani"}</div>
            <div className="cx-brand-sub">Admin Cockpit</div>
          </div>
        </div>

        <nav className="cx-nav" aria-label="Sections">
          {sections.map((s) => (
            <button key={s.id}
              className={`cx-nav-pill${s.id === activeId ? " active" : ""}`}
              aria-current={s.id === activeId ? "page" : undefined}
              onClick={() => setScreen(s.overview || visibleChildren(s, role)[0]?.id)}>
              {s.label}
            </button>
          ))}
        </nav>

        <div className="cx-topbar-actions">
          <button className="cx-round-btn" title="KinetiX activity"
                  aria-label="KinetiX activity"
                  onClick={() => setScreen("kinetix-activity")}>
            <CxTopIcon name="activity"/>
          </button>
          <NotificationsBell/>
          <div style={{ position: "relative" }}>
            <button className="cx-avatar-btn" title={user?.name || "Account"}
                    aria-label="Account menu" onClick={() => setMenuOpen((o) => !o)}>
              {initials}
            </button>
            {menuOpen && (
              <>
                <div onClick={() => setMenuOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 190 }}/>
                <div style={{
                  position: "absolute", top: "calc(100% + 8px)", right: 0, zIndex: 200,
                  width: 240, background: "#fff", border: "1px solid var(--line-strong)",
                  borderRadius: 16, boxShadow: "var(--shadow-pop)", overflow: "hidden",
                }}>
                  <div style={{ padding: "14px 16px", borderBottom: "1px solid var(--line-soft)" }}>
                    <div style={{ fontSize: 14, fontWeight: 600, color: "var(--ink-0)" }}>{user?.name || "Not signed in"}</div>
                    <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
                      {school?.name ? `${school.name} · ${user?.role || "—"}` : (user?.role || "—")}
                    </div>
                  </div>
                  <button style={{
                    display: "block", width: "100%", textAlign: "left",
                    padding: "12px 16px", fontSize: 13, color: "var(--red)", fontWeight: 500,
                  }}
                    onClick={async () => {
                      try { await window.KXApi.post("/teacher/logout", {}); } catch {}
                      window.KXApi.setToken(null);
                      window.location.reload();
                    }}>Sign out</button>
                </div>
              </>
            )}
          </div>
        </div>
      </div>

    </div>

    {children.length > 1 && (
      <div className="cx-shellpad cx-subnavwrap">
        <div className="cx-subnav" aria-label={`${active.label} pages`}>
          {children.map((c) => (
            <button key={c.id}
              className={`cx-subnav-item${screen === c.id ? " active" : ""}`}
              aria-current={screen === c.id ? "page" : undefined}
              onClick={() => setScreen(c.id)}>
              {c.label}
              {c.id === "library" && (window.KX.TESTS || []).length > 0 && (
                <span className="count">{(window.KX.TESTS || []).length}</span>
              )}
            </button>
          ))}
        </div>
      </div>
    )}
    </>
  );
};

/* ---------- Radial navigation widget (mobile / installed PWA) ----------
   One floating circle that opens an arc of sections. Two gestures, one
   pointer pipeline:

     tap           → arc opens and stays open, then tap a section
     press-and-drag→ arc opens, the section nearest the drag angle arms
                     itself, releasing selects it

   Both fall out of the same handlers rather than being two code paths: the
   arc opens on pointerdown either way, and pointerup decides based on
   whether the finger travelled. That is why a slow tap and a fast flick
   both do the sensible thing without a hold timer to tune.

   Rendered at every width; CSS hides it above 760px, so there is no resize
   listener and no flash of the wrong navigation on first paint. */
// Geometry. The trigger sits at bottom-CENTRE, so the arc gets the whole
// upper half to fan across rather than a single quadrant. That extra span is
// what lets the radius stay small: six 56px circles need ~62px of arc between
// centres, and across 156° that is only 62 / (156°/5 in radians) ≈ 114.
//
// A tighter radius is the point — every item stays inside a thumb's sweep,
// and the open menu covers far less of the screen than a corner fan would.
const RADIAL_R = 116;
const RADIAL_FROM = 168;       // degrees — leftmost item, just above level
const RADIAL_TO = 12;          // degrees — rightmost item
const RADIAL_ARM_DIST = 44;    // travel before a drag starts arming items

const CockpitRadialNav = ({ screen, setScreen }) => {
  const role = window.KX.CURRENT_USER?.role;
  const sections = visibleSections(role);
  const activeId = sectionForScreen(screen, role);

  const [open, setOpen] = useState(false);
  const [armed, setArmed] = useState(-1);
  const fabRef = useRef(null);
  // Shrinks while the user reads downward, returns the moment they scroll
  // back up. Never while the arc is open — the trigger is the anchor the
  // whole gesture is measured from, so moving it mid-drag would be hostile.
  const { down } = useScrollState();
  const mini = down && !open;
  // Refs, not state: these are read inside pointer handlers on every move and
  // must not schedule a render or go stale between events.
  const drag = useRef({ active: false, moved: false, wasOpen: false });

  const go = useCallback((s) => {
    setScreen(s.overview || visibleChildren(s, role)[0]?.id);
    setOpen(false);
    setArmed(-1);
  }, [setScreen, role]);

  // Item positions: an even fan across the upper half, left to right.
  // Angles are measured from the +x axis with y flipped for screen space.
  const positions = useMemo(() => {
    const n = sections.length;
    return sections.map((_, i) => {
      // A lone section sits straight above the trigger, not at an arc end.
      const deg = n === 1
        ? 90
        : RADIAL_FROM + ((RADIAL_TO - RADIAL_FROM) * i) / (n - 1);
      const rad = (deg * Math.PI) / 180;
      return { deg, x: Math.cos(rad) * RADIAL_R, y: -Math.sin(rad) * RADIAL_R };
    });
  }, [sections]);

  const pickByPointer = useCallback((clientX, clientY) => {
    const fab = fabRef.current;
    if (!fab) return -1;
    const r = fab.getBoundingClientRect();
    const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
    const dx = clientX - cx, dy = cy - clientY;          // dy up-positive
    if (Math.hypot(dx, dy) < RADIAL_ARM_DIST) return -1; // still on the trigger
    let deg = (Math.atan2(dy, dx) * 180) / Math.PI;
    if (deg < 0) deg += 360;
    // Nearest item by angle. No tolerance window: once the finger has left
    // the trigger, something is always armed, so a release never no-ops in a
    // way the user cannot see coming.
    let best = -1, bestD = Infinity;
    positions.forEach((p, i) => {
      const d = Math.abs(((p.deg - deg + 540) % 360) - 180);
      if (d < bestD) { bestD = d; best = i; }
    });
    return best;
  }, [positions]);

  const onPointerDown = (e) => {
    drag.current = { active: true, moved: false, wasOpen: open };
    setOpen(true);
    setArmed(-1);
    // Keep receiving move/up even when the finger leaves the button.
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* older WebKit */ }
  };

  const onPointerMove = (e) => {
    if (!drag.current.active) return;
    const i = pickByPointer(e.clientX, e.clientY);
    if (i !== -1) drag.current.moved = true;
    setArmed(i);
  };

  const onPointerUp = (e) => {
    if (!drag.current.active) return;
    const { moved, wasOpen } = drag.current;
    drag.current.active = false;
    try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* no capture */ }

    if (moved) {
      const i = pickByPointer(e.clientX, e.clientY);
      if (i !== -1) { go(sections[i]); return; }
    }
    // A tap: toggle. Opening leaves the arc up for a second tap; tapping the
    // trigger again closes it.
    setArmed(-1);
    if (wasOpen) setOpen(false);
  };

  const onKeyDown = (e) => {
    if (e.key === "Escape" && open) { setOpen(false); setArmed(-1); }
  };

  const current = sections.find((s) => s.id === activeId);

  return (
    <div className={`cx-radialnav${open ? " open" : ""}${mini ? " mini" : ""}`} onKeyDown={onKeyDown}>
      {open && (
        <div className="cx-radial-veil"
             onPointerDown={() => { setOpen(false); setArmed(-1); }}
             aria-hidden="true"/>
      )}

      {sections.map((s, i) => (
        <button key={s.id}
          className={`lg cx-radial-item${s.id === activeId ? " current" : ""}${armed === i ? " armed" : ""}`}
          style={{ "--x": `${positions[i].x}px`, "--y": `${positions[i].y}px`,
                   /* Stagger outward from the thumb so the arc unfurls. */
                   transitionDelay: open ? `${i * 22}ms` : "0ms" }}
          tabIndex={open ? 0 : -1}
          aria-current={s.id === activeId ? "page" : undefined}
          onClick={() => go(s)}>
          <Icon name={s.icon || "grid"} size={19}/>
          <span className="cx-radial-label">{s.label}</span>
        </button>
      ))}

      <button ref={fabRef}
        className={`lg dark cx-fab${open ? " open" : ""}`}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-label={open ? "Close navigation" : `Navigation — currently ${current?.label || "Dashboard"}`}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={() => { drag.current.active = false; setArmed(-1); }}
        onContextMenu={(e) => e.preventDefault()}>
        <span className="cx-fab-face">
          <Icon name={open ? "plus" : (current?.icon || "grid")} size={24}/>
        </span>
      </button>
    </div>
  );
};

// Small local icon for the top bar's activity button — the shared CX icon set
// lives in a screen file that loads after the shell.
const CxTopIcon = ({ name }) => (
  <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
       strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    {name === "activity" && (
      <><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/>
        <line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/>
        <line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/>
        <line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/>
        <line x1="17" y1="16" x2="23" y2="16"/></>
    )}
  </svg>
);

/* ---------- Notification rendering (shared) ----------
   ONE renderer for both surfaces: the topbar bell and the full Notifications
   screen. It lives here because shell.jsx loads before the screens, so the
   screen can read it off window.KXUI while the bell uses it directly.

   Why this exists: the bell previously handled six kinds and let everything
   else fall through to a branch that assumed an attempt_submitted payload.
   The database has sixteen kinds, and the most common of them by a wide
   margin — attendance_checkin_reminder, 148 rows — has a payload of just
   { date }. Rendered through the submission branch it produced the literal
   text "submitted · undefined / undefined auto-graded · undefined".

   Two rules for anything added here:
     1. every field access is guarded — a payload is data from another
        subsystem and may legitimately lack a key;
     2. the fallback prints a humanised kind, never a raw snake_case string
        and never the word "undefined". */
const NOTIFICATION_KIND_LABEL = {
  progress_extra_logged:         "Outside-schedule class",
  progress_period_prompt:        "Period log due",
  attempt_submitted:             "Submission",
  question_reported:             "Reported question",
  doubt_raised:                  "Doubt raised",
  doubt_message:                 "Doubt follow-up",
  retest_requested:              "Retest request",
  chat_report_raised:            "Chat report",
  parent_chat_message:           "Parent message",
  kinetix_access_request_raised: "KinetiX access request",
  attendance_checkin_reminder:   "Check-in reminder",
  attendance_absent_report:      "Absence report",
  attendance_checkout_override:  "Off-site check-out",
  teacher_registration_pending:  "New staff registration",
  parent_registration_pending:   "New parent registration",
  password_reset_requested:      "Password reset request",
  form_submission_received:      "Form application",
  leave_application_raised:      "Leave application",
  leave_application_approved:    "Leave approved",
  leave_cover_assigned:          "Cover assigned",
};

// "attendance_checkin_reminder" → "Attendance checkin reminder". Only ever
// used for a kind the server has added and this file does not know yet.
const humaniseKind = (k) =>
  String(k || "notification").replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());

const notificationMeta = (n) => {
  const p = (n && n.payload) || {};
  const label = NOTIFICATION_KIND_LABEL[n?.kind] || humaniseKind(n?.kind);
  const someone = (v, fallback) => v || fallback;
  const dateish = (d) => {
    if (!d) return "";
    const dt = new Date(String(d).length === 10 ? d + "T00:00:00" : d);
    return Number.isNaN(dt.getTime())
      ? String(d)
      : dt.toLocaleDateString("en-IN", { day: "numeric", month: "short" });
  };

  switch (n?.kind) {
    case "attempt_submitted":
      return { pill: "blue", label: "Submitted",
        title: `${someone(p.student_name, "A student")} submitted ${p.test_title || "a test"}`,
        sub: [
          (p.auto_score != null && p.max_marks != null) ? `${p.auto_score} / ${p.max_marks} auto-graded` : null,
          p.student_roll, p.physical_handin ? "handed in on paper" : null,
        ].filter(Boolean).join(" · ") };

    case "question_reported":
      return { pill: "amber", label: "Reported",
        title: `${someone(p.student_name, "A student")} reported Q${p.q_no ?? "?"} in ${p.test_title || "a test"}`,
        sub: (p.note || "").slice(0, 160) || "(no note)" };

    case "doubt_raised":
      return { pill: "amber", label: "Doubt",
        title: `${someone(p.student_name, "A student")} asked about Q${p.q_no ?? "?"} in ${p.test_title || "a test"}`,
        sub: p.body_preview || "" };

    case "doubt_message":
      return { pill: "amber", label: "Doubt · follow-up",
        title: `New message on Q${p.q_no ?? "?"}${p.test_title ? " · " + p.test_title : ""}`,
        sub: p.body_preview || "" };

    case "retest_requested":
      return { pill: "amber", label: "Retest",
        title: `${someone(p.student_name, "A student")} requested a retest`,
        sub: [p.test_title || p.test_display_id, p.reason].filter(Boolean).join(" · ") };

    /* ---- Attendance ---- */
    case "attendance_checkin_reminder":
      return { pill: "", label: "Check-in",
        title: "Attendance check-in is open",
        sub: p.date ? `For ${dateish(p.date)} — staff who have not checked in yet` : "" };

    case "attendance_absent_report": {
      const n2 = Number(p.count ?? (p.names || []).length) || 0;
      return { pill: "amber", label: "Absences",
        title: `${n2} staff member${n2 === 1 ? "" : "s"} unmarked${p.date ? " on " + dateish(p.date) : ""}`,
        sub: (p.names || []).slice(0, 4).join(", ") + ((p.names || []).length > 4 ? ` +${p.names.length - 4} more` : "") };
    }

    case "attendance_checkout_override":
      return { pill: "amber", label: "Off-site",
        title: `${someone(p.teacher_name, "A teacher")} checked out off-site`,
        sub: p.distance != null ? `${Math.round(Number(p.distance))} m from the gate` : "" };

    /* ---- Teaching log ---- */
    case "progress_extra_logged":
      return { pill: "green", label: "Progress",
        title: `${someone(p.teacher_name, "A teacher")} logged an outside-schedule class`,
        sub: [
          (p.classes || []).join(", ") || null, p.subject_name,
          p.started_at ? `${p.started_at}–${p.ended_at || ""}` : null, p.topic,
        ].filter(Boolean).join(" · ") };

    case "progress_period_prompt":
      return { pill: "", label: "Period log",
        title: `Log period ${p.period_no ?? "?"}${p.class_label ? " · " + p.class_label : ""}`,
        sub: [p.subject_name, dateish(p.work_date)].filter(Boolean).join(" · ") };

    /* ---- Leave ---- */
    case "leave_application_raised":
      return { pill: "amber", label: "Leave",
        title: `${someone(p.teacher_name, "A teacher")} applied for leave`,
        sub: [
          p.start_date ? `${dateish(p.start_date)}${p.end_date && p.end_date !== p.start_date ? " – " + dateish(p.end_date) : ""}` : null,
          p.reason_category, p.reason_note,
        ].filter(Boolean).join(" · ") };

    case "leave_application_approved":
      return { pill: "green", label: "Leave approved",
        title: `Leave approved${p.decided_by_name ? " by " + p.decided_by_name : ""}`,
        sub: [
          p.start_date ? `${dateish(p.start_date)}${p.end_date && p.end_date !== p.start_date ? " – " + dateish(p.end_date) : ""}` : null,
          p.reason_category, p.decision_note,
        ].filter(Boolean).join(" · ") };

    case "leave_cover_assigned": {
      const per = p.periods || [];
      return { pill: "blue", label: "Cover",
        title: `You are covering for ${someone(p.absent_teacher_name, "a colleague")}`,
        sub: per.length
          ? per.slice(0, 3).map((x) =>
              `P${x.period_no} ${x.class_label || ""}${x.subject_name ? " · " + x.subject_name : ""}`).join("  ·  ")
            + (per.length > 3 ? ` +${per.length - 3} more` : "")
          : "" };
    }

    /* ---- People ---- */
    case "parent_registration_pending":
      return { pill: "amber", label: "New parent",
        title: `${someone(p.parent_name, "Someone")} registered as a parent`,
        sub: [p.phone, p.child_count ? `${p.child_count} child(ren)` : null,
              "approve on the Parents screen"].filter(Boolean).join(" · ") };

    case "teacher_registration_pending":
      return { pill: "amber", label: "New staff",
        title: `${someone(p.teacher_name, "Someone")} registered and is awaiting approval`,
        sub: [p.role, p.phone, "approve on the Teachers screen"].filter(Boolean).join(" · ") };

    case "password_reset_requested":
      return { pill: "amber", label: "Password reset",
        title: `${someone(p.teacher_name, "A teacher")} requested a password reset`,
        sub: p.phone || "" };

    case "parent_chat_message":
      return { pill: "blue", label: "Parent message",
        title: "A parent replied in a chat thread",
        sub: (p.body || "").slice(0, 140) };

    case "chat_report_raised":
      return { pill: "amber", label: "Chat report",
        title: "A chat message was reported",
        sub: (p.reason || p.body || "").slice(0, 140) };

    case "kinetix_access_request_raised":
      return { pill: "blue", label: "KinetiX access",
        title: `${someone(p.student_name, "A student")} requested KinetiX access`,
        sub: p.reason || "" };

    case "form_submission_received":
      return { pill: "amber", label: p.kind === "vacancy" ? "Job application" : "Application",
        title: `${someone(p.applicant_name, "Someone")} applied to ${p.form_title || "a form"}`,
        sub: [p.headline, p.phone, p.resubmission ? "updated an earlier submission" : null]
          .filter(Boolean).join(" · ") };

    default:
      // An unrecognised kind still reads as a sentence, never as a raw key.
      return { pill: "", label, title: label, sub: p.date ? dateish(p.date) : "" };
  }
};

/* ---------- Notifications panel ---------- */
// Hangs off the topbar bell. Polls /api/notifications every 30s for new items
// and on open. Click an item to navigate (attempt_submitted → submissions
// screen for that test) or open the edit-question modal (question_reported).
const relTime = (iso) => {
  const ms = Date.now() - new Date(iso).getTime();
  const s = Math.floor(ms / 1000);
  if (s < 60) return `${s}s ago`;
  if (s < 3600) return `${Math.floor(s/60)}m ago`;
  if (s < 86400) return `${Math.floor(s/3600)}h ago`;
  return `${Math.floor(s/86400)}d ago`;
};

const NotificationsBell = () => {
  const [open, setOpen] = useState(false);
  const [items, setItems] = useState([]);
  const [unread, setUnread] = useState(0);
  const [loading, setLoading] = useState(false);

  // Route through window.KXApi so the Authorization: Bearer header from the
  // login flow rides every request. Raw fetch() never attached it, which is
  // why /api/notifications kept returning 401 even when the admin's session
  // was healthy and the other admin routes worked.
  const refresh = useCallback(async () => {
    try {
      setLoading(true);
      const data = await window.KXApi.get("/notifications");
      setItems(data?.items || []);
      setUnread(data?.unread || 0);
    } catch (_e) {
      // Silent — the bell polls every 30 s and we don't want a transient
      // network blip to clobber the existing items.
    } finally { setLoading(false); }
  }, []);

  useEffect(() => {
    refresh();
    const t = setInterval(refresh, 30000);
    return () => clearInterval(t);
  }, [refresh]);

  // Refresh again whenever the panel is opened, in case items came in between polls.
  useEffect(() => { if (open) refresh(); }, [open, refresh]);

  const markRead = async (id) => {
    try { await window.KXApi.post(`/notifications/${id}/read`, {}); } catch (_e) { /* best-effort */ }
    setItems(xs => xs.map(x => x.id === id ? { ...x, read_at: new Date().toISOString() } : x));
    setUnread(n => Math.max(0, n - 1));
  };

  const markAll = async () => {
    try { await window.KXApi.post(`/notifications/read-all`, {}); } catch (_e) { /* best-effort */ }
    setItems(xs => xs.map(x => x.read_at ? x : { ...x, read_at: new Date().toISOString() }));
    setUnread(0);
  };

  const handleClick = (n) => {
    if (!n.read_at) markRead(n.id);
    if (n.kind === "attempt_submitted") {
      // Jump to the evaluate screen for this test. Mirrors the dashboard
      // "Submissions →" button — sets KX.EVAL_CONTEXT and asks app.jsx to
      // switch screens via the navigate hook.
      if (typeof window.KX?.navigate === "function") {
        window.KX.navigate("evaluate", { testDisplayId: n.payload?.test_display_id });
      }
      setOpen(false);
    } else if (n.kind === "question_reported") {
      // Open inline edit modal — keeps the teacher on the current screen.
      window.dispatchEvent(new CustomEvent("kx.openEditQuestion", { detail: n.payload }));
      setOpen(false);
    } else if (n.kind === "doubt_raised" || n.kind === "doubt_message") {
      // Open the doubts screen with the right thread preselected.
      if (typeof window.KX?.navigate === "function") {
        window.KX.navigate("doubts", { doubtId: n.payload?.doubt_id, tab: "open" });
      }
      setOpen(false);
    } else if (n.kind === "progress_extra_logged") {
      // Jump to the full notifications panel.
      if (typeof window.KX?.navigate === "function") window.KX.navigate("notifications");
      setOpen(false);
    } else if (n.kind === "form_submission_received") {
      // Someone filled a form the office raised. Land on the Applications tab
      // of the Forms screen — the bell said there was something to READ, so
      // opening on the list of forms would be one click short.
      window.KX = window.KX || {};
      window.KX.FORMS_CONTEXT = { tab: "inbox" };
      if (typeof window.KX?.navigate === "function") window.KX.navigate("forms");
      setOpen(false);
    }
  };

  return (
    <div style={{ position: "relative" }}>
      <button className="cx-round-btn" title="Notifications" aria-label="Notifications"
              onClick={() => setOpen(o => !o)}>
        <Icon name="bell" size={18}/>
        {unread > 0 && (
          <span className="cx-badge-count">{unread > 99 ? "99+" : unread}</span>
        )}
      </button>

      {/* Portalled to <body>, deliberately.

          An overlay must position against the VIEWPORT, and inside the topbar
          it could not: the topbar carries a backdrop-filter for the glass
          material, and a filtered element becomes the containing block for
          its position:fixed descendants. The panel was therefore resolving
          its inset against the topbar's box and only fitting the screen by
          coincidence. A portal removes the coupling entirely. */}
      {open && ReactDOM.createPortal(
        <>
          <div onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 190 }}/>
          <div className="cx-notifpanel" style={{
            position: "fixed", top: 74, right: 12,
            width: 380, maxHeight: 480, overflowY: "auto", zIndex: 200,
            background: "#fff", border: "1px solid var(--line-strong)",
            borderRadius: 18, boxShadow: "var(--shadow-pop)",
          }}>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
              padding: "12px 16px", borderBottom: "1px solid var(--line-soft)" }}>
              <span style={{ color: "var(--ink-0)", fontWeight: 600, fontSize: 14 }}>Notifications</span>
              {unread > 0 && (
                <button className="btn ghost sm" onClick={markAll}>Mark all read</button>
              )}
            </div>

            {items.length === 0 && (
              <div style={{ padding: 28, textAlign: "center", color: "var(--ink-3)", fontSize: 12 }}>
                {loading ? "Loading…" : "No notifications yet."}
              </div>
            )}

            {items.map(n => {
              // One shared renderer — see notificationMeta above. The bell
              // used to carry its own six-branch chain, which is how the
              // most common notification kind ended up rendering as
              // "undefined / undefined auto-graded · undefined".
              const meta = notificationMeta(n);

              return (
                <div key={n.id} onClick={() => handleClick(n)} style={{
                  display: "block", padding: "11px 16px",
                  borderBottom: "1px solid var(--line-soft)",
                  background: n.read_at ? "transparent" : "var(--accent-bg)",
                  cursor: "pointer",
                }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
                    {!n.read_at && <span style={{
                      width: 6, height: 6, borderRadius: "50%", background: "var(--accent)",
                      display: "inline-block",
                    }}/>}
                    <span className={`pill ${meta.pill}`} style={{ fontSize: 9 }}>{meta.label}</span>
                    <span style={{ color: "var(--ink-3)", fontSize: 10, marginLeft: "auto" }}>{relTime(n.created_at)}</span>
                  </div>
                  <div style={{ color: "var(--ink-0)", fontSize: 12, marginBottom: 2 }}>{meta.title}</div>
                  <div style={{ color: "var(--ink-2)", fontSize: 11 }}>{meta.sub}</div>
                </div>
              );
            })}

            <div onClick={() => { if (window.KX?.navigate) window.KX.navigate("notifications"); setOpen(false); }}
              style={{ padding: "12px 16px", textAlign: "center", cursor: "pointer", fontWeight: 500,
                       color: "var(--accent)", fontSize: 13, borderTop: "1px solid var(--line-soft)" }}>
              View all notifications →
            </div>
          </div>
        </>,
        document.body,
      )}
    </div>
  );
};

/* ---------- Edit-question modal (triggered by question_reported notifications) ---------- */
const EditQuestionModal = () => {
  const [payload, setPayload] = useState(null);
  const [text, setText] = useState("");
  const [marks, setMarks] = useState("");
  const [saving, setSaving] = useState(false);
  const [err, setErr] = useState(null);

  useEffect(() => {
    const onOpen = (e) => {
      setPayload(e.detail);
      setText(e.detail?.question_text || "");
      setMarks(e.detail?.question_marks != null ? String(e.detail.question_marks) : "");
      setErr(null);
    };
    window.addEventListener("kx.openEditQuestion", onOpen);
    return () => window.removeEventListener("kx.openEditQuestion", onOpen);
  }, []);

  if (!payload) return null;

  const close = () => setPayload(null);

  const save = async () => {
    setSaving(true); setErr(null);
    try {
      const body = { text };
      if (marks && !Number.isNaN(Number(marks))) body.marks = Number(marks);
      const res = await fetch(`/api/questions/${payload.question_id}`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || `HTTP ${res.status}`);
      close();
    } catch (e) { setErr(e.message); }
    finally { setSaving(false); }
  };

  return (
    <div onClick={close} style={{ position:"fixed", inset:0, background:"rgba(20,20,23,0.45)", zIndex: 260, display:"grid", placeItems:"center", padding: 30 }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        background: "#fff", border: "1px solid var(--line-strong)", borderRadius: 20,
        boxShadow: "var(--shadow-pop)",
        width: 560, maxWidth: "92vw", padding: 22,
      }}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 12 }}>
          <h3 style={{ margin: 0, color: "var(--ink-0)", fontSize: 14 }}>
            Edit Q{payload.q_no} <span style={{ color: "var(--ink-3)", fontWeight: 400 }}>· {payload.test_title}</span>
          </h3>
          <button className="btn ghost sm" onClick={close}>✕</button>
        </div>

        <div style={{ padding: "8px 12px", marginBottom: 12, background: "var(--bg-1)", border: "1px solid var(--line)", borderRadius: 6 }}>
          <div style={{ color: "var(--ink-3)", fontSize: 10, textTransform: "uppercase", letterSpacing: ".04em", marginBottom: 4 }}>
            Reported by {payload.student_name} ({payload.student_roll})
          </div>
          <div style={{ color: "var(--ink-1)", fontSize: 12 }}>{payload.note || "(no note)"}</div>
        </div>

        <label style={{ display: "block", color: "var(--ink-2)", fontSize: 11, marginBottom: 4 }}>Question text</label>
        <textarea className="textarea" rows={6} style={{ width: "100%", fontSize: 13 }}
          value={text} onChange={(e) => setText(e.target.value)}/>

        <label style={{ display: "block", color: "var(--ink-2)", fontSize: 11, margin: "10px 0 4px" }}>Marks</label>
        <input className="input" style={{ width: 100 }} type="number" step="0.5"
          value={marks} onChange={(e) => setMarks(e.target.value)}/>

        {err && <div style={{ color: "var(--red)", fontSize: 12, marginTop: 10 }}>{err}</div>}

        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 16 }}>
          <button className="btn ghost" onClick={close} disabled={saving}>Cancel</button>
          <button className="btn primary" onClick={save} disabled={saving || !text.trim()}>
            {saving ? "Saving…" : "Save changes"}
          </button>
        </div>
      </div>
    </div>
  );
};

/* ---------- Topbar ---------- */
const Topbar = ({ crumbs, actions }) => (
  <header className="topbar">
    <div className="crumbs">
      {crumbs.map((c, i) => (
        <React.Fragment key={i}>
          {i > 0 && <span className="sep">›</span>}
          <span className={i === crumbs.length - 1 ? "here" : ""}>{c}</span>
        </React.Fragment>
      ))}
    </div>
    <div className="topbar-actions">
      <NotificationsBell/>
      <button className="btn ghost sm" title="Keyboard shortcuts"><Icon name="keyboard" size={14}/><span className="kbd">?</span></button>
      {actions}
    </div>
    <EditQuestionModal/>
  </header>
);

/* ---------- Multi-section chip picker ----------
   Tap a section chip to add/remove it. Sections are filtered by `classLevel`.
   selectedIds is an array of class_section_ids. onChange receives the new array. */
const SectionChips = ({ classLevel, selectedIds, onChange }) => {
  const all = (window.KX?.CLASS_SECTIONS || []).filter(cs => !classLevel || cs.class_level === classLevel);
  const toggle = (id) => {
    const set = new Set(selectedIds || []);
    if (set.has(id)) set.delete(id); else set.add(id);
    onChange(Array.from(set));
  };
  if (all.length === 0) {
    return (
      <div className="muted" style={{ fontSize: 11, padding: "6px 8px", border: "1px dashed var(--line)", borderRadius: 6 }}>
        No sections for this class. Add one in Admin → Classes & Subjects.
      </div>
    );
  }
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
      {all.map(cs => {
        const on = (selectedIds || []).includes(cs.id);
        return (
          <button key={cs.id} type="button" onClick={() => toggle(cs.id)}
            style={{
              padding: "4px 10px", borderRadius: 999, fontSize: 11.5,
              border: `1px solid ${on ? "var(--accent)" : "var(--line-strong)"}`,
              background: on ? "var(--accent-bg)" : "#fff",
              color: on ? "var(--accent)" : "var(--ink-1)",
              cursor: "pointer",
            }}>
            {cs.section || "(no section)"} <span className="muted" style={{ fontSize: 10, marginLeft: 4 }}>{cs.academic_year}</span>
          </button>
        );
      })}
    </div>
  );
};

window.KXUI = {
  Icon, Confidence, StatusPill, Topbar, CockpitTopbar, CockpitRadialNav, notificationMeta,
  EditQuestionModal, SectionChips,
  effectiveStatus, isFutureSchedule,
  NAV_SECTIONS, sectionForScreen, visibleSections, visibleChildren,
};
