// app.jsx — Apple 50 root. Wires the handoff screens into a navigable app:
// screen routing, collected-patch persistence, a shared D-pad focus controller,
// tweak vars piped to :root, and the floating Tweaks panel.

const { useState, useEffect, useRef, useCallback } = React;

const STORE_KEY = "apple50_v1";
const BINGO_KEY = "apple50_bingo_v1";

// Dev tools (DebugPanel): shown ONLY while the URL has ?debug=1 — no
// persistence, so a plain reload (or "Exit debug") hides it. Normal visitors
// never see it.
const SHOW_DEBUG = (() => {
  try {
    // Clear any previously-persisted flag from the old behavior.
    localStorage.removeItem("apple50_debug");
    return new URLSearchParams(location.search).get("debug") === "1";
  } catch (e) {
    try { return new URLSearchParams(location.search).get("debug") === "1"; } catch (e2) { return false; }
  }
})();

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "glow": "#74c7ff",
  "patchShape": "hex",
  "claimStyle": "pinch",
  "glowIntensity": 1,
  "speed": 1
}/*EDITMODE-END*/;

function loadCollected() {
  try { const v = JSON.parse(localStorage.getItem(STORE_KEY)); return Array.isArray(v) ? v : []; }
  catch (e) { return []; }
}
function saveCollected(c) {
  try { localStorage.setItem(STORE_KEY, JSON.stringify(c)); } catch (e) {}
}

// WWDC Bingo: which prediction squares the user has marked as "announced".
function loadBingo() {
  try { const v = JSON.parse(localStorage.getItem(BINGO_KEY)); return Array.isArray(v) ? v : []; }
  catch (e) { return []; }
}
function saveBingo(m) {
  try { localStorage.setItem(BINGO_KEY, JSON.stringify(m)); } catch (e) {}
}

// Screens that run their own key handling — the global controller stands down.
const SELF_MANAGED = { claim: true };

