// data.jsx — levels, sounds, achievements, theming, persistence (exported to window)

// ── Level evolution ladder (13 tiers) ─────────────────────
// hours = cumulative focus hours required to ENTER this level.
// left / right = the two-tone orb colours (magenta-ish left, cyan-ish right).
// Every rank uses the app's reference colour — violet (brand accent #B85CFF).
// left/right = a soft violet gradient (badge interior + progress bars), glow = the accent.
const LEVELS = [
  { key: 'spark',       name: 'Spark',       hours: 0,    left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'ember',       name: 'Ember',       hours: 10,   left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'flame',       name: 'Flame',       hours: 25,   left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'pulse',       name: 'Pulse',       hours: 50,   left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'focus',       name: 'Focus',       hours: 80,   left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'flow',        name: 'Flow',        hours: 120,  left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'glow',        name: 'Glow',        hours: 170,  left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'radiant',     name: 'Radiant',     hours: 230,  left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'nova',        name: 'Nova',        hours: 300,  left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'halo',        name: 'Halo',        hours: 400,  left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'zenith',      name: 'Zenith',      hours: 550,  left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'crystal',     name: 'Crystal',     hours: 750,  left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'apex',        name: 'Apex',        hours: 1000, left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'titan',       name: 'Titan',       hours: 1300, left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'champion',    name: 'Champion',    hours: 1700, left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'master',      name: 'Master',      hours: 2100, left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'grandmaster', name: 'Grandmaster', hours: 2600, left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'legend',      name: 'Legend',      hours: 3100, left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'mythic',      name: 'Mythic',      hours: 3600, left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF' },
  { key: 'crown',       name: 'Crown',       hours: 4000, left: '#C46BFF', right: '#7A3FF2', glow: '#B85CFF', cosmic: true },
];

// ── White Noise library ───────────────────────────────────
// The 12 environments, each mapped to its bundled audio file (looped seamlessly).
const SOUNDS = [
  // Core focus sounds are free for everyone: Rain, Forest, Ocean, White Noise.
  // Every other (and any future) ambience is Premium.
  { key: 'rain',      t: 'sound_rain',      file: 'assets/sounds/rain.mp3',      free: true },
  { key: 'forest',    t: 'sound_forest',    file: 'assets/sounds/forest.mp3',    free: true },
  { key: 'ocean',     t: 'sound_ocean',     file: 'assets/sounds/ocean.mp3',     free: true },
  { key: 'coffee',    t: 'sound_coffee',    file: 'assets/sounds/coffee.mp3',    free: false },
  { key: 'fire',      t: 'sound_fire',      file: 'assets/sounds/fire.mp3',      free: false },
  { key: 'thunder',   t: 'sound_thunder',   file: 'assets/sounds/thunder.mp3',   free: false },
  { key: 'jungle',    t: 'sound_jungle',    file: 'assets/sounds/jungle.mp3',    free: false },
  { key: 'night',     t: 'sound_night',     file: 'assets/sounds/night.mp3',     free: false },
  { key: 'stream',    t: 'sound_stream',    file: 'assets/sounds/stream.mp3',    free: false },
  { key: 'wind',      t: 'sound_wind',      file: 'assets/sounds/wind.mp3',      free: false },
  { key: 'clock',     t: 'sound_clock',     file: 'assets/sounds/clock.mp3',     free: false },
  { key: 'waterfall', t: 'sound_waterfall', file: 'assets/sounds/waterfall.mp3', free: false },
];

// Premium value-prop list (rendered on the subscription page).
// Emphasises feature access, not a sound count — future-proof as the library grows.
// Premium value-prop list — V3 model: Free is generous on habit (custom duration, streaks,
// daily goal, 7-day heatmap, ≤3 tags); Premium gates depth + automation + power-user.
const PREMIUM_PERKS = [
  'perk_focus_shield', 'perk_tags', 'perk_analytics', 'perk_heatmap', 'perk_sounds',
  'perk_widgets', 'perk_auto_protect', 'perk_custom_goals', 'perk_quotes', 'perk_sync',
];
const PREMIUM_PRICE = '€3.99';
const PREMIUM_PRICE_YEAR = '€29.99';

// ── V3 features: tags, Focus Shield, streak protection ────────────────────
const TAG_LIMIT_FREE = 3;          // Free: up to 3 tags · Premium: unlimited
const DEFAULT_TAGS = [
  { id: 'work',    label: 'Work',    color: '#B57BFF', builtin: true },
  { id: 'study',   label: 'Study',   color: '#56D8FF', builtin: true },
  { id: 'reading', label: 'Reading', color: '#7CE38B', builtin: true },
];
// Focus Shield has just two modes: "Block Everything" (uses this curated distraction list,
// or a native block-all) and "Custom Blocking" (the user's own sites/apps). `apps` (bundle
// ids / package names) are wired by the native layer later. See src/data/shield.js.
const SHIELD_ALL_SITES = ['twitter.com', 'x.com', 'instagram.com', 'facebook.com', 'tiktok.com',
  'youtube.com', 'reddit.com', 'netflix.com', 'twitch.tv', 'snapchat.com'];
function monthStr(d = new Date()) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); }
// Refined premium accent — a rich, slightly-dark gold (not a flashy bright yellow). Used by
// every Premium indicator (crowns, locks, "PREMIUM" chips) so monetization reads as elegant.
const PREMIUM_GOLD = '#D4A23C';
const FREE_CUSTOM_GOALS = 1; // freemium: 1 custom goal free · additional require Premium

