// Shared primitives: mascot, frame, label, etc.

// Canonical MIME lookup for stored course files. Case-insensitive (so a file
// saved as "PNG" resolves the same as "png") and covers every uploadable type.
// Any image-ish extension falls back to a generic image/* so it still previews
// inline instead of downloading. Use this everywhere a stored file is opened.
const FILE_MIME_MAP = {
  pdf:"application/pdf", txt:"text/plain",
  ppt:"application/vnd.ms-powerpoint",
  pptx:"application/vnd.openxmlformats-officedocument.presentationml.presentation",
  doc:"application/msword",
  docx:"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  png:"image/png", jpg:"image/jpeg", jpeg:"image/jpeg", webp:"image/webp",
  gif:"image/gif", bmp:"image/bmp", svg:"image/svg+xml", avif:"image/avif",
};
const IMAGE_EXTS = ["png","jpg","jpeg","webp","gif","bmp","svg","avif"];
const fileMimeType = (type) => {
  const t = String(type || "").toLowerCase().replace(/^\./, "");
  if (FILE_MIME_MAP[t]) return FILE_MIME_MAP[t];
  if (IMAGE_EXTS.includes(t)) return "image/" + (t === "jpg" ? "jpeg" : t);
  return "application/octet-stream";
};

// Learners must not be able to pull course material out of the LMS, so the file
// viewer hides its download action for them. Staff keep it — they need to manage
// the material. Read at render time; `currentRole` lives in api.jsx.
const isLearnerRole = () => {
  const r = typeof currentRole === "function" ? currentRole() : null;
  return r === "student" || r === "parent";
};

