// Firebase sync layer — data load/save + group + invite management

function weeklyToFs(weekly) {
  return Object.fromEntries(Object.entries(weekly).map(([k, v]) => [String(k), v]));
}
function fsToWeekly(data) {
  return Object.fromEntries(Object.entries(data).map(([k, v]) => [Number(k), v]));
}
function freeDatesToFs(picked) {
  return Object.fromEntries(Object.entries(picked).map(([k, v]) => [String(k), v]));
}
function fsToFreeDates(data) {
  // Keys are date strings ("2026-4-28"); legacy data may use bare day numbers
  // ("28") — keep both as strings, migration to date keys happens in the UI.
  return Object.fromEntries(Object.entries(data).map(([k, v]) => [String(k), v]));
}
// moods: { [dow]: Set<string> } in app  ↔  { [dow]: string[] } in Firestore
function moodsToFs(moods) {
  return Object.fromEntries(Object.entries(moods).map(([k, v]) => [String(k), [...v]]));
}
function fsToMoods(data) {
  return Object.fromEntries(Object.entries(data).map(([k, v]) => [Number(k), new Set(v)]));
}
// Stable key for a (computed) session so per-user RSVP/votes can be stored
function sessionKey(s) {
  if (!s) return '';
  if (s.recurring) return 'rec_' + s.recurring.id;
  return `${s.groupId || 'all'}_${s.dow}_${s.from}_${s.to}`;
}
window.sessionKey = sessionKey;

const API = () => window.API_BASE || '';

// Merge a list of game objects into the global catalog (dedup by id)
function mergeGamesIntoCatalog(games) {
  if (!games || !games.length) return;
  for (const g of games) {
    if (!window.GAMES_BY_ID[g.id]) {
      window.GAMES.push(g);
      window.GAMES_BY_ID[g.id] = g;
    } else {
      // refresh fields (e.g. cover image) on existing entry
      Object.assign(window.GAMES_BY_ID[g.id], g);
    }
  }
  // Let any open Games screen pick up games that arrived after it mounted
  // (Steam import, late-loading crew members, …).
  window.dispatchEvent(new Event('zocker-games-updated'));
}
window.mergeGamesIntoCatalog = mergeGamesIntoCatalog;

// ── Shared games collection (canonical, one row per game) ────────────────────
// A game's data (title, image, genre, play modes) is the same for everyone, so
// it lives ONCE in `games/{gameId}`, readable/writable by any signed-in user.
// A user's `library` stores only references — [{ id, source }] — pointing into
// that table. In memory we keep fully-hydrated objects (so the UI is unchanged);
// only the thin refs are persisted.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Give up on a game after this many failed fetch attempts so a title Steam has
// no usable data for can never put us in a permanent retry loop.
const MAX_META_TRIES = 3;
// Resolved = genre is known (even '') OR we've tried and failed too often.
const gameMetaResolved = (g) =>
  !!g && (g.genre !== undefined || (g.metaTries || 0) >= MAX_META_TRIES);

// Per-user library reference: just the id + where it came from. `plat` is
// derived from source at hydration, so it isn't stored on the ref.
const libraryToRefs = (lib) =>
  (lib || []).map((g) => ({ id: g.id, source: g.source || 'steam' }));

// Canonical document for the shared games/ collection (no per-user fields).
function gameDoc(g) {
  const doc = { title: g.title || '', tag: g.tag || 'game' };
  if (g.img !== undefined)     doc.img     = g.img || null;
  if (g.capsule !== undefined) doc.capsule = g.capsule || null;
  if (g.genre !== undefined)   doc.genre   = g.genre || '';
  if (g.modes !== undefined)   doc.modes   = g.modes || [];
  doc.updatedAt = firebase.firestore.FieldValue.serverTimestamp();
  return doc;
}

async function fbGameGet(id) {
  if (!id || !window.fbDb) return null;
  try {
    const doc = await window.fbDb.collection('games').doc(id).get();
    return doc.exists ? doc.data() : null;
  } catch (e) { return null; }
}

// Upsert canonical game data (merge, so we never clobber fields we don't have).
async function fbGameUpsert(game) {
  if (!game || !game.id || !window.fbDb) return;
  try {
    await window.fbDb.collection('games').doc(game.id).set(gameDoc(game), { merge: true });
  } catch (e) { console.warn('[sync] fbGameUpsert:', e); }
}

// Upsert many games via batched writes (Firestore caps a batch at 500).
async function fbGamesUpsertMany(games) {
  if (!games || !games.length || !window.fbDb) return;
  for (let i = 0; i < games.length; i += 450) {
    const batch = window.fbDb.batch();
    for (const g of games.slice(i, i + 450)) {
      if (!g.id) continue;
      batch.set(window.fbDb.collection('games').doc(g.id), gameDoc(g), { merge: true });
    }
    try { await batch.commit(); }
    catch (e) { console.warn('[sync] fbGamesUpsertMany:', e); }
  }
}