// ── Achievement system ────────────────────────────────────
// Achievements reward consistency, challenges, mastery, premium engagement and sharing —
// NOT accumulated hours (rank progression owns that, exclusively). Each entry carries a
// unique icon, a title + short description, a rarity tier, and a `test(state)` predicate.
// Unlock dates are stamped into state.achUnlocked the first time a test passes.
// rarity: common | rare | epic | legendary
const ACH_GROUPS = ['mastery', 'consistency', 'sessions', 'challenge', 'premium', 'sharing'];
const RARITY = {
  common:    { label: 'Common',    color: '#9aa3bd' },
  rare:      { label: 'Rare',      color: '#58B6FF' },
  epic:      { label: 'Epic',      color: '#B85CFF' },
  legendary: { label: 'Legendary', color: '#FFC04D' },
};
const _mc = (s, m) => ((s.modeCounts || {})[m] || 0);
const _shares = (s) => { const sc = s.shareCounts || {}; return (sc.session || 0) + (sc.stats || 0) + (sc.rank || 0); };
const ACHIEVEMENTS = [
  // Timer Mastery
  { key: 'focus_first',  group: 'mastery', icon: 'Focus',     rarity: 'common', title: 'Focused Start',     desc: 'Use Focus mode for the first time',          test: (s) => _mc(s, 'standard') >= 1 },
  { key: 'pomo_first',   group: 'mastery', icon: 'Clock',     rarity: 'common', title: 'Pomodoro Pioneer',  desc: 'Use Pomodoro mode for the first time',       test: (s) => _mc(s, 'pomodoro') >= 1 },
  { key: 'countup_first',group: 'mastery', icon: 'Wave',      rarity: 'common', title: 'Open Timer',        desc: 'Use Count Up mode for the first time',       test: (s) => _mc(s, 'countup') >= 1 },
  { key: 'modes_10each', group: 'mastery', icon: 'Sparkle',   rarity: 'epic',   title: 'Mode Master',       desc: 'Complete 10 sessions in each timer mode',    test: (s) => _mc(s, 'standard') >= 10 && _mc(s, 'pomodoro') >= 10 && _mc(s, 'countup') >= 10 },
  // Consistency (streaks)
  { key: 'streak_3',     group: 'consistency', icon: 'Flame',    rarity: 'common',    title: '3-Day Streak',   desc: 'Focus 3 days in a row',     test: (s) => (s.longestStreak || 0) >= 3 },
  { key: 'streak_7',     group: 'consistency', icon: 'Streak',   rarity: 'common',    title: '7-Day Streak',   desc: 'Focus 7 days in a row',     test: (s) => (s.longestStreak || 0) >= 7 },
  { key: 'streak_14',    group: 'consistency', icon: 'Calendar', rarity: 'rare',      title: '14-Day Streak',  desc: 'Focus 14 days in a row',    test: (s) => (s.longestStreak || 0) >= 14 },
  { key: 'streak_30',    group: 'consistency', icon: 'Moon',     rarity: 'epic',      title: '30-Day Streak',  desc: 'Focus 30 days in a row',    test: (s) => (s.longestStreak || 0) >= 30 },
  { key: 'streak_100',   group: 'consistency', icon: 'Sun',      rarity: 'legendary', title: '100-Day Streak', desc: 'Focus 100 days in a row',   test: (s) => (s.longestStreak || 0) >= 100 },
  // Sessions
  { key: 'sessions_10',  group: 'sessions', icon: 'Check',  rarity: 'common',    title: '10 Sessions',    desc: 'Complete 10 focus sessions',      test: (s) => (s.sessions || 0) >= 10 },
  { key: 'sessions_50',  group: 'sessions', icon: 'Medal',  rarity: 'rare',      title: '50 Sessions',    desc: 'Complete 50 focus sessions',      test: (s) => (s.sessions || 0) >= 50 },
  { key: 'sessions_250', group: 'sessions', icon: 'Trophy', rarity: 'epic',      title: '250 Sessions',   desc: 'Complete 250 focus sessions',     test: (s) => (s.sessions || 0) >= 250 },
  { key: 'sessions_1000',group: 'sessions', icon: 'Crown',  rarity: 'legendary', title: '1,000 Sessions', desc: 'Complete 1,000 focus sessions',   test: (s) => (s.sessions || 0) >= 1000 },
  // Focus Challenges
  { key: 'nopause_1',  group: 'challenge', icon: 'Bolt',      rarity: 'common', title: 'Unbroken',         desc: 'Complete a session without pausing',      test: (s) => (s.noPauseSessions || 0) >= 1 },
  { key: 'nopause_5',  group: 'challenge', icon: 'Shield',    rarity: 'rare',   title: 'Steady Hand',      desc: 'Complete 5 sessions without pausing',     test: (s) => (s.noPauseSessions || 0) >= 5 },
  { key: 'nopause_25', group: 'challenge', icon: 'Gem',       rarity: 'epic',   title: 'Iron Focus',       desc: 'Complete 25 sessions without pausing',    test: (s) => (s.noPauseSessions || 0) >= 25 },
  { key: 'dur_60',     group: 'challenge', icon: 'Hourglass', rarity: 'rare',   title: 'Deep Hour',        desc: 'Complete a 60-minute session',            test: (s) => (s.longestSessionMin || 0) >= 60 },
  { key: 'dur_120',    group: 'challenge', icon: 'Timer',     rarity: 'epic',   title: 'Marathon',         desc: 'Complete a 120-minute session',           test: (s) => (s.longestSessionMin || 0) >= 120 },
  // Premium
  { key: 'premium', group: 'premium', icon: 'Star', rarity: 'epic', title: 'Premium Member', desc: 'Join EMEO Premium', test: (s) => !!(s.settings && s.settings.premium) },
  // Sharing & Community
  { key: 'share_session', group: 'sharing', icon: 'Share', rarity: 'common', title: 'First Share',     desc: 'Share your first session result',  test: (s) => ((s.shareCounts || {}).session || 0) >= 1 },
  { key: 'share_stats',   group: 'sharing', icon: 'Chart', rarity: 'common', title: 'Show the Numbers', desc: 'Share your statistics',           test: (s) => ((s.shareCounts || {}).stats || 0) >= 1 },
  { key: 'share_rank',    group: 'sharing', icon: 'Target',rarity: 'rare',   title: 'Rank Reveal',      desc: 'Share a rank achievement',        test: (s) => ((s.shareCounts || {}).rank || 0) >= 1 },
  { key: 'share_10',      group: 'sharing', icon: 'Users', rarity: 'rare',   title: 'Spreading Focus',  desc: 'Share 10 times',                  test: (s) => _shares(s) >= 10 },
  { key: 'share_25',      group: 'sharing', icon: 'Heart', rarity: 'epic',   title: 'Community Voice',  desc: 'Share 25 times',                  test: (s) => _shares(s) >= 25 },
];
// Returns the keys earned by `state` but not yet stamped in achUnlocked.
function newlyEarnedAchievements(state) {
  const have = state.achUnlocked || {};
  return ACHIEVEMENTS.filter((a) => !have[a.key] && a.test(state)).map((a) => a.key);
}

