// Top-level layout — 16:9 fixed canvas, flip shell, realistic paper-peel handle.

// ─── 入口路由 (Phase A) ─────────────────────────────────────────────────
// AppRoot 是个微小状态机:
//
//   checking     — 刚装载, 还没决定走哪条路 (短瞬态, 不画 UI)
//   idle         — 没登录态 → 画 LoginGate, 用户在那里选知乎/模拟登录
//   exchanging   — OAuth 回调里, 后端正在拿 code 换 access_token
//   logged-in    — 有效用户态 (走 OAuth 成功 / 已存 localStorage / 模拟登录) → 画 <Page/>
//   error        — OAuth 换 token 炸了 → LoginGate 红条 + 重试 / 模拟登录 / 复制错误
//
// 路由判定 (一次性, 装载时跑):
//   1. URL 上有 ?code= / ?authorization_code= → 走 exchanging 链路
//   2. localStorage 里有有效用户 → logged-in
//   3. 都没有 → idle
//
// 仍然支持: 截图模式 (#clean) — 由 Page 自己处理.
function AppRoot() {
  const [phase, setPhase] = React.useState('checking');
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const q = new URLSearchParams(window.location.search);
        // 知乎实际回调用的是 authorization_code, 不是 RFC 标准的 code; 两个都接.
        const code = q.get('authorization_code') || q.get('code');

        if (code) {
          // 模块级 dedup, 防 React StrictMode / hot reload 双触发把 code 用废
          if (window.__oauthExchangeStarted) return;
          window.__oauthExchangeStarted = true;

          setPhase('exchanging');
          try {
            const user = await exchangeCodeForUser(code);
            saveUser(user);
            stripOAuthQueryFromUrl();
            // 不能直接 setPhase('logged-in') — CURRENT_USER 是模块顶层在
            // localStorage 还空的时候算的, 现在直接 render Page 会拿到 GUEST.
            // reload 一次, 模块重新装载, CURRENT_USER 才是新 user.
            // (URL 上的 code 已经 strip 了, 不会触发再一次 exchange.)
            if (!cancelled) location.reload();
          } catch (e) {
            console.error('[oauth] exchange failed:', e);
            // 错也要把 URL 上的 code 清掉, 否则用户重试时 stale code 又跑一次
            stripOAuthQueryFromUrl();
            if (!cancelled) {
              setError(e?.message || String(e));
              setPhase('error');
            }
          }
          return;
        }

        // 没回调 — 看 localStorage
        const stored = (typeof getStoredUser === 'function') ? getStoredUser() : null;
        if (stored && (stored.uid || stored.hash_id || stored.fullname)) {
          if (!cancelled) setPhase('logged-in');
        } else {
          if (!cancelled) setPhase('idle');
        }
      } catch (e) {
        console.warn('[main] AppRoot init failed:', e?.message || e);
        if (!cancelled) setPhase('idle');
      }
    })();
    return () => { cancelled = true; };
  }, []);

  const startLogin = React.useCallback(() => {
    if (typeof buildAuthorizeUrl !== 'function') return;
    location.href = buildAuthorizeUrl();
  }, []);

  if (phase === 'checking') return null;       // 一瞬, 别闪
  if (phase === 'logged-in') return <Page />;
  return <LoginGate phase={phase} error={error} onLogin={startLogin} />;
}