// Fetch canonical data for a set of ids (chunked `in` queries, 10 per query).
async function fbGamesGet(ids) {
  const out = {};
  const uniq = [...new Set(ids)].filter(Boolean);
  if (!uniq.length || !window.fbDb) return out;
  const col = window.fbDb.collection('games');
  const docId = firebase.firestore.FieldPath.documentId();
  for (let i = 0; i < uniq.length; i += 10) {
    const chunk = uniq.slice(i, i + 10);
    try {
      const snap = await col.where(docId, 'in', chunk).get();
      snap.forEach((d) => { out[d.id] = d.data(); });
    } catch (e) { console.warn('[sync] fbGamesGet:', e); }
  }
  return out;
}

// Resolve a stored library (array of refs) into full in-memory game objects by
// reading canonical data from the shared games/ collection. Refs with no row
// (e.g. after the fresh-start reset) are simply dropped.
async function hydrateLibrary(refs) {
  const norm = (refs || []).map((r) =>
    typeof r === 'string'
      ? { id: r, source: 'steam' }
      : { id: r.id, source: r.source || 'steam' }
  ).filter((r) => r.id);
  const data = await fbGamesGet(norm.map((r) => r.id));
  const games = [];
  for (const r of norm) {
    const c = data[r.id];
    if (!c) continue;
    games.push({ ...c, id: r.id, source: r.source, plat: [r.source] });
  }
  return games;
}
window.hydrateLibrary = hydrateLibrary;

// Map raw Steam API games → app game objects
function steamToGames(rawGames) {
  return (rawGames || []).map((g) => ({
    id: 'steam_' + g.appid,
    title: g.name,
    tag: 'steam',
    plat: ['steam'],
    capsule: g.capsule || null,
    source: 'steam',
    // genre + modes are filled in lazily by fbEnrichLibraryMeta()
  }));
}

// Enrich one game in place with genre + play modes. Prefers the shared games/
// row (filled by another user); otherwise fetches from Steam and writes it back
// so the next person skips the call. Returns 'cache' / 'fetch' / 'fail' / null.
async function enrichGameMeta(g) {
  if (gameMetaResolved(g)) return null;       // already resolved/given up
  if (!g.id || !g.id.startsWith('steam_')) return null; // manual adds arrive enriched

  const canonical = await fbGameGet(g.id);    // shared row from another user?
  if (canonical && canonical.genre !== undefined) {
    g.genre = canonical.genre || '';
    g.modes = canonical.modes || [];
    return 'cache';
  }

  // First time anyone needs this game's details — fetch from Steam's store.
  const appid = g.id.slice('steam_'.length);
  try {
    const r = await fetch(`${API()}/api/steam/appdetails?appid=${appid}`);
    if (!r.ok) throw new Error(`status ${r.status}`);
    const d = await r.json();
    g.genre = d.genre || '';
    g.modes = d.modes || [];
    delete g.metaTries;                        // resolved — drop the retry counter
    await fbGameUpsert(g);                      // write genre+modes into shared row
    return 'fetch';
  } catch (e) {
    g.metaTries = (g.metaTries || 0) + 1;      // bump so we eventually give up
    return 'fail';
  }
}

// Backfill missing genre/modes across the user's library. Runs once in the
// background after a load/import; Steam store calls are throttled to respect its
// rate limit. Canonical data is written to the shared games/ collection, so the
// user's own document (just refs) doesn't need rewriting here.
let __metaRunning = false;
window.fbEnrichLibraryMeta = async function() {
  if (__metaRunning) return;
  const uid = window.__FB_UID;
  if (!uid || !window.fbDb) return;
  const todo = (window.MY_LIBRARY || []).filter((g) => !gameMetaResolved(g) && g.id?.startsWith('steam_'));
  if (!todo.length) return;

  __metaRunning = true;
  try {
    for (const g of todo) {
      const how = await enrichGameMeta(g);
      if (how && how !== 'fail') {
        mergeGamesIntoCatalog([g]);            // refresh catalog + UI
      }
      // Throttle only on real Steam store hits. Its limit is ~200 requests per
      // 5 min, so we space them ~1.5s apart. Cache hits don't wait.
      if (how === 'fetch') await sleep(1500);
    }
  } finally {
    __metaRunning = false;
  }
};

// Connect Steam: pull owned games, merge, persist library + steam profile
window.fbConnectSteam = async function(steamid) {
  const uid = window.__FB_UID;
  if (!uid || !steamid) return { ok: false, error: 'missing-params' };
  try {
    const r = await fetch(`${API()}/api/steam/games?steamid=${steamid}`);
    const data = await r.json();
    if (!r.ok) return { ok: false, error: data.error || 'fetch-failed' };

    // Profile (or its game details) is private — don't mark as connected,
    // surface a helpful message so the user can fix their privacy settings.
    if (data.private) {
      return { ok: false, error: 'private', steamId: steamid };
    }

    const games = steamToGames(data.games);
    mergeGamesIntoCatalog(games);

    // Merge into in-memory library (keep any manually-added games)
    const existing = window.MY_LIBRARY || [];
    const byId = new Map(existing.map(g => [g.id, g]));
    for (const g of games) byId.set(g.id, g);
    window.MY_LIBRARY = [...byId.values()];

    // Persist: canonical game rows go to the shared games/ collection; the user
    // doc stores only references + the steam profile.
    await fbGamesUpsertMany(games);
    window.__MY_STEAM = { steamId: steamid, connected: true };
    await window.fbDb.collection('users').doc(uid).set({
      steam: window.__MY_STEAM,
      library: libraryToRefs(window.MY_LIBRARY),
    }, { merge: true });

    window.fbEnrichLibraryMeta(); // fetch genre + single/multiplayer in the background

    return { ok: true, count: games.length };
  } catch (e) {
    console.warn('[sync] fbConnectSteam:', e);
    return { ok: false, error: 'exception' };
  }
};

