/* darkexpress.top — composition (orb-forward AI hero + link index) */

const { useState, useEffect, useRef } = React;

// ============= DARKY CHAT (server proxy) ========================
// The OpenRouter key never reaches the browser — requests go to the
// /api/chat serverless function, which holds the key server-side.
const OR_MODELS = [
  { id: "openai/gpt-oss-120b:free", label: "GPT-OSS 120B" },
  { id: "deepseek/deepseek-v4-flash:free", label: "DeepSeek V4 Flash" },
  { id: "meta-llama/llama-3.3-70b-instruct:free", label: "Llama 3.3 70B" },
  { id: "qwen/qwen3-next-80b-a3b-instruct:free", label: "Qwen3 Next 80B" },
  { id: "google/gemma-4-31b-it:free", label: "Gemma 4 31B" },
];
const OR_DEFAULT_MODEL = OR_MODELS[0].id;

// Quick-access links shown in the orb's welcome greeting (from linktr.ee/darkexpress).
// Orbit gaps are cumulative between rings: sphere -> 3mm -> first ring,
// then +6mm to the next ring, and so on. They are not independent offsets
// from the central sphere.
function buildGreetingOrbitRings(items) {
  let accumulatedGapMm = 0;
  return items.map(({ sizeMm, gapFromPreviousMm, orbitOffsetMm = 0, ...item }) => {
    accumulatedGapMm += gapFromPreviousMm;
    return {
      ...item,
      size: `${sizeMm}mm`,
      orbit: `calc(var(--orb-visual-radius) + ${accumulatedGapMm + orbitOffsetMm}mm)`,
    };
  });
}

const GREETING_LINKS = buildGreetingOrbitRings([
  { label: "carriage return", tag: "live // youtube", href: "https://www.youtube.com/watch?v=TB0MXxOPnis&list=RDTB0MXxOPnis&start_radio=1&pp=ygULZGFya2V4cHJlc3OgBwE%3D", sizeMm: 2.8, gapFromPreviousMm: 3, orbitOffsetMm: 21, angle: -18, duration: "64s" },
  { label: "proportional", tag: "live // youtube", href: "https://www.youtube.com/live/oxbEVG1uy0w?si=ZtfnIxt5hpGu1j3E", sizeMm: 2.3, gapFromPreviousMm: 6, orbitOffsetMm: 17, angle: 42, duration: "64s" },
  { label: "cross//patch", tag: "live // youtube", href: "https://www.youtube.com/watch?v=EzHqDnfXOfo&t=1381s", sizeMm: 2.6, gapFromPreviousMm: 1, angle: 104, duration: "64s" },
  { label: "SoundCloud", tag: "stream", href: "https://soundcloud.com/dark_express", sizeMm: 2.1, gapFromPreviousMm: 7, angle: 178, duration: "64s" },
  { label: "Resident Advisor", tag: "profile", href: "https://ra.co/dj/darkexpress", sizeMm: 3, gapFromPreviousMm: 11, angle: 244, duration: "64s" },
  { label: "Collapsed", tag: "buy // bandcamp", href: "https://darkexpress.bandcamp.com/track/collapsed", sizeMm: 2.4, gapFromPreviousMm: 5, angle: 312, duration: "64s" },
  { id: "darkreaktor", action: "password", label: "Darkreaktor", tag: "encrypted // visual", href: "/darkreaktor", password: "Sakr", sizeMm: 3.1, gapFromPreviousMm: 9, angle: 158, duration: "64s" },
  { id: "sound-toggle", action: "sound", label: "on/off", tag: "audio", sizeMm: 2.5, gapFromPreviousMm: 13, angle: 72, duration: "64s" },
]);
const PLANET_LINK_EXIT_DELAY_MS = 980;

const ORB_PROMPTS = [
  "Send the next Darky transmission from a red particle sphere listening to an ambient drone. Keep it short, physical, nocturnal.",
  "Describe the hidden rhythm inside a slow drone system as if the light is waking up in orbit.",
  "Generate a compact Darky thought about rotating rhythms, memory, low-frequency pressure, and a signal becoming conscious.",
  "Speak as the orb after hearing the room: one atmospheric answer about darkness, pulse, and liquid machine light.",
  "Create the next cryptic message for a visitor touching a living audio-reactive sphere on darkexpress.top.",
  "Explain the current signal like an electronic music note from the future: brief, cinematic, and slightly dangerous.",
  "Give a new Darky response about drone textures, feedback loops, red glow, and the feeling before a drop.",
  "Write the next transmission from a machine that learned rhythm by watching black space and listening to bass.",
];

async function callChat(userPrompt) {
  const stored = localStorage.getItem("or_model");
  const model = OR_MODELS.some((m) => m.id === stored) ? stored : OR_DEFAULT_MODEL;

  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt: userPrompt, model }),
  });

  let data = null;
  try { data = await res.json(); } catch (_) {}

  if (!res.ok) {
    if (res.status === 429) throw new Error("Busy right now — wait a moment or switch engine.");
    throw new Error((data && data.error) || `Error ${res.status}`);
  }

  const content = data && data.content;
  if (!content || !content.trim()) throw new Error("Empty response.");
  return content;
}

function useFadeIn() {
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (e.isIntersecting) {
            e.target.classList.add("is-in");
            io.unobserve(e.target);
          }
        });
      },
      { threshold: 0.15 }
    );
    el.querySelectorAll(".fade-in").forEach((n) => io.observe(n));
    return () => io.disconnect();
  }, []);
  return ref;
}

function Nav() {
  // Sound activation is now driven by tapping the orb (see Hero / SoundController).
  // The visible toggle button has been removed; we keep the audio engine in a
  // dedicated controller component instead.
  return <nav className="nav" aria-hidden="true"></nav>;
}

