/* ==================================================================
   Admin panel — 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.APP;

/* ---------- Icons (inline SVG) ---------- */
const Icon = ({ name, size = 14, className = "", style }) => {
  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"/></>,
    // ---- Sidebar + primitive-layer icons (lucide-style, 24px grid) ----
    userCheck: <><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><polyline points="16 11 18 13 22 9"/></>,
    userPlus: <><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/></>,
    contact: <><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M15 9h2M15 13h2M6.5 16a3 3 0 0 1 5 0"/></>,
    calendar: <><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></>,
    calendarCheck: <><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="m9 16 2 2 4-4"/></>,
    clipboard: <><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></>,
    briefcase: <><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></>,
    layers: <><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></>,
    book: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></>,
    hash: <><path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/></>,
    monitor: <><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></>,
    penLine: <><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></>,
    chart: <><path d="M3 3v18h18"/><path d="M7 16v-4M12 16V8M17 16v-7"/></>,
    key: <><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></>,
    help: <><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></>,
    megaphone: <><path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></>,
    wallet: <><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></>,
    table: <><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/></>,
    phone: <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>,
    message: <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>,
    mail: <><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></>,
    arrowLeft: <><path d="M19 12H5"/><polyline points="12 19 5 12 12 5"/></>,
    arrowRight: <><path d="M5 12h14"/><polyline points="12 5 19 12 12 19"/></>,
    arrowUpRight: <><path d="M7 17 17 7"/><polyline points="7 7 17 7 17 17"/></>,
    checkCircle: <><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></>,
    alertCircle: <><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></>,
    xCircle: <><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></>,
    dashedCircle: <><path d="M10.1 2.18a10 10 0 0 1 3.8 0M17.6 3.71a10 10 0 0 1 2.7 2.7M21.82 10.1a10 10 0 0 1 0 3.8M20.29 17.6a10 10 0 0 1-2.7 2.7M13.9 21.82a10 10 0 0 1-3.8 0M6.4 20.29a10 10 0 0 1-2.7-2.7M2.18 13.9a10 10 0 0 1 0-3.8M3.71 6.4a10 10 0 0 1 2.7-2.7"/></>,
    clock: <><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></>,
    ban: <><circle cx="12" cy="12" r="10"/><path d="m4.93 4.93 14.14 14.14"/></>,
    logout: <><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><path d="M21 12H9"/></>,
    refresh: <><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></>,
    download: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><path d="M12 15V3"/></>,
    eye: <><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></>,
    more: <><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></>,
    printer: <><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></>,
    rupee: <path d="M6 3h12M6 8h12M6 13l8.5 8M6 13h3a5 5 0 0 0 0-10"/>,
    tag: <><path d="M12 2H2v10l9.29 9.29a1 1 0 0 0 1.41 0l8.59-8.59a1 1 0 0 0 0-1.41Z"/><circle cx="7" cy="7" r="1.5"/></>,
    lock: <><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></>,
  };
  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} style={style} aria-hidden="true" focusable="false">
      {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 Assessment, 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: "student-attendance", label: "Student Attendance" },
      { id: "holidays",         label: "Holidays",           roles: ["admin"] },
      { id: "exams",            label: "Examinations",      roles: ["admin", "hod"] },
      { 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: "assessment", label: "Assessment", icon: "zap", overview: "assessment-overview",
    children: [
      { id: "assessment-overview", label: "Overview" },
      { id: "library",          label: "Test Library" },
      { id: "online-tests",     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: "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"] },
      // Admin AND HoD: the API admits both, and a HoD is exactly who deals
      // with a complaint about a member of their department.
      { id: "parent-complaints", label: "Complaints",     roles: ["admin", "hod"] },
      { id: "notifications",    label: "Notifications" },
    ],
  },
  {
    id: "operations", label: "Operations", icon: "grid", overview: "finance",
    children: [
      { id: "finance",      label: "Finance" },
      { id: "sheet-builder", label: "Sheet Builder", roles: ["admin"] },
    ],
  },
];

// 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: "assessment",
  setup: "dashboard",
  "answer-key": "assessment",
};

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";
};

/* ---------- Paid features (docs/SELF_SERVE.md, "Paid features") ----------
   GET /api/features says which paid add-ons this school has. A locked
   module's nav rows stay visible with a lock and a "Paid" chip; opening one
   shows FeatLockedScreen in place of the module (app.jsx asks
   featLockFor(screen)). The API refuses the module with 402 regardless. */