// Shared D-pad focus controller. Re-binds whenever the screen/content changes,
// and rebuilds its focusable list on DOM mutations (e.g. radar arrival swaps the
// footer buttons in place) so Enter always lands on the live target.
function useDpad(active, deps) {
  useEffect(() => {
    if (!active) return;
    const root = document.getElementById("app");
    if (!root) return;

    let items = [];
    let idx = 0;

    const query = () => Array.from(root.querySelectorAll(".focusable"))
      .filter((el) => el.offsetParent !== null);

    const paint = () => items.forEach((el, i) => el.classList.toggle("is-focused", i === idx));
    const focusCurrent = (scroll) => {
      const el = items[idx];
      if (!el) return;
      try { el.focus({ preventScroll: true }); } catch (e) {}
      if (scroll) el.scrollIntoView({ block: "nearest" });
    };

    // Initial bind: focus the autofocus element, or the first focusable.
    items = query();
    if (!items.length) return;
    idx = Math.max(0, items.findIndex((el) => el.hasAttribute("data-autofocus")));
    paint(); focusCurrent(true);

    // Keep the list current when the screen's DOM changes underneath us.
    const refresh = () => {
      const prev = items[idx] || null;
      const next = query();
      if (!next.length) return;
      const sameSet = next.length === items.length && next.every((el, i) => el === items[i]);
      items = next;
      if (sameSet) { idx = Math.min(idx, items.length - 1); paint(); return; }
      // Structure changed: a data-autofocus element marks the deliberate target
      // (e.g. radar's "Claim patch" on arrival). Prefer it; else keep the node
      // that survived the change; else fall back to the first focusable.
      const af = items.findIndex((el) => el.hasAttribute("data-autofocus"));
      const survived = prev ? items.indexOf(prev) : -1;
      idx = af >= 0 ? af : survived >= 0 ? survived : 0;
      paint(); focusCurrent(true);
    };
    let pending = false;
    const mo = new MutationObserver(() => {
      if (pending) return;
      pending = true;
      requestAnimationFrame(() => { pending = false; refresh(); });
    });
    mo.observe(root, { childList: true, subtree: true });

    // Spatial (geometry-based) navigation: arrows move focus to the nearest
    // focusable in that direction, so 2-D layouts navigate naturally — grid rows
    // AND columns on Bingo, the list + footer bar on Home. A focused
    // [data-scroll] element scrolls before focus leaves it; screens with a
    // [data-back-on-left] root treat ArrowLeft as Back (swipe-left).
    const goBack = () => { const b = root.querySelector("[data-back]"); if (b) b.click(); };
    const centerOf = (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; };
    const spatial = (dir) => {
      const cur = items[idx]; if (!cur) return;
      const a = centerOf(cur);
      let best = -1, bestScore = Infinity;
      for (let i = 0; i < items.length; i++) {
        if (i === idx) continue;
        const b = centerOf(items[i]);
        const dx = b.x - a.x, dy = b.y - a.y;
        let along, cross;
        if (dir === "down") { if (dy <= 4) continue; along = dy; cross = Math.abs(dx); }
        else if (dir === "up") { if (dy >= -4) continue; along = -dy; cross = Math.abs(dx); }
        else if (dir === "right") { if (dx <= 4) continue; along = dx; cross = Math.abs(dy); }
        else { if (dx >= -4) continue; along = -dx; cross = Math.abs(dy); }
        const score = along + cross * 2.2; // weight cross-axis so aligned items win
        if (score < bestScore) { bestScore = score; best = i; }
      }
      if (best >= 0) { idx = best; paint(); focusCurrent(true); }
    };

    const onKey = (e) => {
      const cur = items[idx];
      const scroller = cur && cur.hasAttribute("data-scroll") ? cur : null;
      const backOnLeft = !!root.querySelector("[data-back-on-left]");
      switch (e.key) {
        case "ArrowDown":
        case "ArrowUp": {
          const down = e.key === "ArrowDown";
          if (scroller && down && scroller.scrollTop + scroller.clientHeight < scroller.scrollHeight - 2) {
            scroller.scrollTop += 90; e.preventDefault(); return;
          }
          if (scroller && !down && scroller.scrollTop > 2) {
            scroller.scrollTop -= 90; e.preventDefault(); return;
          }
          spatial(down ? "down" : "up");
          e.preventDefault(); break;
        }
        case "ArrowLeft":
          if (backOnLeft) goBack(); else spatial("left");
          e.preventDefault(); break;
        case "ArrowRight":
          spatial("right");
          e.preventDefault(); break;
        case "Enter":
        case " ":
          if (cur) cur.click();
          e.preventDefault(); break;
        case "Escape":
        case "Backspace":
          goBack();
          e.preventDefault(); break;
        default: break;
      }
    };
    window.addEventListener("keydown", onKey);
    return () => { window.removeEventListener("keydown", onKey); mo.disconnect(); };
  }, deps); // eslint-disable-line react-hooks/exhaustive-deps
}