function Page() {
  // Phase A: 登录后两屏初始都是黑屏, 等用户按电源键点亮.
  const [leftOn, setLeftOn] = React.useState(false);
  const [rightOn, setRightOn] = React.useState(false);
  // 'demo' | 'about'. 截图模式下 ?view=about 直接进背面.
  const [view, setView] = React.useState(() => {
    if (typeof window !== 'undefined') {
      const q = new URLSearchParams(window.location.search);
      if (q.get('view') === 'about') return 'about';
    }
    return 'demo';
  });

  // 左屏电源键蓝光脉冲跟踪 — 在用户**真的按过一次电源键之前** 一直闪.
  // 故意跟 tourStep / 弹窗状态解耦, 这样即使用户先把 popup 的 × 按掉, 脉冲也
  // 继续提示"快按这里", 直到他真的按下电源键才停, 之后也不再闪.
  const [hasClickedLeftPower, setHasClickedLeftPower] = React.useState(false);
  const handleToggleLeft = React.useCallback(() => {
    setLeftOn((v) => !v);
    setHasClickedLeftPower(true);
  }, []);

  // 右屏 label — 默认 "刘看山的手机", 用户点赞镜像答主后变成 "{答主名}的手机".
  // 跟随 likedAuthor 自动更新, 有一个"字号震荡 → 回弹" 的入场动画
  const [likedAuthor, setLikedAuthor] = React.useState(null);
  // 右屏唤醒序列阶段: 'dark' | 'label-shake' | 'lit' | 'home' | 'app-loading' | 'messages' | 'plus-one'
  const [rightStage, setRightStage] = React.useState('dark');

  // Phase B: tour state — 每次页面打开都从 P1 开始, 跟"完没完过 tour"无关.
  // 之前会读 localStorage 里的 tour_completed_v1 跳过 tour, 用户反馈:
  // Cmd+Shift+R 强刷只清缓存不清 localStorage, 所以一旦走完一次, 之后再怎么强刷
  // 第一个 popup 都不出. demo 场景每次都从头, 现在干脆别看 localStorage 了, 并
  // 在装载时主动把 flag 清掉, 避免之前留下的 '1' 干扰任何潜在逻辑.
  //
  // 初始 tourStep 故意是 IDLE, 不是 P1 — 用户反馈"刚打开网页 popup 就糊脸",
  // 现在留 2.5 秒给用户看清楚两个黑屏手机, 然后 popup 跟电源键脉冲一起亮起.
  const TOUR = (typeof TOUR_STEPS !== 'undefined') ? TOUR_STEPS : {};
  const [tourStep, setTourStep] = React.useState(TOUR.IDLE || 'idle');
  const [tourStarted, setTourStarted] = React.useState(false);
  React.useEffect(() => {
    if (typeof resetTour === 'function') resetTour();
    // 截图模式: URL hash 为 #clean 时, 隐藏翻页 peel + fs-hint, 给 gpt-image-2
    // 当干净 reference 图. 还要把"另一面" (跟当前 view 相反的那个 flip-pane)
    // 藏掉, 避免它在 cut 角漏过来.
    if (typeof window !== 'undefined' && window.location.hash === '#clean') {
      const q = new URLSearchParams(window.location.search);
      const aboutView = q.get('view') === 'about';
      const hideOpposite = aboutView ? '.flip-front' : '.flip-back';
      const s = document.createElement('style');
      s.textContent = `${hideOpposite},.peel-wrap,.peel-click-area,.peel-hint,.peel-svg-wrap,.fs-hint{display:none !important}`;
      document.head.appendChild(s);
    }
    const t = setTimeout(() => {
      setTourStarted(true);
      setTourStep(TOUR.P1_LIGHT_SCREEN || 'p1-light-screen');
    }, 2500);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  // Fix #1: 每个 popup 一次性, 用过就不再触发, 即使条件再次满足
  // (例: 用户离开 feed 又回来, Popup 2 不再弹)
  const [shownPopups, setShownPopups] = React.useState(new Set([TOUR.P1_LIGHT_SCREEN]));
  const fireOnce = React.useCallback((step) => {
    setShownPopups(prev => {
      if (prev.has(step)) return prev;
      setTourStep(step);
      return new Set([...prev, step]);
    });
  }, []);

  // 当用户按下左屏电源键 (leftOn: false → true), 把 #1 弹窗 dismiss.
  const prevLeftOnRef = React.useRef(leftOn);
  React.useEffect(() => {
    if (prevLeftOnRef.current === false && leftOn === true) {
      if (tourStep === TOUR.P1_LIGHT_SCREEN) setTourStep(TOUR.IDLE);
    }
    prevLeftOnRef.current = leftOn;
  }, [leftOn, tourStep]);

  // 左屏 screen 跳转触发 Popup 2 / 3 (每个一次性, fireOnce 保证):
  //   - 进 'feed' (知乎 splash 完, 主页) → Popup 2 "帮我问减肥相关的问题"
  //   - 进 'question-created' (发布完出现 invite list) → 主线 P3 或离题 P3_OFFTRACK,
  //     取决于发布瞬间 quickIsInScopeCheck 拿到的 isOffTrack.
  //
  // 重要: isOffTrackRef 不靠 useEffect 同步 — useEffect 在 render 之后才跑,
  // 而 handleScreenChange 是在 setState 触发的 render 中**同步**被调用的, 读
  // ref 时可能拿到上一轮的旧值. 改成 handleAskPublish 里直接 ref.current = ...
  const isOffTrackRef = React.useRef(false);
  const handleScreenChange = React.useCallback((screen) => {
    if (screen === 'feed') fireOnce(TOUR.P2_STARRY_ASK);
    if (screen === 'ask' || screen === 'compose') {
      setTourStep(prev => prev === TOUR.P2_STARRY_ASK ? TOUR.IDLE : prev);
    }
    if (screen === 'question-created') {
      if (isOffTrackRef.current) fireOnce(TOUR.P3_OFFTRACK);
      else fireOnce(TOUR.P3_TRY_MIRROR_BUTTON);
    }
  }, [TOUR, fireOnce]);

  // AskPage 点"发布"那一刻 silently 跑一次 quickIsInScopeCheck.
  // **这个函数是 async 的, 调用方 (phone.jsx 的 AskPage onPublish) 必须 await 它**
  // 再切到 question-created — 之前是 fire-and-forget 导致 race condition:
  // LLM 慢 → handleScreenChange 已经触发 → ref 还是旧值 → 弹错 popup.
  //
  // 另外: 每轮新问题都把 P3-P7 + 离题变种从 shownPopups 移除, 这样教学引导
  // 对每个新问题都重新有效 (用户反馈 "查看完总结再提减肥, 没出 P3 引导").
  const handleAskPublish = React.useCallback(async (title) => {
    // 立刻同步重置 (ref + state), 这样即使 LLM 还没返回, 默认走主线 (in-scope)
    isOffTrackRef.current = false;
    setIsOffTrack(false);
    setEinsteinAnswer(null);
    setLeftBanner(null);
    setNewRealAnswer(null);
    setRightMode('liked');
    setLikedAuthor(null);
    // 教学 popup 对新一轮重新可发: 移除 P3-P7 + 离题 P3/P4/P5
    setShownPopups(prev => {
      const next = new Set(prev);
      [TOUR.P3_TRY_MIRROR_BUTTON, TOUR.P3_OFFTRACK,
       TOUR.P4_TRY_LIKE,          TOUR.P4_OFFTRACK,
       TOUR.P5_UPDATE_ANSWER,     TOUR.P5_OFFTRACK,
       TOUR.P6_SCROLL_TO_BOTTOM,  TOUR.P7_DONE,
      ].forEach(s => next.delete(s));
      return next;
    });
    if (typeof quickIsInScopeCheck !== 'function') return;
    try {
      // 3 秒超时兜底 — LLM 慢的话默认走主线, 不让用户卡在 toast.
      const inScope = await Promise.race([
        quickIsInScopeCheck(title),
        new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 3000)),
      ]);
      // 同步更新 ref (优先于 state, 给即将到来的 handleScreenChange 立即可见)
      isOffTrackRef.current = !inScope;
      setIsOffTrack(!inScope);
    } catch (e) {
      console.warn('[handleAskPublish] scope check failed/timeout:', e.message);
      // 保持默认 (in-scope), ref 还是 false
    }
  }, [TOUR]);

  // 镜像答主真出现 → 主线 Popup 4 ("给一个答案点赞"). 离题分支: 检测 outOfScope
  // → 弹 P4_OFFTRACK ("数据库没这个, 看看推给了谁").
  // info = { count, outOfScope } 从 LeftPhone 传上来 (mirror panel 跑完 pipeline 后)
  const handleMirrorAnswersReady = React.useCallback((info) => {
    if (!info) return;
    const { count, outOfScope } = info;
    if (outOfScope) fireOnce(TOUR.P4_OFFTRACK);
    else if (count > 0) fireOnce(TOUR.P4_TRY_LIKE);
  }, [TOUR, fireOnce]);

  // 用户点赞 (在 mirror panel 中) → 触发右屏唤醒序列 + 推进 tour
  // info = { answerId, authorId, persona, answer? } — 完整的被点赞答案信息
  const handleLikeMirror = React.useCallback((info) => {
    const persona = info?.persona;
    const authorName = persona?.name || '答主';
    setLikedAuthor({
      name: authorName,
      persona,
      answerId: info?.answerId,
      answer: info?.answer,       // 原答案 body, 用做 LLM 生成上下文
    });
    setTourStep(TOUR.IDLE);
    setRightStage('label-shake');
    const T = (ms, fn) => setTimeout(fn, ms);
    // 节奏放慢一倍 (用户反馈太快 — 原来 600/1400/2600/4000/5000, 现在 1200/2800/5200/8000/10000)
    T(1200,  () => { setRightOn(true); setRightStage('lit'); });
    T(2800,  () => setRightStage('home'));
    T(5200,  () => setRightStage('app-loading'));
    T(8000,  () => setRightStage('messages'));
    T(10000, () => {
      setRightStage('plus-one');
      fireOnce(TOUR.P5_UPDATE_ANSWER);
    });
  }, [TOUR, fireOnce]);

  // 用户在右屏点 "更新我的回答" → 打字 → 真人回答内嵌进 AnswerCard. 顶部同时出
  // 一个 sticky banner. 用户读完点 banner × → 触发 P6 ("滑到最底下"). 之后由
  // 用户自己滑动 — 系统**不会**主动 scrollIntoView (上一版那样做体验很差,
  // 用户向上看 reply 时被强行拉到底).
  const [newRealAnswer, setNewRealAnswer] = React.useState(null);
  const [leftBanner, setLeftBanner]       = React.useState(null);
  // synthesis 入/离视口由 mirror-answer.jsx 的 IntersectionObserver 报告.
  const [synthesisInView, setSynthesisInView] = React.useState(false);
  // 当前问题文本 (用来给 LLM 当上下文)
  const [currentQuery, setCurrentQuery] = React.useState('');

  // ─── 离题分支 (off-track) 状态机 ───────────────────────────────
  // 用户提了一个跟减肥无关的问题时, 走神秘专家分支:
  // - isOffTrack: AskPage 点发布瞬间, 后台 silently 调 quickIsInScopeCheck;
  //   返回 isInScope=false 时这里翻成 true, 后续 P3/P4/P5 全部走离题版本.
  // - einsteinAnswer: 爱因斯坦 LLM 生成的"被召集" 回答, 渲染在 mirror panel 顶部
  //   (替换 State C 看板, 用户拍板的方案 C).
  // - rightMode: 右屏当前演的是哪条线 ('liked' 减肥点赞推送 / 'invited' 离题邀请推送)
  const [isOffTrack, setIsOffTrack]     = React.useState(false);
  const [einsteinAnswer, setEinsteinAnswer] = React.useState(null);
  const [rightMode, setRightMode]       = React.useState('liked');

  // 真 LLM 调用 — 真人口吻 prompt (知乎风格, 可以玩梗)
  const generateRealPersonReply = React.useCallback(async () => {
    const question = currentQuery || '减肥相关的问题';
    const original = likedAuthor?.answer?.body || '';
    const authorName = likedAuthor?.name || '答主';
    const credential = likedAuthor?.persona?.credential || '';
    const headline = likedAuthor?.persona?.headline || likedAuthor?.persona?.bio || '';

    const SYSTEM = `你扮演一个真实的知乎答主. 你之前写过一条回答, 现在被人点赞了, 你站在 2026 年 5 月这个时间节点, 打算"二次作答", 补一段新的内容.

═══════ 内容硬约束 (最高优先级, 违反直接重写) ═══════

1) 主题死锁: 你的新回答必须紧扣 **原问题** + **原答案话题**. 用户问什么、你之前写的什么主题, 新回答就必须围绕同一主题.
   - 例: 原问题是"如何减肥", 你必须写减肥相关 (饮食 / 运动 / 药物 / 心理 / 代谢 / 等等)
   - 严禁突然跳到无关主题 (例: 跳到牙科 / 园艺 / 编程 / 历史 / 政治 / 等等)
   - 如果你想不到话题内的新角度, 用"我想补充一个之前没讲到的细节" 转折, 仍然必须留在主题内, 不要硬找

2) 你补充的内容应该是: 站在 2026 年最新时间节点, 这个领域有什么新的研究 / 新的工具 / 新的实践经验 / 新的视角, 是原答案写的时候 (可能 2024-2025 年) 还没特别明朗的.

3) 必须是新角度, 不能重复原答案已经讲过的内容.

═══════ 口吻 (知乎风格, 不能像 AI) ═══════

- 可以玩一个知乎经典开头梗 (挑一个用就好, 别堆): "谢邀, 人在美国, 刚下飞机" / "谢邀, 利益相关: ..." / "看了大部分回答, 没人提到..." / "实名反对楼上" / "强答一波" / "更新一下, 想到一个之前漏掉的点"
- 允许自嘲 / 语气词 (诶, 嗯, 啊, 其实) / 偶尔幽默
- **严禁** "AI 综合 / 重点结论 / 综合来看 / 总而言之 / 由此可见 / 总的来说" 这类 AI 总结词
- **严禁** 感叹号堆砌 / "首先 / 其次 / 最后" 排列结构 / markdown 列表
- 不用第三人称称呼"答主"或"用户" — 你就是答主本人

═══════ 格式 ═══════
- 自然分段 (短可三句话, 长可几段), 段间空行
- 不写小标题
- 不要带 emoji 堆砌 (1-2 个可以, 但不强求)`;

    const USER = `═══ 用户问的问题 (这是新回答的话题范围, 不能偏离) ═══
${question}

═══ 我自己 (${authorName}${credential ? ', ' + credential : ''}${headline ? ', ' + headline : ''}) 之前写过的回答 (被点赞了的就是这条) ═══

${original.slice(0, 900)}

═══ 任务 ═══
站在 2026 年 5 月, 我要"二次作答", 补一段新内容. 紧扣上面的问题主题, 不能换话题. 知乎口吻, 不像 AI.`;

    try {
      // 跟 agents.jsx 同步: local → :5174, Vercel → /api
      const proxyBase = /^(127\.0\.0\.1|localhost)$/.test(location.hostname)
        ? 'http://127.0.0.1:5174' : '/api';
      const resp = await fetch(`${proxyBase}/llm`, {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({
          model: 'qwen-3-235b-a22b-instruct-2507',
          messages: [
            { role: 'system', content: SYSTEM },
            { role: 'user',   content: USER },
          ],
          temperature: 0.9,
          max_tokens: 900,
        }),
      });
      if (!resp.ok) throw new Error('http ' + resp.status);
      const data = await resp.json();
      const text = data?.choices?.[0]?.message?.content?.trim();
      if (text) return text;
    } catch (e) {
      console.warn('[realPersonReply] LLM failed, falling back:', e.message);
    }
    // fallback: 知乎风格的兜底 — 玩一下"谢邀" 梗, 不像 AI 综合
    return '谢邀, 人不在美国, 也没刚下飞机, 就是看到有人点赞了一下旧答案, 突然想到一个之前没写到的点.\n\n这个问题真正容易被忽略的是边界条件 — 多数答案都在讲"方法论怎么用", 但很少有人说"什么时候不该用". 实践里反而是后者更值钱.';
  }, [currentQuery, likedAuthor]);

  const handleUpdateAnswerClicked = React.useCallback(() => {
    setTourStep(TOUR.IDLE);
    setRightStage('typing');
    const T = (ms, fn) => setTimeout(fn, ms);
    // 并行: 启动 LLM 调用, 4 秒后右屏熄
    const llmPromise = generateRealPersonReply();
    T(4000, () => { setRightStage('dark'); setRightOn(false); });
    // T=6000: banner 跟 inline reply 一起亮起. banner 不依赖 LLM, 先设 (避免
    // LLM 慢的时候用户先看到 reply 再看到 banner). reply 依赖 LLM, 等 promise
    // resolve 再设 — 通常 LLM 在 6 秒前已经回了, 两者实际近乎同时出现.
    T(6000, async () => {
      setLeftBanner({
        text: '真人博主更新了你的问题, 请向上滑动查看',
        ts: Date.now(),
      });
      const body = await llmPromise;
      setNewRealAnswer({
        author: likedAuthor?.name || '答主',
        persona: likedAuthor?.persona,
        likedAnswerId: likedAuthor?.answerId,
        body,
        ts: Date.now(),
      });
    });
    // (旧版本在 T=9000 自动 scrollIntoView 到 synthesis. 已删: 用户向上看 reply
    // 时被强行拉到底体验很差. 现在改成纯手动 — banner × → P6 提示 → 用户自己滑.)
  }, [likedAuthor, generateRealPersonReply]);

  // banner × → 用户表示"我看完真人回复了". 关 banner, 弹 P6 ("滑到最底下"),
  // 之后由用户自己滑动. 如果 P7 已经弹过 (极端: 用户没动 banner 直接滑到底,
  // synthesis 入视口 P7 已经触发), 就不再弹 P6 了.
  const handleBannerDismiss = React.useCallback(() => {
    setLeftBanner(null);
    setShownPopups(prev => {
      if (prev.has(TOUR.P7_DONE)) return prev;
      if (prev.has(TOUR.P6_SCROLL_TO_BOTTOM)) return prev;
      setTourStep(TOUR.P6_SCROLL_TO_BOTTOM);
      return new Set([...prev, TOUR.P6_SCROLL_TO_BOTTOM]);
    });
  }, [TOUR]);

  // mirror-answer 报告 synthesis 卡是否在视口里 — 这里只记录, 真正的 P7 触发
  // 在下面的 effect 里 (条件: reply 已出 + synthesis 当前可见 + P7 未弹过).
  const handleSynthesisVisibilityChange = React.useCallback((inView) => {
    setSynthesisInView(inView);
  }, []);

  // P7 触发器: 真人回答已经出现 + synthesis 在视口里 + 还没弹过 P7 → 延迟 1.2s 弹.
  // 不再依赖 P6 — 现在 P6 是可有可无的, 自动滚动直接把用户带到 synthesis 这里.
  React.useEffect(() => {
    if (!newRealAnswer) return;
    if (shownPopups.has(TOUR.P7_DONE)) return;
    if (!synthesisInView) return;
    const t = setTimeout(() => fireOnce(TOUR.P7_DONE), 1200);
    return () => clearTimeout(t);
  }, [shownPopups, synthesisInView, newRealAnswer, TOUR, fireOnce]);

  // ─── 离题分支: 用户关掉 P4_OFFTRACK ("数据库没这个, 看看推给了谁") 触发
  //     右屏唤醒 → 爱因斯坦的手机 → 自动推送通知 → 自动 typing → 左屏插入回答.
  // 跟 handleLikeMirror 节奏一致, 复用现有的 lit/home/splash/messages 阶段;
  // 关键差异:
  //   - 标签直接切到"爱因斯坦", likedAuthor.persona 用 PERSONAS_V2.einstein
  //   - rightMode='invited', 让 RightPhone 渲染 invite-push 卡 (不是 +1 卡)
  //   - 用户**不需要点任何按钮**, T(13000) 自动进 typing, T(17000) 出回答
  const triggerExpertSequence = React.useCallback(() => {
    const einsteinPersona = (typeof getPersona === 'function')
      ? getPersona('einstein')
      : (window.PERSONAS_V2 && window.PERSONAS_V2.einstein) || null;
    setLikedAuthor({ name: '爱因斯坦', persona: einsteinPersona, answerId: null, answer: null });
    setRightMode('invited');
    setRightStage('label-shake');
    const T = (ms, fn) => setTimeout(fn, ms);
    // 唤醒序列 (跟主线 handleLikeMirror 节奏一致)
    T(1200,  () => { setRightOn(true); setRightStage('lit'); });
    T(2800,  () => setRightStage('home'));
    T(5200,  () => setRightStage('app-loading'));
    T(8000,  () => setRightStage('messages'));
    T(10000, () => setRightStage('invite-push'));      // 推送通知卡, 自动走
    // T=13000: 启动 LLM + 切到 typing. 不等用户点任何按钮 (跟主线不同).
    const llmPromise = (typeof generateEinsteinReply === 'function')
      ? generateEinsteinReply(currentQuery || '一个跨领域的问题')
      : Promise.resolve('谢邀, 人在普林斯顿, 刚下飞机. (LLM 不可用兜底)');
    T(13000, () => setRightStage('typing'));
    T(17000, () => { setRightStage('dark'); setRightOn(false); });
    T(19000, async () => {
      const body = await llmPromise;
      setEinsteinAnswer({
        author: '爱因斯坦', persona: einsteinPersona, body, ts: Date.now(),
      });
      setLeftBanner({
        text: '✨ 神秘专家答上来了, 请向上滑动查看',
        ts: Date.now(),
      });
    });
  }, [currentQuery]);

  // 爱因斯坦的回答出现 4 秒后, 弹 P5_OFFTRACK ("回到首页重新提一个减肥相关的")
  React.useEffect(() => {
    if (!einsteinAnswer) return;
    if (shownPopups.has(TOUR.P5_OFFTRACK)) return;
    const t = setTimeout(() => fireOnce(TOUR.P5_OFFTRACK), 4000);
    return () => clearTimeout(t);
  }, [einsteinAnswer, shownPopups, TOUR, fireOnce]);

  // dismiss 大多数情况下只把当前 popup 关掉. 但 P4_OFFTRACK dismiss 是一个**触发器**
  // — 用户按 × 表示"我看完提示了, 让我看看专家", 这时启动右屏唤醒序列.
  const dismissTour = () => {
    setTourStep(prev => {
      if (prev === TOUR.P4_OFFTRACK) {
        triggerExpertSequence();
      }
      return TOUR.IDLE;
    });
  };

  // 翻页切换: 切到 about 时主动把 tour popup 收回, 这样回到 demo 不会
  // 看到悬浮的旧 popup. (Fix #3 一部分: about 视图根本不渲染 dialog.)
  const handleFlip = React.useCallback(() => {
    setView(prev => {
      const next = prev === 'demo' ? 'about' : 'demo';
      if (next === 'about') setTourStep(TOUR.IDLE);
      return next;
    });
  }, [TOUR]);

  return (
    <div className="stage-host">
      {/* 竖屏手机拦截 — 太窄就盖一层提示 */}
      <div className="portrait-warn">
        <div className="portrait-warn-icon">↻</div>
        <div className="portrait-warn-text">
          这是一份桌面 Deck<br/>
          <span className="portrait-warn-sub">请用电脑访问 · 或把手机横过来</span>
        </div>
      </div>
      <div className="canvas">
        <div className="flip-shell">
          <div className={`flip-pane flip-back ${view === 'about' ? 'is-active' : 'is-behind'}`}>
            <AboutPage/>
          </div>
          <div className={`flip-pane flip-front ${view === 'demo' ? 'is-active' : 'is-flipped'}`}>
            <div className="page">
              <Header />
              <Stage
                leftOn={leftOn} onToggleLeft={handleToggleLeft}
                rightOn={rightOn} onToggleRight={() => setRightOn((v) => !v)}
                onLeftScreenChange={handleScreenChange}
                onLeftMirrorAnswersReady={handleMirrorAnswersReady}
                onLeftLike={handleLikeMirror}
                onLeftQueryChange={setCurrentQuery}
                onLeftAskPublish={handleAskPublish}
                onUpdateAnswerClicked={handleUpdateAnswerClicked}
                likedAuthor={likedAuthor}
                rightStage={rightStage}
                rightMode={rightMode}
                newRealAnswer={newRealAnswer}
                einsteinAnswer={einsteinAnswer}
                leftBanner={leftBanner}
                onBannerDismiss={handleBannerDismiss}
                onSynthesisVisibilityChange={handleSynthesisVisibilityChange}
                leftPowerHint={tourStarted && !hasClickedLeftPower} />
            </div>
            <LiukanshanBgVideo/>
          </div>
        </div>
        {/* Phase B tour: 刘看山引导弹窗. **只在 demo 视图渲染** (Fix #3),
           翻到 about 时, dialog 不存在 → 不会出现"项目背景里还在催你点亮手机"
           这个 bug. */}
        {view === 'demo' && typeof LiukanshanDialog === 'function' && (
          <LiukanshanDialog key={tourStep} step={tourStep} onDismiss={dismissTour}/>
        )}
        {/* 翻页触发: 虚线箭头按钮, 跟两台手机风格一致, 不再用纸卷边
           (Fix #2). 永远固定在右下角, 标签随当前视图变化. */}
        <FlipTrigger view={view} onFlip={handleFlip}/>
      </div>
    </div>);
}