const FEAT_SCREENS = {
  assessment: [
    "assessment-overview", "library", "manual", "online-tests", "evaluate",
    "report", "answer-key", "doubts", "ai-review",
  ],
};
const FEAT_INFO = {
  assessment: {
    title: "Assessment",
    lead: "Set, take and mark tests in one place, with AI doing the slow part of marking.",
    points: [
      ["library", "Question bank and test library", "Build tests from a shared bank of questions, by class, subject and chapter."],
      ["monitor", "Online tests", "Students take tests on their phones; objective answers are scored instantly."],
      ["penLine", "AI paper evaluation", "Scan handwritten answer sheets and get AI-suggested marks against a rubric, which teachers confirm or override."],
      ["key", "Answer keys and class reports", "Model answers per question, and reports that show where a class went wrong."],
    ],
  },
};
const FEAT_CONTACT = {
  email: "support@craftncode.in",
  whatsappDisplay: "+91 89062 56270",
  whatsappE164: "918906256270",
};
window.APP = window.APP || {};
window.APP.FEATURES = window.APP.FEATURES || null;
// Unknown (request failed) = nothing locked: the server still enforces 402.
const featReady = fetch(`${window.AppApi.base}/features`, { cache: "no-store" })
  .then((r) => (r.ok ? r.json() : null))
  .then((f) => { window.APP.FEATURES = f; return f; })
  .catch(() => null);
const featLocked = (feature) => {
  const f = window.APP.FEATURES;
  return !!(f && Array.isArray(f.locked) && f.locked.includes(feature));
};
// The locked feature a screen belongs to, or null.
const featLockFor = (screen) => {
  for (const [feature, screens] of Object.entries(FEAT_SCREENS)) {
    if (screens.includes(screen) && featLocked(feature)) return feature;
  }
  return null;
};
const featSectionLocked = (sectionId) => sectionId === "assessment" && featLocked("assessment");

const FeatPaidChip = ({ style }) => (
  <span className="pill amber" title="Paid add-on"
    style={{ fontSize: 10, padding: "1px 6px", lineHeight: "15px", fontWeight: 600, flexShrink: 0, ...style }}>
    Paid
  </span>
);

const FeatLockedScreen = ({ feature = "assessment" }) => {
  const info = FEAT_INFO[feature] || FEAT_INFO.assessment;
  const school = window.APP.CURRENT_SCHOOL?.name || window.APP.TENANT?.name || "";
  const ask = `Hello Craft & Code, we would like to unlock ${info.title}${school ? ` for ${school}` : ""}.`;
  const mailto = `mailto:${FEAT_CONTACT.email}?subject=${encodeURIComponent(`Unlock ${info.title}${school ? ` for ${school}` : ""}`)}&body=${encodeURIComponent(ask)}`;
  const wa = `https://wa.me/${FEAT_CONTACT.whatsappE164}?text=${encodeURIComponent(ask)}`;
  return (
    <div className="cx-page" data-feature-locked={feature}>
      <UiPageHeader eyebrow="Paid add-on" title={info.title}
        status={<FeatPaidChip style={{ fontSize: 11, padding: "2px 8px" }}/>}
        meta={[info.lead]}/>
      <UiCard style={{ maxWidth: 760 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16 }}>
          <span style={{ width: 40, height: 40, borderRadius: 10, display: "grid", placeItems: "center",
                         background: "var(--amber-bg)", color: "var(--amber)", flexShrink: 0 }}>
            <Icon name="lock" size={20}/>
          </span>
          <div style={{ minWidth: 0 }}>
            <div style={{ fontWeight: 600, fontSize: 15, color: "var(--ink-0)" }}>{info.title} is not part of your plan yet</div>
            <div style={{ color: "var(--ink-2)", marginTop: 2 }}>Everything else in School Suite stays free. {info.title} is an add-on you can switch on at any time.</div>
          </div>
        </div>
        <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 12 }}>
          {info.points.map(([icon, title, hint]) => (
            <li key={title} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
              <span style={{ color: "var(--ink-2)", marginTop: 2 }}><Icon name={icon} size={16}/></span>
              <span style={{ minWidth: 0 }}>
                <span style={{ display: "block", fontWeight: 600, color: "var(--ink-0)" }}>{title}</span>
                <span style={{ display: "block", color: "var(--ink-2)" }}>{hint}</span>
              </span>
            </li>
          ))}
        </ul>
        <div style={{ borderTop: "1px solid var(--line)", marginTop: 18, paddingTop: 16 }}>
          <div style={{ fontWeight: 600, color: "var(--ink-0)", marginBottom: 4 }}>Upgrade to unlock</div>
          <div style={{ color: "var(--ink-2)", marginBottom: 12 }}>
            Write to us and we will switch {info.title} on for your school, usually the same day. Exam marks and marksheets stay free either way.
          </div>
          <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
            <a className="btn primary" href={mailto}><Icon name="mail" size={15}/>Email {FEAT_CONTACT.email}</a>
            <a className="btn outline" href={wa} target="_blank" rel="noopener noreferrer"><Icon name="message" size={15}/>WhatsApp {FEAT_CONTACT.whatsappDisplay}</a>
          </div>
        </div>
      </UiCard>
    </div>
  );
};

