const { useState, useEffect, useRef, useMemo, useCallback } = React;
const { buildFractal, hashSeed, seedToFateCode, fateCodeToSeed } = window.FractalEngine;
const CatMeta = window.FractalEngine.CAT_META;
const ENRICH_CONCURRENCY = 6;

const STORAGE_KEY = "lpfv::paths::v1";
// Legacy keys — removed in favour of URL-as-source-of-truth + explicit Saved Paths.
const LEGACY_KEYS = ["lpfv::active::v1", "lpfv::choice", "lpfv::seed"];

// ---------------- URL state ----------------
function readUrlState() {
  try {
    const p = new URLSearchParams(window.location.search);
    const f = (p.get("f") || "").trim().toUpperCase();
    const c = (p.get("c") || "").trim();
    if (f && c && /^[A-Z0-9]{6}$/.test(f)) return { fateCode: f, choice: c };
  } catch {}
  return null;
}
function writeUrlState({ fateCode, choice }) {
  try {
    const url = new URL(window.location.href);
    if (fateCode && choice) {
      url.searchParams.set("f", fateCode);
      url.searchParams.set("c", choice);
    } else {
      url.searchParams.delete("f");
      url.searchParams.delete("c");
    }
    window.history.replaceState(null, "", url.toString());
  } catch {}
}
function clearUrlState() {
  try {
    const url = new URL(window.location.href);
    url.searchParams.delete("f");
    url.searchParams.delete("c");
    window.history.replaceState(null, "", url.toString());
  } catch {}
}

// ---------------- TWEAKS ----------------
const TWEAKS = /*EDITMODE-BEGIN*/{
  "accentCyan": "#00F0FF",
  "accentPurple": "#B24BFF",
  "treeDepth": 4,
  "glow": true,
  "grid": true
}/*EDITMODE-END*/;

// ---------------- utils ----------------
function useLocalStorage(key, initial) {
  const [v, setV] = useState(() => {
    try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : initial; }
    catch { return initial; }
  });
  useEffect(() => {
    try { localStorage.setItem(key, JSON.stringify(v)); } catch {}
  }, [key, v]);
  return [v, setV];
}

function clsx(...a) { return a.filter(Boolean).join(" "); }

// ---------------- Glyphs (small, geometric only) ----------------
const Glyph = {
  Plus:   (p) => <svg viewBox="0 0 16 16" {...p}><path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>,
  Copy:   (p) => <svg viewBox="0 0 16 16" {...p}><rect x="5" y="5" width="8" height="8" rx="1.2" stroke="currentColor" strokeWidth="1.2" fill="none"/><path d="M3 10V4a1 1 0 0 1 1-1h6" stroke="currentColor" strokeWidth="1.2" fill="none"/></svg>,
  Dice:   (p) => <svg viewBox="0 0 16 16" {...p}><rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="1.2" fill="none"/><circle cx="5.5" cy="5.5" r="1" fill="currentColor"/><circle cx="10.5" cy="10.5" r="1" fill="currentColor"/><circle cx="8" cy="8" r="1" fill="currentColor"/></svg>,
  Play:   (p) => <svg viewBox="0 0 16 16" {...p}><path d="M5 3.5v9l7-4.5z" fill="currentColor"/></svg>,
  Save:   (p) => <svg viewBox="0 0 16 16" {...p}><path d="M3 3h8l2 2v8H3z M5 3v4h6V3" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinejoin="round"/></svg>,
  X:      (p) => <svg viewBox="0 0 16 16" {...p}><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>,
  Chev:   (p) => <svg viewBox="0 0 16 16" {...p}><path d="M5 6l3 3 3-3" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  Arrow:  (p) => <svg viewBox="0 0 16 16" {...p}><path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  Gear:   (p) => <svg viewBox="0 0 16 16" {...p}><circle cx="8" cy="8" r="2.2" stroke="currentColor" strokeWidth="1.2" fill="none"/><path d="M8 2v2M8 12v2M2 8h2M12 8h2M3.8 3.8l1.4 1.4M10.8 10.8l1.4 1.4M3.8 12.2l1.4-1.4M10.8 5.2l1.4-1.4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>,
  Link:   (p) => <svg viewBox="0 0 16 16" {...p}><path d="M7 9l2-2M6.5 10.5a2.5 2.5 0 0 1 0-3.5l2-2a2.5 2.5 0 0 1 3.5 3.5L11 9.5M9.5 5.5a2.5 2.5 0 0 1 0 3.5l-2 2a2.5 2.5 0 0 1-3.5-3.5L5 6.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  Spark:  (p) => <svg viewBox="0 0 16 16" {...p}><path d="M8 2v4M8 10v4M2 8h4M10 8h4M4 4l2 2M10 10l2 2M4 12l2-2M10 6l2-2" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" fill="none"/></svg>,
};

// ---------------- Narrative enrichment ----------------
const GEMINI_ENDPOINT   = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
const POLLINATIONS_ENDPOINT = "https://text.pollinations.ai/";

