/* global React, ReactDOM, PANELS, DeputyOrb, ChatWindow, playMenuTick, playSelect, playClose, WorkReel, WhatIDo, AboutMe, BadgeCoronation, Contact */
const { useState, useEffect, useRef, useCallback } = React;

const MENU = [
  { tag:"01", id:"START NEW GAME",   meta:"06 WORKS" },
  { tag:"02", id:"CHARACTER STATUS", meta:"ABOUT · CV" },
  { tag:"03", id:"LOAD ARCHIVE",     meta:"WHAT I DO" },
  { tag:"04", id:"SIDE QUESTS",      meta:"AI LAB" },
  { tag:"05", id:"COMMS",            meta:"OPEN ↗" },
];

const STATUS = ["SYSTEM ONLINE","OPEN TO WORK","AI DEPUTY ACTIVE","UPDATED 2026"];
const STATUS_COL = ["var(--violet)","var(--acid)","var(--rose)","var(--warn)"];

// 02 · SYSTEM INDEX — directory of the four content chapters
const INDEX = [
  { tag:"01", id:"CHARACTER PROFILE", cn:"角色档案_", en:"WHO RUNS THIS SYSTEM",         img:"assets/tape-01.png", edge:"#a07bff", to:"#about" },
  { tag:"02", id:"TIMELINE / JOURNEY", cn:"履历_",    en:"HOW THIS SYSTEM EVOLVED",       img:"assets/tape-02.png", edge:"#8bf06a", to:"#timeline" },
  { tag:"03", id:"SKILLS",            cn:"能力栈_",   en:"WHAT THIS SYSTEM CAN DO",       img:"assets/tape-03.png", edge:"#ff5fce", to:"#skills" },
  { tag:"04", id:"PROJECTS",          cn:"精选作品_", en:"MISSIONS & CASE FILES",         img:"assets/tape-04.png", edge:"#ff8a3d", to:"#work" },
  { tag:"05", id:"CONTACT",           cn:"请联系我_", en:"FINAL CUTSCENE → OPEN CHANNEL",  img:"assets/tape-05.png", edge:"#ffc24d", to:"#coronation" },
];

// Stylized motorcycle + rider silhouette (placeholder for OC art).
// Dark fills + violet rim accents on the upper edges.
function OCPlaceholder(){
  return (
    <svg viewBox="0 0 520 680" preserveAspectRatio="xMidYMax meet">
      <defs>
        <linearGradient id="bodyG" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#1a1623"/>
          <stop offset="1" stopColor="#08060c"/>
        </linearGradient>
        <linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="var(--violet-2)" stopOpacity="0.95"/>
          <stop offset="1" stopColor="var(--violet)" stopOpacity="0"/>
        </linearGradient>
        <radialGradient id="ground" cx="50%" cy="100%" r="60%">
          <stop offset="0" stopColor="rgba(216,170,255,0.35)"/>
          <stop offset="1" stopColor="rgba(0,0,0,0)"/>
        </radialGradient>
        <filter id="glow"><feGaussianBlur stdDeviation="2"/></filter>
      </defs>

      {/* ground pool */}
      <ellipse cx="260" cy="640" rx="240" ry="34" fill="url(#ground)"/>

      {/* motorcycle */}
      <g stroke="var(--stroke)" strokeWidth="1.5" fill="url(#bodyG)">
        {/* rear wheel */}
        <circle cx="125" cy="555" r="60" />
        <circle cx="125" cy="555" r="28" fill="#0a080e"/>
        {/* front wheel */}
        <circle cx="430" cy="565" r="58" />
        <circle cx="430" cy="565" r="26" fill="#0a080e"/>
        {/* frame / tank */}
        <path d="M150,500 C170,440 230,420 280,425 L370,430 C400,432 420,450 430,485 L430,540 L380,540 L370,510 L260,510 L240,540 L160,540 Z" />
        {/* fairing / nose */}
        <path d="M400,440 C440,440 470,460 470,500 L470,540 L430,540 Z" />
        {/* exhaust */}
        <path d="M150,540 L100,560 L90,570 L150,560 Z" fill="#0a080e"/>
        {/* handlebars */}
        <path d="M410,425 L460,400 M410,425 L460,440" stroke="var(--stroke)" strokeWidth="3" strokeLinecap="round" fill="none"/>
        {/* light */}
        <ellipse cx="465" cy="475" rx="14" ry="9" fill="var(--rose)" filter="url(#glow)" opacity=".75"/>
      </g>

      {/* rim light on tank/fairing */}
      <path d="M280,425 L370,430 C400,432 420,450 430,485" stroke="url(#rim)" strokeWidth="3" fill="none" strokeLinecap="round"/>
      <path d="M170,490 C195,445 240,425 280,425" stroke="url(#rim)" strokeWidth="2.5" fill="none" strokeLinecap="round" opacity=".7"/>

      {/* rider — seated, leaning forward */}
      <g>
        {/* legs */}
        <path d="M240,430 C235,470 230,500 250,540 L210,560 L195,545 Z" fill="url(#bodyG)" stroke="var(--stroke)" strokeWidth="1.2"/>
        <path d="M310,430 C320,470 330,500 360,520 L355,545 L335,540 Z" fill="url(#bodyG)" stroke="var(--stroke)" strokeWidth="1.2"/>
        {/* torso (jacket) */}
        <path d="M225,335 C220,310 245,290 275,288 L320,288 C355,290 370,310 365,340 L370,425 C355,440 230,440 220,425 Z" fill="url(#bodyG)" stroke="var(--stroke)" strokeWidth="1.5"/>
        {/* rim light on jacket top */}
        <path d="M232,310 C246,295 268,288 295,287 C322,287 348,295 360,312" stroke="var(--violet-2)" strokeWidth="1.8" fill="none" strokeLinecap="round" opacity=".9"/>
        <path d="M232,310 C246,295 268,288 295,287 C322,287 348,295 360,312" stroke="var(--rose)" strokeWidth="0.8" fill="none" strokeLinecap="round" opacity=".5" filter="url(#glow)"/>

        {/* arms onto handlebars */}
        <path d="M355,335 C380,360 410,395 440,420" stroke="var(--stroke)" strokeWidth="14" fill="none" strokeLinecap="round"/>
        <path d="M240,335 C265,360 290,395 320,420" stroke="var(--stroke)" strokeWidth="14" fill="none" strokeLinecap="round" opacity=".0"/>

        {/* helmet head */}
        <g>
          <path d="M256,225 C256,195 280,178 305,178 C335,178 358,200 358,232 L358,265 C358,280 340,290 320,288 L290,288 C270,290 256,278 256,260 Z" fill="#100c18" stroke="var(--stroke)" strokeWidth="1.5"/>
          {/* visor */}
          <path d="M266,238 L350,238 L348,260 L268,260 Z" fill="var(--violet)" opacity=".75"/>
          <path d="M266,238 L350,238" stroke="var(--rose)" strokeWidth="1.4" opacity=".9"/>
          {/* rim light on helmet top */}
          <path d="M262,220 C275,195 305,180 332,184 C346,186 354,196 357,210" stroke="var(--violet-2)" strokeWidth="2" fill="none" strokeLinecap="round" opacity=".95"/>
          {/* small antenna / ear bud */}
          <circle cx="356" cy="232" r="3" fill="var(--rose)"/>
        </g>
      </g>

      {/* tags */}
      <g fontFamily="IBM Plex Mono" fontSize="9" fill="var(--text-mute)" letterSpacing="1">
        <text x="40" y="595">NN-OC · v0</text>
        <text x="440" y="595" textAnchor="end">PLACEHOLDER</text>
      </g>
    </svg>
  );
}

