import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import type {
  QuestArc,
  QuestNode,
  ClaimedRewardResponse,
} from "../../types/quest";
import { useQuestStore } from "../../stores/useQuestStore";
import { useAuthStore } from "../../stores/authStore";
import { api } from "../../utils/api";
import { QuestNodeModal } from "../../components/QuestNodeModal";
import { ChestOpenModal } from "../../components/ChestOpenModal";
import { InfoHeader } from "../../components/InfoHeader";
import { Canvas, useThree, useFrame } from "@react-three/fiber";
import { Island3D } from "../../components/Island3D";
import { OrbitControls, Stars } from "@react-three/drei";
import * as THREE from "three";

// ─── Map geometry ─────────────────────────────────────────────────────────────
const VW = 420;
const TOP_PAD = 80;
const ROW_H = 520; // vertical step per island (increased for more separation)

// ─── Seeded RNG ───────────────────────────────────────────────────────────────
const mkRng = (seed: number) => {
  let s = seed >>> 0;
  return () => {
    s += 0x6d2b79f5;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
};

const strToSeed = (str: string) => {
  let h = 5381;
  for (let i = 0; i < str.length; i++)
    h = (Math.imul(h, 33) ^ str.charCodeAt(i)) >>> 0;
  return h;
};

// ─── Random island positions ──────────────────────────────────────────────────
// Generates organic-feeling positions that zigzag downward with random offsets.
// Uses a seeded RNG per arc so positions are deterministic per arc but varied.
const MIN_DIST = 600; // minimum px distance between any two islands (increased for more separation)

const generateIslandPositions = (
  nodeCount: number,
  arcId: string,
): { x: number; y: number }[] => {
  const rng = mkRng(strToSeed(arcId + "_positions"));

  // X zones: left, centre-left, centre, centre-right, right
  const ZONES = [
    [0.12, 0.32],
    [0.28, 0.48],
    [0.38, 0.62],
    [0.52, 0.72],
    [0.68, 0.88],
  ];

  const positions: { x: number; y: number }[] = [];

  for (let i = 0; i < nodeCount; i++) {
    const baseY = TOP_PAD + i * ROW_H;
    // vertical jitter ±55px so islands feel scattered, not gridded
    const yJitter = (rng() - 0.5) * 110;

    let best: { x: number; y: number } | null = null;
    let attempts = 0;

    while (!best || attempts < 40) {
      // Pick a random zone, biased to alternate left/right to keep paths readable
      const zoneIdx =
        i % 2 === 0
          ? Math.floor(rng() * 3) // odd rows: left half
          : 2 + Math.floor(rng() * 3); // even rows: right half (clamped below)
      const zone = ZONES[Math.min(zoneIdx, ZONES.length - 1)];
      const candidate = {
        x: Math.round((zone[0] + rng() * (zone[1] - zone[0])) * VW),
        y: Math.round(baseY + yJitter * (attempts < 20 ? 1 : 0.4)),
      };

      // Enforce minimum spacing
      const tooClose = positions.some((p) => {
        const dx = p.x - candidate.x;
        const dy = p.y - candidate.y;
        return Math.sqrt(dx * dx + dy * dy) < MIN_DIST;
      });

      if (!tooClose || attempts >= 39) {
        best = candidate;
        break;
      }
      attempts++;
    }

    positions.push(best!);
  }

  return positions;
};

// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
  @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
  @import url('https://fonts.googleapis.com/css2?family=Sorts+Mill+Goudy:ital@0;1&display=swap');

  * { box-sizing: border-box; }

  .qm-screen {
    height: 100vh;
    font-family: 'Nunito', sans-serif;
    position: relative;
    display: flex;
    flex-direction: column;
    background: #060e1f;
  }

  .qm-header {
    position: relative; z-index: 30; flex-shrink: 0;
    background: rgba(4,10,24,0.94);
    backdrop-filter: blur(20px);
    border-bottom: 1px solid rgba(251,191,36,0.12);
    padding: 1.25rem 1.25rem 0;
  }

  .qm-arc-tabs {
    display: flex; gap:0; overflow-x:auto; scrollbar-width:none;
    border-top: 1px solid rgba(255,255,255,0.06);
  }
  .qm-arc-tabs::-webkit-scrollbar { display:none; }
  .qm-arc-tab {
    flex-shrink:0; display:flex; align-items:center; gap:0.4rem;
    padding: 0.6rem 1rem; border:none; background:transparent; cursor:pointer;
    font-family:'Nunito',sans-serif; font-weight:800; font-size:0.78rem;
    color: rgba(255,255,255,0.3); border-bottom: 3px solid transparent;
    transition: all 0.2s ease; white-space:nowrap;
  }
  .qm-arc-tab:hover { color:rgba(255,255,255,0.6); }
  .qm-arc-tab.active { color:var(--arc-accent); border-bottom-color:var(--arc-accent); }
  .qm-tab-dot {
    width:7px; height:7px; border-radius:50%;
    background:#ef4444; box-shadow:0 0 8px #ef4444;
    animation: qmDotBlink 1.4s ease-in-out infinite;
  }
  @keyframes qmDotBlink { 0%,100%{ opacity:1; } 50%{ opacity:0.4; } }

  .qm-sea-scroll {
    flex:1; overflow-y:auto; overflow-x:visible;
    position:relative; scrollbar-width:none; -webkit-overflow-scrolling:touch;
  }
  .qm-sea-scroll::-webkit-scrollbar { display:none; }

  .qm-sea {
    position: relative;
    min-height: 100%;
    padding: 1.25rem 1.25rem 8rem;
    background:
      radial-gradient(ellipse 80% 40% at 20% 15%, rgba(6,80,160,0.45) 0%, transparent 60%),
      radial-gradient(ellipse 60% 50% at 80% 60%, rgba(4,50,110,0.35) 0%, transparent 55%),
      radial-gradient(ellipse 70% 40% at 50% 90%, rgba(8,120,180,0.2) 0%, transparent 50%),
      linear-gradient(180deg, #071530 0%, #04101e 40%, #020a14 100%);
  }

  .qm-sea-shimmer {
    position:absolute; inset:0; pointer-events:none; z-index:0;
    background:
      repeating-linear-gradient(105deg, transparent 0%, transparent 55%, rgba(56,189,248,0.018) 56%, transparent 57%),
      repeating-linear-gradient(75deg,  transparent 0%, transparent 70%, rgba(56,189,248,0.012) 71%, transparent 72%);
    background-size: 400% 400%, 300% 300%;
    animation: qmSeaMove 14s ease-in-out infinite alternate;
  }
  @keyframes qmSeaMove { 0%{background-position:0% 0%,100% 0%;} 100%{background-position:100% 100%,0% 100%;} }
  .qm-bubble {
    position:absolute; border-radius:50%; pointer-events:none; z-index:1;
    background: rgba(255,255,255,0.045);
    animation: qmBobble var(--bdur) ease-in-out infinite;
    animation-delay: var(--bdelay);
  }
  @keyframes qmBobble { 0%,100%{transform:translateY(0) scale(1);opacity:0.5;} 50%{transform:translateY(-10px) scale(1.1);opacity:0.9;} }

  .qm-arc-banner {
    position:relative; z-index:5;
    border-radius:22px; padding: 1.1rem 1.25rem; margin-bottom: 1.5rem;
    border: 1px solid rgba(255,255,255,0.1);
    box-shadow: 0 12px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08);
    overflow:hidden;
  }
  .qm-arc-banner::before {
    content:''; position:absolute; inset:0;
    background: repeating-linear-gradient(45deg, transparent, transparent 15px, rgba(255,255,255,0.015) 15px, rgba(255,255,255,0.015) 16px);
  }
  .qm-arc-banner-bg-emoji {
    position:absolute; right:0.5rem; top:50%; transform:translateY(-50%);
    font-size:5rem; opacity:0.09; filter:blur(2px); pointer-events:none; z-index:0;
  }
  .qm-arc-banner-name {
    font-family:'Sorts Mill Goudy',serif; font-size:1.25rem; font-weight:900; color:white;
    letter-spacing:0.06em; text-shadow:0 2px 16px rgba(0,0,0,0.6); position:relative; z-index:1;
  }
  .qm-arc-banner-sub {
    font-family:'Nunito Sans',sans-serif; font-size:0.7rem; font-weight:600;
    color:rgba(255,255,255,0.5); margin-top:0.2rem; position:relative; z-index:1;
  }
  .qm-arc-banner-prog {
    display:flex; align-items:center; gap:0.65rem; margin-top:0.8rem; position:relative; z-index:1;
  }
  .qm-arc-banner-track { flex:1; height:5px; border-radius:100px; background:rgba(255,255,255,0.12); overflow:hidden; }
  .qm-arc-banner-fill {
    height:100%; border-radius:100px; background:rgba(255,255,255,0.8);
    box-shadow:0 0 8px rgba(255,255,255,0.5); transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1);
  }
  .qm-arc-banner-count {
    font-family:'Nunito',sans-serif; font-size:0.68rem; font-weight:900;
    color:rgba(255,255,255,0.65); white-space:nowrap;
  }

  .qm-map-wrap {
    width: ${VW}px;
    max-width: 100%;
    margin: 0 auto;
    position: relative;
    z-index: 5;
    overflow: visible;
  }

  .qm-map-svg {
    display: block;
    width: ${VW}px;
    max-width: 100%;
    overflow: visible;
    position: relative;
    z-index: 5;
  }

  .qm-info-card {
    background: rgba(255,255,255,0.055); border:1px solid rgba(255,255,255,0.09);
    border-radius:16px; padding:0.7rem 0.85rem;
    backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
    transition: background 0.15s ease, border-color 0.15s ease; overflow:hidden;
  }
  .qm-info-card.is-claimable { border-color:rgba(251,191,36,0.45); background:rgba(251,191,36,0.07); }
  .qm-info-card.is-active    { border-color:rgba(255,255,255,0.14); }
  .qm-info-card.is-locked    { opacity:0.42; }
  .qm-info-row1 { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.4rem; gap:0.4rem; }
  .qm-info-title { font-family:'Sorts Mill Goudy',serif; font-size:0.78rem; font-weight:700; color:white; line-height:1.25; }
  .qm-xp-badge { display:flex; align-items:center; gap:0.18rem; padding:0.18rem 0.45rem; background:rgba(251,191,36,0.13); border:1px solid rgba(251,191,36,0.3); border-radius:100px; flex-shrink:0; }
  .qm-xp-badge-val { font-size:0.62rem; font-weight:900; color:#fbbf24; }
  .qm-prog-track { height:5px; background:rgba(255,255,255,0.08); border-radius:100px; overflow:hidden; margin-bottom:0.22rem; }
  .qm-prog-fill { height:100%; border-radius:100px; background:linear-gradient(90deg, var(--arc-accent), color-mix(in srgb,var(--arc-accent) 65%,white)); box-shadow:0 0 8px color-mix(in srgb,var(--arc-accent) 55%,transparent); transition:width 0.7s cubic-bezier(0.34,1.56,0.64,1); }
  .qm-prog-label { font-family:'Nunito Sans',sans-serif; font-size:0.55rem; font-weight:700; color:rgba(255,255,255,0.38); }
  .qm-claim-btn { width:100%; margin-top:0.5rem; padding:0.48rem; background:linear-gradient(135deg,#fbbf24,#f59e0b); border:none; border-radius:10px; cursor:pointer; font-family:'Sorts Mill Goudy',serif; font-size:0.72rem; font-weight:700; color:#1a0e00; letter-spacing:0.04em; box-shadow:0 3px 0 #d97706, 0 5px 14px rgba(251,191,36,0.3); transition:all 0.12s ease; }
  .qm-claim-btn:hover  { transform:translateY(-1px); box-shadow:0 5px 0 #d97706; }
  .qm-claim-btn:active { transform:translateY(1px); box-shadow:0 1px 0 #d97706; }

  .qm-fab {
    position:fixed; bottom:calc(1.25rem + 80px + env(safe-area-inset-bottom)); right:1.25rem; z-index:25;
    width:52px; height:52px; border-radius:50%;
    background:linear-gradient(135deg,#1a0e45,#3730a3); border:2px solid rgba(251,191,36,0.45);
    display:flex; align-items:center; justify-content:center; font-size:1.5rem; cursor:pointer;
    box-shadow:0 6px 24px rgba(0,0,0,0.55);
    animation:qmFabFloat 4s ease-in-out infinite;
    transition:transform 0.2s cubic-bezier(0.34,1.56,0.64,1);
  }
  .qm-fab:hover { transform:scale(1.1) rotate(8deg); }
  @keyframes qmFabFloat { 0%,100%{transform:translateY(0) rotate(-4deg);} 50%{transform:translateY(-7px) rotate(4deg);} }

  @media (min-width: 1024px) {
    .qm-header     { display: none; }
    .qm-fab        { display: none; }
    .qm-mobile-sea { display: none; }

    .qm-screen {
      flex-direction: row;
      overflow:visible;
      padding-left: 270px;
    }

    .qm-right-sea-scroll .qm-map-wrap {
      width: 420px !important;
      max-width: none !important;
      margin: 0 auto !important;
      min-height: 600px;
      overflow: visible !important;
    }

    .qm-right-sea-scroll .qm-map-svg {
      width: 420px !important;
      height: auto !important;
      max-width: none !important;
    }

    .qm-left-panel {
      width: 512px;
      flex-shrink: 0;
      height: 100vh;
      overflow-y: auto;
      overflow-x: hidden;
      scrollbar-width: thin;
      scrollbar-color: rgba(251,191,36,0.12) transparent;
      background:
        radial-gradient(ellipse 120% 40% at 50% 0%, rgba(6,60,130,0.25) 0%, transparent 55%),
        linear-gradient(180deg, #07101f 0%, #040c1a 100%);
      border-right: 1px solid rgba(251,191,36,0.07);
      padding: 1.75rem 1.4rem 3rem;
      display: flex;
      flex-direction: column;
      gap: 1.2rem;
    }
    .qm-left-panel::-webkit-scrollbar       { width: 3px; }
    .qm-left-panel::-webkit-scrollbar-thumb { background: rgba(251,191,36,0.12); border-radius: 2px; }

    .qm-right-panel {
      flex: 1;
      min-width: 0;
      height: 100vh;
      display: flex;
      flex-direction: column;
      overflow: visible;
      position: relative;
    }

    .qm-desktop-arc-tabs {
      position: absolute;
      top: 0; left: 0; right: 0;
      z-index: 20;
      display: flex;
      flex-shrink: 0;
      overflow-x: auto;
      scrollbar-width: none;
      background: linear-gradient(180deg, rgba(4,12,28,0.82) 0%, rgba(4,12,28,0.55) 70%, transparent 100%);
      backdrop-filter: blur(12px) saturate(1.4);
      -webkit-backdrop-filter: blur(12px) saturate(1.4);
      border-bottom: none;
      padding: 0.6rem 1.5rem 1.2rem;
      pointer-events: none;
    }
    .qm-desktop-arc-tabs::-webkit-scrollbar { display: none; }

    .qm-desktop-arc-tab {
      pointer-events: all;
      flex-shrink: 0;
      display: flex; align-items: center; gap: 0.5rem;
      padding: 0.65rem 1.25rem 0.55rem;
      background: rgba(255,255,255,0.04);
      border-radius: 100px;
      margin-right: 0.4rem;
      cursor: pointer;
      font-family: 'Nunito', sans-serif; font-weight: 800; font-size: 0.92rem;
      color: rgba(255,255,255,0.38);
      border: 1px solid rgba(255,255,255,0.07);
      transition: all 0.22s ease; white-space: nowrap;
    }
    .qm-desktop-arc-tab:hover {
      color: rgba(255,255,255,0.75);
      background: rgba(255,255,255,0.08);
      border-color: rgba(255,255,255,0.12);
    }
    .qm-desktop-arc-tab.active {
      color: var(--arc-accent);
      background: color-mix(in srgb, var(--arc-accent) 12%, transparent);
      border-color: color-mix(in srgb, var(--arc-accent) 40%, transparent);
      box-shadow: 0 0 16px color-mix(in srgb, var(--arc-accent) 20%, transparent), inset 0 1px 0 rgba(255,255,255,0.08);
    }
    .qm-desktop-tab-dot {
      width: 7px; height: 7px; border-radius: 50%;
      background: #ef4444; box-shadow: 0 0 8px #ef4444;
      animation: qmDotBlink 1.4s ease-in-out infinite;
    }

    .qm-right-sea-scroll {
      flex: 1;
      overflow-y: auto;
      overflow-x: visible;
      scrollbar-width: thin;
      scrollbar-color: rgba(251,191,36,0.12) transparent;
    }
    .qm-right-sea-scroll::-webkit-scrollbar       { width: 4px; }
    .qm-right-sea-scroll::-webkit-scrollbar-thumb { background: rgba(251,191,36,0.12); border-radius: 2px; }

    .qm-right-sea-scroll .qm-sea { padding: 5.5rem 2rem 5rem; }

    .qm-arc-banner-name { font-size: 1.6rem; }
    .qm-arc-banner-sub  { font-size: 0.88rem; }
    .qm-arc-banner-count { font-size: 0.82rem; }
    .qm-info-title      { font-size: 0.92rem; }
    .qm-xp-badge-val    { font-size: 0.75rem; }
    .qm-prog-label      { font-size: 0.68rem; }
    .qm-claim-btn       { font-size: 0.85rem; }
  }

  @media (max-width: 1023px) {
    .qm-left-panel       { display: none !important; }
    .qm-right-panel      { display: none !important; }
    .qm-desktop-arc-tabs { display: none !important; }
    .qm-right-sea-scroll { display: none !important; }
  }

  .qmlp-page-title { font-family:'Sorts Mill Goudy',serif; font-size:1.55rem; font-weight:900; color:#fbbf24; text-shadow:0 0 28px rgba(251,191,36,0.35),0 0 60px rgba(251,191,36,0.08); letter-spacing:0.04em; display:flex; align-items:center; gap:0.5rem; line-height:1; }
  .qmlp-page-sub   { font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:700; color:rgba(255,255,255,0.25); letter-spacing:0.14em; text-transform:uppercase; margin-top:0.22rem; }
  .qmlp-rank-card  { border-radius:20px; background:linear-gradient(160deg,#0d1b38 0%,#070f20 100%); border:1px solid rgba(255,255,255,0.07); box-shadow:0 8px 32px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.05); overflow:hidden; padding:1.2rem; position:relative; }
  .qmlp-rank-card::before { content:''; position:absolute; inset:0; pointer-events:none; background:repeating-linear-gradient(105deg,transparent 55%,rgba(56,189,248,0.014) 56%,transparent 57%); background-size:300% 300%; animation:qmSeaMove 14s ease-in-out infinite alternate; }
  .qmlp-rank-card::after  { content:''; position:absolute; top:-30px; right:-20px; width:140px; height:140px; border-radius:50%; background:radial-gradient(circle,rgba(251,191,36,0.07),transparent 70%); pointer-events:none; }
  .qmlp-rank-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.4rem; position:relative; z-index:1; }
  .qmlp-rank-eyebrow { font-family:'Cinzel',serif; font-size:0.56rem; font-weight:700; letter-spacing:0.2em; text-transform:uppercase; color:rgba(251,191,36,0.45); }
  .qmlp-xp-pill { font-family:'Nunito',sans-serif; font-size:0.72rem; font-weight:900; color:#fbbf24; background:rgba(251,191,36,0.1); border:1px solid rgba(251,191,36,0.16); border-radius:100px; padding:0.18rem 0.6rem; }
  .qmlp-rank-name { font-family:'Cinzel',serif; font-size:1.4rem; font-weight:900; color:#fbbf24; text-shadow:0 0 18px rgba(251,191,36,0.3); display:flex; align-items:center; gap:0.35rem; position:relative; z-index:1; margin-bottom:0.2rem; }
  .qmlp-rank-sub  { font-family:'Nunito Sans',sans-serif; font-size:0.66rem; font-weight:700; color:rgba(255,255,255,0.28); position:relative; z-index:1; margin-bottom:0.9rem; }
  .qmlp-ladder-scroll { overflow-x:auto; overflow-y:hidden; scrollbar-width:none; cursor:grab; position:relative; z-index:1; }
  .qmlp-ladder-scroll::-webkit-scrollbar { display:none; }
  .qmlp-ladder-scroll:active { cursor:grabbing; }
  .qmlp-ladder-inner    { display:flex; align-items:flex-end; position:relative; height:96px; }
  .qmlp-ladder-baseline { position:absolute; top:46px; left:20px; right:20px; height:2px; background:rgba(255,255,255,0.06); border-radius:2px; z-index:0; }
  .qmlp-ladder-progress { position:absolute; top:46px; left:20px; height:2px; background:linear-gradient(90deg,#fbbf24,#f59e0b); box-shadow:0 0 8px rgba(251,191,36,0.5); border-radius:2px; z-index:1; transition:width 1.2s cubic-bezier(0.34,1.56,0.64,1); }
  .qmlp-ship-wrap { position:absolute; top:16px; z-index:10; display:flex; flex-direction:column; align-items:center; pointer-events:none; transform:translateX(-50%); transition:left 1.2s cubic-bezier(0.34,1.56,0.64,1); }
  .qmlp-ship { font-size:1.3rem; filter:drop-shadow(0 2px 10px rgba(251,191,36,0.6)); animation:qmlpShipBob 2.8s ease-in-out infinite; display:block; }
  @keyframes qmlpShipBob { 0%,100%{transform:translateY(0) rotate(-3deg);} 50%{transform:translateY(-5px) rotate(3deg);} }
  .qmlp-ship-tether { width:1px; height:10px; background:linear-gradient(to bottom,rgba(251,191,36,0.35),transparent); }
  .qmlp-ladder-col  { display:flex; flex-direction:column; align-items:center; position:relative; z-index:2; width:72px; flex-shrink:0; }
  .qmlp-ladder-col:first-child,.qmlp-ladder-col:last-child { width:40px; }
  .qmlp-ladder-node { width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:1.2rem; position:relative; z-index:2; margin-top:34px; transition:transform 0.2s; }
  .qmlp-ladder-node:hover { transform:scale(1.1); }
  .qmlp-ladder-node.reached { background:linear-gradient(145deg,#1e0e4a,#3730a3); border:2px solid rgba(251,191,36,0.38); box-shadow:0 0 14px rgba(251,191,36,0.12),0 3px 0 rgba(20,10,50,0.7); }
  .qmlp-ladder-node.current { background:linear-gradient(145deg,#6d28d9,#a855f7); border:2.5px solid #fbbf24; box-shadow:0 0 0 4px rgba(251,191,36,0.1),0 0 18px rgba(168,85,247,0.4),0 3px 0 rgba(80,30,150,0.5); animation:qmlpNodePulse 2.2s ease-in-out infinite; }
  @keyframes qmlpNodePulse { 0%,100%{box-shadow:0 0 0 4px rgba(251,191,36,0.1),0 0 18px rgba(168,85,247,0.4);}50%{box-shadow:0 0 0 7px rgba(251,191,36,0.05),0 0 26px rgba(168,85,247,0.55);} }
  .qmlp-ladder-node.locked { background:rgba(0,0,0,0.4); border:2px solid rgba(255,255,255,0.07); filter:grayscale(0.7) opacity(0.4); }
  .qmlp-ladder-label { margin-top:4px; display:flex; flex-direction:column; align-items:center; gap:1px; text-align:center; }
  .qmlp-ladder-label-name { font-family:'Cinzel',serif; font-size:0.44rem; font-weight:700; max-width:64px; line-height:1.3; letter-spacing:0.03em; }
  .qmlp-ladder-label-name.reached { color:#fbbf24; }
  .qmlp-ladder-label-name.current { color:#c084fc; }
  .qmlp-ladder-label-name.locked  { color:rgba(255,255,255,0.16); }
  .qmlp-ladder-label-xp { font-family:'Nunito Sans',sans-serif; font-size:0.38rem; font-weight:700; }
  .qmlp-ladder-label-xp.reached { color:rgba(251,191,36,0.32); }
  .qmlp-ladder-label-xp.current { color:rgba(192,132,252,0.5); }
  .qmlp-ladder-label-xp.locked  { color:rgba(255,255,255,0.1); }
  .qmlp-stats-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.6rem; }
  .qmlp-stat-tile  { background:rgba(255,255,255,0.035); border:1px solid rgba(255,255,255,0.065); border-radius:16px; padding:0.85rem 1rem; transition:background 0.2s,border-color 0.2s,transform 0.2s; cursor:default; }
  .qmlp-stat-tile:hover { background:rgba(255,255,255,0.06); border-color:rgba(255,255,255,0.1); transform:translateY(-1px); }
  .qmlp-stat-tile.gold { background:rgba(251,191,36,0.06); border-color:rgba(251,191,36,0.18); }
  .qmlp-stat-icon  { font-size:1.15rem; margin-bottom:0.35rem; display:block; }
  .qmlp-stat-value { font-family:'Cinzel',serif; font-size:1.35rem; font-weight:900; color:white; line-height:1; margin-bottom:0.18rem; }
  .qmlp-stat-tile.gold .qmlp-stat-value { color:#fbbf24; }
  .qmlp-stat-label { font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:700; color:rgba(255,255,255,0.27); text-transform:uppercase; letter-spacing:0.1em; }
  .qmlp-section-title { font-family:'Cinzel',serif; font-size:0.6rem; font-weight:700; letter-spacing:0.2em; text-transform:uppercase; color:rgba(255,255,255,0.22); display:flex; align-items:center; gap:0.5rem; }
  .qmlp-section-title::after { content:''; flex:1; height:1px; background:rgba(255,255,255,0.06); }
  .qmlp-quest-card { background:rgba(255,255,255,0.032); border:1px solid rgba(255,255,255,0.065); border-radius:16px; padding:0.85rem 1rem; cursor:pointer; transition:background 0.15s,border-color 0.15s,transform 0.15s; overflow:hidden; position:relative; }
  .qmlp-quest-card::before { content:''; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--qc-accent,rgba(255,255,255,0.15)); border-radius:0 2px 2px 0; }
  .qmlp-quest-card:hover { background:rgba(255,255,255,0.055); border-color:rgba(255,255,255,0.1); transform:translateX(2px); }
  .qmlp-quest-card.claimable { background:rgba(251,191,36,0.05); border-color:rgba(251,191,36,0.22); animation:qmlpClaimPulse 3s ease-in-out infinite; }
  @keyframes qmlpClaimPulse { 0%,100%{box-shadow:0 0 0 rgba(251,191,36,0);} 50%{box-shadow:0 0 14px rgba(251,191,36,0.1);} }
  .qmlp-qc-top  { display:flex; align-items:flex-start; gap:0.6rem; margin-bottom:0.5rem; }
  .qmlp-qc-icon { width:34px; height:34px; border-radius:10px; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:1rem; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.07); }
  .qmlp-quest-card.claimable .qmlp-qc-icon { background:rgba(251,191,36,0.09); border-color:rgba(251,191,36,0.18); animation:hcWiggle 2s ease-in-out infinite; }
  @keyframes hcWiggle { 0%,100%{transform:rotate(0);}30%{transform:rotate(-7deg) scale(1.05);}70%{transform:rotate(7deg) scale(1.05);} }
  .qmlp-qc-body { flex:1; min-width:0; }
  .qmlp-qc-arc  { font-family:'Nunito Sans',sans-serif; font-size:0.57rem; font-weight:800; letter-spacing:0.12em; text-transform:uppercase; color:var(--qc-accent,rgba(255,255,255,0.25)); margin-bottom:0.12rem; }
  .qmlp-qc-name { font-family:'Sorts Mill Goudy',serif; font-size:0.88rem; font-weight:700; color:white; line-height:1.2; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .qmlp-qc-status { font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:700; margin-top:0.12rem; }
  .qmlp-qc-status.ready    { color:#fbbf24; }
  .qmlp-qc-status.progress { color:rgba(255,255,255,0.27); }
  .qmlp-qc-prog-row { display:flex; align-items:center; gap:0.5rem; }
  .qmlp-qc-track { flex:1; height:4px; background:rgba(255,255,255,0.065); border-radius:100px; overflow:hidden; }
  .qmlp-qc-fill  { height:100%; border-radius:100px; background:var(--qc-accent,rgba(255,255,255,0.35)); transition:width 0.7s cubic-bezier(0.34,1.56,0.64,1); }
  .qmlp-qc-pct   { font-family:'Nunito',sans-serif; font-size:0.62rem; font-weight:900; color:rgba(255,255,255,0.35); flex-shrink:0; }
  .qmlp-claim-btn { width:100%; margin-top:0.55rem; padding:0.42rem 0; background:linear-gradient(135deg,#fbbf24,#f59e0b); border:none; border-radius:10px; cursor:pointer; font-family:'Sorts Mill Goudy',serif; font-size:0.74rem; font-weight:700; color:#1a0e00; letter-spacing:0.04em; box-shadow:0 3px 0 #d97706,0 5px 10px rgba(251,191,36,0.22); transition:all 0.12s; }
  .qmlp-claim-btn:hover  { transform:translateY(-1px); box-shadow:0 5px 0 #d97706; }
  .qmlp-claim-btn:active { transform:translateY(1px); box-shadow:0 1px 0 #d97706; }
  .qmlp-arc-list { display:flex; flex-direction:column; gap:0.45rem; }
  .qmlp-arc-row  { display:flex; align-items:center; gap:0.7rem; padding:0.7rem 0.85rem; border-radius:14px; cursor:pointer; border:1px solid transparent; transition:all 0.2s ease; background:rgba(255,255,255,0.025); }
  .qmlp-arc-row:hover  { background:rgba(255,255,255,0.05); border-color:rgba(255,255,255,0.07); }
  .qmlp-arc-row.active { background:rgba(255,255,255,0.055); border-color:var(--arc-accent,rgba(251,191,36,0.25)); box-shadow:inset 0 1px 0 rgba(255,255,255,0.04); }
  .qmlp-arc-emoji { font-size:1.25rem; flex-shrink:0; }
  .qmlp-arc-info  { flex:1; min-width:0; }
  .qmlp-arc-name  { font-family:'Sorts Mill Goudy',serif; font-size:0.84rem; font-weight:700; color:rgba(255,255,255,0.6); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:0.2rem; }
  .qmlp-arc-row.active .qmlp-arc-name { color:var(--arc-accent,#fbbf24); }
  .qmlp-arc-track { height:3px; background:rgba(255,255,255,0.07); border-radius:100px; overflow:hidden; }
  .qmlp-arc-fill  { height:100%; border-radius:100px; background:var(--arc-accent,rgba(255,255,255,0.25)); transition:width 0.6s ease; }
  .qmlp-arc-meta  { font-family:'Nunito Sans',sans-serif; font-size:0.55rem; font-weight:700; color:rgba(255,255,255,0.22); margin-top:0.14rem; }
  .qmlp-arc-dot   { width:7px; height:7px; border-radius:50%; background:#ef4444; box-shadow:0 0 7px #ef4444; flex-shrink:0; animation:qmDotBlink 1.4s ease-in-out infinite; }
`;

const ERROR_STYLES = `
  .qme-bg {
    position:absolute; inset:0;
    background: radial-gradient(ellipse 80% 60% at 50% 100%, #023e6e 0%, #011a38 50%, #020b18 100%);
    z-index:0;
  }
  .qme-fog {
    position:absolute; pointer-events:none; z-index:1;
    border-radius:50%; filter:blur(60px);
  }
  .qme-fog1 {
    width:500px; height:200px; left:-80px; top:30%;
    background:rgba(0,119,182,0.12);
    animation: qmeFogDrift 18s ease-in-out infinite alternate;
  }
  .qme-fog2 {
    width:400px; height:160px; right:-60px; top:50%;
    background:rgba(0,96,145,0.09);
    animation: qmeFogDrift 22s ease-in-out infinite alternate-reverse;
  }
  @keyframes qmeFogDrift { 0%{transform:translate(0,0);} 100%{transform:translate(40px,-20px);} }

  .qme-debris {
    position:absolute; border-radius:2px; pointer-events:none; z-index:2;
    background:rgba(100,65,35,0.55);
  }
  .qme-debris-0 { width:22px;height:5px;  left:12%; top:58%; animation:qmeDebrisBob 5.1s ease-in-out infinite; }
  .qme-debris-1 { width:14px;height:4px;  left:78%; top:62%; animation:qmeDebrisBob 4.3s ease-in-out infinite 0.7s; }
  .qme-debris-2 { width:8px; height:8px; border-radius:50%; left:22%; top:72%; background:rgba(251,191,36,0.18); animation:qmeDebrisBob 6.2s ease-in-out infinite 1.1s; }
  .qme-debris-3 { width:18px;height:4px;  left:64%; top:54%; animation:qmeDebrisBob 4.8s ease-in-out infinite 0.3s; }
  .qme-debris-4 { width:6px; height:6px;  border-radius:50%; left:85%; top:44%; background:rgba(0,180,216,0.25); animation:qmeDebrisBob 3.9s ease-in-out infinite 1.8s; }
  .qme-debris-5 { width:10px;height:3px;  left:8%;  top:80%; animation:qmeDebrisBob 5.5s ease-in-out infinite 0.5s; }
  .qme-debris-6 { width:24px;height:5px;  left:48%; top:76%; animation:qmeDebrisBob 6.7s ease-in-out infinite 2.2s; }
  .qme-debris-7 { width:7px; height:7px;  border-radius:50%; left:35%; top:45%; background:rgba(100,65,35,0.3); animation:qmeDebrisBob 4.1s ease-in-out infinite 1.4s; }
  @keyframes qmeDebrisBob { 0%,100%{transform:translateY(0) rotate(0deg);opacity:0.7;} 50%{transform:translateY(-9px) rotate(6deg);opacity:1;} }

  .qme-stage {
    position:relative; z-index:10; display:flex; flex-direction:column; align-items:center;
    padding:1rem 2rem 3rem; max-width:380px; width:100%;
    animation:qmeStageIn 0.9s cubic-bezier(0.22,1,0.36,1) both;
  }
  @keyframes qmeStageIn { 0%{opacity:0;transform:translateY(28px);} 100%{opacity:1;transform:translateY(0);} }

  .qme-ship-wrap {
    width:240px; margin-bottom:0.4rem;
    animation:qmeShipRock 5s ease-in-out infinite;
    transform-origin:center bottom;
    filter:drop-shadow(0 12px 40px rgba(0,80,160,0.5)) drop-shadow(0 2px 8px rgba(0,0,0,0.8));
  }
  @keyframes qmeShipRock { 0%,100%{transform:rotate(-2.5deg) translateY(0);} 50%{transform:rotate(2.5deg) translateY(-4px);} }
  .qme-ship-svg { width:100%; height:auto; display:block; }
  .qme-mast { animation:qmeMastSway 7s ease-in-out infinite; transform-origin:130px 148px; }
  @keyframes qmeMastSway { 0%,100%{transform:rotate(-1deg);} 50%{transform:rotate(1.5deg);} }
  .qme-hull { animation:qmeHullSettle 5s ease-in-out infinite 0.3s; transform-origin:120px 145px; }
  @keyframes qmeHullSettle { 0%,100%{transform:rotate(-1.5deg);} 50%{transform:rotate(1deg);} }

  .qme-content {
    text-align:center; display:flex; flex-direction:column; align-items:center; gap:0.55rem;
    animation:qmeStageIn 1s cubic-bezier(0.22,1,0.36,1) 0.12s both;
  }

  .qme-eyebrow {
    font-family:'Cinzel',serif; font-size:0.58rem; font-weight:700;
    letter-spacing:0.28em; text-transform:uppercase; color:rgba(251,191,36,0.5);
  }

  .qme-title {
    font-family:'Sorts Mill Goudy',serif; font-size:2.4rem; font-weight:900; margin:0;
    color:#ffffff; letter-spacing:0.02em;
    text-shadow:0 0 40px rgba(0,150,220,0.45), 0 2px 20px rgba(0,0,0,0.8);
    line-height:1.05;
  }

  .qme-subtitle {
    font-family:'Nunito Sans',sans-serif; font-size:0.82rem; font-weight:600; margin:0;
    color:rgba(255,255,255,0.38); max-width:260px; line-height:1.5;
  }

  .qme-error-badge {
    display:flex; align-items:center; gap:0.45rem;
    background:rgba(239,68,68,0.08); border:1px solid rgba(239,68,68,0.25);
    border-radius:100px; padding:0.32rem 0.85rem;
    max-width:100%; overflow:hidden;
  }
  .qme-error-dot {
    width:6px; height:6px; border-radius:50%; background:#ef4444;
    box-shadow:0 0 8px #ef4444; flex-shrink:0;
    animation:qmDotBlink 1.4s ease-in-out infinite;
  }
  .qme-error-text {
    font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:700;
    color:rgba(239,68,68,0.85); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  }

  .qme-retry-btn {
    position:relative; overflow:hidden;
    margin-top:0.35rem; padding:0.7rem 2rem;
    background:linear-gradient(135deg,#0077b6,#023e8a);
    border:1px solid rgba(0,180,216,0.45); border-radius:100px; cursor:pointer;
    font-family:'Sorts Mill Goudy',serif; font-size:0.88rem; font-weight:700; letter-spacing:0.06em;
    color:white;
    box-shadow:0 4px 0 rgba(0,30,80,0.6), 0 6px 28px rgba(0,100,200,0.25);
    transition:all 0.14s ease;
  }
  .qme-retry-btn:hover  { transform:translateY(-2px); box-shadow:0 6px 0 rgba(0,30,80,0.6), 0 10px 32px rgba(0,120,220,0.35); }
  .qme-retry-btn:active { transform:translateY(1px);  box-shadow:0 2px 0 rgba(0,30,80,0.6); }
  .qme-btn-wake {
    position:absolute; inset:0; border-radius:100px;
    background:linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.12) 50%, transparent 100%);
    transform:translateX(-100%);
    animation:qmeBtnSheen 3.5s ease-in-out infinite 1s;
  }
  @keyframes qmeBtnSheen { 0%,100%{transform:translateX(-100%);} 40%,60%{transform:translateX(100%);} }
  .qme-btn-label { position:relative; z-index:1; }

  .qme-hint {
    font-family:'Nunito Sans',sans-serif; font-size:0.62rem; font-weight:600; margin:0;
    color:rgba(255,255,255,0.18); max-width:240px; line-height:1.5; font-style:italic;
  }
`;

// ─── Arc theme ────────────────────────────────────────────────────────────────
export interface ArcTheme {
  accent: string;
  accentDark: string;
  bgFrom: string;
  bgTo: string;
  emoji: string;
  terrain: { l: string; m: string; d: string; s: string };
  decos: [string, string, string];
}

const DECO_SETS: [string, string, string][] = [
  ["🌴", "🌿", "🌴"],
  ["🌵", "🏺", "🌵"],
  ["☁️", "✨", "☁️"],
  ["🪨", "🌾", "🪨"],
  ["🍄", "🌸", "🍄"],
  ["🔥", "💀", "🔥"],
  ["❄️", "🌨️", "❄️"],
  ["🌺", "🦜", "🌺"],
];

const hslToHex = (h: number, s: number, l: number) => {
  const a = s * Math.min(l, 1 - l);
  const f = (n: number) => {
    const k = (n + h * 12) % 12;
    const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
    return Math.round(255 * c)
      .toString(16)
      .padStart(2, "0");
  };
  return `#${f(0)}${f(8)}${f(4)}`;
};

export const generateArcTheme = (arc: QuestArc): ArcTheme => {
  const rng = mkRng(strToSeed(arc.id));
  const anchors = [150, 165, 180, 200, 230, 260];
  const baseHue =
    anchors[Math.floor(rng() * anchors.length)] + (rng() - 0.5) * 8;
  const satBase = 0.48 + rng() * 0.18;
  const satTerrain = Math.min(0.8, satBase + 0.12);
  const accentLightL = 0.48 + rng() * 0.12;
  const accentDarkL = 0.22 + rng() * 0.06;
  const bgFromL = 0.04 + rng() * 0.06;
  const bgToL = 0.1 + rng() * 0.06;
  const accent = hslToHex(baseHue, satBase, accentLightL);
  const accentDark = hslToHex(
    baseHue + (rng() * 6 - 3),
    Math.max(0.35, satBase - 0.08),
    accentDarkL,
  );
  const bgFrom = hslToHex(
    baseHue + (rng() * 10 - 5),
    0.1 + rng() * 0.06,
    bgFromL,
  );
  const bgTo = hslToHex(baseHue + (6 + rng() * 12), 0.08 + rng() * 0.06, bgToL);
  const tL = hslToHex(
    baseHue + 10 + rng() * 6,
    Math.min(0.85, satTerrain),
    0.36 + rng() * 0.08,
  );
  const tM = hslToHex(
    baseHue + (rng() * 6 - 3),
    Math.min(0.72, satTerrain - 0.06),
    0.24 + rng() * 0.06,
  );
  const tD = hslToHex(
    baseHue + (rng() * 8 - 4),
    Math.max(0.38, satBase - 0.18),
    0.1 + rng() * 0.04,
  );
  const sd = parseInt(tD.slice(1, 3), 16);
  const sg = parseInt(tD.slice(3, 5), 16);
  const sb = parseInt(tD.slice(5, 7), 16);
  const emojis = ["🌿", "🌲", "🌳", "🌺", "🪨", "🍄", "🌵"];
  const emoji = emojis[Math.floor(rng() * emojis.length)];
  return {
    accent,
    accentDark,
    bgFrom,
    bgTo,
    emoji,
    terrain: { l: tL, m: tM, d: tD, s: `rgba(${sd},${sg},${sb},0.6)` },
    decos: DECO_SETS[Math.floor(rng() * DECO_SETS.length)],
  };
};

const themeCache = new Map<string, ArcTheme>();
const getArcTheme = (arc: QuestArc): ArcTheme => {
  if (!themeCache.has(arc.id)) themeCache.set(arc.id, generateArcTheme(arc));
  return themeCache.get(arc.id)!;
};

const REQ_EMOJI: Record<string, string> = {
  questions: "❓",
  accuracy: "🎯",
  streak: "🔥",
  sessions: "📚",
  topics: "🗺️",
  xp: "⚡",
  leaderboard: "🏆",
};
const REQ_LABEL: Record<string, string> = {
  questions: "questions answered",
  accuracy: "% accuracy",
  streak: "day streak",
  sessions: "sessions",
  topics: "topics covered",
  xp: "XP earned",
  leaderboard: "leaderboard rank",
};

// ─── Crew ranks ───────────────────────────────────────────────────────────────
const CREW_RANKS = [
  { id: "cabin_boy", label: "Cabin Boy", emoji: "⚓", xpRequired: 0 },
  { id: "navigator", label: "Navigator", emoji: "📚", xpRequired: 500 },
  { id: "first_mate", label: "First Mate", emoji: "🎓", xpRequired: 1500 },
  { id: "warlord", label: "Warlord", emoji: "⚔️", xpRequired: 3000 },
  { id: "emperor", label: "Emperor", emoji: "👑", xpRequired: 6000 },
  { id: "pirate_king", label: "Pirate King", emoji: "☠️", xpRequired: 10000 },
];

const SEG_W = 72,
  EDGE_W = 40;
const nodeX = (i: number, total: number) => {
  if (i === 0) return EDGE_W / 2;
  if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2;
  return EDGE_W + SEG_W * (i - 1) + SEG_W / 2;
};

// ─── 3D Route Paths ───────────────────────────────────────────────────────────
// Proper Three.js sea routes — live in world space, move with camera pan/zoom/rotate.
// Each segment is a CatmullRomCurve3 that bows sideways over the water.
// Dashes scroll via LineDashedMaterial dashOffset animated in useFrame.
// Active legs get a glow tube + a ship that physically travels the curve.

interface RouteSegmentProps {
  from: THREE.Vector3;
  to: THREE.Vector3;
  idx: number;
  isDone: boolean;
  isActive: boolean;
  isNext: boolean;
  accent: string;
}

const RouteSegment = ({
  from,
  to,
  idx,
  isDone,
  isActive,
  isNext,
  accent,
}: RouteSegmentProps) => {
  const lineRef = useRef<THREE.Line | null>(null);
  const glowRef = useRef<THREE.Mesh | null>(null);
  const shipRef = useRef<THREE.Group | null>(null);
  const shipT = useRef(0);

  // CatmullRom curve bowing sideways — alternate direction per segment
  const curve = useMemo(() => {
    const side = idx % 2 === 0 ? 1 : -1;
    const dx = to.x - from.x;
    const dz = to.z - from.z;
    const mid = new THREE.Vector3(
      (from.x + to.x) / 2 + -dz * 0.28 * side,
      -0.65, // hover just above water
      (from.z + to.z) / 2 + dx * 0.28 * side,
    );
    const y = -0.72;
    const p0 = new THREE.Vector3(from.x, y, from.z);
    const p1 = new THREE.Vector3(to.x, y, to.z);
    return new THREE.CatmullRomCurve3([p0, mid, p1], false, "catmullrom", 0.5);
  }, [from, to, idx]);

  // Geometry for the dashed line (needs computeLineDistances)
  const lineGeo = useMemo(() => {
    const pts = curve.getPoints(80);
    return new THREE.BufferGeometry().setFromPoints(pts);
  }, [curve]);

  // Tube geometry for the soft glow underlayer
  const glowGeo = useMemo(
    () => new THREE.TubeGeometry(curve, 40, 0.055, 5, false),
    [curve],
  );

  const color = new THREE.Color(
    isDone || isNext ? accent : isActive ? "#94d8ff" : "#ffffff",
  );
  const opacity = isDone || isNext ? 0.8 : isActive ? 0.85 : 0.15;
  const glowOpacity = isDone || isNext ? 0.2 : isActive ? 0.25 : 0;
  const dashSpeed = isDone || isNext ? 0.55 : isActive ? 1.05 : 0;

  useFrame((_, dt) => {
    // Scroll dashes forward along the route
    if (lineRef.current) {
      // material typings may not include dashOffset; use any and guard the value
      const lineMat = lineRef.current.material as any;
      if (dashSpeed > 0)
        lineMat.dashOffset = (lineMat.dashOffset ?? 0) - dt * dashSpeed;
    }
    // Pulse glow on active segments
    if (glowRef.current && (isActive || isNext)) {
      const mat = glowRef.current.material as THREE.MeshStandardMaterial;
      mat.emissiveIntensity = 0.28 + Math.sin(Date.now() * 0.0018) * 0.12;
    }
    // Travel ship along active curve
    if (shipRef.current && isActive && !isDone) {
      shipT.current = (shipT.current + dt * 0.11) % 1;
      const pt = curve.getPoint(shipT.current);
      const tan = curve.getTangent(shipT.current);
      shipRef.current.position.copy(pt);
      shipRef.current.position.y = -0.58; // float just above water surface
      shipRef.current.rotation.y = Math.atan2(tan.x, tan.z);
    }
  });

  return (
    <group>
      {/* Glow underlayer tube */}
      {(isDone || isNext || isActive) && (
        <mesh ref={glowRef} geometry={glowGeo}>
          <meshStandardMaterial
            color={color}
            emissive={color}
            emissiveIntensity={0.28}
            transparent
            opacity={glowOpacity}
            depthWrite={false}
            side={THREE.DoubleSide}
          />
        </mesh>
      )}

      {/* Dashed route line */}
      <line
        ref={(r: any) => {
          // r may be an SVGLineElement in JSX DOM typings; treat as any to satisfy TS and assign to Line ref
          lineRef.current = r as THREE.Line | null;
        }}
        // @ts-ignore - geometry is a three.js prop, not an SVG attribute
        geometry={lineGeo}
        // onUpdate receives a three.js Line; use any to avoid DOM typings
        onUpdate={(self: any) => self.computeLineDistances()}
      >
        <lineDashedMaterial
          color={color}
          dashSize={0.2}
          gapSize={0.13}
          opacity={opacity}
          transparent
        />
      </line>

      {/* Travelling ship on the active leg */}
      {isActive && !isDone && (
        <group ref={shipRef}>
          {/* Gold wake glow disk */}
          <mesh rotation-x={-Math.PI / 2} position-y={0.04}>
            <circleGeometry args={[0.19, 16]} />
            <meshStandardMaterial
              color="#fbbf24"
              emissive="#fbbf24"
              emissiveIntensity={1.0}
              transparent
              opacity={0.38}
              depthWrite={false}
            />
          </mesh>
        </group>
      )}
    </group>
  );
};

// ── RoutePaths3D — all route segments, lives inside <Canvas> ─────────────────
interface RoutePaths3DProps {
  nodes: QuestNode[];
  positions: { x: number; y: number }[];
  nodeCount: number;
  accent: string;
}

const RoutePaths3D = ({
  nodes,
  positions,
  nodeCount,
  accent,
}: RoutePaths3DProps) => {
  // Mirror the exact same 2D→3D coordinate mapping used in IslandScene
  const pos3d = useMemo(
    () =>
      positions.map(
        (p, i) =>
          new THREE.Vector3(
            (p.x / VW) * 9 - 4.5,
            0,
            (i / Math.max(nodeCount - 1, 1)) * (nodeCount * 2.4),
          ),
      ),
    [positions, nodeCount],
  );

  return (
    <>
      {nodes.slice(0, -1).map((node, i) => {
        const from = pos3d[i];
        const to = pos3d[i + 1];
        if (!from || !to) return null;
        const nextNode = nodes[i + 1];
        const isDone = node.status === "completed";
        const isActive =
          node.status === "ACTIVE" || node.status === "CLAIMABLE";
        const isNext =
          isDone &&
          (nextNode?.status === "ACTIVE" || nextNode?.status === "CLAIMABLE");
        return (
          <RouteSegment
            key={`r3d-${i}`}
            from={from}
            to={to}
            idx={i}
            isDone={isDone}
            isActive={isActive}
            isNext={isNext}
            accent={accent}
          />
        );
      })}
    </>
  );
};

// ─── Map geometry ─────────────────────────────────────────────────────────────
// Camera constants
const POLAR_FIXED = Math.PI / 2 - (25 * Math.PI) / 180;
const DIST_DEFAULT = 7;
const DIST_MIN = 3.5;
const DIST_MAX = 14;
const PAN_RADIUS = 8;

// ── WaterPlane ────────────────────────────────────────────────────────────────
const WaterPlane = () => {
  const meshRef = useRef<THREE.Mesh>(null!);
  useFrame(({ clock }) => {
    const geo = meshRef.current?.geometry as THREE.PlaneGeometry;
    if (!geo) return;
    const pos = geo.attributes.position;
    const t = clock.elapsedTime;
    for (let i = 0; i < pos.count; i++) {
      const x = pos.getX(i);
      const z = pos.getZ(i);
      pos.setY(
        i,
        Math.sin(x * 0.6 + t * 0.7) * 0.04 + Math.cos(z * 0.5 + t * 0.5) * 0.03,
      );
    }
    pos.needsUpdate = true;
    geo.computeVertexNormals();
  });
  return (
    <mesh
      ref={meshRef}
      rotation-x={-Math.PI / 2}
      position-y={-0.85}
      receiveShadow
    >
      <planeGeometry args={[120, 120, 80, 80]} />
      <meshStandardMaterial
        color="#03045e"
        roughness={0.08}
        metalness={0.35}
        emissive="#03045e"
        emissiveIntensity={0.55}
      />
    </mesh>
  );
};

// ── PanClamp ──────────────────────────────────────────────────────────────────
const PanClamp = () => {
  const controls = useThree((s) => s.controls) as any;
  useFrame(() => {
    if (!controls?.target) return;
    const t = controls.target as THREE.Vector3;
    const dx = t.x,
      dz = t.z;
    const d = Math.sqrt(dx * dx + dz * dz);
    if (d > PAN_RADIUS) {
      const sc = PAN_RADIUS / d;
      t.x *= sc;
      t.z *= sc;
    }
    t.y = THREE.MathUtils.clamp(t.y, -1, 2);
  });
  return null;
};

// ── CameraDistSync ─────────────────────────────────────────────────────────────
const CameraDistSync = ({ dist }: { dist: number }) => {
  const { camera, controls } = useThree() as any;
  useEffect(() => {
    if (!camera) return;
    const target = controls?.target ?? new THREE.Vector3(0, 0, 0);
    const dir = camera.position.clone().sub(target).normalize();
    camera.position.copy(target).addScaledVector(dir, dist);
  }, [camera, controls, dist]);
  return null;
};

// ── IslandScene ───────────────────────────────────────────────────────────────
interface MapContentProps {
  arc: QuestArc;
  theme: ArcTheme;
  positions: { x: number; y: number }[];
  onNodeTap: (node: QuestNode) => void;
  onClaim: (node: QuestNode) => void;
  initialTarget: [number, number, number];
  modalOpen: boolean;
}

const IslandScene = ({
  arc,
  theme,
  positions,
  onNodeTap,
  onClaim,
  initialTarget,
  modalOpen,
}: MapContentProps) => {
  const nodeCount = arc.nodes.length;

  const sorted = useMemo(
    () => [...arc.nodes].sort((a, b) => a.sequence_order - b.sequence_order),
    [arc.nodes],
  );

  const currentIdx = sorted.findIndex(
    (n) => n.status === "ACTIVE" || n.status === "CLAIMABLE",
  );

  return (
    <>
      <WaterPlane />
      <mesh>
        <sphereGeometry args={[55, 32, 16]} />
        <meshBasicMaterial color="#87ceeb" side={THREE.BackSide} />
      </mesh>

      {/* 3D sea routes — live in world space, respect camera */}
      <RoutePaths3D
        nodes={sorted}
        positions={positions}
        nodeCount={nodeCount}
        accent={theme.accent}
      />

      {sorted.map((node, i) => {
        const centre = positions[i] ?? { x: VW / 2, y: TOP_PAD + i * ROW_H };
        const x3 = (centre.x / VW) * 9 - 4.5;
        const z3 = (i / Math.max(nodeCount - 1, 1)) * (nodeCount * 2.4);
        return (
          <Island3D
            key={node.node_id}
            node={node}
            index={i}
            position={[x3, 0, z3]}
            accent={theme.accent}
            terrain={theme.terrain}
            onTap={onNodeTap}
            onClaim={onClaim}
            isCurrent={i === currentIdx} // ← add this
            modalOpen={modalOpen}
          />
        );
      })}

      <ambientLight intensity={2.5} color="#ffffff" />
      <directionalLight
        position={[8, 14, 6]}
        intensity={3.5}
        color="#ffe8a0"
        castShadow
        shadow-mapSize-width={1024}
        shadow-mapSize-height={1024}
        shadow-camera-near={0.1}
        shadow-camera-far={80}
        shadow-camera-left={-20}
        shadow-camera-right={20}
        shadow-camera-top={20}
        shadow-camera-bottom={-20}
      />
      <directionalLight
        position={[-5, 8, -4]}
        intensity={1.2}
        color="#b0d8f5"
      />
      <pointLight
        position={[0, -0.5, 3]}
        intensity={1.8}
        color="#7dcfef"
        distance={30}
      />

      <Stars
        radius={70}
        depth={35}
        count={600}
        factor={3}
        saturation={0}
        fade
        speed={0.3}
      />

      <OrbitControls
        makeDefault
        enableZoom
        enablePan
        enableRotate
        minDistance={DIST_MIN}
        maxDistance={DIST_MAX}
        minPolarAngle={POLAR_FIXED - (8 * Math.PI) / 180}
        maxPolarAngle={POLAR_FIXED + (8 * Math.PI) / 180}
        dampingFactor={0.07}
        enableDamping
        zoomSpeed={0.65}
        panSpeed={0.7}
        screenSpacePanning={false}
        target={new THREE.Vector3(...initialTarget)}
      />
      <PanClamp />
    </>
  );
};

// ── Zoom button ───────────────────────────────────────────────────────────────
const zoomBtnStyle: React.CSSProperties = {
  width: 30,
  height: 30,
  borderRadius: "50%",
  background: "rgba(255,255,255,0.08)",
  border: "1px solid rgba(255,255,255,0.18)",
  color: "rgba(255,255,255,0.75)",
  fontSize: "1.15rem",
  lineHeight: "1",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  cursor: "pointer",
  fontFamily: "monospace",
  transition: "background 0.15s, transform 0.1s",
  userSelect: "none",
  padding: 0,
};

// ── MapContent ────────────────────────────────────────────────────────────────
const MapContent = ({
  arc,
  theme,
  positions,
  onNodeTap,
  onClaim,
  initialTarget,
  modalOpen,
}: MapContentProps) => {
  const nodeCount = arc.nodes.length;
  const isMobile = window.innerWidth < 768;
  const offset = isMobile ? 150 : 0;
  const canvasH = Math.max(window.innerHeight - offset, nodeCount * 160);
  const [dist, setDist] = useState(DIST_DEFAULT);

  return (
    <div
      style={{
        position: "relative",
        width: "100%",
        height: `${canvasH}px`,
        background: "#023047",
      }}
    >
      {/* Zoom buttons */}
      <div
        style={{
          position: "absolute",
          bottom: "1.5rem",
          right: "1rem",
          zIndex: 10,
          display: "flex",
          flexDirection: "column",
          gap: "0.4rem",
          alignItems: "center",
        }}
      >
        <button
          aria-label="Zoom in"
          style={zoomBtnStyle}
          onClick={() => setDist((d) => Math.max(DIST_MIN, d - 1.5))}
        >
          +
        </button>
        <div
          style={{
            width: 3,
            height: 56,
            background: "rgba(255,255,255,0.08)",
            borderRadius: 4,
            position: "relative",
          }}
        >
          <div
            style={{
              position: "absolute",
              bottom: 0,
              width: "100%",
              borderRadius: 4,
              background: "rgba(251,191,36,0.65)",
              height: `${((DIST_MAX - dist) / (DIST_MAX - DIST_MIN)) * 100}%`,
              transition: "height 0.2s ease",
            }}
          />
        </div>
        <button
          aria-label="Zoom out"
          style={zoomBtnStyle}
          onClick={() => setDist((d) => Math.min(DIST_MAX, d + 1.5))}
        >
          −
        </button>
      </div>

      <Canvas
        camera={{
          position: [
            initialTarget[0] + Math.sin(0) * DIST_DEFAULT,
            Math.sin(POLAR_FIXED) * DIST_DEFAULT,
            initialTarget[2] + Math.cos(POLAR_FIXED) * DIST_DEFAULT,
          ],
          fov: 52,
          near: 0.1,
          far: 300,
        }}
        gl={{ antialias: true, alpha: false }}
        style={{ width: "100%", height: "100%", background: "#023047" }}
        shadows
        onPointerMissed={() => {}}
      >
        <fog attach="fog" args={["#023047", 18, 60]} />
        <CameraDistSync dist={dist} />
        <IslandScene
          arc={arc}
          theme={theme}
          positions={positions}
          onNodeTap={onNodeTap}
          onClaim={onClaim}
          initialTarget={initialTarget}
          modalOpen={modalOpen}
        />
      </Canvas>
    </div>
  );
};

// ─── Left Panel ───────────────────────────────────────────────────────────────
const LeftPanel = ({
  arcs,
  activeArcId,
  onSelectArc,
  scrollRef,
  user,
  onClaim,
}: {
  arcs: QuestArc[];
  activeArcId: string;
  onSelectArc: (id: string) => void;
  scrollRef: React.RefObject<HTMLDivElement | null>;
  user: any;
  onClaim: (n: QuestNode) => void;
}) => {
  const sortedArcs = [...arcs].sort(
    (a, b) => a.sequence_order - b.sequence_order,
  );
  const earnedXP = user?.total_xp ?? 0,
    streak = user?.streak ?? user?.current_streak ?? 0,
    level = user?.current_level ?? 1;
  const totalDone = arcs.reduce(
    (s, a) => s + a.nodes.filter((n) => n.status === "completed").length,
    0,
  );
  const totalNodes = arcs.reduce((s, a) => s + a.nodes.length, 0);
  const claimable = arcs.reduce(
    (s, a) => s + a.nodes.filter((n) => n.status === "CLAIMABLE").length,
    0,
  );

  const activeNodes: { node: QuestNode; arc: QuestArc }[] = [];
  for (const a of arcs)
    for (const n of a.nodes)
      if (n.status === "CLAIMABLE" || n.status === "ACTIVE")
        activeNodes.push({ node: n, arc: a });
  activeNodes.sort((a, b) =>
    a.node.status === "CLAIMABLE" && b.node.status !== "CLAIMABLE"
      ? -1
      : b.node.status === "CLAIMABLE" && a.node.status !== "CLAIMABLE"
        ? 1
        : 0,
  );

  const ladder = CREW_RANKS,
    N = ladder.length;
  let currentIdx = 0;
  for (let i = N - 1; i >= 0; i--)
    if (earnedXP >= ladder[i].xpRequired) {
      currentIdx = i;
      break;
    }
  const current = ladder[currentIdx],
    nextRank = ladder[currentIdx + 1] ?? null;
  const progToNext = nextRank
    ? Math.min(
        1,
        (earnedXP - current.xpRequired) /
          (nextRank.xpRequired - current.xpRequired),
      )
    : 1;
  const shipXPos = nextRank
    ? nodeX(currentIdx, N) +
      (nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progToNext
    : nodeX(currentIdx, N);
  const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W;
  const [animated, setAnimated] = useState(false);
  const ladderRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const id = requestAnimationFrame(() =>
      requestAnimationFrame(() => setAnimated(true)),
    );
    return () => cancelAnimationFrame(id);
  }, []);

  useEffect(() => {
    if (!ladderRef.current) return;
    ladderRef.current.scrollTo({
      left: Math.max(0, shipXPos - ladderRef.current.offsetWidth / 2),
      behavior: "smooth",
    });
  }, [shipXPos]);

  const rankPct = Math.round(progToNext * 100);

  return (
    <div className="qm-left-panel">
      <div>
        <div className="qmlp-page-title">🏴‍☠️ Quest Map</div>
        <div className="qmlp-page-sub">Track your voyage</div>
      </div>
      <div className="qmlp-rank-card">
        <div className="qmlp-rank-header">
          <span className="qmlp-rank-eyebrow">⚓ Crew Rank</span>
          <span className="qmlp-xp-pill">{earnedXP.toLocaleString()} XP</span>
        </div>
        <div className="qmlp-rank-name">
          {current.emoji} {current.label}
        </div>
        <div className="qmlp-rank-sub">
          {nextRank
            ? `${rankPct}% · ${(nextRank.xpRequired - earnedXP).toLocaleString()} XP to ${nextRank.label}`
            : "Maximum rank achieved ✨"}
        </div>
        <div className="qmlp-ladder-scroll" ref={ladderRef}>
          <div className="qmlp-ladder-inner" style={{ width: totalW }}>
            <div className="qmlp-ladder-baseline" />
            <div
              className="qmlp-ladder-progress"
              style={{ width: animated ? shipXPos : 20 }}
            />
            <div
              className="qmlp-ship-wrap"
              style={{ left: animated ? shipXPos : nodeX(0, N) }}
            >
              <span className="qmlp-ship">⛵</span>
              <div className="qmlp-ship-tether" />
            </div>
            {ladder.map((r, i) => {
              const state =
                i < currentIdx
                  ? "reached"
                  : i === currentIdx
                    ? "current"
                    : "locked";
              return (
                <div key={r.id} className="qmlp-ladder-col">
                  <div className={`qmlp-ladder-node ${state}`}>{r.emoji}</div>
                  <div className="qmlp-ladder-label">
                    <span className={`qmlp-ladder-label-name ${state}`}>
                      {r.label}
                    </span>
                    <span className={`qmlp-ladder-label-xp ${state}`}>
                      {r.xpRequired === 0
                        ? "Start"
                        : `${r.xpRequired.toLocaleString()} XP`}
                    </span>
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      </div>
      <div className="qmlp-stats-grid">
        <div className="qmlp-stat-tile gold">
          <span className="qmlp-stat-icon">⚡</span>
          <div className="qmlp-stat-value">{earnedXP.toLocaleString()}</div>
          <div className="qmlp-stat-label">Total XP</div>
        </div>
        <div className="qmlp-stat-tile">
          <span className="qmlp-stat-icon">🏝️</span>
          <div className="qmlp-stat-value">
            {totalDone}
            <span
              style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.28)" }}
            >
              /{totalNodes}
            </span>
          </div>
          <div className="qmlp-stat-label">Islands</div>
        </div>
        {streak > 0 ? (
          <div className="qmlp-stat-tile">
            <span className="qmlp-stat-icon">🔥</span>
            <div className="qmlp-stat-value">{streak}</div>
            <div className="qmlp-stat-label">Day Streak</div>
          </div>
        ) : (
          <div className="qmlp-stat-tile">
            <span className="qmlp-stat-icon">🎖️</span>
            <div className="qmlp-stat-value">Lv {level}</div>
            <div className="qmlp-stat-label">Level</div>
          </div>
        )}
        <div className={`qmlp-stat-tile${claimable > 0 ? " gold" : ""}`}>
          <span className="qmlp-stat-icon">📦</span>
          <div className="qmlp-stat-value">{claimable}</div>
          <div className="qmlp-stat-label">To Claim</div>
        </div>
      </div>
      {activeNodes.length > 0 && (
        <>
          <div className="qmlp-section-title">Active Quests</div>
          {activeNodes.slice(0, 5).map(({ node, arc: a }) => {
            const t = getArcTheme(a);
            const pct = Math.min(
              100,
              Math.round((node.current_value / node.req_target) * 100),
            );
            const isClaim = node.status === "CLAIMABLE";
            return (
              <div
                key={node.node_id}
                className={`qmlp-quest-card${isClaim ? " claimable" : ""}`}
                style={{ "--qc-accent": t.accent } as React.CSSProperties}
              >
                <div className="qmlp-qc-top">
                  <div className="qmlp-qc-icon">
                    {isClaim ? "📦" : (REQ_EMOJI[node.req_type] ?? "🏝️")}
                  </div>
                  <div className="qmlp-qc-body">
                    <div className="qmlp-qc-arc">{a.name}</div>
                    <div className="qmlp-qc-name">{node.name ?? "—"}</div>
                    {isClaim ? (
                      <div className="qmlp-qc-status ready">
                        ✨ Ready to claim!
                      </div>
                    ) : (
                      <div className="qmlp-qc-status progress">
                        {node.current_value}/{node.req_target}{" "}
                        {REQ_LABEL[node.req_type] ?? node.req_type}
                      </div>
                    )}
                  </div>
                </div>
                {!isClaim && (
                  <div className="qmlp-qc-prog-row">
                    <div className="qmlp-qc-track">
                      <div
                        className="qmlp-qc-fill"
                        style={{ width: `${pct}%` }}
                      />
                    </div>
                    <span className="qmlp-qc-pct">{pct}%</span>
                  </div>
                )}
                {isClaim && (
                  <button
                    className="qmlp-claim-btn"
                    onClick={() => onClaim(node)}
                  >
                    ⚓ Open Chest
                  </button>
                )}
              </div>
            );
          })}
        </>
      )}
      <div className="qmlp-section-title">All Arcs</div>
      <div className="qmlp-arc-list">
        {sortedArcs.map((a) => {
          const t = getArcTheme(a);
          const done = a.nodes.filter((n) => n.status === "completed").length;
          const pct = Math.round((done / Math.max(a.nodes.length, 1)) * 100);
          const hasClaim = a.nodes.some((n) => n.status === "CLAIMABLE");
          return (
            <div
              key={a.id}
              className={`qmlp-arc-row${a.id === activeArcId ? " active" : ""}`}
              style={{ "--arc-accent": t.accent } as React.CSSProperties}
              onClick={() => {
                onSelectArc(a.id);
                scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
              }}
            >
              <span className="qmlp-arc-emoji">{t.emoji}</span>
              <div className="qmlp-arc-info">
                <div className="qmlp-arc-name">{a.name}</div>
                <div className="qmlp-arc-track">
                  <div className="qmlp-arc-fill" style={{ width: `${pct}%` }} />
                </div>
                <div className="qmlp-arc-meta">
                  {done}/{a.nodes.length} islands · {pct}%
                </div>
              </div>
              {hasClaim && <div className="qmlp-arc-dot" />}
            </div>
          );
        })}
      </div>
    </div>
  );
};

// ─── Main ─────────────────────────────────────────────────────────────────────
export const QuestMap = () => {
  const arcs = useQuestStore((s) => s.arcs);
  const activeArcId = useQuestStore((s) => s.activeArcId);
  const setActiveArc = useQuestStore((s) => s.setActiveArc);
  const claimNode = useQuestStore((s) => s.claimNode);
  const syncFromAPI = useQuestStore((s) => s.syncFromAPI);
  const user = useAuthStore((s) => s.user);
  const token = useAuthStore((s) => s.token);

  const [loading, setLoading] = useState(true);
  const [fetchError, setFetchError] = useState<string | null>(null);
  const [claimingNode, setClaimingNode] = useState<QuestNode | null>(null);
  const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
    null,
  );

  const [claimError, setClaimError] = useState<string | null>(null);
  const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null);

  const mobileScrollRef = useRef<HTMLDivElement>(null);
  const desktopScrollRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (arcs.length > 0 && !activeArcId) setActiveArc(arcs[0].id);
  }, [arcs, activeArcId, setActiveArc]);

  useEffect(() => {
    if (!token) return;
    let cancelled = false;
    (async () => {
      try {
        setLoading(true);
        setFetchError(null);
        const data = await api.fetchUserJourney(token);
        if (!cancelled) syncFromAPI(data);
      } catch (err) {
        if (!cancelled)
          setFetchError(
            err instanceof Error ? err.message : "Failed to load quests",
          );
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [token, syncFromAPI]);

  const handleClaim = useCallback(
    async (node: QuestNode) => {
      if (!token) return;
      setClaimingNode(node);
      setClaimResult(null);
      setClaimError(null);

      try {
        const result = await api.claimReward(token, node.node_id);
        setClaimResult(result);
      } catch (err) {
        setClaimError(err instanceof Error ? err.message : "Claim failed");
      }
    },
    [token],
  );

  const handleNodeTap = useCallback((node: QuestNode) => {
    setSelectedNode(node);
  }, []);

  const arc = arcs.find((a) => a.id === activeArcId) ?? arcs[0];

  const handleChestClose = useCallback(() => {
    if (!claimingNode || !arc) return;
    const titles = Array.isArray(claimResult?.title_unlocked)
      ? claimResult!.title_unlocked
      : claimResult?.title_unlocked
        ? [claimResult.title_unlocked]
        : [];
    claimNode(
      arc.id,
      claimingNode.node_id,
      claimResult?.xp_awarded ?? 0,
      titles.map((t: any) => t.name),
    );
    setClaimingNode(null);
    setClaimResult(null);
    setClaimError(null);
  }, [claimingNode, claimResult, arc, claimNode]);

  // ── Early returns ─────────────────────────────────────────────────────────
  if (loading) {
    return (
      <div
        className="qm-screen"
        style={{ alignItems: "center", justifyContent: "center" }}
      >
        <style>{STYLES}</style>
        <div
          style={{
            textAlign: "center",
            color: "rgba(255,255,255,0.5)",
            fontFamily: "'Nunito',sans-serif",
          }}
        >
          <div
            style={{
              fontSize: "2.5rem",
              marginBottom: "1rem",
              animation: "qmFabFloat 2s ease-in-out infinite",
            }}
          >
            ⛵
          </div>
          <p
            style={{
              fontSize: "0.85rem",
              fontWeight: 800,
              letterSpacing: "0.1em",
            }}
          >
            CHARTING YOUR COURSE...
          </p>
        </div>
      </div>
    );
  }

  if (fetchError || !arc) {
    return (
      <div
        className="qm-screen"
        style={{
          alignItems: "center",
          justifyContent: "center",
          overflow: "hidden",
        }}
      >
        <style>
          {STYLES}
          {ERROR_STYLES}
        </style>

        {/* Deep-sea atmospheric background */}
        <div className="qme-bg" />
        <div className="qme-fog qme-fog1" />
        <div className="qme-fog qme-fog2" />

        {/* Floating wreckage debris */}
        {[...Array(8)].map((_, i) => (
          <div key={i} className={`qme-debris qme-debris-${i}`} />
        ))}

        <div className="qme-stage">
          {/* ── Shipwreck SVG illustration ── */}
          <div className="qme-ship-wrap">
            <svg
              className="qme-ship-svg"
              viewBox="0 0 240 180"
              fill="none"
              xmlns="http://www.w3.org/2000/svg"
            >
              {/* Ocean surface rings behind the wreck */}
              <ellipse
                cx="120"
                cy="145"
                rx="100"
                ry="18"
                fill="rgba(3,4,94,0.6)"
              />
              <path
                d="M20 145 Q45 132 70 145 Q95 158 120 145 Q145 132 170 145 Q195 158 220 145"
                stroke="#0077b6"
                strokeWidth="2"
                fill="none"
                opacity="0.5"
                strokeDasharray="6 4"
              >
                <animate
                  attributeName="stroke-dashoffset"
                  values="0;20"
                  dur="2s"
                  repeatCount="indefinite"
                />
              </path>
              <path
                d="M10 155 Q40 143 68 155 Q96 167 124 155 Q152 143 180 155 Q208 167 230 155"
                stroke="#0096c7"
                strokeWidth="1.5"
                fill="none"
                opacity="0.35"
                strokeDasharray="5 5"
              >
                <animate
                  attributeName="stroke-dashoffset"
                  values="20;0"
                  dur="2.6s"
                  repeatCount="indefinite"
                />
              </path>

              {/* Broken mast — leaning left */}
              <g className="qme-mast">
                <line
                  x1="130"
                  y1="148"
                  x2="88"
                  y2="62"
                  stroke="#6b4226"
                  strokeWidth="5"
                  strokeLinecap="round"
                />
                <line
                  x1="88"
                  y1="62"
                  x2="52"
                  y2="44"
                  stroke="#6b4226"
                  strokeWidth="3.5"
                  strokeLinecap="round"
                  opacity="0.7"
                />
                {/* Shredded sail */}
                <path
                  d="M88 62 Q74 75 64 92 Q78 88 94 78 Z"
                  fill="rgba(200,160,80,0.35)"
                  stroke="rgba(200,160,80,0.5)"
                  strokeWidth="1"
                />
                <path
                  d="M88 62 Q98 72 104 84 Q96 80 88 62 Z"
                  fill="rgba(200,160,80,0.2)"
                  stroke="rgba(200,160,80,0.3)"
                  strokeWidth="1"
                />
                {/* Torn pirate flag */}
                <path d="M52 44 L72 51 L58 61 Z" fill="rgba(239,68,68,0.7)" />
                <path
                  d="M58 61 Q65 57 72 51"
                  stroke="rgba(239,68,68,0.5)"
                  strokeWidth="1"
                  fill="none"
                  strokeDasharray="3 2"
                >
                  <animate
                    attributeName="stroke-dashoffset"
                    values="0;10"
                    dur="1.2s"
                    repeatCount="indefinite"
                  />
                </path>
              </g>

              {/* Hull — cracked, half-submerged */}
              <g className="qme-hull">
                <path
                  d="M58 130 Q72 110 100 108 Q128 106 152 112 Q172 116 174 130 Q160 148 120 152 Q80 156 58 130 Z"
                  fill="#3d1c0c"
                  stroke="#6b4226"
                  strokeWidth="2"
                />
                {/* Crack lines */}
                <path
                  d="M100 108 L96 128 L104 140"
                  stroke="rgba(0,0,0,0.6)"
                  strokeWidth="1.5"
                  strokeLinecap="round"
                  fill="none"
                />
                <path
                  d="M138 110 L142 132"
                  stroke="rgba(0,0,0,0.5)"
                  strokeWidth="1"
                  strokeLinecap="round"
                  fill="none"
                />
                {/* Porthole */}
                <circle
                  cx="118"
                  cy="128"
                  r="7"
                  fill="#1a0900"
                  stroke="#6b4226"
                  strokeWidth="1.5"
                />
                <circle
                  cx="118"
                  cy="128"
                  r="4"
                  fill="rgba(251,191,36,0.06)"
                  stroke="rgba(251,191,36,0.15)"
                  strokeWidth="1"
                />
                {/* Water-line sheen */}
                <path
                  d="M70 140 Q95 133 120 135 Q145 133 168 138"
                  stroke="rgba(0,180,216,0.3)"
                  strokeWidth="1"
                  fill="none"
                />
              </g>

              {/* Barnacles */}
              <circle cx="85" cy="133" r="2" fill="rgba(100,200,100,0.25)" />
              <circle cx="92" cy="140" r="1.5" fill="rgba(100,200,100,0.2)" />
              <circle cx="155" cy="130" r="2" fill="rgba(100,200,100,0.25)" />

              {/* Floating planks */}
              <rect
                x="40"
                y="138"
                width="14"
                height="5"
                rx="2"
                fill="#4a2c10"
                opacity="0.8"
              >
                <animateTransform
                  attributeName="transform"
                  type="translate"
                  values="0,0;0,-3;0,0"
                  dur="3.2s"
                  repeatCount="indefinite"
                />
              </rect>
              <rect
                x="185"
                y="141"
                width="10"
                height="4"
                rx="1.5"
                fill="#4a2c10"
                opacity="0.6"
              >
                <animateTransform
                  attributeName="transform"
                  type="translate"
                  values="0,0;0,-2;0,0"
                  dur="2.7s"
                  begin="0.5s"
                  repeatCount="indefinite"
                />
              </rect>

              {/* Half-submerged treasure chest */}
              <g opacity="0.9">
                <rect
                  x="106"
                  y="148"
                  width="28"
                  height="18"
                  rx="3"
                  fill="#5c3d1a"
                  stroke="#fbbf24"
                  strokeWidth="1.2"
                />
                <rect
                  x="106"
                  y="148"
                  width="28"
                  height="8"
                  rx="3"
                  fill="#7a5228"
                  stroke="#fbbf24"
                  strokeWidth="1.2"
                />
                <rect
                  x="117"
                  y="153"
                  width="6"
                  height="5"
                  rx="1"
                  fill="#fbbf24"
                  opacity="0.8"
                />
                <circle cx="120" cy="152" r="1" fill="white" opacity="0.7">
                  <animate
                    attributeName="opacity"
                    values="0.7;0.1;0.7"
                    dur="2s"
                    repeatCount="indefinite"
                  />
                </circle>
              </g>

              {/* Rising bubbles */}
              <circle
                cx="108"
                cy="130"
                r="2"
                fill="none"
                stroke="rgba(0,180,216,0.5)"
                strokeWidth="1"
              >
                <animate
                  attributeName="cy"
                  values="130;90"
                  dur="3s"
                  repeatCount="indefinite"
                  begin="0s"
                />
                <animate
                  attributeName="opacity"
                  values="0.6;0"
                  dur="3s"
                  repeatCount="indefinite"
                  begin="0s"
                />
              </circle>
              <circle
                cx="135"
                cy="125"
                r="1.5"
                fill="none"
                stroke="rgba(0,180,216,0.4)"
                strokeWidth="1"
              >
                <animate
                  attributeName="cy"
                  values="125;85"
                  dur="2.5s"
                  repeatCount="indefinite"
                  begin="0.8s"
                />
                <animate
                  attributeName="opacity"
                  values="0.5;0"
                  dur="2.5s"
                  repeatCount="indefinite"
                  begin="0.8s"
                />
              </circle>
              <circle
                cx="122"
                cy="132"
                r="1"
                fill="none"
                stroke="rgba(0,180,216,0.35)"
                strokeWidth="0.8"
              >
                <animate
                  attributeName="cy"
                  values="132;100"
                  dur="2.1s"
                  repeatCount="indefinite"
                  begin="1.4s"
                />
                <animate
                  attributeName="opacity"
                  values="0.4;0"
                  dur="2.1s"
                  repeatCount="indefinite"
                  begin="1.4s"
                />
              </circle>
            </svg>
          </div>

          {/* Copy & actions */}
          <div className="qme-content">
            <div className="qme-eyebrow">⚓ SHIPWRECKED</div>
            <h1 className="qme-title">Lost at Sea</h1>
            <p className="qme-subtitle">
              {fetchError
                ? "The charts couldn't be retrieved from the depths."
                : "Your quest map has sunk without a trace."}
            </p>
            <div className="qme-error-badge">
              <span className="qme-error-dot" />
              <span className="qme-error-text">
                {fetchError ?? "No quest data found"}
              </span>
            </div>
            <button
              className="qme-retry-btn"
              onClick={() => window.location.reload()}
            >
              <span className="qme-btn-wake" />
              <span className="qme-btn-label">⛵ Set Sail Again</span>
            </button>
            <p className="qme-hint">
              Your progress and treasures are safely stored in Davy Jones'
              vault.
            </p>
          </div>
        </div>
      </div>
    );
  }

  const theme = getArcTheme(arc);
  const sorted = [...arc.nodes].sort(
    (a, b) => a.sequence_order - b.sequence_order,
  );

  // ── Generate random positions (deterministic per arc) ─────────────────────
  const positions = generateIslandPositions(sorted.length, arc.id);

  const sortedArcs = [...arcs].sort(
    (a, b) => a.sequence_order - b.sequence_order,
  );

  // Focus camera on first active/claimable island
  const focusIdx = (() => {
    const i = sorted.findIndex(
      (n) => n.status === "ACTIVE" || n.status === "CLAIMABLE",
    );
    return i >= 0 ? i : 0;
  })();
  const nodeCount = sorted.length;
  const focusX3 = ((positions[focusIdx]?.x ?? VW / 2) / VW) * 9 - 4.5;
  const focusZ3 = (focusIdx / Math.max(nodeCount - 1, 1)) * (nodeCount * 2.4);
  const initialTarget: [number, number, number] = [focusX3, 0, focusZ3];

  const modalOpen = !!(selectedNode || claimingNode);

  return (
    <div className="qm-screen">
      <style>{STYLES}</style>

      {/* ══ MOBILE ══ */}
      <div className="qm-header">
        <InfoHeader mode="QUEST_EXTENDED" />
        <div className="qm-arc-tabs">
          {sortedArcs.map((a) => {
            const t = getArcTheme(a);
            return (
              <button
                key={a.id}
                className={`qm-arc-tab${activeArcId === a.id ? " active" : ""}`}
                style={{ "--arc-accent": t.accent } as React.CSSProperties}
                onClick={() => {
                  setActiveArc(a.id);
                  mobileScrollRef.current?.scrollTo({
                    top: 0,
                    behavior: "smooth",
                  });
                }}
              >
                {t.emoji} {a.name}
                {a.nodes.some((n) => n.status === "CLAIMABLE") && (
                  <span className="qm-tab-dot" />
                )}
              </button>
            );
          })}
        </div>
      </div>

      <div className="qm-sea-scroll qm-mobile-sea" ref={mobileScrollRef}>
        <MapContent
          arc={arc}
          theme={theme}
          positions={positions}
          onNodeTap={handleNodeTap}
          onClaim={handleClaim}
          initialTarget={initialTarget}
          modalOpen={modalOpen}
        />
      </div>

      <div
        className="qm-fab"
        onClick={() =>
          mobileScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" })
        }
      >
        🏴‍☠️
      </div>

      {/* ══ DESKTOP ══ */}
      <LeftPanel
        arcs={arcs}
        activeArcId={activeArcId ?? arc.id}
        onSelectArc={setActiveArc}
        scrollRef={desktopScrollRef}
        user={user}
        onClaim={handleClaim}
      />
      <div className="qm-right-panel">
        <div className="qm-desktop-arc-tabs">
          {sortedArcs.map((a) => {
            const t = getArcTheme(a);
            return (
              <button
                key={a.id}
                className={`qm-desktop-arc-tab${activeArcId === a.id ? " active" : ""}`}
                style={{ "--arc-accent": t.accent } as React.CSSProperties}
                onClick={() => {
                  setActiveArc(a.id);
                  desktopScrollRef.current?.scrollTo({
                    top: 0,
                    behavior: "smooth",
                  });
                }}
              >
                {t.emoji} {a.name}
                {a.nodes.some((n) => n.status === "CLAIMABLE") && (
                  <span className="qm-desktop-tab-dot" />
                )}
              </button>
            );
          })}
        </div>
        <div className="qm-right-sea-scroll" ref={desktopScrollRef}>
          <MapContent
            arc={arc}
            theme={theme}
            positions={positions}
            onNodeTap={handleNodeTap}
            onClaim={handleClaim}
            initialTarget={initialTarget}
            modalOpen={modalOpen}
          />
        </div>
      </div>

      {selectedNode && (
        <QuestNodeModal
          node={selectedNode}
          arc={arc}
          arcAccent={theme.accent}
          arcDark={theme.accentDark}
          arcId={arc.id}
          nodeIndex={selectedNode.sequence_order}
          onClose={() => setSelectedNode(null)}
          onClaim={() => {
            setSelectedNode(null);
            handleClaim(selectedNode);
          }}
        />
      )}

      {claimingNode && (
        <ChestOpenModal
          node={claimingNode}
          claimResult={claimResult}
          onClose={handleChestClose}
        />
      )}

      {claimError && (
        <div
          style={{
            position: "fixed",
            bottom: "calc(2rem + env(safe-area-inset-bottom))",
            left: "50%",
            transform: "translateX(-50%)",
            zIndex: 100,
            background: "#7f1d1d",
            border: "1px solid #ef4444",
            borderRadius: "12px",
            padding: "0.6rem 1.1rem",
            color: "white",
            fontFamily: "'Nunito',sans-serif",
            fontSize: "0.78rem",
            fontWeight: 800,
            boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
            whiteSpace: "nowrap",
          }}
        >
          ⚠️ {claimError} — your progress is saved
        </div>
      )}
    </div>
  );
};