// Free session presets (minutes). Custom duration (1–300) is premium.
const PRESETS = [30, 60, 120];
const CUSTOM_MIN = 1, CUSTOM_MAX = 300;

// ── helpers ───────────────────────────────────────────────
function levelForHours(hours) {
  let idx = 0;
  for (let i = 0; i < LEVELS.length; i++) if (hours >= LEVELS[i].hours) idx = i;
  return idx;
}
function nextThreshold(idx) { return idx < LEVELS.length - 1 ? LEVELS[idx + 1].hours : LEVELS[idx].hours; }
function fmtClock(totalSeconds) {
  const s = Math.max(0, Math.round(totalSeconds));
  return { m: String(Math.floor(s / 60)).padStart(2, '0'), s: String(s % 60).padStart(2, '0') };
}
function fmtHours(h) { return h >= 100 ? Math.round(h).toLocaleString() : (Math.round(h * 10) / 10).toString(); }

// Darken a 6-digit hex toward black by factor f (0=black, 1=unchanged) → "rgb(r,g,b)".
function shadeHex(hex, f) {
  const h = String(hex || '#000000').replace('#', '');
  const r = parseInt(h.slice(0, 2), 16) || 0, g = parseInt(h.slice(2, 4), 16) || 0, b = parseInt(h.slice(4, 6), 16) || 0;
  return `rgb(${Math.round(r * f)}, ${Math.round(g * f)}, ${Math.round(b * f)})`;
}
// ── RankBadge ──────────────────────────────────────────────
// One shared circular rank badge used everywhere (Focus, Progress, Share, Evolution,
// Achievements). Structure: the interior is a DEEP, DARKENED shade of the rank's own
// colour (not black) with a soft BLURRED rank-colour glow diffusing from the centre, all
// wrapped by a bold, saturated rank-colour OUTER RING — the dominant, identity-carrying
// element, crisp and readable even small. Prestige grows by tier through ring weight + a
// subtle metallic edge, not extra glow: 0 Spark–Nova · 1 Halo–Crystal · 2 Apex–Champion ·
// 3 Master–Crown. `glow` adds a soft halo for the *current* rank; `reached` dims locked.
function RankBadge({ levelIdx = 0, size = 32, reached = true, glow = true, pop = false, vivid = false }) {
  const i = Math.max(0, Math.min(LEVELS.length - 1, levelIdx | 0));
  const lv = LEVELS[i];
  const c = lv.glow;
  const lit = reached && glow;
  const tier = i >= 15 ? 3 : i >= 12 ? 2 : i >= 9 ? 1 : 0;
  // Interior = darkened rank colour: a touch lighter at the centre, deeper at the rim.
  const fill = reached
    ? `radial-gradient(circle at 50% 42%, ${shadeHex(c, 0.4)} 0%, ${shadeHex(c, 0.26)} 48%, ${shadeHex(c, 0.16)} 100%)`
    : 'rgba(255,255,255,.04)';
  // Bold saturated outer ring — the most visible element. Weight (and a metallic
  // double-edge on higher tiers) carry prestige; the colour stays fully saturated.
  const ringW = [1.75, 1.9, 2.1, 2.3][tier];
  const ring = reached ? c : 'rgba(255,255,255,.2)';
  const layers = [`0 0 0 ${ringW}px ${ring}`];
  if (reached && tier >= 2) layers.push(`0 0 0 ${ringW + 1}px ${c}26`);        // soft metallic outer edge
  if (reached) layers.push('inset 0 1px 1px rgba(255,255,255,.14)');           // faint ring sheen (top)
  layers.push('0 2px 7px rgba(0,0,0,.34)');                                     // soft drop shadow
  if (lit) layers.push(`0 0 ${Math.round(size * 0.3)}px ${c}3a`);               // restrained current-rank halo
  // Soft blurred rank-colour glow diffusing from the centre (the "blur" effect), clipped
  // to the disc. Strengthens slightly with prestige.
  const glowOp = [0.42, 0.5, 0.58, 0.68][tier];
  return (
    <span className={pop ? 'rank-pop' : undefined} style={{
      width: size, height: size, borderRadius: '50%', flexShrink: 0, boxSizing: 'border-box', overflow: 'hidden',
      display: 'inline-flex', alignItems: 'center', justifyContent: 'center', position: 'relative',
      background: fill, border: '1px solid transparent', boxShadow: layers.join(', '),
    }}>
      {reached && <span aria-hidden="true" style={{ position: 'absolute', left: '50%', top: '44%', width: size * 0.66, height: size * 0.66,
        transform: 'translate(-50%,-50%)', borderRadius: '50%', background: c, opacity: glowOp,
        filter: `blur(${Math.max(3, Math.round(size * 0.17))}px)`, pointerEvents: 'none' }} />}
      <span style={{ position: 'relative', zIndex: 1, fontSize: Math.round(size * 0.42), fontWeight: 600, lineHeight: 1, letterSpacing: .2,
        color: reached ? '#fff' : 'rgba(255,255,255,.4)', textShadow: '0 1px 3px rgba(0,0,0,.7)' }}>{i + 1}</span>
    </span>
  );
}