// Add a single game (manual add) to the library
window.fbAddGame = async function(game) {
  const uid = window.__FB_UID;
  if (!uid || !game) return;
  mergeGamesIntoCatalog([game]);
  const existing = window.MY_LIBRARY || [];
  if (!existing.find(g => g.id === game.id)) {
    window.MY_LIBRARY = [...existing, game];
    // Canonical data into the shared collection; only a ref into the user doc.
    await fbGameUpsert(game);
    try {
      await window.fbDb.collection('users').doc(uid).set(
        { library: libraryToRefs(window.MY_LIBRARY) }, { merge: true });
    } catch (e) { console.warn('[sync] fbAddGame:', e); }
  }
};

// Remove a game from the library (only drops the user's reference; the shared
// games/ row stays so other users keep it).
window.fbRemoveGame = async function(gameId) {
  const uid = window.__FB_UID;
  if (!uid) return;
  window.MY_LIBRARY = (window.MY_LIBRARY || []).filter(g => g.id !== gameId);
  try {
    await window.fbDb.collection('users').doc(uid).set(
      { library: libraryToRefs(window.MY_LIBRARY) }, { merge: true });
  } catch (e) { console.warn('[sync] fbRemoveGame:', e); }
};

// Search games for manual add
window.fbSearchGames = async function(query) {
  try {
    const r = await fetch(`${API()}/api/games/search?q=${encodeURIComponent(query)}`);
    const data = await r.json();
    if (!r.ok) return { ok: false, error: data.error || 'search-failed', games: [] };
    return { ok: true, games: data.games || [] };
  } catch (e) {
    return { ok: false, error: 'exception', games: [] };
  }
};

function invalidateSessions() {
  if (window.SESSIONS_CACHE) Object.keys(window.SESSIONS_CACHE).forEach(k => delete window.SESSIONS_CACHE[k]);
}

// ── Group helpers ──────────────────────────────────────────────────────────

// Merge Firestore groups into window globals.
// The local user is represented by the id 'me', so swap our real uid → 'me'
// in each group's memberIds for consistent rendering.
function applyGroupsToWindow(groups) {
  if (!groups || groups.length === 0) return;
  const myUid = window.__FB_UID;
  const existingIds = new Set(window.GROUPS.map(g => g.id));
  for (const g of groups) {
    if (myUid && Array.isArray(g.memberIds)) {
      g.memberIds = g.memberIds.map(id => (id === myUid ? 'me' : id));
    }
    if (!existingIds.has(g.id)) {
      window.GROUPS.push(g);
      existingIds.add(g.id);
    } else {
      // Update in-place (e.g. memberIds changed)
      const idx = window.GROUPS.findIndex(x => x.id === g.id);
      if (idx !== -1) window.GROUPS[idx] = g;
    }
  }
  window.GROUPS_BY_ID = Object.fromEntries(window.GROUPS.map(g => [g.id, g]));
  invalidateSessions();
}

// Load all members' profile + weekly + picks into window globals
async function fbLoadGroupMembers(groups) {
  const myUid = window.__FB_UID;
  const allIds = [...new Set((groups || []).flatMap(g => g.memberIds || []))];

  for (const uid of allIds) {
    if (uid === myUid) continue;

    try {
      const doc = await window.fbDb.collection('users').doc(uid).get();
      if (!doc.exists) continue;
      const d = doc.data();

      // Create the friend on first load, otherwise refresh the existing entry
      // in place so a re-fetch picks up profile changes.
      const existing = window.FRIENDS_BY_ID[uid];
      const friend = existing || { id: uid, online: false };
      friend.name = (d.displayName || 'Unbekannt').split(' ')[0];
      friend.gamertag = d.gamertag || '';
      friend.hue = d.hue || 200;
      friend.platform = d.platform || 'steam';
      friend.photo = d.photo || null;
      if (!existing) {
        window.FRIENDS.push(friend);
        window.FRIENDS_BY_ID[uid] = friend;
      }
      if (d.weekly) window.WEEKLY[uid] = fsToWeekly(d.weekly);
      if (d.picks)  window.PICKS[uid]  = d.picks;
      // Each member's per-session RSVP/vote, indexed by sessionKey so the whole
      // crew can see who's in / maybe / declined.
      if (d.responses) {
        for (const [skey, resp] of Object.entries(d.responses)) {
          (window.__SESSION_RESPONSES[skey] ||= {})[uid] = resp;
        }
      }
      if (d.library && d.library.length) {
        // library is an array of refs → hydrate from the shared games/ table
        const lib = await hydrateLibrary(d.library);
        window.FRIEND_LIBRARY[uid] = lib;
        mergeGamesIntoCatalog(lib);
      }
    } catch (e) {
      console.warn('[sync] fbLoadGroupMembers uid', uid, e);
    }
  }
  invalidateSessions();
}