/* ---------- 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;
};

/* ---------- Sidebar navigation ----------
   Desktop (>=1024px): a fixed left rail — brand row, then one uppercase
   group label per section with its screens as icon + label rows.
   Below 1024px the same list lives in a drawer opened from the top bar.
   Both read NAV_SECTIONS, so the nav model is still defined once. */
const NAV_ICONS = {
  dashboard: "grid",
  academics: "cap", students: "student", teachers: "users",
  "staff-attendance": "userCheck", "student-attendance": "calendarCheck",
  holidays: "calendar", exams: "clipboard", personnel: "briefcase",
  taxonomy: "layers", chapters: "book", topics: "hash",
  "assessment-overview": "zap", library: "library", "online-tests": "monitor",
  evaluate: "penLine", report: "chart", "answer-key": "key", doubts: "help",
  "ai-review": "sparkle",
  "parents-overview": "users", parents: "contact", announcements: "megaphone",
  "parent-complaints": "flag", notifications: "bell",
  finance: "wallet", "sheet-builder": "table",
};
// Deep screens with no nav row of their own light the row they came from.
const SCREEN_ITEM_OVERRIDES = { manual: "library" };

const navGroups = (role) => visibleSections(role).map((s) => {
  const kids = visibleChildren(s, role);
  if (kids.length === 0) {
    return { id: s.id, label: s.id === "dashboard" ? "Overview" : s.label,
             items: [{ id: s.overview, label: s.label }] };
  }
  return { id: s.id, label: s.label, items: kids };
});

const SidebarBrand = () => {
  const school = window.APP.CURRENT_SCHOOL;
  return (
    <div className="nv-brand">
      <span className="nv-brand-mark"><BrandMark size={30}/></span>
      <span className="nv-brand-text">
        <span className="nv-brand-name">{school?.name || window.BRAND.orgName}</span>
        <span className="nv-brand-sub">{window.BRAND.appName}</span>
      </span>
    </div>
  );
};

const SidebarNav = ({ screen, setScreen, onNavigate }) => {
  const role = window.APP.CURRENT_USER?.role;
  const current = SCREEN_ITEM_OVERRIDES[screen] || screen;
  const libCount = (window.APP.TESTS || []).length;
  return (
    <nav className="nv-nav" aria-label="Sections">
      {/* Guided setup (screen-setup.jsx) while the school is not onboarded. */}
      {(() => {
        const ob = window.APP.ONBOARDING;
        if (!ob || !ob.applicable || ob.onboarded_at) return null;
        const on = current === "setup";
        return (
          <div className="nv-nav-group" role="group" aria-labelledby="adnav-setup">
            <div className="nv-nav-label" id="adnav-setup">Get started</div>
            <button type="button" className={`nv-nav-item${on ? " active" : ""}`} aria-current={on ? "page" : undefined}
              onClick={() => { setScreen("setup"); if (onNavigate) onNavigate(); }}>
              <Icon name="sparkle" size={17}/>
              <span className="nv-nav-text">Guided setup</span>
              <span className="nv-nav-count">{(ob.steps || []).filter((x) => x.done).length}/{(ob.steps || []).length}</span>
            </button>
          </div>
        );
      })()}
      {navGroups(role).map((g) => (
        <div key={g.id} className="nv-nav-group" role="group" aria-labelledby={`adnav-${g.id}`}>
          <div className="nv-nav-label" id={`adnav-${g.id}`}
            style={featSectionLocked(g.id) ? { display: "flex", alignItems: "center", gap: 6 } : undefined}>
            {g.label}
            {featSectionLocked(g.id) && <FeatPaidChip/>}
          </div>
          {g.items.map((it) => {
            const on = current === it.id;
            const locked = !!featLockFor(it.id);
            return (
              <button key={it.id} type="button"
                className={`nv-nav-item${on ? " active" : ""}${locked ? " nv-locked" : ""}`}
                aria-current={on ? "page" : undefined}
                title={locked ? `${it.label}: paid add-on` : undefined}
                onClick={() => { setScreen(it.id); if (onNavigate) onNavigate(); }}>
                <Icon name={NAV_ICONS[it.id] || "grid"} size={17}/>
                <span className="nv-nav-text" style={locked ? { color: "var(--ink-2)" } : undefined}>{it.label}</span>
                {locked && <span aria-label="Locked" style={{ display: "inline-flex", color: "var(--ink-3)" }}><Icon name="lock" size={13}/></span>}
                {!locked && it.id === "library" && libCount > 0 && (
                  <span className="nv-nav-count">{libCount}</span>
                )}
              </button>
            );
          })}
        </div>
      ))}
    </nav>
  );
};