// 翻页按钮 — 虚线三角形 + 手写体小字. 替换原 PeelCorner 纸卷边设计.
// 颜色用 currentColor, 由 .flip-trigger 上的 class (on-about) 切换.
function FlipTrigger({ view, onFlip }) {
  const isOnDemo = view === 'demo';
  // 文字像素风 (Silkscreen), 跟顶部 "HACKATHON" 同款字体. 知乎蓝.
  const label = isOnDemo ? '查看项目花絮' : 'BACK TO DEMO';
  return (
    <button
      className={`flip-trigger${isOnDemo ? '' : ' on-about'}`}
      onClick={onFlip}
      aria-label={isOnDemo ? '了解项目背景' : '回到 Demo'}
    >
      <span className="flip-trigger-text">{label}</span>
      <svg className="flip-trigger-arrow" width="56" height="56" viewBox="0 0 56 56">
        <polygon
          points={isOnDemo ? "10,10 46,10 10,46" : "46,46 10,46 46,10"}
          fill="none" stroke="currentColor" strokeWidth="2.5"
          strokeLinejoin="round" strokeDasharray="6 4"
        />
        <path
          d={isOnDemo ? "M 22 22 L 38 38 M 32 38 L 38 38 L 38 32" : "M 34 34 L 18 18 M 24 18 L 18 18 L 18 24"}
          fill="none" stroke="currentColor" strokeWidth="2.5"
          strokeLinecap="round" strokeLinejoin="round"
        />
      </svg>
    </button>
  );
}