// Load or create this user's groups on first login
async function fbInitUserGroups(uid) {
  try {
    const snap = await window.fbDb.collection('groups')
      .where('memberIds', 'array-contains', uid)
      .get();

    if (!snap.empty) {
      return snap.docs.map(d => ({ id: d.id, ...d.data() }));
    }

    // First login — create default group
    const ref = await window.fbDb.collection('groups').add({
      nameDe: 'Deine Crew',
      nameEn: 'Your Crew',
      glyph: '◉',
      hue: 145,
      ownerUid: uid,
      memberIds: [uid],
      createdAt: firebase.firestore.FieldValue.serverTimestamp(),
    });
    return [{ id: ref.id, nameDe: 'Deine Crew', nameEn: 'Your Crew', glyph: '◉', hue: 145, ownerUid: uid, memberIds: [uid] }];
  } catch (e) {
    console.warn('[sync] fbInitUserGroups:', e);
    return null;
  }
}

// Create a new group. Persists to Firestore first so the local group uses the
// real document id (required for invites/member updates to work), and is shown
// instantly with the local 'me' id as its member.
window.fbCreateGroup = async function({ nameDe, nameEn, glyph, hue }) {
  const uid = window.__FB_UID;
  const group = {
    id: 'local_' + Date.now(),
    nameDe: nameDe || 'Neue Gruppe',
    nameEn: nameEn || nameDe || 'New group',
    glyph: glyph || '◉',
    hue: hue || 145,
    ownerUid: uid || null, // creator is the admin
    memberIds: ['me'],
  };

  if (uid && window.fbDb) {
    try {
      const ref = await window.fbDb.collection('groups').add({
        nameDe: group.nameDe,
        nameEn: group.nameEn,
        glyph: group.glyph,
        hue: group.hue,
        ownerUid: uid,
        memberIds: [uid],
        createdAt: firebase.firestore.FieldValue.serverTimestamp(),
      });
      group.id = ref.id; // use the real Firestore doc id
    } catch (e) { console.warn('[sync] fbCreateGroup:', e); }
  }

  window.GROUPS.push(group);
  window.GROUPS_BY_ID[group.id] = group;
  invalidateSessions();
  return group;
};

// Leave a group — remove the current user from its memberIds (local + Firestore)
window.fbLeaveGroup = async function(groupId) {
  const uid = window.__FB_UID;

  // Remove locally so the UI updates immediately
  const idx = window.GROUPS.findIndex(g => g.id === groupId);
  if (idx !== -1) window.GROUPS.splice(idx, 1);
  delete window.GROUPS_BY_ID[groupId];
  invalidateSessions();

  // Persist to Firestore (skip purely-local groups that were never saved)
  if (uid && window.fbDb && !String(groupId).startsWith('local_')) {
    try {
      await window.fbDb.collection('groups').doc(groupId).update({
        memberIds: firebase.firestore.FieldValue.arrayRemove(uid),
      });
    } catch (e) { console.warn('[sync] fbLeaveGroup:', e); }
  }
};

// Delete a group entirely (admin/founder only). Removes the Firestore doc and
// the local copy. Callers should gate this on ownerUid === current uid.
window.fbDeleteGroup = async function(groupId) {
  // Remove locally so the UI updates immediately
  const idx = window.GROUPS.findIndex(g => g.id === groupId);
  if (idx !== -1) window.GROUPS.splice(idx, 1);
  delete window.GROUPS_BY_ID[groupId];
  invalidateSessions();

  const uid = window.__FB_UID;
  if (uid && window.fbDb && !String(groupId).startsWith('local_')) {
    try {
      await window.fbDb.collection('groups').doc(groupId).delete();
    } catch (e) { console.warn('[sync] fbDeleteGroup:', e); }
  }
};

// Background refresh of the already-connected Steam library — no OpenID
// round-trip, since the steamId is stored. Merges new games into the catalog
// and library and persists. Silent on private/error so we never wipe what we
// already have.
window.fbRefreshSteamLibrary = async function() {
  const uid = window.__FB_UID;
  const steam = window.__MY_STEAM;
  if (!uid || !window.fbDb || !steam || !steam.connected || !steam.steamId) return { ok: false };
  try {
    const r = await fetch(`${API()}/api/steam/games?steamid=${steam.steamId}`);
    const data = await r.json();
    if (!r.ok || data.private) return { ok: false };

    const games = steamToGames(data.games);
    if (!games.length) return { ok: false };

    // Merge with the in-memory library: keep existing entries (and their already
    // resolved genre/modes) untouched, only append games we don't have yet.
    const existing = window.MY_LIBRARY || [];
    const have = new Set(existing.map(g => g.id));
    const fresh = games.filter(g => !have.has(g.id));
    if (fresh.length) {
      window.MY_LIBRARY = [...existing, ...fresh];
      mergeGamesIntoCatalog(fresh); // dispatches zocker-games-updated → UI refresh
      await fbGamesUpsertMany(fresh);
      await window.fbDb.collection('users').doc(uid).set(
        { library: libraryToRefs(window.MY_LIBRARY) }, { merge: true });
    }
    window.fbEnrichLibraryMeta(); // backfill metadata for any newly-added games
    return { ok: true, added: fresh.length };
  } catch (e) {
    console.warn('[sync] fbRefreshSteamLibrary:', e);
    return { ok: false };
  }
};