// ── theme tokens ──────────────────────────────────────────
// Eme is a dark-first experience — dark is the only visual mode.
function makeTheme() {
  return {
    name: 'dark',
    bg: 'radial-gradient(120% 90% at 50% 0%, #120e1d 0%, #08070d 55%, #050507 100%)',
    text: '#ffffff',
    dim: 'rgba(255,255,255,.62)',
    faint: 'rgba(255,255,255,.45)',
    ghost: 'rgba(255,255,255,.3)',
    surface: 'rgba(255,255,255,.035)',
    surface2: 'rgba(255,255,255,.06)',
    border: 'rgba(255,255,255,.08)',
    border2: 'rgba(255,255,255,.12)',
    track: 'rgba(255,255,255,.1)',
    tabBg: 'rgba(20,18,30,.7)',
    tabGrad: 'linear-gradient(180deg, rgba(8,8,14,0) 0%, rgba(8,8,14,.86) 38%, #06060b 100%)',
    sheet: 'linear-gradient(180deg,#15131f,#0c0b14)',
    pill: 'rgba(255,255,255,.04)',
    modal: 'linear-gradient(180deg,#17151f,#0e0d15)',
  };
}
const ThemeCtx = React.createContext(makeTheme());
const useTh = () => React.useContext(ThemeCtx);

// ── persistence ───────────────────────────────────────────
const STORE_KEY = 'lumora:v3';
const ONBOARD_KEY = 'lumora:onboarded';
const todayStr = () => new Date().toISOString().slice(0, 10);

// First-launch language detection (item 3): French / Arabic marketplaces → fr/ar, else en.
function detectLang() {
  try {
    const cands = [navigator.language, ...(navigator.languages || [])].filter(Boolean).map((s) => String(s).toLowerCase());
    for (const c of cands) {
      if (c.startsWith('ar')) return 'ar';
      if (c.startsWith('fr')) return 'fr';
    }
  } catch (e) {}
  return 'en';
}
function onboardingSeen() { try { return !!localStorage.getItem(ONBOARD_KEY); } catch (e) { return false; } }
function markOnboarded() { try { localStorage.setItem(ONBOARD_KEY, '1'); } catch (e) {} }
function clearOnboarded() { try { localStorage.removeItem(ONBOARD_KEY); } catch (e) {} }