// — Paper-peel: a 3D-feeling triangular flap with shading, shadow, and curl —
function PeelCorner({ kind, label }) {
  // The lifted FLAP shows the BACK of the current page (off-white / dim-red);
  // through the cut (handled by the page's clip-path), the OTHER page peeks through.
  const flapBase  = kind === 'front' ? '#FFFFFF' : '#E83560';
  const flapHi    = kind === 'front' ? '#FFFFFF' : '#FF5377';
  const flapLo    = kind === 'front' ? '#D9DEE7' : '#B5274A';
  const ink = '#0A0014';
  const id = `peel-${kind}`;

  return (
    <div className="peel-wrap">
      {/* handwritten hint + arrow */}
      <div className="peel-hint">
        {label.split('\n').map((line, i) => (
          <div key={i} className="peel-text">{line}</div>
        ))}
        <svg className="peel-arrow" width="120" height="80" viewBox="0 0 120 80" fill="none">
          <path d="M6 12 C 30 28, 60 50, 96 64"
            stroke={ink} strokeWidth="2.5" strokeLinecap="round" fill="none"/>
          <path d="M82 50 L100 66 L78 70"
            stroke={ink} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
        </svg>
      </div>

      {/* The lifted flap (the BACK side of the current page) */}
      <div className="peel-svg-wrap">
        <svg viewBox="0 0 240 240" preserveAspectRatio="none">
          <defs>
            <linearGradient id={`${id}-fill`} x1="0%" y1="0%" x2="100%" y2="100%">
              <stop offset="0%" stopColor={flapHi}/>
              <stop offset="55%" stopColor={flapBase}/>
              <stop offset="100%" stopColor={flapLo}/>
            </linearGradient>
            <filter id={`${id}-shadow`} x="-30%" y="-30%" width="160%" height="160%">
              <feGaussianBlur stdDeviation="5"/>
            </filter>
          </defs>

          {/* Cast shadow of the lifted flap, falling onto the underlying page */}
          <path d="M 240 70 Q 150 150, 70 240 L 70 70 Z"
            fill="rgba(0,0,0,0.25)" filter={`url(#${id}-shadow)`}
            transform="translate(8 8)"/>

          {/* The flap itself — the curled BACK side of the current page */}
          <path d="M 240 70 Q 150 150, 70 240 L 70 70 Z"
            fill={`url(#${id}-fill)`}
            stroke={ink} strokeWidth="0.8" strokeOpacity="0.25"/>

          {/* Fold-line crease (the diagonal where the paper bends) */}
          <path d="M 240 70 Q 150 150, 70 240"
            stroke="rgba(0,0,0,0.30)" strokeWidth="1.2" fill="none"/>
        </svg>
      </div>

      {/* (the clickable click-area is hoisted to the canvas level so it
         isn't trimmed by the flip-pane's clip-path) */}
    </div>);
}