// Save push-notification preference
window.fbSavePushPref = async function(enabled) {
  const uid = window.__FB_UID;
  window.__MY_PUSH = !!enabled;
  if (!uid || !window.fbDb) return;
  try {
    await window.fbDb.collection('users').doc(uid).set({ push: !!enabled }, { merge: true });
  } catch (e) { console.warn('[sync] fbSavePushPref:', e); }
};

// Save how many minutes before a game night the push reminder fires
window.fbSavePushLead = async function(minutes) {
  const uid = window.__FB_UID;
  const m = Number(minutes) || 15;
  window.__MY_PUSH_LEAD = m;
  if (!uid || !window.fbDb) return;
  try {
    await window.fbDb.collection('users').doc(uid).set({ pushLead: m }, { merge: true });
  } catch (e) { console.warn('[sync] fbSavePushLead:', e); }
};

// Save the user's chosen UI language so it follows them across devices.
window.fbSaveLang = async function(code) {
  const uid = window.__FB_UID;
  if (!uid || !window.fbDb || !code) return;
  try {
    await window.fbDb.collection('users').doc(uid).set({ lang: code }, { merge: true });
  } catch (e) { console.warn('[sync] fbSaveLang:', e); }
};

// Accept any pending invites for this user's email
async function fbAcceptPendingInvites(user) {
  if (!user?.email) return;
  const email = user.email.toLowerCase().trim();
  try {
    const snap = await window.fbDb.collection('invites')
      .where('toEmail', '==', email)
      .where('status', '==', 'pending')
      .get();

    if (snap.empty) return;

    const batch = window.fbDb.batch();
    for (const doc of snap.docs) {
      const inv = doc.data();
      const groupRef = window.fbDb.collection('groups').doc(inv.groupId);
      batch.update(groupRef, { memberIds: firebase.firestore.FieldValue.arrayUnion(user.uid) });
      batch.update(doc.ref, { status: 'accepted', acceptedAt: firebase.firestore.FieldValue.serverTimestamp() });
    }
    await batch.commit();
  } catch (e) {
    console.warn('[sync] fbAcceptPendingInvites:', e);
  }
}

// Send invite to an email for a specific group
window.fbInviteFriend = async function(toEmail, groupId) {
  const uid    = window.__FB_UID;
  const me     = window.FRIENDS && window.FRIENDS.find(f => f.id === 'me');
  const fromName = me?.name || 'Unbekannt';
  const group  = window.GROUPS_BY_ID?.[groupId];
  const groupName = group ? (window.__LANG === 'en' ? group.nameEn : group.nameDe) : groupId;

  if (!uid) throw new Error('Not logged in');

  const email = toEmail.toLowerCase().trim();

  // Check if they're already a member
  if (group && group.memberIds) {
    const usersSnap = await window.fbDb.collection('users')
      .where('email', '==', email).limit(1).get();

    if (!usersSnap.empty) {
      const targetUid = usersSnap.docs[0].id;
      if ((group.memberIds || []).includes(targetUid)) {
        throw new Error('already_member');
      }
      // User exists → add directly + mark invite accepted
      await window.fbDb.collection('groups').doc(groupId).update({
        memberIds: firebase.firestore.FieldValue.arrayUnion(targetUid),
      });
      await window.fbDb.collection('invites').add({
        fromUid: uid, fromName, toEmail: email, groupId, groupName,
        status: 'accepted',
        createdAt: firebase.firestore.FieldValue.serverTimestamp(),
      });
      // Send notification email to the existing user
      await sendInviteEmail({ toEmail: email, fromName, groupName, lang: window.__LANG, status: 'added' });
      return 'added'; // already registered, added immediately
    }
  }

  // User not registered yet — store pending invite
  await window.fbDb.collection('invites').add({
    fromUid: uid, fromName, toEmail: email, groupId, groupName,
    status: 'pending',
    createdAt: firebase.firestore.FieldValue.serverTimestamp(),
  });
  // Send invite email with registration link
  await sendInviteEmail({ toEmail: email, fromName, groupName, lang: window.__LANG, status: 'invited' });
  return 'invited'; // invite stored, they'll join on registration
};

// Build the shareable URL for an invite token, anchored to the current page so
// it works regardless of which HTML entry (index / Zocker-Abstimmung) is served.
function inviteUrl(token) {
  const loc = window.location;
  return `${loc.origin}${loc.pathname}?invite=${token}`;
}

// Create a reusable, token-based invite link for a group. Anyone who opens it
// joins the group directly (or after registering). Returns { token, url }.
window.fbCreateInviteLink = async function(groupId) {
  const uid = window.__FB_UID;
  const me = window.FRIENDS && window.FRIENDS.find(f => f.id === 'me');
  const fromName = me?.name || 'Unbekannt';
  const group = window.GROUPS_BY_ID?.[groupId];
  const groupName = group ? (window.__LANG === 'en' ? group.nameEn : group.nameDe) : groupId;
  if (!uid || !window.fbDb) throw new Error('Not logged in');

  const ref = await window.fbDb.collection('invites').add({
    type: 'link', groupId, groupName,
    fromUid: uid, fromName, status: 'open',
    createdAt: firebase.firestore.FieldValue.serverTimestamp(),
  });
  return { token: ref.id, url: inviteUrl(ref.id), groupName };
};