function buildNodePrompt({ node, choice }) {
  const catLabel = CatMeta[node.category]?.label || "Ending";
  const valLabel = node.valence > 0 ? "thriving" : node.valence < 0 ? "struggle" : "ambiguous";
  const tYears = (node.depth * 2.5).toFixed(1);
  const stepStr = node.step
    ? ["wealth","joy","chaos","legacy"].map(k => `${k[0]}${node.step[k]>=0?"+":""}${node.step[k]}`).join(" ")
    : "";
  const terminalHint = node.terminal
    ? "This event ENDS the path — write it as a quiet closure, not a cliffhanger."
    : "This is an ongoing event, not an ending.";

  return `You narrate a what-if life simulator. The person committed to: "${choice}".

At T+${tYears} years, write a single specific event that happens.
Category: ${catLabel}. Valence: ${valLabel}. Step changes: ${stepStr}.
${terminalHint}

Return ONLY valid JSON: {"title": "...", "micro": "..."}.
- title: 2–5 words, evocative, no clichés.
- micro: one or two sentences (max 220 chars), concrete and specific to "${choice}". Show, don't tell. No moralizing.
No prose outside the JSON. No markdown fences.`;
}

function parseEnrichmentJson(text) {
  // Some models wrap JSON in ```json ... ``` fences; strip defensively.
  const cleaned = String(text).trim()
    .replace(/^```(?:json)?\s*/i, "")
    .replace(/\s*```$/i, "")
    .trim();
  const parsed = JSON.parse(cleaned);
  if (!parsed.title || !parsed.micro) throw new Error("Missing fields");
  return { title: String(parsed.title).trim(), micro: String(parsed.micro).trim() };
}

// Gemini (BYOK)
async function enrichViaGemini({ node, choice, apiKey, signal }) {
  const res = await fetch(`${GEMINI_ENDPOINT}?key=${encodeURIComponent(apiKey)}`, {
    method: "POST",
    signal,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      contents: [{ parts: [{ text: buildNodePrompt({ node, choice }) }] }],
      generationConfig: {
        responseMimeType: "application/json",
        responseSchema: {
          type: "object",
          properties: { title: { type: "string" }, micro: { type: "string" } },
          required: ["title", "micro"],
        },
        temperature: 0.85,
      },
    }),
  });
  if (!res.ok) throw new Error(`Gemini ${res.status}`);
  const data = await res.json();
  const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
  if (!text) throw new Error("Empty response");
  return parseEnrichmentJson(text);
}

// Pollinations (free, anonymous — shared resource, no SLA)
async function enrichViaPollinations({ node, choice, signal }) {
  const res = await fetch(POLLINATIONS_ENDPOINT, {
    method: "POST",
    signal,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      messages: [{ role: "user", content: buildNodePrompt({ node, choice }) }],
      model: "openai",
      jsonMode: true,
      private: true,
      referrer: "lifefractal.com",
    }),
  });
  if (!res.ok) throw new Error(`Pollinations ${res.status}`);
  const text = await res.text();
  return parseEnrichmentJson(text);
}

// Concurrency-limited parallel runner — Gemini free tier is ~15 req/min.
async function runLimited(items, limit, fn) {
  const queue = [...items];
  const workers = Array.from({ length: Math.min(limit, queue.length) }).map(async () => {
    while (queue.length) {
      const item = queue.shift();
      try { await fn(item); } catch (e) { /* swallow — fallback to template */ }
    }
  });
  await Promise.all(workers);
}

// Enrichment cache key: tree is fully determined by (seed, choice), so cache per (seed, choice).
function enrichmentCacheKey(seed, choice) {
  return `lpfv::enrich::${seed}::${hashSeed(choice)}`;
}