const CockpitSidebar = ({ screen, setScreen }) => (
  <aside className="nv-sidebar" aria-label="Main navigation">
    <SidebarBrand/>
    <SidebarNav screen={screen} setScreen={setScreen}/>
  </aside>
);

/* ---------- Cockpit top bar ----------
   Thin bar with a hairline under it: (drawer button + brand mark on narrow
   screens) · breadcrumb · notifications bell · round initials avatar.
   The admin panel is light-only, so there is no theme toggle. */
const CockpitTopbar = ({ screen, setScreen }) => {
  const user = window.APP.CURRENT_USER;
  const school = window.APP.CURRENT_SCHOOL;
  const role = user?.role;
  const activeId = sectionForScreen(screen, role);
  const active = NAV_SECTIONS.find((s) => s.id === activeId);
  const children = active ? visibleChildren(active, role) : [];
  const currentItem = SCREEN_ITEM_OVERRIDES[screen] || screen;
  const here = children.find((c) => c.id === currentItem);

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

  const [menuOpen, setMenuOpen] = useState(false);
  const [drawerOpen, setDrawerOpen] = useState(false);

  useEffect(() => {
    if (!drawerOpen && !menuOpen) return;
    const onKey = (e) => { if (e.key === "Escape") { setDrawerOpen(false); setMenuOpen(false); } };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [drawerOpen, menuOpen]);

  return (
    <>
    <header className="nv-topbar">
      <button type="button" className="nv-menu-btn cx-round-btn" aria-label="Open navigation"
              aria-expanded={drawerOpen} onClick={() => setDrawerOpen(true)}>
        <Icon name="menu" size={18}/>
      </button>
      <span className="nv-topbar-mark"><BrandMark size={26}/></span>

      <div className="nv-crumbs">
        <span className={here && here.id !== active?.overview ? "" : "here"}>{active?.label || "Dashboard"}</span>
        {here && here.id !== active?.overview && (
          <>
            <Icon name="chevronRight" size={13} className="nv-crumb-sep"/>
            <span className="here">{here.label}</span>
          </>
        )}
      </div>

      <div className="nv-topbar-actions">
        <NotificationsBell/>
        <div style={{ position: "relative" }}>
          <button className="cx-avatar-btn" title={user?.name || "Account"}
                  aria-label="Account menu" aria-expanded={menuOpen}
                  onClick={() => setMenuOpen((o) => !o)}>
            {initials}
          </button>
          {menuOpen && (
            <>
              <div onClick={() => setMenuOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 190 }}/>
              <div className="ui-menu" role="menu" style={{ position: "absolute", top: "calc(100% + 8px)", right: 0, zIndex: 200, width: 248 }}>
                <div className="ui-menu-head">
                  <span className="ui-avatar">{initials}</span>
                  <div style={{ minWidth: 0 }}>
                    <div className="ui-menu-title">{user?.name || "Not signed in"}</div>
                    <div className="ui-menu-sub">
                      {school?.name ? `${school.name} · ${user?.role || "—"}` : (user?.role || "—")}
                    </div>
                  </div>
                </div>
                {window.APP.ONBOARDING && window.APP.ONBOARDING.applicable && (
                  <button className="ui-menu-item" role="menuitem"
                    onClick={async () => {
                      setMenuOpen(false);
                      if (window.APP.ONBOARDING.onboarded_at) {
                        try { await window.AppApi.post("/admin/onboarding/reopen", {}); window.APP.ONBOARDING.onboarded_at = null; } catch {}
                      }
                      setScreen("setup");
                    }}><Icon name="sparkle" size={15}/>Guided setup</button>
                )}
                <button className="ui-menu-item danger" role="menuitem"
                  onClick={async () => {
                    try { await window.AppApi.post("/teacher/logout", {}); } catch {}
                    window.AppApi.setToken(null);
                    window.location.reload();
                  }}><Icon name="logout" size={15}/>Sign out</button>
              </div>
            </>
          )}
        </div>
      </div>
    </header>

    {/* Narrow screens: the section's pages as a swipeable segmented strip, so
        a deep screen is one tap away without opening the drawer. Hidden on
        desktop, where the sidebar already lists them. */}
    {children.length > 1 && (
      <div className="cx-subnavwrap">
        <div className="cx-subnav" aria-label={`${active.label} pages`}>
          {children.map((c) => (
            <button key={c.id}
              className={`cx-subnav-item${currentItem === c.id ? " active" : ""}`}
              aria-current={currentItem === c.id ? "page" : undefined}
              onClick={() => setScreen(c.id)}>
              {featLockFor(c.id) && <Icon name="lock" size={11} style={{ marginRight: 4, verticalAlign: "-1px" }}/>}
              {c.label}
              {!featLockFor(c.id) && c.id === "library" && (window.APP.TESTS || []).length > 0 && (
                <span className="count">{(window.APP.TESTS || []).length}</span>
              )}
            </button>
          ))}
        </div>
      </div>
    )}

    {drawerOpen && ReactDOM.createPortal(
      <>
        <div className="nv-drawer-scrim" onClick={() => setDrawerOpen(false)} aria-hidden="true"/>
        <aside className="nv-sidebar nv-drawer" aria-label="Main navigation" role="dialog" aria-modal="true">
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <SidebarBrand/>
            <button type="button" className="cx-round-btn" style={{ marginLeft: "auto" }}
                    aria-label="Close navigation" onClick={() => setDrawerOpen(false)}>
              <Icon name="x" size={18}/>
            </button>
          </div>
          <SidebarNav screen={screen} setScreen={setScreen} onNavigate={() => setDrawerOpen(false)}/>
        </aside>
      </>,
      document.body,
    )}
    </>
  );
};

