// DiveDex — React context + actions.
// Owns the live appState. Every mutation funnels through here so persistence
// is automatic and the action surface is the one place future Firebase wiring
// will plug into.

const DiveDexContext = React.createContext(null);

// ── id helper ────────────────────────────────────────────────────────
function ddId(prefix) { return prefix + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6); }

// Push state.settings audio prefs into the audio engine — used on boot and
// after an import so the speakers reflect the just-restored state.
function pushAudioPrefs(settings) {
  const A = window.DiveDexAudio;
  if (!A || !settings) return;
  if (typeof settings.soundEnabled === 'boolean') A.setSoundEnabled(settings.soundEnabled);
  if (typeof settings.musicEnabled === 'boolean') A.setMusicEnabled(settings.musicEnabled);
  if (settings.volume) A.setMasterVolume(settings.volume);
}

// Bridge helper: collect cosmetic item ids granted by a set of achievement ids,
// minus anything already owned. Pure function over the bridge map.
function itemsBridgedFromAchievements(achievementIds, alreadyOwned) {
  const map = (window.DD_STATE && window.DD_STATE.ACHIEVEMENT_TO_ITEMS) || {};
  const out = [];
  achievementIds.forEach(aid => {
    (map[aid] || []).forEach(itemId => {
      if (out.indexOf(itemId) < 0 && alreadyOwned.indexOf(itemId) < 0) out.push(itemId);
    });
  });
  return out;
}