// ---------------- Fractal Tree SVG ----------------
function FractalTree({ tree, focusId, setFocusId, hoverId, setHoverId, growthKey, accentCyan, accentPurple, glow }) {
  const svgRef = useRef(null);
  const [growth, setGrowth] = useState(0); // 0..1

  // Animate grow on mount / whenever growthKey changes
  useEffect(() => {
    setGrowth(0);
    let raf;
    const start = performance.now();
    const dur = 1400;
    const tick = (t) => {
      const p = Math.min(1, (t - start) / dur);
      // Ease out cubic
      setGrowth(1 - Math.pow(1 - p, 3));
      if (p < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [growthKey]);

  if (!tree) return null;

  const { nodes, edges } = tree;
  const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]));
  const maxDepth = Math.max(...nodes.map(n => n.depth));

  // Each edge/node has a reveal window based on its depth
  const windowFor = (depth) => {
    const segs = maxDepth + 1;
    const start = depth === 0 ? 0 : (depth - 1) / segs;
    const end   = (depth) / segs + 0.15;
    return [start, Math.min(1, end)];
  };
  const revealAmount = (depth) => {
    const [s, e] = windowFor(depth);
    if (growth <= s) return 0;
    if (growth >= e) return 1;
    const t = (growth - s) / (e - s);
    return 1 - Math.pow(1 - t, 2);
  };

  const valenceColor = (v) => v > 0 ? accentCyan : v < 0 ? accentPurple : "#8892a6";

  // Focus path = chain from focus up to root
  const focusChain = useMemo(() => {
    if (!focusId) return new Set();
    const s = new Set();
    let cur = nodeMap[focusId];
    while (cur) { s.add(cur.id); cur = cur.parentId ? nodeMap[cur.parentId] : null; }
    return s;
  }, [focusId, nodeMap]);

  return (
    <svg ref={svgRef} viewBox="0 0 1000 680" preserveAspectRatio="xMidYMid meet"
         style={{ width: "100%", height: "100%", display: "block" }}>
      <defs>
        <radialGradient id="bgGlow" cx="50%" cy="100%" r="80%">
          <stop offset="0%"  stopColor={accentCyan}   stopOpacity="0.08"/>
          <stop offset="60%" stopColor={accentPurple} stopOpacity="0.04"/>
          <stop offset="100%" stopColor="#000" stopOpacity="0"/>
        </radialGradient>
        <filter id="softGlow" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur stdDeviation="2.4" result="blur"/>
          <feMerge>
            <feMergeNode in="blur"/>
            <feMergeNode in="SourceGraphic"/>
          </feMerge>
        </filter>
      </defs>

      {/* ambient glow */}
      <rect x="0" y="0" width="1000" height="680" fill="url(#bgGlow)"/>

      {/* depth grid lines */}
      {Array.from({length: maxDepth + 1}).map((_, d) => {
        const y = 680 - (d / maxDepth) * (680 - 80) - 40;
        return <line key={d} x1="40" x2="960" y1={y} y2={y} stroke="rgba(255,255,255,0.04)" strokeDasharray="2 6"/>;
      })}

      {/* EDGES */}
      {edges.map((e) => {
        const a = nodeMap[e.from];
        const b = nodeMap[e.to];
        const r = revealAmount(b.depth);
        if (r <= 0) return null;
        const dx = b.x - a.x, dy = b.y - a.y;
        const endX = a.x + dx * r;
        const endY = a.y + dy * r;
        const col = valenceColor(b.valence);
        const onPath = focusId ? (focusChain.has(a.id) && focusChain.has(b.id)) : true;
        const dim = focusId && !onPath;
        return (
          <g key={e.from + "-" + e.to}>
            {/* soft halo */}
            {glow && !dim && (
              <line x1={a.x} y1={a.y} x2={endX} y2={endY}
                stroke={col} strokeOpacity={0.18} strokeWidth={onPath ? 4 : 2.5} strokeLinecap="round"/>
            )}
            <line x1={a.x} y1={a.y} x2={endX} y2={endY}
              stroke={col}
              strokeOpacity={dim ? 0.15 : (onPath ? 0.95 : 0.55)}
              strokeWidth={onPath ? 1.6 : 1}
              strokeLinecap="round"/>
          </g>
        );
      })}

      {/* NODES */}
      {nodes.map((n) => {
        const r = revealAmount(n.depth);
        if (r <= 0) return null;
        const col = n.depth === 0 ? "#fff" : valenceColor(n.valence);
        const size = n.depth === 0 ? 11 : (10 - n.depth * 1.1);
        const isFocus = focusId === n.id;
        const isHover = hoverId === n.id;
        const onPath = focusId ? focusChain.has(n.id) : true;
        const dim = focusId && !onPath;
        return (
          <g key={n.id}
             transform={`translate(${n.x} ${n.y})`}
             opacity={dim ? 0.25 : 1}
             style={{cursor:"pointer", transition:"opacity .25s"}}
             onMouseEnter={() => setHoverId(n.id)}
             onMouseLeave={() => setHoverId(null)}
             onClick={() => setFocusId(isFocus ? null : n.id)}>
            {/* outer hit target */}
            <circle r={18} fill="transparent"/>
            {/* halo */}
            {glow && (isHover || isFocus || n.depth === 0) && (
              <circle r={size + 8} fill={col} opacity={0.18} filter="url(#softGlow)"/>
            )}
            <circle r={size * r} fill={n.terminal ? col : "#0a0c14"} stroke={col}
                    strokeWidth={isFocus ? 2 : 1.2}
                    fillOpacity={n.terminal ? 0.9 : 1}
                    style={{transition:"r .25s"}}/>
            {/* terminal outer ring */}
            {n.terminal && (
              <circle r={(size + 3) * r} fill="none" stroke={col}
                      strokeOpacity={0.55} strokeWidth="0.6"
                      strokeDasharray="1 2"/>
            )}
            {n.depth === 0 && (
              <circle r={3} fill={col}/>
            )}
            {/* valence tick (skip on terminal — ring already signals it) */}
            {n.depth > 0 && n.valence !== 0 && !n.terminal && (
              <text y="1" textAnchor="middle" fill={col} fontSize="7"
                    fontFamily="JetBrains Mono, monospace" fontWeight="600">
                {n.valence > 0 ? "+" : "−"}
              </text>
            )}
          </g>
        );
      })}
    </svg>
  );
}

// ---------------- Stat bar ----------------
function StatBar({ label, value, accent }) {
  const pct = Math.max(0, Math.min(1, (value + 10) / 20));
  const sign = value > 0 ? "+" : "";
  return (
    <div className="stat-row">
      <div className="stat-head">
        <span className="stat-label">{label}</span>
        <span className="stat-value" style={{color: accent}}>{sign}{value}</span>
      </div>
      <div className="stat-track">
        <div className="stat-mid"/>
        <div className="stat-fill" style={{
          left: value >= 0 ? "50%" : `${pct * 100}%`,
          width: `${Math.abs(pct - 0.5) * 100}%`,
          background: accent,
          boxShadow: `0 0 12px ${accent}80`,
        }}/>
      </div>
    </div>
  );
}