// Extract an 11-char YouTube id from any common link form, or a bare id.
const youtubeId = (url) => {
  if (!url) return "";
  const m = String(url).match(/(?:v=|\/embed\/|\/shorts\/|\/live\/|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
  return m ? m[1] : /^[a-zA-Z0-9_-]{11}$/.test(String(url)) ? String(url) : "";
};

// ── Safe YouTube player ───────────────────────────────────────────────────────
// A stock embed hands learners several ways out of the LMS: the channel avatar
// and video title in the hover overlay, "Watch on YouTube" in the control bar,
// and the end-screen suggestions. No player parameter removes them all, so we
// suppress YouTube's own chrome (controls=0) and drive playback through the
// IFrame API with our own controls. A transparent shield covers the video so
// nothing YouTube draws is ever clickable — clicking just toggles play/pause.
let ytApiPromise = null;
const loadYouTubeApi = () => {
  if (ytApiPromise) return ytApiPromise;
  ytApiPromise = new Promise((resolve) => {
    if (window.YT && window.YT.Player) return resolve(window.YT);
    const prev = window.onYouTubeIframeAPIReady;
    window.onYouTubeIframeAPIReady = () => { if (typeof prev === "function") prev(); resolve(window.YT); };
    const s = document.createElement("script");
    s.src = "https://www.youtube.com/iframe_api";
    document.head.appendChild(s);
  });
  return ytApiPromise;
};

const fmtClock = (secs) => {
  const s = isFinite(secs) && secs > 0 ? Math.floor(secs) : 0;
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};

const SafeYouTube = ({ url, videoId, title = "Video", autoplay = false, onEnded, style }) => {
  const vid = videoId || youtubeId(url);
  const mountRef = React.useRef(null);   // YT.Player replaces the node we hand it
  const wrapRef = React.useRef(null);    // fullscreen target
  const playerRef = React.useRef(null);
  const [ready, setReady] = React.useState(false);
  const [playing, setPlaying] = React.useState(false);
  const [muted, setMuted] = React.useState(false);
  const [pos, setPos] = React.useState(0);
  const [dur, setDur] = React.useState(0);
  // A ref, not state: the polling interval below closes over it once.
  const seekingRef = React.useRef(false);

  React.useEffect(() => {
    if (!vid || !mountRef.current) return;
    let dead = false, tick = null;
    const host = mountRef.current;
    // The API swaps the node we pass for an iframe, so give it a throwaway child
    // rather than the React-owned container.
    const node = document.createElement("div");
    node.style.width = "100%"; node.style.height = "100%";
    host.appendChild(node);
    setReady(false); setPlaying(false); setPos(0); setDur(0);

    loadYouTubeApi().then((YT) => {
      if (dead || !YT || !YT.Player) return;
      playerRef.current = new YT.Player(node, {
        videoId: vid,
        playerVars: {
          controls: 0, rel: 0, modestbranding: 1, iv_load_policy: 3,
          fs: 0, playsinline: 1, disablekb: 1, autoplay: autoplay ? 1 : 0,
        },
        events: {
          onReady: (e) => {
            if (dead) return;
            setReady(true);
            setDur(e.target.getDuration() || 0);
            setMuted(!!e.target.isMuted?.());
            if (autoplay) e.target.playVideo();
          },
          onStateChange: (e) => {
            if (dead) return;
            setPlaying(e.data === YT.PlayerState.PLAYING);
            const d = e.target.getDuration();
            if (d) setDur(d);
            if (e.data === YT.PlayerState.ENDED && onEnded) onEnded();
          },
        },
      });
      tick = setInterval(() => {
        const p = playerRef.current;
        if (!p || typeof p.getCurrentTime !== "function") return;
        if (!seekingRef.current) setPos(p.getCurrentTime() || 0);
        const d = p.getDuration();
        if (d) setDur(d);
      }, 400);
    });

    return () => {
      dead = true;
      if (tick) clearInterval(tick);
      try { playerRef.current?.destroy?.(); } catch { /* already torn down */ }
      playerRef.current = null;
      host.innerHTML = "";
    };
  }, [vid, autoplay]);

  const p = () => playerRef.current;
  const togglePlay = () => { const pl = p(); if (!pl) return; playing ? pl.pauseVideo() : pl.playVideo(); };
  const toggleMute = () => { const pl = p(); if (!pl) return; muted ? pl.unMute() : pl.mute(); setMuted(!muted); };
  const seekTo = (v) => { const pl = p(); if (!pl) return; pl.seekTo(v, true); setPos(v); };
  const toggleFullscreen = () => {
    const el = wrapRef.current;
    if (!el) return;
    if (document.fullscreenElement) document.exitFullscreen?.();
    else el.requestFullscreen?.();
  };

  if (!vid) return <div style={{ fontSize:12, color:"var(--coral)" }}>Invalid video link</div>;

  const btn = { background:"transparent", border:"none", color:"white", cursor:"pointer", fontSize:15, lineHeight:1, padding:"2px 6px", borderRadius:6 };
  return (
    <div ref={wrapRef} style={{ background:"#000", borderRadius:12, overflow:"hidden", ...style }}>
      <div style={{ position:"relative", paddingBottom:"56.25%", height:0, background:"#000" }}>
        <div ref={mountRef} title={title} style={{ position:"absolute", inset:0 }}/>
        {/* Click shield — swallows every click YouTube's own UI would receive. */}
        <div onClick={togglePlay} title={playing ? "Pause" : "Play"}
          style={{ position:"absolute", inset:0, cursor:"pointer", background:"transparent" }}/>
        {!ready && (
          <div style={{ position:"absolute", inset:0, display:"grid", placeItems:"center", color:"#888", fontSize:13 }}>Loading video…</div>
        )}
      </div>
      {/* Our own controls: nothing here can navigate out of the app. */}
      <div style={{ display:"flex", alignItems:"center", gap:10, padding:"8px 12px", background:"#111" }}>
        <button onClick={togglePlay} style={btn} title={playing ? "Pause" : "Play"}>{playing ? "❚❚" : "▶"}</button>
        <span className="mono" style={{ fontSize:11, color:"#bbb", minWidth:42 }}>{fmtClock(pos)}</span>
        <input type="range" min={0} max={Math.max(dur, 1)} step="any" value={Math.min(pos, dur || 0)}
          onMouseDown={() => { seekingRef.current = true; }} onTouchStart={() => { seekingRef.current = true; }}
          onMouseUp={() => { seekingRef.current = false; }} onTouchEnd={() => { seekingRef.current = false; }}
          onChange={(e) => seekTo(Number(e.target.value))}
          style={{ flex:1, accentColor:"var(--coral)", cursor:"pointer" }}/>
        <span className="mono" style={{ fontSize:11, color:"#bbb", minWidth:42, textAlign:"right" }}>{fmtClock(dur)}</span>
        <button onClick={toggleMute} style={btn} title={muted ? "Unmute" : "Mute"}>{muted ? "🔇" : "🔊"}</button>
        <button onClick={toggleFullscreen} style={btn} title="Full screen">⛶</button>
      </div>
    </div>
  );
};

// Visible text of a rich-text value — used to tell "empty" apart from the
// `<p><br></p>` a contentEditable leaves behind when you delete everything.
const stripHtml = (html) =>
  String(html || "").replace(/<[^>]*>/g, "").replace(/&nbsp;/gi, " ").trim();

// Read-only renderer for rich-text lesson bodies. The HTML was allowlisted
// server-side (lib/richtext.js) before it was ever stored.
const RichTextView = ({ html, style }) => (
  <div className="rich-text" style={{ fontSize:14.5, lineHeight:1.7, color:"var(--ink)", ...style }}
    dangerouslySetInnerHTML={{ __html: html || "" }}/>
);

// ── Rich text editor ──────────────────────────────────────────────────────────
// A dependency-free WYSIWYG for authoring text lessons directly in the LMS.
// contentEditable + execCommand: deprecated on paper, but it is the only
// formatting API every browser here actually implements, and whatever it emits
// is re-sanitised server-side before storage.
//
// Uncontrolled by design — writing `value` back into the node on every keystroke
// would collapse the caret to the start. We seed it once per `docKey` (bump that
// when you load a different lesson) and report changes upward via onChange.
//
// DESIGN: Google Docs-style — white page canvas on a neutral backdrop, sticky
// segmented toolbar, document typography scale, and a status bar with live
// word count + autosave indicator.
const RT_TOOLS = [
  ["bold",          "B",   "Bold",            { fontWeight:800 }],
  ["italic",        "I",   "Italic",          { fontStyle:"italic" }],
  ["underline",     "U",   "Underline",       { textDecoration:"underline" }],
  ["formatBlock:h2","H2",  "Heading 2",       { fontWeight:800, fontSize:11 }],
  ["formatBlock:h3","H3",  "Heading 3",       { fontWeight:700, fontSize:11 }],
  ["formatBlock:p", "¶",   "Normal text",     {}],
  ["insertUnorderedList", "•—", "Bullet list", {}],
  ["insertOrderedList",   "1.", "Numbered list", {}],
  ["formatBlock:blockquote", "❝", "Quote",    {}],
  ["formatBlock:pre",  "</>", "Code block",   { fontFamily:"var(--font-mono, monospace)", fontSize:11 }],
];

function countWords(html) {
  const text = String(html || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
  return text ? text.split(" ").length : 0;
}

const RichTextEditor = ({ value = "", onChange, docKey = "new", minHeight = 400, placeholder = "Start writing…", courseId = null }) => {
  const ref = React.useRef(null);
  const [empty, setEmpty] = React.useState(!value);
  const [wordCount, setWordCount] = React.useState(0);
  const [saveState, setSaveState] = React.useState(null); // null | "saving" | "saved" | "error"
  const saveTimer = React.useRef(null);

  React.useEffect(() => {
    if (!ref.current) return;
    ref.current.innerHTML = value || "";
    setEmpty(!ref.current.textContent.trim());
    setWordCount(countWords(value));
    ensureCodeBlockTrailingParagraphs(ref.current);
  }, [docKey]);

  // Universal Invariant: every <pre> anywhere in the document must always have an
  // immediate sibling <p> directly following it so an exit target always exists.
  const ensureCodeBlockTrailingParagraphs = (container) => {
    if (!container) return;
    const pres = container.querySelectorAll("pre");
    pres.forEach((pre) => {
      const next = pre.nextElementSibling;
      if (!next || next.tagName.toLowerCase() !== "p") {
        const p = document.createElement("p");
        p.innerHTML = "<br>";
        pre.parentNode.insertBefore(p, pre.nextSibling);
      }
    });
  };

  const emit = () => {
    if (!ref.current) return;
    ensureCodeBlockTrailingParagraphs(ref.current);
    const html = ref.current.innerHTML;
    setEmpty(!ref.current.textContent.trim());
    setWordCount(countWords(html));
    // Debounced autosave indicator
    if (onChange) {
      setSaveState("saving");
      clearTimeout(saveTimer.current);
      saveTimer.current = setTimeout(() => { setSaveState(null); }, 800);
      onChange(html);
    }
  };

  const getSelectedBlock = () => {
    const sel = window.getSelection();
    if (!sel || !sel.rangeCount) return null;
    let node = sel.anchorNode;
    while (node && node !== ref.current) {
      if (node.nodeType === 1 && /^(PRE|BLOCKQUOTE|H1|H2|H3|H4|P|UL|OL|LI)$/i.test(node.tagName)) {
        return node;
      }
      node = node.parentNode;
    }
    return null;
  };

  const exec = (tool) => {
    ref.current?.focus();
    if (tool === "removeFormat") {
      try {
        document.execCommand("removeFormat", false, null);
        document.execCommand("unlink", false, null);
        const block = getSelectedBlock();
        if (block && /^(PRE|BLOCKQUOTE|H1|H2|H3|H4)$/i.test(block.tagName)) {
          if (block.tagName.toLowerCase() === "pre") {
            const p = document.createElement("p");
            p.innerHTML = (block.textContent || "").replace(/\n/g, "<br>") || "<br>";
            block.parentNode?.replaceChild(p, block);
          } else {
            document.execCommand("formatBlock", false, "<p>");
          }
        }
      } catch { /* unsupported */ }
      emit();
      return;
    }

    if (tool === "formatBlock:p") {
      const block = getSelectedBlock();
      if (block && block.tagName.toLowerCase() === "pre") {
        const text = block.textContent || "";
        const p = document.createElement("p");
        p.innerHTML = text ? text.replace(/\n/g, "<br>") : "<br>";
        block.parentNode?.replaceChild(p, block);
        const range = document.createRange();
        const sel = window.getSelection();
        range.selectNodeContents(p);
        range.collapse(false);
        sel?.removeAllRanges();
        sel?.addRange(range);
        emit();
        return;
      }
    }

    // Split on the first colon only — args like link URLs contain colons of
    // their own (https://…) and must not be truncated by a naive tool.split(":").
    const i = tool.indexOf(":");
    const cmd = i === -1 ? tool : tool.slice(0, i);
    const arg = i === -1 ? undefined : tool.slice(i + 1);
    // Only formatBlock wants its arg as a tag literal (`<h2>`); other commands
    // (e.g. createLink) take the arg as-is.
    const cmdArg = cmd === "formatBlock" && arg ? `<${arg}>` : arg;
    try { document.execCommand(cmd, false, cmdArg); } catch { /* unsupported */ }
    emit();
  };

  const [linkModal, setLinkModal] = React.useState(null);

  const openLinkDialog = () => {
    const sel = window.getSelection();
    let selectedText = "";
    let existingUrl = "";
    let existingNode = null;
    let savedRange = null;

    if (sel && sel.rangeCount > 0) {
      savedRange = sel.getRangeAt(0).cloneRange();
      selectedText = sel.toString();
      let node = sel.anchorNode;
      while (node && node !== ref.current) {
        if (node.nodeType === 1 && node.tagName.toLowerCase() === "a") {
          existingNode = node;
          existingUrl = node.getAttribute("href") || "";
          selectedText = node.textContent || "";
          break;
        }
        node = node.parentNode;
      }
    }
    setLinkModal({
      url: existingUrl || "https://",
      text: selectedText,
      range: savedRange,
      existingNode,
    });
  };

  const saveLink = (e) => {
    e.preventDefault();
    if (!linkModal) return;
    let url = (linkModal.url || "").trim();
    if (!url) return;
    if (!/^(https?:\/\/|mailto:|\/|#)/i.test(url)) {
      url = "https://" + url;
    }
    const text = (linkModal.text || "").trim() || url;

    ref.current?.focus();
    const sel = window.getSelection();
    if (linkModal.range && sel) {
      sel.removeAllRanges();
      sel.addRange(linkModal.range);
    }

    if (linkModal.existingNode) {
      linkModal.existingNode.setAttribute("href", url);
      linkModal.existingNode.textContent = text;
    } else {
      const a = document.createElement("a");
      a.setAttribute("href", url);
      a.textContent = text;
      if (linkModal.range && !linkModal.range.collapsed) {
        linkModal.range.deleteContents();
        linkModal.range.insertNode(a);
      } else if (sel && sel.rangeCount > 0) {
        const range = sel.getRangeAt(0);
        range.insertNode(a);
      } else {
        ref.current?.appendChild(a);
      }
    }
    setLinkModal(null);
    emit();
  };

  const removeLink = () => {
    if (linkModal?.existingNode) {
      const text = linkModal.existingNode.textContent;
      const textNode = document.createTextNode(text);
      linkModal.existingNode.parentNode?.replaceChild(textNode, linkModal.existingNode);
    }
    setLinkModal(null);
    emit();
  };

  // Item 4: Exit a code block with keyboard (Shift+Enter or Enter on blank trailing line)
  // or by clicking below the <pre> into the container's padding area.
  const onKeyDown = (e) => {
    if (e.key === "Enter") {
      const block = getSelectedBlock();
      if (block && block.tagName.toLowerCase() === "pre") {
        const sel = window.getSelection();
        if (sel && sel.rangeCount) {
          const range = sel.getRangeAt(0);
          const preText = block.textContent || "";
          if (e.shiftKey || (range.endOffset === preText.length && (preText.length === 0 || preText.endsWith("\n")))) {
            e.preventDefault();
            // Find or create the next sibling paragraph to move into
            let nextP = block.nextElementSibling;
            if (!nextP || nextP.tagName.toLowerCase() !== "p") {
              nextP = document.createElement("p"); nextP.innerHTML = "<br>";
              block.parentNode.insertBefore(nextP, block.nextSibling);
            }
            const newRange = document.createRange();
            newRange.setStart(nextP, 0);
            newRange.collapse(true);
            sel.removeAllRanges();
            sel.addRange(newRange);
            emit();
          }
        }
      }
    }
  };

  // If user clicks in the canvas outside any block, or directly below/between blocks
  const onContainerClick = (e) => {
    // If the click is inside a <pre> element, let standard cursor placement edit the code
    if (e.target.closest && e.target.closest("pre")) {
      return;
    }
    // If clicking directly on the canvas container (e.g. empty whitespace around or between blocks)
    if (e.target === ref.current) {
      const sel = window.getSelection();
      if (!sel || !sel.rangeCount) return;
      const block = getSelectedBlock();
      if (block && block.tagName.toLowerCase() === "pre") {
        let nextP = block.nextElementSibling;
        if (!nextP || nextP.tagName.toLowerCase() !== "p") {
          nextP = document.createElement("p"); nextP.innerHTML = "<br>";
          block.parentNode.insertBefore(nextP, block.nextSibling);
        }
        const newRange = document.createRange();
        newRange.setStart(nextP, 0); newRange.collapse(true);
        sel.removeAllRanges(); sel.addRange(newRange);
      }
    }
  };

  // Item 3: Upload image via apiFetch through the asset pipeline, store a URL reference
  // in the document — NOT raw base64 which would bloat the JSON payload past 1MB limit.
  const imgInputRef = React.useRef(null);
  const [imgUploading, setImgUploading] = React.useState(false);
  const insertImage = () => {
    ref.current?.focus();
    imgInputRef.current?.click();
  };
  const onImageSelect = async (e) => {
    const file = e.target.files?.[0];
    if (!file) return;
    e.target.value = "";
    if (file.size > 10 * 1024 * 1024) {
      alert("Image must be smaller than 10 MB");
      return;
    }
    setImgUploading(true);
    try {
      const base64 = await new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = (ev) => resolve(String(ev.target?.result || "").split(",")[1]);
        reader.onerror = reject;
        reader.readAsDataURL(file);
      });
      const res = await apiFetch("/editor-image", {
        method: "POST",
        body: JSON.stringify({ file_name: file.name, file_data: base64, course_id: courseId || null }),
      });
      if (!res.ok) {
        const d = await res.json().catch(() => ({}));
        alert(d.error || `Image upload failed (${res.status})`);
        return;
      }
      const d = await res.json();
      // Insert a server-hosted URL reference — keeps document JSON small
      ref.current?.focus();
      document.execCommand("insertImage", false, d.url);
      emit();
    } catch (err) {
      alert("Could not upload image: " + (err.message || err));
    } finally {
      setImgUploading(false);
    }
  };

  // Paste as plain text so Word/Google Docs markup never enters the document.
  const onPaste = (e) => {
    e.preventDefault();
    const text = (e.clipboardData || window.clipboardData).getData("text/plain");
    document.execCommand("insertText", false, text);
    emit();
  };

  // ── Google Docs-style editor shell ───────────────────────────────────────────
  const toolBtn = {
    minWidth: 28, padding: "4px 7px", borderRadius: 5,
    border: "1px solid transparent",
    background: "transparent", color: "var(--ink)", cursor: "pointer",
    fontSize: 12, lineHeight: 1.3,
    transition: "background .12s, border-color .12s",
  };
  const toolBtnHover = { background: "var(--paper-deep)", borderColor: "var(--rule)" };
  const divider = <div style={{ width: 1, height: 18, background: "var(--rule)", margin: "0 3px", flexShrink: 0 }}/>;

  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100%", background: "var(--paper-deep)", borderRadius: 10, overflow: "hidden", border: "var(--border-thin)" }}>

      {/* Link modal */}
      {linkModal && (
        <div style={{ position:"fixed", inset:0, background:"rgba(0,0,0,.45)", display:"flex", alignItems:"center", justifyContent:"center", zIndex:99999 }}>
          <form onSubmit={saveLink} style={{ background:"var(--paper-card)", border:"var(--border-thin)", borderRadius:12, padding:20, width:360, maxWidth:"90vw", boxShadow:"0 16px 40px rgba(0,0,0,.25)" }}>
            <div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", marginBottom:14 }}>
              <div style={{ fontWeight:700, fontSize:15, color:"var(--ink)" }}>{linkModal.existingNode ? "Edit Link" : "Insert Link"}</div>
              <button type="button" onClick={() => setLinkModal(null)} style={{ background:"none", border:"none", fontSize:18, cursor:"pointer", color:"var(--ink-mute)", lineHeight:1 }}>×</button>
            </div>
            <div style={{ marginBottom:12 }}>
              <label style={{ display:"block", fontSize:11, fontWeight:700, color:"var(--ink-mute)", marginBottom:4 }}>LINK URL</label>
              <input type="text" autoFocus placeholder="https://example.com" value={linkModal.url}
                onChange={e => setLinkModal(m => ({ ...m, url: e.target.value }))}
                style={{ width:"100%", padding:"7px 10px", border:"var(--border-thin)", borderRadius:6, background:"var(--paper-deep)", color:"var(--ink)", fontSize:13, outline:"none", boxSizing:"border-box" }}/>
            </div>
            <div style={{ marginBottom:16 }}>
              <label style={{ display:"block", fontSize:11, fontWeight:700, color:"var(--ink-mute)", marginBottom:4 }}>TEXT TO DISPLAY</label>
              <input type="text" placeholder="Link text" value={linkModal.text}
                onChange={e => setLinkModal(m => ({ ...m, text: e.target.value }))}
                style={{ width:"100%", padding:"7px 10px", border:"var(--border-thin)", borderRadius:6, background:"var(--paper-deep)", color:"var(--ink)", fontSize:13, outline:"none", boxSizing:"border-box" }}/>
            </div>
            <div style={{ display:"flex", justifyContent:"flex-end", alignItems:"center", gap:8 }}>
              {linkModal.existingNode && (
                <button type="button" onClick={removeLink} style={{ ...toolBtn, color:"var(--coral)", marginRight:"auto" }}>Remove link</button>
              )}
              <button type="button" onClick={() => setLinkModal(null)} style={{ padding:"6px 12px", borderRadius:6, border:"var(--border-thin)", background:"var(--paper-deep)", color:"var(--ink)", cursor:"pointer", fontSize:12 }}>Cancel</button>
              <button type="submit" style={{ padding:"6px 14px", borderRadius:6, border:"none", background:"var(--sky-deep)", color:"white", fontWeight:700, fontSize:12, cursor:"pointer" }}>Save</button>
            </div>
          </form>
        </div>
      )}

      {/* ── Sticky toolbar ─────────────────────────────────────────────────── */}
      <div style={{
        display: "flex", flexWrap: "wrap", alignItems: "center", gap: 2,
        padding: "6px 10px", borderBottom: "1px solid var(--rule)",
        background: "var(--paper-card)",
        position: "sticky", top: 0, zIndex: 10,
        boxShadow: "0 1px 4px rgba(0,0,0,.06)",
      }}>
        {/* History */}
        <button type="button" title="Undo (Ctrl+Z)" onMouseDown={e => e.preventDefault()} onClick={() => exec("undo")} style={toolBtn}>↩</button>
        <button type="button" title="Redo (Ctrl+Y)" onMouseDown={e => e.preventDefault()} onClick={() => exec("redo")} style={toolBtn}>↪</button>
        {divider}
        {/* Block style select */}
        <select aria-label="Block style" defaultValue=""
          onMouseDown={e => e.stopPropagation()}
          onChange={e => { if (e.target.value) { exec(e.target.value); e.target.value = ""; } }}
          style={{ ...toolBtn, minWidth: 110, padding: "3px 6px", border: "1px solid var(--rule)", background: "var(--paper-card)", outline: "none", borderRadius: 5, fontSize: 12 }}
        >
          <option value="" disabled>Paragraph style…</option>
          <option value="formatBlock:p">Normal text</option>
          <option value="formatBlock:h2">Heading 2</option>
          <option value="formatBlock:h3">Heading 3</option>
          <option value="formatBlock:blockquote">Quote</option>
          <option value="formatBlock:pre">Code block</option>
        </select>
        {divider}
        {/* Text formatting */}
        <button type="button" title="Bold" onMouseDown={e => e.preventDefault()} onClick={() => exec("bold")} style={{ ...toolBtn, fontWeight: 800 }}>B</button>
        <button type="button" title="Italic" onMouseDown={e => e.preventDefault()} onClick={() => exec("italic")} style={{ ...toolBtn, fontStyle: "italic" }}>I</button>
        <button type="button" title="Underline" onMouseDown={e => e.preventDefault()} onClick={() => exec("underline")} style={{ ...toolBtn, textDecoration: "underline" }}>U</button>
        {divider}
        {/* Lists */}
        <button type="button" title="Bullet list" onMouseDown={e => e.preventDefault()} onClick={() => exec("insertUnorderedList")} style={toolBtn}>•—</button>
        <button type="button" title="Numbered list" onMouseDown={e => e.preventDefault()} onClick={() => exec("insertOrderedList")} style={toolBtn}>1.</button>
        {divider}
        {/* Font size */}
        <select aria-label="Font Size" defaultValue=""
          onMouseDown={e => e.stopPropagation()}
          onChange={e => { if (e.target.value) { exec("fontSize:" + e.target.value); e.target.value = ""; } }}
          style={{ ...toolBtn, minWidth: 72, padding: "3px 6px", border: "1px solid var(--rule)", background: "var(--paper-card)", outline: "none", borderRadius: 5, fontSize: 12 }}
        >
          <option value="" disabled>Size…</option>
          <option value="2">Small</option>
          <option value="3">Normal</option>
          <option value="4">Medium</option>
          <option value="5">Large</option>
          <option value="6">X-Large</option>
        </select>
        {divider}
        {/* Insert */}
        <button type="button" title={imgUploading ? "Uploading…" : "Insert image"} onMouseDown={e => e.preventDefault()} onClick={insertImage}
          disabled={imgUploading} style={{ ...toolBtn, opacity: imgUploading ? 0.5 : 1 }}>🖼️</button>
        <input ref={imgInputRef} type="file" accept="image/*" style={{ display:"none" }} onChange={onImageSelect} />
        <button type="button" title="Insert link" onMouseDown={e => e.preventDefault()} onClick={openLinkDialog} style={toolBtn}>🔗</button>
        {divider}
        {/* Clear */}
        <button type="button" title="Clear formatting" onMouseDown={e => e.preventDefault()} onClick={() => exec("removeFormat")} style={{ ...toolBtn, fontSize: 11 }}>✕ᶠ</button>

        {/* Save status indicator pushed to the right */}
        {saveState && (
          <div style={{ marginLeft: "auto", fontSize: 11, color: saveState === "error" ? "var(--coral)" : "var(--ink-mute)", fontStyle: "italic" }}>
            {saveState === "saving" ? "Saving…" : saveState === "saved" ? "Saved ✓" : "Save failed"}
          </div>
        )}
      </div>

      {/* ── Page canvas backdrop ──────────────────────────────────────────── */}
      <div style={{ flex: 1, overflow: "auto", padding: "28px 24px", background: "var(--paper-deep)" }}>
        {/* White document page */}
        <div style={{
          maxWidth: 800, minHeight: minHeight,
          margin: "0 auto",
          background: "var(--paper-card)",
          borderRadius: 4,
          boxShadow: "0 2px 12px rgba(0,0,0,.10), 0 0 0 1px var(--rule)",
          padding: "48px 56px",
          position: "relative",
          boxSizing: "border-box",
        }}>
          {empty && (
            <div style={{ position:"absolute", top:48, left:56, right:56, pointerEvents:"none", color:"var(--ink-mute)", fontSize:15, lineHeight:1.7 }}>{placeholder}</div>
          )}
          <div ref={ref} contentEditable suppressContentEditableWarning spellCheck
            className="rich-text docs-canvas"
            onInput={emit} onBlur={emit} onPaste={onPaste} onKeyDown={onKeyDown} onClick={onContainerClick}
            style={{
              outline: "none",
              fontSize: 15, lineHeight: 1.75,
              color: "var(--ink)", fontFamily: "var(--font-ui)",
              minHeight: minHeight,
            }}/>
        </div>

        {/* Status bar */}
        <div style={{ maxWidth: 800, margin: "8px auto 0", display: "flex", justifyContent: "flex-end", gap: 16, fontSize: 11, color: "var(--ink-mute)" }}>
          <span>{wordCount} word{wordCount !== 1 ? "s" : ""}</span>
        </div>
      </div>
    </div>
  );
};

// Responsive helper: true when the viewport is at/below `breakpoint` px.
// Lets inline-styled components switch layout on mobile (no CSS media queries).
const useIsMobile = (breakpoint = 820) => {
  const [mobile, setMobile] = React.useState(
    () => typeof window !== "undefined" && window.innerWidth <= breakpoint
  );
  React.useEffect(() => {
    const onResize = () => setMobile(window.innerWidth <= breakpoint);
    onResize();
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, [breakpoint]);
  return mobile;
};

// Mascot — a geometric pet using only simple primitives (circles, rects).
// Variants change accessory + base color; no complex SVG drawing.
const Mascot = ({ size = 88, variant = "default", expression = "happy" }) => {
  const palette = {
    default: { body: "var(--coral)", accent: "var(--gold)" },
    space:   { body: "var(--sky)", accent: "var(--gold)" },
    dino:    { body: "var(--moss)", accent: "var(--coral)" },
    ninja:   { body: "var(--ink)", accent: "var(--cyan)" },
    jungle:  { body: "var(--moss)", accent: "var(--gold)" },
  }[variant] || { body: "var(--coral)", accent: "var(--gold)" };
  const eyeY = expression === "wink" ? 0 : 0;
  return (
    <svg width={size} height={size} viewBox="0 0 100 100" style={{ display: "block" }}>
      {/* shadow */}
      <ellipse cx="50" cy="92" rx="28" ry="3" fill="rgba(0,0,0,0.12)"/>
      {/* body */}
      <rect x="14" y="22" width="72" height="64" rx="22"
            fill={palette.body} stroke="var(--rule-bold)" strokeWidth="2.5"/>
      {/* belly panel */}
      <rect x="28" y="44" width="44" height="34" rx="14"
            fill="var(--paper-card)" stroke="var(--rule-bold)" strokeWidth="2"/>
      {/* eyes */}
      <circle cx="38" cy={56 + eyeY} r="4" fill="var(--ink)"/>
      {expression === "wink"
        ? <path d="M58 56 L66 56" stroke="var(--ink)" strokeWidth="3" strokeLinecap="round"/>
        : <circle cx="62" cy={56 + eyeY} r="4" fill="var(--ink)"/>}
      {/* smile */}
      <path d="M42 68 Q50 74 58 68" stroke="var(--ink)" strokeWidth="2.5" strokeLinecap="round" fill="none"/>
      {/* antenna */}
      <line x1="50" y1="22" x2="50" y2="10" stroke="var(--rule-bold)" strokeWidth="2.5"/>
      <circle cx="50" cy="8" r="4" fill={palette.accent} stroke="var(--rule-bold)" strokeWidth="2"/>
      {/* ears */}
      <rect x="6" y="40" width="10" height="18" rx="5" fill={palette.body} stroke="var(--rule-bold)" strokeWidth="2"/>
      <rect x="84" y="40" width="10" height="18" rx="5" fill={palette.body} stroke="var(--rule-bold)" strokeWidth="2"/>
    </svg>
  );
};

// XP bar with chunky outline
const XPBar = ({ value = 64, max = 100, level = 7 }) => (
  <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
    <div style={{
      display: "grid", placeItems: "center",
      width: 38, height: 38, borderRadius: "50%",
      background: "var(--gold)", border: "1.5px solid var(--rule-bold)",
      boxShadow: "var(--sh-1)", fontWeight: 800, fontSize: 14,
    }}>{level}</div>
    <div style={{ flex: 1 }}>
      <div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, fontWeight: 700, color: "var(--ink-soft)", marginBottom: 4 }}>
        <span>LEVEL {level}</span><span>{value}/{max} XP</span>
      </div>
      <div className="progress"><i style={{ width: `${(value/max)*100}%` }}/></div>
    </div>
  </div>
);