function SoundController() {
  const [sound, setSound] = useState(false);
  const [soundTransitioning, setSoundTransitioning] = useState(false);
  const audioRef = useRef(null);
  const audioEngineRef = useRef(null);
  const MUSIC_BASE_VOLUME = 0.18;
  const MUSIC_DUCK_VOLUME = 0.153;
  const musicVolumeRef = useRef(MUSIC_BASE_VOLUME);
  const loopFadeRef = useRef(0);
  const soundFadeRef = useRef(0);
  const fadeFrameRef = useRef(0);
  const soundFadeFrameRef = useRef(0);
  const playFadeStartedAtRef = useRef(0);
  const loopRestartingRef = useRef(false);
  const soundStoppingRef = useRef(false);
  const LOOP_FADE_SECONDS = 2.5;

  const smoothFade = (value) => {
    const v = Math.max(0, Math.min(1, value));
    return v * v * (3 - 2 * v);
  };

  const applyMusicVolume = () => {
    const audio = audioRef.current;
    const volume = Math.max(0, Math.min(1, musicVolumeRef.current * loopFadeRef.current * soundFadeRef.current));
    if (audio) audio.volume = volume;
    const engine = audioEngineRef.current;
    if (engine && engine.masterGain) {
      try {
        engine.masterGain.gain.setValueAtTime(volume, engine.context.currentTime);
      } catch (_) {
        engine.masterGain.gain.value = volume;
      }
      return;
    }
  };

  const stopLoopFadeMonitor = () => {
    if (fadeFrameRef.current) {
      cancelAnimationFrame(fadeFrameRef.current);
      fadeFrameRef.current = 0;
    }
  };

  const stopSoundFade = () => {
    if (soundFadeFrameRef.current) {
      cancelAnimationFrame(soundFadeFrameRef.current);
      soundFadeFrameRef.current = 0;
    }
  };

  const fadeSoundVolumeTo = (target, onComplete, durationSeconds = LOOP_FADE_SECONDS) => {
    stopSoundFade();
    const from = soundFadeRef.current;
    const started = performance.now();
    const durationMs = Math.max(0.16, durationSeconds) * 1000;
    const tick = () => {
      const p = Math.min(1, (performance.now() - started) / durationMs);
      const eased = smoothFade(p);
      soundFadeRef.current = from + (target - from) * eased;
      applyMusicVolume();
      if (p < 1) {
        soundFadeFrameRef.current = requestAnimationFrame(tick);
      } else {
        soundFadeRef.current = target;
        applyMusicVolume();
        soundFadeFrameRef.current = 0;
        if (onComplete) onComplete();
      }
    };
    soundFadeFrameRef.current = requestAnimationFrame(tick);
  };

  const startLoopFadeMonitor = () => {
    stopLoopFadeMonitor();
    const tick = () => {
      const audio = audioRef.current;
      if (!audio || audio.paused || !window.darkySoundOn) {
        fadeFrameRef.current = 0;
        return;
      }

      let fade = 1;
      if (Number.isFinite(audio.duration) && audio.duration > 0) {
        const fadeSeconds = Math.min(LOOP_FADE_SECONDS, audio.duration * 0.5);
        const fadeIn = fadeSeconds ? smoothFade(audio.currentTime / fadeSeconds) : 1;
        const fadeOut = fadeSeconds ? smoothFade((audio.duration - audio.currentTime) / fadeSeconds) : 1;
        fade = Math.min(fadeIn, fadeOut);
      } else {
        fade = smoothFade(audio.currentTime / LOOP_FADE_SECONDS);
      }

      if (playFadeStartedAtRef.current) {
        const playFade = smoothFade((performance.now() - playFadeStartedAtRef.current) / (LOOP_FADE_SECONDS * 1000));
        fade = Math.min(fade, playFade);
        if (playFade >= 0.999) playFadeStartedAtRef.current = 0;
      }

      loopFadeRef.current = fade;
      applyMusicVolume();
      fadeFrameRef.current = requestAnimationFrame(tick);
    };
    fadeFrameRef.current = requestAnimationFrame(tick);
  };

  const restartAudioLoop = async () => {
    const audio = audioRef.current;
    if (!audio || !window.darkySoundOn || loopRestartingRef.current || soundStoppingRef.current) return;
    loopRestartingRef.current = true;
    stopLoopFadeMonitor();
    loopFadeRef.current = 0;
    playFadeStartedAtRef.current = performance.now();
    applyMusicVolume();
    audio.currentTime = 0;
    try {
      await audio.play();
      startLoopFadeMonitor();
    } catch (err) {
      console.warn("Audio loop restart blocked", err);
      window.darkySoundOn = false;
      if (audioEngineRef.current) audioEngineRef.current.state.enabled = false;
      if (window.darkyAudio) window.darkyAudio.enabled = false;
      setSound(false);
    } finally {
      loopRestartingRef.current = false;
    }
  };

  const getAudio = () => {
    if (!audioRef.current) {
      const audio = new Audio("uploads/darkexpress-main-theme.mp3");
      audio.loop = true;
      audio.preload = "auto";
      audio.volume = 0;
      audio.addEventListener("ended", restartAudioLoop);
      audioRef.current = audio;
    }
    return audioRef.current;
  };

  const finishSoundOff = (audio) => {
    stopLoopFadeMonitor();
    audio.pause();
    loopFadeRef.current = 0;
    soundFadeRef.current = 0;
    playFadeStartedAtRef.current = 0;
    applyMusicVolume();
    window.darkySoundOn = false;
    if (audioEngineRef.current) audioEngineRef.current.state.enabled = false;
    if (window.darkyAudio) window.darkyAudio.enabled = false;
    try { delete window.darkyAudioContext; } catch (_) {}
    soundStoppingRef.current = false;
    setSound(false);
    setSoundTransitioning(false);
  };

  const fadeOutSound = (durationSeconds = LOOP_FADE_SECONDS) => {
    const audio = audioRef.current;
    if (!audio || soundStoppingRef.current) return;
    if (audio.paused && !window.darkySoundOn && !sound) return;
    soundStoppingRef.current = true;
    setSoundTransitioning(true);
    fadeSoundVolumeTo(0, () => finishSoundOff(audio), durationSeconds);
  };

  const getAudioEngine = () => {
    const audio = getAudio();
    if (!audioEngineRef.current) {
      const AudioCtx = window.AudioContext || window.webkitAudioContext;
      if (!AudioCtx) {
        if (!window.darkyAudio) {
          window.darkyAudio = {
            enabled: false,
            energy: 0,
            bass: 0,
            mid: 0,
            high: 0,
            sample() { return this; },
          };
        }
        return null;
      }

      const context = new AudioCtx();
      window.darkyAudioContext = context;
      const source = context.createMediaElementSource(audio);
      const analyser = context.createAnalyser();
      analyser.fftSize = 1024;
      analyser.smoothingTimeConstant = 0.88;
      analyser.minDecibels = -90;
      analyser.maxDecibels = -18;
      source.connect(analyser);

      // Long, low-wet track reverb. The filtered tail adds space without
      // pushing the background music forward or overdriving the master.
      const buildTrackReverbIR = (ctx, duration = 8.4, decay = 4.6) => {
        const rate = ctx.sampleRate;
        const length = Math.max(1, Math.floor(rate * duration));
        const impulse = ctx.createBuffer(2, length, rate);
        for (let ch = 0; ch < 2; ch++) {
          const data = impulse.getChannelData(ch);
          let smoothNoise = 0;
          for (let i = 0; i < length; i++) {
            const t = i / length;
            smoothNoise = smoothNoise * 0.72 + (Math.random() * 2 - 1) * 0.28;
            const earlyBloom = Math.min(1, i / (rate * 0.18));
            data[i] = smoothNoise * earlyBloom * Math.pow(1 - t, decay);
          }
        }
        return impulse;
      };
      const trackDry = context.createGain();
      const trackWet = context.createGain();
      const trackPreDelay = context.createDelay(0.32);
      const trackTailHighpass = context.createBiquadFilter();
      const trackTailLowpass = context.createBiquadFilter();
      const trackConvolver = context.createConvolver();
      const trackMaster = context.createGain();
      trackConvolver.buffer = buildTrackReverbIR(context);
      trackDry.gain.value = 0.86;
      trackWet.gain.value = 0.14;
      trackMaster.gain.value = 0;
      trackPreDelay.delayTime.value = 0.12;
      trackTailHighpass.type = "highpass";
      trackTailHighpass.frequency.value = 170;
      trackTailLowpass.type = "lowpass";
      trackTailLowpass.frequency.value = 5400;
      analyser.connect(trackDry);
      analyser.connect(trackWet);
      trackDry.connect(trackMaster);
      trackWet.connect(trackPreDelay);
      trackPreDelay.connect(trackTailHighpass);
      trackTailHighpass.connect(trackTailLowpass);
      trackTailLowpass.connect(trackConvolver);
      trackConvolver.connect(trackMaster);
      trackMaster.connect(context.destination);

      const bins = new Uint8Array(analyser.frequencyBinCount);
      const state = {
        enabled: false,
        energy: 0,
        bass: 0,
        mid: 0,
        high: 0,
        sample() {
          if (!this.enabled || audio.paused) {
            this.energy *= 0.92;
            this.bass *= 0.92;
            this.mid *= 0.92;
            this.high *= 0.92;
            return this;
          }

          analyser.getByteFrequencyData(bins);
          const nyquist = context.sampleRate * 0.5;
          const band = (from, to) => {
            let sum = 0;
            let count = 0;
            for (let i = 0; i < bins.length; i++) {
              const hz = (i / bins.length) * nyquist;
              if (hz >= from && hz < to) {
                sum += bins[i] / 255;
                count++;
              }
            }
            return count ? sum / count : 0;
          };

          const bassNow = band(35, 180);
          const midNow = band(180, 1200);
          const highNow = band(1200, 7000);
          const energyNow = Math.min(1, bassNow * 0.48 + midNow * 0.42 + highNow * 0.28);
          this.bass += (bassNow - this.bass) * 0.16;
          this.mid += (midNow - this.mid) * 0.14;
          this.high += (highNow - this.high) * 0.12;
          this.energy += (energyNow - this.energy) * 0.14;
          return this;
        },
      };

      audioEngineRef.current = { context, source, analyser, masterGain: trackMaster, state };
      applyMusicVolume();
      window.darkyAudio = state;
    }
    return audioEngineRef.current;
  };

  useEffect(() => () => {
    window.darkySoundOn = false;
    window.dispatchEvent(new CustomEvent("darky-sound", { detail: { enabled: false } }));
    try { delete window.darkyDuckMusic; } catch (_) {}
    try { delete window.darkyFadeOutMusic; } catch (_) {}
    try { delete window.darkyToggleSound; } catch (_) {}
    stopLoopFadeMonitor();
    stopSoundFade();
    if (audioRef.current) {
      audioRef.current.removeEventListener("ended", restartAudioLoop);
      audioRef.current.pause();
      audioRef.current.src = "";
      audioRef.current = null;
    }
    if (audioEngineRef.current) {
      audioEngineRef.current.state.enabled = false;
      try { audioEngineRef.current.source.disconnect(); } catch (_) {}
      try { audioEngineRef.current.analyser.disconnect(); } catch (_) {}
      try { audioEngineRef.current.masterGain.disconnect(); } catch (_) {}
      try { audioEngineRef.current.context.close(); } catch (_) {}
      audioEngineRef.current = null;
    }
    try { delete window.darkyAudioContext; } catch (_) {}
    if (window.darkyAudio) window.darkyAudio.enabled = false;
  }, []);

  useEffect(() => {
    window.darkySoundOn = sound;
    window.darkyDuckMusic = (duck) => {
      musicVolumeRef.current = duck ? MUSIC_DUCK_VOLUME : MUSIC_BASE_VOLUME;
      applyMusicVolume();
    };
    window.darkyFadeOutMusic = (durationSeconds = 1.05) => fadeOutSound(durationSeconds);
    window.dispatchEvent(new CustomEvent("darky-sound", { detail: { enabled: sound } }));
  }, [sound]);

  const toggleSound = async () => {
    const soundIsOn = sound || !!window.darkySoundOn;
    if (soundTransitioning && !soundIsOn) return;
    const audio = getAudio();
    if (soundIsOn) {
      fadeOutSound(LOOP_FADE_SECONDS);
      return;
    }
    try {
      soundStoppingRef.current = false;
      setSoundTransitioning(true);
      const engine = getAudioEngine();
      if (engine && engine.context.state !== "running") {
        await engine.context.resume();
      }
      loopFadeRef.current = 0;
      soundFadeRef.current = 0;
      playFadeStartedAtRef.current = performance.now();
      applyMusicVolume();
      await audio.play();
      startLoopFadeMonitor();
      if (engine) engine.state.enabled = true;
      else if (window.darkyAudio) window.darkyAudio.enabled = true;
      if (engine) window.darkyAudioContext = engine.context;
      window.darkySoundOn = true;
      setSound(true);
      fadeSoundVolumeTo(1, () => setSoundTransitioning(false));
    } catch (err) {
      console.warn("Audio playback blocked", err);
      stopSoundFade();
      window.darkySoundOn = false;
      if (audioEngineRef.current) audioEngineRef.current.state.enabled = false;
      if (window.darkyAudio) window.darkyAudio.enabled = false;
      setSound(false);
      setSoundTransitioning(false);
    }
  };

  useEffect(() => {
    window.darkyToggleSound = () => toggleSound();
    return () => {
      try { delete window.darkyToggleSound; } catch (_) {}
    };
  }, [sound, soundTransitioning]);

  useEffect(() => {
    const handleActivate = () => {
      if (!sound && !soundTransitioning) toggleSound();
    };
    const handleDeactivate = () => {
      if (sound) toggleSound();
    };
    window.addEventListener("darky-activate", handleActivate);
    window.addEventListener("darky-deactivate", handleDeactivate);
    return () => {
      window.removeEventListener("darky-activate", handleActivate);
      window.removeEventListener("darky-deactivate", handleDeactivate);
    };
  }, [sound, soundTransitioning]);

  // UI sound effects — Web Audio API. Buffers pre-loaded on mount, played
  // through a dry+wet split (convolver reverb at 20% wet), so triggers are
  // instantaneous (no first-tap latency) and every UI sfx sits in a small
  // reverb tail while the background music track stays completely dry.
  useEffect(() => {
    const SOURCES = {
      planetHover: "uploads/planet-hover.wav",
      orbTap: "uploads/orb-tap.wav",
    };
    const VOLUMES = { planetHover: 0.44, orbTap: 0.68 };
    const WET_LEVEL = 0.20;
    const SUPPRESSED_STATES = new Set(["waking", "generating", "typing", "speaking", "climax"]);
    const minGap = { planetHover: 90, orbTap: 30 };

    const AudioCtx = window.AudioContext || window.webkitAudioContext;
    if (!AudioCtx) return;

    const ctx = new AudioCtx();
    const buffers = {};
    const lastPlayedAt = {};
    let disposed = false;

    // Procedural impulse response for a short room/hall reverb.
    const buildReverbIR = (duration = 1.6, decay = 2.6) => {
      const rate = ctx.sampleRate;
      const length = Math.max(1, Math.floor(rate * duration));
      const impulse = ctx.createBuffer(2, length, rate);
      for (let ch = 0; ch < 2; ch++) {
        const data = impulse.getChannelData(ch);
        for (let i = 0; i < length; i++) {
          data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
        }
      }
      return impulse;
    };

    const dryGain = ctx.createGain();
    const wetGain = ctx.createGain();
    const convolver = ctx.createConvolver();
    convolver.buffer = buildReverbIR();
    dryGain.gain.value = 1 - WET_LEVEL;
    wetGain.gain.value = WET_LEVEL;
    dryGain.connect(ctx.destination);
    wetGain.connect(convolver);
    convolver.connect(ctx.destination);

    Object.entries(SOURCES).forEach(([key, url]) => {
      fetch(url)
        .then((res) => res.arrayBuffer())
        .then((arr) => ctx.decodeAudioData(arr))
        .then((buf) => { if (!disposed) buffers[key] = buf; })
        .catch((err) => console.warn("sfx load failed", key, err));
    });

    const resumeOnGesture = () => {
      if (ctx.state === "suspended") ctx.resume().catch(() => {});
    };
    window.addEventListener("pointerdown", resumeOnGesture, true);
    window.addEventListener("keydown", resumeOnGesture, true);

    window.darkySfx = {
      play(key) {
        const buf = buffers[key];
        if (!buf) return;
        const orbState = window.darkyPerf && window.darkyPerf.orbState;
        if (orbState && SUPPRESSED_STATES.has(orbState)) return;
        const now = performance.now();
        if (now - (lastPlayedAt[key] || 0) < (minGap[key] || 0)) return;
        lastPlayedAt[key] = now;
        try {
          if (ctx.state === "suspended") ctx.resume().catch(() => {});
          const src = ctx.createBufferSource();
          src.buffer = buf;
          const gain = ctx.createGain();
          gain.gain.value = VOLUMES[key] || 1;
          src.connect(gain);
          gain.connect(dryGain);
          gain.connect(wetGain);
          src.start(0);
        } catch (_) {}
      },
    };

    return () => {
      disposed = true;
      window.removeEventListener("pointerdown", resumeOnGesture, true);
      window.removeEventListener("keydown", resumeOnGesture, true);
      try { delete window.darkySfx; } catch (_) {}
      try { ctx.close(); } catch (_) {}
    };
  }, []);

  return null;
}