// Look up an invite token (used to show the group name on the auth screen).
window.fbFetchInvite = async function(token) {
  if (!token || !window.fbDb) return null;
  try {
    const doc = await window.fbDb.collection('invites').doc(token).get();
    return doc.exists ? { id: doc.id, ...doc.data() } : null;
  } catch (e) { return null; }
};

// Join a group from an invite token while already signed in, and load the group
// (+ its members) into the in-memory state so the UI updates immediately.
window.fbJoinGroupByInvite = async function(token) {
  const uid = window.__FB_UID;
  const inv = await window.fbFetchInvite(token);
  if (!inv || !inv.groupId || !uid || !window.fbDb) return { ok: false };
  try {
    await window.fbDb.collection('groups').doc(inv.groupId).update({
      memberIds: firebase.firestore.FieldValue.arrayUnion(uid),
    });
    const gd = await window.fbDb.collection('groups').doc(inv.groupId).get();
    if (gd.exists) {
      const g = { id: gd.id, ...gd.data() };
      applyGroupsToWindow([g]);
      await fbLoadGroupMembers([g]);
    }
  } catch (e) {
    console.warn('[sync] fbJoinGroupByInvite:', e);
    return { ok: false };
  }
  return { ok: true, groupId: inv.groupId, groupName: inv.groupName };
};

// On sign-in/up, consume a link-invite token captured from the URL before login.
async function fbAcceptPendingInviteLink(user) {
  let token = null;
  try { token = localStorage.getItem('zocker_pending_invite'); } catch (_) {}
  if (!token || !user || !window.fbDb) return null;
  let groupId = null, groupName = null;
  try {
    const doc = await window.fbDb.collection('invites').doc(token).get();
    if (doc.exists) {
      const inv = doc.data();
      if (inv.groupId) {
        await window.fbDb.collection('groups').doc(inv.groupId).update({
          memberIds: firebase.firestore.FieldValue.arrayUnion(user.uid),
        });
        groupId = inv.groupId;
        groupName = inv.groupName || null;
      }
    }
  } catch (e) {
    console.warn('[sync] fbAcceptPendingInviteLink:', e);
  }
  try { localStorage.removeItem('zocker_pending_invite'); } catch (_) {}
  return groupId ? { groupId, groupName } : null;
}

async function sendInviteEmail({ toEmail, fromName, groupName, lang, status }) {
  try {
    const base = window.API_BASE || '';
    await fetch(`${base}/api/invite/send`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ toEmail, fromName, groupName, lang, status }),
    });
  } catch (_) {
    // Email is best-effort — don't block the invite flow
  }
}

// Load own data + init groups + load members
async function loadUserData(uid) {
  if (!uid || !window.fbDb) return null;
  window.__DATA_READY = false;
  try {
    const [userDoc, ggDoc] = await Promise.all([
      window.fbDb.collection('users').doc(uid).get(),
      window.fbDb.collection('groupGames').doc(uid).get(),
    ]);

    // defaults so the UI always has something to read
    window.__MY_FREE_DATES  = {};
    window.__MY_MOODS       = {};
    window.__MY_WEEKLY_GROUPS = null;
    window.__MY_CREW_PRIORITY = [];
    window.__MY_CONNECTIONS = { steam: false, epic: false };
    window.__MY_RESPONSES   = {};
    window.__SESSION_RESPONSES = {}; // { sessionKey: { uid: { rsvp, voteGameId } } } — other members' responses
    window.MY_LIBRARY       = [];
    window.__MY_STEAM       = { steamId: null, connected: false };
    window.__MY_PUSH        = false;
    window.__MY_PUSH_LEAD   = 15;
    window.__MY_HIDE_SINGLE = true; // default: hide single-player games

    if (userDoc.exists) {
      const d = userDoc.data();
      if (d.weekly) {
        window.WEEKLY.me = fsToWeekly(d.weekly);
        invalidateSessions();
      }
      if (d.picks) window.PICKS.me = d.picks;
      if (d.freeDates)    window.__MY_FREE_DATES  = fsToFreeDates(d.freeDates);
      if (d.moods)        window.__MY_MOODS       = fsToMoods(d.moods);
      if (d.weeklyGroups) window.__MY_WEEKLY_GROUPS = d.weeklyGroups;
      if (Array.isArray(d.crewPriority)) window.__MY_CREW_PRIORITY = d.crewPriority;
      if (d.connections)  window.__MY_CONNECTIONS = d.connections;
      if (d.responses)    window.__MY_RESPONSES   = d.responses;
      if (d.steam)        window.__MY_STEAM       = d.steam;
      if (d.library && d.library.length) {
        // library is an array of refs → hydrate from the shared games/ table
        const lib = await hydrateLibrary(d.library);
        window.MY_LIBRARY = lib;
        mergeGamesIntoCatalog(lib);
      }
      if (d.displayName) {
        const me = window.FRIENDS && window.FRIENDS.find(f => f.id === 'me');
        if (me) me.name = d.displayName.split(' ')[0];
      }
      if (d.gamertag !== undefined) {
        const me = window.FRIENDS && window.FRIENDS.find(f => f.id === 'me');
        if (me) me.gamertag = d.gamertag || '';
      }
      if (d.photo !== undefined) {
        const me = window.FRIENDS && window.FRIENDS.find(f => f.id === 'me');
        if (me) me.photo = d.photo || null;
      }
      if (d.push !== undefined) window.__MY_PUSH = !!d.push;
      if (d.pushLead !== undefined) window.__MY_PUSH_LEAD = Number(d.pushLead) || 15;
      if (d.hideSingle !== undefined) window.__MY_HIDE_SINGLE = !!d.hideSingle;
      // The account's saved language wins over the local/browser guess on login,
      // so the user's choice follows them onto a new device. We apply it without
      // re-persisting (persistCloud:false) since it just came from the cloud.
      if (d.lang && window.isLangSupported?.(d.lang)) window.setLang?.(d.lang, { persistCloud: false });
    }

    const groups = await fbInitUserGroups(uid);
    if (groups) {
      applyGroupsToWindow(groups);
      await fbLoadGroupMembers(groups);
    }

    // Backfill genre + single/multiplayer for any library games still missing it
    // (e.g. saved before this feature existed). Runs in the background.
    window.fbEnrichLibraryMeta();

    return {
      groupGames: ggDoc.exists ? ggDoc.data() : null,
      groups,
    };
  } catch (e) {
    console.warn('[sync] loadUserData error:', e);
    return null;
  } finally {
    // Signal the UI that the DB load has settled (so the Games screen can stop
    // showing its loading spinner), whether it succeeded or errored.
    window.__DATA_READY = true;
    window.dispatchEvent(new Event('zocker-data-ready'));
  }
}