// Floating CRT screen, top-right of hero
function FloatingCRT(){
  return (
    <div style={{
      position:"absolute", right:"10vw", top:"22vh",
      width:200, height:140, border:"1.5px solid var(--stroke)", borderRadius:6,
      background:"#0a080e",
      boxShadow:"0 10px 30px rgba(0,0,0,.6), 0 0 20px rgba(124,92,255,.25)",
      transform:"rotate(-4deg)",
      overflow:"hidden",
    }}>
      <div style={{ position:"absolute", inset:0, background:"repeating-linear-gradient(to bottom, rgba(124,92,255,.08) 0 1px, transparent 1px 3px)"}}/>
      <div style={{ padding:8, fontFamily:"IBM Plex Mono", fontSize:9, color:"var(--violet-2)", letterSpacing:".15em"}}>CAM_02 · LIVE</div>
      <div style={{ position:"absolute", left:"50%", top:"55%", transform:"translate(-50%,-50%)", width:80, height:60, border:"1px solid var(--violet-deep)"}}/>
      <div style={{ position:"absolute", bottom:8, left:8, right:8, display:"flex", justifyContent:"space-between", fontFamily:"IBM Plex Mono", fontSize:8, color:"var(--text-mute)"}}>
        <span>● REC</span><span>00:42:18</span>
      </div>
    </div>
  );
}

// ---------- MENU ITEM ----------
function MenuItem({ m, active, onPick, delay=0 }){
  const enter = () => playMenuTick();
  return (
    <button
      className={"menu-item" + (active?" is-active":"")}
      style={{ "--rd": delay+"ms" }}
      onMouseEnter={enter}
      onFocus={enter}
      onClick={()=>{ playSelect(); onPick(m.id); }}
    >
      <span className="tag mono">[{m.tag}]</span>
      <span className="id">{m.id}</span>
      <span className="sub mono">{m.meta}</span>
      <span className="arrow">›</span>
    </button>
  );
}