// ── scoring engine ───────────────────────────────────────────────────
// Pure function. Given a dive draft + sightings + the creature registry +
// scoring config, returns the breakdown that drives Recap *and* the actual
// XP/Pearl awards.
//
// dive:      { site, op, buddy, maxDepth, time, temp, vis, gas, nitrox,
//              startBar, endBar, notes, mood, current, drift, photos,
//              buddyCheck, safeReserveBar }
// sightings: [{ creatureId, count, firstSpotter }]
// creatures: state.creatures
// scoring:   state-level SCORING_CONFIG
//
// Returns: { xpRows, totalXp, perUserXp, pearls, newDiscoveries, newLegendaries,
//            sharkFamilyCount, achievementsToUnlock }
function computeDiveScoring(dive, sightings, creatures, scoring, alreadyUnlocked) {
  const xp = scoring.xp;
  const prl = scoring.pearls;
  const rows = [];

  rows.push({ label: 'Dive logged', value: xp.diveLogged });

  const nitrox = Number(dive.nitrox) || (dive.gas === 'Nitrox' ? 32 : 21);
  if (nitrox > 21) rows.push({ label: `Nitrox ${nitrox}`, value: xp.nitroxDive });

  const safeReserve = dive.safeReserveBar || 50;
  const airZen = Number(dive.endBar) >= safeReserve;
  if (airZen) rows.push({ label: 'Air Zen bonus', value: xp.airZenBonus, tone: 'foam' });

  if (dive.drift || dive.current === 'strong' || dive.current === 'ripping') {
    rows.push({ label: 'Drift dive completed', value: xp.driftDive });
  }
  if (dive.buddyCheck) rows.push({ label: 'Buddy check', value: xp.buddyCheck });

  const photoCount = Number(dive.photos) || 0;
  if (photoCount > 0) rows.push({ label: `Photos added (${photoCount})`, value: xp.photosAdded });

  // Sighting awards
  const rarityXp = {
    common: xp.commonSighting, uncommon: xp.uncommonSighting, rare: xp.rareSighting,
    epic: xp.epicSighting, legendary: xp.legendarySighting, mythic: xp.mythicSighting,
  };
  const sharkFamilies = new Set();
  let sharkTotalCount = 0; // sum of count across all shark sightings on this dive
  const newDiscoveries = []; // creatureIds newly discovered
  const newLegendaries = []; // for Pearl bonus
  const perUserXp = { rick: 0, siet: 0 };
  let firstSpotterBonusGivenTo = null;

  sightings.forEach(s => {
    const cr = creatures.find(c => c.id === s.creatureId);
    if (!cr) return;
    const xpAward = rarityXp[cr.rarity] || xp.commonSighting;
    rows.push({
      label: `${cr.commonName}${s.count > 1 ? ' ×' + s.count : ''}`,
      value: xpAward,
      tone: cr.rarity === 'legendary' || cr.rarity === 'mythic' ? 'gold' : null,
    });
    // first-spotter attribution
    if (s.firstSpotter && perUserXp[s.firstSpotter] !== undefined) {
      perUserXp[s.firstSpotter] += xpAward;
    }
    if (s.firstSpotter && !cr.discovered && !firstSpotterBonusGivenTo) {
      firstSpotterBonusGivenTo = s.firstSpotter;
    }
    if (cr.familyType === 'shark') {
      sharkFamilies.add(cr.id);
      sharkTotalCount += (s.count || 1);
    }
    if (!cr.discovered) newDiscoveries.push(cr.id);
    if (cr.rarity === 'legendary' || cr.rarity === 'mythic') newLegendaries.push(cr.id);
  });

  if (firstSpotterBonusGivenTo) {
    rows.push({ label: `${firstSpotterBonusGivenTo === 'siet' ? 'Siet' : 'Rick'} spotted it first`, value: xp.firstSpotterBonus });
    perUserXp[firstSpotterBonusGivenTo] += xp.firstSpotterBonus;
  }

  const totalXp = rows.reduce((s, r) => s + r.value, 0);
  // Distribute the *unattributed* XP 50/50 between the two users.
  const attributed = perUserXp.rick + perUserXp.siet;
  const remainder = totalXp - attributed;
  perUserXp.rick += Math.floor(remainder / 2);
  perUserXp.siet += Math.ceil(remainder / 2);

  // Pearls
  let pearls = prl.diveLogged;
  pearls += newDiscoveries.length * prl.newCreature;
  pearls += newLegendaries.length * prl.legendaryEncounter;
  if (airZen) pearls += prl.airZenBonus;

  // Achievement auto-unlock conditions
  const achievementsToUnlock = [];
  const want = (id) => {
    if (alreadyUnlocked.indexOf(id) >= 0) return false;
    achievementsToUnlock.push(id);
    return true;
  };
  // first-dive handled by caller (needs prior dive count)
  if (nitrox > 21) want('first-nitrox');
  if (dive.endBar >= 50) want('fifty-bar-club');
  if (dive.drift || dive.current === 'strong' || dive.current === 'ripping') want('drift-dive');
  if (sightings.some(s => {
    const cr = creatures.find(c => c.id === s.creatureId);
    return cr && cr.familyType === 'shark';
  })) want('first-shark');
  if (sightings.some(s => s.creatureId === 'scalloped-hammerhead')) want('hammerhead-hunter');
  if (sightings.some(s => s.creatureId === 'hammerhead-school')) want('hammerhead-school');
  if (sightings.some(s => {
    const cr = creatures.find(c => c.id === s.creatureId);
    return cr && cr.rarity === 'legendary' && s.firstSpotter === 'siet';
  })) want('siet-first');
  if (airZen) want('air-zen-bonus');
  // Sharknado: variety (≥3 distinct shark species) OR volume (≥10 sharks total).
  if (sharkFamilies.size >= 3 || sharkTotalCount >= 10) want('sharknado');

  return {
    xpRows: rows,
    totalXp,
    perUserXp,
    pearls,
    newDiscoveries,
    newLegendaries,
    sharkFamilyCount: sharkFamilies.size,
    sharkTotalCount,
    airZen,
    achievementsToUnlock,
  };
}