// — Liukanshan ambient video, anchored bottom-left of the demo page —
function LiukanshanBgVideo() {
  return (
    <video
      className="liukanshan-bg"
      src="assets/liukanshan-bg.mp4"
      autoPlay loop muted playsInline
      aria-hidden="true"
    />
  );
}

function Header() {
  return (
    <header className="hero">
      <div className="brand">
        <span className="brand-zh">知乎</span>
        <span className="brand-en">Hackathon</span>
        <span className="brand-divider" />
        <span className="ai-chip">AI</span>
        <span className="brand-sub">脑洞实验室</span>
      </div>
      <div className="title-row">
        <h1 className="project">镜像回答</h1>
        <div className="team">
          <span className="team-label">TEAM</span>
          <span className="team-name">CDM 失踪人口</span>
        </div>
      </div>
    </header>);
}

function Stage({
  leftOn, onToggleLeft, rightOn, onToggleRight,
  onLeftScreenChange, onLeftMirrorAnswersReady, onLeftLike, onLeftQueryChange, onLeftAskPublish,
  onUpdateAnswerClicked,
  likedAuthor, rightStage, rightMode, newRealAnswer, einsteinAnswer,
  leftBanner, onBannerDismiss, onSynthesisVisibilityChange,
  leftPowerHint,
}) {
  // 右屏 label: 初始 "???" (留个悬念, 等用户点赞镜像答主才揭晓);
  // 点赞后变成 "{答主名}的手机" + 震荡动画
  const rightLabel = likedAuthor ? `${likedAuthor.name}的手机` : '???';
  return (
    <main className="stage">
      <PhoneSlot label={`${CURRENT_USER.name}的手机`}>
        <RealisticPhone powered={leftOn} onTogglePower={onToggleLeft} hintPulse={leftPowerHint}>
          <LeftPhone
            onScreenChange={onLeftScreenChange}
            onMirrorAnswersReady={onLeftMirrorAnswersReady}
            onLike={onLeftLike}
            onQueryChange={onLeftQueryChange}
            onAskPublish={onLeftAskPublish}
            newRealAnswer={newRealAnswer}
            einsteinAnswer={einsteinAnswer}
            leftBanner={leftBanner}
            onBannerDismiss={onBannerDismiss}
            onSynthesisVisibilityChange={onSynthesisVisibilityChange}/>
        </RealisticPhone>
      </PhoneSlot>

      <PhoneSlot label={rightLabel} labelKey={rightLabel}>
        <RealisticPhone powered={rightOn} onTogglePower={onToggleRight}>
          <RightPhone
            stage={rightStage}
            mode={rightMode}
            likedAuthor={likedAuthor}
            onUpdateAnswerClicked={onUpdateAnswerClicked}/>
        </RealisticPhone>
      </PhoneSlot>
    </main>);
}