/* ---------- 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.APP.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>
  );
};

/* ---------- Brand mark ----------
   The configured logo (window.BRAND.logoUrl), or — when none is set — a
   neutral rounded square carrying the organisation's initial. Shared by the
   top bar, the login screen and any printout header. */
const BrandMark = ({ size = 32 }) => {
  const B = window.BRAND || {};
  if (B.logoUrl) {
    return <img src={B.logoUrl} alt={B.orgName || ""} width={size} height={size}
                style={{ display: "block", objectFit: "contain" }}/>;
  }
  const letter = String(B.orgShortName || B.orgName || "S").trim().charAt(0).toUpperCase() || "S";
  return (
    <span aria-hidden="true" style={{
      width: size, height: size, borderRadius: Math.round(size * 0.28),
      background: "var(--primary)", color: "var(--primary-ink)",
      display: "grid", placeItems: "center",
      fontWeight: 700, fontSize: Math.round(size * 0.48), lineHeight: 1,
    }}>{letter}</span>
  );
};

/* ---------- 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.AppUI while the bell uses it directly.

   Payloads vary widely by kind (some carry only { date }). 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",
  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",
  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) };

    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.AppApi 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.AppApi.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.AppApi.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.AppApi.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 APP.EVAL_CONTEXT and asks app.jsx to
      // switch screens via the navigate hook.
      if (typeof window.APP?.navigate === "function") {
        window.APP.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("app.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.APP?.navigate === "function") {
        window.APP.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.APP?.navigate === "function") window.APP.navigate("notifications");
      setOpen(false);
    }
  };

  return (
    <div style={{ position: "relative" }}>
      <button className="cx-round-btn" title="Notifications" aria-label="Notifications"
              aria-expanded={open} 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 ui-menu" role="dialog" aria-label="Notifications" style={{
            position: "fixed", top: "calc(var(--topbar-h) + 6px)", right: 16,
            width: 380, maxHeight: 480, overflowY: "auto", zIndex: 200,
          }}>
            <div className="ui-notif-head">
              <span className="ui-menu-title">Notifications</span>
              {unread > 0 && (
                <button className="btn ghost sm" onClick={markAll}>Mark all read</button>
              )}
            </div>

            {items.length === 0 && (
              <div className="ui-empty compact">
                <div className="ui-empty-mark"><Icon name="bell" size={18}/></div>
                <div className="ui-empty-hint">{loading ? "Loading…" : "No notifications yet."}</div>
              </div>
            )}

            {items.map(n => {
              // One shared renderer — see notificationMeta above.
              const meta = notificationMeta(n);

              return (
                <div key={n.id} onClick={() => handleClick(n)}
                  className={`ui-notif${n.read_at ? "" : " unread"}`}>
                  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 3 }}>
                    {!n.read_at && <span className="ui-notif-dot"/>}
                    <span className={`pill ${meta.pill}`} style={{ fontSize: 10.5 }}>{meta.label}</span>
                    <span style={{ color: "var(--ink-3)", fontSize: 11, marginLeft: "auto" }}>{relTime(n.created_at)}</span>
                  </div>
                  <div className="ui-notif-title">{meta.title}</div>
                  {meta.sub && <div className="ui-notif-sub">{meta.sub}</div>}
                </div>
              );
            })}

            <button type="button" className="ui-notif-all"
              onClick={() => { if (window.APP?.navigate) window.APP.navigate("notifications"); setOpen(false); }}>
              View all notifications <Icon name="arrowRight" size={13}/>
            </button>
          </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("app.openEditQuestion", onOpen);
    return () => window.removeEventListener("app.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);
      await window.AppApi.patch(`/questions/${payload.question_id}`, body);
      close();
    } catch (e) { setErr(e.message); }
    finally { setSaving(false); }
  };

  return (
    <div className="ui-scrim" onClick={close} style={{ zIndex: 260 }}>
      <div className="ui-modal" role="dialog" aria-modal="true" aria-label={`Edit Q${payload.q_no}`}
           onClick={(e) => e.stopPropagation()}>
        <div className="ui-modal-head">
          <div style={{ minWidth: 0 }}>
            <div className="ui-modal-title">Edit Q{payload.q_no}</div>
            <div className="ui-modal-sub">{payload.test_title}</div>
          </div>
          <button className="ui-iconbtn ui-modal-close" aria-label="Close" onClick={close}><Icon name="x" size={16}/></button>
        </div>

        <div className="ui-modal-body">
          <div className="cx-note" style={{ display: "block", marginBottom: 16 }}>
            <div className="ui-section-label" style={{ marginBottom: 4 }}>
              Reported by {payload.student_name} ({payload.student_roll})
            </div>
            <div style={{ color: "var(--ink-1)", fontSize: 13 }}>{payload.note || "(no note)"}</div>
          </div>

          <div className="field" style={{ marginBottom: 12 }}>
            <label className="label">Question text</label>
            <textarea className="textarea" rows={6}
              value={text} onChange={(e) => setText(e.target.value)}/>
          </div>

          <div className="field">
            <label className="label">Marks</label>
            <input className="input" style={{ width: 110 }} type="number" step="0.5"
              value={marks} onChange={(e) => setMarks(e.target.value)}/>
          </div>

          {err && <div className="ui-alert red" style={{ marginTop: 12 }}>{err}</div>}
        </div>

        <div className="ui-modal-foot">
          <button className="btn" 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.APP?.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: 12, minHeight: 28,
              border: `1px solid ${on ? "var(--accent)" : "var(--line-strong)"}`,
              background: on ? "var(--accent-bg)" : "var(--surface)",
              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>
  );
};

/* ==================================================================
   Primitive layer — small presentational helpers for screens.
   ------------------------------------------------------------------
   Pure markup over the classes in styles.css (ui-*, cx-*, btn, pill).
   No state, no fetching. Screens reach them through window.AppUI; in a
   screen file use `<window.AppUI.PageHeader …/>` or destructure under a
   screen-unique alias (top-level consts share ONE global scope across
   every text/babel script, so `const { Card } = window.AppUI` in two files
   is a SyntaxError).
   ================================================================== */