// Re-fetch the signed-in user's data + the whole crew's shared data (picks,
// per-session RSVP/votes, libraries, group memberships) WITHOUT tearing down
// the UI or showing the loading screen. Called when the app regains focus and
// on a light interval, so cached snapshots from login don't go stale. Guarded
// so overlapping triggers (focus + interval firing together) run only once.
let __refreshing = false;
async function refreshUserData() {
  const uid = window.__FB_UID;
  if (!uid || !window.fbDb || __refreshing) return;
  __refreshing = true;
  try {
    const userDoc = await window.fbDb.collection('users').doc(uid).get();
    if (userDoc.exists) {
      const d = userDoc.data();
      // Only overwrite when the field is present, so a refresh never wipes
      // state the UI is already showing.
      if (d.weekly) window.WEEKLY.me = fsToWeekly(d.weekly);
      if (d.picks) window.PICKS.me = d.picks;
      if (d.freeDates)    window.__MY_FREE_DATES  = fsToFreeDates(d.freeDates);
      if (d.moods)        window.__MY_MOODS       = fsToMoods(d.moods);
      if (d.weeklyGroups) window.__MY_WEEKLY_GROUPS = d.weeklyGroups;
      if (Array.isArray(d.crewPriority)) window.__MY_CREW_PRIORITY = d.crewPriority;
      if (d.connections)  window.__MY_CONNECTIONS = d.connections;
      if (d.responses)    window.__MY_RESPONSES   = d.responses;
      if (d.steam)        window.__MY_STEAM       = d.steam;
      if (d.library && d.library.length) {
        const lib = await hydrateLibrary(d.library);
        window.MY_LIBRARY = lib;
        mergeGamesIntoCatalog(lib);
      }
      const me = window.FRIENDS && window.FRIENDS.find(f => f.id === 'me');
      if (me) {
        if (d.displayName) me.name = d.displayName.split(' ')[0];
        if (d.gamertag !== undefined) me.gamertag = d.gamertag || '';
        if (d.photo !== undefined) me.photo = d.photo || null;
      }
      if (d.push !== undefined) window.__MY_PUSH = !!d.push;
      if (d.pushLead !== undefined) window.__MY_PUSH_LEAD = Number(d.pushLead) || 15;
      if (d.hideSingle !== undefined) window.__MY_HIDE_SINGLE = !!d.hideSingle;
    }

    // Refresh group memberships + every member's shared data in place.
    const groups = await fbInitUserGroups(uid);
    if (groups) {
      applyGroupsToWindow(groups);
      await fbLoadGroupMembers(groups);
    }

    invalidateSessions();
    window.dispatchEvent(new Event('zocker-data-refreshed'));
  } catch (e) {
    console.warn('[sync] refreshUserData:', e);
  } finally {
    __refreshing = false;
  }
}
window.refreshUserData = refreshUserData;

// ── Save functions ──────────────────────────────────────────────────────────

window.fbSaveWeekly = async function(slots) {
  const uid = window.__FB_UID;
  if (!uid) return;
  window.WEEKLY.me = slots;
  invalidateSessions();
  try {
    await window.fbDb.collection('users').doc(uid).set({ weekly: weeklyToFs(slots) }, { merge: true });
  } catch (e) { console.warn('[sync] fbSaveWeekly:', e); }
};

window.fbSavePicks = async function(picks) {
  const uid = window.__FB_UID;
  if (!uid) return;
  window.PICKS.me = picks;
  try {
    await window.fbDb.collection('users').doc(uid).set({ picks }, { merge: true });
  } catch (e) { console.warn('[sync] fbSavePicks:', e); }
};

window.fbSaveFreeDates = async function(picked) {
  const uid = window.__FB_UID;
  if (!uid) return;
  window.__MY_FREE_DATES = picked;
  invalidateSessions(); // a per-date override changes availability → rebuild sessions
  try {
    await window.fbDb.collection('users').doc(uid).set({ freeDates: freeDatesToFs(picked) }, { merge: true });
  } catch (e) { console.warn('[sync] fbSaveFreeDates:', e); }
};