function DiveDexProvider({ children }) {
  const [state, setStateRaw] = React.useState(() => {
    return window.DD_STORAGE.loadAppState() || window.DD_STATE.makeInitialState();
  });

  React.useEffect(() => {
    window.DD_STORAGE.saveAppState(state);
    // On boot, push the persisted audio prefs into the audio engine so a
    // backup-restored state actually controls the speakers. (The engine keeps
    // its own LS keys for legacy reasons; state.settings is the backup-safe
    // mirror that survives export/import.)
    pushAudioPrefs(state.settings);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const setState = React.useCallback((updater) => {
    setStateRaw(prev => {
      const next = typeof updater === 'function' ? updater(prev) : updater;
      return window.DD_STORAGE.saveAppState(next);
    });
  }, []);

  const actions = React.useMemo(() => ({
    // ── settings & backup ──────────────────────────────────────────────
    updateSettings(patch) {
      setState(prev => ({ ...prev, settings: { ...prev.settings, ...patch } }));
    },
    exportBackup() {
      window.DD_BACKUP.downloadBackup(state);
      setState(prev => ({ ...prev, lastBackupAt: new Date().toISOString() }));
    },
    async importBackupFromFile(file) {
      const incoming = await window.DD_BACKUP.readBackupFile(file);
      setStateRaw(incoming);
      window.DD_STORAGE.saveAppState(incoming);
      // Restore audio prefs into the live engine
      pushAudioPrefs(incoming.settings);
      return incoming;
    },
    resetDemoData() {
      const fresh = window.DD_STORAGE.resetAppState();
      setStateRaw(fresh);
      pushAudioPrefs(fresh.settings);
    },

    // ── auth (lightweight, single shared password) ────────────────────
    // unlockApp validates the password if one is set, then records the
    // active user. Returns { ok, reason? } so the LockScreen can show
    // feedback without throwing.
    // unlockApp: validate-then-unlock. If `setNewPassword` is provided AND
    // there is no current password, this *also* sets the password atomically
    // — so first-run users can pick a profile and set a password in one tap.
    unlockApp(userId, password, opts) {
      if (!['rick', 'siet'].includes(userId)) {
        return { ok: false, reason: 'Pick who\'s diving.' };
      }
      const setNewPassword = opts && typeof opts.setNewPassword === 'string' ? opts.setNewPassword : null;
      if (state.authPassword) {
        if (password !== state.authPassword) {
          return { ok: false, reason: 'Wrong password.' };
        }
      } else if (setNewPassword) {
        // First-run: caller chose to add a password before continuing
        setState(prev => ({ ...prev, locked: false, activeUserId: userId, authPassword: setNewPassword }));
        return { ok: true };
      }
      setState(prev => ({ ...prev, locked: false, activeUserId: userId }));
      return { ok: true };
    },

    lockApp() {
      setState(prev => ({ ...prev, locked: true }));
    },

    setActiveUser(userId) {
      if (!['rick', 'siet'].includes(userId)) return { ok: false };
      setState(prev => ({ ...prev, activeUserId: userId }));
      return { ok: true };
    },

    // Set or clear (empty string) the shared password. Plain-text storage —
    // this is casual access protection, not a real security boundary.
    updateAuthPassword(next) {
      setState(prev => ({ ...prev, authPassword: typeof next === 'string' ? next : '' }));
      return { ok: true };
    },

    // ── dive logging (the core loop) ──────────────────────────────────
    // dive: see computeDiveScoring above. sightings: same.
    // Returns the new dive id so the caller can navigate to its recap.
    logDive(diveInput, sightings) {
      const id = ddId('dive');
      const isoNow = new Date().toISOString();
      // Honor caller-provided dive time (from the date/time picker); fall back
      // to now. Sightings inherit the same timestamp so the Memories timeline
      // groups them on the dive's day, not the save day.
      const diveAt = (diveInput && diveInput.createdAt) || isoNow;
      let createdId = null;
      setState(prev => {
        const score = computeDiveScoring(
          diveInput, sightings || [], prev.creatures,
          window.DD_STATE.SCORING_CONFIG, prev.unlockedAchievementIds,
        );

        // first-dive achievement — only if this is the very first dive
        const willBeFirst = prev.dives.length === 0
          && prev.unlockedAchievementIds.indexOf('first-dive') < 0;
        if (willBeFirst) score.achievementsToUnlock.unshift('first-dive');

        const diveRecord = {
          id, no: prev.dives.length + 1,
          ...diveInput,
          totalXp: score.totalXp, perUserXp: score.perUserXp, pearls: score.pearls,
          airZen: score.airZen, photoCount: Number(diveInput.photos) || 0,
          xpRows: score.xpRows, // freeze the breakdown so Recap is reproducible
          unlockedAchievementIds: score.achievementsToUnlock.slice(),
          newDiscoveryIds: score.newDiscoveries.slice(),
          createdAt: diveAt, savedAt: isoNow, status: 'logged',
        };
        createdId = id;

        const sightingRecords = (sightings || []).map(s => ({
          id: ddId('sgt'), diveId: id, creatureId: s.creatureId,
          count: s.count || 1, firstSpotter: s.firstSpotter || null,
          createdAt: diveAt,
        }));

        // Update creature discoveries
        const newDiscSet = new Set(score.newDiscoveries);
        const creatures = prev.creatures.map(c => {
          const sighting = (sightings || []).find(s => s.creatureId === c.id);
          if (!sighting) return c;
          const becomesDiscovered = newDiscSet.has(c.id);
          return {
            ...c,
            discovered: c.discovered || becomesDiscovered,
            sightingsCount: c.sightingsCount + (sighting.count || 1),
            firstSeenBy: c.firstSeenBy || sighting.firstSpotter || null,
            firstSeenAt: c.firstSeenAt || (becomesDiscovered ? isoNow : null),
            firstSeenDiveId: c.firstSeenDiveId || (becomesDiscovered ? id : null),
          };
        });
        const discoveredIds = Array.from(new Set([...prev.discoveredCreatureIds, ...score.newDiscoveries]));

        // Unlock achievements + accumulate their Pearl rewards
        let achievementPearls = 0;
        const achievements = prev.achievements.map(a => {
          if (score.achievementsToUnlock.indexOf(a.id) >= 0 && !a.unlocked) {
            achievementPearls += a.rewardPearls || 0;
            return { ...a, unlocked: true, unlockedAt: isoNow };
          }
          return a;
        });
        const unlockedIds = Array.from(new Set([...prev.unlockedAchievementIds, ...score.achievementsToUnlock]));

        // Bridge: grant cosmetics tied to the achievements that just unlocked.
        const bridgedItems = itemsBridgedFromAchievements(score.achievementsToUnlock, prev.inventory);
        diveRecord.bridgedItemIds = bridgedItems.slice();

        return {
          ...prev,
          dives: [...prev.dives, diveRecord],
          sightings: [...prev.sightings, ...sightingRecords],
          creatures, discoveredCreatureIds: discoveredIds,
          achievements, unlockedAchievementIds: unlockedIds,
          inventory: [...prev.inventory, ...bridgedItems],
          currencies: { ...prev.currencies, pearls: prev.currencies.pearls + score.pearls + achievementPearls },
          xp: {
            teamXp: prev.xp.teamXp + score.totalXp,
            userXp: {
              rick: prev.xp.userXp.rick + score.perUserXp.rick,
              siet: prev.xp.userXp.siet + score.perUserXp.siet,
            },
          },
        };
      });
      return createdId;
    },

    // Delete a logged dive. Reverses XP and base Pearls (clamped at 0),
    // removes sightings, recomputes creature counts and first-seen attribution.
    // Achievements stay unlocked — too tangled to un-unlock cleanly, and the
    // user's trophies should be sticky once earned.
    deleteDive(diveId) {
      // Best-effort cleanup of any attached photos in IndexedDB. Fire-and-forget
      // so a slow IDB doesn't block the state mutation below.
      if (window.DD_PHOTOS) window.DD_PHOTOS.deleteDivePhotos(diveId).catch(() => {});
      setState(prev => {
        const dive = prev.dives.find(d => d.id === diveId);
        if (!dive) return prev;

        // Drop the dive + its sightings
        const dives = prev.dives.filter(d => d.id !== diveId);
        const remainingSightings = prev.sightings.filter(s => s.diveId !== diveId);

        // Recompute per-creature count + reattribute first-seen when needed
        const countByCreature = remainingSightings.reduce((acc, s) => {
          acc[s.creatureId] = (acc[s.creatureId] || 0) + (s.count || 1);
          return acc;
        }, {});
        // For each creature first-seen on the deleted dive, find the earliest
        // remaining sighting and reattribute. If none, mark undiscovered.
        const earliestSighting = {};
        remainingSightings.forEach(s => {
          const existing = earliestSighting[s.creatureId];
          if (!existing || s.createdAt < existing.createdAt) earliestSighting[s.creatureId] = s;
        });
        const stillDiscovered = new Set();
        const creatures = prev.creatures.map(c => {
          const newCount = countByCreature[c.id] || 0;
          const wasFirstSeenOnThisDive = c.firstSeenDiveId === diveId;
          if (wasFirstSeenOnThisDive) {
            const next = earliestSighting[c.id];
            if (next) {
              // Reattribute first-seen to the next-earliest remaining sighting
              const nextDive = dives.find(d => d.id === next.diveId);
              stillDiscovered.add(c.id);
              return {
                ...c,
                sightingsCount: newCount,
                discovered: true,
                firstSeenBy: next.firstSpotter || null,
                firstSeenAt: next.createdAt,
                firstSeenDiveId: next.diveId,
              };
            }
            // No other sightings — un-discover
            return {
              ...c,
              sightingsCount: 0,
              discovered: false,
              firstSeenBy: null, firstSeenAt: null, firstSeenDiveId: null,
            };
          }
          // Was discovered on a different dive — keep state, just sync count
          if (c.discovered) stillDiscovered.add(c.id);
          return { ...c, sightingsCount: newCount };
        });
        const discoveredCreatureIds = Array.from(stillDiscovered);

        // Reverse XP — clamp at 0 so a stale state never goes negative
        const teamXp = Math.max(0, prev.xp.teamXp - (dive.totalXp || 0));
        const rickXp = Math.max(0, prev.xp.userXp.rick - (dive.perUserXp ? dive.perUserXp.rick || 0 : 0));
        const sietXp = Math.max(0, prev.xp.userXp.siet - (dive.perUserXp ? dive.perUserXp.siet || 0 : 0));

        // Reverse base scoring Pearls. Achievement-reward Pearls stay
        // (the achievement is keeping its trophy, so its Pearl reward stays
        // counted as already-spent income).
        const pearls = Math.max(0, prev.currencies.pearls - (dive.pearls || 0));

        // Renumber subsequent dives so dive.no stays contiguous
        const renumbered = dives.map((d, i) => ({ ...d, no: i + 1 }));

        return {
          ...prev,
          dives: renumbered,
          sightings: remainingSightings,
          creatures,
          discoveredCreatureIds,
          currencies: { ...prev.currencies, pearls },
          xp: { teamXp, userXp: { rick: rickXp, siet: sietXp } },
        };
      });
      return { ok: true };
    },

    // Edit a logged dive's metadata + sightings without changing scoring.
    // XP / Pearls / achievement unlocks stay frozen at the original log time —
    // edits are treated as "fix-ups" to the canonical record, not retroactive
    // re-scoring. Adding a sighting marks the creature discovered (so the Dex
    // reflects it) but does NOT grant retroactive XP/Pearl bonuses.
    updateDive(diveId, patch, sightings) {
      setState(prev => {
        const idx = prev.dives.findIndex(d => d.id === diveId);
        if (idx < 0) return prev;
        const old = prev.dives[idx];
        const isoNow = new Date().toISOString();

        // Merge metadata patch onto the existing dive, preserving immutable
        // scoring fields (totalXp, perUserXp, pearls, xpRows, etc.)
        const nextDive = {
          ...old, ...patch,
          // Re-derive a few view-friendly fields the form might change:
          airZen: typeof patch.endBar === 'number' ? (patch.endBar >= (prev.settings.safeReserveBar || 50)) : old.airZen,
          updatedAt: isoNow,
        };
        const dives = prev.dives.slice();
        dives[idx] = nextDive;

        // Replace the sighting set for this dive. We *keep* the old creature
        // discovery state (you can't un-discover), but new sightings can mark
        // additional creatures discovered.
        const otherSightings = prev.sightings.filter(s => s.diveId !== diveId);
        const newSightings = (sightings || []).map(s => ({
          id: ddId('sgt'), diveId: diveId, creatureId: s.creatureId,
          count: s.count || 1, firstSpotter: s.firstSpotter || null,
          createdAt: isoNow,
        }));

        // Find creature ids that weren't previously discovered but are now
        // in this dive's sighting list — mark them discovered (no XP delta).
        const stillUndiscovered = new Set(prev.creatures.filter(c => !c.discovered).map(c => c.id));
        const newlyDiscoveredHere = newSightings
          .filter(s => stillUndiscovered.has(s.creatureId))
          .map(s => s.creatureId);

        // Recompute each affected creature's sighting count from scratch
        // (sum of `count` across all sightings for that creature, post-edit).
        const allSightings = [...otherSightings, ...newSightings];
        const countByCreature = allSightings.reduce((acc, s) => {
          acc[s.creatureId] = (acc[s.creatureId] || 0) + (s.count || 1);
          return acc;
        }, {});
        const newDiscSet = new Set(newlyDiscoveredHere);
        const creatures = prev.creatures.map(c => {
          // touch only creatures whose sighting count changed OR are newly discovered
          if (countByCreature[c.id] === undefined && !newDiscSet.has(c.id)) {
            // Was the old dive's only contribution to a now-removed sighting?
            const hadOldSighting = prev.sightings.some(s => s.diveId === diveId && s.creatureId === c.id);
            if (!hadOldSighting) return c;
            // Removed from this dive — recompute count, keep discovered state
            return { ...c, sightingsCount: countByCreature[c.id] || 0 };
          }
          const newCount = countByCreature[c.id] || 0;
          const newlyDisc = newDiscSet.has(c.id);
          const firstSightingOnThisDive = newSightings.find(s => s.creatureId === c.id);
          return {
            ...c,
            sightingsCount: newCount,
            discovered: c.discovered || newlyDisc,
            firstSeenBy: c.firstSeenBy || (newlyDisc && firstSightingOnThisDive ? firstSightingOnThisDive.firstSpotter : c.firstSeenBy),
            firstSeenAt: c.firstSeenAt || (newlyDisc ? isoNow : c.firstSeenAt),
            firstSeenDiveId: c.firstSeenDiveId || (newlyDisc ? diveId : c.firstSeenDiveId),
          };
        });
        const discoveredCreatureIds = Array.from(new Set([...prev.discoveredCreatureIds, ...newlyDiscoveredHere]));

        return {
          ...prev,
          dives,
          sightings: allSightings,
          creatures,
          discoveredCreatureIds,
        };
      });
      return { ok: true };
    },

    unlockAchievement(achievementId) {
      setState(prev => {
        if (prev.unlockedAchievementIds.indexOf(achievementId) >= 0) return prev;
        const ach = prev.achievements.find(a => a.id === achievementId);
        if (!ach) return prev;
        const bridged = itemsBridgedFromAchievements([achievementId], prev.inventory);
        return {
          ...prev,
          achievements: prev.achievements.map(a =>
            a.id === achievementId ? { ...a, unlocked: true, unlockedAt: new Date().toISOString() } : a),
          unlockedAchievementIds: [...prev.unlockedAchievementIds, achievementId],
          inventory: [...prev.inventory, ...bridged],
          currencies: { ...prev.currencies, pearls: prev.currencies.pearls + (ach.rewardPearls || 0) },
          xp: { ...prev.xp, teamXp: prev.xp.teamXp + (ach.rewardXp || 0) },
        };
      });
    },

    completeSideQuest(questId) {
      setState(prev => {
        const q = prev.sideQuests.find(x => x.id === questId);
        if (!q || q.state === 'locked') return prev;
        // Non-repeatable quests can only be completed once.
        if (!q.repeatable && prev.completedSideQuestIds.indexOf(questId) >= 0) return prev;
        const now = new Date().toISOString();
        const questXp = q.rewardXp || 0;
        const questPearls = q.rewardPearls || 0;
        const attribution = q.attribution || 'team';

        // Attribute XP per the quest's setting:
        //   'rick' / 'siet' → all XP goes to that user + counted in teamXp
        //   'team'          → split 50/50 between users + counted in teamXp
        let rickXpDelta = 0, sietXpDelta = 0;
        if (attribution === 'rick') rickXpDelta = questXp;
        else if (attribution === 'siet') sietXpDelta = questXp;
        else { rickXpDelta = Math.floor(questXp / 2); sietXpDelta = questXp - rickXpDelta; }

        let pearls = prev.currencies.pearls + questPearls;
        let teamXp = prev.xp.teamXp + questXp;
        let rickXp = prev.xp.userXp.rick + rickXpDelta;
        let sietXp = prev.xp.userXp.siet + sietXpDelta;
        let unlockedIds = prev.unlockedAchievementIds.slice();
        let achievements = prev.achievements;
        let inventory = prev.inventory;

        // Bridge: a non-repeatable quest can trigger an achievement on first
        // completion. Repeatable quests skip the bridge (otherwise it'd
        // re-fire every tap).
        const bridgedAchievementId = !q.repeatable
          ? (window.DD_STATE.SIDEQUEST_TO_ACHIEVEMENT || {})[questId]
          : null;
        if (bridgedAchievementId && unlockedIds.indexOf(bridgedAchievementId) < 0) {
          const ach = achievements.find(a => a.id === bridgedAchievementId);
          if (ach) {
            achievements = achievements.map(a =>
              a.id === bridgedAchievementId ? { ...a, unlocked: true, unlockedAt: now } : a);
            unlockedIds.push(bridgedAchievementId);
            pearls += ach.rewardPearls || 0;
            teamXp += ach.rewardXp || 0;
            // Achievement XP follows the same attribution as the quest that
            // triggered it — keeps the user-level math consistent.
            if (attribution === 'rick') rickXp += ach.rewardXp || 0;
            else if (attribution === 'siet') sietXp += ach.rewardXp || 0;
            else {
              const half = Math.floor((ach.rewardXp || 0) / 2);
              rickXp += half;
              sietXp += (ach.rewardXp || 0) - half;
            }
            const bridgedItems = itemsBridgedFromAchievements([bridgedAchievementId], inventory);
            inventory = [...inventory, ...bridgedItems];
          }
        }

        // Log this completion (including repeats) so the UI can show
        // "done × N" and Memories can pull it in chronologically.
        const completionLog = [
          ...(prev.sideQuestCompletions || []),
          { id: ddId('sqc'), questId, when: now, xp: questXp, pearls: questPearls, attribution },
        ];

        // Repeatable quests stay in Active (state unchanged). Non-repeatable
        // move to Completed and get added to completedSideQuestIds.
        const newSideQuests = q.repeatable
          ? prev.sideQuests
          : prev.sideQuests.map(x => x.id === questId
              ? { ...x, state: 'completed', completedAt: now } : x);
        const newCompletedIds = q.repeatable
          ? prev.completedSideQuestIds
          : [...prev.completedSideQuestIds, questId];

        return {
          ...prev,
          sideQuests: newSideQuests,
          completedSideQuestIds: newCompletedIds,
          sideQuestCompletions: completionLog,
          achievements,
          unlockedAchievementIds: unlockedIds,
          inventory,
          currencies: { ...prev.currencies, pearls },
          xp: { teamXp, userXp: { rick: rickXp, siet: sietXp } },
        };
      });
    },

    // ── boutique ──────────────────────────────────────────────────────
    // Returns { ok: bool, reason?: string } so screens can show feedback.
    buyShopItem(itemId) {
      const catalog = window.BOUTIQUE_ITEMS || [];
      const item = catalog.find(i => i.id === itemId);
      if (!item) return { ok: false, reason: 'Item not found.' };
      if (item.state === 'locked' || item.state === 'achievement') {
        return { ok: false, reason: 'This item is unlock-only.' };
      }
      if (state.inventory.indexOf(itemId) >= 0) return { ok: false, reason: 'Already owned.' };
      const price = item.price || 0;
      if (state.currencies.pearls < price) {
        return { ok: false, reason: 'Not enough Pearls.' };
      }
      setState(prev => ({
        ...prev,
        currencies: { ...prev.currencies, pearls: prev.currencies.pearls - price },
        inventory: [...prev.inventory, itemId],
      }));
      return { ok: true };
    },

    equipShopItem(itemId, userId) {
      const catalog = window.BOUTIQUE_ITEMS || [];
      const item = catalog.find(i => i.id === itemId);
      if (!item) return { ok: false, reason: 'Item not found.' };
      if (state.inventory.indexOf(itemId) < 0) return { ok: false, reason: 'Not owned.' };
      if (!['rick','siet'].includes(userId)) return { ok: false, reason: 'Unknown user.' };
      const slot = (window.DD_STATE.CAT_TO_SLOT || {})[item.cat];
      if (!slot) return { ok: false, reason: 'Nothing to equip for this item.' };
      setState(prev => ({
        ...prev,
        equippedItems: {
          ...prev.equippedItems,
          [userId]: { ...(prev.equippedItems[userId] || {}), [slot]: itemId },
        },
      }));
      return { ok: true };
    },

    unequipShopItem(itemId, userId) {
      setState(prev => {
        const u = prev.equippedItems[userId] || {};
        const next = { ...u };
        Object.keys(next).forEach(slot => { if (next[slot] === itemId) delete next[slot]; });
        return { ...prev, equippedItems: { ...prev.equippedItems, [userId]: next } };
      });
      return { ok: true };
    },
  }), [state, setState]);

  React.useEffect(() => { window.__DD_STATE__ = state; }, [state]);

  return React.createElement(
    DiveDexContext.Provider,
    { value: { state, actions } },
    children
  );
}

function useDiveDex() {
  const v = React.useContext(DiveDexContext);
  if (!v) throw new Error('useDiveDex must be used within DiveDexProvider');
  return v;
}

// Convenience selectors (pure)
function selectLatestDive(state) {
  return state.dives.length ? state.dives[state.dives.length - 1] : null;
}
function selectDiveById(state, id) {
  return state.dives.find(d => d.id === id) || null;
}

// Resolve a dive's "hero" sighting + creature — the rarest species seen.
// Used by Recap and Story Card so both surfaces highlight the same species.
function selectDiveHero(state, dive) {
  if (!dive) return { sighting: null, creature: null };
  const rank = { mythic: 6, legendary: 5, epic: 4, rare: 3, uncommon: 2, common: 1 };
  const diveSightings = state.sightings.filter(s => s.diveId === dive.id);
  if (diveSightings.length === 0) return { sighting: null, creature: null };
  const sorted = diveSightings.slice().sort((a, b) => {
    const ra = (state.creatures.find(c => c.id === a.creatureId) || {}).rarity || 'common';
    const rb = (state.creatures.find(c => c.id === b.creatureId) || {}).rarity || 'common';
    return (rank[rb] || 0) - (rank[ra] || 0);
  });
  const sighting = sorted[0];
  const creature = state.creatures.find(c => c.id === sighting.creatureId) || null;
  return { sighting, creature };
}

// Effective per-item state, derived from catalog + inventory + equipped + pearls.
// Returns one of:
//   'equipped'    — owned and equipped by at least one user
//   'owned'       — owned, not equipped
//   'achievement' — catalog says unlock-only via achievement (not owned)
//   'locked'      — catalog locked (not owned)
//   'not-enough'  — buyable but pearls < price
//   'buy'         — buyable and affordable
function selectBoutiqueItemState(state, catalogItem) {
  const id = catalogItem.id;
  const owned = state.inventory.indexOf(id) >= 0;
  if (owned) {
    const equipped = ['rick','siet'].some(u =>
      Object.values(state.equippedItems[u] || {}).indexOf(id) >= 0
    );
    return equipped ? 'equipped' : 'owned';
  }
  if (catalogItem.state === 'achievement') return 'achievement';
  if (catalogItem.state === 'locked') return 'locked';
  if (state.currencies.pearls < (catalogItem.price || 0)) return 'not-enough';
  return 'buy';
}

function selectItemEquippedBy(state, itemId) {
  return ['rick','siet'].filter(u =>
    Object.values(state.equippedItems[u] || {}).indexOf(itemId) >= 0
  );
}

function selectEquippedForUser(state, userId, slot) {
  const ids = state.equippedItems[userId] || {};
  if (slot) return ids[slot] || null;
  return ids;
}

Object.assign(window, {
  DiveDexProvider, useDiveDex, DiveDexContext,
  computeDiveScoring, selectLatestDive, selectDiveById, selectDiveHero,
  selectBoutiqueItemState, selectItemEquippedBy, selectEquippedForUser,
});