// ============= ORB INTERACTION ==================================
function OrbInteraction({ portalRef, activated = true }) {
  const [busy, setBusy] = useState(false);
  const [response, setResponse] = useState("");
  const [streamed, setStreamed] = useState("");
  const [sentiment, setSentiment] = useState("neutral");
  const [orbState, setOrbState] = useState("idle");
  const streamRef = useRef(null);
  const interactedRef = useRef(false);
  const promptIndexRef = useRef(0);
  const busyRef = useRef(false);
  const askRef = useRef(null);
  const speechRef = useRef({ utterance: null, id: 0 });
  const requestIdRef = useRef(0);
  const [voiceOnly, setVoiceOnly] = useState(false);
  const linkExitRef = useRef(0);
  const [soundEnabled, setSoundEnabled] = useState(() => !!window.darkySoundOn);

  useEffect(() => {
    const handler = (e) => setOrbState(e.detail);
    window.addEventListener("orb-state", handler);
    return () => window.removeEventListener("orb-state", handler);
  }, []);

  useEffect(() => {
    const syncSound = (event) => setSoundEnabled(!!event.detail?.enabled);
    setSoundEnabled(!!window.darkySoundOn);
    window.addEventListener("darky-sound", syncSound);
    return () => window.removeEventListener("darky-sound", syncSound);
  }, []);

  const stopSpeech = () => {
    speechRef.current.id += 1;
    if (window.darkyVoiceAudio) window.darkyVoiceAudio.enabled = false;
    if (window.speechSynthesis) {
      try { window.speechSynthesis.cancel(); } catch (_) {}
    }
    speechRef.current.utterance = null;
    try { delete window.darkyVoiceAudio; } catch (_) {}
    window.darkyDuckMusic?.(false);
    window.orb?.reset?.();
  };

  const pickLocalVoice = () => {
    if (!window.speechSynthesis) return null;
    const voices = window.speechSynthesis.getVoices() || [];
    const preferred = [
      /samantha/i,
      /serena/i,
      /victoria/i,
      /karen/i,
      /moira/i,
      /tessa/i,
      /zira/i,
      /google uk english female/i,
      /female/i,
    ];
    return preferred.reduce((found, pattern) => {
      return found || voices.find((voice) => pattern.test(`${voice.name} ${voice.lang}`));
    }, null) || voices.find((voice) => /^en/i.test(voice.lang)) || voices[0] || null;
  };

  const createLocalVoiceSignal = (text) => {
    const words = (text.match(/\S+/g) || ["signal"]);
    const duration = Math.max(2400, Math.min(9000, words.length * 430));
    const start = performance.now();
    let lastWord = -1;
    let impulse = 0;
    return {
      enabled: true,
      energy: 0,
      bass: 0,
      mid: 0,
      high: 0,
      sample() {
        const elapsed = performance.now() - start;
        if (!this.enabled || elapsed > duration + 900) {
          this.enabled = false;
          this.energy *= 0.86;
          this.bass *= 0.86;
          this.mid *= 0.86;
          this.high *= 0.86;
          return this;
        }

        const progress = Math.min(1, elapsed / duration);
        const wordIndex = Math.min(words.length - 1, Math.floor(progress * words.length));
        if (wordIndex !== lastWord) {
          lastWord = wordIndex;
          const word = words[wordIndex] || "";
          impulse = 0.54 + Math.min(0.34, word.length * 0.025);
        }
        impulse *= 0.84;

        const carrier = 0.5 + 0.5 * Math.sin(elapsed * 0.018);
        const formant = 0.5 + 0.5 * Math.sin(elapsed * 0.044 + wordIndex * 0.71);
        const consonants = Math.max(0, Math.sin(elapsed * 0.092 + wordIndex * 1.37));
        const envelope = Math.sin(Math.PI * progress);
        const bassNow = Math.min(1, (0.10 + impulse * 0.20 + carrier * 0.10) * envelope);
        const midNow = Math.min(1, (0.18 + impulse * 0.82 + formant * 0.34) * envelope);
        const highNow = Math.min(1, (0.08 + impulse * 0.42 + consonants * 0.48) * envelope);
        const energyNow = Math.min(1, bassNow * 0.22 + midNow * 0.72 + highNow * 0.52);
        this.bass += (bassNow - this.bass) * 0.30;
        this.mid += (midNow - this.mid) * 0.34;
        this.high += (highNow - this.high) * 0.32;
        this.energy += (energyNow - this.energy) * 0.34;
        return this;
      },
    };
  };

  const speakIfSoundOn = (text) => {
    const clean = (text || "").replace(/\s+/g, " ").trim();
    if (!clean || !window.darkySoundOn || !window.speechSynthesis) return false;

    const id = speechRef.current.id + 1;
    stopSpeech();
    speechRef.current.id = id;

    try {
      window.orb?.speak?.();
      const utterance = new SpeechSynthesisUtterance(clean);
      const voice = pickLocalVoice();
      if (voice) utterance.voice = voice;
      utterance.lang = voice?.lang || "en-US";
      utterance.rate = 0.78;
      utterance.pitch = 1.04;
      utterance.volume = 0.95;

      const voiceState = createLocalVoiceSignal(clean);
      window.darkyVoiceAudio = voiceState;
      speechRef.current.utterance = utterance;

      const release = () => {
        if (speechRef.current.id === id) {
          voiceState.enabled = false;
          speechRef.current.utterance = null;
          window.darkyDuckMusic?.(false);
          window.dispatchEvent(new CustomEvent("darky-orb-deactivated"));
          window.orb?.reset?.();
        }
      };

      utterance.onstart = () => {
        if (speechRef.current.id === id) {
          voiceState.enabled = true;
          window.darkyDuckMusic?.(true);
          window.orb?.speak?.();
        }
      };
      utterance.onend = release;
      utterance.onerror = release;

      window.darkyDuckMusic?.(true);
      window.speechSynthesis.cancel();
      window.speechSynthesis.speak(utterance);
      return true;
    } catch (err) {
      console.warn("Local speech failed", err);
      if (speechRef.current.id === id) stopSpeech();
      window.orb?.reset?.();
      return false;
    }
  };

  const whisperAudioRef = useRef(null);
  const whisperCtxRef = useRef(null);
  const whisperAnalyserRef = useRef(null);
  const whisperBufRef = useRef(null);
  const whisperPrevAudioRef = useRef(null);
  const whisperLastIdxRef = useRef(-1);
  const whisperRouteActiveRef = useRef(false);
  const whisperStopResolverRef = useRef(null);
  const whisperDryGainRef = useRef(null);
  const whisperWetGainRef = useRef(null);

  const WHISPER_FILES = [
    "/uploads/whisper-belle.wav",
    "/uploads/whisper-02.wav",
    "/uploads/whisper-03.wav",
    "/uploads/whisper-04.wav",
    "/uploads/whisper-05.wav",
    "/uploads/whisper-06.wav",
    "/uploads/whisper-07.wav",
  ];

  const pickNextWhisper = () => {
    if (WHISPER_FILES.length <= 1) return 0;
    let idx;
    do {
      idx = Math.floor(Math.random() * WHISPER_FILES.length);
    } while (idx === whisperLastIdxRef.current);
    whisperLastIdxRef.current = idx;
    return idx;
  };

  const releaseWhisperAudio = () => {
    if (!whisperRouteActiveRef.current) return;
    whisperRouteActiveRef.current = false;
    whisperStopResolverRef.current = null;
    if (window.darkyVoiceAudio) window.darkyVoiceAudio.enabled = false;
    try { delete window.darkyVoiceAudio; } catch (_) {}
    window.darkyAudio = whisperPrevAudioRef.current;
    whisperPrevAudioRef.current = null;
    window.darkyDuckMusic?.(false);
    window.dispatchEvent(new CustomEvent("darky-orb-deactivated"));
  };

  const stopWhisperPlayback = () => {
    const audio = whisperAudioRef.current;
    const ctx = whisperCtxRef.current;
    const muteGain = (node) => {
      if (!node || !ctx) return;
      try {
        const now = ctx.currentTime;
        node.gain.cancelScheduledValues(now);
        node.gain.setValueAtTime(0, now);
      } catch (_) {
        node.gain.value = 0;
      }
    };
    muteGain(whisperDryGainRef.current);
    muteGain(whisperWetGainRef.current);
    if (window.darkyVoiceAudio) window.darkyVoiceAudio.enabled = false;
    if (audio) {
      try { audio.pause(); } catch (_) {}
      try { audio.currentTime = 0; } catch (_) {}
    }
    if (whisperStopResolverRef.current) {
      whisperStopResolverRef.current();
    } else {
      releaseWhisperAudio();
    }
  };

  // Procedural long-hall impulse response — smooth exponential decay,
  // ~3.6 s tail, gentle high-frequency roll-off so the whisper stays soft.
  const buildHallImpulse = (ctx) => {
    const sampleRate = ctx.sampleRate;
    const length = Math.floor(sampleRate * 3.6);
    const buf = ctx.createBuffer(2, length, sampleRate);
    for (let ch = 0; ch < 2; ch++) {
      const data = buf.getChannelData(ch);
      let lp = 0;
      for (let i = 0; i < length; i++) {
        const t = i / length;
        const envelope = Math.pow(1 - t, 2.8);
        const noise = Math.random() * 2 - 1;
        // single-pole low-pass to remove sizzle, keep the room warm
        lp += (noise - lp) * 0.32;
        const early = i < sampleRate * 0.025 ? 0.6 : 1;
        data[i] = lp * envelope * early * 0.78;
      }
    }
    return buf;
  };

  const ensureWhisperRig = async () => {
    if (whisperAudioRef.current && whisperAnalyserRef.current) return;
    const audio = new Audio();
    audio.crossOrigin = "anonymous";
    audio.preload = "auto";
    const AC = window.AudioContext || window.webkitAudioContext;
    const ctx = new AC();
    const src = ctx.createMediaElementSource(audio);
    const analyser = ctx.createAnalyser();
    analyser.fftSize = 1024;
    analyser.smoothingTimeConstant = 0.55;

    // dry path — direct whisper, kept fairly present
    const dryGain = ctx.createGain();
    dryGain.gain.value = 0.72;
    whisperDryGainRef.current = dryGain;

    // wet path — long hall reverb, sits behind the whisper, kept subtle
    const preReverbHP = ctx.createBiquadFilter();
    preReverbHP.type = "highpass";
    preReverbHP.frequency.value = 180;
	    const convolver = ctx.createConvolver();
	    convolver.normalize = true;
	    convolver.buffer = buildHallImpulse(ctx);
	    const wetGain = ctx.createGain();
	    wetGain.gain.value = 0.13;
    whisperWetGainRef.current = wetGain;
    const postReverbLP = ctx.createBiquadFilter();
    postReverbLP.type = "lowpass";
    postReverbLP.frequency.value = 5200;
    postReverbLP.Q.value = 0.6;

    src.connect(analyser);
    src.connect(dryGain).connect(ctx.destination);
    src.connect(preReverbHP).connect(convolver).connect(wetGain).connect(postReverbLP).connect(ctx.destination);

    whisperAudioRef.current = audio;
    whisperCtxRef.current = ctx;
    whisperAnalyserRef.current = analyser;
    whisperBufRef.current = new Uint8Array(analyser.frequencyBinCount);
  };

  const playWhisper = async () => {
    await ensureWhisperRig();
    const audio = whisperAudioRef.current;
    const ctx = whisperCtxRef.current;
    const analyser = whisperAnalyserRef.current;
    const buf = whisperBufRef.current;
    if (ctx.state === "suspended") {
      try { await ctx.resume(); } catch (_) {}
    }

    const nextSrc = WHISPER_FILES[pickNextWhisper()];
    if (audio.src.indexOf(nextSrc) === -1) {
      audio.src = nextSrc;
      try { audio.load(); } catch (_) {}
      await new Promise((resolve) => {
        const ok = () => { cleanup(); resolve(); };
        const fail = () => { cleanup(); resolve(); };
        const cleanup = () => {
          audio.removeEventListener("canplay", ok);
          audio.removeEventListener("error", fail);
        };
        audio.addEventListener("canplay", ok, { once: true });
        audio.addEventListener("error", fail, { once: true });
      });
    }

    const bins = buf.length;
    const bassEnd = Math.max(2, Math.floor(bins * 0.10));
    const midEnd = Math.max(bassEnd + 1, Math.floor(bins * 0.42));
    const sampleFFT = () => {
      analyser.getByteFrequencyData(buf);
      let bs = 0, ms = 0, hs = 0;
      for (let i = 0; i < bassEnd; i++) bs += buf[i];
      for (let i = bassEnd; i < midEnd; i++) ms += buf[i];
      for (let i = midEnd; i < bins; i++) hs += buf[i];
      const bass = Math.min(1, (bs / bassEnd / 255) * 1.55);
      const mid = Math.min(1, (ms / (midEnd - bassEnd) / 255) * 1.45);
      const high = Math.min(1, (hs / (bins - midEnd) / 255) * 1.35);
      const energy = Math.min(1, (bass * 0.55 + mid * 0.75 + high * 0.50));
      return { enabled: true, bass, mid, high, energy };
    };

    whisperPrevAudioRef.current = window.darkyAudio || null;
    whisperRouteActiveRef.current = true;
    window.darkyAudio = { enabled: true, sample: sampleFFT };
    window.darkyVoiceAudio = {
      enabled: true,
      sample() { const s = sampleFFT(); return { enabled: true, energy: s.energy, mid: s.mid, high: s.high, bass: s.bass }; },
    };
    window.darkyDuckMusic?.(true);
    window.orb?.speak?.();

    const restoreGain = (node, value) => {
      if (!node) return;
      try {
        const now = ctx.currentTime;
        node.gain.cancelScheduledValues(now);
        node.gain.setValueAtTime(value, now);
      } catch (_) {
        node.gain.value = value;
      }
    };
    restoreGain(whisperDryGainRef.current, 0.72);
    restoreGain(whisperWetGainRef.current, 0.13);

    audio.currentTime = 0;
    try {
      await audio.play();
    } catch (e) {
      console.warn("audio.play() blocked", e);
      releaseWhisperAudio();
      return;
    }

    await new Promise((resolve) => {
      let done = false;
      const finish = () => {
        if (done) return;
        done = true;
        cleanup();
        whisperStopResolverRef.current = null;
        resolve();
      };
      const onEnd = () => {
        finish();
      };
      const cleanup = () => {
        audio.removeEventListener("ended", onEnd);
        audio.removeEventListener("error", onEnd);
      };
      whisperStopResolverRef.current = finish;
      audio.addEventListener("ended", onEnd);
      audio.addEventListener("error", onEnd);
    });

    releaseWhisperAudio();
  };

  useEffect(() => () => {
    if (streamRef.current) clearInterval(streamRef.current);
    stopSpeech();
    stopWhisperPlayback();
    const c = whisperCtxRef.current;
    if (c) { try { c.close(); } catch (_) {} }
  }, []);

  useEffect(() => {
    const handler = (e) => {
      if (!e.detail || !e.detail.enabled) {
        stopSpeech();
        stopWhisperPlayback();
        setVoiceOnly(false);
      }
    };
    window.addEventListener("darky-sound", handler);
    return () => window.removeEventListener("darky-sound", handler);
  }, []);

  // welcome pulse — runs after activation only, so the dormant icosahedron
  // stays calm until the user taps the orb.
  useEffect(() => {
    if (!activated) return;
    const t = setTimeout(() => {
      if (!interactedRef.current) window.orb?.complete(50);
    }, 750);
    return () => clearTimeout(t);
  }, [activated]);

  const streamReveal = (body, options = {}) => {
    const voiceMode = !!options.voiceMode;
    return new Promise((resolve) => {
      const tokens = body.match(/\S+\s*/g) || [];
      let i = 0;
      if (streamRef.current) clearInterval(streamRef.current);
      setStreamed("");
      if (voiceMode) {
        window.orb?.speak?.();
      } else {
        window.orb?.type?.();
      }
      const tick = 42;
      streamRef.current = setInterval(() => {
        const burst = 1 + (Math.random() < 0.3 ? 1 : 0) + (Math.random() < 0.1 ? 1 : 0);
        const next = tokens.slice(i, i + burst).join("");
        i += burst;
        setStreamed((s) => s + next);
        // smoother, more meditative target — less noise, narrower swing
        const r = 0.50 + 0.22 * Math.sin(i * 0.09) + (Math.random() - 0.5) * 0.05;
        window.orb?.setRate(Math.max(0.25, Math.min(0.85, r)));
        if (i >= tokens.length) {
          clearInterval(streamRef.current);
          streamRef.current = null;
          window.orb?.setRate(0.9);
          if (voiceMode) {
            resolve();
            return;
          }
          setTimeout(() => {
            window.orb?.complete(body.length);
            resolve();
          }, 220);
        }
      }, tick);
    });
  };

  const ask = async (prompt) => {
    if (busyRef.current || busy || !prompt || !prompt.trim()) return;
    const requestId = requestIdRef.current + 1;
    requestIdRef.current = requestId;
    busyRef.current = true;
    interactedRef.current = true;
    setBusy(true);
    setResponse("");
    setStreamed("");
    setVoiceOnly(true);
    setSentiment("neutral");

    window.orb?.wake();
    window.orb?.setRate(0.6);

    // fake LLM-thinking pacing — slow meditative oscillation
    const startTime = Date.now();
    const rateInterval = setInterval(() => {
      const elapsed = (Date.now() - startTime) / 1000;
      const r = 0.48 + 0.20 * Math.sin(elapsed * 0.55) + 0.07 * Math.sin(elapsed * 1.25);
      window.orb?.setRate(Math.max(0.2, Math.min(0.80, r)));
    }, 110);

    try {
      const thinkMs = 1500 + Math.random() * 1400;
      await new Promise((r) => setTimeout(r, thinkMs));
      if (requestIdRef.current !== requestId) return;
      await playWhisper();
    } catch (err) {
      console.warn("whisper playback failed", err);
    } finally {
      clearInterval(rateInterval);
      if (requestIdRef.current === requestId) {
        window.orb?.reset?.();
        busyRef.current = false;
        setBusy(false);
      }
    }
  };

  useEffect(() => {
    busyRef.current = busy;
    askRef.current = ask;
  });

  useEffect(() => {
    const handler = () => {
      if (busyRef.current) {
        window.orb?.setRate(0.95);
        return;
      }
      const prompt = ORB_PROMPTS[promptIndexRef.current % ORB_PROMPTS.length];
      promptIndexRef.current += 1;
      askRef.current?.(prompt);
    };
    window.addEventListener("darky-orb-generate", handler);
    return () => window.removeEventListener("darky-orb-generate", handler);
  }, []);

  const stateLabel = {
    idle: "Idle",
    waking: "Awakening",
    generating: "Thinking",
    typing: "Typing",
    speaking: "Speaking",
    climax: "Speaking",
    calming: "Settling",
  }[orbState] || "Idle";

  const [settled, setSettled] = useState(false);
  const settleTimerRef = useRef(null);
  const hideTimerRef = useRef(null);
  const hasAnswer = Boolean(streamed || response);
  const showGreeting = activated && !busy && (!hasAnswer || settled);
  const clearResponseTimers = () => {
    if (settleTimerRef.current) {
      clearTimeout(settleTimerRef.current);
      settleTimerRef.current = null;
    }
    if (hideTimerRef.current) {
      clearTimeout(hideTimerRef.current);
      hideTimerRef.current = null;
    }
  };
  const resetAnswer = () => {
    requestIdRef.current += 1;
    busyRef.current = false;
    if (streamRef.current) {
      clearInterval(streamRef.current);
      streamRef.current = null;
    }
    clearResponseTimers();
    stopSpeech();
    stopWhisperPlayback();
    setBusy(false);
    setResponse("");
    setStreamed("");
    setVoiceOnly(false);
    setSentiment("neutral");
    setSettled(false);
    window.orb?.reset?.();
    window.dispatchEvent(new CustomEvent("darky-orb-deactivated"));
  };

  useEffect(() => {
    const handler = () => resetAnswer();
    window.addEventListener("darky-orb-reset-answer", handler);
    return () => window.removeEventListener("darky-orb-reset-answer", handler);
  });
  const triggerPlanetLinkExit = () => {
    const now = performance.now();
    if (now - linkExitRef.current < 180) return;
    linkExitRef.current = now;
    window.darkyFadeOutMusic?.(1.05);
    window.dispatchEvent(new CustomEvent("darky-orb-link-burst"));
  };
  const handlePlanetLinkClick = (event, href) => {
    if (event.defaultPrevented || event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
      triggerPlanetLinkExit();
      return;
    }
    event.preventDefault();
    triggerPlanetLinkExit();
    window.setTimeout(() => {
      const opened = window.open(href, "_blank");
      if (opened) {
        try { opened.opener = null; } catch (_) {}
      }
      if (!opened) window.location.assign(href);
    }, PLANET_LINK_EXIT_DELAY_MS);
  };
  const handlePasswordPlanetClick = (event, href, password) => {
    event.preventDefault();
    event.stopPropagation();
    window.darkySfx?.play?.("orbTap");
    const entered = window.prompt("Введите пароль");
    if (entered === null) return;
    if (entered.trim() !== password) return;
    triggerPlanetLinkExit();
    window.setTimeout(() => {
      window.location.assign(href);
    }, PLANET_LINK_EXIT_DELAY_MS);
  };
  const responseEl = !voiceOnly && (streamed || response) ? (
    <div
      className={`orb-ui__response orb-ui__response--${sentiment}`}
      data-settled={settled ? "true" : "false"}
    >
      <div className="orb-ui__response-text">
        {streamed || response}
        {busy && streamed && <span className="orb-ui__cursor">▍</span>}
      </div>
    </div>
  ) : null;

  useEffect(() => {
    // when streaming starts (or a new request begins), unsettle immediately
    if (busy) {
      setSettled(false);
      if (settleTimerRef.current) {
        clearTimeout(settleTimerRef.current);
        settleTimerRef.current = null;
      }
      if (hideTimerRef.current) {
        clearTimeout(hideTimerRef.current);
        hideTimerRef.current = null;
      }
      return;
    }
    // after streaming ends, settle first, then remove the message fully
    if (response || streamed) {
      settleTimerRef.current = setTimeout(() => setSettled(true), 3500);
      hideTimerRef.current = setTimeout(() => {
        setResponse("");
        setStreamed("");
        setSettled(false);
        setVoiceOnly(false);
        window.dispatchEvent(new CustomEvent("darky-orb-deactivated"));
        hideTimerRef.current = null;
      }, 7000);
      return () => {
        if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
        if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
      };
    }
  }, [busy, response, streamed]);

  return (
    <div className="orb-ui">
      {portalRef?.current && responseEl
        ? ReactDOM.createPortal(responseEl, portalRef.current)
        : responseEl}

      <div className="orb-ui__state">
        <span className={`orb-ui__state-dot orb-ui__state-dot--${orbState}`}></span>
        <span className="orb-ui__state-text">// {stateLabel}</span>
        {sentiment !== "neutral" && (
          <span className={`orb-ui__state-sent orb-ui__state-sent--${sentiment}`}>
            · {sentiment}
          </span>
        )}
      </div>

      <div
        className="orb-greeting"
        data-visible={showGreeting ? "true" : "false"}
        data-returning={hasAnswer && settled ? "true" : "false"}
        aria-hidden={showGreeting ? "false" : "true"}
      >
          <p className="orb-greeting__hail">
            <span className="orb-greeting__hail-mark">//</span>
            Darky signal online. The next transmission is inside the sphere.
          </p>
          <div className="orb-greeting__links">
            {GREETING_LINKS.map((l) => {
              const isSoundToggle = l.action === "sound";
              const isPasswordGate = l.action === "password";
              const planetStyle = {
                "--planet-size": l.size,
                "--orbit-radius": l.orbit,
                "--orbit-start": `${l.angle}deg`,
                "--orbit-end": `${l.angle + 360}deg`,
                "--orbit-start-inverse": `${-l.angle}deg`,
                "--orbit-end-inverse": `${-(l.angle + 360)}deg`,
                "--orbit-duration": l.duration,
                "--orbit-delay": "0s",
              };
              const content = (
                <>
                  <PlanetOrb />
                  <span className="orb-greeting__link-main">
                    <span className="orb-greeting__link-label">{l.label}</span>
                    {!isSoundToggle && (
                      <span className="orb-greeting__link-tag">{l.tag}</span>
                    )}
                  </span>
                  {!isSoundToggle && (
                    <span className="orb-greeting__link-arrow">↗</span>
                  )}
                </>
              );

              if (isPasswordGate) {
                return (
                  <button
                    key={l.id}
                    type="button"
                    className="orb-greeting__link"
                    aria-label={`${l.label} — password required`}
                    onPointerEnter={() => window.darkySfx?.play?.("planetHover")}
                    onFocus={() => window.darkySfx?.play?.("planetHover")}
                    onClick={(event) => handlePasswordPlanetClick(event, l.href, l.password)}
                    style={planetStyle}
                  >
                    {content}
                  </button>
                );
              }

              if (isSoundToggle) {
                return (
                  <button
                    key={l.id}
                    type="button"
                    className="orb-greeting__link orb-greeting__link--sound"
                    aria-label={soundEnabled ? "Sound off" : "Sound on"}
                    aria-pressed={soundEnabled ? "true" : "false"}
                    onPointerEnter={() => window.darkySfx?.play?.("planetHover")}
                    onFocus={() => window.darkySfx?.play?.("planetHover")}
                    onClick={(event) => {
                      event.preventDefault();
                      event.stopPropagation();
                      window.darkySfx?.play?.("orbTap");
                      window.darkyToggleSound?.();
                    }}
                    style={planetStyle}
                  >
                    {content}
                  </button>
                );
              }

              return (
                <a
                  key={l.href}
                  className="orb-greeting__link"
                  href={l.href}
                  target="_blank"
                  rel="noopener noreferrer"
                  onPointerEnter={() => window.darkySfx?.play?.("planetHover")}
                  onFocus={() => window.darkySfx?.play?.("planetHover")}
                  onPointerDown={(event) => {
                    if (event.button === 0) triggerPlanetLinkExit();
                  }}
                  onClick={(event) => handlePlanetLinkClick(event, l.href)}
                  style={planetStyle}
                >
                  {content}
                </a>
              );
            })}
          </div>
        </div>
    </div>
  );
}