// ---------------- Main App ----------------
function App() {
  // Tweaks (live)
  const [tweaks, setTweaks] = useState(TWEAKS);
  const [editMode, setEditMode] = useState(false);

  // Core state — URL is the source of truth for a share-able session.
  // localStorage is reserved for explicit Saved Paths only.
  const [choice, setChoice] = useState("");
  const [draftChoice, setDraftChoice] = useState("");
  const [seed, setSeed] = useState(0);
  const [tree, setTree] = useState(null);
  const [growthKey, setGrowthKey] = useState(0);
  const [hoverId, setHoverId] = useState(null);
  const [focusId, setFocusId] = useState(null);
  const [savedPaths, setSavedPaths] = useLocalStorage(STORAGE_KEY, []);
  const [toast, setToast] = useState(null);
  const [fateInput, setFateInput] = useState("");

  // BYOK + settings
  const [apiKey, setApiKey] = useLocalStorage("lpfv::gemini_key::v1", "");
  // narrativeSource: "off" | "pollinations" | "gemini"
  // Default to Pollinations so out-of-box users get AI narrative with zero setup.
  const [narrativeSource, setNarrativeSource] = useLocalStorage("lpfv::narrative_source::v1", "pollinations");
  const [settingsOpen, setSettingsOpen] = useState(false);
  const [enrichStatus, setEnrichStatus] = useState({ running: false, done: 0, total: 0 });
  const enrichAbortRef = useRef(null);

  // Clean up legacy implicit-session keys (one-time migration).
  useEffect(() => {
    try { LEGACY_KEYS.forEach(k => localStorage.removeItem(k)); } catch {}
  }, []);

  // Load from URL on mount. Bare URL = empty state; no implicit restore.
  useEffect(() => {
    const urlState = readUrlState();
    if (!urlState) return;
    const s = fateCodeToSeed(urlState.fateCode);
    setChoice(urlState.choice);
    setDraftChoice(urlState.choice);
    setSeed(s);
    setTree(buildFractal({ seed: s, choice: urlState.choice, depth: tweaks.treeDepth }));
    setGrowthKey(k => k + 1);
  }, []);

  // Keep the URL in sync with the current fate (shareable state).
  useEffect(() => {
    if (choice && seed) {
      writeUrlState({ fateCode: seedToFateCode(seed), choice });
    } else {
      clearUrlState();
    }
  }, [choice, seed]);

  // Edit mode wiring
  useEffect(() => {
    const handler = (e) => {
      if (e.data?.type === "__activate_edit_mode") setEditMode(true);
      if (e.data?.type === "__deactivate_edit_mode") setEditMode(false);
    };
    window.addEventListener("message", handler);
    window.parent.postMessage({type:"__edit_mode_available"}, "*");
    return () => window.removeEventListener("message", handler);
  }, []);

  const applyTweak = (k, v) => {
    setTweaks(t => {
      const next = {...t, [k]: v};
      window.parent.postMessage({type:"__edit_mode_set_keys", edits: {[k]: v}}, "*");
      return next;
    });
  };

  // Rebuild tree when depth changes and choice/seed present
  useEffect(() => {
    if (choice && seed) {
      setTree(buildFractal({ seed, choice, depth: tweaks.treeDepth }));
      setGrowthKey(k => k + 1);
    }
  }, [tweaks.treeDepth]);

  // ---------- Enrichment effect ----------
  // Re-runs when the tree identity or AI settings change. Applies cached text
  // immediately, then fires fresh enrichment for any uncached nodes.
  useEffect(() => {
    if (!tree || !choice || !seed) return;
    const cacheKey = enrichmentCacheKey(seed, choice);
    let cache = {};
    try { cache = JSON.parse(localStorage.getItem(cacheKey) || "{}"); } catch {}

    // Apply cached enrichments synchronously
    if (Object.keys(cache).length) {
      setTree(prev => {
        if (!prev) return prev;
        let changed = false;
        const nextNodes = prev.nodes.map(n => {
          if (cache[n.id] && n.title !== cache[n.id].title) {
            changed = true;
            return { ...n, title: cache[n.id].title, micro: cache[n.id].micro };
          }
          return n;
        });
        return changed ? { ...prev, nodes: nextNodes } : prev;
      });
    }

    // Decide which enricher to use.
    let enricher = null;
    if (narrativeSource === "gemini" && apiKey) {
      enricher = (node, signal) => enrichViaGemini({ node, choice, apiKey, signal });
    } else if (narrativeSource === "pollinations") {
      enricher = (node, signal) => enrichViaPollinations({ node, choice, signal });
    }
    if (!enricher) return;

    const uncached = tree.nodes.filter(n => n.depth > 0 && !cache[n.id]);
    if (uncached.length === 0) return;

    if (enrichAbortRef.current) enrichAbortRef.current.abort();
    const controller = new AbortController();
    enrichAbortRef.current = controller;

    setEnrichStatus({ running: true, done: 0, total: uncached.length });
    let done = 0;

    runLimited(uncached, ENRICH_CONCURRENCY, async (node) => {
      if (controller.signal.aborted) return;
      const enriched = await enricher(node, controller.signal);
      cache[node.id] = enriched;
      try { localStorage.setItem(cacheKey, JSON.stringify(cache)); } catch {}
      setTree(prev => {
        if (!prev) return prev;
        const nextNodes = prev.nodes.map(n =>
          n.id === node.id ? { ...n, title: enriched.title, micro: enriched.micro } : n
        );
        return { ...prev, nodes: nextNodes };
      });
      done++;
      setEnrichStatus({ running: true, done, total: uncached.length });
    }).finally(() => {
      if (!controller.signal.aborted) setEnrichStatus(s => ({ ...s, running: false }));
    });

    return () => controller.abort();
  }, [seed, choice, narrativeSource, apiKey, tweaks.treeDepth]);

  const runEvolve = (nextChoice, nextSeed) => {
    const c = nextChoice ?? draftChoice ?? choice;
    if (!c || !c.trim()) {
      flash("Enter a life choice first");
      return;
    }
    const s = nextSeed ?? Math.floor(Math.random() * 0xffffffff);
    setChoice(c.trim());
    setSeed(s);
    setTree(buildFractal({ seed: s, choice: c.trim(), depth: tweaks.treeDepth }));
    setFocusId(null);
    setHoverId(null);
    setGrowthKey(k => k + 1);
  };

  const reEvolve = () => {
    const s = Math.floor(Math.random() * 0xffffffff);
    setSeed(s);
    setTree(buildFractal({ seed: s, choice, depth: tweaks.treeDepth }));
    setFocusId(null);
    setGrowthKey(k => k + 1);
  };

  const replay = () => {
    if (tree) setGrowthKey(k => k + 1);
  };

  const clearAll = () => {
    if (enrichAbortRef.current) enrichAbortRef.current.abort();
    setChoice("");
    setDraftChoice("");
    setSeed(0);
    setTree(null);
    setFocusId(null);
    setHoverId(null);
    setFateInput("");
    setEnrichStatus({ running: false, done: 0, total: 0 });
    clearUrlState();
    flash("cleared");
  };

  const loadFateCode = () => {
    const code = fateInput.trim().toUpperCase();
    if (code.length < 4) { flash("Fate code too short"); return; }
    const s = fateCodeToSeed(code);
    if (!choice) { flash("Enter your life choice first"); return; }
    setSeed(s);
    setTree(buildFractal({ seed: s, choice, depth: tweaks.treeDepth }));
    setFateInput("");
    setFocusId(null);
    setGrowthKey(k => k + 1);
    flash(`Fate ${code} loaded`);
  };

  const copyFate = async () => {
    if (!seed) return;
    const code = seedToFateCode(seed);
    try {
      await navigator.clipboard.writeText(code);
      flash(`${code} copied`);
    } catch {
      flash(code);
    }
  };

  const copyShareLink = async () => {
    if (!seed || !choice) return;
    const url = new URL(window.location.origin + window.location.pathname);
    url.searchParams.set("f", seedToFateCode(seed));
    url.searchParams.set("c", choice);
    const link = url.toString();
    try {
      await navigator.clipboard.writeText(link);
      flash("share link copied");
    } catch {
      flash(link);
    }
  };

  const savePath = () => {
    if (!tree || !choice || !seed) return;
    const code = seedToFateCode(seed);
    const entry = {
      id: `${code}-${Date.now()}`,
      code, choice, seed, savedAt: Date.now(),
    };
    setSavedPaths(arr => [entry, ...arr.filter(e => !(e.code === code && e.choice === choice))].slice(0, 24));
    flash("Path saved");
  };

  const loadPath = (p) => {
    setChoice(p.choice);
    setDraftChoice(p.choice);
    setSeed(p.seed);
    setTree(buildFractal({ seed: p.seed, choice: p.choice, depth: tweaks.treeDepth }));
    setFocusId(null);
    setGrowthKey(k => k + 1);
  };

  const deletePath = (id) => {
    setSavedPaths(arr => arr.filter(e => e.id !== id));
  };

  const flash = (msg) => {
    setToast(msg);
    setTimeout(() => setToast(t => (t === msg ? null : t)), 1800);
  };

  // Current focus / hover node data
  const inspectId = hoverId || focusId;
  const inspectNode = tree && inspectId ? tree.nodes.find(n => n.id === inspectId) : null;
  // Summary = extreme leaf, normalized by depth so early terminals can compete
  // fairly with deep leaves (cumulative delta grows with depth).
  const leafSummary = useMemo(() => {
    if (!tree) return null;
    const childSet = new Set(tree.edges.map(e => e.from));
    const leaves = tree.nodes.filter(n => !childSet.has(n.id));
    if (leaves.length === 0) return null;
    // Score per simulated year (2.5y per depth). Max(depth,1) guards root-only trees.
    const score = (n) => {
      const years = Math.max(1, n.depth * 2.5);
      return (n.delta.wealth + n.delta.joy - n.delta.chaos + n.delta.legacy) / years;
    };
    const best = leaves.reduce((a,b) => score(b) > score(a) ? b : a, leaves[0]);
    const worst = leaves.reduce((a,b) => score(b) < score(a) ? b : a, leaves[0]);
    return { best, worst, count: leaves.length };
  }, [tree]);

  const fateCode = seed ? seedToFateCode(seed) : "";

  const styleVars = {
    "--cy": tweaks.accentCyan,
    "--pp": tweaks.accentPurple,
  };

  return (
    <div className="app" style={styleVars}>
      <BackgroundFX grid={tweaks.grid} />

      <header className="topbar">
        <div className="brand">
          <span className="brand-mark" aria-hidden>
            <svg viewBox="0 0 24 24" width="22" height="22">
              <circle cx="12" cy="18" r="2" fill="var(--cy)"/>
              <path d="M12 18 L6 10 M12 18 L18 10 M6 10 L3 4 M6 10 L9 4 M18 10 L15 4 M18 10 L21 4"
                    stroke="var(--cy)" strokeWidth="1" fill="none" opacity="0.8"/>
            </svg>
          </span>
          <div>
            <div className="brand-title">LIFE-PATH FRACTAL</div>
            <div className="brand-sub">what-if engine · v0.4</div>
          </div>
        </div>
        <div className="top-meta">
          <span className="pill"><span className="dot dot-cy"/>thriving</span>
          <span className="pill"><span className="dot dot-neutral"/>ambiguous</span>
          <span className="pill"><span className="dot dot-pp"/>struggle</span>
          {narrativeSource !== "off" && !(narrativeSource === "gemini" && !apiKey) && (
            <span className="pill" title={
              enrichStatus.running
                ? `Enriching via ${narrativeSource === "gemini" ? "Gemini" : "Pollinations"}`
                : `AI narrative: ${narrativeSource === "gemini" ? "Gemini" : "Pollinations"}`
            }>
              <Glyph.Spark width="10" height="10"/>
              {enrichStatus.running ? `AI ${enrichStatus.done}/${enrichStatus.total}` : "AI"}
            </span>
          )}
          <button className="pill pill-btn" onClick={() => setSettingsOpen(s => !s)} title="Settings">
            <Glyph.Gear width="11" height="11"/>
          </button>
        </div>
      </header>

      {/* Bento Grid */}
      <main className="bento">
        {/* INPUT cell */}
        <section className="cell cell-input">
          <div className="cell-head">
            <span className="cell-label">01 · Life Choice</span>
            <span className="cell-hint">the decision you're modeling</span>
          </div>
          <div className="input-wrap">
            <textarea
              className="life-input"
              placeholder="Moving to Tokyo..."
              value={draftChoice}
              onChange={e => setDraftChoice(e.target.value)}
              onKeyDown={e => {
                if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                  e.preventDefault();
                  runEvolve(draftChoice);
                }
              }}
              rows={2}
            />
            <div className="input-ring" aria-hidden/>
          </div>
          <div className="choice-examples">
            {["Moving to Tokyo", "Starting a bakery", "Leaving the PhD", "Having a kid at 39"].map(ex => (
              <button key={ex} className="chip" onClick={() => { setDraftChoice(ex); }}>{ex}</button>
            ))}
          </div>
          <div className="row">
            <button className="btn btn-primary" onClick={() => runEvolve(draftChoice)}>
              <Glyph.Play width="12" height="12"/> Evolve
              <span className="kbd">⌘↵</span>
            </button>
            {tree && <button className="btn btn-ghost" onClick={reEvolve} title="New seed, same choice">
              <Glyph.Dice width="14" height="14"/> Re-roll
            </button>}
            {tree && <button className="btn btn-ghost" onClick={replay} title="Replay grow animation">
              Replay
            </button>}
            {(tree || draftChoice || choice) && (
              <button className="btn btn-ghost" onClick={clearAll} title="Clear everything">
                Clear
              </button>
            )}
          </div>
        </section>

        {/* FATE CODE cell */}
        <section className="cell cell-fate">
          <div className="cell-head">
            <span className="cell-label">02 · Fate Code</span>
            <span className="cell-hint">copy link to share this fate</span>
          </div>
          <div className="fate-display">
            <div className="fate-code">{fateCode || "— — — — — —"}</div>
            <button className="icon-btn" disabled={!fateCode} onClick={copyFate} title="Copy fate code">
              <Glyph.Copy width="14" height="14"/>
            </button>
            <button className="icon-btn" disabled={!fateCode} onClick={copyShareLink} title="Copy share link">
              <Glyph.Link width="14" height="14"/>
            </button>
          </div>
          <div className="fate-seed mono">seed {seed ? `0x${seed.toString(16).padStart(8,"0")}` : "—"}</div>
          <div className="divider"/>
          <div className="cell-label tiny">LOAD CODE</div>
          <div className="fate-input-row">
            <input
              className="mono fate-input"
              placeholder="ABC234"
              maxLength={6}
              value={fateInput}
              onChange={e => setFateInput(e.target.value.toUpperCase())}
              onKeyDown={e => e.key === "Enter" && loadFateCode()}
            />
            <button className="btn btn-ghost btn-sm" onClick={loadFateCode}>
              <Glyph.Arrow width="12" height="12"/>
            </button>
          </div>
        </section>

        {/* TREE cell - main canvas */}
        <section className="cell cell-tree">
          <div className="cell-head">
            <span className="cell-label">03 · Fractal Projection</span>
            <span className="cell-hint">
              {tree
                ? `${tree.nodes.length} nodes · depth ${Math.max(...tree.nodes.map(n=>n.depth))}`
                : "awaiting evolve"}
            </span>
          </div>
          <div className="tree-canvas">
            {!tree && <EmptyTree />}
            {tree && <FractalTree
              tree={tree}
              focusId={focusId} setFocusId={setFocusId}
              hoverId={hoverId} setHoverId={setHoverId}
              growthKey={growthKey}
              accentCyan={tweaks.accentCyan}
              accentPurple={tweaks.accentPurple}
              glow={tweaks.glow}
            />}
            {tree && (
              <div className="canvas-overlay">
                <div className="legend-chip"><span className="mono">y↑</span> time · 10 yrs</div>
                <div className="legend-chip"><span className="mono">x</span> divergence</div>
                {focusId && <button className="legend-chip clickable" onClick={()=>setFocusId(null)}>
                  <Glyph.X width="10" height="10"/> unfocus
                </button>}
              </div>
            )}
          </div>
        </section>

        {/* STATS cell */}
        <section className="cell cell-stats">
          <div className="cell-head">
            <span className="cell-label">04 · Trajectory</span>
            <span className="cell-hint">
              {focusId ? "focus path" : hoverId ? "hover" : "(pick a node)"}
            </span>
          </div>
          {inspectNode ? (
            <div className="stats">
              <StatBar label="Wealth" value={inspectNode.delta.wealth} accent={tweaks.accentCyan}/>
              <StatBar label="Joy"    value={inspectNode.delta.joy}    accent="#6DFFB5"/>
              <StatBar label="Chaos"  value={inspectNode.delta.chaos}  accent={tweaks.accentPurple}/>
              <StatBar label="Legacy" value={inspectNode.delta.legacy} accent="#FFB84D"/>
            </div>
          ) : (
            <div className="stats-empty">
              <div className="mono stats-hint">Δ WEALTH · JOY · CHAOS · LEGACY</div>
              <div className="stats-sub">hover any node to read its vector</div>
            </div>
          )}
        </section>

        {/* INSPECTOR cell */}
        <section className="cell cell-inspector">
          <div className="cell-head">
            <span className="cell-label">05 · Event</span>
            <span className="cell-hint">micro-story</span>
          </div>
          {inspectNode ? (
            <div className="inspector">
              <div className="insp-meta">
                <span className="insp-cat" style={{
                  color: inspectNode.depth === 0 ? "#fff" : (inspectNode.valence > 0 ? tweaks.accentCyan : inspectNode.valence < 0 ? tweaks.accentPurple : "#8892a6"),
                  borderColor: inspectNode.depth === 0 ? "rgba(255,255,255,.4)" : (inspectNode.valence > 0 ? tweaks.accentCyan+"66" : inspectNode.valence < 0 ? tweaks.accentPurple+"66" : "#8892a655"),
                }}>
                  {inspectNode.depth === 0 ? "ORIGIN" : (CatMeta[inspectNode.category]?.label || "").toUpperCase()}
                </span>
                <span className="insp-depth mono">T+{inspectNode.depth * 2.5}y</span>
              </div>
              <div className="insp-title">{inspectNode.title}</div>
              <div className="insp-micro">{inspectNode.micro}</div>
              {inspectNode.step && (
                <div className="insp-step mono">
                  this event: {Object.entries(inspectNode.step).map(([k,v]) => (
                    <span key={k} className={clsx("step-tag", v > 0 && "pos", v < 0 && "neg")}>
                      {k[0].toUpperCase()}{v >= 0 ? "+" : ""}{v}
                    </span>
                  ))}
                </div>
              )}
            </div>
          ) : (
            <div className="inspector-empty">
              <div className="mono">no event selected</div>
              <div className="muted-sm">hover a node on the fractal →</div>
            </div>
          )}
        </section>

        {/* SUMMARY cell */}
        <section className="cell cell-summary">
          <div className="cell-head">
            <span className="cell-label">06 · Tail Outcomes</span>
            <span className="cell-hint">10-yr extremes</span>
          </div>
          {leafSummary ? (
            <div className="summary">
              <div className="sum-row sum-best">
                <div className="sum-head">
                  <span className="sum-label">BEST</span>
                  <span className="sum-tag" style={{color: tweaks.accentCyan}}>P{leafSummary.count > 0 ? "1/"+leafSummary.count : ""}</span>
                </div>
                <div className="sum-title">{leafSummary.best.title}</div>
                <button className="sum-jump" onClick={() => setFocusId(leafSummary.best.id)}>trace path →</button>
              </div>
              <div className="sum-row sum-worst">
                <div className="sum-head">
                  <span className="sum-label">WORST</span>
                  <span className="sum-tag" style={{color: tweaks.accentPurple}}>P{leafSummary.count > 0 ? "1/"+leafSummary.count : ""}</span>
                </div>
                <div className="sum-title">{leafSummary.worst.title}</div>
                <button className="sum-jump" onClick={() => setFocusId(leafSummary.worst.id)}>trace path →</button>
              </div>
            </div>
          ) : (
            <div className="summary-empty">
              <div className="mono muted-sm">awaiting projection</div>
            </div>
          )}
        </section>

        {/* SAVED cell */}
        <section className="cell cell-saved">
          <div className="cell-head">
            <span className="cell-label">07 · Saved Paths</span>
            <button className="cell-action" onClick={savePath} disabled={!tree}>
              <Glyph.Plus width="12" height="12"/> save current
            </button>
          </div>
          {savedPaths.length === 0 ? (
            <div className="saved-empty">
              <div className="mono muted-sm">no paths saved</div>
              <div className="muted-sm">evolve → save → return anytime</div>
            </div>
          ) : (
            <div className="saved-list">
              {savedPaths.map(p => (
                <div key={p.id} className="saved-item" onClick={() => loadPath(p)}>
                  <div className="saved-code mono">{p.code}</div>
                  <div className="saved-choice">{p.choice}</div>
                  <button className="saved-del" onClick={(e) => { e.stopPropagation(); deletePath(p.id); }}>
                    <Glyph.X width="10" height="10"/>
                  </button>
                </div>
              ))}
            </div>
          )}
        </section>
      </main>

      {/* toast */}
      {toast && <div className="toast">{toast}</div>}

      {/* Settings panel */}
      {settingsOpen && (
        <SettingsPanel
          apiKey={apiKey}
          setApiKey={setApiKey}
          narrativeSource={narrativeSource}
          setNarrativeSource={setNarrativeSource}
          onClose={() => setSettingsOpen(false)}
          onClearCache={() => {
            try {
              const prefix = "lpfv::enrich::";
              const keys = [];
              for (let i = 0; i < localStorage.length; i++) {
                const k = localStorage.key(i);
                if (k && k.startsWith(prefix)) keys.push(k);
              }
              keys.forEach(k => localStorage.removeItem(k));
              flash(`cleared ${keys.length} cached ${keys.length === 1 ? "tree" : "trees"}`);
            } catch { flash("clear failed"); }
          }}
        />
      )}

      {/* tweaks panel */}
      {editMode && (
        <div className="tweaks-panel">
          <div className="tweaks-head">
            <span className="cell-label">Tweaks</span>
          </div>
          <div className="tweak-row">
            <label>Cyan</label>
            <input type="color" value={tweaks.accentCyan} onChange={e => applyTweak("accentCyan", e.target.value)}/>
          </div>
          <div className="tweak-row">
            <label>Purple</label>
            <input type="color" value={tweaks.accentPurple} onChange={e => applyTweak("accentPurple", e.target.value)}/>
          </div>
          <div className="tweak-row">
            <label>Depth</label>
            <input type="range" min="2" max="5" value={tweaks.treeDepth}
                   onChange={e => applyTweak("treeDepth", parseInt(e.target.value))}/>
            <span className="mono">{tweaks.treeDepth}</span>
          </div>
          <div className="tweak-row">
            <label>Glow</label>
            <input type="checkbox" checked={tweaks.glow} onChange={e => applyTweak("glow", e.target.checked)}/>
          </div>
          <div className="tweak-row">
            <label>Grid</label>
            <input type="checkbox" checked={tweaks.grid} onChange={e => applyTweak("grid", e.target.checked)}/>
          </div>
        </div>
      )}
    </div>
  );
}