window.fbSaveGroupGames = async function(groupGames) {
  const uid = window.__FB_UID;
  if (!uid) return;
  try {
    await window.fbDb.collection('groupGames').doc(uid).set(groupGames);
  } catch (e) { console.warn('[sync] fbSaveGroupGames:', e); }
};

window.fbSaveProfile = async function(profile) {
  const uid = window.__FB_UID;
  if (!uid) return;
  try {
    await window.fbDb.collection('users').doc(uid).set(profile, { merge: true });
  } catch (e) { console.warn('[sync] fbSaveProfile:', e); }
};

// Persist the "hide single-player games" preference. Fires an event so any
// open games view re-filters immediately.
window.setHideSinglePlayer = async function(on) {
  window.__MY_HIDE_SINGLE = !!on;
  window.dispatchEvent(new Event('zocker-prefs-changed'));
  const uid = window.__FB_UID;
  if (!uid) return;
  try {
    await window.fbDb.collection('users').doc(uid).set({ hideSingle: !!on }, { merge: true });
  } catch (e) { console.warn('[sync] setHideSinglePlayer:', e); }
};

// Persist a (compressed) profile photo data-URL, or null to remove it. Updates
// the local FRIENDS entry so the new avatar shows everywhere immediately.
window.fbSaveProfilePhoto = async function(dataUrl) {
  const me = window.FRIENDS && window.FRIENDS.find(f => f.id === 'me');
  if (me) me.photo = dataUrl || null;
  const uid = window.__FB_UID;
  if (!uid) return;
  try {
    await window.fbDb.collection('users').doc(uid).set({ photo: dataUrl || null }, { merge: true });
  } catch (e) { console.warn('[sync] fbSaveProfilePhoto:', e); }
};

window.fbSaveMoods = async function(moods) {
  const uid = window.__FB_UID;
  if (!uid) return;
  window.__MY_MOODS = moods;
  try {
    await window.fbDb.collection('users').doc(uid).set({ moods: moodsToFs(moods) }, { merge: true });
  } catch (e) { console.warn('[sync] fbSaveMoods:', e); }
};

window.fbSaveWeeklyGroups = async function(groupIds) {
  const uid = window.__FB_UID;
  if (!uid) return;
  window.__MY_WEEKLY_GROUPS = groupIds;
  invalidateSessions(); // per-day crew selection changes which crews I'm free for
  try {
    await window.fbDb.collection('users').doc(uid).set({ weeklyGroups: groupIds }, { merge: true });
  } catch (e) { console.warn('[sync] fbSaveWeeklyGroups:', e); }
};

window.fbSaveCrewPriority = async function(groupIds) {
  const uid = window.__FB_UID;
  window.__MY_CREW_PRIORITY = groupIds;
  if (!uid) return;
  try {
    await window.fbDb.collection('users').doc(uid).set({ crewPriority: groupIds }, { merge: true });
  } catch (e) { console.warn('[sync] fbSaveCrewPriority:', e); }
};

window.fbSaveConnections = async function(connections) {
  const uid = window.__FB_UID;
  if (!uid) return;
  window.__MY_CONNECTIONS = connections;
  try {
    await window.fbDb.collection('users').doc(uid).set({ connections }, { merge: true });
  } catch (e) { console.warn('[sync] fbSaveConnections:', e); }
};

// Merge a patch into this user's response for one session, in memory + Firestore.
// Existing fields survive (so changing the RSVP keeps a ready-check answer, and
// vice versa). Returns the merged response.
async function fbMergeResponse(key, patch) {
  const uid = window.__FB_UID;
  if (!uid || !key) return null;
  const prev = (window.__MY_RESPONSES || {})[key] || {};
  const merged = { ...prev, ...patch };
  window.__MY_RESPONSES = { ...(window.__MY_RESPONSES || {}), [key]: merged };
  try {
    await window.fbDb.collection('users').doc(uid)
      .set({ responses: { [key]: merged } }, { merge: true });
  } catch (e) { console.warn('[sync] fbMergeResponse:', e); }
  return merged;
}
window.fbMergeResponse = fbMergeResponse;

// Persist this user's RSVP + game vote for a single session (field-preserving).
window.fbSaveResponse = async function(key, response) {
  return fbMergeResponse(key, response || {});
};

// Fire a readiness check for a session — records it on my response so the rest
// of the crew picks it up on their next data refresh. Returns the check object.
window.fbRequestReadyCheck = async function(session) {
  const key = window.sessionKey?.(session);
  const uid = window.__FB_UID;
  if (!key || !uid) return null;
  const check = { at: Date.now(), by: uid };
  // Requesting also clears my own stale answer so I show up as "no reply" too.
  await fbMergeResponse(key, { readyCheck: check, ready: null });
  return check;
};

// Record my own readiness answer ('yes' | 'coming') for a session.
window.fbSetReady = async function(session, status) {
  const key = window.sessionKey?.(session);
  if (!key) return;
  await fbMergeResponse(key, { ready: status ? { status, at: Date.now() } : null });
};

Object.assign(window, { loadUserData, fbAcceptPendingInvites, fbAcceptPendingInviteLink });