function PhoneSlot({ label, labelKey, children }) {
  // 当 label 变化 (例: "刘看山的手机" → "{答主名}的手机"), 重播一次 zoom-bounce 动画.
  // key={label} 强制 remount label DOM, CSS animation 重新跑.
  return (
    <div className="phone-slot">
      {children}
      <div className="phone-label" key={labelKey || label} style={{
        animation: 'phone-label-pop 720ms cubic-bezier(0.34, 1.56, 0.64, 1)',
      }}>{label}</div>
      <style>{`
        @keyframes phone-label-pop {
          0%   { transform: scale(1); }
          18%  { transform: scale(1.32); }
          32%  { transform: scale(0.94); }
          46%  { transform: scale(1.08); }
          62%  { transform: scale(0.98); }
          80%  { transform: scale(1.02); }
          100% { transform: scale(1); }
        }
      `}</style>
    </div>);
}

function RealisticPhone({ children, powered, onTogglePower, hintPulse }) {
  // 点亮瞬间播一次"白光闪过" 效果, ~600ms 自动收掉.
  // 之前只有 power-overlay 的 opacity 0→1 渐变, 用户反馈"点亮动画几乎看不到".
  const [flashing, setFlashing] = React.useState(false);
  const prevPoweredRef = React.useRef(powered);
  React.useEffect(() => {
    if (prevPoweredRef.current === false && powered === true) {
      setFlashing(true);
      const t = setTimeout(() => setFlashing(false), 620);
      prevPoweredRef.current = powered;
      return () => clearTimeout(t);
    }
    prevPoweredRef.current = powered;
  }, [powered]);

  return (
    <div className="device-wrap">
      <div className="side-mute" />
      <div className="side-vol-up" />
      <div className="side-vol-dn" />
      <button
        className={`side-power side-power-btn${hintPulse ? ' side-power-pulse' : ''}`}
        key={hintPulse ? 'pulsing' : 'idle'}
        onClick={onTogglePower}
        aria-label={powered ? '关闭屏幕' : '开启屏幕'} />
      <div className="device-outer">
        <div className="device-inner">
          {React.cloneElement(children, { powered })}
          <div className={`power-overlay ${powered ? 'on' : 'off'}`} />
          {flashing && <div className="power-flash" key={`flash-${prevPoweredRef.current}`} />}
        </div>
        <div className="device-glare" />
      </div>
    </div>);
}

Object.assign(window, { Page });