function seedState() {
  // Production-clean starting state: a brand-new user begins at ZERO (rank Spark, no
  // sessions, no streak, no achievements). This avoids polluting the cloud with demo data.
  return {
    totalMinutes: 0,
    sessions: 0,
    longestSessionMin: 0,
    currentStreak: 0,
    longestStreak: 0,
    lastActive: todayStr(),
    todayMinutes: 0,
    weekMinutes: 0,
    weekDaily: [0, 0, 0, 0, 0, 0, 0],          // Mon–Sun focus minutes (Weekly Activity)
    monthMinutes: 0,
    monthWeekly: [0, 0, 0, 0],                 // last 4 weeks of focus minutes (Monthly Activity)
    goalsAchieved: 0,
    sessionsSinceInstall: 0,                   // counts completions this install (review gate)
    reviewAsked: false,                        // review request shown once (item 11)
    // Achievement tracking (see ACHIEVEMENTS). Counts feed the unlock predicates.
    modeCounts: { standard: 0, pomodoro: 0, countup: 0 }, // completed sessions per mode
    noPauseSessions: 0,                        // sessions finished without ever pausing
    shareCounts: { session: 0, stats: 0, rank: 0 }, // share actions by type
    achUnlocked: {},                           // key → unlock date (YYYY-MM-DD)
    goals: { daily: 90, weekly: 600 },        // minutes — sensible default focus goals
    favDurations: [],                          // saved custom durations (premium)
    // Personalized quotes (premium). The user authors their own; `quoteSource`
    // chooses what surfaces during a session: built-in phrases, their own, or a mix.
    quotes: [],
    history: [],                               // Session History: one record per completed session
    // ── V3 ──
    tags: DEFAULT_TAGS,                        // focus tags / projects (Free ≤3, Premium unlimited)
    activeTag: 'work',                         // the tag a new session inherits
    focusShield: { enabled: false, mode: 'all', sites: [], apps: [] }, // app/website blocking (all | custom)
    streakFreeze: { tokens: 1, autoProtect: false, lastGrant: monthStr() }, // streak protection
    widgets: { home: true, lock: false, liveActivity: false }, // widget / Live Activity config
    settings: { notifications: true, haptics: true, sounds: true, language: 'en', premium: false,
      notifDaily: true, notifStreak: true, notifWeekly: true,
      showProgress: false,                     // Focus-screen progression section — OFF by default
      quotes: true, quoteSource: 'mix',        // quotes: in-session quotes on/off · quoteSource: system|custom|mix
      // timer modes (free): standard | countup | pomodoro · durations customizable on Premium
      timerMode: 'standard', stdDuration: 30, pomoWork: 25, pomoBreak: 5, cuGoal: 0 },
  };
}
// Back-fill achUnlocked with every achievement already earned, preserving existing dates.
// Done at load so the runtime watcher only ever sees genuine in-session unlocks (and never
// flashes a banner for already-earned achievements on launch).
function withStampedAchievements(s) {
  const cur = { ...(s.achUnlocked || {}) };
  const d = todayStr();
  ACHIEVEMENTS.forEach((a) => { if (!cur[a.key] && a.test(s)) cur[a.key] = d; });
  return { ...s, achUnlocked: cur };
}
function loadState() {
  try {
    const raw = localStorage.getItem(STORE_KEY);
    if (!raw) { const s = seedState(); s.settings.language = detectLang(); return withStampedAchievements(s); }
    const parsed = JSON.parse(raw);
    const merged = { ...seedState(), ...parsed, settings: { ...seedState().settings, ...(parsed.settings || {}) } };
    merged.settings.language = normalizeLang(merged.settings.language); // migrate old labels → codes
    if (merged.settings.stdDuration === 25) merged.settings.stdDuration = 30; // 25 retired → 30
    return withStampedAchievements(merged);
  } catch (e) { return withStampedAchievements(seedState()); }
}
function saveState(s) { try { localStorage.setItem(STORE_KEY, JSON.stringify(s)); } catch (e) {} }

// Non-destructive merge of a local and a cloud state document. Cumulative counters take the
// MAX (so progress is never lost across devices), sets/maps are unioned, and local prefs win
// for non-cumulative settings. Used when reconciling a cloud pull with local cache.
function mergeStates(local, remote) {
  if (!remote) return local;
  if (!local) return remote;
  const num = (a, b) => Math.max(Number(a) || 0, Number(b) || 0);
  const arrMax = (a, b, n) => { const o = []; for (let i = 0; i < n; i++) o.push(Math.max((a && a[i]) || 0, (b && b[i]) || 0)); return o; };
  const objMax = (a, b, keys) => { const o = { ...(a || {}) }; keys.forEach((k) => { o[k] = num((a || {})[k], (b || {})[k]); }); return o; };
  const ach = { ...(remote.achUnlocked || {}) };
  Object.entries(local.achUnlocked || {}).forEach(([k, d]) => { ach[k] = ach[k] ? (ach[k] < d ? ach[k] : d) : d; });
  const qmap = {};
  (remote.quotes || []).forEach((q) => { if (q && q.id) qmap[q.id] = q; });
  (local.quotes || []).forEach((q) => { if (q && q.id) qmap[q.id] = q; });
  const hmap = {};
  (remote.history || []).forEach((h) => { if (h && h.id) hmap[h.id] = h; });
  (local.history || []).forEach((h) => { if (h && h.id) hmap[h.id] = h; });
  const history = Object.values(hmap).sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, 1000);
  return {
    ...local, // keep local prefs (settings, goals, favDurations) by default
    totalMinutes: num(local.totalMinutes, remote.totalMinutes),
    sessions: num(local.sessions, remote.sessions),
    longestSessionMin: num(local.longestSessionMin, remote.longestSessionMin),
    currentStreak: num(local.currentStreak, remote.currentStreak),
    longestStreak: num(local.longestStreak, remote.longestStreak),
    todayMinutes: num(local.todayMinutes, remote.todayMinutes),
    weekMinutes: num(local.weekMinutes, remote.weekMinutes),
    monthMinutes: num(local.monthMinutes, remote.monthMinutes),
    goalsAchieved: num(local.goalsAchieved, remote.goalsAchieved),
    noPauseSessions: num(local.noPauseSessions, remote.noPauseSessions),
    sessionsSinceInstall: num(local.sessionsSinceInstall, remote.sessionsSinceInstall),
    weekDaily: arrMax(local.weekDaily, remote.weekDaily, 7),
    monthWeekly: arrMax(local.monthWeekly, remote.monthWeekly, 4),
    modeCounts: objMax(local.modeCounts, remote.modeCounts, ['standard', 'pomodoro', 'countup']),
    shareCounts: objMax(local.shareCounts, remote.shareCounts, ['session', 'stats', 'rank']),
    achUnlocked: ach,
    quotes: Object.values(qmap),
    history: history,
    reviewAsked: !!(local.reviewAsked || remote.reviewAsked),
    lastActive: (local.lastActive || '') >= (remote.lastActive || '') ? local.lastActive : remote.lastActive,
  };
}