function Hero() {
  const portalRef = useRef(null);
  const nebulaRef = useRef(null);
  const [orbTone, setOrbTone] = useState("idle");
  const [activated, setActivated] = useState(false);
  const [flashing, setFlashing] = useState(false);
  const flashTimerRef = useRef(null);
  useEffect(() => {
    const toneForState = (state) => {
      if (state === "waking" || state === "generating") return "thinking";
      if (state === "typing" || state === "speaking" || state === "climax") return "answering";
      return "idle";
    };
    const syncTone = (event) => setOrbTone(toneForState(event.detail));
    const deactivate = () => setOrbTone("idle");
    window.addEventListener("orb-state", syncTone);
    window.addEventListener("darky-orb-deactivated", deactivate);
    return () => {
      window.removeEventListener("orb-state", syncTone);
      window.removeEventListener("darky-orb-deactivated", deactivate);
    };
  }, []);
  useEffect(() => {
    const nebula = nebulaRef.current;
    if (!nebula) return;
    let mx = 0, my = 0, tx = 0, ty = 0, raf = 0;
    const onMove = (e) => {
      const w = window.innerWidth || 1;
      const h = window.innerHeight || 1;
      tx = (e.clientX / w) * 2 - 1;
      ty = (e.clientY / h) * 2 - 1;
    };
    const tick = () => {
      mx += (tx - mx) * 0.06;
      my += (ty - my) * 0.06;
      nebula.style.setProperty("--mx", mx.toFixed(3));
      nebula.style.setProperty("--my", my.toFixed(3));
      raf = requestAnimationFrame(tick);
    };
    window.addEventListener("pointermove", onMove, { passive: true });
    raf = requestAnimationFrame(tick);
    return () => {
      window.removeEventListener("pointermove", onMove);
      cancelAnimationFrame(raf);
    };
  }, []);
  useEffect(() => () => {
    if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
  }, []);
  const setOrbHover = (active) => {
    window.dispatchEvent(new CustomEvent("darky-orb-hover", { detail: { active } }));
  };
  const triggerFlash = () => {
    setFlashing(true);
    if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
    flashTimerRef.current = setTimeout(() => setFlashing(false), 1500);
  };
  const handleOrbClick = () => {
    window.darkySfx?.play?.("orbTap");
    const liveOrbState = window.orb?.getState?.();
    const isAnswering = orbTone === "answering" || liveOrbState === "typing" || liveOrbState === "speaking" || liveOrbState === "climax";
    if (activated && isAnswering) {
      window.dispatchEvent(new CustomEvent("darky-orb-reset-answer"));
      return;
    }
    if (!activated) {
      setActivated(true);
      triggerFlash();
      window.dispatchEvent(new CustomEvent("darky-activate"));
      window.dispatchEvent(new CustomEvent("darky-orb-activated"));
      window.dispatchEvent(new CustomEvent("darky-orb-generate"));
      return;
    }
    window.dispatchEvent(new CustomEvent("darky-orb-activated"));
    window.dispatchEvent(new CustomEvent("darky-orb-generate"));
  };
  return (
    <section
      className="hero"
      data-orb-tone={orbTone}
      data-activated={activated ? "true" : "false"}
      data-flash={flashing ? "true" : "false"}
      data-screen-label="01 Hero"
    >
	      <OrbCanvas />
	      <div ref={nebulaRef} className="hero__nebula" aria-hidden="true">
	        <span className="hero__nebula-grain"></span>
	      </div>
      <div className="hero__flash" aria-hidden="true"></div>
      <button
        type="button"
        className="hero__orb-trigger"
        aria-label={orbTone === "answering" ? "Reset Darky answer" : activated ? "Generate next Darky transmission" : "Tap to activate"}
        onPointerEnter={() => setOrbHover(true)}
        onPointerMove={() => setOrbHover(true)}
        onPointerLeave={() => setOrbHover(false)}
        onBlur={() => setOrbHover(false)}
        onFocus={() => setOrbHover(true)}
        onClick={handleOrbClick}
      />
      {!activated && (
        <div className="hero__activate-hint" aria-hidden="true">
          <span className="hero__activate-hint-mark">//</span>
          Tap to Activate
        </div>
      )}
      <div className="hero__response-anchor" ref={portalRef}></div>

      <div className="hero__stack">
        <div className="hero__orb-space"></div>

        <OrbInteraction portalRef={portalRef} activated={activated} />
      </div>
    </section>
  );
}

