// Main app - pulls all sections together, sets accent color via Tweaks.
const { useEffect, useState, useRef, useCallback } = React;

function LanguageDropdown({ lang, setLang }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, []);
  const opts = [
    { v: "en", short: "EN", long: "English" },
    { v: "nl", short: "NL", long: "Nederlands" },
  ];
  const current = opts.find((o) => o.v === lang) || opts[0];
  return (
    <div className="lang-dd" ref={ref}>
      <button
        className="lang-trigger"
        onClick={() => setOpen((o) => !o)}
        aria-haspopup="listbox"
        aria-expanded={open}
      >
        <span className="lang-globe" aria-hidden="true">◉</span>
        <span className="lang-current">{current.short}</span>
        <span className={"lang-caret " + (open ? "open" : "")}>▾</span>
      </button>
      {open && (
        <ul className="lang-menu" role="listbox">
          {opts.map((o) => (
            <li key={o.v}>
              <button
                role="option"
                aria-selected={o.v === lang}
                className={"lang-opt " + (o.v === lang ? "on" : "")}
                onClick={() => { setLang(o.v); setOpen(false); }}
              >
                <span className="short">{o.short}</span>
                <span className="long">{o.long}</span>
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// Streak palette and layout helper. Streaks always start at heroEnd (just
// past the helmet zoom) and fall the full remaining document height.
const STREAK_PALETTE = ["#ff7a00", "#ffa040", "#ffd166", "#00d9ff", "#7ae6ff"];
function getLayout() {
  const vh = window.innerHeight;
  // Spawn well inside the helmet's sticky coverage (viewport 0–100vh) so new
  // streaks are fully hidden by the helmet image and only emerge as they
  // fall past it. Fall distance grows accordingly so streaks still reach
  // the bottom of the document.
  const heroEnd = vh * 0.5;
  const docHeight = Math.max(
    document.body.scrollHeight,
    document.documentElement.scrollHeight,
  );
  const fallDistance = Math.max(vh * 6, docHeight - heroEnd);
  return { heroEnd, fallDistance };
}
// Minimum horizontal separation (in percent of container width) between any
// two streaks. With 8 slots and 100% width, an even distribution lands at
// 12.5% gaps - leaving us comfortable headroom at 10%.
const MIN_LEFT_GAP = 10;

function pickSpacedLeft(existingLefts) {
  // Try a handful of random samples, accept the first one that clears the
  // gap threshold. If none does, fall back to the candidate with the
  // largest minimum distance - keeps streaks as far apart as possible even
  // when the slot pool is already saturated.
  let bestLeft = Math.random() * 100;
  let bestMinDist = -Infinity;
  for (let i = 0; i < 30; i++) {
    const candidate = Math.random() * 100;
    let minDist = Infinity;
    for (const ex of existingLefts) {
      const d = Math.abs(candidate - ex);
      if (d < minDist) minDist = d;
    }
    if (existingLefts.length === 0 || minDist >= MIN_LEFT_GAP) {
      return candidate;
    }
    if (minDist > bestMinDist) {
      bestMinDist = minDist;
      bestLeft = candidate;
    }
  }
  return bestLeft;
}

function makeStreakConfig({ initial, existingLefts = [] }) {
  // Streaks now fall at a constant px/s rate; the Streak component reads the
  // current fallDistance every frame via a ref, so a streak in flight when
  // the document grows (images/fonts loaded late) automatically continues
  // until it reaches the new bottom rather than despawning mid-page.
  return {
    id: Math.random().toString(36).slice(2),
    left: pickSpacedLeft(existingLefts),
    color: STREAK_PALETTE[Math.floor(Math.random() * STREAK_PALETTE.length)],
    // One unified rate for every streak (initial wave + replacements). An
    // earlier version used 150 px/s for the first wave and 380 px/s for
    // steady-state, but when the slow wave finished the rate jumped 2.5x
    // all at once - users noticed it as the streaks "suddenly going fast."
    // Constant 200 px/s sidesteps the discontinuity entirely.
    fallRate: 200,
    // Positive delays stagger the initial wave so streaks emerge one by one
    // from the top instead of appearing already mid-flight.
    delay: initial ? Math.random() * 8 : 0,
    height: 70 + Math.random() * 140,
  };
}

// Single streak - animated by requestAnimationFrame in JS. We previously
// used a CSS animation with `translateY(var(--fall))`, but the @property +
// custom-property interpolation is unreliable across browsers (in some
// browsers the transform snaps from 0 to the final value with no visible
// fall, which made streaks appear and then "disappear" before reaching the
// user). JS animation is bulletproof: every frame, we set the transform
// based on real elapsed time, and the streak truly travels from heroEnd
// down to docBottom.
const Streak = React.memo(function Streak({ slot, cfg, heroEndPx, fallDistance, onDone }) {
  const ref = useRef(null);
  // Live fallDistance ref so the animate loop reads the latest value every
  // frame. Prevents existing streaks from despawning at a stale boundary if
  // the document grows mid-flight (images/fonts/lazy content), and avoids
  // restarting the RAF when the prop changes.
  const fallDistanceRef = useRef(fallDistance);
  useEffect(() => { fallDistanceRef.current = fallDistance; }, [fallDistance]);

  useEffect(() => {
    let raf = 0;
    let cancelled = false;
    // Per-frame delta accumulation rather than (now - startTime). When the
    // tab is hidden, RAF pauses but performance.now() keeps advancing - if
    // we computed total elapsed on resume, every streak would teleport past
    // the bottom and fire onDone in unison, spawning a synchronized burst
    // of replacements. Clamping dt to 100ms makes the streak effectively
    // pause while the tab is away and resume from where it left off.
    let lastTs = null;
    let waitedMs = 0;
    let fallen = 0;
    const totalDelayMs = cfg.delay * 1000;
    if (ref.current) {
      ref.current.style.transform = "translateY(0px)";
    }
    const animate = (now) => {
      if (cancelled) return;
      if (lastTs !== null) {
        const dt = Math.min(now - lastTs, 100);
        if (waitedMs < totalDelayMs) {
          waitedMs += dt;
        } else {
          fallen += (dt / 1000) * cfg.fallRate;
        }
      }
      lastTs = now;
      const fd = fallDistanceRef.current;
      if (ref.current) {
        ref.current.style.transform = `translateY(${Math.min(fallen, fd)}px)`;
      }
      if (fallen < fd) {
        raf = requestAnimationFrame(animate);
      } else {
        onDone(slot);
      }
    };
    raf = requestAnimationFrame(animate);
    return () => {
      cancelled = true;
      if (raf) cancelAnimationFrame(raf);
    };
  }, [slot, cfg.id, cfg.fallRate, cfg.delay, onDone]);
  return (
    <span
      ref={ref}
      className="hero-streak"
      style={{
        left: `${cfg.left}%`,
        top: `${heroEndPx}px`,
        color: cfg.color,
        height: `${cfg.height}px`,
      }}
    />
  );
});

// Visor exhaust - 5 streaks falling from heroEnd all the way down to the
// bottom of the document. Each is animated by its own RAF loop. After the
// fall completes naturally, the slot's React key changes and a new streak
// mounts to start the journey over.
function VisorExhaust() {
  const containerRef = useRef(null);
  const [layout, setLayout] = useState(getLayout);
  const [slots, setSlots] = useState(() => {
    // Build the initial 8 sequentially so each new streak knows the lefts
    // already taken and can pick a spaced-out position.
    const arr = [];
    for (let i = 0; i < 8; i++) {
      arr.push(makeStreakConfig({
        initial: true,
        existingLefts: arr.map((s) => s.left),
      }));
    }
    return arr;
  });

  const replaceSlot = useCallback((slot) => {
    setSlots((prev) => {
      const next = [...prev];
      const others = prev.filter((_, i) => i !== slot).map((s) => s.left);
      next[slot] = makeStreakConfig({ initial: false, existingLefts: others });
      return next;
    });
  }, []);

  useEffect(() => {
    const remeasure = () => setLayout(getLayout());
    const onScroll = () => {
      const el = containerRef.current;
      if (!el) return;
      const y = window.scrollY;
      const vh = window.innerHeight;
      // Trigger fade-in over the last bit of the hero so streaks become
      // visible right as the helmet exits the viewport.
      const start = vh * 1.7;
      const end = vh * 2;
      el.style.opacity = String(Math.max(0, Math.min(1, (y - start) / (end - start))));
    };
    const onResize = () => {
      remeasure();
      onScroll();
    };
    onScroll();
    remeasure();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onResize);
    window.addEventListener("load", remeasure);

    // The document keeps growing after initial mount as images, fonts, and
    // late-laying-out sections settle. Without re-measuring, the streaks'
    // fallDistance freezes at the early-mount value and they despawn mid-
    // page (around the Activities section) instead of reaching the footer.
    let ro = null;
    if (typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver(remeasure);
      ro.observe(document.body);
    }
    document.fonts?.ready?.then(remeasure).catch(() => {});

    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onResize);
      window.removeEventListener("load", remeasure);
      if (ro) ro.disconnect();
    };
  }, []);

  return (
    <div ref={containerRef} className="visor-exhaust" aria-hidden="true">
      {slots.map((cfg, slot) => (
        <Streak
          key={cfg.id}
          slot={slot}
          cfg={cfg}
          heroEndPx={layout.heroEnd}
          fallDistance={layout.fallDistance}
          onDone={replaceSlot}
        />
      ))}
    </div>
  );
}

function App() {
  const [tweaks, setTweak] = window.useTweaks(window.TWEAK_DEFAULTS);
  const [lang, setLang] = useState("en");
  const [navOpen, setNavOpen] = useState(false);

  useEffect(() => {
    const root = document.documentElement;
    // Validate as a hex color before applying so a malicious tweak edit
    // can't smuggle arbitrary CSS into a `var(--accent)` consumer.
    const isHex = (s) => typeof s === "string" && /^#[0-9a-f]{3,8}$/i.test(s);
    if (isHex(tweaks.accent))  root.style.setProperty("--accent", tweaks.accent);
    if (isHex(tweaks.accent2)) root.style.setProperty("--accent-2", tweaks.accent2);
  }, [tweaks.accent, tweaks.accent2]);

  const closeNav = () => setNavOpen(false);

  return (
    <>
      <div id="bg-canvas" />
      <div id="bg-grid" />
      <VisorExhaust />

      <nav className={"nav" + (navOpen ? " open" : "")}>
        <div className="nav-left">
          <span className="nav-mark"><span className="dot" /> MARTIJN VAN BAELEN</span>
        </div>
        <button
          className={"nav-burger" + (navOpen ? " on" : "")}
          aria-label="Toggle navigation"
          aria-expanded={navOpen}
          onClick={() => setNavOpen((o) => !o)}
        >
          <span /><span /><span />
        </button>
        <div className="nav-right">
          <a className="nav-link" href="#about" onClick={closeNav}>{lang === "nl" ? "Coureur" : "Driver"}</a>
          <a className="nav-link" href="#simulator" onClick={closeNav}>Simulator</a>
          <a className="nav-link" href="#activities" onClick={closeNav}>{lang === "nl" ? "Activiteiten" : "Activities"}</a>
          <a className="nav-link" href="#selected" onClick={closeNav}>{lang === "nl" ? "Hoogtepunten" : "Highlights"}</a>
          <a className="nav-link" href="#timeline" onClick={closeNav}>{lang === "nl" ? "Seizoen" : "Season"}</a>
          <a className="nav-link" href="#reflection" onClick={closeNav}>Debrief</a>
          <a className="nav-link" href="#contact" onClick={closeNav}>Contact</a>
          <LanguageDropdown lang={lang} setLang={setLang} />
        </div>
      </nav>

      <window.Hero lang={lang} accent={tweaks.accent} />
      <window.Profile lang={lang} />
      <window.SimulatorSection accent={tweaks.accent} lang={lang} />
      <window.Activities lang={lang} />
      <window.Selected lang={lang} />
      <window.Timeline lang={lang} />
      <window.Final lang={lang} />
      <window.Contact lang={lang} />

      <footer className="footer">
        <span>© 2026 · Martijn Van Baelen · I-Talent Portfolio</span>
      </footer>

      <window.TweaksPanel title="Tweaks · Pit Wall">
        <window.TweakSection title="Brand">
          <window.TweakColor label="Primary accent (red)" value={tweaks.accent} onChange={(v) => setTweak("accent", v)} />
          <window.TweakColor label="Secondary accent (cyan)" value={tweaks.accent2} onChange={(v) => setTweak("accent2", v)} />
          <window.TweakRadio label="Accent palette" value={tweaks.palette} onChange={(v) => {
            setTweak("palette", v);
            const presets = {
              redblue: { accent: "#ff2e3e", accent2: "#00d9ff" },
              orange:  { accent: "#ff7a00", accent2: "#ffd400" },
              violet:  { accent: "#b14bff", accent2: "#00ffa8" },
              mono:    { accent: "#e8edf5", accent2: "#6f7c8e" },
            };
            const p = presets[v]; if (p) setTweak(p);
          }} options={[
            { label: "Red/Blue", value: "redblue" },
            { label: "Orange", value: "orange" },
            { label: "Violet", value: "violet" },
            { label: "Mono", value: "mono" },
          ]} />
        </window.TweakSection>
      </window.TweaksPanel>
    </>
  );
}

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