function App() {
  const [t, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
  const [collected, setCollected] = useState(loadCollected);
  const [bingoMarked, setBingoMarked] = useState(loadBingo);
  // If the trail is already complete on load, open on the reward screen first.
  const [screen, setScreen] = useState(() => {
    const c = loadCollected();
    const vis = c.filter((id) => window.TRAIL.some((s) => s.id === id));
    return window.TRAIL.length > 0 && vis.length === window.TRAIL.length ? "complete" : "home";
  });
  const [activeId, setActiveId] = useState(null);
  const [justEarned, setJustEarned] = useState(false);

  const TRAIL = window.TRAIL;
  // Only count/show patches that belong to the trail shown right now. The WWDC
  // event patch is hidden outside its date window even if previously earned, so
  // derive a "visible" collected set to keep progress and completion consistent.
  const visible = collected.filter((id) => TRAIL.some((s) => s.id === id));
  const allDone = visible.length === TRAIL.length;
  // Resolve from the full set so the Passport can open a collected WWDC patch
  // from a past/other year (which is hidden from window.TRAIL outside its event).
  // Also search the parallel OS Trail (macOS-named California places).
  const site = window.TRAIL_ALL.find((s) => s.id === activeId)
    || (window.OS_TRAIL || []).find((s) => s.id === activeId) || null;
  const isCollected = !!site && collected.includes(site.id);
  const isOsSite = !!site && (window.OS_TRAIL || []).some((s) => s.id === site.id);
  // Detail's "X / 0N" counter and back target depend on which trail the site is on.
  const siteTotal = isOsSite ? window.OS_TRAIL.length : TRAIL.length;

  // pipe tweak values to the design-system CSS variables
  useEffect(() => {
    const r = document.documentElement.style;
    r.setProperty("--glow", t.glow);
    r.setProperty("--gi", String(t.glowIntensity));
    r.setProperty("--spd", String(t.speed));
  }, [t.glow, t.glowIntensity, t.speed]);

  useEffect(() => { saveCollected(collected); }, [collected]);
  useEffect(() => { saveBingo(bingoMarked); }, [bingoMarked]);

  // WWDC Bingo handlers + liveness (keynote window, or a localhost debug toggle).
  const bingoToggle = useCallback((id) => {
    setBingoMarked((m) => (m.includes(id) ? m.filter((x) => x !== id) : [...m, id]));
  }, []);
  const bingoReset = useCallback(() => setBingoMarked([]), []);
  const bingoLive = window.bingoLive ? window.bingoLive() : false;
  // OS Trail is hidden until its reveal (Fri Jun 12, 2026, 9am PT) — or forced on
  // via the DebugPanel toggle (forceOsTrail flips window.__DEBUG_FORCE_OS_TRAIL).
  const osTrailLive = window.osTrailLive ? window.osTrailLive() : false;

  // navigation helpers. `openedFrom` remembers which list a detail was opened
  // from ("home" or "ostrail") so Detail's Back returns to the right trail.
  const [openedFrom, setOpenedFrom] = useState("home");
  const open = useCallback((s, from) => {
    setActiveId(s.id); setJustEarned(false);
    if (from) setOpenedFrom(from);
    setScreen("detail");
  }, []);
  const goHome = useCallback(() => { setJustEarned(false); setScreen("home"); }, []);
  const goBackFromDetail = useCallback(() => { setJustEarned(false); setScreen(openedFrom); }, [openedFrom]);
  // Open a site's unlocked secret on its own screen; remember where we came from.
  const [secretFrom, setSecretFrom] = useState("home");
  const openSecret = useCallback((s, from) => {
    setActiveId(s.id); setSecretFrom(from || "home"); setScreen("secret");
  }, []);
  // Open the Passport, remembering which screen we came from so Back returns
  // to the right trail ("home" or "ostrail") instead of always going home.
  const [passportFrom, setPassportFrom] = useState("home");
  const openPassport = useCallback((from) => {
    setJustEarned(false); setPassportFrom(from || "home"); setScreen("passport");
  }, []);
  const goBackFromPassport = useCallback(() => { setJustEarned(false); setScreen(passportFrom); }, [passportFrom]);
  // Watch a video: on mobile, pop a bottom drawer that plays inline; elsewhere
  // (glasses/desktop), use the full-screen WatchScreen route.
  const [videoDrawer, setVideoDrawer] = useState(null);
  const watchVideo = useCallback((video, name, screen) => {
    if (!video) return;
    if (window.innerWidth < 600) setVideoDrawer({ video: video, name: name });
    else setScreen(screen);
  }, []);
  const claim = useCallback(() => {
    const after = !activeId || collected.includes(activeId) ? collected : [...collected, activeId];
    setCollected(after);
    setJustEarned(true);
    // OS Trail patches have no separate reward screen — land back on the detail.
    if ((window.OS_TRAIL || []).some((s) => s.id === activeId)) { setScreen("detail"); return; }
    // If that was the final Origin Trail patch, go straight to the reward screen.
    const visAfter = after.filter((id) => TRAIL.some((s) => s.id === id));
    setScreen(TRAIL.length > 0 && visAfter.length === TRAIL.length ? "complete" : "detail");
  }, [activeId, collected, TRAIL]);

  // ---- Debug tools (localhost only) ----
  const [forceEvents, setForceEvents] = useState(false);
  const [forceBingo, setForceBingo] = useState(false);
  const dbgClaimHere = useCallback(() => {
    if (!activeId) return;
    setCollected((c) => (c.includes(activeId) ? c : [...c, activeId]));
    setJustEarned(true);
    setScreen("detail");
  }, [activeId]);
  const dbgClaimAll = useCallback(() => {
    setCollected(window.TRAIL_ALL.concat(window.OS_TRAIL || []).map((s) => s.id));
  }, []);
  const dbgReset = useCallback(() => { setCollected([]); setJustEarned(false); }, []);
  const dbgSpoofHere = useCallback(() => {
    if (!site) return;
    if (window.__debugWalkStop) window.__debugWalkStop();
    window.__DEBUG_LOC = { lat: site.lat, lng: site.lng };
    window.dispatchEvent(new Event("debug:loc"));
  }, [site]);
  // One-click: drop onto the radar ~0.4 mi from the active site and animate the
  // approach, so you see the full navigating → arrived → claim → unlock flow.
  const dbgWalkClaim = useCallback(() => {
    if (!site) return;
    setScreen("radar");
    if (window.__debugWalk) window.__debugWalk({ lat: site.lat, lng: site.lng });
  }, [site]);
  const dbgResetLoc = useCallback(() => {
    if (window.__debugWalkStop) window.__debugWalkStop();
    window.__DEBUG_LOC = null;
    window.dispatchEvent(new Event("debug:loc"));
  }, []);
  const dbgToggleWWDC = useCallback(() => {
    setForceEvents((on) => {
      const next = !on;
      window.__DEBUG_FORCE_EVENTS = next;
      if (window.__recomputeTrail) window.__recomputeTrail();
      return next;
    });
  }, []);
  const dbgToggleBingo = useCallback(() => {
    setForceBingo((on) => {
      const next = !on;
      window.__DEBUG_FORCE_BINGO = next; // bingoLive() reads this; state re-renders App
      return next;
    });
  }, []);
  const [forceHalloween, setForceHalloween] = useState(false);
  const dbgToggleHalloween = useCallback(() => {
    setForceHalloween((on) => { const next = !on; window.__DEBUG_HALLOWEEN = next; return next; });
  }, []);
  const [forceOsTrail, setForceOsTrail] = useState(false);
  const dbgToggleOsTrail = useCallback(() => {
    setForceOsTrail((on) => { const next = !on; window.__DEBUG_FORCE_OS_TRAIL = next; return next; });
  }, []);
  const dbgExit = useCallback(() => {
    try { localStorage.removeItem("apple50_debug"); } catch (e) {}
    location.href = location.pathname; // drop ?debug and reload clean
  }, []);

  // controller is active on every screen except the self-managed ones
  useDpad(!SELF_MANAGED[screen], [screen, activeId, collected.length, justEarned]);

  // Debug tools bundle (the "debug" screen spreads this). Built before the
  // switch so the route can reference it.
  const debug = SHOW_DEBUG ? {
    site, collected, forceEvents, forceBingo, forceHalloween, forceOsTrail,
    locActive: !!window.__DEBUG_LOC,
    onClaimHere: dbgClaimHere, onClaimAll: dbgClaimAll, onReset: dbgReset,
    onSpoofHere: dbgSpoofHere, onWalkClaim: dbgWalkClaim, onResetLoc: dbgResetLoc,
    onToggleWWDC: dbgToggleWWDC, onToggleBingo: dbgToggleBingo,
    onToggleHalloween: dbgToggleHalloween, onToggleOsTrail: dbgToggleOsTrail, onExit: dbgExit,
  } : null;

  let view = null;
  switch (screen) {
    case "home":
      view = <window.HomeScreen
        collected={visible} allDone={allDone} shape={t.patchShape}
        onSite={open} onSecret={(s) => openSecret(s, "home")}
        onPassport={() => openPassport("home")}
        onOsTrail={osTrailLive ? () => setScreen("ostrail") : undefined}
        bingoLive={bingoLive} onBingo={() => setScreen("bingo")}
        showDebug={SHOW_DEBUG} onDebug={() => setScreen("debug")} />;
      break;
    case "ostrail":
      view = <window.OSTrailScreen collected={collected} shape={t.patchShape}
        onSite={(s) => open(s, "ostrail")} onPassport={() => openPassport("ostrail")} onBack={goHome} />;
      break;
    case "bingo":
      view = <window.BingoScreen marked={bingoMarked}
        onToggle={bingoToggle} onReset={bingoReset}
        onBoard={() => setScreen("bingoboard")} onBack={goHome} />;
      break;
    case "bingoboard":
      view = <window.BingoLeaderboard marked={bingoMarked} onBack={() => setScreen("bingo")} />;
      break;
    case "detail":
      view = site && <window.DetailScreen
        site={site} shape={t.patchShape} justEarned={justEarned}
        collected={isCollected} isLast={allDone} total={siteTotal}
        onNavigate={() => setScreen("radar")}
        onWatch={() => watchVideo(site.video, site.name, "watch")}
        onHome={goBackFromDetail}
        onPassport={() => openPassport("detail")}
        onFinish={() => setScreen("complete")}
        onSecret={() => openSecret(site, "detail")} />;
      break;
    case "radar":
      view = site && <window.RadarScreen
        site={site} onArrived={() => setScreen("claim")} onBack={() => setScreen("detail")} />;
      break;
    case "claim":
      view = site && <window.ClaimScreen
        site={site} shape={t.patchShape} claimStyle={t.claimStyle} onClaimed={claim} />;
      break;
    case "passport":
      view = <window.PassportScreen collected={collected} shape={t.patchShape} onOpen={open} onBack={goBackFromPassport} />;
      break;
    case "watch":
      view = site && site.video
        ? <window.WatchScreen site={site} onBack={() => setScreen("detail")} />
        : <window.DetailScreen site={site} shape={t.patchShape} justEarned={justEarned}
            collected={isCollected} isLast={allDone} total={siteTotal}
            onNavigate={() => setScreen("radar")} onWatch={() => setScreen("watch")}
            onHome={goBackFromDetail} onPassport={() => openPassport("detail")} onFinish={() => setScreen("complete")}
            onSecret={() => openSecret(site, "detail")} />;
      break;
    case "secret":
      view = site && site.secret && <window.SecretScreen
        secret={site.secret} siteName={site.name}
        onBack={() => setScreen(secretFrom)}
        onWatch={() => watchVideo(site.secret.video, site.secret.name, "secretwatch")} />;
      break;
    case "secretwatch":
      view = site && site.secret && site.secret.video
        ? <window.WatchScreen video={site.secret.video} name={site.secret.name}
            onBack={() => setScreen("secret")} />
        : site && site.secret && <window.SecretScreen secret={site.secret} siteName={site.name}
            onBack={() => setScreen(secretFrom)} onWatch={() => setScreen("secretwatch")} />;
      break;
    case "complete":
      view = <window.CompleteScreen shape={t.patchShape} collected={visible} onHome={goHome} />;
      break;
    case "debug":
      view = debug ? <window.DebugPanel {...debug} onBack={goHome} /> : null;
      break;
    default:
      view = null;
  }

  const { TweaksPanel, TweakSection, TweakColor, TweakRadio, TweakSlider } = window;

  return (
    <>
      {view}
      {videoDrawer && window.VideoDrawer && (
        <window.VideoDrawer video={videoDrawer.video} name={videoDrawer.name}
          onClose={() => setVideoDrawer(null)} />
      )}
      <TweaksPanel title="Apple 50">
        <TweakSection label="Patch" />
        <TweakRadio label="Shape" value={t.patchShape}
          options={[
            { value: "hex", label: "Hex" },
            { value: "shield", label: "Shield" },
            { value: "rounded", label: "Rounded" },
            { value: "circle", label: "Circle" },
          ]}
          onChange={(v) => setTweak("patchShape", v)} />
        <TweakRadio label="Claim gesture" value={t.claimStyle}
          options={[{ value: "hold", label: "Hold" }, { value: "pinch", label: "Pinch" }]}
          onChange={(v) => setTweak("claimStyle", v)} />
        <TweakSection label="Glow" />
        <TweakColor label="Hue" value={t.glow}
          options={["#74c7ff", "#6ee7b7", "#ffb273", "#c77dff", "#ff7a93"]}
          onChange={(v) => setTweak("glow", v)} />
        <TweakSlider label="Intensity" value={t.glowIntensity} min={0} max={2} step={0.1}
          onChange={(v) => setTweak("glowIntensity", v)} />
        <TweakSlider label="Speed" value={t.speed} min={0.5} max={2} step={0.1} unit="x"
          onChange={(v) => setTweak("speed", v)} />
      </TweaksPanel>
    </>
  );
}

window.App = App;
ReactDOM.createRoot(document.getElementById("app")).render(<App />);