function NowPlayingMarquee() {
  const items = [
    "rotating rhythms", "drones", "synap sessions", "darkexpress",
    "collapsed", "carriage return", "proportional", "cross//patch",
    "darky engine", "live", "buy", "stream",
  ];
  const all = [...items, ...items];
  return (
    <div className="portfolio">
      <div className="portfolio__label">// Now playing</div>
      <div className="portfolio__track">
        {all.map((name, i) => (
          <span key={i} className="portfolio__item">{name}</span>
        ))}
      </div>
    </div>
  );
}

// ============= INDEX LINKS ======================================
const LINKS = [
  {
    num: "01",
    title: "carriage return",
    subtitle: "live set @ synap",
    platform: "YouTube",
    href: "https://www.youtube.com/watch?v=TB0MXxOPnis&list=RDTB0MXxOPnis&start_radio=1",
    Graphic: () => <PillarMeter />,
    accent: "wave",
  },
  {
    num: "02",
    title: "proportional",
    subtitle: "live set @ synap",
    platform: "YouTube · Live",
    href: "https://www.youtube.com/live/oxbEVG1uy0w",
    Graphic: () => <PillarMeter />,
    accent: "wave",
  },
  {
    num: "03",
    title: "cross//patch",
    subtitle: "live set @ synap",
    platform: "YouTube",
    href: "https://www.youtube.com/watch?v=EzHqDnfXOfo",
    Graphic: () => <PillarMeter />,
    accent: "wave",
  },
  {
    num: "04",
    title: "Back catalogue",
    subtitle: "stream the full archive",
    platform: "SoundCloud",
    href: "http://soundcloud.com/dark_express",
    Graphic: () => <PillarRadar />,
    accent: "radar",
  },
  {
    num: "05",
    title: "Artist profile",
    subtitle: "bookings & resume",
    platform: "Resident Advisor",
    href: "https://ra.co/dj/darkexpress",
    Graphic: () => <PillarNetwork />,
    accent: "network",
  },
  {
    num: "06",
    title: "Collapsed",
    subtitle: "single — buy the track",
    platform: "Bandcamp",
    href: "https://darkexpress.bandcamp.com/track/collapsed",
    Graphic: () => <VinylSpinner />,
    accent: "vinyl",
  },
  {
    num: "07",
    title: "Darky Visual Engine",
    subtitle: "live visuals & screensaver",
    platform: "darkyvisualengine.vercel.app",
    href: "https://darkyvisualengine.vercel.app",
    Graphic: () => <MiniOrb />,
    accent: "orb",
  },
];