function useEmeState() {
  const [state, setState] = React.useState(loadState);
  const stateRef = React.useRef(state);
  React.useEffect(() => { stateRef.current = state; }, [state]);

  // Local cache (instant) + debounced background push to the cloud (no-op unless configured).
  React.useEffect(() => {
    saveState(state);
    if (window.EmeoCloud) window.EmeoCloud.pushDebounced(state);
  }, [state]);

  // Cloud bootstrap (once): anonymous sign-in → pull this user's row → reconcile with cache.
  // First sync on a device adopts the cloud copy; afterwards we max-merge to protect progress.
  // Entirely skipped when Supabase isn't configured, so offline behaviour is unchanged.
  React.useEffect(() => {
    const C = window.EmeoCloud;
    if (!C || !C.configured()) return;
    let alive = true;
    (async () => {
      const uid = await C.init();
      if (!alive || !uid) return;
      const remote = await C.pull();
      if (!alive) return;
      if (remote && remote.state) {
        const firstSync = !C.lastSyncedAt();
        setState((local) => (firstSync ? { ...local, ...remote.state } : mergeStates(local, remote.state)));
      } else {
        C.push(stateRef.current); // no cloud row yet → seed it from local
      }
    })();
    return () => { alive = false; };
  }, []);

  // `opts.mode` (standard|pomodoro|countup) and `opts.paused` (was the session ever
  // paused) feed the achievement trackers: per-mode completion counts and the running
  // count of sessions finished without a single pause.
  const addFocusMinutes = React.useCallback((min, opts = {}) => {
    const di = (new Date().getDay() + 6) % 7; // today's index, Monday-first
    const mode = opts.mode === 'pomodoro' || opts.mode === 'countup' ? opts.mode : 'standard';
    // Build a Session History record. start/end derived from timestamps when available.
    const endTs = opts.endTs || Date.now();
    const startTs = opts.startTs || (endTs - min * 60000);
    const pad = (n) => String(n).padStart(2, '0');
    const dY = new Date(endTs), dS = new Date(startTs);
    const entry = {
      id: 'h' + endTs.toString(36) + Math.random().toString(36).slice(2, 6),
      ts: endTs,
      date: dY.getFullYear() + '-' + pad(dY.getMonth() + 1) + '-' + pad(dY.getDate()),
      start: pad(dS.getHours()) + ':' + pad(dS.getMinutes()),
      end: pad(dY.getHours()) + ':' + pad(dY.getMinutes()),
      dur: min,
      type: mode,
      sound: opts.sound || null,
      tag: opts.tag || null,                   // V3: focus tag/project this session belonged to
    };
    setState((s) => {
      const wd = (s.weekDaily && s.weekDaily.length === 7 ? s.weekDaily : [0,0,0,0,0,0,0]).slice();
      wd[di] = (wd[di] || 0) + min;
      const mw = (s.monthWeekly && s.monthWeekly.length === 4 ? s.monthWeekly : [0,0,0,0]).slice();
      mw[3] = (mw[3] || 0) + min; // current week is the last bar
      const mc = { standard: 0, pomodoro: 0, countup: 0, ...(s.modeCounts || {}) };
      mc[mode] = (mc[mode] || 0) + 1;
      return {
        ...s,
        totalMinutes: s.totalMinutes + min,
        sessions: s.sessions + 1,
        longestSessionMin: Math.max(s.longestSessionMin, min),
        todayMinutes: s.todayMinutes + min,
        weekMinutes: s.weekMinutes + min,
        weekDaily: wd,
        monthMinutes: s.monthMinutes + min,
        monthWeekly: mw,
        modeCounts: mc,
        noPauseSessions: (s.noPauseSessions || 0) + (opts.paused ? 0 : 1),
        sessionsSinceInstall: (s.sessionsSinceInstall || 0) + 1,
        lastActive: todayStr(),
        history: [entry, ...(Array.isArray(s.history) ? s.history : [])].slice(0, 1000),
      };
    });
  }, []);
  const deleteSession = React.useCallback((id) => {
    setState((s) => ({ ...s, history: (s.history || []).filter((h) => h.id !== id) }));
  }, []);
  const clearHistory = React.useCallback(() => {
    setState((s) => ({ ...s, history: [] }));
  }, []);
  // Record a share action by kind (session|stats|rank) — drives the Sharing achievements.
  const recordShare = React.useCallback((kind) => {
    const k = kind === 'stats' || kind === 'rank' ? kind : 'session';
    setState((s) => ({ ...s, shareCounts: { session: 0, stats: 0, rank: 0, ...(s.shareCounts || {}), [k]: (((s.shareCounts || {})[k]) || 0) + 1 } }));
  }, []);
  // Stamp newly-earned achievements with today's date (never un-stamped). Runs whenever a
  // tracked field changes; idempotent, so it settles after one pass with no render loop.
  React.useEffect(() => {
    const fresh = newlyEarnedAchievements(state);
    if (fresh.length) {
      setState((s) => {
        const next = { ...(s.achUnlocked || {}) }; const d = todayStr();
        newlyEarnedAchievements(s).forEach((k) => { next[k] = d; });
        return { ...s, achUnlocked: next };
      });
    }
  }, [state.sessions, state.modeCounts, state.noPauseSessions, state.longestSessionMin, state.longestStreak, state.settings.premium, state.shareCounts]);
  const updateSettings = React.useCallback((patch) => {
    setState((s) => ({ ...s, settings: { ...s.settings, ...patch } }));
  }, []);
  const setPremium = React.useCallback((on) => {
    setState((s) => ({ ...s, settings: { ...s.settings, premium: on } }));
  }, []);
  const updateGoals = React.useCallback((patch) => {
    setState((s) => ({ ...s, goals: { ...s.goals, ...patch } }));
  }, []);
  const saveFavDuration = React.useCallback((min) => {
    setState((s) => {
      if (s.favDurations.includes(min)) return s;
      return { ...s, favDurations: [...s.favDurations, min].sort((a, b) => a - b).slice(0, 8) };
    });
  }, []);
  const removeFavDuration = React.useCallback((min) => {
    setState((s) => ({ ...s, favDurations: s.favDurations.filter((d) => d !== min) }));
  }, []);
  // ── V3: Focus Tags / Projects ──
  const setActiveTag = React.useCallback((id) => {
    setState((s) => ({ ...s, activeTag: id }));
  }, []);
  const addTag = React.useCallback((label, color) => {
    const id = 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
    const tag = { id, label: String(label || 'Tag').trim().slice(0, 24) || 'Tag', color: color || '#B57BFF' };
    setState((s) => ({ ...s, tags: [...(s.tags || []), tag], activeTag: id }));
    return id;
  }, []);
  const renameTag = React.useCallback((id, label, color) => {
    setState((s) => ({ ...s, tags: (s.tags || []).map((g) => (g.id === id ? { ...g, label: label != null ? String(label).slice(0, 24) : g.label, color: color || g.color } : g)) }));
  }, []);
  const removeTag = React.useCallback((id) => {
    setState((s) => {
      const tag = (s.tags || []).find((g) => g.id === id);
      if (!tag || tag.builtin) return s; // built-in tags can't be deleted
      const tags = (s.tags || []).filter((g) => g.id !== id);
      return { ...s, tags, activeTag: s.activeTag === id ? (tags[0] && tags[0].id) || null : s.activeTag };
    });
  }, []);

  // ── V3: Focus Shield (app/website blocking — see src/data/shield.js for the bridge) ──
  const setShieldEnabled = React.useCallback((on) => {
    setState((s) => ({ ...s, focusShield: { ...s.focusShield, enabled: !!on } }));
  }, []);
  const setShieldMode = React.useCallback((mode) => {
    setState((s) => ({ ...s, focusShield: { ...s.focusShield, mode: mode === 'custom' ? 'custom' : 'all' } }));
  }, []);
  const setShieldCustom = React.useCallback((patch) => {
    setState((s) => ({ ...s, focusShield: { ...s.focusShield, ...patch } }));
  }, []);

  // ── V3: Streak protection (freeze tokens) ──
  const useFreezeToken = React.useCallback(() => {
    let used = false;
    setState((s) => {
      const f = s.streakFreeze || { tokens: 0 };
      if ((f.tokens || 0) <= 0) return s;
      used = true;
      return { ...s, streakFreeze: { ...f, tokens: f.tokens - 1 } };
    });
    return used;
  }, []);
  const setAutoProtect = React.useCallback((on) => {
    setState((s) => ({ ...s, streakFreeze: { ...(s.streakFreeze || {}), autoProtect: !!on } }));
  }, []);
  // Refill the monthly free freeze token (1/month) — called once at app start.
  const grantMonthlyFreeze = React.useCallback(() => {
    setState((s) => {
      const f = s.streakFreeze || { tokens: 0, lastGrant: '' };
      const m = monthStr();
      if (f.lastGrant === m) return s;
      return { ...s, streakFreeze: { ...f, tokens: Math.max(f.tokens || 0, 1), lastGrant: m } };
    });
  }, []);

  // ── V3: Widgets / Live Activities config ──
  const updateWidgets = React.useCallback((patch) => {
    setState((s) => ({ ...s, widgets: { ...(s.widgets || {}), ...patch } }));
  }, []);

  const newQuoteId = () => 'q' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  const addQuote = React.useCallback((text) => {
    const v = String(text || '').trim().slice(0, 200);
    if (!v) return;
    setState((s) => ({ ...s, quotes: [{ id: newQuoteId(), text: v }, ...(s.quotes || [])].slice(0, 100) }));
  }, []);
  const updateQuote = React.useCallback((id, text) => {
    const v = String(text || '').trim().slice(0, 200);
    setState((s) => ({ ...s, quotes: (s.quotes || []).map((q) => (q.id === id ? { ...q, text: v } : q)).filter((q) => q.text) }));
  }, []);
  const removeQuote = React.useCallback((id) => {
    setState((s) => ({ ...s, quotes: (s.quotes || []).filter((q) => q.id !== id) }));
  }, []);
  const markReviewAsked = React.useCallback(() => {
    setState((s) => ({ ...s, reviewAsked: true }));
  }, []);
  const resetProgress = React.useCallback(() => {
    clearOnboarded();                 // data reset re-enables the first-run onboarding (next launch)
    const fresh = seedState();        // already zeroed
    const cur = stateRef.current;
    const next = { ...fresh, settings: cur.settings, goals: cur.goals, favDurations: cur.favDurations, quotes: cur.quotes };
    setState(next);
    // Push the cleared state to the cloud IMMEDIATELY (not debounced) and stamp the sync
    // time, so the cloud can't resurrect the old data via merge on the next launch.
    if (window.EmeoCloud && window.EmeoCloud.configured()) {
      try { localStorage.setItem('lumora:v3:syncedAt', new Date().toISOString()); } catch (e) {}
      window.EmeoCloud.push(next);
    }
  }, []);

  // ── Device pairing (cross-device sync, no account) ──
  // Generate a code on the device that holds the data.
  const createPairingCode = React.useCallback(async () => {
    if (!window.EmeoCloud || !window.EmeoCloud.configured()) return { error: 'cloud-off' };
    return window.EmeoCloud.createPairing();
  }, []);
  // Enter a code on the NEW device: adopt that account, then MERGE this device's local
  // progress into it (never lose either side) and push the reconciled result.
  const linkWithCode = React.useCallback(async (code) => {
    if (!window.EmeoCloud || !window.EmeoCloud.configured()) return { error: 'cloud-off' };
    const res = await window.EmeoCloud.claimPairing(code);
    if (!res || !res.ok) return res || { error: 'failed' };
    const remote = await window.EmeoCloud.pull();
    if (remote && remote.state) {
      const merged = mergeStates(stateRef.current, remote.state);
      setState(merged);
      window.EmeoCloud.push(merged);
    } else {
      window.EmeoCloud.push(stateRef.current);
    }
    return { ok: true };
  }, []);

  return { state, setState, addFocusMinutes, deleteSession, clearHistory, recordShare, updateSettings, resetProgress, setPremium, updateGoals, saveFavDuration, removeFavDuration, markReviewAsked, addQuote, updateQuote, removeQuote, createPairingCode, linkWithCode,
    // V3 actions
    setActiveTag, addTag, renameTag, removeTag,
    setShieldEnabled, setShieldMode, setShieldCustom,
    useFreezeToken, setAutoProtect, grantMonthlyFreeze, updateWidgets };
}

