// screens-extra.jsx — Rating modal + Leaderboard + Smart "Go To" screens.
// Loaded after screens-detail.jsx, screens-home.jsx, and user-actions.jsx so
// it can wire into PH365_ACTIONS and the global PH365 dataset.
(function () {
const { COLORS, waitColor, Card, Caption, Display, Body, Stamp, StampPill,
  PerforatedDivider, Icon, BarProgress, PAPER_NOISE } = window.PH365_UI;

// ─────────────────────────────────────────────────────────────
// RATING & COMMENT MODAL
// ─────────────────────────────────────────────────────────────
// Open via:  window.PH365_ACTIONS.openRatingModal({ kind, ...payload })
//   kind = 'attraction'  → payload: { attractionId, name, defaultStars, defaultText }
//   kind = 'checklist'   → payload: { checklistId, templateId, name, category, landId, verb, defaultStars, defaultText }
// The modal hosts itself globally — App renders <RatingModalHost /> once.
function RatingModalHost() {
  const [open, setOpen]       = React.useState(false);
  const [payload, setPayload] = React.useState(null);
  const [stars, setStars]     = React.useState(7);
  const [text, setText]       = React.useState('');
  const [pub, setPub]         = React.useState(true);
  const [busy, setBusy]       = React.useState(false);
  const [err,  setErr]        = React.useState(null);

  React.useEffect(() => {
    function handler(e) {
      const p = e.detail || {};
      setPayload(p);
      setStars(p.defaultStars ?? 7);
      setText(p.defaultText ?? '');
      setPub(p.defaultPublic !== false);
      setErr(null);
      setOpen(true);
    }
    window.addEventListener('ph365-open-rating', handler);
    return () => window.removeEventListener('ph365-open-rating', handler);
  }, []);

  // Broadcast open/close so anything that needs to defer (e.g. the
  // achievement-unlock toast) can wait until the modal is dismissed.
  React.useEffect(() => {
    if (!open) return;
    window.dispatchEvent(new CustomEvent('ph365-modal-open', { detail: { kind: 'rating' } }));
    return () => {
      window.dispatchEvent(new CustomEvent('ph365-modal-close', { detail: { kind: 'rating' } }));
    };
  }, [open]);

  if (!open || !payload) return null;

  const close = () => { setOpen(false); setPayload(null); };

  async function save() {
    if (busy) return;
    setBusy(true); setErr(null);
    try {
      const A = window.PH365_ACTIONS || {};
      if (payload.kind === 'attraction') {
        await A.rateAttraction?.({
          attractionId: payload.attractionId,
          stars, reviewText: text.trim() || null, isPublic: pub,
        });
      } else {
        await A.rateChecklistItem?.({
          checklistId: payload.checklistId,
          templateId:  payload.templateId,
          name:        payload.name,
          category:    payload.category,
          landId:      payload.landId,
          verb:        payload.verb,
          stars, reviewText: text.trim() || null, isPublic: pub,
        });
      }
      close();
    } catch (e) {
      setErr(e?.message || 'Could not save your rating.');
    } finally {
      setBusy(false);
    }
  }

  const accent = stars >= 8 ? COLORS.moss : stars >= 5 ? COLORS.mustard : COLORS.clay;
  const subtitle = payload.kind === 'attraction'
    ? 'Rate this attraction'
    : payload.category === 'Eat'  ? 'Rate this bite'
    : payload.category === 'Show' ? 'Rate this show'
    : 'Rate this experience';

  return (
    <div onClick={close} style={{
      position: 'absolute', inset: 0, zIndex: 250,
      background: 'rgba(26,37,102,0.55)',
      display: 'flex', alignItems: 'flex-end',
      animation: 'fadeIn 0.18s ease-out',
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        width: '100%',
        background: COLORS.paper,
        borderRadius: '18px 18px 0 0',
        // Last 28px has to clear the home indicator, not stop under it.
        padding: '18px 20px calc(28px + var(--safe-b))',
        backgroundImage: `url("${PAPER_NOISE}")`,
        backgroundSize: '220px 220px',
        animation: 'slideUp 0.24s cubic-bezier(.2,.7,.3,1)',
        maxHeight: '88%',
        overflowY: 'auto',
      }}>
        <div style={{
          width: 36, height: 4, borderRadius: 2, background: COLORS.paperEdge,
          margin: '0 auto 14px',
        }} />

        <Caption color={COLORS.teal} style={{ marginBottom: 4 }}>{subtitle}</Caption>
        <Display size={22} style={{ marginBottom: 14, lineHeight: 1.15 }}>
          {payload.name || 'Your visit'}
        </Display>

        <StarSlider value={stars} onChange={setStars} accent={accent} />

        <textarea
          value={text}
          onChange={e => setText(e.target.value)}
          placeholder="Add a note — what stood out? (optional)"
          rows={3}
          maxLength={500}
          style={{
            width: '100%',
            marginTop: 14,
            padding: '10px 12px',
            border: `1px solid ${COLORS.paperEdge}`,
            borderRadius: 10,
            background: COLORS.paperDeep,
            fontFamily: 'Manrope, system-ui, sans-serif',
            fontSize: 14,
            color: COLORS.ink,
            resize: 'none',
            outline: 'none',
            boxSizing: 'border-box',
          }}
        />

        <label style={{
          display: 'flex', alignItems: 'center', gap: 8, marginTop: 12,
          cursor: 'pointer',
        }}>
          <input
            type="checkbox"
            checked={pub}
            onChange={e => setPub(e.target.checked)}
            style={{ accentColor: COLORS.teal, width: 16, height: 16 }}
          />
          <Caption style={{ letterSpacing: '0.06em' }}>
            Share publicly on the leaderboard
          </Caption>
        </label>

        {err && (
          <Body size={12} color={COLORS.clay} style={{ marginTop: 10 }}>{err}</Body>
        )}

        <div style={{ display: 'flex', gap: 8, marginTop: 18 }}>
          <button onClick={close} disabled={busy} style={{
            flex: 1,
            background: 'transparent',
            color: COLORS.inkSoft,
            border: `1px solid ${COLORS.paperEdge}`,
            borderRadius: 10,
            padding: '12px 14px',
            fontFamily: 'Manrope', fontSize: 14, fontWeight: 600,
            cursor: 'pointer',
          }}>Skip</button>
          <button onClick={save} disabled={busy} style={{
            flex: 2,
            background: COLORS.teal,
            color: COLORS.paper,
            border: 'none',
            borderRadius: 10,
            padding: '12px 14px',
            fontFamily: 'Manrope', fontSize: 14, fontWeight: 700,
            cursor: busy ? 'default' : 'pointer',
            opacity: busy ? 0.7 : 1,
            boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.18)',
          }}>{busy ? 'Saving…' : 'Save rating'}</button>
        </div>
      </div>
    </div>
  );
}

function StarSlider({ value, onChange, accent }) {
  // 11 segments (0..10). Tap or drag to set. Always shows the label below.
  const trackRef = React.useRef(null);

  function setFromClientX(clientX) {
    const el = trackRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const ratio = Math.max(0, Math.min(1, (clientX - r.left) / r.width));
    onChange(Math.round(ratio * 10));
  }

  function onPointerDown(e) {
    e.currentTarget.setPointerCapture?.(e.pointerId);
    setFromClientX(e.clientX);
  }
  function onPointerMove(e) {
    if (e.buttons !== 1) return;
    setFromClientX(e.clientX);
  }

  return (
    <div>
      <div style={{
        display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8,
      }}>
        <Stamp value={value} unit={`/ 10`} size={42} color={accent} />
        <Caption color={COLORS.inkSoft} style={{ marginLeft: 'auto' }}>
          {value === 10 ? 'Top tier' : value >= 8 ? 'Loved it' : value >= 5 ? 'Solid' : value >= 2 ? 'Meh' : value === 0 ? 'Skip it' : 'Rough'}
        </Caption>
      </div>

      <div
        ref={trackRef}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        style={{
          padding: '8px 0',
          touchAction: 'none',
          cursor: 'pointer',
        }}
      >
        <div style={{ display: 'flex', gap: 4 }}>
          {Array.from({ length: 11 }).map((_, i) => {
            const filled = i <= value;
            return (
              <div key={i} style={{
                flex: 1,
                height: 22,
                borderRadius: 4,
                background: filled ? accent : 'transparent',
                border: `1.5px solid ${filled ? accent : COLORS.paperEdge}`,
                transition: 'background 0.1s, border-color 0.1s',
              }} />
            );
          })}
        </div>
        <div style={{
          display: 'flex', justifyContent: 'space-between', marginTop: 6,
          fontFamily: '"JetBrains Mono"', fontSize: 10, color: COLORS.inkSoft,
        }}>
          <span>0</span><span>5</span><span>10</span>
        </div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// LEADERBOARD
// ─────────────────────────────────────────────────────────────
const LB_FILTERS = [
  { id: 'all',    label: 'All'    },
  { id: 'Ride',   label: 'Rides'  },
  { id: 'Dining', label: 'Dining' },
  { id: 'Show',   label: 'Shows'  },
];

const LB_PARK_FILTERS = [
  { id: 'all', label: 'All'        },
  { id: 'DLR', label: 'Disneyland' },
  { id: 'DCA', label: 'DCA'        },
];

function useLeaderboard() {
  const [items, setItems]     = React.useState(null);
  const [error, setError]     = React.useState(null);
  const [reload, setReload]   = React.useState(0);

  React.useEffect(() => {
    let cancelled = false;
    async function load() {
      try {
        const list = await fetchLeaderboard();
        if (!cancelled) { setItems(list); setError(null); }
      } catch (e) {
        if (!cancelled) { setItems([]); setError(e.message || 'Could not load.'); }
      }
    }
    load();
    return () => { cancelled = true; };
  }, [reload]);

  return { items, error, refresh: () => setReload(r => r + 1) };
}

async function fetchLeaderboard() {
  // Always seed every attraction with its editorial baseline so newly opened
  // rides aren't invisible until somebody rates them. User reviews then blend
  // with the baseline (same formula GoToScreen uses for its quality score).
  const ATTRACTIONS = window.PH365.ATTRACTIONS || [];
  const baseRows = ATTRACTIONS.map(a => {
    const baseline = baselineFor(a);
    return {
      item_id:        'attraction:' + a.id,
      source_id:      a.id,
      source:         'attraction',
      name:           a.name,
      land_id:        a.land,
      park:           a.park,
      category:       a.type === 'Show' ? 'Show' : 'Ride',
      baseline_score: baseline,
      avg_user_rating: null,
      rating_count:   0,
      avg_stars:      baseline,  // displayed score; replaced when reviews exist
    };
  });
  const byAttrId = new Map(baseRows.map(r => [r.source_id, r]));

  const sb = window.PH365_SUPABASE;
  if (!sb) return mergeLocalRatings(baseRows);

  // Preferred path — uses the leaderboard_items view (migration 20260502).
  // It also surfaces checklist items (Dining/Show categories) which have no
  // editorial baseline and so are only included when they have reviews.
  const lb = await sb.from('leaderboard_items').select('*');
  if (!lb.error) {
    const extra = [];
    for (const r of (lb.data || [])) {
      if (r.source === 'attraction') {
        applyAttractionRating(byAttrId.get(r.source_id), r.avg_stars, r.rating_count);
      } else if (r.avg_stars != null && Number(r.rating_count) > 0) {
        extra.push({
          ...r,
          baseline_score:  null,
          avg_user_rating: r.avg_stars,
        });
      }
    }
    return baseRows.concat(extra);
  }

  // View missing (migration not pushed yet) — fall back to the raw rating
  // summary on attractions only.
  console.warn('[leaderboard] view missing, falling back to attraction_rating_summary:', lb.error?.message);
  const sum = await sb.from('attraction_rating_summary').select('*');
  if (sum.error) {
    console.warn('[leaderboard] summary view missing too — baseline only:', sum.error?.message);
    return mergeLocalRatings(baseRows);
  }
  for (const s of (sum.data || [])) {
    applyAttractionRating(byAttrId.get(s.attraction_id), s.avg_stars, s.rating_count);
  }
  return baseRows;
}

function applyAttractionRating(row, avgStars, ratingCount) {
  if (!row) return;
  const count = Number(ratingCount) || 0;
  const avgUser = avgStars != null ? Number(avgStars) : null;
  if (count > 0 && avgUser != null) {
    row.avg_user_rating = avgUser;
    row.rating_count    = count;
    row.avg_stars       = (row.baseline_score + avgUser) / 2;
  }
}

// Fallback when Supabase isn't configured: blend any localStorage ratings the
// prototype has accrued into the baseline rows.
function mergeLocalRatings(baseRows) {
  let local = {};
  try { local = JSON.parse(localStorage.getItem('ph365_local_ratings') || '{}'); } catch {}
  for (const row of baseRows) {
    const r = local[row.source_id];
    if (r && typeof r.stars === 'number') {
      applyAttractionRating(row, r.stars, 1);
    }
  }
  return baseRows;
}

function LeaderboardScreen({ onSelectItem }) {
  const [filter, setFilter] = React.useState('all');
  const [parkFilter, setParkFilter] = React.useState('all');
  const { items, error, refresh } = useLeaderboard();
  const LANDS = window.PH365.LANDS || {};

  let visible = items || [];
  if (filter !== 'all') visible = visible.filter(i => i.category === filter);
  if (parkFilter !== 'all') {
    visible = visible.filter(i => {
      const park = i.park || LANDS[i.land_id]?.park;
      return park === parkFilter;
    });
  }
  visible = visible
    .filter(i => i.avg_stars != null)
    .sort((a, b) => {
      const ax = Number(a.avg_stars), bx = Number(b.avg_stars);
      if (bx !== ax) return bx - ax;
      // Tie-break: more reviews wins, then alphabetical so the order's stable.
      const ca = a.rating_count || 0, cb = b.rating_count || 0;
      if (cb !== ca) return cb - ca;
      return (a.name || '').localeCompare(b.name || '');
    });

  return (
    <div style={{ paddingBottom: 100 }}>
      <div style={{ padding: '14px 16px 4px' }}>
        <div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
          <Display size={26} style={{ flex: 1 }}>Leaderboard</Display>
          <button onClick={refresh} title="Refresh" style={{
            background: 'transparent',
            border: `1px solid ${COLORS.paperEdge}`,
            borderRadius: 8,
            padding: '6px 8px',
            cursor: 'pointer',
            color: COLORS.inkSoft,
            display: 'flex', alignItems: 'center',
          }}>
            <Icon name="refresh" size={14} color={COLORS.inkSoft} />
          </button>
        </div>
        <Caption style={{ marginTop: 6 }}>Editorial baseline · blended with community ratings</Caption>
      </div>

      <div style={{
        display: 'flex', gap: 6, overflowX: 'auto',
        padding: '12px 16px 4px', scrollbarWidth: 'none',
      }}>
        {LB_PARK_FILTERS.map(f => (
          <button key={f.id} onClick={() => setParkFilter(f.id)} style={{
            flexShrink: 0, padding: '6px 12px', borderRadius: 100,
            background: parkFilter === f.id ? COLORS.ink : 'transparent',
            color: parkFilter === f.id ? COLORS.paper : COLORS.ink,
            border: `1px solid ${parkFilter === f.id ? COLORS.ink : COLORS.paperEdge}`,
            fontFamily: 'Manrope', fontSize: 12, fontWeight: 600, cursor: 'pointer',
          }}>{f.label}</button>
        ))}
      </div>

      <div style={{
        display: 'flex', gap: 6, overflowX: 'auto',
        padding: '4px 16px 8px', scrollbarWidth: 'none',
      }}>
        {LB_FILTERS.map(f => (
          <button key={f.id} onClick={() => setFilter(f.id)} style={{
            flexShrink: 0, padding: '6px 12px', borderRadius: 100,
            background: filter === f.id ? COLORS.ink : 'transparent',
            color: filter === f.id ? COLORS.paper : COLORS.ink,
            border: `1px solid ${filter === f.id ? COLORS.ink : COLORS.paperEdge}`,
            fontFamily: 'Manrope', fontSize: 12, fontWeight: 600, cursor: 'pointer',
          }}>{f.label}</button>
        ))}
      </div>

      {items === null && (
        <div style={{ padding: '40px 16px', textAlign: 'center' }}>
          <Caption>Loading leaderboard…</Caption>
        </div>
      )}

      {error && (
        <div style={{ padding: '16px' }}>
          <Card style={{ padding: 14 }}>
            <Caption color={COLORS.clay}>Couldn't load leaderboard</Caption>
            <Body size={12} color={COLORS.inkSoft} style={{ marginTop: 4 }}>{error}</Body>
          </Card>
        </div>
      )}

      {items !== null && visible.length === 0 && !error && (
        <div style={{ padding: '24px 16px' }}>
          <Card style={{ padding: 16, textAlign: 'center' }}>
            <Caption>Nothing in this category</Caption>
            <Body size={13} color={COLORS.inkSoft} style={{ marginTop: 6 }}>
              Try a different filter, or rate something to seed it on the board.
            </Body>
          </Card>
        </div>
      )}

      <div style={{ padding: '8px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
        {visible.map((it, i) => (
          <LeaderboardRow key={it.item_id} rank={i + 1} item={it} onClick={() => onSelectItem(it)} />
        ))}
      </div>
    </div>
  );
}

function LeaderboardRow({ rank, item, onClick }) {
  const LANDS = window.PH365.LANDS || {};
  const land  = LANDS[item.land_id];
  const score = Number(item.avg_stars).toFixed(1);
  const accent = score >= 8 ? COLORS.moss : score >= 5 ? COLORS.mustard : COLORS.clay;
  const rankColor = rank === 1 ? COLORS.mustard
                  : rank === 2 ? COLORS.teal
                  : rank === 3 ? COLORS.ribbon
                  : COLORS.inkSoft;

  return (
    <Card onClick={onClick} style={{ padding: '12px 14px' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
        <div style={{
          width: 32, height: 32, borderRadius: '50%',
          border: `1.5px solid ${rankColor}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontFamily: '"JetBrains Mono"', fontWeight: 700, fontSize: 13,
          color: rankColor, flexShrink: 0,
        }}>{rank}</div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <Body size={14} weight={600} style={{
            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          }}>{item.name}</Body>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 3 }}>
            <Caption>{item.category}</Caption>
            {land && <span style={{ color: COLORS.paperEdge, fontSize: 10 }}>·</span>}
            {land && <Caption>{land.name}</Caption>}
          </div>
        </div>
        <div style={{ textAlign: 'right', flexShrink: 0 }}>
          <Stamp value={score} unit="/10" size={22} color={accent} />
          <Caption style={{ marginTop: 2, fontSize: 9 }}>
            {item.rating_count > 0
              ? `${item.rating_count} ${item.rating_count === 1 ? 'review' : 'reviews'}`
              : 'baseline'}
          </Caption>
        </div>
      </div>
    </Card>
  );
}

// ── Detail: all reviews for one leaderboard item ──────────────────────────
function LeaderboardDetailScreen({ item, onBack }) {
  const [reviews, setReviews] = React.useState(null);
  const [error,   setError]   = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;
    async function load() {
      try {
        const list = await fetchReviewsFor(item);
        if (!cancelled) { setReviews(list); setError(null); }
      } catch (e) {
        if (!cancelled) { setReviews([]); setError(e.message || 'Could not load.'); }
      }
    }
    load();
    return () => { cancelled = true; };
  }, [item.item_id]);

  const score = item.avg_stars != null ? Number(item.avg_stars).toFixed(1) : '—';
  const accent = score === '—' ? COLORS.inkSoft : score >= 8 ? COLORS.moss : score >= 5 ? COLORS.mustard : COLORS.clay;
  const LANDS = window.PH365.LANDS || {};
  const land = LANDS[item.land_id];

  return (
    <div style={{ paddingBottom: 100 }}>
      <div style={{ padding: '8px 16px 18px', borderBottom: `1px dashed ${COLORS.paperEdge}` }}>
        <button onClick={onBack} style={{
          background: 'transparent', border: 'none', padding: 0, cursor: 'pointer',
          display: 'flex', alignItems: 'center', gap: 4, marginBottom: 14,
          fontFamily: 'Manrope, sans-serif', fontSize: 13, color: COLORS.inkSoft,
        }}>
          <Icon name="chevron" size={14} color={COLORS.inkSoft} />
          <span style={{ transform: 'scaleX(-1)' }}> </span>
          Back to leaderboard
        </button>
        <Caption style={{ marginBottom: 6 }}>
          {item.category}{land ? ` · ${land.name}` : ''}
        </Caption>
        <Display size={26} style={{ marginBottom: 10 }}>{item.name}</Display>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
          <Stamp value={score} unit="/10" size={48} color={accent} />
          <Caption>
            {item.rating_count > 0
              ? `blended with ${item.rating_count} ${item.rating_count === 1 ? 'review' : 'reviews'}`
              : 'editorial baseline · no reviews yet'}
          </Caption>
        </div>
      </div>

      <div style={{ padding: '14px 16px' }}>
        <PerforatedDivider label={`What ${item.rating_count || 'people'} are saying`} />

        {reviews === null && (
          <div style={{ padding: '18px 0', textAlign: 'center' }}>
            <Caption>Loading reviews…</Caption>
          </div>
        )}

        {error && (
          <Card style={{ padding: 14, marginTop: 10 }}>
            <Caption color={COLORS.clay}>Couldn't load reviews</Caption>
            <Body size={12} color={COLORS.inkSoft} style={{ marginTop: 4 }}>{error}</Body>
          </Card>
        )}

        {reviews && reviews.length === 0 && !error && (
          <Card style={{ padding: 14, marginTop: 10, textAlign: 'center' }}>
            <Caption>No public comments yet</Caption>
            <Body size={13} color={COLORS.inkSoft} style={{ marginTop: 6 }}>
              Ratings without a written note still count toward the score.
            </Body>
          </Card>
        )}

        <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 12 }}>
          {(reviews || []).map((r, i) => <ReviewCard key={i} review={r} />)}
        </div>
      </div>
    </div>
  );
}

function ReviewCard({ review }) {
  const accent = review.stars >= 8 ? COLORS.moss : review.stars >= 5 ? COLORS.mustard : COLORS.clay;
  return (
    <Card style={{ padding: 14 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
        <Body size={13} weight={600}>{review.name || 'Passholder'}</Body>
        <Caption>{review.date}</Caption>
      </div>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: review.text ? 8 : 0 }}>
        <Stamp value={review.stars} unit="/10" size={20} color={accent} />
      </div>
      {review.text && <Body size={13} color={COLORS.inkSoft}>{review.text}</Body>}
    </Card>
  );
}

async function fetchReviewsFor(item) {
  const sb = window.PH365_SUPABASE;
  if (!sb) return [];
  const isAttraction = item.source === 'attraction';
  const tbl = isAttraction ? 'user_ratings' : 'user_checklist';
  const matchCol = isAttraction ? 'attraction_id' : 'template_id';
  let q = sb.from(tbl)
    .select('user_id, stars, review_text, created_at, updated_at')
    .eq('is_public', true)
    .eq(matchCol, item.source_id)
    .order('updated_at', { ascending: false, nullsFirst: false })
    .limit(100);
  if (!isAttraction) q = q.not('stars', 'is', null);

  const { data, error } = await q;
  if (error) throw error;

  // Resolve display names in one round-trip
  const userIds = Array.from(new Set((data || []).map(r => r.user_id))).filter(Boolean);
  let nameById = new Map();
  if (userIds.length) {
    const { data: profs } = await sb
      .from('user_profiles')
      .select('id, display_name')
      .in('id', userIds);
    nameById = new Map((profs || []).map(p => [p.id, p.display_name]));
  }

  return (data || []).map(r => ({
    name:  friendlyName(nameById.get(r.user_id)),
    stars: r.stars,
    text:  r.review_text,
    date:  formatShortDate(r.updated_at || r.created_at),
  }));
}

function friendlyName(s) {
  if (!s) return 'Passholder';
  // "Margaret Eldridge" → "Margaret E."
  const parts = String(s).trim().split(/\s+/);
  if (parts.length === 1) return parts[0];
  return `${parts[0]} ${parts[parts.length - 1][0]}.`;
}

function formatShortDate(iso) {
  if (!iso) return '';
  try {
    return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
  } catch { return ''; }
}

// ─────────────────────────────────────────────────────────────
// SMART "GO TO" RECOMMENDATIONS
// ─────────────────────────────────────────────────────────────
// Only ride-style attractions are eligible for "Go To" picks. Walk-throughs,
// shows, character meets, play areas, and transport don't have meaningful
// wait times — they'd otherwise dominate the list with their 0-min waits.
const RIDE_TYPES = new Set([
  'Coaster', 'Dark Ride', 'Boat Ride', 'Spinner', 'Carousel', 'Wheel',
  'Drop', 'Water', 'Train', 'Simulator', 'Interactive Dark Ride', 'Track Ride',
]);
// ─── Go To categories ────────────────────────────────────────
// Four views over the same board: everything, the rides a small kid can
// actually get on, the big ones, and the shows. The first three are pure
// filters over the candidate set that feeds rank() — same scorer, smaller
// pool. Shows are not (see rankShows below for why).
//
// Kids vs thrill is derived from the `height` field data.js already carries,
// rather than a hand-written list that would drift the moment an attraction
// changes:
//   Kids   — no height requirement, or one at/below 35" (Autopia, Luigi's,
//            Mater's, Inside Out, GADGETcoaster), which any kid tall enough
//            to be riding anything already clears.
//   Thrill — 40" and up.
// That gets 49 of the 53 rides right on its own. The four it doesn't are
// listed below, because a height stick measures restraint geometry, not how
// intense a ride is.
const KIDS_MAX_HEIGHT = 35;
const THRILL_MIN_HEIGHT = 40;
const CATEGORY_OVERRIDES = {
  // Gated at 40-42" for harness fit, but nobody would call these thrills.
  // They land in neither bucket: too tall for the kids list, too tame for
  // the thrill list. They still appear under "All".
  'jumpin-jellyfish':      'neither',
  'silly-symphony-swings': 'neither',
  'golden-zephyr':         'neither',
  // Under the 40" line, but it's a pitching motion-simulator cockpit.
  'millennium-falcon-smugglers-run': 'thrill',
};

// data.js stores height as a display string: 'Any', '40"', '46"'.
function heightInches(a) {
  if (!a.height || a.height === 'Any') return 0;
  const n = parseInt(a.height, 10);
  return Number.isFinite(n) ? n : 0;
}

// 'kids' | 'thrill' | null. null means the ride sits between the two lines
// and only shows up under "All".
function rideCategory(a) {
  const override = CATEGORY_OVERRIDES[a.id];
  if (override) return override === 'neither' ? null : override;
  const h = heightInches(a);
  if (h <= KIDS_MAX_HEIGHT) return 'kids';
  if (h >= THRILL_MIN_HEIGHT) return 'thrill';
  return null;
}

const PARK_OPTIONS = [
  { key: 'DLR',  label: 'Disneyland' },
  { key: 'DCA',  label: 'DCA' },
  { key: 'BOTH', label: 'Both' },
];

const CATEGORY_OPTIONS = [
  { key: 'ALL',    label: 'All' },
  { key: 'KIDS',   label: 'Kids' },
  { key: 'THRILL', label: 'Thrill' },
  { key: 'SHOWS',  label: 'Shows' },
];

const RIDE_CATEGORY_FILTER = {
  ALL:    () => true,
  KIDS:   a => rideCategory(a) === 'kids',
  THRILL: a => rideCategory(a) === 'thrill',
};

// Per-type fallback for the rare case an attraction lacks a baselineScore
// (e.g. a new ride added before generate-seed.mjs has been re-run).
const TYPE_FALLBACK = {
  'Coaster': 7, 'Dark Ride': 7, 'Boat Ride': 6, 'Water': 7, 'Drop': 7,
  'Simulator': 7, 'Interactive Dark Ride': 6, 'Spinner': 5, 'Carousel': 5,
  'Wheel': 6, 'Track Ride': 5, 'Train': 5,
};
function baselineFor(a) {
  if (typeof a.baselineScore === 'number') return a.baselineScore;
  return TYPE_FALLBACK[a.type] ?? 5;
}

// Typical on-ride duration in minutes. The recommender scores against the
// *total* time a pick costs you — queue + walk + the ride itself — so a
// 2-minute teacup spin and a 12-minute railroad loop stop being interchangeable
// just because they both posted a 5-minute wait.
const RIDE_MINUTES = {
  'Coaster': 4, 'Dark Ride': 5, 'Boat Ride': 10, 'Spinner': 2, 'Carousel': 2,
  'Wheel': 8, 'Drop': 3, 'Water': 8, 'Train': 12, 'Simulator': 5,
  'Interactive Dark Ride': 6, 'Track Ride': 4,
};
const DEFAULT_RIDE_MINUTES = 5;

// How much of your day a ride is "worth", in minutes, as a function of its
// 0-10 quality. Superlinear on purpose: the old formula was linear in quality
// but hyperbolic in wait (value = quality*10 / (wait+5)), so the wait term
// out-ranged the quality term ~8x and the list collapsed into "whatever is
// posting the shortest wait". Since the same handful of low-tier rides post
// 5 minutes all day, the same picks surfaced no matter what the headline
// attractions were doing. Exponent 2.25 is calibrated so the two terms carry
// comparable weight (Spearman ~0.48 vs a pure shortest-wait sort, ~0.70 vs a
// pure quality sort — the old formula scored 0.97 vs shortest-wait).
const WORTH_EXP = 2.25;
// Divisor puts the curve in real minutes: a 9/10 ride is worth ~60 min of
// your day, a 7/10 ~34 min, a 5/10 ~16 min.
const WORTH_DIVISOR = 2.34;
function worthMinutes(quality) {
  return Math.pow(Math.max(0, quality), WORTH_EXP) / WORTH_DIVISOR;
}

function haversineKm(lat1, lon1, lat2, lon2) {
  const R = 6371;
  const toRad = d => d * Math.PI / 180;
  const dLat = toRad(lat2 - lat1);
  const dLon = toRad(lon2 - lon1);
  const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
  return 2 * R * Math.asin(Math.sqrt(a));
}

// Approximate park-center coordinates. Used both to seed a fallback location
// when GPS denies us, and to decide which park the user is currently standing
// in (whichever center is closer wins).
const PARK_CENTERS = {
  DLR: { lat: 33.8121, lon: -117.9190 },
  DCA: { lat: 33.8068, lon: -117.9209 },
};

function detectParkFromCoords(coords) {
  if (!coords) return null;
  const dDLR = haversineKm(coords.lat, coords.lon, PARK_CENTERS.DLR.lat, PARK_CENTERS.DLR.lon);
  const dDCA = haversineKm(coords.lat, coords.lon, PARK_CENTERS.DCA.lat, PARK_CENTERS.DCA.lon);
  return dDLR <= dDCA ? 'DLR' : 'DCA';
}

function GoToScreen({ onSelectAttraction }) {
  const [coords,  setCoords]  = React.useState(null);
  const [geoErr,  setGeoErr]  = React.useState(null);
  const [locating, setLocating] = React.useState(true);
  const [tick,    setTick]    = React.useState(0);  // Refresh counter
  const [waitsRefreshing, setWaitsRefreshing] = React.useState(false);
  // Park filter: 'DLR' | 'DCA' | 'BOTH'. `userOverride` flips true the moment
  // the user taps a pill — after that, GPS refreshes won't yank the selection
  // out from under them.
  const [selectedPark, setSelectedPark] = React.useState('BOTH');
  const [userOverride, setUserOverride] = React.useState(false);
  // Category filter: 'ALL' | 'KIDS' | 'THRILL' | 'SHOWS'.
  const [category, setCategory] = React.useState('ALL');
  const detectedPark = React.useMemo(() => detectParkFromCoords(coords), [coords]);
  React.useEffect(() => {
    if (!userOverride && detectedPark) setSelectedPark(detectedPark);
  }, [detectedPark, userOverride]);

  // Always fetch a fresh GPS fix — maximumAge: 0 prevents the browser from
  // handing back a cached position from when the user was in another land.
  const fetchLocation = React.useCallback(() => new Promise(resolve => {
    if (!('geolocation' in navigator)) {
      setGeoErr('Location services unavailable on this device.');
      setCoords(prev => prev || { lat: 33.8095, lon: -117.9203 });
      resolve();
      return;
    }
    navigator.geolocation.getCurrentPosition(
      pos => {
        setGeoErr(null);
        setCoords({ lat: pos.coords.latitude, lon: pos.coords.longitude });
        resolve();
      },
      err => {
        setGeoErr(err?.message || 'Location unavailable — falling back to park center.');
        setCoords(prev => prev || { lat: 33.8095, lon: -117.9203 });
        resolve();
      },
      { enableHighAccuracy: true, timeout: 8000, maximumAge: 0 }
    );
  }), []);

  // Initial GPS fix on mount
  React.useEffect(() => {
    setLocating(true);
    fetchLocation().then(() => setLocating(false));
  }, [fetchLocation]);

  // Refresh re-acquires GPS before pulling fresh waits, so distance and
  // walk-time estimates reflect where the user is *now*, not where they were.
  async function refresh() {
    setLocating(true);
    await fetchLocation();
    setLocating(false);

    setWaitsRefreshing(true);
    try {
      await window.PH365_LIVE?.refreshWaits?.();
    } catch {}
    setWaitsRefreshing(false);
    setTick(t => t + 1);
  }

  const ATTRACTIONS = window.PH365.ATTRACTIONS || [];
  const LANDS = window.PH365.LANDS || {};
  const PARKS = window.PH365.PARKS || {};

  const showsMode = category === 'SHOWS';

  const ranked = React.useMemo(() => {
    if (!coords) return [];
    // Each attraction has a `park` field ('DLR' | 'DCA') seeded from data.js.
    // Fall back to the land's park if it's somehow missing so a stray
    // attraction can't disappear from the list entirely.
    const inPark = a => {
      if (selectedPark === 'BOTH') return true;
      const p = a.park || LANDS[a.land]?.park;
      return p === selectedPark;
    };
    const pool = ATTRACTIONS.filter(inPark);
    if (showsMode) return rankShows(pool, coords);
    // Kids/Thrill narrow the candidate set that feeds the existing scorer —
    // same formula, smaller pool — rather than scoring by a second path.
    const inCategory = RIDE_CATEGORY_FILTER[category] || RIDE_CATEGORY_FILTER.ALL;
    return rank(pool.filter(inCategory), coords);
  }, [coords, tick, ATTRACTIONS, LANDS, selectedPark, category, showsMode]);

  // Shows are a short, complete list (eleven across both parks) — there's no
  // reason to truncate it the way the ride board is truncated to a top 10.
  const topPicks = showsMode ? ranked : ranked.slice(0, 10);

  return (
    <div style={{ paddingBottom: 100 }}>
      <div style={{ padding: '14px 16px 4px' }}>
        <div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
          <Display size={26} style={{ flex: 1 }}>What's next?</Display>
          <button onClick={refresh} disabled={locating || waitsRefreshing} title="Refresh"
            style={{
              background: COLORS.teal, color: COLORS.paper, border: 'none',
              borderRadius: 10, padding: '8px 12px', cursor: 'pointer',
              fontFamily: 'Manrope', fontSize: 12, fontWeight: 700,
              display: 'flex', alignItems: 'center', gap: 6,
              opacity: (locating || waitsRefreshing) ? 0.6 : 1,
            }}>
            <Icon name="refresh" size={14} color={COLORS.paper} />
            {locating ? 'Locating…' : (waitsRefreshing ? 'Refreshing…' : 'Refresh')}
          </button>
        </div>
        <Caption style={{ marginTop: 6 }}>
          {showsMode
            ? 'Shows and spectaculars, best first'
            : category === 'KIDS'
              ? 'Best ride for the wait — nothing your kid is too short for'
              : category === 'THRILL'
                ? 'Best of the big ones for the wait, near you'
                : 'Best ride for the wait, near you'}
        </Caption>

        {detectedPark && (
          <Body size={12} color={COLORS.inkSoft} style={{ marginTop: 8 }}>
            You're in: <span style={{ color: COLORS.ink, fontWeight: 700 }}>
              {PARKS[detectedPark]?.name || detectedPark}
            </span>
            {userOverride && selectedPark !== detectedPark && (
              <button
                onClick={() => { setUserOverride(false); setSelectedPark(detectedPark); }}
                style={{
                  marginLeft: 8, background: 'transparent', border: 'none',
                  color: COLORS.teal, fontFamily: 'Manrope', fontSize: 12,
                  fontWeight: 700, cursor: 'pointer', padding: 0,
                }}>
                Switch back
              </button>
            )}
          </Body>
        )}

        <FilterPills
          options={PARK_OPTIONS}
          value={selectedPark}
          onChange={key => { setUserOverride(true); setSelectedPark(key); }}
        />
        <FilterPills
          options={CATEGORY_OPTIONS}
          value={category}
          onChange={setCategory}
        />
      </div>

      {locating && (
        <div style={{ padding: '40px 16px', textAlign: 'center' }}>
          <Caption>Finding you on the map…</Caption>
        </div>
      )}

      {geoErr && (
        <div style={{ padding: '0 16px', marginTop: 10 }}>
          <Card style={{ padding: 12 }}>
            <Caption color={COLORS.clay}>Using park center for distance</Caption>
            <Body size={12} color={COLORS.inkSoft} style={{ marginTop: 4 }}>{geoErr}</Body>
          </Card>
        </div>
      )}

      {!locating && topPicks.length === 0 && (
        <div style={{ padding: '24px 16px' }}>
          <Card style={{ padding: 16, textAlign: 'center' }}>
            {showsMode ? (
              <>
                <Caption>Nothing showing here right now</Caption>
                <Body size={13} color={COLORS.inkSoft} style={{ marginTop: 6 }}>
                  No shows are listed as running in this park. Try the other park,
                  or check the official Disneyland app for today's schedule.
                </Body>
              </>
            ) : (
              <>
                <Caption>No live waits to score yet</Caption>
                <Body size={13} color={COLORS.inkSoft} style={{ marginTop: 6 }}>
                  Wait times are still loading from queue-times.com — try again in a moment.
                </Body>
              </>
            )}
          </Card>
        </div>
      )}

      <div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
        {topPicks.map((r, i) => (
          showsMode ? (
            <ShowCard
              key={r.attraction.id}
              rank={i + 1}
              r={r}
              land={LANDS[r.attraction.land]}
              onClick={() => onSelectAttraction?.(r.attraction.id)}
            />
          ) : (
            <RecommendationCard
              key={r.attraction.id}
              rank={i + 1}
              r={r}
              land={LANDS[r.attraction.land]}
              onClick={() => onSelectAttraction?.(r.attraction.id)}
            />
          )
        ))}
      </div>

      {!locating && topPicks.length > 0 && (
        <div style={{ padding: '0 16px', marginTop: 6 }}>
          <PerforatedDivider label={showsMode ? 'How this is ordered' : 'How this is scored'} />
          <Card style={{ marginTop: 10, padding: 14 }}>
            {showsMode ? (
              <Body size={12} color={COLORS.inkSoft}>
                Shows aren't scored the way rides are, because there's nothing to
                score: a show has no queue, so the wait-versus-worth maths that
                ranks the rides would just be measuring which show is nearest.
                What decides a show is its next showtime, and those aren't in
                this app — check the official Disneyland app for today's times.
                This list is ordered by how good the show is, then by how far
                you'd have to walk.
              </Body>
            ) : (
              <Body size={12} color={COLORS.inkSoft}>
                Every pick is scored on what it's worth against what it costs you:
                how good the ride is, versus the queue plus the walk over plus the
                ride itself. A 9/10 ride is worth about an hour of your day, a
                5/10 about a quarter of that — so a great ride stays a good call
                at 25 minutes, and a mediocre one stops winning just because it
                posted 5. Only real rides are scored here; walk-throughs, meets,
                and play areas are skipped so they don't crowd out the picks, and
                shows have a tab of their own.
                {category === 'KIDS' && ' Kids shows every ride with no height requirement, plus the few gated at 35" or under.'}
                {category === 'THRILL' && ' Thrill shows the rides gated at 40" and up, plus Smugglers Run.'}
              </Body>
            )}
          </Card>
        </div>
      )}
    </div>
  );
}

function rank(attractions, coords) {
  // Only real rides are eligible — RIDE_TYPES already excludes the shows,
  // walk-throughs, character meets, play areas, and transport that post a
  // permanent wait: 0 from queue-times.
  //
  // A wait of 0 is admitted only when live data actually reached this
  // attraction, which queue-times-fetch marks by stamping `lastUpdated` on
  // every ride it touches. That distinction matters in both directions:
  //   - a genuine live 0 is a walk-on, often the best pick on the board. The
  //     old blanket `wait > 0` filter threw those away (it was hiding an open,
  //     0-minute Soarin' Over California while recommending a 5-min carousel).
  //   - an unstamped 0 is just the seed value from data.js, meaning waits
  //     haven't loaded. Those must stay out, or the screen cheerfully reports
  //     that all forty rides are walk-ons instead of showing "no live waits yet".
  const hasLiveWait = a => a.wait > 0 || a.lastUpdated != null;
  const candidates = attractions.filter(a =>
    a.isOpen !== false &&
    a.queueTimesId != null &&
    a.lat != null && a.lon != null &&
    typeof a.wait === 'number' &&
    a.wait >= 0 &&
    hasLiveWait(a) &&
    RIDE_TYPES.has(a.type)
  );
  if (!candidates.length) return [];

  const scored = candidates.map(a => {
    const distKm = haversineKm(coords.lat, coords.lon, a.lat, a.lon);

    // Quality: blend the editorial baseline (1-10) with the live aggregate
    // rating (user_ratings.stars is 0-10 since migration 20260502000001), so a
    // single 5/10 rating can't double-spike Autopia to 10/10. The legacy 4.5
    // default from the seed is treated as "no rating". No * 2 doubling — both
    // baseline and the new user_ratings scale are already 0-10 native.
    const baseline = baselineFor(a);
    const hasRating = typeof a.rating === 'number' && a.rating > 0 && a.rating !== 4.5;
    const quality = hasRating ? (baseline + a.rating) / 2 : baseline;

    // Walking time at ~65 m/min (~2.4 mph theme-park pace) with a 1.6x path
    // multiplier — park routes wind around buildings and through bottlenecks,
    // so straight-line distance under-counts real walking time.
    const walkMin = Math.max(1, Math.round((distKm * 1000 * 1.6) / 65));
    const rideMin = RIDE_MINUTES[a.type] ?? DEFAULT_RIDE_MINUTES;

    // Distance is now priced in real minutes inside timeCost rather than as a
    // separate multiplier, so "it's far" and "the queue is long" are finally
    // the same currency.
    const timeCost = a.wait + walkMin + rideMin;

    // value > 1 means the ride is worth more of your day than it costs you.
    const worth = worthMinutes(quality);
    const value = worth / timeCost;

    // Displayed 0-10 score, saturating: value 1 → 5.0, value 2 → 6.7, value 3
    // → 7.5. Derived from `value` itself so the number on the card is always
    // monotonic with the rank. (The old card score was computed from a
    // separate penalty formula and could rise as you went *down* the list.)
    const valueScore = Math.max(0, Math.min(10, (10 * value) / (value + 1)));

    return {
      attraction: a, distKm, quality, wait: a.wait, value, valueScore,
      walkMin, rideMin, timeCost, worth,
      reason: explain(a, quality, a.wait, distKm, value),
    };
  });

  // Ties (common when a cluster all post the same wait) break toward the
  // better ride rather than whatever order the data happened to arrive in.
  scored.sort((a, b) => b.value - a.value || b.quality - a.quality);
  return scored;
}

// ─── Shows ───────────────────────────────────────────────────
// Shows are deliberately NOT run through rank(), and the Shows tab does not
// show a value score.
//
// rank() prices an attraction as what it's worth against what it costs, and
// the dominant cost term is the queue. A show has no queue — queue-times
// reports every one of them at wait 0, all day. Push them through the same
// scorer and `value` degenerates into "whichever decent show is nearest",
// wearing a 0-10 number that looks like it measured something. RIDE_MINUTES
// has no Show entry either, so all eleven would be costed at the 5-minute
// default, pricing a 25-minute Fantasmic the same as Turtle Talk.
//
// What actually decides whether a show is a good call right now is its next
// showtime, and the app has no showtime data anywhere — screens-detail and
// screens-home both already send the user to the official Disneyland app for
// it. So this list is ordered by how good the show is and how far you'd walk,
// and says out loud that the times have to come from elsewhere.
const SHOW_TYPES = new Set(['Show']);

function rankShows(attractions, coords) {
  const shows = attractions.filter(a =>
    SHOW_TYPES.has(a.type) &&
    a.lat != null && a.lon != null &&
    a.isOpen !== false
  );

  return shows.map(a => {
    const distKm = haversineKm(coords.lat, coords.lon, a.lat, a.lon);
    const baseline = baselineFor(a);
    const hasRating = typeof a.rating === 'number' && a.rating > 0 && a.rating !== 4.5;
    const quality = hasRating ? (baseline + a.rating) / 2 : baseline;
    const walkMin = Math.max(1, Math.round((distKm * 1000 * 1.6) / 65));

    // The nighttime spectaculars and the parade have no queueTimesId at all,
    // so no feed ever reaches them. Say "unknown" rather than let an absent
    // closure imply "running".
    const liveStatus = (a.queueTimesId != null && a.lastUpdated != null) ? 'live' : 'unknown';

    return { attraction: a, distKm, walkMin, quality, liveStatus };
  }).sort((a, b) => b.quality - a.quality || a.walkMin - b.walkMin);
}

function describeShow(r) {
  const parts = [];
  if (r.quality >= 8) parts.push('a headliner');
  else if (r.quality >= 6.5) parts.push('well worth catching');
  else parts.push('a quieter sit-down');

  if (r.distKm < 0.15) parts.push('right next to you');
  else if (r.distKm < 0.4) parts.push('close by');

  parts.push(r.liveStatus === 'live'
    ? 'listed as running today'
    : 'no live status — check the app');

  return parts.join(' · ');
}

function explain(a, quality, wait, distKm, value) {
  const parts = [];
  if (quality >= 8.5) parts.push('top-rated experience');
  else if (quality >= 7.5) parts.push('high quality');
  else if (quality >= 6.5) parts.push('crowd-pleaser');
  else parts.push('worth a quick spin');

  if (wait === 0) parts.push('walk on right now');
  else if (wait <= 10) parts.push(`only ${wait} min`);
  else if (wait <= 25) parts.push(`fair ${wait}-min wait`);
  else if (value >= 1) parts.push(`${wait}-min wait worth it`);
  else parts.push(`${wait}-min wait — steep for now`);

  if (distKm < 0.15) parts.push('right next to you');
  else if (distKm < 0.4) parts.push('close by');

  return parts.join(' · ');
}

// Shared pill row — the park filter and the category filter are the same
// control twice, so they stay one implementation.
function FilterPills({ options, value, onChange }) {
  return (
    <div style={{
      display: 'flex', gap: 6, marginTop: 10,
      background: COLORS.paper, padding: 4,
      borderRadius: 999, border: `1px solid ${COLORS.paperEdge}`,
      width: 'fit-content', maxWidth: '100%', flexWrap: 'wrap',
    }}>
      {options.map(opt => {
        const active = value === opt.key;
        return (
          <button key={opt.key}
            onClick={() => onChange(opt.key)}
            style={{
              background: active ? COLORS.teal : 'transparent',
              color: active ? COLORS.paper : COLORS.inkSoft,
              border: 'none', borderRadius: 999,
              padding: '6px 14px', cursor: 'pointer',
              fontFamily: 'Manrope', fontSize: 12,
              fontWeight: active ? 700 : 600,
            }}>
            {opt.label}
          </button>
        );
      })}
    </div>
  );
}

// Deliberately not a RecommendationCard: no value score, no value bar, no
// wait. A show has no queue to price, so every one of those would be a
// number invented to fill the slot. What the card shows instead is the two
// things that are actually known — how good it is and how far away — and
// where the missing third thing, the showtime, has to come from.
function ShowCard({ rank, r, land, onClick }) {
  const a = r.attraction;
  const accent = COLORS.teal;

  return (
    <Card onClick={onClick} style={{ padding: 14 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
        <div style={{
          width: 36, height: 36, borderRadius: '50%',
          border: `1.5px solid ${accent}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontFamily: 'Fraunces, serif', fontWeight: 700, fontSize: 16,
          color: accent, flexShrink: 0,
        }}>{rank}</div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <Body size={15} weight={700} style={{
            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          }}>{a.name}</Body>
          <Caption style={{ marginTop: 2 }}>{land?.name || a.type}</Caption>
        </div>
        <StampPill attraction={a} size={22} />
      </div>

      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
        background: COLORS.paper, borderRadius: 8, padding: '10px 12px',
        border: `1px dashed ${COLORS.paperEdge}`,
      }}>
        <Stat label="Rated"     value={r.quality.toFixed(1)}   unit="/10" color={accent} />
        <Stat label="Walk"      value={`${r.walkMin} min walk`}           color={COLORS.inkSoft} />
        <Stat label="Showtimes" value="Disneyland app"                    color={COLORS.inkSoft} />
      </div>

      <div style={{ display: 'flex', gap: 8, marginTop: 10, alignItems: 'flex-start' }}>
        <Icon name="sparkle" size={14} color={COLORS.teal} />
        <Body size={12.5} color={COLORS.inkSoft} style={{ flex: 1 }}>
          {describeShow(r)}
        </Body>
      </div>
    </Card>
  );
}

function RecommendationCard({ rank, r, land, onClick }) {
  const a = r.attraction;
  // Card accent reflects overall value, not just raw quality, so a great ride
  // with a brutal wait stops shouting "high quality" in green.
  const accent = r.valueScore >= 7 ? COLORS.moss : r.valueScore >= 5 ? COLORS.mustard : COLORS.clay;
  const walkLabel = `${r.walkMin} min walk`;

  return (
    <Card onClick={onClick} style={{ padding: 14 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
        <div style={{
          width: 36, height: 36, borderRadius: '50%',
          border: `1.5px solid ${accent}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontFamily: 'Fraunces, serif', fontWeight: 700, fontSize: 16,
          color: accent, flexShrink: 0,
        }}>{rank}</div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <Body size={15} weight={700} style={{
            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          }}>{a.name}</Body>
          <Caption style={{ marginTop: 2 }}>{land?.name || a.type}</Caption>
        </div>
        <StampPill attraction={a} size={22} />
      </div>

      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
        background: COLORS.paper, borderRadius: 8, padding: '10px 12px',
        border: `1px dashed ${COLORS.paperEdge}`,
      }}>
        <Stat label="Wait"  value={a.wait}                  unit="min" color={waitColor(a.wait)} />
        <Stat label="Value" value={r.valueScore.toFixed(1)} unit="/10" color={accent} />
        <Stat label="Walk"  value={walkLabel}                          color={COLORS.inkSoft} />
      </div>

      <ValueBar score={r.valueScore} color={accent} />

      <div style={{ display: 'flex', gap: 8, marginTop: 10, alignItems: 'flex-start' }}>
        <Icon name="sparkle" size={14} color={COLORS.teal} />
        <Body size={12.5} color={COLORS.inkSoft} style={{ flex: 1 }}>
          {r.reason}
        </Body>
      </div>
    </Card>
  );
}

function ValueBar({ score, color }) {
  const pct = Math.max(0, Math.min(100, score * 10));
  return (
    <div style={{ marginTop: 10 }}>
      <div style={{
        height: 6, borderRadius: 3,
        background: COLORS.paperEdge, overflow: 'hidden',
      }}>
        <div style={{
          width: `${pct}%`, height: '100%',
          background: color, transition: 'width 200ms ease-out',
        }} />
      </div>
    </div>
  );
}

function Stat({ label, value, unit, color }) {
  return (
    <div>
      <Caption style={{ fontSize: 9, marginBottom: 2 }}>{label}</Caption>
      {typeof value === 'string' && !unit
        ? <Body size={15} weight={700} color={color}>{value}</Body>
        : <Stamp value={value} unit={unit} size={18} color={color} />}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// PARKING SPOT
// ─────────────────────────────────────────────────────────────
// Saves the user's car location to localStorage so they can find it again
// at the end of the day. Stays local — parking spots are temporary.
const PARKING_KEY = 'ph365_parking_spot';
const FLOOR_PRESETS = ['Level 1', 'Level 2', 'Level 3', 'Level 4', 'Level 5', 'Roof'];

function loadParkingSpot() {
  try {
    const raw = localStorage.getItem(PARKING_KEY);
    if (!raw) return null;
    return JSON.parse(raw);
  } catch (e) { return null; }
}

function saveParkingSpot(spot) {
  try { localStorage.setItem(PARKING_KEY, JSON.stringify(spot)); } catch (e) {}
}

function clearParkingSpot() {
  try { localStorage.removeItem(PARKING_KEY); } catch (e) {}
}

// Haversine — meters between two lat/lon pairs.
function metersBetween(a, b) {
  if (!a || !b) return null;
  const R = 6371000;
  const toRad = (d) => (d * Math.PI) / 180;
  const dLat = toRad(b.lat - a.lat);
  const dLon = toRad(b.lon - a.lon);
  const lat1 = toRad(a.lat);
  const lat2 = toRad(b.lat);
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
  return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}

function formatDistance(m) {
  if (m == null || !isFinite(m)) return '—';
  if (m < 1000) return `${Math.round(m)} m`;
  return `${(m / 1000).toFixed(2)} km`;
}

function formatSavedTime(ts) {
  if (!ts) return '';
  const d = new Date(ts);
  const today = new Date();
  const sameDay = d.toDateString() === today.toDateString();
  const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
  if (sameDay) return `Saved at ${time}`;
  return `Saved ${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} · ${time}`;
}

function isIOS() {
  return /iPad|iPhone|iPod/.test(navigator.userAgent || '')
    || (navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1);
}

function ParkingScreen() {
  const [spot, setSpot]   = React.useState(() => loadParkingSpot());
  const [busy, setBusy]   = React.useState(false);
  const [err,  setErr]    = React.useState(null);
  const [here, setHere]   = React.useState(null);
  const [editingMeta, setEditingMeta] = React.useState(false);
  const [draftFloor, setDraftFloor]   = React.useState('');
  const [draftCustomFloor, setDraftCustomFloor] = React.useState('');
  const [draftNote,  setDraftNote]    = React.useState('');

  // Watch current location while a spot is saved so the distance line
  // updates as the user walks back. Cleared on unmount or when spot is
  // cleared.
  React.useEffect(() => {
    if (!spot) { setHere(null); return; }
    if (!navigator.geolocation) return;
    let watchId = null;
    navigator.geolocation.getCurrentPosition(
      (p) => setHere({ lat: p.coords.latitude, lon: p.coords.longitude }),
      () => {},
      { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 },
    );
    watchId = navigator.geolocation.watchPosition(
      (p) => setHere({ lat: p.coords.latitude, lon: p.coords.longitude }),
      () => {},
      { enableHighAccuracy: true, maximumAge: 15000 },
    );
    return () => { if (watchId != null) navigator.geolocation.clearWatch(watchId); };
  }, [spot && spot.savedAt]);

  function handleSave() {
    if (busy) return;
    setErr(null);
    if (!navigator.geolocation) {
      setErr('Geolocation is not supported on this device.');
      return;
    }
    setBusy(true);
    navigator.geolocation.getCurrentPosition(
      (pos) => {
        const next = {
          lat: pos.coords.latitude,
          lon: pos.coords.longitude,
          accuracy: pos.coords.accuracy || null,
          savedAt: Date.now(),
          floor: '',
          note:  '',
        };
        saveParkingSpot(next);
        setSpot(next);
        setBusy(false);
        // Open the floor/note editor inline so the user can tag the spot
        // while the memory is fresh.
        setDraftFloor('');
        setDraftCustomFloor('');
        setDraftNote('');
        setEditingMeta(true);
      },
      (e) => {
        setBusy(false);
        const msg = e && e.code === 1
          ? 'Location permission denied. Enable it in your browser settings to save your parking spot.'
          : (e && e.message) || 'Could not read your location. Try again outside.';
        setErr(msg);
      },
      { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 },
    );
  }

  function handleClear() {
    clearParkingSpot();
    setSpot(null);
    setEditingMeta(false);
    setErr(null);
  }

  function handleNavigate() {
    if (!spot) return;
    const dest = `${spot.lat.toFixed(6)},${spot.lon.toFixed(6)}`;
    const url = isIOS()
      ? `https://maps.apple.com/?daddr=${dest}&dirflg=w`
      : `https://www.google.com/maps/dir/?api=1&destination=${dest}&travelmode=walking`;
    try {
      window.open(url, '_blank', 'noopener,noreferrer');
    } catch (e) {
      window.location.href = url;
    }
  }

  function startEditMeta() {
    if (!spot) return;
    const presetMatch = FLOOR_PRESETS.includes(spot.floor);
    setDraftFloor(presetMatch ? spot.floor : (spot.floor ? '__custom' : ''));
    setDraftCustomFloor(presetMatch ? '' : (spot.floor || ''));
    setDraftNote(spot.note || '');
    setEditingMeta(true);
  }

  function saveMeta() {
    if (!spot) return;
    const floor = draftFloor === '__custom'
      ? draftCustomFloor.trim()
      : draftFloor;
    const next = { ...spot, floor: floor || '', note: draftNote.trim() };
    saveParkingSpot(next);
    setSpot(next);
    setEditingMeta(false);
  }

  const distance = (spot && here) ? metersBetween(here, { lat: spot.lat, lon: spot.lon }) : null;

  return (
    <div style={{ paddingBottom: 140 }}>
      <div style={{ padding: '12px 16px 18px', borderBottom: `1px dashed ${COLORS.paperEdge}` }}>
        <Caption color={COLORS.moss} style={{ marginBottom: 4 }}>★ Parking Spot</Caption>
        <Display size={26}>Where's the car?</Display>
        <Body size={13} color={COLORS.inkSoft} style={{ marginTop: 6, lineHeight: 1.4 }}>
          Drop a pin in the structure and we'll guide you back at park close.
        </Body>
      </div>

      <div style={{ padding: '16px' }}>
        {!spot && (
          <NoSpotCard onSave={handleSave} busy={busy} err={err} />
        )}

        {spot && (
          <>
            <SavedSpotCard
              spot={spot}
              distance={distance}
              onNavigate={handleNavigate}
              onClear={handleClear}
              onEditMeta={startEditMeta}
              onResave={handleSave}
              busy={busy}
              err={err}
            />

            {editingMeta && (
              <FloorNoteEditor
                draftFloor={draftFloor}
                draftCustomFloor={draftCustomFloor}
                draftNote={draftNote}
                onChangeFloor={setDraftFloor}
                onChangeCustomFloor={setDraftCustomFloor}
                onChangeNote={setDraftNote}
                onSave={saveMeta}
                onCancel={() => setEditingMeta(false)}
              />
            )}
          </>
        )}
      </div>
    </div>
  );
}

function NoSpotCard({ onSave, busy, err }) {
  return (
    <Card style={{ padding: '20px 18px', textAlign: 'center' }}>
      <div style={{
        width: 64, height: 64, borderRadius: '50%',
        background: `${COLORS.moss}1A`,
        border: `1.5px dashed ${COLORS.moss}`,
        margin: '0 auto 14px',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>
        <Icon name="car" size={30} color={COLORS.moss} strokeWidth={1.8} />
      </div>
      <Display size={18} style={{ marginBottom: 4 }}>No spot saved</Display>
      <Body size={13} color={COLORS.inkSoft} style={{ marginBottom: 16 }}>
        Park, then tap below to mark this exact spot. We'll remember the GPS
        location until you clear it.
      </Body>
      <button onClick={onSave} disabled={busy} style={primaryButtonStyle(busy)}>
        <Icon name="pin" size={16} color={COLORS.paper} strokeWidth={2.2} />
        {busy ? 'Reading GPS…' : 'Save My Parking Spot'}
      </button>
      {err && (
        <Body size={12} color={COLORS.clay} style={{ marginTop: 12, lineHeight: 1.4 }}>
          {err}
        </Body>
      )}
    </Card>
  );
}

function SavedSpotCard({ spot, distance, onNavigate, onClear, onEditMeta, onResave, busy, err }) {
  return (
    <>
      <Card style={{ padding: '16px 16px 18px' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
          <div style={{
            width: 36, height: 36, borderRadius: '50%',
            background: COLORS.moss,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            boxShadow: `0 4px 10px ${COLORS.moss}55`,
          }}>
            <Icon name="car" size={20} color={COLORS.paper} strokeWidth={2} />
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <Body size={14} weight={700}>Your car is parked here</Body>
            <Caption style={{ marginTop: 2 }}>{formatSavedTime(spot.savedAt)}</Caption>
          </div>
        </div>

        {(spot.floor || spot.note) ? (
          <div style={{
            marginTop: 4, marginBottom: 14,
            background: `${COLORS.mustard}1F`,
            border: `1px solid ${COLORS.mustard}66`,
            borderRadius: 10,
            padding: '10px 12px',
          }}>
            {spot.floor && (
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
                <Caption color={COLORS.inkSoft} style={{ letterSpacing: '0.12em' }}>FLOOR</Caption>
                <Body size={16} weight={700} color={COLORS.ink}>{spot.floor}</Body>
              </div>
            )}
            {spot.note && (
              <Body size={13} color={COLORS.ink} style={{ marginTop: spot.floor ? 4 : 0, lineHeight: 1.35 }}>
                {spot.note}
              </Body>
            )}
          </div>
        ) : (
          <button onClick={onEditMeta} style={{
            width: '100%', marginTop: 4, marginBottom: 14,
            background: 'transparent',
            border: `1px dashed ${COLORS.paperEdge}`,
            borderRadius: 10,
            padding: '10px 12px',
            cursor: 'pointer',
            fontFamily: 'Manrope, sans-serif',
            fontSize: 13, color: COLORS.inkSoft,
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
          }}>
            <Icon name="plus" size={14} color={COLORS.inkSoft} />
            Add floor / section note
          </button>
        )}

        <MapPreview lat={spot.lat} lon={spot.lon} />

        <div style={{
          display: 'grid', gridTemplateColumns: '1fr 1fr',
          gap: 8, marginTop: 14,
        }}>
          <Stat label="Distance" value={formatDistance(distance)} color={COLORS.teal} />
          <Stat label="Coords"   value={`${spot.lat.toFixed(4)}, ${spot.lon.toFixed(4)}`} color={COLORS.inkSoft} />
        </div>

        <button onClick={onNavigate} style={{ ...primaryButtonStyle(false), marginTop: 16 }}>
          <Icon name="navigate" size={16} color={COLORS.paper} strokeWidth={2} />
          Navigate to My Car
        </button>

        <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
          <button onClick={onEditMeta} style={secondaryButtonStyle}>
            <Icon name="pin" size={14} color={COLORS.teal} />
            {spot.floor || spot.note ? 'Edit floor / note' : 'Add floor / note'}
          </button>
          <button onClick={onResave} disabled={busy} style={secondaryButtonStyle}>
            <Icon name="refresh" size={14} color={COLORS.teal} />
            {busy ? 'Updating…' : 'Update GPS'}
          </button>
        </div>

        <button onClick={onClear} style={{
          width: '100%', marginTop: 10,
          background: 'transparent',
          border: 'none',
          padding: '8px',
          cursor: 'pointer',
          fontFamily: 'Manrope, sans-serif',
          fontSize: 12, fontWeight: 700,
          color: COLORS.clay,
          letterSpacing: '0.06em',
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
        }}>
          <Icon name="trash" size={13} color={COLORS.clay} />
          Clear Parking Spot
        </button>

        {err && (
          <Body size={12} color={COLORS.clay} style={{ marginTop: 10, lineHeight: 1.4 }}>
            {err}
          </Body>
        )}
      </Card>
    </>
  );
}

function MapPreview({ lat, lon }) {
  const ref = React.useRef(null);
  const mapRef = React.useRef(null);
  const hasLeaflet = typeof window !== 'undefined' && !!window.L;

  React.useEffect(() => {
    if (!hasLeaflet || !ref.current) return;
    const L = window.L;
    if (!mapRef.current) {
      const m = L.map(ref.current, {
        zoomControl: false, attributionControl: false,
        dragging: false, scrollWheelZoom: false, doubleClickZoom: false,
        touchZoom: false, keyboard: false, tap: false,
      }).setView([lat, lon], 18);
      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(m);
      L.circleMarker([lat, lon], {
        radius: 8, color: COLORS.moss, weight: 3,
        fillColor: COLORS.paper, fillOpacity: 1,
      }).addTo(m);
      mapRef.current = m;
    } else {
      mapRef.current.setView([lat, lon], 18);
    }
    // Leaflet sometimes mis-sizes inside a freshly mounted scaled container.
    setTimeout(() => mapRef.current && mapRef.current.invalidateSize(), 80);
    return () => {};
  }, [hasLeaflet, lat, lon]);

  if (!hasLeaflet) {
    // Fallback: simple coordinates pill so the screen still works without
    // Leaflet (the map is pre-loaded by MapScreen, but this screen can be
    // hit before that).
    return (
      <div style={{
        marginTop: 4,
        height: 140,
        borderRadius: 12,
        border: `1px solid ${COLORS.paperEdge}`,
        background: COLORS.paperDeep,
        display: 'flex', flexDirection: 'column',
        alignItems: 'center', justifyContent: 'center', gap: 6,
      }}>
        <Icon name="pin" size={22} color={COLORS.moss} strokeWidth={2} />
        <Caption>{lat.toFixed(5)}, {lon.toFixed(5)}</Caption>
      </div>
    );
  }
  return (
    <div ref={ref} style={{
      marginTop: 4,
      height: 140,
      borderRadius: 12,
      overflow: 'hidden',
      border: `1px solid ${COLORS.paperEdge}`,
    }} />
  );
}

function FloorNoteEditor({
  draftFloor, draftCustomFloor, draftNote,
  onChangeFloor, onChangeCustomFloor, onChangeNote,
  onSave, onCancel,
}) {
  return (
    <Card style={{ padding: '16px', marginTop: 12 }}>
      <Caption color={COLORS.teal} style={{ marginBottom: 6 }}>Floor / Level</Caption>
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)',
        gap: 6, marginBottom: 8,
      }}>
        {FLOOR_PRESETS.map((f) => {
          const active = draftFloor === f;
          return (
            <button key={f} onClick={() => onChangeFloor(f)} style={floorChipStyle(active)}>
              {f}
            </button>
          );
        })}
        <button
          onClick={() => onChangeFloor('__custom')}
          style={floorChipStyle(draftFloor === '__custom')}
        >
          Custom…
        </button>
      </div>
      {draftFloor === '__custom' && (
        <input
          value={draftCustomFloor}
          onChange={(e) => onChangeCustomFloor(e.target.value)}
          placeholder="e.g. Mickey & Friends G"
          maxLength={40}
          style={inputStyle}
        />
      )}

      <Caption color={COLORS.teal} style={{ marginTop: 14, marginBottom: 6 }}>
        Section / Row (optional)
      </Caption>
      <input
        value={draftNote}
        onChange={(e) => onChangeNote(e.target.value)}
        placeholder="e.g. Goofy section, Row 12"
        maxLength={120}
        style={inputStyle}
      />

      <div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
        <button onClick={onCancel} style={secondaryButtonStyle}>Cancel</button>
        <button onClick={onSave} style={{ ...primaryButtonStyle(false), flex: 1, padding: '10px 14px' }}>
          <Icon name="check" size={14} color={COLORS.paper} strokeWidth={2.2} />
          Save details
        </button>
      </div>
    </Card>
  );
}

function primaryButtonStyle(busy) {
  return {
    width: '100%',
    background: busy ? COLORS.inkSoft : COLORS.teal,
    color: COLORS.paper,
    border: 'none',
    padding: '14px 18px',
    borderRadius: 12,
    fontFamily: 'Manrope, sans-serif',
    fontSize: 14, fontWeight: 700,
    letterSpacing: '0.04em',
    cursor: busy ? 'wait' : 'pointer',
    display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
    boxShadow: busy ? 'none' : '0 4px 12px rgba(74,98,216,0.28)',
  };
}

const secondaryButtonStyle = {
  flex: 1,
  background: COLORS.paper,
  color: COLORS.teal,
  border: `1px solid ${COLORS.teal}`,
  padding: '10px 12px',
  borderRadius: 10,
  fontFamily: 'Manrope, sans-serif',
  fontSize: 13, fontWeight: 700,
  cursor: 'pointer',
  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
};

function floorChipStyle(active) {
  return {
    padding: '10px 6px',
    background: active ? COLORS.teal : COLORS.paper,
    color: active ? COLORS.paper : COLORS.ink,
    border: `1px solid ${active ? COLORS.teal : COLORS.paperEdge}`,
    borderRadius: 10,
    fontFamily: 'Manrope, sans-serif',
    fontSize: 12, fontWeight: 700,
    cursor: 'pointer',
  };
}

const inputStyle = {
  width: '100%',
  padding: '10px 12px',
  border: `1px solid ${COLORS.paperEdge}`,
  borderRadius: 10,
  background: COLORS.paperDeep,
  fontFamily: 'Manrope, system-ui, sans-serif',
  fontSize: 14,
  color: COLORS.ink,
  outline: 'none',
  boxSizing: 'border-box',
};

// ─────────────────────────────────────────────────────────────
// LINE QUIZ TIME — trivia game for waiting in line
// ─────────────────────────────────────────────────────────────
// Three-phase flow: pick an area → answer 10 timed multiple-choice questions
// → see your score with per-question review. Question bank lives in
// QUIZ_BANK below, keyed by area id.

const QUIZ_AREAS = [
  { id: 'all',          label: 'All Parks',          park: 'Disney',   color: COLORS.teal,    glyph: '★' },
  { id: 'main-street',  label: 'Main Street',        park: 'DLR',      color: COLORS.mustard, glyph: '🎡' },
  { id: 'adventure',    label: 'Adventureland',      park: 'DLR',      color: COLORS.moss,    glyph: '🌴' },
  { id: 'frontier',     label: 'Frontierland',       park: 'DLR',      color: COLORS.mustard, glyph: '⛰' },
  { id: 'new-orleans',  label: 'New Orleans Square', park: 'DLR',      color: COLORS.grape,   glyph: '⚜' },
  { id: 'critter',      label: 'Critter Country',    park: 'DLR',      color: COLORS.moss,    glyph: '🦝' },
  { id: 'galaxys-edge', label: "Galaxy's Edge",      park: 'DLR',      color: COLORS.ink,     glyph: '✦' },
  { id: 'fantasy',      label: 'Fantasyland',        park: 'DLR',      color: COLORS.grape,   glyph: '🏰' },
  { id: 'tomorrow',     label: 'Tomorrowland',       park: 'DLR',      color: COLORS.teal,    glyph: '🚀' },
  { id: 'cars',         label: 'Cars Land',          park: 'DCA',      color: COLORS.clay,    glyph: '🏁' },
  { id: 'hollywood',    label: 'Hollywood Land',     park: 'DCA',      color: COLORS.grape,   glyph: '🎬' },
  { id: 'avengers',     label: 'Avengers Campus',    park: 'DCA',      color: COLORS.teal,    glyph: '⚡' },
  { id: 'pixar-pier',   label: 'Pixar Pier',         park: 'DCA',      color: COLORS.mustard, glyph: '🎢' },
  { id: 'paradise',     label: 'Paradise Gardens',   park: 'DCA',      color: COLORS.moss,    glyph: '🌊' },
  { id: 'grizzly',      label: 'Grizzly Peak',       park: 'DCA',      color: COLORS.moss,    glyph: '🌲' },
  { id: 'buena-vista',  label: 'Buena Vista Street', park: 'DCA',      color: COLORS.mustard, glyph: '🎭' },
];

// Question schema: { q, options: [4], answer: 0..3, fact }
// `fact` shown after the answer reveals — short trivia bite.
const QUIZ_BANK = {
  all: [
    { q: 'What year did Disneyland open?', options: ['1949', '1955', '1962', '1971'], answer: 1, fact: 'Disneyland opened on July 17, 1955 — its grand opening was actually called "Black Sunday" by Walt because so much went wrong.' },
    { q: 'Who was the first Disney Princess?', options: ['Cinderella', 'Aurora', 'Snow White', 'Belle'], answer: 2, fact: 'Snow White debuted in 1937 — the first feature-length animated film in history.' },
    { q: 'How many attractions did Disneyland open with on Opening Day?', options: ['12', '18', '25', '32'], answer: 1, fact: 'Just 18 — and several were not even fully working that hot July day.' },
    { q: 'What is the name of Walt Disney\'s mouse before Mickey?', options: ['Oscar', 'Oswald', 'Otto', 'Olly'], answer: 1, fact: 'Oswald the Lucky Rabbit — Walt lost the rights to him in 1928, which led to Mickey.' },
    { q: 'What is the name of the train that circles Disneyland?', options: ['Disneyland Express', 'Disneyland Railroad', 'Magic Kingdom Line', 'Steam Circle'], answer: 1, fact: 'The Disneyland Railroad has five locomotives, all named after Santa Fe Railway figures.' },
    { q: 'What color is Mickey\'s car icon on the Disney parks app?', options: ['Red', 'Blue', 'Yellow', 'Green'], answer: 0, fact: 'Mickey\'s classic shorts are red — the iconic red and yellow combo dates back to the 1930s.' },
    { q: 'What year did Walt Disney die?', options: ['1955', '1966', '1971', '1981'], answer: 1, fact: 'Walt died December 15, 1966, five years before Walt Disney World opened.' },
    { q: 'Which Disney park opened in California Adventure on opening day in 2001?', options: ['Soarin\' Over California', 'Radiator Springs Racers', 'Toy Story Midway Mania', 'Guardians of the Galaxy'], answer: 0, fact: 'Soarin\' opened with DCA in 2001; Radiator Springs Racers came in 2012.' },
    { q: 'What is the name of the iconic central plaza at Disneyland?', options: ['Town Square', 'Hub / Plaza', 'Main Circle', 'Castle Court'], answer: 1, fact: 'The Hub (officially Plaza) is where every land radiates from — a Walt design choice for easy navigation.' },
    { q: 'How tall is Sleeping Beauty Castle?', options: ['51 feet', '77 feet', '101 feet', '189 feet'], answer: 1, fact: '77 feet — Walt insisted it be human-scale so it would feel intimate, not intimidating.' },
    { q: 'What is the first attraction guests see entering Disneyland?', options: ['Main Street Train Station', 'Castle', 'Carousel', 'Walt Disney Statue'], answer: 0, fact: 'The Main Street Station — Walt loved trains, and the railroad was a personal passion project.' },
    { q: 'Which character was originally going to be named "Mortimer Mouse"?', options: ['Donald', 'Goofy', 'Mickey', 'Pluto'], answer: 2, fact: 'Walt\'s wife Lillian convinced him "Mortimer" was too pompous — Mickey it was.' },
    { q: 'Which is NOT one of the original "Big Five" lands at Disneyland?', options: ['Frontierland', 'Tomorrowland', 'Mickey\'s Toontown', 'Fantasyland'], answer: 2, fact: 'Toontown opened in 1993 — long after the original Adventureland, Frontierland, Fantasyland, and Tomorrowland.' },
    { q: 'What is "E-Ticket" originally referred to?', options: ['Entry pass', 'A premium ride coupon', 'Employee badge', 'Express line ticket'], answer: 1, fact: 'In the original ticket-book era, E-tickets got you onto the best rides like Pirates and Matterhorn.' },
    { q: 'What is the unofficial Disney park gesture for pointing?', options: ['One finger', 'Thumb up', 'Two fingers', 'Open hand'], answer: 2, fact: 'Cast members are trained to point with two fingers (or full hand) — a gesture considered more polite worldwide.' },
    { q: 'How many Disney parks are there worldwide?', options: ['8', '10', '12', '14'], answer: 2, fact: '12 theme parks across 6 resort destinations: Anaheim, Orlando, Paris, Tokyo, Hong Kong, and Shanghai.' },
    { q: 'What does "Have a magical day" replace in Disney cast vocabulary?', options: ['Hello', 'Goodbye / "Have a nice day"', 'Welcome', 'Thank you'], answer: 1, fact: 'Cast members are trained to say "magical" instead of "nice" — part of Disney\'s scripted hospitality.' },
    { q: 'Which is true about Walt\'s apartment above the Fire Station?', options: ['It is a museum', 'A lamp burns when the family is in residence', 'It is a hidden suite', 'It does not exist'], answer: 1, fact: 'A lamp in the window stays lit in Walt\'s honor — the apartment is preserved as it was.' },
    { q: 'What was the very first Disneyland attraction to open after July 1955?', options: ['Dumbo', 'Tom Sawyer Island', 'Matterhorn', 'Astro Jets'], answer: 3, fact: 'Astro Jets opened in March 1956 — Tomorrowland was barely finished on opening day.' },
    { q: 'Which color of M&Ms is the official color of Mickey\'s shorts?', options: ['Yellow', 'Red', 'Brown', 'Blue'], answer: 1, fact: 'Mickey\'s red shorts have been a staple since 1935.' },
  ],
  'main-street': [
    { q: 'What era is Main Street, U.S.A. modeled after?', options: ['1850s', 'Turn of the 20th century', '1920s', '1950s'], answer: 1, fact: 'Main Street is set around 1900 — inspired by Walt\'s hometown of Marceline, Missouri.' },
    { q: 'What sits inside the Main Street Cinema?', options: ['Live shows', 'Mickey Mouse cartoons', 'A wax museum', 'A gift shop'], answer: 1, fact: 'The Cinema continuously plays classic Mickey shorts including "Steamboat Willie."' },
    { q: 'What vehicle ferries guests up and down Main Street?', options: ['Trolleys and horse-drawn streetcars', 'Buses', 'Monorails', 'Pedicabs'], answer: 0, fact: 'Horse-drawn streetcars, an omnibus, fire trucks, and horseless carriages — all in keeping with the era.' },
    { q: 'What is the name of the barbershop on Main Street?', options: ['Walt\'s Cuts', 'Harmony Barber Shop', 'Main Street Trim', 'Mickey\'s Cuts'], answer: 1, fact: 'Harmony Barber Shop offers real haircuts and is famous for the Dapper Dans quartet stopping by to sing.' },
    { q: 'What treat is Main Street most famous for serving?', options: ['Dole Whip', 'Mickey-shaped pretzels', 'Hand-dipped corn dogs', 'Beignets'], answer: 2, fact: 'The Little Red Wagon corn dogs are a Disneyland legend — dipped to order in front of you.' },
    { q: 'What is the name of the central icon at the end of Main Street?', options: ['Cinderella Castle', 'Sleeping Beauty Castle', 'Beast\'s Castle', 'Snow White Castle'], answer: 1, fact: 'Sleeping Beauty Castle was chosen because the film was about to release in 1959.' },
    { q: 'What is the "Partners" statue?', options: ['Mickey and Minnie', 'Walt and Mickey', 'Walt and Roy', 'Mickey and Pluto'], answer: 1, fact: 'Walt and Mickey holding hands — sculpted by Imagineer Blaine Gibson, unveiled in 1993.' },
    { q: 'What unique smell pumps onto Main Street in the morning?', options: ['Cinnamon and vanilla', 'Roses', 'Popcorn', 'Pine'], answer: 0, fact: 'Smellitzers pump scents like vanilla and cinnamon to evoke baking — Disney\'s scent-engineering tech.' },
    { q: 'What is the building above the Disneyland Fire Department?', options: ['A hotel suite', 'Walt\'s private apartment', 'Storage', 'A meeting room'], answer: 1, fact: 'Walt designed the apartment for his family during park visits — preserved exactly as he left it.' },
    { q: 'Which window on Main Street is essentially a "credit"?', options: ['A theme', 'A tribute to an Imagineer', 'Just decoration', 'Tax sign'], answer: 1, fact: 'Each window honors an Imagineer or contributor — the highest honor at Disney.' },
  ],
  adventure: [
    { q: 'How long is the Jungle Cruise river journey?', options: ['About 2 minutes', 'About 7 minutes', 'About 12 minutes', 'About 20 minutes'], answer: 1, fact: 'The Jungle Cruise lasts ~7 minutes — packed with corny puns delivered live by skippers.' },
    { q: 'What planet does the Indiana Jones Adventure take place on?', options: ['Earth', 'Tatooine', 'Pandora', 'Crystal Skull'], answer: 0, fact: 'Indy is set in 1935 India — the Temple of the Forbidden Eye.' },
    { q: 'What deity guards the Indiana Jones temple?', options: ['Anubis', 'Mara', 'Kali', 'Quetzalcoatl'], answer: 1, fact: 'Mara grants three gifts — but never look in her eyes!' },
    { q: 'Which house does the Tarzan tree house once belong to?', options: ['Tarzan', 'The Swiss Family Robinson', 'Both — it has been both', 'Robin Hood'], answer: 2, fact: 'Originally Swiss Family Treehouse (1962), retheme to Tarzan\'s Treehouse in 1999, now Adventureland Treehouse.' },
    { q: 'What is the name of Indy\'s unreliable transport?', options: ['Bessie', 'Doris', 'Mara-mobile', 'Jeep #1'], answer: 0, fact: 'Each enhanced motion vehicle on Indy is a different number — tracked carefully by maintenance.' },
    { q: 'How many backside-of-water sights are on Jungle Cruise?', options: ['One', 'Two', 'Three', 'It\'s a joke — there is one'], answer: 3, fact: 'The "backside of water" gag is one of the oldest jokes in Disney history.' },
    { q: 'What is the Tiki Room\'s defining feature?', options: ['It is dark', 'Audio-animatronic birds singing', 'A drop ride', 'A dance floor'], answer: 1, fact: 'Walt\'s Enchanted Tiki Room (1963) was the first audio-animatronic attraction ever.' },
    { q: 'Which actor voices skippers on the Jungle Cruise?', options: ['Tom Hanks', 'There is no recorded voice — skippers are live', 'Dwayne Johnson', 'Jim Cummings'], answer: 1, fact: 'Skippers ad-lib live — the script is a starting point, but jokes are personal.' },
  ],
  frontier: [
    { q: 'What is the wildest ride in the wilderness?', options: ['Splash Mountain', 'Big Thunder Mountain Railroad', 'Matterhorn', 'Space Mountain'], answer: 1, fact: 'Big Thunder\'s tagline is "the wildest ride in the wilderness" — a mining-train coaster opened 1979.' },
    { q: 'What town is Big Thunder Mountain themed to?', options: ['Tombstone', 'Rainbow Ridge', 'Big Sky', 'Coyote Junction'], answer: 1, fact: 'Rainbow Ridge — the queue\'s buildings are remnants of the original 1956 Mine Train Through Nature\'s Wonderland.' },
    { q: 'What raft destination floats off Frontierland?', options: ['Tom Sawyer Island', 'Pirate\'s Cove', 'Frontier Refuge', 'Mickey\'s Marsh'], answer: 0, fact: 'Tom Sawyer Island opened 1956 — Walt designed it himself.' },
    { q: 'What is the name of the keelboats that used to sail Frontierland?', options: ['Mike Fink Keelboats', 'Mark Twain Boats', 'Davy\'s Drifters', 'Pioneer Punts'], answer: 0, fact: 'Mike Fink Keelboats ran from 1955 to 1997 — discontinued after a capsizing incident.' },
    { q: 'What is the river in Frontierland called?', options: ['Mississippi River', 'Rivers of America', 'Frontier River', 'Mark River'], answer: 1, fact: 'The Rivers of America circle Tom Sawyer Island — site of the Mark Twain Riverboat and Sailing Ship Columbia.' },
    { q: 'What is the only sailing ship in Disneyland?', options: ['Black Pearl', 'Sailing Ship Columbia', 'Mark Twain', 'Liberty Belle'], answer: 1, fact: 'Sailing Ship Columbia is a full-scale replica of the first American ship to circumnavigate the globe.' },
  ],
  'new-orleans': [
    { q: 'How many "happy haunts" are in the Haunted Mansion?', options: ['666', '999', '1313', '1,000'], answer: 1, fact: 'There are 999 happy haunts — but there\'s always room for 1000! Care to volunteer?' },
    { q: 'What year did Pirates of the Caribbean open?', options: ['1955', '1962', '1967', '1972'], answer: 2, fact: 'Pirates opened March 18, 1967 — the last attraction Walt personally oversaw.' },
    { q: 'What is the famous catchphrase from Pirates?', options: ['"Yo ho, yo ho..."', '"Land ho!"', '"Avast ye!"', '"Ahoy mateys!"'], answer: 0, fact: '"Yo ho, yo ho, a pirate\'s life for me" was written by X. Atencio and George Bruns.' },
    { q: 'Who was the first villain added into the Pirates ride for the films?', options: ['Davy Jones', 'Jack Sparrow', 'Blackbeard', 'Barbossa'], answer: 1, fact: 'Captain Jack Sparrow was added in 2006 after the films\' success — a rare film-to-ride retheme.' },
    { q: 'Which restaurant overlooks Pirates of the Caribbean?', options: ['Cafe Orleans', 'Blue Bayou', 'Royal Street Veranda', 'Mint Julep Bar'], answer: 1, fact: 'Blue Bayou — diners watch boats drop into the bayou scene. The fireflies are real LED lights.' },
    { q: 'What is the Haunted Mansion\'s outside architectural style?', options: ['Victorian Gothic', 'Antebellum / Plantation', 'Greek Revival', 'Art Deco'], answer: 1, fact: 'Walt insisted the exterior be pristine — "we\'ll take care of the outside, the ghosts can have the inside."' },
    { q: 'What is the name of the singing busts in Haunted Mansion?', options: ['The Phantom Five', 'The Singing Quartet', 'The Grim Grinning Ghosts', 'The Haunted Hosts'], answer: 2, fact: '"Grim Grinning Ghosts" is the song; the singing busts include vocalist Thurl Ravenscroft (Tony the Tiger\'s voice).' },
    { q: 'What is the Haunted Mansion ride vehicle called?', options: ['Doom Buggy', 'Spirit Cart', 'Ghost Tram', 'Death Coach'], answer: 0, fact: 'Doom Buggies use the Omnimover system — invented by Imagineers Bob Gurr and Roger Broggie.' },
    { q: 'Which seasonal overlay takes over Haunted Mansion?', options: ['Christmas — Tim Burton\'s Nightmare Before Christmas', 'Halloween only', 'New Year\'s Eve', 'No overlay'], answer: 0, fact: 'Haunted Mansion Holiday runs Sept–early January with Jack Skellington overlay.' },
    { q: 'What does the Hatbox Ghost hold?', options: ['A clock', 'His head in a hatbox', 'A lantern', 'A scythe'], answer: 1, fact: 'Removed in 1969, the Hatbox Ghost was restored in 2015 — his head vanishes and reappears in his hatbox.' },
  ],
  critter: [
    { q: 'Which mountain ride sat in Critter Country until 2023?', options: ['Splash Mountain', 'Big Thunder', 'Grizzly Peak', 'Matterhorn'], answer: 0, fact: 'Splash Mountain closed May 2023 to be retheme to Tiana\'s Bayou Adventure (Princess and the Frog).' },
    { q: 'What is the new theme replacing Splash Mountain?', options: ['Frozen', 'Princess and the Frog (Tiana\'s Bayou Adventure)', 'Encanto', 'Moana'], answer: 1, fact: 'Tiana\'s Bayou Adventure follows Princess Tiana on a Mardi Gras prep journey through the bayou.' },
    { q: 'What character\'s hangout is in Critter Country?', options: ['Pooh\'s', 'Tigger\'s', 'Eeyore\'s', 'Roo\'s'], answer: 0, fact: 'The Many Adventures of Winnie the Pooh — opened in 2003 in the former Country Bear Jamboree space.' },
    { q: 'What was Critter Country called before 1988?', options: ['Bear Country', 'Forest Land', 'Frontier Annex', 'Indian Village'], answer: 0, fact: 'Bear Country (1972–1988), themed around the Country Bear Jamboree.' },
    { q: 'Which canoes paddle around Critter Country?', options: ['Davy Crockett Explorer Canoes', 'Pirate Skiffs', 'Pioneer Paddles', 'Big Bear Boats'], answer: 0, fact: 'Davy Crockett Canoes are guest-paddled — actually moving by your effort. A rare interactive attraction.' },
  ],
  'galaxys-edge': [
    { q: 'What planet is Galaxy\'s Edge set on?', options: ['Tatooine', 'Naboo', 'Batuu', 'Hoth'], answer: 2, fact: 'Batuu is on the Outer Rim — a remote trading post called Black Spire Outpost.' },
    { q: 'What is the name of the cantina in Galaxy\'s Edge?', options: ['Mos Eisley Cantina', 'Oga\'s Cantina', 'Cantina del Sol', 'Jedi Bar'], answer: 1, fact: 'Oga\'s Cantina is run by Oga Garra, a Blutopian who runs Black Spire\'s social hub.' },
    { q: 'Which ride lets you pilot the Millennium Falcon?', options: ['Smugglers Run', 'Rise of the Resistance', 'Falcon Dive', 'Star Tours'], answer: 0, fact: 'Millennium Falcon: Smugglers Run has 6 guests crewing — pilots, gunners, engineers.' },
    { q: 'How long is Rise of the Resistance, end-to-end?', options: ['8 minutes', '12 minutes', '18 minutes', '25 minutes'], answer: 2, fact: '~18 minutes — multiple ride systems, a trackless dark ride, drop, and full sets. One of the longest rides in any park.' },
    { q: 'Who runs the marketplace stall "Dok-Ondar\'s"?', options: ['A Jawa', 'An Ithorian named Dok-Ondar', 'A Wookiee', 'A Twi\'lek'], answer: 1, fact: 'Dok-Ondar is an Ithorian (hammerhead) collector — his shop is full of authentic-feeling Star Wars artifacts.' },
    { q: 'What can you build at Savi\'s Workshop?', options: ['A blaster', 'A custom lightsaber', 'A droid', 'A holocron'], answer: 1, fact: 'Lightsabers run ~$250 — built by guests under guidance of Gatherers in a ceremonial experience.' },
    { q: 'What can you build at Droid Depot?', options: ['Astromechs (R-series) or BB-series', 'Lightsabers', 'Blasters', 'Holocrons'], answer: 0, fact: 'Choose between R-unit or BB-unit — they react to certain Galaxy\'s Edge environments via remote.' },
    { q: 'What is the in-park currency tied to Galaxy\'s Edge?', options: ['Galactic Credits', 'Spira Credits (galactic credits on physical medallion)', 'Resistance Coins', 'Imperial chips'], answer: 1, fact: 'Spira credits exist as themed metal coins — but everyone still uses USD or charge cards.' },
    { q: 'What drink is served in Oga\'s Cantina that\'s blue?', options: ['Bantha Milk', 'Blue Milk', 'Tatooine Twist', 'Sky Punch'], answer: 1, fact: 'Blue Milk (and Green Milk) — plant-based, with a tropical-citrus taste.' },
    { q: 'What is the resistance fighter played at the start of Rise of the Resistance?', options: ['Rey', 'Poe', 'Finn', 'BB-8'], answer: 0, fact: 'Rey appears via projection telling you you\'re joining the Resistance — before the Star Destroyer captures you.' },
  ],
  fantasy: [
    { q: 'How many countries are represented in "It\'s a Small World"?', options: ['Around 50', 'Around 100', 'Around 300', 'Around 500'], answer: 2, fact: 'About 300 dolls representing children from around the world — the song has been called the most-played music ever.' },
    { q: 'What year did the Matterhorn Bobsleds open?', options: ['1955', '1959', '1965', '1970'], answer: 1, fact: 'Matterhorn opened June 14, 1959 — the world\'s first tubular steel-tracked roller coaster.' },
    { q: 'Who sings the famous song in "It\'s a Small World"?', options: ['Disney Choir', 'The Sherman Brothers wrote it; children sing in many languages', 'Julie Andrews', 'Annette Funicello'], answer: 1, fact: 'Robert and Richard Sherman wrote the song in 1964 — originally for the World\'s Fair pavilion.' },
    { q: 'What yeti lurks on the Matterhorn?', options: ['Has no name', 'Harold', 'Frank', 'Klaus'], answer: 1, fact: 'Harold the Abominable Snowman has roamed the Matterhorn since 1978.' },
    { q: 'How many tea cups can you spin?', options: ['12', '14', '18', '24'], answer: 2, fact: 'Mad Tea Party has 18 tea cups — based on the Unbirthday Party from Alice in Wonderland (1951).' },
    { q: 'What is the only original 1955 Fantasyland dark ride still operating in original form?', options: ['Mr. Toad\'s Wild Ride', 'Snow White\'s Scary Adventures', 'Peter Pan\'s Flight', 'Casey Jr.'], answer: 0, fact: 'Mr. Toad\'s Wild Ride — though heavily refurbished, it operates close to its original 1955 layout.' },
    { q: 'What ride takes you flying over London?', options: ['Peter Pan\'s Flight', 'Soarin\'', 'Dumbo', 'Casey Jr.'], answer: 0, fact: 'Peter Pan\'s Flight uses a suspended track that gives the illusion of flying — original 1955 attraction.' },
    { q: 'Which famous princess castle is at Disneyland?', options: ['Cinderella\'s', 'Sleeping Beauty\'s', 'Belle\'s', 'Elsa\'s'], answer: 1, fact: 'Sleeping Beauty Castle — Cinderella Castle is at Walt Disney World in Florida.' },
    { q: 'What flies above Fantasyland on the carousel attraction?', options: ['Jumbo elephants', 'Flying Dumbo elephants', 'Pegasus horses', 'Angels'], answer: 1, fact: 'Dumbo the Flying Elephant — original 1955 attraction; elephants raise/lower with rider control.' },
    { q: 'What is King Arthur Carrousel\'s lead horse called?', options: ['Hercules', 'Jingles', 'Aurora\'s Steed', 'Champion'], answer: 1, fact: 'Jingles — dedicated to Julie Andrews, with Mary Poppins details added on her 50th-anniversary visit.' },
  ],
  tomorrow: [
    { q: 'What year did Space Mountain open at Disneyland?', options: ['1971', '1977', '1983', '1989'], answer: 1, fact: 'Space Mountain opened May 27, 1977 — Walt Disney World\'s opened in 1975, two years earlier.' },
    { q: 'What was the Astro Orbitor originally called?', options: ['Astro Jets', 'Rocket Jets', 'Tomorrow Flyers', 'Sky Cycles'], answer: 0, fact: 'It opened as "Astro Jets" in 1956, became "Rocket Jets" in 1967, and Astro Orbitor in 1998.' },
    { q: 'What is the name of the racing attraction in Tomorrowland?', options: ['Autopia', 'Speedway', 'Race Track', 'Indy 500'], answer: 0, fact: 'Autopia opened in 1955 — sponsored over the years by Richfield, Chevron, and Honda.' },
    { q: 'What was Innoventions before Star Wars Launch Bay?', options: ['America Sings', 'Carousel of Progress', 'Both before Innoventions', 'It was always Innoventions'], answer: 2, fact: 'The building hosted Carousel of Progress, then America Sings, then Innoventions, then Star Wars Launch Bay.' },
    { q: 'What flies guests through Star Tours?', options: ['Falcon', 'StarSpeeder 1000', 'X-Wing', 'TIE Bomber'], answer: 1, fact: 'StarSpeeder 1000 — the original ride used StarSpeeder 3000 (1987–2010).' },
    { q: 'What did Buzz Lightyear shoot at when the ride first opened?', options: ['Z buttons', 'Targets in Zurg\'s base', 'Aliens', 'Stars'], answer: 1, fact: 'Buzz Lightyear Astro Blasters has guests shoot Z-marked targets in Zurg\'s base — score increases with hits.' },
    { q: 'How fast does Space Mountain go at top speed?', options: ['~28 mph', '~35 mph', '~45 mph', '~60 mph'], answer: 1, fact: 'Top speed about 35 mph — feels much faster in the dark!' },
    { q: 'What was the Finding Nemo Submarine Voyage called originally?', options: ['Submarine Voyage', '20,000 Leagues', 'Sub Adventure', 'Nautilus'], answer: 0, fact: 'Submarine Voyage opened 1959 — closed 1998, retheme to Finding Nemo and reopened 2007.' },
    { q: 'What replaced the PeopleMover at Disneyland?', options: ['Rocket Rods', 'Skyway', 'Autopia', 'Nothing — it just closed'], answer: 0, fact: 'Rocket Rods (1998–2000) replaced PeopleMover but were plagued with issues. Track still visible above Tomorrowland.' },
    { q: 'What attraction features the song "There\'s a Great Big Beautiful Tomorrow"?', options: ['Carousel of Progress', 'Innoventions', 'Space Mountain', 'World of Color'], answer: 0, fact: 'Carousel of Progress — moved from the 1964 World\'s Fair, ran at Disneyland 1967–1973.' },
  ],
  cars: [
    { q: 'What town is Cars Land based on?', options: ['Cars City', 'Radiator Springs', 'Route 66 City', 'Carburetor County'], answer: 1, fact: 'Radiator Springs — the fictional town from the Cars films, faithfully rebuilt at scale.' },
    { q: 'What is the top speed of Radiator Springs Racers?', options: ['25 mph', '40 mph', '55 mph', '70 mph'], answer: 1, fact: 'Top speed about 40 mph — uses the same tech as Test Track at Epcot.' },
    { q: 'What real highway inspired Cars Land\'s design?', options: ['Route 66', 'Pacific Coast Highway', 'Interstate 5', 'Lincoln Highway'], answer: 0, fact: 'Route 66 — Pixar\'s research trips along the Mother Road shaped both the film and the land.' },
    { q: 'What rotates riders at Luigi\'s Rollickin\' Roadsters?', options: ['Cars dance via trackless choreography', 'Centrifuge', 'Spinner', 'It\'s a coaster'], answer: 0, fact: 'Trackless cars perform a choreographed Italian dance — a complex programmed routine.' },
    { q: 'What does Mater\'s Junkyard Jamboree spin riders to?', options: ['Country songs', 'Mater\'s songs / square dance', 'Pop hits', 'Random songs'], answer: 1, fact: 'Mater sings square-dance numbers as you spin — the song changes order every cycle.' },
    { q: 'What\'s the name of Lightning McQueen\'s love interest?', options: ['Sally', 'Mater', 'Holly', 'Flo'], answer: 0, fact: 'Sally Carrera, a 2002 Porsche 911. The Cozy Cone Motel is hers.' },
    { q: 'What is the Cozy Cone Motel themed around?', options: ['Tepees', 'Traffic cones', 'Tents', 'Igloos'], answer: 1, fact: 'Each "cone" is a giant orange traffic cone — sold snacks like ice cream cones and pretzels in cones.' },
    { q: 'When did Cars Land open at California Adventure?', options: ['2001', '2007', '2012', '2016'], answer: 2, fact: 'Cars Land opened June 15, 2012 — part of DCA\'s billion-dollar expansion.' },
  ],
  hollywood: [
    { q: 'What stunt-themed attraction is in Hollywood Land?', options: ['Hollywood Stunt Spectacular', 'Tower of Terror', 'Guardians of the Galaxy: Mission BREAKOUT!', 'Walking Dead'], answer: 2, fact: 'Tower of Terror was retheme into Guardians: Mission BREAKOUT! in 2017 — first major Marvel addition.' },
    { q: 'Who is the curator of the Tivan Collection in Mission BREAKOUT!?', options: ['Star-Lord', 'The Collector (Taneleer Tivan)', 'Rocket', 'Yondu'], answer: 1, fact: 'Benicio del Toro reprises the Collector — he\'s captured the Guardians as exhibits.' },
    { q: 'What disco songs play during Mission BREAKOUT!?', options: ['ABBA only', 'Awesome Mix vol. classics like "Free Ride," "Hit Me with Your Best Shot"', 'Modern hits', 'No music'], answer: 1, fact: 'Each ride cycle plays a different song — six different drop sequences from the Awesome Mix tape.' },
    { q: 'What used to be Hollywood Land\'s name?', options: ['Hollywood Pictures Backlot', 'Studio Land', 'Movieland', 'Backlot'], answer: 0, fact: 'Hollywood Pictures Backlot until 2010 — renamed when Walt Disney Studios was added.' },
    { q: 'What is Disney Animation\'s Sorcerer\'s Workshop?', options: ['A ride', 'An interactive walkthrough', 'A live show', 'A restaurant'], answer: 1, fact: 'Walkthrough exhibit with multiple rooms exploring the art of Disney animation.' },
    { q: 'What live-action character meets in Hollywood Land\'s animation building?', options: ['Anna and Elsa', 'Mickey & Minnie', 'Tiana', 'Aurora'], answer: 0, fact: 'Anna & Elsa\'s Royal Welcome and other character experiences live in this building.' },
  ],
  avengers: [
    { q: 'What year did Avengers Campus open?', options: ['2018', '2020', '2021', '2023'], answer: 2, fact: 'Avengers Campus opened June 4, 2021 — delayed by COVID-19.' },
    { q: 'What ride lets guests sling webs alongside Spider-Man?', options: ['Web Slingers', 'Spider-Cycle', 'Spider-Lift', 'Quinjet'], answer: 0, fact: 'WEB SLINGERS: A Spider-Man Adventure uses gesture tracking to launch virtual webs.' },
    { q: 'What does WEB stand for?', options: ['Web Enterprises Bureau', 'Worldwide Engineering Brigade', 'Wakandan Engineering Branch', 'Web Engineers Battalion'], answer: 1, fact: 'Worldwide Engineering Brigade — Tony Stark\'s science-and-engineering organization.' },
    { q: 'What ride was retheme into Guardians of the Galaxy?', options: ['Hollywood Tower of Terror', 'It\'s a Small World', 'Soarin\'', 'California Screamin\''], answer: 0, fact: 'Tower of Terror became Guardians: Mission BREAKOUT! in 2017 — kicking off Marvel at DCA.' },
    { q: 'What food spot is famous for Spider-Man-themed treats?', options: ['Pym Test Kitchen', 'Avengers HQ', 'Stark Bar', 'Shawarma Palace'], answer: 0, fact: 'Pym Test Kitchen plays with size — giant pretzels, mini hot dogs.' },
    { q: 'Who shows up at Avengers HQ?', options: ['Spider-Man only', 'Multiple Avengers including Black Widow, Thor, Captain America, etc.', 'Just Iron Man', 'Just Star-Lord'], answer: 1, fact: 'Avengers HQ is a meet location for nearly every Avenger including Doctor Strange and Loki.' },
    { q: 'What Spider-Man flying stunt happens above Avengers Campus?', options: ['Animatronic', 'A live stunt-double on a wire system', 'Animated projection', 'Drone'], answer: 1, fact: 'Stunt performers fly via custom rigs — an animatronic was tested but live stunts proved more dynamic.' },
  ],
  'pixar-pier': [
    { q: 'What roller coaster anchors Pixar Pier?', options: ['California Screamin\'', 'Incredicoaster', 'Pixar Coaster', 'Mickey Mountain'], answer: 1, fact: 'Incredicoaster — formerly California Screamin\', retheme around The Incredibles 2 in 2018.' },
    { q: 'What does Pixar Pier replace?', options: ['Paradise Pier', 'Boardwalk', 'Pleasure Pier', 'Fantasy Pier'], answer: 0, fact: 'Paradise Pier was rebranded Pixar Pier in 2018 — themes rolled out gradually.' },
    { q: 'Which Pixar character\'s "world" includes a Ferris wheel?', options: ['Mickey\'s', 'Inside Out\'s', 'Pixar Pal-A-Round (with Mickey)', 'Toy Story\'s'], answer: 2, fact: 'Mickey\'s Fun Wheel was renamed Pixar Pal-A-Round — features sliding gondolas and stationary cars.' },
    { q: 'What Toy Story attraction lives on Pixar Pier?', options: ['Toy Story Mania!', 'Slinky Dog Coaster', 'Buzz Lightyear', 'Andy\'s Room'], answer: 0, fact: 'Toy Story Midway Mania — guests fire 4D virtual projectiles at carnival games.' },
    { q: 'What Inside Out attraction opened in 2024?', options: ['Joy\'s Train', 'Inside Out Emotional Whirlwind', 'Sadness Slide', 'Anger Coaster'], answer: 1, fact: 'Emotional Whirlwind (2024) — a spinning ride with Riley\'s "islands of personality."' },
  ],
  paradise: [
    { q: 'What ride simulates a hang-gliding tour?', options: ['Soarin\' Around the World', 'Goofy\'s Sky School', 'Astro Orbitor', 'California Screamin\''], answer: 0, fact: 'Soarin\' opened with DCA in 2001 as Soarin\' Over California — global version added 2016.' },
    { q: 'What scent is sprayed during Soarin\'?', options: ['No scent', 'Pine, citrus, ocean — varies by scene', 'Bubble gum', 'Caramel'], answer: 1, fact: 'Soarin\' pumps scents — Yosemite pine, ocean breeze, Disneyland fireworks ozone, and more.' },
    { q: 'What is the Little Mermaid attraction at Paradise Gardens?', options: ['Ariel\'s Undersea Adventure', 'Sebastian\'s Calypso', 'Eric\'s Voyage', 'Triton\'s Castle'], answer: 0, fact: 'The Little Mermaid: Ariel\'s Undersea Adventure — a slow-moving Omnimover dark ride.' },
    { q: 'What characters live near Goofy\'s Sky School?', options: ['Pixar', 'Mickey, Donald, Goofy', 'Frozen', 'Star Wars'], answer: 1, fact: 'Goofy\'s Sky School (formerly Mulholland Madness) themed to a Goofy "How to Fly" cartoon.' },
  ],
  grizzly: [
    { q: 'What does Grizzly River Run simulate?', options: ['White-water rafting', 'Coaster', 'Sub voyage', 'Train ride'], answer: 0, fact: 'Grizzly River Run — circular rafts spin down a recreated American River.' },
    { q: 'How tall is Grizzly Peak (the rock)?', options: ['65 ft', '110 ft', '170 ft', '230 ft'], answer: 2, fact: 'Roughly 170 feet — shaped like a grizzly bear\'s head when viewed from the right angle.' },
    { q: 'What is the airfield section\'s theme?', options: ['Modern aviation', 'Historic California aviation / Soarin\'', 'Space exploration', 'Air races'], answer: 1, fact: 'Grizzly Peak Airfield houses Soarin\' — themed as a vintage California aviation airfield.' },
    { q: 'What ranger characters roam Grizzly Peak?', options: ['Smokey Bear', 'The Redwood Creek rangers', 'Ranger Rick', 'Gus the Bear'], answer: 1, fact: 'Redwood Creek Challenge Trail features rangers and the Wilderness Explorer (Up) program.' },
  ],
  'buena-vista': [
    { q: 'What era is Buena Vista Street themed to?', options: ['Modern day', '1920s/1930s Los Angeles', '1950s', 'Victorian Era'], answer: 1, fact: 'Buena Vista Street represents the 1920s/30s LA Walt arrived to from Kansas City.' },
    { q: 'What is the Red Car Trolley?', options: ['Themed bus', 'Replica Pacific Electric trolley', 'Modern light rail', 'Cable car'], answer: 1, fact: 'Replicas of the historic Pacific Electric "Red Cars" — though as of 2020 the trolley operates only as a character vehicle.' },
    { q: 'What sits at the end of Buena Vista Street?', options: ['Carthay Circle Restaurant', 'Castle', 'Tower of Terror', 'Pier'], answer: 0, fact: 'Carthay Circle Theatre — a replica of where Snow White premiered in 1937.' },
    { q: 'What replaced the original DCA entrance plaza?', options: ['Mickey wheel', 'Buena Vista Street (2012)', 'Cars Land entrance', 'Avengers Campus'], answer: 1, fact: 'Buena Vista Street replaced the original Sunshine Plaza in 2012 as part of the DCA reimagining.' },
  ],
};

// Pull questions for an area, falling back to the "all" pool when the
// area-specific bank is small. We always end up with `count` questions in a
// shuffled order, with each question\'s options shuffled too.
function buildQuiz(areaId, count = 10) {
  const pool = (QUIZ_BANK[areaId] || []).slice();
  // Top up from the all-parks pool for areas with smaller banks.
  if (pool.length < count) {
    const general = (QUIZ_BANK.all || []).filter(g => !pool.find(p => p.q === g.q));
    pool.push(...general);
  }
  const shuffled = pool.slice().sort(() => Math.random() - 0.5).slice(0, count);
  return shuffled.map(q => {
    const indexed = q.options.map((opt, i) => ({ opt, isAnswer: i === q.answer }));
    indexed.sort(() => Math.random() - 0.5);
    return {
      q: q.q,
      options: indexed.map(x => x.opt),
      answer: indexed.findIndex(x => x.isAnswer),
      fact: q.fact,
    };
  });
}

const QUIZ_HIGH_KEY = 'ph365.quiz.high';
function loadQuizHighScores() {
  try {
    const raw = localStorage.getItem(QUIZ_HIGH_KEY);
    return raw ? JSON.parse(raw) : {};
  } catch { return {}; }
}
function saveQuizHighScore(areaId, score) {
  try {
    const all = loadQuizHighScores();
    if (!all[areaId] || score > all[areaId]) {
      all[areaId] = score;
      localStorage.setItem(QUIZ_HIGH_KEY, JSON.stringify(all));
    }
  } catch { /* localStorage may be disabled */ }
}

const QUIZ_TIME_PER_Q = 15; // seconds

function QuizScreen() {
  const [phase, setPhase]       = React.useState('area');   // 'area' | 'play' | 'result'
  const [areaId, setAreaId]     = React.useState(null);
  const [questions, setQs]      = React.useState([]);
  const [qIndex, setQIndex]     = React.useState(0);
  const [picks, setPicks]       = React.useState([]);       // index per question (-1 = timeout)
  const [revealed, setRevealed] = React.useState(false);    // showing answer for current Q
  const [timeLeft, setTimeLeft] = React.useState(QUIZ_TIME_PER_Q);
  const [flash, setFlash]       = React.useState(null);     // 'correct' | 'wrong' | null
  const [highScores, setHigh]   = React.useState(() => loadQuizHighScores());

  function startQuiz(id) {
    setAreaId(id);
    setQs(buildQuiz(id, 10));
    setQIndex(0);
    setPicks([]);
    setRevealed(false);
    setTimeLeft(QUIZ_TIME_PER_Q);
    setFlash(null);
    setPhase('play');
  }

  // Per-question countdown — runs until answer is revealed.
  React.useEffect(() => {
    if (phase !== 'play' || revealed) return;
    setTimeLeft(QUIZ_TIME_PER_Q);
    const start = Date.now();
    const id = setInterval(() => {
      const elapsed = (Date.now() - start) / 1000;
      const left = Math.max(0, QUIZ_TIME_PER_Q - elapsed);
      setTimeLeft(left);
      if (left <= 0) {
        clearInterval(id);
        handlePick(-1);
      }
    }, 100);
    return () => clearInterval(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phase, qIndex, revealed]);

  function handlePick(choiceIdx) {
    if (revealed) return;
    setRevealed(true);
    setPicks(p => [...p, choiceIdx]);
    const correct = questions[qIndex] && choiceIdx === questions[qIndex].answer;
    setFlash(correct ? 'correct' : 'wrong');
    setTimeout(() => setFlash(null), 600);
  }

  function nextQuestion() {
    if (qIndex + 1 >= questions.length) {
      const score = picks.reduce(
        (s, p, i) => s + (p === questions[i].answer ? 1 : 0),
        0,
      );
      saveQuizHighScore(areaId, score);
      setHigh(loadQuizHighScores());
      setPhase('result');
      return;
    }
    setQIndex(i => i + 1);
    setRevealed(false);
  }

  function backToAreas() {
    setPhase('area');
    setAreaId(null);
    setQs([]);
    setPicks([]);
    setQIndex(0);
    setRevealed(false);
  }

  if (phase === 'area') {
    return <QuizAreaSelect highScores={highScores} onPick={startQuiz} />;
  }
  if (phase === 'play') {
    return (
      <QuizPlay
        question={questions[qIndex]}
        index={qIndex}
        total={questions.length}
        timeLeft={timeLeft}
        revealed={revealed}
        pick={picks[qIndex]}
        flash={flash}
        onPick={handlePick}
        onNext={nextQuestion}
      />
    );
  }
  return (
    <QuizResult
      areaId={areaId}
      questions={questions}
      picks={picks}
      highScore={highScores[areaId]}
      onPlayAgain={() => startQuiz(areaId)}
      onTryAnother={backToAreas}
    />
  );
}

function QuizAreaSelect({ highScores, onPick }) {
  const dlrAreas = QUIZ_AREAS.filter(a => a.park === 'DLR');
  const dcaAreas = QUIZ_AREAS.filter(a => a.park === 'DCA');
  const allArea  = QUIZ_AREAS.find(a => a.id === 'all');

  const Section = ({ title, areas }) => (
    <div style={{ marginBottom: 18 }}>
      <Caption style={{ marginBottom: 8 }}>{title}</Caption>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
        {areas.map(a => {
          const hi = highScores[a.id];
          return (
            <button key={a.id} onClick={() => onPick(a.id)} style={areaBtn(a.color)}>
              <span style={{ fontSize: 22, lineHeight: 1 }}>{a.glyph}</span>
              <span style={{
                fontFamily: 'Fraunces, serif', fontWeight: 600, fontSize: 14,
                color: COLORS.ink, lineHeight: 1.15, textAlign: 'center',
              }}>{a.label}</span>
              {hi != null && (
                <span style={{
                  fontFamily: '"JetBrains Mono", monospace',
                  fontSize: 10, fontWeight: 700,
                  color: a.color, letterSpacing: '0.06em',
                }}>BEST {hi}/10</span>
              )}
            </button>
          );
        })}
      </div>
    </div>
  );

  return (
    <div style={{ padding: 20, paddingBottom: 200 }}>
      <Display style={{ marginBottom: 4 }}>Line Quiz Time</Display>
      <Body style={{ color: COLORS.inkSoft, marginBottom: 20 }}>
        Pick where you are — we\'ll quiz you on the magic.
      </Body>

      <button onClick={() => onPick(allArea.id)} style={{
        ...areaBtn(allArea.color),
        width: '100%',
        flexDirection: 'row',
        gap: 14,
        padding: '16px 18px',
        marginBottom: 18,
        background: `linear-gradient(135deg, ${allArea.color}18, ${COLORS.paper})`,
        borderColor: allArea.color,
      }}>
        <span style={{ fontSize: 28 }}>{allArea.glyph}</span>
        <div style={{ textAlign: 'left', flex: 1 }}>
          <div style={{ fontFamily: 'Fraunces, serif', fontWeight: 700, fontSize: 17, color: COLORS.ink }}>
            All Parks Trivia
          </div>
          <div style={{ fontFamily: 'Manrope', fontSize: 12, color: COLORS.inkSoft }}>
            General Disney knowledge
          </div>
        </div>
        {highScores[allArea.id] != null && (
          <span style={{
            fontFamily: '"JetBrains Mono", monospace',
            fontSize: 11, fontWeight: 700,
            color: allArea.color, letterSpacing: '0.06em',
          }}>BEST {highScores[allArea.id]}/10</span>
        )}
      </button>

      <Section title="Disneyland Park" areas={dlrAreas} />
      <Section title="California Adventure" areas={dcaAreas} />
    </div>
  );
}

function areaBtn(color) {
  return {
    background: COLORS.paper,
    border: `1.5px solid ${COLORS.paperEdge}`,
    borderLeft: `4px solid ${color}`,
    borderRadius: 12,
    padding: '14px 10px',
    cursor: 'pointer',
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'center',
    justifyContent: 'center',
    gap: 6,
    minHeight: 88,
    boxShadow: '0 2px 6px rgba(26,37,102,0.06)',
  };
}

function QuizPlay({ question, index, total, timeLeft, revealed, pick, flash, onPick, onNext }) {
  if (!question) return null;
  const pct = (timeLeft / QUIZ_TIME_PER_Q) * 100;
  const timerColor = timeLeft > 8 ? COLORS.moss
                   : timeLeft > 4 ? COLORS.mustard
                   : COLORS.clay;

  const flashBg = flash === 'correct' ? 'rgba(26,178,138,0.18)'
                : flash === 'wrong'   ? 'rgba(116,107,171,0.18)'
                : 'transparent';

  return (
    <div style={{
      padding: 20, paddingBottom: 160,
      minHeight: '100%',
      transition: 'background 0.3s ease',
      background: flashBg,
    }}>
      {/* Header — question count + timer bar */}
      <div style={{
        display: 'flex', justifyContent: 'space-between', alignItems: 'center',
        marginBottom: 8,
      }}>
        <Caption>Question {index + 1} of {total}</Caption>
        <span style={{
          fontFamily: '"JetBrains Mono", monospace',
          fontSize: 14, fontWeight: 700, color: timerColor,
          fontVariantNumeric: 'tabular-nums',
        }}>{Math.ceil(timeLeft)}s</span>
      </div>
      <div style={{
        height: 6, borderRadius: 3,
        background: 'rgba(26,37,102,0.08)',
        overflow: 'hidden', marginBottom: 22,
      }}>
        <div style={{
          width: `${pct}%`, height: '100%',
          background: timerColor,
          transition: 'width 0.1s linear, background 0.3s ease',
        }} />
      </div>

      {/* Question card */}
      <Card style={{ padding: 20, marginBottom: 20 }}>
        <div style={{
          fontFamily: 'Fraunces, serif',
          fontWeight: 600, fontSize: 20, lineHeight: 1.3,
          color: COLORS.ink,
        }}>{question.q}</div>
      </Card>

      {/* Options */}
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        {question.options.map((opt, i) => {
          const letter = ['A','B','C','D'][i];
          const isCorrect = i === question.answer;
          const isPick    = i === pick;
          let bg = COLORS.paper;
          let border = COLORS.paperEdge;
          let textColor = COLORS.ink;
          let pillBg = COLORS.paperDeep;
          let pillColor = COLORS.ink;
          if (revealed) {
            if (isCorrect) {
              bg = `${COLORS.moss}1A`;
              border = COLORS.moss;
              pillBg = COLORS.moss;
              pillColor = COLORS.paper;
            } else if (isPick) {
              bg = `${COLORS.clay}18`;
              border = COLORS.clay;
              pillBg = COLORS.clay;
              pillColor = COLORS.paper;
            } else {
              textColor = COLORS.inkSoft;
            }
          }
          const bouncing = revealed && isCorrect;
          return (
            <button key={i}
              onClick={() => onPick(i)}
              disabled={revealed}
              style={{
                background: bg,
                border: `1.5px solid ${border}`,
                borderRadius: 12,
                padding: '14px 14px',
                display: 'flex', alignItems: 'center', gap: 12,
                cursor: revealed ? 'default' : 'pointer',
                textAlign: 'left',
                transition: 'background 0.2s, border-color 0.2s, transform 0.18s cubic-bezier(.34,1.56,.64,1)',
                animation: bouncing ? 'quizBounce 0.5s ease-out' : 'none',
              }}>
              <span style={{
                width: 32, height: 32, borderRadius: '50%',
                background: pillBg, color: pillColor,
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                fontFamily: 'Fraunces, serif', fontWeight: 700, fontSize: 15,
                flexShrink: 0,
              }}>{letter}</span>
              <span style={{
                fontFamily: 'Manrope', fontSize: 15, fontWeight: 500,
                color: textColor, lineHeight: 1.3, flex: 1,
              }}>{opt}</span>
              {revealed && isCorrect && (
                <Icon name="check" size={20} color={COLORS.moss} strokeWidth={2.6} />
              )}
              {revealed && isPick && !isCorrect && (
                <Icon name="close" size={20} color={COLORS.clay} strokeWidth={2.4} />
              )}
            </button>
          );
        })}
      </div>

      {/* Reveal panel */}
      {revealed && (
        <div style={{
          marginTop: 18,
          padding: 14,
          background: COLORS.paperDeep,
          borderRadius: 12,
          border: `1px solid ${COLORS.paperEdge}`,
        }}>
          <Caption style={{ marginBottom: 6 }}>
            {pick === question.answer ? '✓ Correct!'
             : pick === -1 ? "⏱ Time's up"
             : '✕ Not quite'}
          </Caption>
          <Body style={{ color: COLORS.ink, lineHeight: 1.4 }}>{question.fact}</Body>
          <button onClick={onNext} style={{
            marginTop: 14, width: '100%',
            background: COLORS.teal, color: COLORS.paper,
            border: 'none', borderRadius: 10,
            padding: '12px 16px',
            fontFamily: 'Manrope', fontWeight: 700, fontSize: 14,
            letterSpacing: '0.04em', cursor: 'pointer',
            boxShadow: '0 4px 10px rgba(74,98,216,0.30)',
          }}>
            {index + 1 >= total ? 'See Results →' : 'Next Question →'}
          </button>
        </div>
      )}
    </div>
  );
}

function QuizResult({ areaId, questions, picks, highScore, onPlayAgain, onTryAnother }) {
  const score = picks.reduce(
    (s, p, i) => s + (p === questions[i].answer ? 1 : 0),
    0,
  );
  const total = questions.length;
  const area = QUIZ_AREAS.find(a => a.id === areaId);
  const verdict = score >= 9 ? { title: 'Disney Expert!', color: COLORS.mustard, sub: 'Walt himself would be proud.' }
                : score >= 7 ? { title: 'True Fan!',      color: COLORS.moss,    sub: 'You really know your magic.' }
                : score >= 5 ? { title: 'Getting There!', color: COLORS.teal,    sub: 'Solid trivia game on you.' }
                             : { title: 'Keep Visiting!', color: COLORS.grape,   sub: 'More park days incoming.' };

  return (
    <div style={{ padding: 20, paddingBottom: 160 }}>
      <Caption style={{ marginBottom: 4 }}>{area ? area.label : 'Quiz'}</Caption>
      <Display style={{ marginBottom: 18 }}>{verdict.title}</Display>

      <div style={{
        background: `linear-gradient(135deg, ${verdict.color}22, ${COLORS.paper})`,
        border: `2px solid ${verdict.color}`,
        borderRadius: 16,
        padding: '24px 16px',
        textAlign: 'center',
        marginBottom: 18,
      }}>
        <div style={{
          fontFamily: 'Fraunces, serif', fontWeight: 700,
          fontSize: 56, lineHeight: 1, color: verdict.color,
        }}>
          {score}<span style={{ color: COLORS.inkSoft, fontSize: 28 }}>/{total}</span>
        </div>
        <div style={{
          fontFamily: 'Manrope', fontSize: 13, color: COLORS.inkSoft,
          marginTop: 6,
        }}>{verdict.sub}</div>
        {highScore != null && score >= highScore && score === highScore && score > 0 && (
          <div style={{
            fontFamily: '"JetBrains Mono", monospace',
            fontSize: 11, fontWeight: 700, color: COLORS.mustard,
            letterSpacing: '0.08em', marginTop: 10,
          }}>★ NEW HIGH SCORE</div>
        )}
        {highScore != null && score < highScore && (
          <div style={{
            fontFamily: '"JetBrains Mono", monospace',
            fontSize: 10, fontWeight: 700, color: COLORS.inkSoft,
            letterSpacing: '0.08em', marginTop: 10,
          }}>BEST {highScore}/{total}</div>
        )}
      </div>

      <Caption style={{ marginBottom: 8 }}>Review</Caption>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 22 }}>
        {questions.map((q, i) => {
          const correct = picks[i] === q.answer;
          const accent = correct ? COLORS.moss : COLORS.clay;
          return (
            <div key={i} style={{
              background: COLORS.paper,
              border: `1px solid ${COLORS.paperEdge}`,
              borderLeft: `3px solid ${accent}`,
              borderRadius: 10,
              padding: '10px 12px',
              display: 'flex', alignItems: 'flex-start', gap: 10,
            }}>
              <span style={{
                width: 22, height: 22, borderRadius: '50%',
                background: accent, color: COLORS.paper,
                fontFamily: 'Fraunces, serif', fontWeight: 700, fontSize: 12,
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                flexShrink: 0, marginTop: 1,
              }}>{i + 1}</span>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{
                  fontFamily: 'Manrope', fontSize: 13, fontWeight: 600,
                  color: COLORS.ink, marginBottom: 2,
                }}>{q.q}</div>
                <div style={{
                  fontFamily: 'Manrope', fontSize: 11.5,
                  color: correct ? COLORS.moss : COLORS.inkSoft,
                }}>
                  {correct
                    ? `✓ ${q.options[q.answer]}`
                    : <>✕ {picks[i] === -1 ? '(out of time)' : q.options[picks[i]]} · <span style={{ color: COLORS.moss }}>Answer: {q.options[q.answer]}</span></>
                  }
                </div>
              </div>
            </div>
          );
        })}
      </div>

      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        <button onClick={onPlayAgain} style={{
          background: COLORS.teal, color: COLORS.paper,
          border: 'none', borderRadius: 12,
          padding: '14px', fontFamily: 'Manrope', fontWeight: 700,
          fontSize: 14, letterSpacing: '0.04em', cursor: 'pointer',
          boxShadow: '0 4px 12px rgba(74,98,216,0.32)',
        }}>Play Again</button>
        <button onClick={onTryAnother} style={{
          background: COLORS.paper, color: COLORS.ink,
          border: `1.5px solid ${COLORS.paperEdge}`,
          borderRadius: 12, padding: '14px',
          fontFamily: 'Manrope', fontWeight: 700,
          fontSize: 14, letterSpacing: '0.04em', cursor: 'pointer',
        }}>Try Another Area</button>
      </div>
    </div>
  );
}

Object.assign(window, {
  RatingModalHost,
  LeaderboardScreen,
  LeaderboardDetailScreen,
  GoToScreen,
  ParkingScreen,
  QuizScreen,
});
})();