// Section header with eyebrow + title
const SectionHead = ({ eyebrow, title, action }) => (
  <div style={{ display: "flex", alignItems: "end", justifyContent: "space-between", marginBottom: 14 }}>
    <div>
      {eyebrow && <div style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--ink-mute)", textTransform: "uppercase", letterSpacing: ".08em", marginBottom: 4 }}>{eyebrow}</div>}
      <h3 className="display" style={{ fontSize: 22, margin: 0 }}>{title}</h3>
    </div>
    {action}
  </div>
);

// Theme-scoped wrapper for any artboard
const Themed = ({ theme = "", className = "", children, style }) => (
  <div className={`codeland ${theme ? "theme-" + theme : ""} ${className}`} style={style}>
    {children}
  </div>
);

// Small avatar — initial inside a circle
const Avatar = ({ name = "AB", color = "var(--coral)", size = 32 }) => (
  <div style={{
    width: size, height: size, borderRadius: "50%",
    background: color, color: "white",
    border: "1.5px solid var(--rule-bold)",
    display: "grid", placeItems: "center",
    fontFamily: "var(--font-display)", fontWeight: 700, fontSize: size * 0.42,
    flexShrink: 0,
  }}>{name}</div>
);

// Mini stat card
const Stat = ({ icon, label, value, sub, accent = "var(--coral)" }) => (
  <div className="card" style={{ padding: 14 }}>
    <div style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--ink-soft)", fontSize: 12, fontWeight: 600 }}>
      <span style={{ color: accent }}>{icon}</span>{label}
    </div>
    <div className="display" style={{ fontSize: 28, marginTop: 6 }}>{value}</div>
    {sub && <div style={{ fontSize: 12, color: "var(--ink-mute)", marginTop: 2 }}>{sub}</div>}
  </div>
);