const uiInitials = (name) =>
  String(name || "").trim().split(/\s+/).filter(Boolean)
    .map((p) => p[0]).slice(0, 2).join("").toUpperCase() || "—";

const uiIsBlank = (v) => v == null || v === "" || (Array.isArray(v) && v.length === 0);

// Round initials avatar. size: "" (36) | "sm" (28) | "lg" (44) | "xl" (64)
const UiAvatar = ({ name, initials, size = "", className = "", style, title }) => (
  <span className={`ui-avatar ${size} ${className}`} style={style} title={title || name}
        aria-hidden={title || name ? undefined : "true"}>
    {initials || uiInitials(name)}
  </span>
);

// Status pill. tone: green|amber|red|blue|violet|pink|grey|"" ;
// variant: "outline" (header status, icon + text) | "tint" | "solid".
const UI_TONE_ICON = { green: "checkCircle", red: "xCircle", amber: "alertCircle", blue: "clock", grey: "dashedCircle" };
const UiStatusBadge = ({ tone = "", variant = "tint", icon, children, className = "", title }) => {
  const ic = icon === false ? null : (icon || (variant === "outline" ? UI_TONE_ICON[tone] : null));
  return (
    <span className={`ui-badge ${variant} ${tone} ${className}`} title={title}>
      {ic && <Icon name={ic} size={12}/>}{children}
    </span>
  );
};