// inline mini visuals — defined here so we can keep app.jsx self-contained
function VinylSpinner() {
  const ref = useRef(null);
  useEffect(() => {
    const c = ref.current;
    if (!c) return;
    const ctx = c.getContext("2d");
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    let W = 0, H = 0, raf;
    const resize = () => {
      const r = c.getBoundingClientRect();
      W = r.width; H = r.height;
      c.width = W * dpr; c.height = H * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const onResize = () => resize();
    window.addEventListener("resize", onResize);
    const t0 = performance.now();
    const draw = () => {
      const t = (performance.now() - t0) / 1000;
      ctx.clearRect(0, 0, W, H);
      const cx = W / 2, cy = H / 2;
      const maxR = Math.min(W, H) * 0.46;
      // disc body
      const disc = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
      disc.addColorStop(0, "#1a1c20");
      disc.addColorStop(0.4, "#101216");
      disc.addColorStop(1, "#06080a");
      ctx.fillStyle = disc;
      ctx.beginPath(); ctx.arc(cx, cy, maxR, 0, Math.PI * 2); ctx.fill();
      // grooves
      ctx.strokeStyle = "rgba(232,228,220,0.05)";
      ctx.lineWidth = 0.5;
      for (let i = 0; i < 24; i++) {
        const r = maxR * (0.25 + (i / 24) * 0.7);
        ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
      }
      // moving highlight (light reflection)
      const ang = t * 0.6;
      ctx.save();
      ctx.translate(cx, cy);
      ctx.rotate(ang);
      const refl = ctx.createLinearGradient(-maxR, 0, maxR, 0);
      refl.addColorStop(0, "rgba(212,240,80,0)");
      refl.addColorStop(0.48, "rgba(212,240,80,0.18)");
      refl.addColorStop(0.5, "rgba(232,228,220,0.32)");
      refl.addColorStop(0.52, "rgba(212,240,80,0.18)");
      refl.addColorStop(1, "rgba(212,240,80,0)");
      ctx.fillStyle = refl;
      ctx.beginPath(); ctx.arc(0, 0, maxR, 0, Math.PI * 2); ctx.fill();
      ctx.restore();
      // center label
      ctx.fillStyle = "var(--accent)";
      ctx.fillStyle = "rgba(212,240,80,1)";
      ctx.beginPath(); ctx.arc(cx, cy, maxR * 0.22, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = "#0b0c0e";
      ctx.beginPath(); ctx.arc(cx, cy, maxR * 0.05, 0, Math.PI * 2); ctx.fill();
      raf = requestAnimationFrame(draw);
    };
    draw();
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", onResize); };
  }, []);
  return <canvas ref={ref} className="pillar__canvas" />;
}

// PlanetOrb — static glow only. Color is inherited from the live orb palette.
function PlanetOrb() {
  return (
    <span
      className="orb-greeting__planet"
      aria-hidden="true"
    />
  );
}

function MiniOrb() {
  const ref = useRef(null);
  useEffect(() => {
    const c = ref.current;
    if (!c) return;
    const ctx = c.getContext("2d");
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    let W = 0, H = 0, raf;
    const resize = () => {
      const r = c.getBoundingClientRect();
      W = r.width; H = r.height;
      c.width = W * dpr; c.height = H * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const onResize = () => resize();
    window.addEventListener("resize", onResize);
    // particles on Fibonacci sphere
    const N = 380;
    const golden = Math.PI * (3 - Math.sqrt(5));
    const pts = [];
    for (let i = 0; i < N; i++) {
      const tt = i / (N - 1);
      const cosPhi = 1 - 2 * tt;
      const sinPhi = Math.sqrt(Math.max(0, 1 - cosPhi * cosPhi));
      const theta = golden * i;
      pts.push({
        x: sinPhi * Math.cos(theta),
        y: sinPhi * Math.sin(theta),
        z: cosPhi,
        size: 0.5 + Math.random() * 1.0,
        phase: Math.random() * Math.PI * 2,
        useAcc: Math.random() < 0.22,
      });
    }
    const t0 = performance.now();
    const draw = () => {
      const t = (performance.now() - t0) / 1000;
      ctx.clearRect(0, 0, W, H);
      const cx = W / 2, cy = H / 2;
      const baseR = Math.min(W, H) * 0.36;
      // bg glow
      const bg = ctx.createRadialGradient(cx, cy, 0, cx, cy, baseR * 2.5);
      bg.addColorStop(0, "rgba(212,240,80,0.14)");
      bg.addColorStop(1, "rgba(212,240,80,0)");
      ctx.save(); ctx.globalCompositeOperation = "lighter"; ctx.fillStyle = bg;
      ctx.fillRect(cx - baseR * 2.5, cy - baseR * 2.5, baseR * 5, baseR * 5);
      ctx.restore();
      // rotation
      const rotY = t * 0.4;
      const rotX = -0.15 + Math.sin(t * 0.2) * 0.1;
      const cyy = Math.cos(rotY), sy = Math.sin(rotY);
      const cxr = Math.cos(rotX), sx = Math.sin(rotX);
      const proj = pts.map((p) => {
        const x1 = p.x * cyy + p.z * sy;
        const z1 = -p.x * sy + p.z * cyy;
        const y1 = p.y * cxr - z1 * sx;
        const z2 = p.y * sx + z1 * cxr;
        const persp = 3.2 / (3.2 - z2);
        return {
          sx: cx + x1 * baseR * persp,
          sy: cy + y1 * baseR * persp,
          z: z2,
          persp,
          p,
        };
      });
      proj.sort((a, b) => a.z - b.z);
      // halos
      ctx.save(); ctx.globalCompositeOperation = "lighter";
      for (const a of proj) {
        const fwd = (a.z + 1) * 0.5;
        const col = a.p.useAcc ? "212,240,80" : "232,228,220";
        const flick = 0.85 + 0.15 * Math.sin(t * 2 + a.p.phase);
        ctx.fillStyle = `rgba(${col},${(0.4 + fwd * 0.5) * 0.16 * flick})`;
        ctx.beginPath();
        ctx.arc(a.sx, a.sy, a.p.size * a.persp * 3, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.restore();
      // particles
      for (const a of proj) {
        const fwd = (a.z + 1) * 0.5;
        const col = a.p.useAcc ? "212,240,80" : "232,228,220";
        const flick = 0.85 + 0.15 * Math.sin(t * 2 + a.p.phase);
        ctx.fillStyle = `rgba(${col},${(0.45 + fwd * 0.55) * flick})`;
        ctx.beginPath();
        ctx.arc(a.sx, a.sy, a.p.size * a.persp, 0, Math.PI * 2);
        ctx.fill();
      }
      // core
      const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, baseR * 0.6);
      core.addColorStop(0, "rgba(212,240,80,0.35)");
      core.addColorStop(1, "rgba(212,240,80,0)");
      ctx.save(); ctx.globalCompositeOperation = "lighter"; ctx.fillStyle = core;
      ctx.fillRect(cx - baseR, cy - baseR, baseR * 2, baseR * 2); ctx.restore();
      raf = requestAnimationFrame(draw);
    };
    draw();
    return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", onResize); };
  }, []);
  return <canvas ref={ref} className="pillar__canvas" />;
}

function IndexSection() {
  const ref = useFadeIn();
  return (
    <section className="section" ref={ref} data-screen-label="02 Index">
      <div className="section__head">
        <div className="section__index fade-in">
          <span>// Index</span>
          <span>{LINKS.length} links</span>
        </div>
        <h2 className="section__title fade-in">
          Where to find<br /><em>the work</em>.
        </h2>
      </div>

      <div className="link-list fade-in">
        {LINKS.map((l, i) => (
          <a
            key={l.num}
            className={`link-row link-row--${l.accent}`}
            href={l.href}
            target="_blank"
            rel="noopener noreferrer"
          >
            <div className="link-row__num">// {l.num}</div>
            <div className="link-row__body">
              <div className="link-row__title">{l.title}</div>
              <div className="link-row__sub">{l.subtitle}</div>
            </div>
            <div className="link-row__platform">{l.platform}</div>
            <div className="link-row__graphic"><l.Graphic /></div>
            <div className="link-row__arrow">↗</div>
          </a>
        ))}
      </div>
    </section>
  );
}

function JudgmentCounter() {
  const [, setTick] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setTick((t) => t + 1), 1000);
    return () => clearInterval(id);
  }, []);

  const judgmentDay = new Date("1997-08-29T00:00:00-07:00").getTime();
  const diff = Math.abs(Date.now() - judgmentDay);
  const total = Math.floor(diff / 1000);
  const days = Math.floor(total / 86400);
  const hours = Math.floor((total % 86400) / 3600);
  const minutes = Math.floor((total % 3600) / 60);
  const seconds = total % 60;
  const pad = (v) => String(v).padStart(2, "0");

  return (
    <h2 className="footer__mega footer__judgment fade-in">
      <span className="footer__judgment-kicker">Judgment Day // T2</span>
      <span className="footer__judgment-main">T+ {days}D</span>
      <em>{pad(hours)}:{pad(minutes)}:{pad(seconds)}</em>
      <span className="footer__judgment-date">AUG 29 1997</span>
    </h2>
  );
}

function Outro() {
  const ref = useFadeIn();
  return (
    <footer className="footer" ref={ref} data-screen-label="03 Outro">
      <JudgmentCounter />

      <a
        href="https://ig.me/m/darkexpress"
        className="footer__cta fade-in"
        target="_blank"
        rel="noopener noreferrer"
      >
        <span>Instagram DM</span>
        <span className="footer__cta-arrow">→</span>
      </a>

      <div className="footer__bottom">
        <div>© darkexpress 2026 · darkexpress.top</div>
        <div className="footer__links">
          <a href="http://soundcloud.com/dark_express" target="_blank" rel="noopener noreferrer">SoundCloud</a>
          <a href="https://ra.co/dj/darkexpress" target="_blank" rel="noopener noreferrer">RA</a>
          <a href="https://darkexpress.bandcamp.com" target="_blank" rel="noopener noreferrer">Bandcamp</a>
        </div>
      </div>
    </footer>
  );
}

function App() {
  return (
    <div className="site">
      <div className="grain"></div>
      <Nav />
      <SoundController />
      <Hero />
    </div>
  );
}

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