// Tab pills
const Tabs = ({ items, active, onChange }) => (
  <div style={{
    display: "inline-flex", padding: 4, background: "var(--paper-deep)",
    borderRadius: "var(--r-pill)", border: "var(--border)",
    boxShadow: "var(--sh-1)",
  }}>
    {items.map(it => (
      <button key={it} onClick={() => onChange?.(it)}
        style={{
          padding: "6px 14px", borderRadius: "var(--r-pill)",
          border: "none", background: it === active ? "var(--ink)" : "transparent",
          color: it === active ? "var(--paper-card)" : "var(--ink-soft)",
          fontWeight: 700, fontSize: 13, fontFamily: "var(--font-ui)", cursor: "pointer",
        }}>{it}</button>
    ))}
  </div>
);

// Attendance history card — shared by the student and parent dashboards. Fetches
// /students/:id/attendance (summary + past records) and renders a rate + list.
const ATT_STATUS = {
  present:    { label: "Present",    color: "var(--moss)" },
  late:       { label: "Late",       color: "var(--gold)" },
  missed:     { label: "Missed",     color: "var(--coral)" },
  absent:     { label: "Absent",     color: "var(--coral)" },
  tech_issue: { label: "Tech issue", color: "var(--sky)" },
  pending:    { label: "Pending",    color: "var(--ink-mute)" },
};
const AttendanceHistory = ({ studentId, title = "Attendance history", limit = 8 }) => {
  const { data, loading } = useApi(studentId ? `/students/${studentId}/attendance` : null, { summary: {}, records: [] });
  const summary = data.summary || {};
  const records = (data.records || []).slice(0, limit);
  const fmtDay = d => { try { return new Date(d + "T00:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "short" }); } catch { return d; } };
  return (
    <div className="card-flat" style={{ padding: 16 }}>
      <SectionHead eyebrow="CLASSES" title={title}/>
      {loading ? (
        <div style={{ fontSize: 12, color: "var(--ink-mute)", padding: "8px 0" }}>Loading…</div>
      ) : (
        <>
          <div style={{ display: "flex", alignItems: "baseline", gap: 8, margin: "6px 0 12px" }}>
            <span style={{ fontSize: 28, fontWeight: 800, color: "var(--moss)" }}>{summary.rate || 0}%</span>
            <span style={{ fontSize: 12, color: "var(--ink-mute)" }}>
              {(summary.present || 0) + (summary.late || 0)}/{(summary.present || 0) + (summary.late || 0) + (summary.missed || 0) + (summary.absent || 0) + (summary.tech_issue || 0)} attended
            </span>
          </div>
          {records.length === 0 ? (
            <div style={{ fontSize: 12, color: "var(--ink-mute)" }}>No past classes yet.</div>
          ) : records.map(r => {
            const st = ATT_STATUS[r.status] || ATT_STATUS.pending;
            return (
              <div key={r.session_id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0", borderTop: "var(--border-thin)" }}>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontSize: 13, fontWeight: 600 }}>{r.course}</div>
                  <div style={{ fontSize: 11, color: "var(--ink-mute)" }}>{fmtDay(r.day)} · {r.time}{r.tz ? " " + (window.tzAbbr ? tzAbbr(r.tz) : "") : ""}</div>
                </div>
                <span style={{ fontSize: 11, fontWeight: 700, padding: "2px 9px", borderRadius: 999, background: st.color, color: "white" }}>{st.label}</span>
              </div>
            );
          })}
        </>
      )}
    </div>
  );
};

// ── In-app file viewer ────────────────────────────────────────────────────────
// One modal for previewing a stored course file without leaving the app. PDFs,
// images and plain text render inline; Office formats (ppt/doc/xls) can't be
// rendered by browsers and have no public URL for an online viewer, so they get
// a clean download action instead. Fixes the old approach of window.open() after
// an await, which browsers block as a popup (only same-origin images survived).
//
// Props: courseId, fileId, title, fileType, kind ("course" | "library"), onClose.
const FILE_TYPE_ICON = { pdf:"📄", ppt:"📊", pptx:"📊", doc:"📝", docx:"📝", xls:"📈", xlsx:"📈", txt:"📝", zip:"🗜️" };
const FileViewer = ({ courseId, fileId, title = "File", fileType = "", kind = "course", onClose }) => {
  const [st, setSt] = React.useState({ loading: true });
  React.useEffect(() => {
    let alive = true, objUrl = null;
    (async () => {
      try {
        const path = kind === "library"
          ? `/library-assets/${courseId}/${encodeURIComponent(fileId)}/data`
          : `/course-files/${courseId}/${encodeURIComponent(fileId)}/data`;
        const r = await apiFetch(path);
        if (!r.ok) { if (alive) setSt({ loading: false, error: true }); return; }
        const d = await r.json();
        const type = String(d.file_type || fileType || "").toLowerCase().replace(/^\./, "");
        const bin = atob(d.file_data || ""), bytes = new Uint8Array(bin.length);
        for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
        const blob = new Blob([bytes], { type: fileMimeType(type) });
        objUrl = URL.createObjectURL(blob);
        let text = null;
        if (type === "txt") { try { text = await blob.text(); } catch { text = ""; } }
        if (alive) setSt({ loading: false, url: objUrl, type, text, title: d.title || title });
      } catch { if (alive) setSt({ loading: false, error: true }); }
    })();
    return () => { alive = false; if (objUrl) setTimeout(() => URL.revokeObjectURL(objUrl), 100); };
  }, [courseId, fileId, kind]);

  const type = st.type || String(fileType || "").toLowerCase().replace(/^\./, "");
  const isImg = IMAGE_EXTS.includes(type);
  const isPdf = type === "pdf";
  const isTxt = type === "txt";
  const canPreview = isImg || isPdf || isTxt;
  // Learners get view-only access to anything we can render in-app — no download
  // of PDFs, images or text. Formats the browser can't preview (ppt/doc/xls)
  // keep the download action, otherwise those materials become unreachable.
  const viewOnly = isLearnerRole() && canPreview;
  const download = () => {
    if (!st.url || viewOnly) return;
    const a = document.createElement("a");
    a.href = st.url; a.download = st.title || title || "file"; a.click();
  };

  // Full screen: the native API on the shell, falling back to a maximised layout
  // when the browser refuses (older Safari, embedded webviews).
  const shellRef = React.useRef(null);
  const [full, setFull] = React.useState(false);
  React.useEffect(() => {
    const onFsChange = () => setFull(!!document.fullscreenElement);
    const onKey = (e) => { if (e.key === "Escape" && !document.fullscreenElement) onClose && onClose(); };
    document.addEventListener("fullscreenchange", onFsChange);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("fullscreenchange", onFsChange);
      document.removeEventListener("keydown", onKey);
      if (document.fullscreenElement) document.exitFullscreen?.();
    };
  }, [onClose]);
  const toggleFull = () => {
    const el = shellRef.current;
    if (!el) return;
    if (document.fullscreenElement) { document.exitFullscreen?.(); return; }
    const req = el.requestFullscreen?.();
    if (req && req.catch) req.catch(() => setFull(true));   // no native support → maximise
    else if (!el.requestFullscreen) setFull(true);
  };

  // Chrome/Edge honour these PDF fragment params and drop their own toolbar,
  // which is where the built-in download and print buttons live.
  const pdfSrc = st.url ? `${st.url}#toolbar=${viewOnly ? 0 : 1}&navpanes=0&statusbar=0` : "";
  const mediaH = full ? "calc(100vh - 52px)" : "78vh";

  return (
    <div onClick={e => e.target === e.currentTarget && onClose && onClose()}
      style={{ position:"fixed", inset:0, background:"rgba(0,0,0,.72)", display:"flex", alignItems:"center", justifyContent:"center", zIndex:9999, padding: full ? 0 : 24 }}>
      <div ref={shellRef} className="codeland"
        onContextMenu={e => { if (viewOnly) e.preventDefault(); }}
        style={{ background:"var(--paper-card)", borderRadius: full ? 0 : 16, overflow:"hidden",
          width: full ? "100vw" : canPreview ? "min(1000px,94vw)" : "min(440px,94vw)",
          height: full ? "100vh" : "auto", maxHeight: full ? "100vh" : "92vh",
          display:"flex", flexDirection:"column", boxShadow: full ? "none" : "0 32px 80px rgba(0,0,0,.5)" }}>
        <div style={{ display:"flex", alignItems:"center", gap:12, padding:"12px 18px", borderBottom:"var(--border-thin)", background:"var(--paper-deep)", flexShrink:0 }}>
          <span style={{ fontSize:20 }}>{FILE_TYPE_ICON[type] || (isImg ? "🖼️" : "📁")}</span>
          <span style={{ fontWeight:700, fontSize:14, color:"var(--ink)", flex:1, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{st.title || title}</span>
          {type && <span style={{ fontSize:10, fontWeight:700, textTransform:"uppercase", padding:"2px 8px", borderRadius:999, background:"var(--sky-deep)", color:"white" }}>{type}</span>}
          {viewOnly && <span title="This material is view-only" style={{ fontSize:10, fontWeight:700, padding:"2px 8px", borderRadius:999, background:"var(--paper-card)", color:"var(--ink-mute)", border:"var(--border-thin)" }}>view only</span>}
          {canPreview && (
            <button onClick={toggleFull} className="btn btn-ghost" style={{ padding:"5px 12px", fontSize:12 }} title={full ? "Exit full screen" : "Full screen"}>
              {full ? "⤡ Exit full screen" : "⛶ Full screen"}
            </button>
          )}
          {st.url && !viewOnly && <button onClick={download} className="btn btn-ghost" style={{ padding:"5px 12px", fontSize:12 }}>{I.download ? I.download({ size:13 }) : "⬇"} Download</button>}
          <button onClick={() => onClose && onClose()} style={{ background:"none", border:"none", color:"var(--ink-mute)", fontSize:24, cursor:"pointer", lineHeight:1 }}>×</button>
        </div>
        <div style={{ flex:1, minHeight:0, overflow:"auto", background:"var(--paper-deep)", display:"flex", alignItems:"center", justifyContent:"center" }}>
          {st.loading ? (
            <div style={{ padding:60 }}><Spinner/></div>
          ) : st.error ? (
            <div style={{ padding:60, textAlign:"center", color:"var(--coral)", fontSize:14, fontWeight:600 }}>Could not open this file.</div>
          ) : isPdf ? (
            <iframe src={pdfSrc} title={st.title} style={{ width:"100%", height:mediaH, border:"none", background:"#fff" }}/>
          ) : isImg ? (
            <img src={st.url} alt={st.title} draggable={!viewOnly}
              style={{ maxWidth:"100%", maxHeight:mediaH, objectFit:"contain", display:"block", userSelect: viewOnly ? "none" : "auto" }}/>
          ) : isTxt ? (
            <pre style={{ width:"100%", maxHeight:mediaH, overflow:"auto", margin:0, padding:"18px 22px", fontFamily:"var(--font-mono, monospace)", fontSize:13, lineHeight:1.6, color:"var(--ink)", whiteSpace:"pre-wrap", wordBreak:"break-word", textAlign:"left", boxSizing:"border-box" }}>{st.text}</pre>
          ) : (
            <div style={{ padding:"48px 32px", textAlign:"center" }}>
              <div style={{ fontSize:52, marginBottom:14 }}>{FILE_TYPE_ICON[type] || "📁"}</div>
              <div style={{ fontWeight:700, fontSize:15, color:"var(--ink)", marginBottom:6 }}>Preview not available for {type ? type.toUpperCase() : "this format"}</div>
              <div style={{ fontSize:13, color:"var(--ink-mute)", marginBottom:20, maxWidth:300, marginLeft:"auto", marginRight:"auto" }}>
                Office documents can't be shown in the browser. Download the file to open it.
              </div>
              <button onClick={download} className="btn btn-primary" style={{ padding:"10px 22px", fontSize:14 }}>Download file</button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

// ── Universal IANA Timezone Selector with Search ──────────────────────────────
const TZ_SEARCH_ALIASES = {
  "Asia/Kolkata": "India Bharat Calcutta Mumbai Delhi Bangalore IST",
  "Asia/Dubai": "United Arab Emirates UAE Gulf GST Abu Dhabi",
  "Europe/London": "United Kingdom UK Great Britain England Britain GMT BST",
  "America/New_York": "United States USA US Eastern ET EST EDT New York NY NYC",
  "America/Chicago": "United States USA US Central CT CST CDT Chicago IL",
  "America/Denver": "United States USA US Mountain MT MST MDT Denver CO",
  "America/Los_Angeles": "United States USA US Pacific PT PST PDT California CA LA Los Angeles",
  "America/Phoenix": "United States USA US Arizona Mountain MST Phoenix AZ",
  "America/Anchorage": "United States USA US Alaska AKST AKDT Anchorage AK",
  "America/Honolulu": "United States USA US Hawaii HST Honolulu HI",
  "America/Toronto": "Canada Eastern ET Toronto ON",
  "America/Vancouver": "Canada Pacific PT Vancouver BC",
  "Australia/Sydney": "Australia AEST AEDT Sydney NSW",
  "Australia/Melbourne": "Australia AEST AEDT Melbourne VIC",
  "Australia/Perth": "Australia AWST Perth WA",
  "Australia/Brisbane": "Australia AEST Brisbane QLD",
  "Australia/Adelaide": "Australia ACST ACDT Adelaide SA",
  "Asia/Tokyo": "Japan JST Tokyo",
  "Asia/Singapore": "Singapore SGT",
  "Asia/Hong_Kong": "Hong Kong HKT",
  "Asia/Taipei": "Taiwan CST Taipei",
  "Asia/Seoul": "South Korea Korea KST Seoul",
  "Europe/Paris": "France CET CEST Paris",
  "Europe/Berlin": "Germany CET CEST Berlin",
  "Europe/Rome": "Italy CET CEST Rome",
  "Europe/Madrid": "Spain CET CEST Madrid",
  "Europe/Amsterdam": "Netherlands Holland CET CEST Amsterdam",
  "Europe/Dublin": "Ireland GMT IST Dublin",
  "Europe/Zurich": "Switzerland CET CEST Zurich",
  "Europe/Stockholm": "Sweden CET CEST Stockholm",
  "Europe/Oslo": "Norway CET CEST Oslo",
  "Europe/Copenhagen": "Denmark CET CEST Copenhagen",
  "Europe/Helsinki": "Finland EET EEST Helsinki",
  "Europe/Vienna": "Austria CET CEST Vienna",
  "Europe/Brussels": "Belgium CET CEST Brussels",
  "Europe/Warsaw": "Poland CET CEST Warsaw",
  "Europe/Athens": "Greece EET EEST Athens",
  "Europe/Istanbul": "Turkey TRT Istanbul",
  "Europe/Kyiv": "Ukraine EET EEST Kyiv",
  "Europe/Moscow": "Russia MSK Moscow",
  "Asia/Karachi": "Pakistan PKT Karachi Islamabad",
  "Asia/Dhaka": "Bangladesh BST Dhaka",
  "Asia/Colombo": "Sri Lanka SLST Colombo",
  "Asia/Kathmandu": "Nepal NPT Kathmandu",
  "Asia/Riyadh": "Saudi Arabia AST Riyadh",
  "Asia/Qatar": "Qatar AST Doha",
  "Asia/Kuwait": "Kuwait AST Kuwait City",
  "Asia/Muscat": "Oman GST Muscat",
  "Asia/Bahrain": "Bahrain AST Manama",
  "Asia/Bangkok": "Thailand ICT Bangkok",
  "Asia/Jakarta": "Indonesia WIB Jakarta",
  "Asia/Kuala_Lumpur": "Malaysia MYT Kuala Lumpur",
  "Asia/Manila": "Philippines PHT Manila",
  "Pacific/Auckland": "New Zealand NZ NZST NZDT Auckland",
  "Pacific/Fiji": "Fiji FJT Suva",
  "Africa/Cairo": "Egypt EET Cairo",
  "Africa/Johannesburg": "South Africa SAST Johannesburg",
  "Africa/Lagos": "Nigeria WAT Lagos",
  "Africa/Nairobi": "Kenya EAT Nairobi",
  "America/Sao_Paulo": "Brazil BRT Sao Paulo",
  "America/Mexico_City": "Mexico CST Mexico City",
  "America/Bogota": "Colombia COT Bogota",
  "America/Lima": "Peru PET Lima",
  "America/Santiago": "Chile CLT CLST Santiago",
  "America/Buenos_Aires": "Argentina ART Buenos Aires",
  "UTC": "UTC GMT Coordinated Universal Time",
};

let _cachedTzList = null;

function getTzOffsetLabel(tz, date) {
  const d = date || new Date();
  try {
    const dtf = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "shortOffset" });
    const part = dtf.formatToParts(d).find(p => p.type === "timeZoneName")?.value;
    if (part) return part.replace(/^GMT/, "UTC");
  } catch (e) {}
  try {
    const dtf = new Intl.DateTimeFormat("en-US", {
      timeZone: tz, hour12: false,
      year: "numeric", month: "2-digit", day: "2-digit",
      hour: "2-digit", minute: "2-digit", second: "2-digit"
    });
    const p = dtf.formatToParts(d).reduce((a, x) => ((a[x.type] = x.value), a), {});
    const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, (+p.hour) % 24, +p.minute, +p.second);
    const ms = asUTC - d.getTime();
    const totalMin = Math.round(ms / 60000);
    const sign = totalMin >= 0 ? "+" : "-";
    const absMin = Math.abs(totalMin);
    const h = Math.floor(absMin / 60);
    const m = absMin % 60;
    return `UTC${sign}${h}${m ? `:${String(m).padStart(2, "0")}` : ""}`;
  } catch (e) {
    return "UTC";
  }
}

function getAllTimezones() {
  if (_cachedTzList) return _cachedTzList;
  let rawZones = [];
  try {
    if (typeof Intl !== "undefined" && typeof Intl.supportedValuesOf === "function") {
      rawZones = Intl.supportedValuesOf("timeZone");
    }
  } catch (e) {}
  if (!rawZones || !rawZones.length) {
    rawZones = [
      "Asia/Kolkata", "America/New_York", "America/Chicago",
      "America/Denver", "America/Los_Angeles", "Europe/London", "Asia/Dubai", "UTC"
    ];
  }
  const set = new Set();
  rawZones.forEach(z => {
    if (z === "Asia/Calcutta") set.add("Asia/Kolkata");
    else set.add(z);
  });
  set.add("Asia/Kolkata");
  set.add("UTC");

  const now = new Date();
  _cachedTzList = Array.from(set).map(tz => {
    const offset = getTzOffsetLabel(tz, now);
    const label = `${tz} (${offset})`;
    const cleanCity = tz.split("/").slice(1).join(" ").replace(/_/g, " ");
    const region = tz.split("/")[0] || "";
    const aliases = TZ_SEARCH_ALIASES[tz] || "";
    const searchTokens = [tz, cleanCity, region, aliases, offset, label].join(" ").toLowerCase();
    return { tz, offset, label, cleanCity, aliases, searchTokens };
  });
  return _cachedTzList;
}

function rankTimezoneMatches(list, query) {
  const q = String(query || "").trim().toLowerCase();
  if (!q) return list;
  const scored = [];
  for (const item of list) {
    const tzLower = item.tz.toLowerCase();
    const cityLower = item.cleanCity.toLowerCase();
    const cityWords = cityLower.split(/[\s_/]+/).filter(Boolean);
    const aliasesLower = (item.aliases || "").toLowerCase();
    const aliasWords = aliasesLower.split(/\s+/).filter(Boolean);
    const segments = tzLower.split("/").map(s => s.replace(/_/g, " ").toLowerCase());
    const lastSegment = segments[segments.length - 1] || "";
    let score = 0;

    // 1. Exact canonical matches
    if (tzLower === q || cityLower === q || lastSegment === q) {
      score = 100;
    }
    // 2. Exact alias word or exact multi-word alias phrase (e.g. "india", "uk", "united kingdom")
    else if (aliasWords.includes(q) || (aliasesLower.includes(q) && q.includes(" "))) {
      score = 90;
    }
    // 3. Exact city word (e.g. "york" in "New York")
    else if (cityWords.includes(q)) {
      score = 85;
    }
    // 4. Country / alias word prefix match (e.g. "ind" -> "India" in Asia/Kolkata)
    // Primary country/region aliases take precedence over county/state sub-zones
    else if (aliasWords.some(w => w.startsWith(q))) {
      const matchingAliases = aliasWords.filter(w => w.startsWith(q));
      const shortest = Math.min(...matchingAliases.map(w => w.length));
      score = 80 - Math.min(shortest - q.length, 5);
    }
    // 5. City word prefix match
    else if (cityWords.some(w => w.startsWith(q))) {
      score = 70;
    } else if (lastSegment.startsWith(q)) {
      score = 65;
    }
    // 5. Weaker segment prefix or general substring
    else if (segments.some(s => s.startsWith(q))) {
      score = 35;
    } else if (cityLower.includes(q)) {
      score = 25;
    } else if (item.label.toLowerCase().includes(q) || item.searchTokens.includes(q)) {
      score = 15;
    }

    if (score > 0) {
      scored.push({ item, score });
    }
  }

  if (!scored.length) return [];

  // When strong exact matches exist (score >= 85, such as an exact country alias
  // or exact city/timezone match), exclude weak prefix and substring matches (< 80)
  // so queries like 'india' only surface Asia/Kolkata and do not get diluted by
  // America/Indiana/* or Indian/* ocean territories.
  const maxScore = Math.max(...scored.map(s => s.score));
  const filtered = (maxScore >= 85)
    ? scored.filter(s => s.score >= 80)
    : (maxScore >= 65)
      ? scored.filter(s => s.score >= 50)
      : scored;

  filtered.sort((a, b) => b.score - a.score || a.item.label.localeCompare(b.item.label));
  return filtered.map(s => s.item);
}

const TimezoneSelect = ({
  value = "Asia/Kolkata",
  onChange,
  style = {},
  triggerStyle = {},
  id = "timezone-select",
  required = false,
  disabled = false,
}) => {
  const [open, setOpen] = React.useState(false);
  const [search, setSearch] = React.useState("");
  const containerRef = React.useRef(null);
  const searchInputRef = React.useRef(null);

  const allTimezones = React.useMemo(() => getAllTimezones(), []);

  const selectedItem = React.useMemo(() => {
    const val = value || "Asia/Kolkata";
    const found = allTimezones.find(t => t.tz === val);
    if (found) return found;
    const offset = getTzOffsetLabel(val);
    return { tz: val, offset, label: `${val} (${offset})`, cleanCity: val, aliases: "", searchTokens: val.toLowerCase() };
  }, [allTimezones, value]);

  const filtered = React.useMemo(() => {
    return rankTimezoneMatches(allTimezones, search);
  }, [allTimezones, search]);

  React.useEffect(() => {
    if (open) {
      setTimeout(() => {
        if (searchInputRef.current) searchInputRef.current.focus();
      }, 50);
    } else {
      setSearch("");
    }
  }, [open]);

  React.useEffect(() => {
    const onDocClick = (e) => {
      if (containerRef.current && !containerRef.current.contains(e.target)) {
        setOpen(false);
      }
    };
    const onKey = (e) => {
      if (e.key === "Escape") setOpen(false);
    };
    document.addEventListener("mousedown", onDocClick);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDocClick);
      document.removeEventListener("keydown", onKey);
    };
  }, []);

  const handleSelect = (tz) => {
    if (onChange) {
      onChange({ target: { value: tz, name: id } });
    }
    setOpen(false);
  };

  return (
    <div ref={containerRef} style={{ position: "relative", width: "100%", ...style }}>
      <input type="hidden" id={id} name={id} value={selectedItem.tz} required={required} />
      <button
        type="button"
        id={`${id}-trigger`}
        data-testid="timezone-select-trigger"
        disabled={disabled}
        onClick={() => setOpen(prev => !prev)}
        style={{
          width: "100%",
          padding: "8px 12px",
          border: open ? "1.5px solid var(--sky, #0284c7)" : "var(--border-thin)",
          borderRadius: 8,
          background: "var(--paper-deep)",
          color: "var(--ink)",
          fontSize: 13,
          fontFamily: "var(--font-ui)",
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          gap: 8,
          cursor: disabled ? "not-allowed" : "pointer",
          textAlign: "left",
          boxSizing: "border-box",
          outline: "none",
          transition: "border-color .15s ease",
          ...triggerStyle,
        }}
        title={selectedItem.label}
      >
        <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>
          {selectedItem.label}
        </span>
        <span style={{ fontSize: 10, color: "var(--ink-mute)", transform: open ? "rotate(180deg)" : "none", transition: "transform .15s" }}>
          ▼
        </span>
      </button>

      {open && (
        <div
          data-testid="timezone-dropdown-menu"
          style={{
            position: "absolute",
            top: "calc(100% + 4px)",
            left: 0,
            right: 0,
            zIndex: 1000,
            background: "var(--paper-card)",
            border: "var(--border-thin)",
            borderRadius: 8,
            boxShadow: "0 10px 28px rgba(0,0,0,0.18)",
            padding: 8,
            boxSizing: "border-box",
            display: "flex",
            flexDirection: "column",
            gap: 6,
          }}
        >
          <div style={{ position: "relative", display: "flex", alignItems: "center" }}>
            <span style={{ position: "absolute", left: 10, fontSize: 12, color: "var(--ink-mute)", pointerEvents: "none" }}>
              🔍
            </span>
            <input
              ref={searchInputRef}
              type="text"
              id="timezone-search-input"
              data-testid="timezone-search-input"
              placeholder="Search city, country, or zone (e.g. India, London, NY)..."
              value={search}
              onChange={e => setSearch(e.target.value)}
              style={{
                width: "100%",
                padding: "7px 30px 7px 28px",
                border: "var(--border-thin)",
                borderRadius: 6,
                background: "var(--paper-deep)",
                color: "var(--ink)",
                fontSize: 12.5,
                fontFamily: "var(--font-ui)",
                outline: "none",
                boxSizing: "border-box",
              }}
            />
            {search && (
              <button
                type="button"
                onClick={() => setSearch("")}
                style={{
                  position: "absolute",
                  right: 8,
                  background: "none",
                  border: "none",
                  color: "var(--ink-mute)",
                  fontSize: 12,
                  cursor: "pointer",
                  padding: 2,
                }}
              >
                ✕
              </button>
            )}
          </div>

          <div style={{ fontSize: 11, color: "var(--ink-mute)", padding: "0 4px" }}>
            {filtered.length} {filtered.length === 1 ? "timezone" : "timezones"}{search ? ` for "${search}"` : ""}
          </div>

          <div
            data-testid="timezone-options-list"
            style={{
              maxHeight: 220,
              overflowY: "auto",
              display: "flex",
              flexDirection: "column",
              gap: 2,
            }}
          >
            {filtered.length === 0 ? (
              <div style={{ padding: "18px 12px", textAlign: "center", color: "var(--ink-mute)", fontSize: 12.5 }}>
                No timezones found for "{search}"
              </div>
            ) : (
              filtered.map(item => {
                const isSelected = item.tz === selectedItem.tz;
                return (
                  <button
                    key={item.tz}
                    type="button"
                    data-testid="timezone-option"
                    data-tz={item.tz}
                    onClick={() => handleSelect(item.tz)}
                    style={{
                      width: "100%",
                      padding: "7px 10px",
                      border: "none",
                      borderRadius: 6,
                      background: isSelected ? "var(--paper-deep)" : "transparent",
                      color: "var(--ink)",
                      fontWeight: isSelected ? 700 : 400,
                      fontSize: 12.5,
                      fontFamily: "var(--font-ui)",
                      display: "flex",
                      alignItems: "center",
                      justifyContent: "space-between",
                      gap: 8,
                      cursor: "pointer",
                      textAlign: "left",
                      boxSizing: "border-box",
                      transition: "background .1s ease",
                    }}
                    onMouseEnter={e => {
                      if (!isSelected) e.currentTarget.style.background = "var(--paper-deep)";
                    }}
                    onMouseLeave={e => {
                      if (!isSelected) e.currentTarget.style.background = "transparent";
                    }}
                  >
                    <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                      {item.label}
                    </span>
                    {isSelected && (
                      <span style={{ fontSize: 13, color: "var(--moss)", fontWeight: 700 }}>✓</span>
                    )}
                  </button>
                );
              })
            )}
          </div>
        </div>
      )}
    </div>
  );
};

Object.assign(window, {
  Mascot, XPBar, SectionHead, Themed, Avatar, Stat, Tabs, AttendanceHistory, useIsMobile,
  fileMimeType, FileViewer, SafeYouTube, RichTextView, RichTextEditor, stripHtml, youtubeId, isLearnerRole,
  TimezoneSelect, getAllTimezones, getTzOffsetLabel, rankTimezoneMatches,
});