const UiBackLink = ({ onClick, children = "Back" }) => (
  <button type="button" className="ui-back" onClick={onClick}>
    <Icon name="arrowLeft" size={14}/>{children}
  </button>
);

// Page header: ← Back · avatar · title + status pill · meta line · actions,
// with an optional secondary toolbar row (outline buttons) beneath.
const UiPageHeader = ({
  onBack, backLabel, eyebrow, avatar, title, status, meta, actions, toolbar, className = "",
}) => (
  <div className={`ui-pagehead-wrap ${className}`}>
    {onBack && <UiBackLink onClick={onBack}>{backLabel || "Back"}</UiBackLink>}
    <div className="ui-pagehead">
      <div className="ui-pagehead-main">
        {avatar && (typeof avatar === "string" ? <UiAvatar name={avatar} size="xl"/> : avatar)}
        <div style={{ minWidth: 0 }}>
          {eyebrow && <div className="ui-eyebrow">{eyebrow}</div>}
          <div className="ui-pagehead-titlerow">
            <h1 className="ui-pagehead-title">{title}</h1>
            {status}
          </div>
          {meta && (
            <div className="ui-pagehead-meta">
              {Array.isArray(meta)
                ? meta.filter((m) => !uiIsBlank(m) && m !== false).map((m, i) => <span key={i}>{m}</span>)
                : meta}
            </div>
          )}
        </div>
      </div>
      {actions && <div className="ui-pagehead-actions">{actions}</div>}
    </div>
    {toolbar && <div className="ui-toolbar">{toolbar}</div>}
  </div>
);

// Segmented tabs. items: [{ value, label, icon?, count?, disabled? }]
const UiTabs = ({ value, onChange, items, full = false, size = "", className = "", ariaLabel }) => (
  <div className={`ui-tabs${full ? " full" : ""}${size ? " " + size : ""} ${className}`}
       role="tablist" aria-label={ariaLabel}>
    {(items || []).map((it) => {
      const v = it.value !== undefined ? it.value : it.id;
      const on = v === value;
      return (
        <button key={String(v)} type="button" role="tab" aria-selected={on}
          className={on ? "active" : ""} disabled={it.disabled}
          onClick={() => onChange && onChange(v)}>
          {it.icon && <Icon name={it.icon} size={14}/>}
          {it.label}
          {it.count != null && <span className="ui-tab-count">{it.count}</span>}
        </button>
      );
    })}
  </div>
);

// Padded hairline card with an optional title row.
const UiCard = ({ title, sub, action, children, className = "", flush = false, style, ...rest }) => (
  <div className={`cx-card${flush ? " flush" : ""} ${className}`} style={style} {...rest}>
    {(title || action) && (
      <div className="cx-card-head">
        <div style={{ minWidth: 0 }}>
          {title && <div className="cx-card-title">{title}</div>}
          {sub && <div className="cx-card-sub">{sub}</div>}
        </div>
        {action}
      </div>
    )}
    {children}
  </div>
);

// Equal columns of big number over muted caption, hairline above.
// items: [{ value, label, tone? }]  — tone colours the number (green|amber|red|blue)
const UiStatRow = ({ items, flush = false, align = "center", className = "" }) => (
  <div className={`ui-statrow${flush ? " flush" : ""}${align === "left" ? " left" : ""} ${className}`}
       style={{ "--cols": (items || []).length || 1 }}>
    {(items || []).map((s, i) => (
      <div key={i} className="ui-stat">
        <div className={`ui-stat-value${s.tone ? " " + s.tone : ""}`}>{uiIsBlank(s.value) ? "—" : s.value}</div>
        <div className="ui-stat-label">{s.label}</div>
      </div>
    ))}
  </div>
);

// Label-over-value grid; empty values print "—". items: [{ label, value, mono?, span? }]
const UiDetailGrid = ({ items, cols = 3, className = "" }) => (
  <dl className={`ui-details ${className}`} style={{ "--cols": cols }}>
    {(items || []).filter(Boolean).map((d, i) => (
      <div key={i} className="ui-detail" style={d.span ? { gridColumn: `span ${d.span}` } : undefined}>
        <dt className="ui-detail-label">{d.label}</dt>
        <dd className={`ui-detail-value${d.mono ? " mono" : ""}`}>{uiIsBlank(d.value) ? "—" : d.value}</dd>
      </div>
    ))}
  </dl>
);