function EmptyTree() {
  return (
    <div className="empty-tree">
      <div className="empty-glyph">
        <svg viewBox="0 0 120 120" width="120" height="120">
          <circle cx="60" cy="100" r="3" fill="var(--cy)" opacity="0.8"/>
          <path d="M60 100 L30 60 M60 100 L90 60" stroke="var(--cy)" strokeWidth="0.8" opacity="0.5"/>
          <path d="M30 60 L15 30 M30 60 L45 30 M90 60 L75 30 M90 60 L105 30"
                stroke="var(--cy)" strokeWidth="0.5" opacity="0.25" strokeDasharray="2 3"/>
          <circle cx="30" cy="60" r="1.5" fill="var(--cy)" opacity="0.4"/>
          <circle cx="90" cy="60" r="1.5" fill="var(--cy)" opacity="0.4"/>
        </svg>
      </div>
      <div className="empty-title">awaiting your choice</div>
      <div className="empty-sub">type a decision in 01 · then Evolve</div>
    </div>
  );
}

function SettingsPanel({ apiKey, setApiKey, narrativeSource, setNarrativeSource, onClose, onClearCache }) {
  const [draft, setDraft] = useState(apiKey || "");
  const [reveal, setReveal] = useState(false);
  const saveKey = () => { setApiKey(draft.trim()); };
  const removeKey = () => { setDraft(""); setApiKey(""); };

  const sources = [
    {
      id: "pollinations",
      label: "Free — Pollinations.ai",
      desc: "Anonymous, no signup, no keys. Runs on a free community service (text.pollinations.ai) — may be slower or briefly unavailable. Your life-choice text is sent to a third party.",
    },
    {
      id: "gemini",
      label: "Gemini — bring your own key",
      desc: "Higher quality and faster. Your key is stored only in this browser's localStorage and sent directly to Google — never to us.",
    },
    {
      id: "off",
      label: "Off — templates only",
      desc: "No network calls. Events use the built-in procedural templates.",
    },
  ];

  return (
    <div className="settings-overlay" onClick={onClose}>
      <div className="settings-panel" onClick={e => e.stopPropagation()}>
        <div className="settings-head">
          <span className="cell-label">Settings</span>
          <button className="icon-btn" onClick={onClose} title="Close">
            <Glyph.X width="12" height="12"/>
          </button>
        </div>

        <div className="settings-section">
          <div className="cell-label tiny">AI NARRATIVE</div>
          <p className="settings-note">
            Tree structure stays deterministic either way — AI only rewrites the
            event titles and micro-stories to fit your specific choice.
          </p>

          <div className="source-list">
            {sources.map(s => (
              <label key={s.id} className={`source-row${narrativeSource === s.id ? " active" : ""}`}>
                <input
                  type="radio"
                  name="narrative-source"
                  value={s.id}
                  checked={narrativeSource === s.id}
                  onChange={() => setNarrativeSource(s.id)}
                />
                <div className="source-body">
                  <div className="source-label">{s.label}</div>
                  <div className="source-desc">{s.desc}</div>
                </div>
              </label>
            ))}
          </div>
        </div>

        {narrativeSource === "gemini" && (
          <div className="settings-section">
            <div className="cell-label tiny">GEMINI API KEY</div>
            <p className="settings-note">
              Get a free key at{" "}
              <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer">
                aistudio.google.com/apikey
              </a>.
            </p>
            <div className="settings-key-row">
              <input
                className="mono fate-input"
                type={reveal ? "text" : "password"}
                placeholder="AIza…"
                value={draft}
                onChange={e => setDraft(e.target.value)}
                spellCheck={false}
              />
              <button className="btn btn-sm btn-ghost" onClick={() => setReveal(r => !r)}>
                {reveal ? "hide" : "show"}
              </button>
            </div>
            <div className="row settings-row">
              <button className="btn btn-sm btn-primary" onClick={saveKey} disabled={!draft.trim()}>
                save key
              </button>
              {apiKey && (
                <button className="btn btn-sm btn-ghost" onClick={removeKey}>
                  remove
                </button>
              )}
            </div>
          </div>
        )}

        <div className="settings-section">
          <div className="cell-label tiny">CACHE</div>
          <p className="settings-note">
            Enriched narratives are cached per Fate Code so revisits are free.
            Tree structure is fully deterministic — clearing the cache only forces
            fresh narrative text.
          </p>
          <button className="btn btn-sm btn-ghost" onClick={onClearCache}>
            clear all narrative caches
          </button>
        </div>
      </div>
    </div>
  );
}

function BackgroundFX({ grid }) {
  return (
    <div className="bgfx" aria-hidden>
      {grid && <div className="bg-grid"/>}
      <div className="bg-glow bg-glow-cy"/>
      <div className="bg-glow bg-glow-pp"/>
      <div className="bg-noise"/>
    </div>
  );
}

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