// ---------- PANEL ----------
function Panel({ openKey, onClose }){
  const def = openKey ? PANELS[openKey] : null;
  const open = !!def;
  const Render = def?.render;
  return (
    <div className={"panel-overlay" + (open?" open":"")} onClick={(e)=>{ if(e.target.classList.contains("panel-overlay")) onClose(); }}>
      <div className="panel-card">
        {def && (
          <>
            <div className="panel-head">
              <span className="tag">{def.tag}</span>
              <span className="h">{openKey}</span>
              <button className="x" onClick={onClose} aria-label="Close panel">×</button>
            </div>
            <div className="panel-body">
              <Render/>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// ---------- MAIN APP ----------
function App(){
  const [openPanel, setOpenPanel] = useState(null);
  const [chatOpen, setChatOpen] = useState(false);
  const [activeMenu, setActiveMenu] = useState(null);
  const [menuIn, setMenuIn] = useState(false);
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
  const [activeSection, setActiveSection] = useState("HOME");
  useEffect(()=>{ window.__openChat = ()=> setChatOpen(true); return ()=>{ try{ delete window.__openChat; }catch(e){} }; }, []);

  // ---- scroll-spy: track current section for the mobile nav label/highlight ----
  useEffect(()=>{
    const map = [[".hero-screen","HOME"],["#index","INDEX"],["#about","PROFILE"],["#timeline","TIMELINE"],["#skills","SKILLS"],["#work","PROJECTS"],["#coronation","CONTACT"]];
    const els = map.map(([sel,label])=>{ const el=document.querySelector(sel); return el?{el,label}:null; }).filter(Boolean);
    if(!els.length) return;
    const io = new IntersectionObserver((entries)=>{
      entries.forEach(e=>{ if(e.isIntersecting){ const m=els.find(x=>x.el===e.target); if(m) setActiveSection(m.label); } });
    }, { rootMargin:"-45% 0px -45% 0px", threshold:0 });
    els.forEach(x=>io.observe(x.el));
    return ()=> io.disconnect();
  }, []);
  // lock body scroll while the mobile menu sheet is open
  useEffect(()=>{ if(mobileMenuOpen){ const p=document.body.style.overflow; document.body.style.overflow="hidden"; return ()=>{ document.body.style.overflow=p; }; } }, [mobileMenuOpen]);
  const [playing, setPlaying] = useState(true);
  const videoStartRef = useRef(false);
  const blinkRef = useRef(null);
  const playingRef = useRef(false);
  const aliveT0 = useRef(0);
  useEffect(()=>{ playingRef.current = playing; }, [playing]);
  const [sound, setSound] = useState(false);   // false = muted (default)
  const soundRef = useRef(false);
  useEffect(()=>{ soundRef.current = sound; }, [sound]);
  const ambientRef = useRef(null);             // {ctx, gain, nodes}
  const stageRef = useRef(null);
  const glowRef  = useRef(null);
  const ocRef    = useRef(null);
  const kanjiRef = useRef(null);
  const heroRef  = useRef(null);
  const dimRef   = useRef(null);
  const titleRef = useRef(null);
  const hintRef  = useRef(null);
  const menuRef  = useRef(null);
  const menuSecRef = useRef(null);
  const indexSecRef = useRef(null);
  const indexFlashRef = useRef(null);
  const flashFiredRef = useRef(false);
  const tapeRowRef = useRef(null);
  const tapeStageRef = useRef(null);
  const [tapeActive, setTapeActive] = useState(0);
  const tapePos = useRef(0);          // smoothed carousel position (float index)
  const tapeTarget = useRef(0);       // snap target index
  const tapeVel = useRef(0);          // inertia velocity
  const tapeActiveRef = useRef(0);
  const tapeDragEnd = useRef(0);      // ts of last real swipe — guards the click that follows
  const target   = useRef({ x:0.55, y:0.55 });
  const current  = useRef({ x:0.55, y:0.55 });

  // ---- SINGLE smooth-scroll provider (Lenis). Initialised exactly once. ----
  useEffect(()=>{
    if (!window.Lenis) return;
    const lenis = new window.Lenis({
      duration: 1.1,
      easing: (t)=> Math.min(1, 1.001 - Math.pow(2, -10*t)),
      smoothWheel: true,
      wheelMultiplier: 1,
      touchMultiplier: 1.6,
    });
    window.__lenis = lenis;
    let raf;
    const loop = (t)=>{ lenis.raf(t); raf = requestAnimationFrame(loop); };
    raf = requestAnimationFrame(loop);
    return ()=>{ cancelAnimationFrame(raf); lenis.destroy(); if(window.__lenis===lenis) window.__lenis=null; };
  }, []);

  // cursor → spotlight lerp loop
  useEffect(()=>{
    function onMove(e){
      const w = window.innerWidth, h = window.innerHeight;
      target.current.x = e.clientX / w;
      target.current.y = e.clientY / h;
    }
    window.addEventListener("mousemove", onMove);
    let raf;
    function tick(){
      current.current.x += (target.current.x - current.current.x) * 0.06;
      current.current.y += (target.current.y - current.current.y) * 0.06;
      const cx = current.current.x, cy = current.current.y;
      if (glowRef.current){
        glowRef.current.style.left = (cx*100)+"%";
        glowRef.current.style.top  = (cy*100)+"%";
      }
      // "alive" still — JS-driven so it runs regardless of CSS-animation quirks.
      if (!playingRef.current){
        if (!aliveT0.current) aliveT0.current = performance.now();
        const el = (performance.now() - aliveT0.current) / 1000;  // seconds since alive start
        const tt = performance.now() / 1000;
        const sc = 1.038 + 0.014 * Math.sin(tt * 0.85);
        const tx = 0.22 * Math.sin(tt * 0.55) + (cx - 0.5) * 0.5;
        const ty = 0.16 * Math.sin(tt * 0.42 + 1) + (cy - 0.5) * 0.4;
        const rot = 0.14 * Math.sin(tt * 0.6);
        if (ocRef.current)
          ocRef.current.style.transform = `scale(${sc}) translate(${tx}%, ${ty}%) rotate(${rot}deg)`;
        // two soft blinks during the still hold (~1.4s and ~3.0s)
        if (blinkRef.current){
          const pulse = (c,w)=>{ const k=(el-c)/w; return (k>0&&k<1)? Math.sin(k*Math.PI) : 0; };
          const b = Math.max(pulse(1.4,0.34), pulse(3.0,0.34)*0.85);
          blinkRef.current.style.opacity = b.toFixed(3);
        }
      }
      raf = requestAnimationFrame(tick);
    }
    raf = requestAnimationFrame(tick);
    return ()=>{ window.removeEventListener("mousemove", onMove); cancelAnimationFrame(raf);};
  }, []);

  // scroll → fade hero title, grow OC dim, parallax OC + reveal menu
  useEffect(()=>{
    function onScroll(){
      const y = window.scrollY || window.pageYOffset || 0;
      const vh = window.innerHeight || 1;
      const p = Math.min(Math.max(y / (vh*0.7), 0), 1);
      if (dimRef.current)  dimRef.current.style.opacity = String(p);
      // Hero PIN handoff: gentle push-in toward the OC — transform + opacity ONLY (no filter on scroll)
      if (heroRef.current){
        const s = 1 + p*0.5;                        // softer push-in
        heroRef.current.style.transformOrigin = "72% 46%";   // focal point = OC right side
        heroRef.current.style.transform = `scale(${s.toFixed(4)})`;
      }
      if (titleRef.current){
        titleRef.current.style.opacity = String(1 - Math.min(p*1.6, 1));
        titleRef.current.style.transform = `translateY(${(-p*70).toFixed(1)}px)`;
      }
      if (hintRef.current) hintRef.current.style.opacity = String(1 - Math.min(p*1.8, 1));
      // sound is an OPENING-only ambiance: once you page past the hero, cut it automatically
      if (soundRef.current && p > 0.6){ setSound(false); }
    }
    window.addEventListener("scroll", onScroll, { passive:true });
    onScroll();
    return ()=> window.removeEventListener("scroll", onScroll);
  }, []);

  // UNIFIED entrance: reveal [data-cine] elements ONCE on enter (opacity 0→1, y 60→0).
  // No per-frame scroll transforms. Primary = IntersectionObserver; fallback = a cheap
  // rect check on each Lenis scroll (covers cases where IO is throttled). Each element
  // is revealed once, then dropped from the watch set so steady-state scroll stays free.
  useEffect(()=>{
    let pending = Array.prototype.slice.call(document.querySelectorAll("[data-cine]"));
    const reveal = (el)=>{ el.classList.add("in"); };
    const sweep = ()=>{
      if (!pending.length) return;
      const vh = window.innerHeight || 1;
      pending = pending.filter((el)=>{
        const r = el.getBoundingClientRect();
        if (r.top < vh*0.86 && r.bottom > 0){ reveal(el); return false; }
        return true;
      });
    };
    const io = new IntersectionObserver((entries)=>{
      entries.forEach((e)=>{ if (e.isIntersecting){ reveal(e.target); io.unobserve(e.target); pending = pending.filter((x)=>x!==e.target); } });
    }, { threshold:0.18, rootMargin:"0px 0px -8% 0px" });
    pending.forEach((el)=> io.observe(el));
    sweep();                                   // reveal whatever is already on screen
    const onScroll = ()=> sweep();
    if (window.__lenis) window.__lenis.on("scroll", onScroll);
    window.addEventListener("scroll", onScroll, { passive:true });
    window.addEventListener("resize", onScroll);
    return ()=>{
      io.disconnect();
      if (window.__lenis) window.__lenis.off("scroll", onScroll);
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, []);

  // keyboard shortcuts
  useEffect(()=>{
    function onKey(e){
      if (e.key === "Escape"){
        if (chatOpen) { setChatOpen(false); playClose(); return; }
        if (openPanel){ setOpenPanel(null); playClose(); return; }
      }
      if (e.key === "`" || e.key === "~"){
        e.preventDefault();
        setChatOpen(o => !o);
        playSelect();
      }
      // when the INDEX carousel is in view, ←/→ browse, Enter inserts
      const ix = document.getElementById("index");
      if (ix && !chatOpen && !openPanel){
        const r = ix.getBoundingClientRect();
        const inView = r.top < window.innerHeight*0.5 && r.bottom > window.innerHeight*0.5;
        if (inView){
          if (e.key === "ArrowLeft"){ e.preventDefault(); stepTape(-1); }
          else if (e.key === "ArrowRight"){ e.preventDefault(); stepTape(1); }
          else if (e.key === "Enter"){ e.preventDefault(); onTapeClick(tapeActiveRef.current); }
        }
      }
    }
    window.addEventListener("keydown", onKey);
    return ()=> window.removeEventListener("keydown", onKey);
  }, [openPanel, chatOpen, activeMenu, stepTape, onTapeClick]);

  const pick = useCallback((id)=>{
    setActiveMenu(id);
    setOpenPanel(id);
  }, []);

  // ---- SOUND: opening-only ambiance (video audio + low hum). Always starts OFF
  //      so the breathing button invites a click; auto-cuts when you page past hero. ----

  function buildAmbient(){
    try{
      const AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) return null;
      const ctx = new AC();
      const master = ctx.createGain();
      master.gain.value = 0;
      master.connect(ctx.destination);
      // low hum (two detuned oscillators)
      const o1 = ctx.createOscillator(); o1.type="sine"; o1.frequency.value=56;
      const o2 = ctx.createOscillator(); o2.type="sine"; o2.frequency.value=56.4;
      const humG = ctx.createGain(); humG.gain.value=0.14;
      o1.connect(humG); o2.connect(humG); humG.connect(master);
      // faint filtered noise "tape hiss"
      const buf = ctx.createBuffer(1, ctx.sampleRate*2, ctx.sampleRate);
      const d = buf.getChannelData(0);
      for (let i=0;i<d.length;i++) d[i] = (Math.random()*2-1)*0.6;
      const noise = ctx.createBufferSource(); noise.buffer=buf; noise.loop=true;
      const lp = ctx.createBiquadFilter(); lp.type="lowpass"; lp.frequency.value=620;
      const noiseG = ctx.createGain(); noiseG.gain.value=0.05;
      noise.connect(lp); lp.connect(noiseG); noiseG.connect(master);
      o1.start(); o2.start(); noise.start();
      return { ctx, master };
    }catch(e){ return null; }
  }

  // apply sound state to video + ambient
  useEffect(()=>{
    const v = ocRef.current;
    if (v){
      v.muted = !sound;
      if (sound){ const pr = v.play && v.play(); if (pr && pr.catch) pr.catch(()=>{}); }
    }
    if (sound){
      if (!ambientRef.current) ambientRef.current = buildAmbient();
      const a = ambientRef.current;
      if (a){
        if (a.ctx.state === "suspended") a.ctx.resume();
        a.master.gain.cancelScheduledValues(a.ctx.currentTime);
        a.master.gain.setTargetAtTime(0.5, a.ctx.currentTime, 0.6);  // fade in
      }
    } else {
      const a = ambientRef.current;
      if (a){ a.master.gain.cancelScheduledValues(a.ctx.currentTime); a.master.gain.setTargetAtTime(0, a.ctx.currentTime, 0.4); }
    }
  }, [sound]);

  const toggleSound = useCallback(()=>{
    try{ playSelect(); }catch(e){}
    setSound(s => !s);
  }, []);

  // smooth scroll to a section anchor (via the single Lenis instance)
  const goTo = useCallback((sel)=>{
    try{ playSelect(); }catch(e){}
    if (sel === "#top"){
      if (window.__lenis) window.__lenis.scrollTo(0); else window.scrollTo({ top:0, behavior:"smooth" });
      return;
    }
    const el = document.querySelector(sel);
    if (!el) return;
    // CONTACT (#coronation) is a 480vh scroll-scrubbed cinematic; the contact panel
    // only appears at the very END. Jump to the bottom of that section so the
    // contact card is revealed directly instead of landing on the animation start.
    if (sel === "#coronation"){
      const vh = window.innerHeight || 0;
      const target = window.scrollY + el.getBoundingClientRect().top + (el.offsetHeight - vh);
      if (window.__lenis){ window.__lenis.scrollTo(target); }
      else { window.scrollTo({ top:target, behavior:"smooth" }); }
      return;
    }
    if (window.__lenis){ window.__lenis.scrollTo(el, { offset:0 }); }
    else { const y = window.scrollY + el.getBoundingClientRect().top; window.scrollTo({ top:y, behavior:"smooth" }); }
  }, []);

  // ---- INDEX cassette carousel: drag / wheel / arrows, inertia + snap-to-center ----
  const N = INDEX.length;
  const clampTape = (v)=> Math.max(0, Math.min(N-1, v));
  const snapTape = useCallback((i)=>{
    i = clampTape(i);
    tapeTarget.current = i; tapeVel.current = 0;
    try{ playTick && playTick({freq:300+i*40,dur:0.07,type:"square",gain:0.05}); }catch(e){}
  }, []);
  const stepTape = useCallback((d)=>{ snapTape(Math.round(tapeTarget.current)+d); }, [snapTape]);
  const onTapeClick = useCallback((i)=>{
    // ignore the click that browsers synthesise at the end of a swipe/drag
    if (tapeDragEnd.current && performance.now() - tapeDragEnd.current < 320) return;
    if (Math.round(tapePos.current) !== i){ snapTape(i); return; }   // not centered → just bring to center
    try{ playSelect(); }catch(e){}
    goTo(INDEX[i].to);
  }, [snapTape]);

  // carousel render loop + input
  useEffect(()=>{
    const stage = tapeStageRef.current, track = tapeRowRef.current;
    if (!stage || !track) return;
    const cards = Array.prototype.slice.call(track.children);
    let raf = 0, dragging=false, startX=0, startY=0, startPos=0, lastX=0, lastT=0, moved=false, axis=null;

    const SPACING = ()=> Math.min(window.innerWidth*0.30, 420);  // px between tape centers

    function layout(){
      const pos = tapePos.current;
      const sp = SPACING();
      for (let i=0;i<cards.length;i++){
        const off = i - pos;                       // distance from center in index units
        const aoff = Math.abs(off);
        const x = off * sp;
        const z = -aoff * 260;                     // depth recede (deeper → center pops more)
        const scale = Math.max(0.5, 1 - aoff*0.26); // sharper falloff so the centre tape dominates
        const rotY = Math.max(-26, Math.min(26, -off*16));
        const op = Math.max(0.16, 1 - aoff*0.48);
        const blur = Math.min(8, aoff*aoff*3.6);
        const c = cards[i];
        c.style.transform = `translate3d(${x.toFixed(1)}px, 0, ${z.toFixed(1)}px) rotateY(${rotY.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
        c.style.opacity = op.toFixed(3);
        c.style.filter = `blur(${blur.toFixed(2)}px)`;
        c.style.zIndex = String(100 - Math.round(aoff*10));
        const isC = aoff < 0.5;
        c.classList.toggle("is-center", isC);
        // chromatic aberration ghost grows off-center
        const ab = c.querySelector(".tape-ab");
        if (ab){ ab.style.opacity = Math.min(0.5, aoff*0.4).toFixed(3); ab.style.transform = `translateX(${(off*4).toFixed(1)}px)`; }
      }
    }
    function tick(){
      const t = tapeTarget.current;
      // spring toward target with mild inertia
      tapeVel.current += (t - tapePos.current) * 0.12;
      tapeVel.current *= 0.78;                    // damping → slight rebound
      tapePos.current += tapeVel.current;
      if (Math.abs(t - tapePos.current) < 0.001 && Math.abs(tapeVel.current) < 0.001){ tapePos.current = t; }
      layout();
      const ai = clampTape(Math.round(tapePos.current));
      if (ai !== tapeActiveRef.current){ tapeActiveRef.current = ai; setTapeActive(ai); }
      raf = requestAnimationFrame(tick);
    }
    raf = requestAnimationFrame(tick);

    // pointer drag — works for mouse AND touch (so mobile = horizontal swipe, no wheel)
    function down(e){ dragging=true; moved=false; axis=null; startX=lastX=e.clientX; startY=e.clientY; lastT=performance.now(); startPos=tapePos.current; tapeVel.current=0; }
    function move(e){
      if (!dragging) return;
      const dx = e.clientX - startX;
      const dy = e.clientY - startY;
      // lock the gesture to one axis on first real movement: horizontal → drive the
      // carousel; vertical → bail so the PAGE scrolls (mobile can swipe up/down to leave).
      if (axis===null && (Math.abs(dx) > 6 || Math.abs(dy) > 6)){
        axis = Math.abs(dx) >= Math.abs(dy) ? "x" : "y";
        if (axis === "x" && stage.setPointerCapture){ try{ stage.setPointerCapture(e.pointerId); }catch(_){} }
      }
      if (axis === "y"){ dragging=false; return; }
      if (axis !== "x") return;
      if (Math.abs(dx) > 4) moved=true;
      let p = startPos - dx / SPACING();
      // ONE swipe = ONE card: cap the live drag to ~1 card each side of where it began,
      // so you can never fling visually through several tapes at once.
      p = Math.max(startPos - 1.15, Math.min(startPos + 1.15, p));
      tapePos.current = Math.max(-0.4, Math.min(N-0.6, p));
      tapeTarget.current = tapePos.current;       // follow finger
      lastX = e.clientX; lastT = performance.now();
    }
    function up(e){
      if (!dragging) return; dragging=false;
      if (axis === "x" && moved) tapeDragEnd.current = performance.now();   // suppress the trailing click
      // land on AT MOST ±1 from the card the gesture started on, then let the spring
      // (tapeVel inertia + rebound in tick()) carry it home with the bouncy settle.
      const startIndex = Math.round(startPos);
      const delta = tapePos.current - startPos;        // index units dragged (+ = next)
      let dir = 0;
      if (delta > 0.16) dir = 1; else if (delta < -0.16) dir = -1;
      snapTape(startIndex + dir);
      axis = null;
    }

    stage.addEventListener("pointerdown", down);
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("resize", layout);
    layout();
    return ()=>{
      cancelAnimationFrame(raf);
      stage.removeEventListener("pointerdown", down);
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("resize", layout);
    };
  }, [snapTape, stepTape]);

  // ===== DIRECTION-SEPARATED INPUT =====
  // The page NEVER pins. Vertical wheel/scroll is left entirely to the page (Lenis).
  // Only a HORIZONTAL-dominant wheel (trackpad sideways) over the carousel switches cards.
  // Card switching otherwise comes from swipe (pointer drag), arrows and dots.
  useEffect(()=>{
    const stage = tapeStageRef.current;
    if (!stage) return;
    let acc=0, cd=0;
    function onWheel(e){
      // vertical intent → do nothing, let the page scroll normally
      if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
      e.preventDefault();                       // horizontal intent only
      const now = performance.now();
      if (now < cd) return;
      acc += e.deltaX;
      if (Math.abs(acc) > 40){ stepTape(acc>0?1:-1); acc=0; cd = now + 340; }
    }
    stage.addEventListener("wheel", onWheel, { passive:false });
    return ()=> stage.removeEventListener("wheel", onWheel);
  }, [stepTape]);

  // (image-first crossfade removed — video plays full-bleed from the start)

  return (
    <>
      {/* fixed night bg */}
      <div className="stage" ref={stageRef}/>

      {/* fixed OC backdrop — full-bleed image→video, persists through scroll */}
      <div className="hero" ref={heroRef}>
        {/* OC media: full-bleed video, plays from the start */}
        <div className="oc-photo playing">
          <video className="layer vid"
                  src="assets/hero-v3.mp4"
                  poster="assets/oc-hero-v2.jpg"
                  autoPlay muted loop playsInline preload="auto"
                  ref={ocRef}/>
          <div className="scan"/>
          <div className="fade"/>
        </div>
        {/* violet spotlight that follows the cursor */}
        <div className="glow" ref={glowRef}/>
      </div>

      {/* fixed dim that grows as you scroll into the menu */}
      <div className="oc-dim" ref={dimRef}/>

      {/* slogan rail (left edge, over the black negative space) */}
      <div className="jp-rail" style={{ left:22, top:60, bottom:60 }}>BUILDING WITH AI, THINKING WITH STORIES</div>

      {/* top bar (fixed) */}
      <div className="topbar">
        <span className="emblem">AX</span>
        <span className="brandmark" style={{ display:"flex", alignItems:"baseline", gap:0 }}>
          <span style={{ fontFamily:"'Noto Sans SC',sans-serif", fontWeight:900, fontSize:16, letterSpacing:".08em", color:"var(--text)" }}>谢安妮</span>
          <span className="tb-desk" style={{ color:"var(--violet)", padding:"0 6px" }}>·</span>
          <span className="tb-desk" style={{ fontFamily:"'IBM Plex Mono',monospace", fontWeight:700, fontSize:12, letterSpacing:".06em", color:"var(--text-2)" }}>v2026</span>
        </span>
        <span className="topbar-current">{activeSection}</span>
        <nav className="nav">
          <a href="#top" className="active" onClick={(e)=>{e.preventDefault(); goTo("#top");}}>HOME</a>
          <a href="#index" onClick={(e)=>{e.preventDefault(); goTo("#index");}}>INDEX</a>
          <a href="#about" onClick={(e)=>{e.preventDefault(); goTo("#about");}}>PROFILE</a>
          <a href="#timeline" onClick={(e)=>{e.preventDefault(); goTo("#timeline");}}>TIMELINE</a>
          <a href="#skills" onClick={(e)=>{e.preventDefault(); goTo("#skills");}}>SKILLS</a>
          <a href="#work" onClick={(e)=>{e.preventDefault(); goTo("#work");}}>PROJECTS</a>
          <a href="#coronation" onClick={(e)=>{e.preventDefault(); goTo("#coronation");}}>CONTACT</a>
        </nav>
        <span style={{ flex:1 }}/>
        <button onClick={toggleSound} title="Toggle sound" className={"sound-btn" + (sound?" on":" off")}>
          <span className="sb-dot"/><span className="sb-txt">{sound ? "SOUND ON" : "SOUND OFF"}</span>
        </button>
        <span className="tb-desk" style={{ opacity:.35 }}>|</span>
        <span className="tb-desk"><b style={{ color:"var(--text)"}}>CN</b> · <span style={{ color:"var(--text-mute)"}}>EN</span></span>
        <button className="btn btn-ghost tb-desk" style={{ padding:"5px 12px", fontSize:10, borderRadius:999, whiteSpace:"nowrap", flexShrink:0 }} onClick={()=>{ playSelect(); setChatOpen(true); }}>◉ TALK TO ANNIE</button>
        <button className="menu-btn" aria-label="Open menu" aria-expanded={mobileMenuOpen} onClick={()=>{ playSelect(); setMobileMenuOpen(true); }}>
          <span/><span/><span/>
        </button>
      </div>

      {/* mobile full-screen menu sheet */}
      <div className={"mobile-menu" + (mobileMenuOpen?" open":"")} role="dialog" aria-label="Navigation menu" aria-hidden={!mobileMenuOpen}>
        <div className="mm-top">
          <span className="mm-brand"><span className="emblem">AX</span><b>谢安妮</b><i>· CREATIVE OS</i></span>
          <button className="mm-x" aria-label="Close menu" onClick={()=>{ playClose(); setMobileMenuOpen(false); }}>✕</button>
        </div>
        <nav className="mm-list">
          {[["HOME","#top"],["INDEX","#index"],["PROFILE","#about"],["TIMELINE","#timeline"],["SKILLS","#skills"],["PROJECTS","#work"],["CONTACT","#coronation"]].map(([label,href],i)=>(
            <a key={href} href={href} className={activeSection===label?"on":""}
               onClick={(e)=>{ e.preventDefault(); setMobileMenuOpen(false); setTimeout(()=>goTo(href),120); }}>
              <span className="mm-n">{String(i+1).padStart(2,"0")}</span>
              <span className="mm-l">{label}</span>
              <span className="mm-ar">→</span>
            </a>
          ))}
        </nav>
        <button className="mm-talk" onClick={()=>{ playSelect(); setMobileMenuOpen(false); setChatOpen(true); }}>◉ TALK TO ANNIE</button>
        <div className="mm-foot">© 2026 · 谢安妮 ANNIE XIE · v2026.05</div>
      </div>

      {/* scrolling content over the fixed backdrop */}
      <main className="scroll" id="top">
        {/* 01 — HERO: title + job only */}
        <section className="screen hero-screen">
          <div className="title-card" ref={titleRef}>
            <CRTTitle/>
          </div>
          <div className="scroll-hint" ref={hintRef}>
            <span>SCROLL TO BOOT · 向下进入系统</span>
            <span className="ar">▼</span>
          </div>
        </section>

        {/* 02 — SYSTEM INDEX: fixed glitch-CRT chamber + draggable cassette carousel */}
        <section className="screen index-screen" id="index" ref={indexSecRef}>
          <div className="index-sticky">
          <div className="index-room"/>
          <div className="index-darken"/>
          <div className="index-vig"/>
          <div className="crt-glitch"/>
          <div className="tyndall"/>
          <div className="index-head" data-cine>
            <div className="ix-label">
              <span className="ix-bk">[</span>
              <span className="ix-k">INDEX</span>
              <span className="ix-sep">/</span>
              <span className="ix-cn">系统目录</span>
              <span className="ix-bk">]</span>
            </div>
            <div className="ix-sub">SELECT A MODULE</div>
            <div className="ix-sub2">选择一个模块进入 Annie Creative OS</div>
          </div>

          {/* draggable carousel stage */}
          <div className="tape-stage" ref={tapeStageRef}>
            <div className="tape-track" ref={tapeRowRef}>
              {INDEX.map((m,i)=>(
                <button key={m.id} className="tape-card" data-tape={i}
                        style={{ "--edge": m.edge, backgroundImage:`url(${m.img})` }}
                        onClick={()=>onTapeClick(i)}>
                  <span className="tape-ab" style={{ backgroundImage:`url(${m.img})` }}/>
                  <span className="tape-glow"/>
                </button>
              ))}
            </div>
          </div>

          <button className="tape-nav prev" onClick={()=>stepTape(-1)} aria-label="Previous chapter">‹</button>
          <button className="tape-nav next" onClick={()=>stepTape(1)} aria-label="Next chapter">›</button>

          {/* center chapter info plate */}
          <div className="tape-info" style={{ "--edge": INDEX[tapeActive].edge }}>
            <div className="seq">
              <span className="lit"><b>● </b>INSERT TAPE</span>
              <span className="lit"><b>● </b>SELECT CHAPTER</span>
              <span className="lit"><b>● </b>ACCESS READY</span>
            </div>
            <div className="ti-sel">
              <span className="ti-now">SELECTED</span>
              <span className="ti-tag">{INDEX[tapeActive].tag}</span>
              <span className="ti-id">{INDEX[tapeActive].id}</span>
              <span className="ti-cn">{INDEX[tapeActive].cn}</span>
            </div>
            <div className="ti-en">{INDEX[tapeActive].en}</div>
            <button className="ti-cta" onClick={()=>onTapeClick(tapeActive)}>► INSERT &amp; ENTER</button>
          </div>

          <div className="index-dots">
            {INDEX.map((m,i)=>(
              <i key={m.id} className={i===tapeActive?"on":""} style={{ "--edge": m.edge }} onClick={()=>snapTape(i)}/>
            ))}
          </div>

          <div className="index-foot" data-cine>
            <span className="key">◂ SLIDE TO BROWSE ▸</span>
            <span className="key">⏎ CLICK TO LOAD</span>
            <span className="key">~ TALK TO ANNIE</span>
          </div>
          <div className="index-edge top"/>
          <div className="index-edge bottom"/>
          </div>
        </section>

        {/* 03 — CHARACTER PROFILE */}
        <AboutMe/>

        {/* 04 — TIMELINE */}
        <Timeline/>

        {/* 05 — SKILLS */}
        <WhatIDo/>

        {/* 06 — PROJECTS (film reel) */}
        <WorkReel/>

        {/* 07 — CONTACT (badge coronation finale) */}
        <BadgeCoronation/>
      </main>

      {/* deputy orb (fixed) */}
      <DeputyOrb onOpen={()=>setChatOpen(true)}/>

      {/* bottom status (fixed) */}
      <div className="botbar">
        {STATUS.map((s,i)=>(
          <span className="pill" key={s}>
            <span className="dot" style={{ background:STATUS_COL[i], boxShadow:`0 0 8px ${STATUS_COL[i]}`}}/>
            {s}
          </span>
        ))}
        <span style={{ flex:1 }}/>
        <span style={{ color:"var(--text-mute)"}}>© 2026 · 谢安妮 ANNIE XIE · v2026.05</span>
      </div>

      <div className="scanlines"/>
      <div className="vhs"/>
      <div className="bezel"/>

      {/* panel system */}
      <Panel openKey={openPanel} onClose={()=>{ playClose(); setOpenPanel(null);}}/>

      {/* chat */}
      <ChatWindow open={chatOpen} onClose={()=>setChatOpen(false)}/>
    </>
  );
}

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