// Hairline-divided row: [leading] title/sub ... status · amount · trailing
const UiListRow = ({ leading, title, sub, status, amount, trailing, onClick, className = "" }) => {
  const Tag = onClick ? "button" : "div";
  return (
    <Tag type={onClick ? "button" : undefined} onClick={onClick}
         className={`ui-row${onClick ? " clickable" : ""} ${className}`}>
      {leading}
      <div className="ui-row-main">
        <div className="ui-row-title">{title}</div>
        {sub && <div className="ui-row-sub">{sub}</div>}
      </div>
      {(status || amount != null || trailing) && (
        <div className="ui-row-end">
          {status}
          {amount != null && <span className="ui-amount">{amount}</span>}
          {trailing}
        </div>
      )}
    </Tag>
  );
};

const UiEmptyState = ({ icon = "inbox", title, hint, action, compact = false, card = false }) => {
  const body = (
    <div className={`ui-empty${compact ? " compact" : ""}`}>
      <div className="ui-empty-mark"><Icon name={icon} size={18}/></div>
      {title && <div className="ui-empty-title">{title}</div>}
      {hint && <div className="ui-empty-hint">{hint}</div>}
      {action && <div className="ui-empty-action">{action}</div>}
    </div>
  );
  return card ? <div className="cx-card">{body}</div> : body;
};

// Round ghost icon button (call / message / close). variant: "ghost" | "outline"
const UiIconButton = ({ icon, label, onClick, size = 16, variant = "ghost", className = "", ...rest }) => (
  <button type="button" className={`ui-iconbtn ${variant} ${className}`}
          title={label} aria-label={label} onClick={onClick} {...rest}>
    <Icon name={icon} size={size}/>
  </button>
);

// Modal shell, portalled to <body>. size: "" (560) | "lg" (780) | "xl" (920)
const UiModal = ({ open = true, onClose, title, sub, icon, size = "", children, footer, zIndex, bodyClassName = "", bodyStyle }) => {
  useEffect(() => {
    if (!open || !onClose) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onClose]);
  if (!open) return null;
  return ReactDOM.createPortal(
    <div className="ui-scrim" onClick={onClose} style={zIndex ? { zIndex } : undefined}>
      <div className={`ui-modal ${size}`} role="dialog" aria-modal="true"
           aria-label={typeof title === "string" ? title : undefined}
           onClick={(e) => e.stopPropagation()}>
        {(title || onClose) && (
          <div className="ui-modal-head">
            {icon && <span className="ui-modal-icon"><Icon name={icon} size={16}/></span>}
            <div style={{ minWidth: 0 }}>
              {title && <div className="ui-modal-title">{title}</div>}
              {sub && <div className="ui-modal-sub">{sub}</div>}
            </div>
            {onClose && (
              <button type="button" className="ui-iconbtn ui-modal-close" aria-label="Close" onClick={onClose}>
                <Icon name="x" size={16}/>
              </button>
            )}
          </div>
        )}
        <div className={`ui-modal-body ${bodyClassName}`} style={bodyStyle}>{children}</div>
        {footer && <div className="ui-modal-foot">{footer}</div>}
      </div>
    </div>,
    document.body,
  );
};

window.AppUI = {
  Icon, BrandMark, Confidence, StatusPill, Topbar, CockpitTopbar, CockpitSidebar, CockpitRadialNav, notificationMeta,
  EditQuestionModal, SectionChips,
  effectiveStatus, isFutureSchedule,
  NAV_SECTIONS, NAV_ICONS, sectionForScreen, visibleSections, visibleChildren,
  // Paid features (see the block above "Shared scroll state").
  featReady, featLocked, featLockFor, FeatLockedScreen, FeatPaidChip,
  // Primitive layer (see the block above and styles.css "Primitive layer").
  Avatar: UiAvatar, StatusBadge: UiStatusBadge, BackLink: UiBackLink,
  PageHeader: UiPageHeader, Tabs: UiTabs, Card: UiCard, StatRow: UiStatRow,
  DetailGrid: UiDetailGrid, ListRow: UiListRow, EmptyState: UiEmptyState,
  IconButton: UiIconButton, Modal: UiModal, initials: uiInitials,
};