// ── Haptics ──────────────────────────────────────────────
// Tiered, Apple-inspired feedback. Patterns are deliberately short and subtle so they
// read as a tactile "tick", never a buzz. navigator.vibrate cancels any in-flight
// pattern, so a stronger handler firing just after the global light tick simply wins.
// Softer, premium-feeling patterns — deliberately gentle so they read as a faint tick.
const HAPTIC = {
  light:   4,                 // simple taps · list selection
  medium:  7,                 // toggles · tab / mode changes · state changes
  heavy:   [0, 12],           // starting a session · important commits
  success: [0, 9, 60, 14],    // session complete · Premium purchased
  warning: [0, 10, 40, 10],   // locked item · paywall
};
let HAPTICS_ON = true;
let SOUNDS_ON = true;
function setHapticsEnabled(on) { HAPTICS_ON = !!on; }
function setSoundsEnabled(on) { SOUNDS_ON = !!on; }
function haptic(pattern = HAPTIC.light, enabled) {
  const on = enabled === undefined ? HAPTICS_ON : enabled;
  if (!on) return;
  try { if (navigator.vibrate) navigator.vibrate(pattern); } catch (e) {}
}
// One delegated listener gives every interactive control a light tick on press —
// buttons, links, role=button, and anything styled clickable (cursor:pointer). A
// `data-haptic="medium|heavy|success|warning|off"` attribute overrides the tier. A
// subtle UI tap *sound* plays alongside light/medium taps (the prominent actions —
// heavy/success/warning — play their own dedicated cue instead).
function installGlobalHaptics() {
  if (window.__emeoHaptics) return;
  window.__emeoHaptics = true;
  const fire = (tier) => {
    if (tier === 'off') return;
    haptic(HAPTIC[tier] || HAPTIC.light);
    if (SOUNDS_ON && (tier === 'light' || tier === 'medium')) { try { window.ambientEngine.cue('tap'); } catch (e) {} }
  };
  const resolveTier = (target) => {
    let el = target;
    for (let i = 0; i < 6 && el && el !== document.body; i++, el = el.parentElement) {
      const tier = el.dataset && el.dataset.haptic;
      if (tier) return tier;
      const tag = el.tagName;
      if (tag === 'BUTTON' || tag === 'A' || tag === 'LABEL' || tag === 'INPUT' || el.getAttribute('role') === 'button') return 'light';
      try { if (getComputedStyle(el).cursor === 'pointer') return 'light'; } catch (e2) {}
    }
    return null;
  };
  // Fire on a genuine TAP only — pointerup with no meaningful movement. A scroll/drag moves
  // the pointer past the threshold, so scrolling never triggers haptics or the tap sound.
  let sx = 0, sy = 0, downTarget = null, moved = false;
  document.addEventListener('pointerdown', (e) => { sx = e.clientX; sy = e.clientY; downTarget = e.target; moved = false; }, { passive: true, capture: true });
  document.addEventListener('pointermove', (e) => { if (downTarget && (Math.abs(e.clientX - sx) > 8 || Math.abs(e.clientY - sy) > 8)) moved = true; }, { passive: true, capture: true });
  document.addEventListener('pointerup', () => { if (downTarget && !moved) { const tier = resolveTier(downTarget); if (tier) fire(tier); } downTarget = null; }, { passive: true, capture: true });
  document.addEventListener('pointercancel', () => { downTarget = null; }, { passive: true, capture: true });
}

Object.assign(window, {
  LEVELS, SOUNDS, ACHIEVEMENTS, PRESETS, CUSTOM_MIN, CUSTOM_MAX, PREMIUM_PERKS, PREMIUM_PRICE, PREMIUM_PRICE_YEAR,
  TAG_LIMIT_FREE, DEFAULT_TAGS, SHIELD_ALL_SITES, PREMIUM_GOLD, FREE_CUSTOM_GOALS,
  levelForHours, nextThreshold, fmtClock, fmtHours, RankBadge,
  makeTheme, ThemeCtx, useTh,
  useEmeState, haptic, HAPTIC, setHapticsEnabled, setSoundsEnabled, installGlobalHaptics, loadState, saveState,
  detectLang, onboardingSeen, markOnboarded, clearOnboarded,
});

  