/* Jovana Medical — main app */
const { useState, useEffect, useMemo, useRef, useCallback } = React;

function classNames(...arr) { return arr.filter(Boolean).join(' '); }

/* ---------------- Auth (Google + email magic-link, server-backed) ---------------- */

function getGoogleClientId() {
  return (window.JOVANA_CONFIG && window.JOVANA_CONFIG.googleClientId) || '';
}

async function api(path, opts = {}) {
  const init = { credentials: 'include', ...opts };
  init.headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
  if (init.body && typeof init.body !== 'string') init.body = JSON.stringify(init.body);
  const res = await fetch(path, init);
  let data = null;
  try { data = await res.json(); } catch {}
  if (!res.ok) {
    const err = new Error((data && data.error) || `http_${res.status}`);
    err.status = res.status;
    err.data = data;
    throw err;
  }
  return data;
}

function useAuth() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  const refresh = useCallback(async () => {
    try {
      const data = await api('/api/me');
      setUser(data.user || null);
      return data.user || null;
    } catch {
      setUser(null);
      return null;
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { refresh(); }, [refresh]);

  // Also fetch the public client config (Google Client ID, etc.) once and
  // merge into window.JOVANA_CONFIG. Keeping the server env var as the
  // single source of truth so config.js can stay empty.
  useEffect(() => {
    let cancelled = false;
    api('/api/config')
      .then(cfg => {
        if (cancelled) return;
        window.JOVANA_CONFIG = { ...(window.JOVANA_CONFIG || {}), ...cfg };
        window.dispatchEvent(new CustomEvent('jovana:config'));
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, []);

  const signOut = useCallback(async () => {
    try { await api('/api/auth/signout', { method: 'POST' }); } catch {}
    setUser(null);
    if (window.google && window.google.accounts && window.google.accounts.id) {
      try { window.google.accounts.id.disableAutoSelect(); } catch (e) {}
    }
  }, []);

  return { user, loading, refresh, signOut, setUser };
}

function SignInGate({ onSignedIn }) {
  const btnRef = useRef(null);
  const [clientId, setClientId] = useState(getGoogleClientId());

  // Pick up the Client ID once /api/config resolves and writes it onto window.
  useEffect(() => {
    if (clientId) return;
    const onConfig = () => setClientId(getGoogleClientId());
    window.addEventListener('jovana:config', onConfig);
    onConfig(); // in case the event already fired before we subscribed
    return () => window.removeEventListener('jovana:config', onConfig);
  }, [clientId]);

  // step: 'email' | 'code'
  const [step, setStep] = useState('email');
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');
  const [code, setCode] = useState('');
  const [error, setError] = useState('');
  const [busy, setBusy] = useState(false);
  const [devCode, setDevCode] = useState('');

  useEffect(() => {
    if (step !== 'email' || !clientId) return;
    let cancelled = false;
    let attempts = 0;
    const tryInit = () => {
      if (cancelled) return;
      const gsi = window.google && window.google.accounts && window.google.accounts.id;
      if (!gsi) {
        if (attempts++ < 50) setTimeout(tryInit, 150);
        return;
      }
      gsi.initialize({
        client_id: clientId,
        callback: async (res) => {
          if (!res || !res.credential) return;
          try {
            const data = await api('/api/auth/google', { method: 'POST', body: { idToken: res.credential } });
            onSignedIn(data.user);
          } catch (e) {
            setError('Google sign-in failed: ' + (e.message || ''));
          }
        },
        ux_mode: 'popup',
        auto_select: false,
        itp_support: true,
      });
      if (btnRef.current) {
        gsi.renderButton(btnRef.current, {
          type: 'standard',
          theme: 'filled_black',
          size: 'large',
          shape: 'rectangular',
          text: 'continue_with',
          logo_alignment: 'left',
          width: 360,
        });
      }
      gsi.prompt();
    };
    tryInit();
    return () => { cancelled = true; };
  }, [clientId, onSignedIn, step]);

  const submitEmail = async (e) => {
    e.preventDefault();
    if (busy) return;
    const value = email.trim().toLowerCase();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      setError('請輸入有效的電子郵件 · Please enter a valid email.');
      return;
    }
    if (!name.trim()) {
      setError('請輸入顯示名稱 · Please enter a display name.');
      return;
    }
    setError('');
    setBusy(true);
    try {
      const data = await api('/api/auth/email/start', { method: 'POST', body: { email: value } });
      setEmail(value);
      setDevCode(data.devCode || '');
      setStep('code');
    } catch (e) {
      if (e.status === 429) setError('請求過於頻繁，請稍後再試 · Too many requests, try again later.');
      else setError(e.message || '寄送失敗 · Failed to send code.');
    } finally {
      setBusy(false);
    }
  };

  const submitCode = async (e) => {
    e.preventDefault();
    if (busy) return;
    const cleanCode = code.replace(/\s+/g, '');
    if (!/^\d{6}$/.test(cleanCode)) {
      setError('請輸入 6 位數驗證碼 · Enter the 6-digit code.');
      return;
    }
    setError('');
    setBusy(true);
    try {
      const data = await api('/api/auth/email/verify', {
        method: 'POST',
        body: { email, code: cleanCode, name: name.trim() || undefined },
      });
      onSignedIn(data.user);
    } catch (e) {
      if (e.status === 401) setError('驗證碼錯誤 · Incorrect code.');
      else if (e.status === 429) setError('嘗試次數過多 · Too many attempts.');
      else if (e.message === 'code_expired') setError('驗證碼已過期 · Code expired.');
      else if (e.message === 'no_active_code') setError('找不到有效驗證碼，請重新請求 · No active code, request a new one.');
      else setError(e.message || '驗證失敗 · Verification failed.');
    } finally {
      setBusy(false);
    }
  };

  const resend = async () => {
    if (busy) return;
    setError('');
    setBusy(true);
    try {
      const data = await api('/api/auth/email/start', { method: 'POST', body: { email } });
      setDevCode(data.devCode || '');
    } catch (e) {
      if (e.status === 429) setError('請求過於頻繁，請稍後再試 · Too many requests, try again later.');
      else setError(e.message || '');
    } finally { setBusy(false); }
  };

  return (
    <div className="sign-in-gate">
      <div className="gate-stage">
        <div className="gate-aura"></div>
        <div className="gate-grain"></div>
      </div>
      <div className="gate-card">
        <div className="gate-brand">
          <div className="gate-mark"><img src="assets/jovana-logo.png" alt="Jovana Medical" /></div>
          <div className="gate-brand-text">
            <div className="b1">JOVANA</div>
            <div className="b2">MEDICAL · CLINICAL ARCHIVE</div>
          </div>
        </div>
        <h1 className="gate-headline">
          <span>Members</span>
          <span><em className="grad">only.</em></span>
        </h1>
        <p className="gate-lede">
          完整 80 題詳解、官方修訂答案與延伸學習引用對註冊會員開放。
          以 Google 帳號或電子郵件快速進入。
        </p>

        <div className="gate-auth-box">
          {step === 'email' && (
            <>
              {clientId ? (
                <div ref={btnRef} className="gate-google-btn"></div>
              ) : (
                <button type="button" className="gate-google-fallback" disabled
                  title="config.js 尚未設定 Google OAuth Client ID">
                  <GoogleGlyph /><span>Continue with Google</span>
                </button>
              )}
              <div className="gate-or"><span>OR</span></div>
              <form onSubmit={submitEmail} noValidate>
                <input
                  type="email"
                  className="gate-email-input"
                  autoComplete="email"
                  value={email}
                  onChange={e => setEmail(e.target.value)}
                  placeholder="Enter your email"
                  required
                  autoFocus
                />
                <input
                  type="text"
                  className="gate-email-input"
                  autoComplete="name"
                  value={name}
                  onChange={e => setName(e.target.value)}
                  placeholder="Display name"
                  required
                />
                {error && <div className="gate-form-error" role="alert">{error}</div>}
                <button type="submit" className="gate-email-submit" disabled={busy}>
                  {busy ? 'Sending…' : 'Continue with email'}
                </button>
              </form>
            </>
          )}

          {step === 'code' && (
            <form onSubmit={submitCode} noValidate className="gate-step-form">
              <div className="gate-step-head">
                <button type="button" className="gate-back" onClick={() => { setStep('email'); setCode(''); setError(''); }} aria-label="Back">←</button>
                <div>
                  <div className="gate-step-title">Check your inbox</div>
                  <div className="gate-step-sub">{email}</div>
                </div>
              </div>
              <div className="gate-note">
                我們已將 6 位數驗證碼寄到您的信箱（15 分鐘內有效）。<br />
                We sent a 6-digit code to your inbox. It expires in 15 minutes.
              </div>
              <input
                type="text"
                inputMode="numeric"
                pattern="\d{6}"
                maxLength={6}
                className="gate-email-input gate-mono"
                value={code}
                onChange={e => setCode(e.target.value)}
                placeholder="000000"
                autoFocus
                spellCheck={false}
                required
              />
              {devCode && (
                <div className="gate-note warn">
                  <strong>Dev mode:</strong> RESEND_API_KEY not set on the server.
                  Code is <code className="gate-mono">{devCode}</code>.
                </div>
              )}
              {error && <div className="gate-form-error" role="alert">{error}</div>}
              <button type="submit" className="gate-email-submit" disabled={busy}>
                {busy ? 'Verifying…' : 'Sign in'}
              </button>
              <button type="button" className="gate-link-btn" onClick={resend} disabled={busy}>
                Resend code
              </button>
            </form>
          )}

          <div className="gate-fineprint">
            By continuing, you acknowledge Jovana Medical's
            {' '}<a href="#privacy">Privacy Policy</a>.
          </div>
        </div>
      </div>
    </div>
  );
}

function GoogleGlyph() {
  return (
    <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
      <path fill="#EA4335" d="M9 3.48c1.69 0 2.83.73 3.48 1.34l2.54-2.48C13.46.89 11.43 0 9 0 5.48 0 2.44 2.02.96 4.96l2.91 2.26C4.6 5.05 6.62 3.48 9 3.48z"/>
      <path fill="#4285F4" d="M17.64 9.2c0-.74-.06-1.28-.19-1.84H9v3.34h4.96c-.1.83-.64 2.08-1.84 2.92l2.84 2.2c1.7-1.57 2.68-3.88 2.68-6.62z"/>
      <path fill="#FBBC05" d="M3.88 10.78A5.54 5.54 0 0 1 3.58 9c0-.62.11-1.22.29-1.78L.96 4.96A9 9 0 0 0 0 9c0 1.45.35 2.82.96 4.04l2.92-2.26z"/>
      <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.84-2.2c-.76.53-1.78.9-3.12.9-2.38 0-4.4-1.57-5.12-3.74L.97 13.04C2.45 15.98 5.48 18 9 18z"/>
    </svg>
  );
}

function Parts({ parts, asTag = 'span' }) {
  const Tag = asTag;
  return (
    <Tag>
      {parts.map((p, i) => {
        const f = p.f || '';
        const isB = /(^|[^i:])b/.test(f) || f === 'b' || f.startsWith('b');
        const isI = f.startsWith('i') || f.includes('bi') || f === 'i';
        const cm = f.match(/:([0-9A-F]{6})/);
        const color = cm ? cm[1] : null;
        let style = {};
        if (color) style.color = '#' + color;
        let node = p.t;
        if (isB) node = <b>{node}</b>;
        if (isI) node = <i>{node}</i>;
        return <span key={i} style={style}>{node}</span>;
      })}
    </Tag>
  );
}
function partsText(parts) { return (parts || []).map(p => p.t).join(''); }

function DocxTable({ rows }) {
  return (
    <table>
      <tbody>
        {rows.map((row, ri) => (
          <tr key={ri}>
            {row.map((cell, ci) => (
              <td key={ci}>
                {cell.map((ps, pi) => <Parts key={pi} parts={ps} asTag="div" />)}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function classifyParagraph(text) {
  const t = text.trim();
  if (t.startsWith('☆')) return 'keypoint';
  if (t.startsWith('💡')) return 'pearl';
  return '';
}

function ExplainBlock({ items }) {
  const out = [];
  let i = 0;
  while (i < items.length) {
    const it = items[i];
    if (it.t === 'tbl') {
      out.push(<DocxTable key={'t'+i} rows={it.rows} />);
      i++; continue;
    }
    const text = partsText(it.r);
    const trim = text.trim();
    if (!trim) { i++; continue; }
    if (trim.startsWith('■')) {
      const headerText = trim.replace(/^■\s*/, '');
      out.push(<h4 key={'h'+i}>{headerText}</h4>);
      i++;
      if (headerText.includes('延伸學習')) {
        const ext = [];
        while (i < items.length) {
          const next = items[i];
          if (next.t === 'p') {
            const ntxt = partsText(next.r).trim();
            if (ntxt.startsWith('■')) break;
            if (ntxt) ext.push(<Parts key={'ep'+i} parts={next.r} asTag="p" />);
          } else {
            ext.push(<DocxTable key={'et'+i} rows={next.rows} />);
          }
          i++;
        }
        out.push(<div className="extlearn" key={'ext'+i}>{ext}</div>);
      }
      continue;
    }
    const cls = classifyParagraph(trim);
    if (cls) {
      out.push(<p key={'p'+i} className={cls}><Parts parts={it.r} asTag="span" /></p>);
    } else {
      out.push(<Parts key={'p'+i} parts={it.r} asTag="p" />);
    }
    i++;
  }
  return <>{out}</>;
}

function QuestionCard({ q, system, isRevised, examSlug, currentUser }) {
  const [expanded, setExpanded] = useState(false);

  const parsed = useMemo(() => {
    const block = q.block;
    let stemParts = null;
    const options = [];
    let answerLine = null;
    const explainItems = [];
    let phase = 'pre';

    for (let i = 0; i < block.length; i++) {
      const it = block[i];
      if (it.t === 'tbl') {
        if (phase === 'explain' || phase === 'answer') explainItems.push(it);
        continue;
      }
      const text = partsText(it.r).trim();
      if (!text) continue;
      if (/^第\s*\d+\s*題/.test(text)) { phase = 'stem'; continue; }
      if (phase === 'stem') {
        if (/^\([A-E]\)/.test(text)) phase = 'options';
        else { stemParts = it.r; continue; }
      }
      if (phase === 'options') {
        const m = text.match(/^\(([A-E])\)\s*(.*)/s);
        if (m) { options.push({ letter: m[1], parts: it.r, raw: text }); continue; }
        else if (text.startsWith('★') || text.startsWith('正解')) {
          phase = 'answer'; answerLine = it.r; continue;
        } else {
          if (options.length) {
            options[options.length-1].parts = [...options[options.length-1].parts, {t: ' ', f: ''}, ...it.r];
          }
          continue;
        }
      }
      if (phase === 'answer') phase = 'explain';
      if (phase === 'explain') explainItems.push(it);
    }
    return { stemParts, options, answerLine, explainItems };
  }, [q]);

  const correctAnswer = useMemo(() => {
    if (!parsed.answerLine) return null;
    const txt = partsText(parsed.answerLine);
    const m = txt.match(/正解\s*[：:]\s*\(?([A-E])/);
    return m ? m[1] : null;
  }, [parsed]);

  const answerNote = useMemo(() => {
    let inline = '';
    if (parsed.answerLine) {
      const t = partsText(parsed.answerLine);
      const m = t.match(/正解\s*[：:]\s*\(?[A-E]\)?\s*(.*)/s);
      inline = m ? m[1].trim() : t.replace(/^★\s*/, '').trim();
    }
    if (inline) return inline;
    // Fallback for exams whose answer line is just `★ 正解：(X)` with no
    // trailing summary (e.g. exam 5). The Answer Note's role — a brief
    // "why X is right" — is filled by the `(X) 【正確…】` explanation
    // paragraph for the correct option, NOT the ☆ 關鍵考點 background block
    // (which already shows in the expanded full explanation).
    if (!correctAnswer) return '';
    const optRe = new RegExp(`^\\(${correctAnswer}\\)\\s*`);
    // The correct option's analysis paragraph is the `(X) ...` line that
    // carries a 【…】 qualifier — this distinguishes it from the bare option
    // line in the stem (which never has a 【…】 marker). Both `【正確…】` and
    // `【錯誤（…正解）】` / `【錯誤（官方認可）】` legitimately mark the chosen
    // answer depending on whether the question asks "which is most
    // appropriate" vs "which is most inappropriate / incorrect".
    for (const it of parsed.explainItems) {
      if (it.t !== 'p') continue;
      const text = partsText(it.r).trim();
      if (!optRe.test(text)) continue;
      const m = text.match(/^\([A-E]\)\s*【([^】]+)】\s*(.*)$/s);
      if (!m) continue;
      return m[2].trim();
    }
    return '';
  }, [parsed, correctAnswer]);

  return (
    <article className={classNames('qcard', isRevised && 'revised', expanded && 'expanded')} id={`q${q.num}`} data-system={system}>
      <header className="qcard-head">
        <div className="qmeta">
          <span className="qno">Q{String(q.num).padStart(2, '0')}</span>
          <span className="system">{system || '—'}</span>
          {isRevised && <span className="badge-revised">官方修訂</span>}
        </div>
        <h3 className="qtitle">{q.title}</h3>
      </header>
      <div className="qcard-body">
        {parsed.stemParts && <div className="qstem"><Parts parts={parsed.stemParts} /></div>}
        <div className="qoptions">
          {parsed.options.map(opt => {
            const cls = classNames('qopt', opt.letter === correctAnswer && 'correct');
            const cleanParts = (() => {
              const p0 = opt.parts[0];
              if (!p0) return opt.parts;
              const m = p0.t.match(/^\([A-E]\)\s*(.*)/s);
              if (m) return [{t: m[1], f: p0.f}, ...opt.parts.slice(1)];
              return opt.parts;
            })();
            return (
              <div key={opt.letter} className={cls}>
                <span className="letter">{opt.letter}</span>
                <span className="opt-text"><Parts parts={cleanParts} /></span>
              </div>
            );
          })}
        </div>

        {answerNote && (
          <div className="answer-reveal">
            <div className="label">▍ 解析摘要 · Answer Note</div>
            <div className="value"><span className="ans">{correctAnswer}</span>{isRevised && <span className="rev-tag">官方修訂多答</span>}</div>
            <div className="note">{answerNote}</div>
          </div>
        )}

        <div className="actions">
          <button className="btn primary" onClick={() => setExpanded(e => !e)}>
            {expanded ? '▾ 收起完整詳解' : '▸ 展開完整詳解 · Full Explanation'}
          </button>
        </div>

        <div className="explain">
          <ExplainBlock items={parsed.explainItems} />
        </div>

        {expanded && examSlug && (
          <div className="qcard-social">
            <NoteEditor examSlug={examSlug} qnum={q.num} />
            <CommentThread examSlug={examSlug} qnum={q.num} currentUser={currentUser} />
          </div>
        )}
      </div>
    </article>
  );
}

/* ---------------- NoteEditor (private per-user) ---------------- */

function NoteEditor({ examSlug, qnum }) {
  const [body, setBody] = useState('');
  const [loaded, setLoaded] = useState(false);
  const [status, setStatus] = useState('idle'); // 'idle' | 'saving' | 'saved' | 'error'
  const initial = useRef('');
  const debounce = useRef(null);

  useEffect(() => {
    let cancelled = false;
    setLoaded(false);
    api(`/api/notes/${encodeURIComponent(examSlug)}/${qnum}`)
      .then(d => { if (!cancelled) { initial.current = d.body || ''; setBody(d.body || ''); setLoaded(true); } })
      .catch(() => { if (!cancelled) { setLoaded(true); } });
    return () => { cancelled = true; };
  }, [examSlug, qnum]);

  useEffect(() => {
    if (!loaded) return;
    if (body === initial.current) return;
    if (debounce.current) clearTimeout(debounce.current);
    setStatus('saving');
    debounce.current = setTimeout(async () => {
      try {
        await api(`/api/notes/${encodeURIComponent(examSlug)}/${qnum}`, { method: 'PUT', body: { body } });
        initial.current = body;
        setStatus('saved');
        setTimeout(() => setStatus(s => (s === 'saved' ? 'idle' : s)), 1500);
      } catch {
        setStatus('error');
      }
    }, 600);
    return () => { if (debounce.current) clearTimeout(debounce.current); };
  }, [body, examSlug, qnum, loaded]);

  return (
    <section className="note-editor">
      <header className="note-head">
        <h4>▍ 我的筆記 · My note</h4>
        <span className={classNames('note-status', status)}>
          {status === 'saving' && 'Saving…'}
          {status === 'saved' && 'Saved ✓'}
          {status === 'error' && 'Save failed — retrying'}
        </span>
      </header>
      <textarea
        className="note-textarea"
        value={body}
        onChange={e => setBody(e.target.value.slice(0, 8000))}
        placeholder={loaded ? '記下重點、易混淆鑑別、考點… (僅自己看得到)\nPersonal notes — only you see this. Auto-saves.' : 'Loading…'}
        rows={4}
        disabled={!loaded}
      />
    </section>
  );
}

/* ---------------- CommentThread (public, threaded) ---------------- */

function CommentThread({ examSlug, qnum, currentUser }) {
  const [comments, setComments] = useState(null);
  const [error, setError] = useState('');
  const [busy, setBusy] = useState(false);
  const [body, setBody] = useState('');
  const [replyTo, setReplyTo] = useState(null);

  const load = useCallback(async () => {
    try {
      const data = await api(`/api/comments/${encodeURIComponent(examSlug)}/${qnum}`);
      setComments(data.comments || []);
    } catch (e) {
      setError(e.message || 'Failed to load comments.');
    }
  }, [examSlug, qnum]);

  useEffect(() => { setComments(null); setError(''); load(); }, [load]);

  const submit = async (e) => {
    e.preventDefault();
    if (busy) return;
    const text = body.trim();
    if (text.length < 1 || text.length > 2000) return;
    setBusy(true); setError('');
    try {
      const data = await api(`/api/comments/${encodeURIComponent(examSlug)}/${qnum}`, {
        method: 'POST',
        body: { body: text, parentId: replyTo },
      });
      setComments(prev => [...(prev || []), data.comment]);
      setBody('');
      setReplyTo(null);
    } catch (e) {
      if (e.status === 429) setError('留言過於頻繁，每小時最多 30 則 · Too many comments — max 30/hour.');
      else if (e.status === 401) setError('請先登入 · Please sign in.');
      else setError(e.message || 'Failed to post comment.');
    } finally { setBusy(false); }
  };

  const onDelete = async (id) => {
    if (!confirm('刪除這則留言？ · Delete this comment?')) return;
    try {
      await api(`/api/comment/${id}`, { method: 'DELETE' });
      setComments(prev => prev.map(c => c.id === id ? { ...c, isDeleted: true, body: '' } : c));
    } catch (e) {
      setError(e.message || 'Failed to delete.');
    }
  };

  const tree = useMemo(() => {
    if (!comments) return null;
    const byParent = new Map();
    for (const c of comments) {
      const k = c.parentId || '__root__';
      if (!byParent.has(k)) byParent.set(k, []);
      byParent.get(k).push(c);
    }
    return byParent;
  }, [comments]);

  const renderNode = (c, depth) => (
    <div key={c.id} className={classNames('comment', c.isDeleted && 'deleted')} style={{ marginLeft: depth * 16 }}>
      <div className="comment-meta">
        <span className="comment-author">{c.author?.name || 'Member'}{c.author?.isAdmin && <span className="comment-admin"> · admin</span>}</span>
        <span className="comment-time">{formatRelative(c.createdAt)}</span>
      </div>
      <div className="comment-body">
        {c.isDeleted ? <em>[deleted]</em> : c.body}
      </div>
      {!c.isDeleted && (
        <div className="comment-actions">
          <button type="button" className="comment-action" onClick={() => { setReplyTo(c.id); setBody(''); }}>
            Reply
          </button>
          {currentUser && (currentUser.id === c.author?.id || currentUser.isAdmin) && (
            <button type="button" className="comment-action danger" onClick={() => onDelete(c.id)}>
              Delete
            </button>
          )}
        </div>
      )}
      {(tree.get(c.id) || []).map(child => renderNode(child, Math.min(depth + 1, 4)))}
    </div>
  );

  return (
    <section className="comment-thread">
      <header className="comments-head">
        <h4>▍ 討論串 · Comments</h4>
        {comments && <span className="comments-count">{comments.filter(c => !c.isDeleted).length}</span>}
      </header>

      {comments === null && <div className="comments-loading">Loading comments…</div>}

      {comments && tree && (tree.get('__root__') || []).map(c => renderNode(c, 0))}

      {comments && !comments.length && (
        <div className="comments-empty">尚無留言，搶頭香吧 · No comments yet — be the first.</div>
      )}

      <form className="comment-form" onSubmit={submit}>
        {replyTo && (
          <div className="reply-banner">
            Replying to comment
            <button type="button" className="reply-cancel" onClick={() => setReplyTo(null)}>Cancel</button>
          </div>
        )}
        <textarea
          className="comment-textarea"
          value={body}
          onChange={e => setBody(e.target.value.slice(0, 2000))}
          placeholder={currentUser ? 'Share a clarification, additional reference, or question…' : 'Sign in to comment.'}
          rows={3}
          maxLength={2000}
          disabled={!currentUser}
        />
        <div className="comment-form-foot">
          <span className="comment-counter">{body.length} / 2000</span>
          <button type="submit" className="comment-submit" disabled={!currentUser || busy || body.trim().length < 1}>
            {busy ? 'Posting…' : (replyTo ? 'Post reply' : 'Post comment')}
          </button>
        </div>
        {error && <div className="comment-error" role="alert">{error}</div>}
      </form>
    </section>
  );
}

function formatRelative(iso) {
  if (!iso) return '';
  const t = new Date(iso).getTime();
  const diff = Math.floor((Date.now() - t) / 1000);
  if (diff < 60) return 'just now';
  if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
  if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
  if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
  return new Date(iso).toLocaleDateString();
}

function makeSystemForQ(systemMap) {
  const entries = Object.entries(systemMap);
  return (num) => {
    for (const [name, nums] of entries) {
      if (nums.includes(num)) return name;
    }
    return '—';
  };
}

// ----- Pulse line for marquee bottom -----
function PulseLine() {
  return (
    <svg viewBox="0 0 900 60" preserveAspectRatio="none">
      <defs>
        <linearGradient id="pl" x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#3a6cff" stopOpacity="0" />
          <stop offset="0.2" stopColor="#3a6cff" />
          <stop offset="0.5" stopColor="#c060ff" />
          <stop offset="0.8" stopColor="#3a6cff" />
          <stop offset="1" stopColor="#3a6cff" stopOpacity="0" />
        </linearGradient>
      </defs>
      <path d="M 0 30 L 200 30 L 220 30 L 240 10 L 260 50 L 280 5 L 300 55 L 320 30 L 500 30 L 520 20 L 540 40 L 560 30 L 900 30"
        stroke="url(#pl)" strokeWidth="1.5" fill="none" strokeLinecap="round">
        <animate attributeName="opacity" values="0.3;1;0.3" dur="2s" repeatCount="indefinite" />
      </path>
    </svg>
  );
}

function TopNav({ manifest, currentSlug, onPickExam, user, onSignOut }) {
  const [scrolled, setScrolled] = useState(false);
  const [open, setOpen] = useState(false);
  useEffect(() => {
    const onScroll = () => setScrolled(window.scrollY > 40);
    window.addEventListener('scroll', onScroll);
    return () => window.removeEventListener('scroll', onScroll);
  }, []);
  const current = manifest && manifest.exams.find(e => e.slug === currentSlug);
  return (
    <nav className={classNames('topnav', scrolled && 'scrolled')}>
      <div className="topnav-inner">
        <a className="brand" href="#top">
          <div className="brand-mark"><img src="assets/jovana-logo.png" alt="Jovana Medical" /></div>
          <div className="brand-text">
            <div className="b1">JOVANA</div>
            <div className="b2">MEDICAL · CLINICAL ARCHIVE</div>
          </div>
        </a>
        {manifest && (
          <div className={classNames('exam-switcher', open && 'open')}>
            <button className="exam-switcher-btn" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
              <span className="es-label">EXAM</span>
              <span className="es-value">醫學({current ? toCJK(current.id) : '—'})</span>
              <span className="es-caret">▾</span>
            </button>
            {open && (
              <div className="exam-switcher-menu" role="listbox">
                {manifest.exams.map(e => (
                  <button
                    key={e.slug}
                    role="option"
                    aria-selected={e.slug === currentSlug}
                    className={classNames('exam-switcher-item', e.slug === currentSlug && 'active')}
                    onClick={() => { onPickExam(e.slug); setOpen(false); }}
                  >
                    <span className="es-id">醫學({toCJK(e.id)})</span>
                    <span className="es-title">{e.title}</span>
                    <span className="es-focus">{e.focus}</span>
                  </button>
                ))}
              </div>
            )}
          </div>
        )}
        <div className="nav-links">
          <a href="#revisions">Revisions</a>
          <a href="#answer-key">Answer Key</a>
          <a href="#systems">Systems</a>
          <a href="#questions">Archive</a>
        </div>
        <a className="nav-cta" href="#questions">進入詳解 →</a>
        {user && (
          <UserChip user={user} onSignOut={onSignOut} />
        )}
      </div>
    </nav>
  );
}

function UserChip({ user, onSignOut }) {
  const [open, setOpen] = useState(false);
  const initials = (user.name || user.email || 'M').trim().slice(0, 1).toUpperCase();
  return (
    <div className={classNames('user-chip', open && 'open')}>
      <button className="user-chip-btn" onClick={() => setOpen(o => !o)} aria-haspopup="menu" aria-expanded={open}>
        {user.picture
          ? <img src={user.picture} alt="" referrerPolicy="no-referrer" />
          : <span className="user-fallback">{initials}</span>
        }
      </button>
      {open && (
        <div className="user-chip-menu" role="menu">
          <div className="user-info">
            <div className="user-name">{user.name}</div>
            {user.email && <div className="user-email">{user.email}</div>}
          </div>
          <button className="user-signout" onClick={() => { setOpen(false); onSignOut(); }}>
            登出 · Sign out
          </button>
        </div>
      )}
    </div>
  );
}

function toCJK(n) {
  return ['零','一','二','三','四','五','六','七','八','九','十'][n] || String(n);
}

function Hero({ totalQ, revisedCount, systems, exam }) {
  const focusTokens = (exam.focus || '').split(/[、，,]/).map(s => s.trim()).filter(Boolean);
  const revisedList = (exam.revisedQs || []).map(n => `Q${String(n).padStart(2, '0')}`);
  const revisedListPlain = revisedList.join(' · ');
  const innerRingText = revisedList.length
    ? '◆ ' + revisedList.join(' ◆ ') + ' ◆ POCKET MEDICINE 8e ◆ WASHINGTON MANUAL 36e ◆ OFFICIAL REVISION ◆'
    : '◆ POCKET MEDICINE 8e ◆ WASHINGTON MANUAL 36e ◆ OFFICIAL REVISION ◆';
  const outerRingTokens = focusTokens.length
    ? ' · ' + focusTokens.join(' · ') + ' ·'
    : '';

  // Marquee animation duration scales with fragment width so the visual
  // scroll speed (px/sec) stays constant across exams. Baseline = exam 3
  // (3 focus tokens, 3 revisions) at 60s; CJK chars count as ~2× ASCII
  // because the marquee uses a proportional sans for CJK and monospace
  // letterspacing for Latin.
  const measureWidth = (s) => {
    let w = 0;
    for (const ch of s) w += /[一-鿿（）]/.test(ch) ? 2 : 1;
    return w;
  };
  const fragmentText =
    `EXAM 115·1 醫學(${toCJK(exam.id)}) ${focusTokens.join(' ')} ` +
    `POCKET MEDICINE 8e WASHINGTON MANUAL 36e ` +
    (revisedList.length ? `OFFICIAL REVISION ${revisedListPlain}` : '');
  const baselineText =
    'EXAM 115·1 醫學(三) 內科 家庭醫學科 醫學倫理 ' +
    'POCKET MEDICINE 8e WASHINGTON MANUAL 36e OFFICIAL REVISION Q01 · Q08 · Q14';
  const marqueeDuration = (measureWidth(fragmentText) / measureWidth(baselineText)) * 60;
  return (
    <section className="hero hero--commander" id="top">
      <div className="hero-stage">
        <div className="aura-back"></div>
        <div className="nebula"></div>
        <div className="smoke"></div>
        <div className="hero-figure">
          <img src="assets/hero-character-v2.png" alt="Jovana commander" />
        </div>
        <div className="aura-front"></div>
        <div className="rune-ring rune-ring--outer">
          <svg viewBox="0 0 800 800" preserveAspectRatio="xMidYMid meet">
            <defs>
              <path id="circOuter" d="M 400 400 m -340 0 a 340 340 0 1 1 680 0 a 340 340 0 1 1 -680 0" />
            </defs>
            <text fontFamily="JetBrains Mono, monospace" fontSize="14" letterSpacing="8" fill="rgba(220,200,255,0.55)">
              <textPath href="#circOuter">
                {`✦ JOVANA · MEDICAL · ARCHIVE · VOL 115 · ANNO 2026 · OCTOGINTA QVAESTIONES · RESTITVTAE · ✦${outerRingTokens}`}
              </textPath>
            </text>
          </svg>
        </div>
        <div className="rune-ring rune-ring--inner">
          <svg viewBox="0 0 800 800" preserveAspectRatio="xMidYMid meet">
            <defs>
              <path id="circInner" d="M 400 400 m -240 0 a 240 240 0 1 1 480 0 a 240 240 0 1 1 -480 0" />
            </defs>
            <text fontFamily="JetBrains Mono, monospace" fontSize="11" letterSpacing="14" fill="rgba(168,80,255,0.7)">
              <textPath href="#circInner">{innerRingText}</textPath>
            </text>
          </svg>
        </div>
        <div className="sigil sigil--n">N</div>
        <div className="sigil sigil--cross">✚</div>
        <div className="sigil sigil--diamond">◆</div>
        <div className="sigil sigil--star">✦</div>
        <div className="sigil sigil--ankh">☥</div>
        <div className="sigil sigil--alpha">α</div>
        <div className="banner-streak banner-streak--l"></div>
        <div className="banner-streak banner-streak--r"></div>
        <div className="grain"></div>
        <div className="vignette"></div>
        <div className="scanlines"></div>
      </div>

      <div className="hero-side">JOVANA / MED · ARCHIVE</div>
      <div className="hero-codes">VOL · 115 — 2026</div>

      <div className="hero-content hero-content--commander">
        <div className="hero-tag"><span className="dot"></span>{exam.tagLine}</div>
        <h1>
          <span className="line"><span>Eighty</span></span>
          <span className="line"><span><em className="grad">questions,</em></span></span>
          <span className="line"><span>fully restored.</span></span>
        </h1>
        <p className="hero-sub">{exam.heroSub}</p>
        <div className="hero-meta">
          <div className="cell">
            <div className="k">QUESTIONS</div>
            <div className="v">{totalQ}<small>題完整收錄</small></div>
          </div>
          <div className="cell">
            <div className="k">REVISED</div>
            <div className="v">{revisedCount}<small>官方修訂</small></div>
          </div>
          <div className="cell">
            <div className="k">SYSTEMS</div>
            <div className="v">{systems}<small>臨床分類</small></div>
          </div>
          <div className="cell">
            <div className="k">REFS</div>
            <div className="v">2<small>standard texts</small></div>
          </div>
        </div>
      </div>

      <div className="hero-pulseline"><PulseLine /></div>

      <div className="hero-marquee">
        <div className="track" style={{ animationDuration: `${marqueeDuration.toFixed(1)}s` }}>
          {Array.from({length: 6}).map((_, k) => (
            <React.Fragment key={k}>
              <span>EXAM <em>115·1</em></span>
              <span className="dot-sep">◆</span>
              <span>醫學<em>{`(${toCJK(exam.id)})`}</em></span>
              <span className="dot-sep">◆</span>
              {focusTokens.map(tok => (
                <React.Fragment key={`f-${k}-${tok}`}>
                  <span>{tok}</span>
                  <span className="dot-sep">◆</span>
                </React.Fragment>
              ))}
              <span>POCKET MEDICINE <em>8e</em></span>
              <span className="dot-sep">◆</span>
              <span>WASHINGTON MANUAL <em>36e</em></span>
              <span className="dot-sep">◆</span>
              {revisedList.length > 0 && (
                <>
                  <span>OFFICIAL REVISION <em>{revisedListPlain}</em></span>
                  <span className="dot-sep">◆</span>
                </>
              )}
            </React.Fragment>
          ))}
        </div>
      </div>
    </section>
  );
}

function Revisions({ data, lede }) {
  return (
    <section className="block" id="revisions">
      <div className="section-head">
        <div>
          <div className="label">01 — REVISIONS</div>
          <h2>修訂<em> 說明</em></h2>
        </div>
        <div className="right">official answer keys updated</div>
      </div>
      {lede && <p className="section-lede">{lede}</p>}
      <div className="rev-grid">
        {data.map((d, i) => (
          <a key={d.q} href={`#q${parseInt(d.q.slice(1))}`} className="rev-card">
            <div className="rev-num">{String(i+1).padStart(2,'0')}</div>
            <div className="qno">▍ {d.q} · {d.topic}</div>
            <div className="row"><span className="l">Original</span><span className="v warn">{d.orig}</span></div>
            <div className="arrow">↓</div>
            <div className="row"><span className="l">Revised</span><span className="v good">{d.rev}</span></div>
            <div className="note">{d.note}</div>
          </a>
        ))}
      </div>
    </section>
  );
}

function AnswerKey({ answers }) {
  return (
    <section className="block" id="answer-key">
      <div className="section-head">
        <div>
          <div className="label">02 — MASTER KEY</div>
          <h2>80 題答案<em> 總表</em></h2>
        </div>
        <div className="right">★ = officially revised</div>
      </div>
      <div className="answer-grid">
        {answers.map(a => (
          <a key={a.num} className={classNames('answer-cell', a.revised && 'revised')} href={`#q${a.num}`}>
            <span className="qn">Q{String(a.num).padStart(2,'0')}</span>
            <span className="ans">{a.ans}</span>
            {a.revised && <span className="star">★</span>}
          </a>
        ))}
      </div>
    </section>
  );
}

function Systems({ systemMap, totalQ }) {
  const entries = Object.entries(systemMap);
  return (
    <section className="block" id="systems">
      <div className="section-head">
        <div>
          <div className="label">03 — INDEX</div>
          <h2>系統<em> 分類索引</em></h2>
        </div>
        <div className="right">{entries.length} categories · {totalQ} questions</div>
      </div>
      <div className="systems">
        {entries.map(([name, qs], i) => (
          <div key={name} className="row">
            <div className="name">
              <span className="n">{String(i+1).padStart(2, '0')} · {qs.length} Q</span>
              {name}
            </div>
            <div className="qs">
              {qs.map(n => <a key={n} href={`#q${n}`}>Q{String(n).padStart(2,'0')}</a>)}
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}

function QuestionsSection({ questions, systemMap, revisedQs, examSlug, currentUser }) {
  const [search, setSearch] = useState('');
  const [activeSystem, setActiveSystem] = useState('All');
  const systems = ['All', ...Object.keys(systemMap)];
  const systemForQ = useMemo(() => makeSystemForQ(systemMap), [systemMap]);

  const filtered = useMemo(() => {
    return questions.filter(q => {
      const sys = systemForQ(q.num);
      if (activeSystem !== 'All' && sys !== activeSystem) return false;
      if (search.trim()) {
        const s = search.toLowerCase();
        const stemText = q.block.slice(0, 6).map(it =>
          it.t === 'p' ? partsText(it.r) : ''
        ).join(' ').toLowerCase();
        const haystack = (q.title + ' Q' + q.num + ' ' + sys + ' ' + stemText).toLowerCase();
        if (!haystack.includes(s)) return false;
      }
      return true;
    });
  }, [questions, search, activeSystem]);

  return (
    <section className="block" id="questions">
      <div className="section-head">
        <div>
          <div className="label">04 — ARCHIVE</div>
          <h2>80 題<em> 完整詳解</em></h2>
        </div>
        <div className="right">click to expand each card for the full explanation</div>
      </div>
      <div className="filter-bar">
        <div className="filter-inner">
          <div className="filter-search">
            <span className="ico">⌕</span>
            <input
              placeholder="搜尋題目、關鍵字、系統…"
              value={search}
              onChange={e => setSearch(e.target.value)}
            />
            {search && <button className="clear" onClick={() => setSearch('')}>✕</button>}
          </div>
          <div className="filter-status">
            <span className="num">{filtered.length}</span> / {questions.length}
          </div>
        </div>
        <div className="filter-inner" style={{marginTop: 12}}>
          <div className="filter-chips">
            {systems.map(s => (
              <button key={s}
                className={classNames('chip', activeSystem === s && 'active')}
                onClick={() => setActiveSystem(s)}>
                {s === 'All' ? 'ALL · 全部' : s}
              </button>
            ))}
          </div>
        </div>
      </div>
      <div className="questions">
        {filtered.map(q => (
          <QuestionCard
            key={q.num}
            q={q}
            system={systemForQ(q.num)}
            isRevised={revisedQs.includes(q.num)}
            examSlug={examSlug}
            currentUser={currentUser}
          />
        ))}
        {!filtered.length && (
          <div className="no-results">⌀ no questions match the current filter</div>
        )}
      </div>
    </section>
  );
}

function Footer({ exam }) {
  return (
    <footer>
      <div className="footer-inner">
        <div className="col">
          <div className="footer-tag">Clinical knowledge,<br /><em>preserved with care.</em></div>
          <p className="footer-blurb">
            Jovana Medical 致力於將臨床醫學考試詳解整理為可長期保存、可快速檢索的數位檔案。
            All content reproduced faithfully from the explanatory document.
          </p>
        </div>
        <div className="col">
          <h5>Document</h5>
          <a href="#revisions">Revisions</a>
          <a href="#answer-key">Master Answer Key</a>
          <a href="#systems">Systems Index</a>
          <a href="#questions">80 Questions</a>
        </div>
        <div className="col">
          <h5>References</h5>
          <span>Pocket Medicine 8e</span>
          <span>Washington Manual 36e</span>
          <span>考選部官方公告</span>
        </div>
      </div>
      <div className="footer-bot">
        <span>© 2026 JOVANA MEDICAL</span>
        <span>115·1 {exam.title} — 合併修訂版</span>
      </div>
    </footer>
  );
}

function computeAnswers(questions, revisedQs) {
  return questions.map(q => {
    let ans = '?';
    for (const it of q.block) {
      if (it.t !== 'p') continue;
      const t = partsText(it.r);
      const m = t.match(/正解\s*[：:]\s*\(?([A-E])/);
      if (m) { ans = m[1]; break; }
    }
    return { num: q.num, ans, revised: revisedQs.includes(q.num) };
  });
}

function buildQuestions(raw) {
  const qStarts = [];
  for (let i = 0; i < raw.length; i++) {
    if (raw[i].t !== 'p') continue;
    const txt = partsText(raw[i].r).trim();
    const m = txt.match(/^第\s*(\d+)\s*題\s*(.*)/);
    if (m) qStarts.push({ i, num: parseInt(m[1]), title: m[2].trim() });
  }
  return qStarts.map((qs, idx) => {
    const end = idx + 1 < qStarts.length ? qStarts[idx + 1].i : raw.length;
    return { num: qs.num, title: qs.title, block: raw.slice(qs.i, end) };
  });
}

function readSlugFromHash() {
  const m = (window.location.hash || '').match(/(?:^#|&)e=([a-z0-9_-]+)/i);
  return m ? m[1] : null;
}

function writeSlugToHash(slug) {
  const next = `#e=${slug}`;
  if (window.location.hash !== next && !window.location.hash.startsWith('#q')) {
    history.replaceState(null, '', next);
  }
}

function App() {
  const { user, loading, signOut, setUser } = useAuth();
  const [manifest, setManifest] = useState(null);
  const [exam, setExam] = useState(null);
  const [slug, setSlug] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!user) return;
    fetch('data/exams/manifest.json', { cache: 'no-cache' })
      .then(r => r.json())
      .then(m => {
        setManifest(m);
        const requested = readSlugFromHash();
        const initial = (requested && m.exams.some(e => e.slug === requested))
          ? requested
          : m.default;
        setSlug(initial);
      })
      .catch(e => setError(`Failed to load manifest: ${e.message}`));
  }, [user]);

  useEffect(() => {
    if (!manifest || !slug) return;
    const entry = manifest.exams.find(e => e.slug === slug);
    if (!entry) return;
    setExam(null);
    fetch(entry.file, { cache: 'no-cache' })
      .then(r => r.json())
      .then(payload => {
        setExam({ ...payload, questions: buildQuestions(payload.data) });
        writeSlugToHash(slug);
        window.scrollTo({ top: 0, behavior: 'instant' });
      })
      .catch(e => setError(`Failed to load ${entry.file}: ${e.message}`));
  }, [manifest, slug]);

  useEffect(() => {
    const onHash = () => {
      const requested = readSlugFromHash();
      if (manifest && requested && manifest.exams.some(e => e.slug === requested)) {
        setSlug(requested);
      }
    };
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, [manifest]);

  if (loading) {
    return (
      <div className="loading">
        <div className="logo-pulse"><img src="assets/jovana-logo.png" alt="" /></div>
        <div className="loading-text">JOVANA MEDICAL</div>
        <div className="loading-sub">checking session…</div>
      </div>
    );
  }

  if (!user) {
    return <SignInGate onSignedIn={setUser} />;
  }

  if (error) {
    return <div className="loading"><div className="loading-text">JOVANA MEDICAL</div><div className="loading-sub">{error}</div></div>;
  }

  if (!exam) {
    return (
      <div className="loading">
        <div className="logo-pulse"><img src="assets/jovana-logo.png" alt="" /></div>
        <div className="loading-text">JOVANA MEDICAL</div>
        <div className="loading-sub">initializing clinical archive…</div>
      </div>
    );
  }

  const answers = computeAnswers(exam.questions, exam.revisedQs);
  return (
    <>
      <TopNav manifest={manifest} currentSlug={slug} onPickExam={setSlug} user={user} onSignOut={signOut} />
      <Hero
        totalQ={exam.questions.length}
        revisedCount={exam.revisedQs.length}
        systems={Object.keys(exam.systemMap).length}
        exam={exam}
      />
      <Revisions data={exam.revisions} lede={exam.revisionsLede} />
      <AnswerKey answers={answers} />
      <Systems systemMap={exam.systemMap} totalQ={exam.questions.length} />
      <QuestionsSection
        questions={exam.questions}
        systemMap={exam.systemMap}
        revisedQs={exam.revisedQs}
        examSlug={slug}
        currentUser={user}
      />
      <Footer exam={exam} />
    </>
  );
}

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