// JustZix sync module — klient API dla backendu justzix.com
// Pełen spec: project/justzix-docs/api.md
//
// Eksportuje globalnie window.JZ_SYNC (lub self.JZ_SYNC w service workerze).
// Stanu trzyma w chrome.storage.local pod kluczem 'syncState'.
//
// Użycie:
//   await JZ_SYNC.init();
//   if (!JZ_SYNC.isAuthenticated()) { ... pokaż login UI ... }
//   await JZ_SYNC.register({ syncKey, deviceLabel: 'PC' });
//   await JZ_SYNC.pull();
//   JZ_SYNC.enqueueChange({ type: 'folder', id, payload });
//   await JZ_SYNC.push();

(function (g) {
  'use strict';

  // ============================================================
  // Konfiguracja i stałe
  // ============================================================

  const DEFAULT_API_BASE = 'https://www.justzix.com';
  const STORAGE_KEY = 'syncState';
  const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Crockford bez I/L/O/U
  const SESSION_TOKEN_TTL_HINT_S = 7 * 24 * 3600; // 7 dni — info dla UI; prawdę mówi expiresAt
  const PUSH_BATCH_LIMIT = 1000;
  const PULL_BATCH_LIMIT = 1000;

  // ============================================================
  // Domyślny stan + cache w pamięci
  // ============================================================

  function defaultState() {
    return {
      apiBase: DEFAULT_API_BASE,
      sessionToken: null,
      expiresAt: null,
      user: null,
      deviceUuid: null,
      deviceLabel: null,
      // syncKey jest trzymany lokalnie tylko by user mógł podejrzeć/skopiować na drugie
      // urządzenie. Backend zna tylko hash. Można go usunąć przez forgetSyncKey().
      syncKey: null,
      lastPulledCursor: 0,
      // Jednorazowa migracja po naprawie błędu kursora (v2.13.159) — patrz loadState().
      cursorResyncDone: false,
      lastSeenServerNow: 0,
      lastSyncedAt: 0, // epoch ms ostatniego udanego push/pull tick
      pendingChanges: [], // [{type, id, parentId, payload, updatedAt}]
      pendingDeletes: [], // [{type, id, parentId, deletedAt}]
    };
  }

  let state = defaultState();
  let initialized = false;

  // ============================================================
  // Storage (chrome.storage.local)
  // ============================================================

  // Czyta surowy stan z chrome.storage.local (z domyślkami). NIE dotyka `state`.
  async function _readStoredState() {
    try {
      const data = await chrome.storage.local.get([STORAGE_KEY]);
      const stored = data[STORAGE_KEY];
      if (stored && typeof stored === 'object') {
        return { ...defaultState(), ...stored };
      }
    } catch (e) {
      console.warn('[JZ_SYNC] _readStoredState error:', e);
    }
    return defaultState();
  }

  async function loadState() {
    state = await _readStoredState();
    // Jednorazowe odzyskanie po naprawie błędu kursora (v2.13.159). Wcześniej
    // pushBatch przesuwał lastPulledCursor na newCursor — jeśli między starym
    // kursorem a newCursor wisiały niepobrane encje z innego urządzenia, skok
    // pomijał je bezpowrotnie (rozjazd bibliotek). Reset kursora do 0 wymusza
    // ponowny pull całego logu serwera; applyEntities jest idempotentne
    // (upsert replace-by-id, tombstone'y), więc to bezpieczne i biblioteki
    // urządzeń się zejdą. Flaga gwarantuje jednokrotne wykonanie. Idzie przez
    // _commitState — RMW na świeżym stanie, nie clobber kopią w pamięci.
    if (state.cursorResyncDone !== true) {
      await _commitState((s) => {
        if (s.cursorResyncDone !== true) {
          s.lastPulledCursor = 0;
          s.cursorResyncDone = true;
        }
      });
    }
  }

  async function saveState() {
    try {
      await chrome.storage.local.set({ [STORAGE_KEY]: state });
    } catch (e) {
      console.warn('[JZ_SYNC] saveState error:', e);
    }
  }

  // ============================================================
  // _commitState — serializowany read-modify-write stanu sync
  // ============================================================
  //
  // Problem: każdy kontekst (service worker / options / popup) trzyma własną
  // kopię `state` w pamięci, a saveState() zapisuje CAŁY blob. Kontekst ze
  // starą kopią nadpisywał świeże zmiany innego kontekstu — gubił
  // pendingChanges/pendingDeletes (kolejka znikała), cofał lastPulledCursor
  // albo kasował cursorResyncDone (migracja v2.13.159 ruszała ponownie).
  //
  // Rozwiązanie: każda mutacja stanu idzie przez _commitState — tuż przed
  // zapisem przeładowuje świeży stan z storage, aplikuje deltę, zapisuje.
  // Okno wyścigu zwęża się z „całego życia kopii w pamięci" do paru ms RMW.
  // Wywołania w obrębie jednego kontekstu są serializowane łańcuchem promisów.
  let _commitChain = Promise.resolve();
  function _commitState(mutator) {
    const run = async () => {
      const fresh = await _readStoredState();
      let result;
      try { result = mutator(fresh); }
      catch (e) { console.warn('[JZ_SYNC] _commitState mutator error:', e); }
      state = fresh;
      await saveState();
      return result;
    };
    _commitChain = _commitChain.then(run, run);
    return _commitChain;
  }

  async function patchState(patch) {
    await _commitState((s) => { Object.assign(s, patch); });
  }

  // ============================================================
  // Sync key — generacja, kanonikalizacja, walidacja
  // ============================================================

  function generateSyncKey() {
    const bytes = (g.crypto || globalThis.crypto).getRandomValues(new Uint8Array(25));
    let chars = '';
    for (const b of bytes) chars += ALPHABET[b % 32];
    return 'SYNC-' + chars.match(/.{5}/g).join('-');
  }

  function canonicalizeSyncKey(input) {
    if (typeof input !== 'string') {
      throw new Error('Klucz musi być stringiem');
    }
    let s = input.toUpperCase().trim();
    if (s.startsWith('SYNC-')) s = s.slice(5);
    s = s.replace(/[\s\-_]+/g, '');
    // Crockford normalization — backend zrobi to samo
    s = s.replace(/[IL]/g, '1').replace(/O/g, '0').replace(/U/g, 'V');
    if (s.length !== 25 || !/^[0-9A-HJKMNPQRSTVWXYZ]+$/.test(s)) {
      throw new Error('Nieprawidłowy klucz synchronizacji (oczekiwane 25 znaków alfanumerycznych)');
    }
    // Format wyświetlania zgodny ze spec:
    return 'SYNC-' + s.match(/.{5}/g).join('-');
  }

  function isValidSyncKey(input) {
    try { canonicalizeSyncKey(input); return true; } catch (_) { return false; }
  }

  // ============================================================
  // Lamport-style timestamp — chroni przed clock skew
  // ============================================================

  function nowMs() {
    const t = Date.now();
    return t > state.lastSeenServerNow ? t : (state.lastSeenServerNow + 1);
  }

  // Warianty czyste — operują na podanym stanie `s` (świeży, wewnątrz _commitState).
  function _nowMsFor(s) {
    const t = Date.now();
    return t > s.lastSeenServerNow ? t : (s.lastSeenServerNow + 1);
  }

  function _bumpServerNowFor(s, serverTs) {
    if (typeof serverTs === 'number' && serverTs > s.lastSeenServerNow) {
      s.lastSeenServerNow = serverTs;
    }
  }

  // ============================================================
  // Device UUID — losowo generowane przy pierwszym register/login
  // ============================================================

  function generateDeviceUuid() {
    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
      return crypto.randomUUID();
    }
    // fallback (older browsers)
    return 'd-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
  }

  async function ensureDeviceUuid(label) {
    await _commitState((s) => {
      if (!s.deviceUuid) s.deviceUuid = generateDeviceUuid();
      if (label && !s.deviceLabel) s.deviceLabel = label;
    });
    return state.deviceUuid;
  }

  // ============================================================
  // HTTP klient
  // ============================================================

  class ApiError extends Error {
    constructor(status, code, message, body) {
      super(message || code || ('HTTP ' + status));
      this.status = status;
      this.code = code;
      this.body = body;
    }
  }

  async function api(path, opts = {}) {
    const headers = { 'Content-Type': 'application/json; charset=utf-8', ...(opts.headers || {}) };
    if (state.sessionToken && !opts.skipAuth) {
      headers['Authorization'] = 'Bearer ' + state.sessionToken;
    }
    const url = state.apiBase + path;
    let r;
    try {
      r = await fetch(url, {
        method: opts.method || 'GET',
        headers,
        body: opts.body,
        credentials: 'omit',
      });
    } catch (e) {
      throw new ApiError(0, 'network_error', 'Brak połączenia: ' + e.message);
    }
    let body = null;
    if (r.status !== 204) {
      try { body = await r.json(); }
      catch (_) { body = null; }
    }
    if (!r.ok) {
      const code = (body && body.error) || ('http_' + r.status);
      const msg = (body && body.message) || ('HTTP ' + r.status);
      // 401 — wyczyść lokalny token, niech UI pokaże login
      if (r.status === 401 && (code === 'invalid_token' || code === 'unauthorized')) {
        await _commitState((s) => {
          s.sessionToken = null;
          s.expiresAt = null;
          s.user = null;
        });
      }
      throw new ApiError(r.status, code, msg, body);
    }
    return body;
  }

  // ============================================================
  // Auth
  // ============================================================

  async function register({ syncKey, deviceLabel, rememberKey }) {
    const canonical = canonicalizeSyncKey(syncKey);
    const deviceUuid = await ensureDeviceUuid(deviceLabel);
    const body = JSON.stringify({ syncKey: canonical, deviceUuid, deviceLabel: deviceLabel || state.deviceLabel || null });
    const data = await api('/api/auth/register', { method: 'POST', body, skipAuth: true });
    // Domyślnie zapisujemy klucz lokalnie — user może go podejrzeć by przekazać na 2gie
    // urządzenie. Wyłącz jawnie przez rememberKey:false jeśli ktoś chce trzymać tylko
    // w pamięci.
    await _commitState((s) => {
      s.sessionToken = data.sessionToken;
      s.expiresAt = data.expiresAt;
      s.user = data.user;
      if (deviceLabel) s.deviceLabel = deviceLabel;
      if (rememberKey !== false) s.syncKey = canonical;
    });
    return data;
  }

  async function login({ syncKey, deviceLabel, rememberKey }) {
    const canonical = canonicalizeSyncKey(syncKey);
    const deviceUuid = await ensureDeviceUuid(deviceLabel);
    const body = JSON.stringify({ syncKey: canonical, deviceUuid, deviceLabel: deviceLabel || state.deviceLabel || null });
    const data = await api('/api/auth/login', { method: 'POST', body, skipAuth: true });
    await _commitState((s) => {
      s.sessionToken = data.sessionToken;
      s.expiresAt = data.expiresAt;
      s.user = data.user;
      if (deviceLabel) s.deviceLabel = deviceLabel;
      if (rememberKey !== false) s.syncKey = canonical;
    });
    return data;
  }

  async function forgetSyncKey() {
    await _commitState((s) => { s.syncKey = null; });
  }

  // Wywoływane przez background po udanym tickach (push lub pull). UI używa do
  // wyświetlenia "ostatnia sync: X minut temu" w tooltipie.
  async function markSynced() {
    await _commitState((s) => { s.lastSyncedAt = Date.now(); });
  }

  async function logout() {
    if (!state.sessionToken) return;
    try { await api('/api/auth/logout', { method: 'POST' }); }
    catch (e) {
      // 401 też OK — token był już nieaktualny
      if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 0)) {
        // network errors swallow — i tak czyścimy lokalnie
        console.warn('[JZ_SYNC] logout API error (lokalnie wyczyszczone):', e);
      }
    }
    await _commitState((s) => {
      s.sessionToken = null;
      s.expiresAt = null;
      s.user = null;
    });
    _orphanActions = [];
    _failedEntities = [];
  }

  async function me() {
    return api('/api/auth/me');
  }

  async function recover(email) {
    const body = JSON.stringify({ email });
    return api('/api/auth/recover', { method: 'POST', body, skipAuth: true });
  }

  // ============================================================
  // Extension version (no-auth)
  // ============================================================

  // TTL cache w storage.local — backend ma `Cache-Control: public, max-age=300`,
  // ale my robimy dodatkową warstwę po stronie wtyczki żeby nie bić sieci przy
  // każdym otwarciu options. Wartość trzymamy oddzielnie od `state` (luźny cache,
  // niezwiązany z kontem usera).
  const VERSION_CACHE_KEY = 'jz_extVersionCache';
  const VERSION_CACHE_TTL_MS = 30 * 60 * 1000; // 30 min

  async function fetchExtensionVersion({ force = false } = {}) {
    let installedVersion = null;
    try { installedVersion = chrome.runtime.getManifest().version; } catch (_) {}
    if (!force) {
      try {
        const data = await chrome.storage.local.get([VERSION_CACHE_KEY]);
        const cached = data[VERSION_CACHE_KEY];
        // Cache hit tylko gdy: TTL nie wygasł I zainstalowana wersja się nie
        // zmieniła. Po update'cie wtyczki user może oczekiwać świeżego sprawdzenia
        // — stary cache (sprzed bumpa) byłby nieadekwatny i mógłby wprowadzać
        // w błąd (np. cache mówi v2.6 → brak update banner, choć backend ma już v2.7).
        if (
          cached &&
          cached.fetchedAt &&
          (Date.now() - cached.fetchedAt) < VERSION_CACHE_TTL_MS &&
          cached.installedVersion === installedVersion
        ) {
          return { ...cached.payload, fromCache: true };
        }
      } catch (_) {}
    }
    const payload = await api('/api/extension/version', { skipAuth: true });
    try {
      await chrome.storage.local.set({
        [VERSION_CACHE_KEY]: { payload, fetchedAt: Date.now(), installedVersion },
      });
    } catch (_) {}
    return { ...payload, fromCache: false };
  }

  // Feed dashboardu panelu edycji — ostatnie przykłady + wpisy blogowe.
  // Publiczny no-auth GET /api/extension/home-feed?lang=&limit=. Cache lokalny
  // per język (luźny, niezwiązany z kontem) — TTL 30 min, niezależny od HTTP
  // max-age=900 backendu. Błędy propagują do callera (dashboard ukrywa kafelki).
  const HOMEFEED_CACHE_KEY = 'jz_homeFeedCache';
  const HOMEFEED_CACHE_TTL_MS = 30 * 60 * 1000; // 30 min

  async function fetchHomeFeed({ lang = 'en', limit = 5, force = false } = {}) {
    const safeLang = /^[a-z]{2}$/.test(lang) ? lang : 'en';
    if (!force) {
      try {
        const data = await chrome.storage.local.get([HOMEFEED_CACHE_KEY]);
        const cached = (data[HOMEFEED_CACHE_KEY] || {})[safeLang];
        if (cached && cached.fetchedAt && (Date.now() - cached.fetchedAt) < HOMEFEED_CACHE_TTL_MS) {
          return { ...cached.payload, fromCache: true };
        }
      } catch (_) {}
    }
    const lim = Math.max(1, Math.min(10, parseInt(limit, 10) || 5));
    const payload = await api(
      `/api/extension/home-feed?lang=${encodeURIComponent(safeLang)}&limit=${lim}`,
      { skipAuth: true }
    );
    try {
      const data = await chrome.storage.local.get([HOMEFEED_CACHE_KEY]);
      const all = data[HOMEFEED_CACHE_KEY] || {};
      all[safeLang] = { payload, fetchedAt: Date.now() };
      await chrome.storage.local.set({ [HOMEFEED_CACHE_KEY]: all });
    } catch (_) {}
    return { ...payload, fromCache: false };
  }

  // Semver-aware porównanie "2.10" > "2.9". Zwraca:
  //   1  jeśli a > b
  //   0  jeśli a == b
  //  -1  jeśli a < b
  // Dla null/pustych zwraca 0 (nie wiadomo - traktujemy jako równe).
  function compareVersions(a, b) {
    if (!a || !b) return 0;
    const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
    const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
    const len = Math.max(pa.length, pb.length);
    for (let i = 0; i < len; i++) {
      const x = pa[i] || 0;
      const y = pb[i] || 0;
      if (x > y) return 1;
      if (x < y) return -1;
    }
    return 0;
  }

  // ============================================================
  // Account
  // ============================================================

  async function patchAccount({ displayName }) {
    const body = JSON.stringify({ displayName: displayName === undefined ? null : displayName });
    return api('/api/account', { method: 'PATCH', body });
  }

  async function deleteAccount() {
    await api('/api/account', { method: 'DELETE' });
    await _commitState((s) => {
      const def = defaultState();
      def.deviceUuid = null;
      for (const k of Object.keys(s)) delete s[k];
      Object.assign(s, def);
    });
    // Wyczyść lokalne liczniki użycia — przeżywają reset stanu sync, więc bez
    // tego nowo zarejestrowane konto dziedziczyłoby statystyki skasowanego.
    try {
      await chrome.storage.local.remove(['jzUsageStats', 'jzUsageStatsPushed', 'jzUsageStatsRemote']);
    } catch (_) {}
    _orphanActions = [];
    _failedEntities = [];
  }

  async function emailAdd(email) {
    return api('/api/account/email/add', { method: 'POST', body: JSON.stringify({ email }) });
  }

  async function emailRemove() {
    return api('/api/account/email', { method: 'DELETE' });
  }

  async function revokeSession(sessionId) {
    return api('/api/account/sessions/' + encodeURIComponent(sessionId), { method: 'DELETE' });
  }

  // ============================================================
  // Sync — pull / push / snapshot
  // ============================================================

  async function pullOnce({ since, limit } = {}) {
    const cursor = typeof since === 'number' ? since : state.lastPulledCursor;
    const lim = typeof limit === 'number' ? limit : PULL_BATCH_LIMIT;
    const data = await api(`/api/sync/pull?since=${cursor}&limit=${lim}`);
    // UWAGA: kursora NIE utrwalamy tutaj. Wolno go przesunąć dopiero PO udanym
    // applyEntities tej strony — inaczej wyjątek (np. storage quota) albo uśpienie
    // service workera między pullem a apply trwale gubi te encje (kursor już za
    // nimi). Caller woła commitPullCursor(data.cursor) po zaaplikowaniu strony.
    await _commitState((s) => { _bumpServerNowFor(s, data.now); });
    return data;
  }

  // Utrwala kursor po udanym zaaplikowaniu strony pulla. Wywoływane przez callera
  // applyEntities — patrz komentarz w pullOnce.
  async function commitPullCursor(cursor) {
    if (typeof cursor !== 'number') return;
    await _commitState((s) => {
      if (cursor >= s.lastPulledCursor) s.lastPulledCursor = cursor;
    });
  }

  /**
   * Iteracyjny pull dopóki hasMore=true. Zwraca tablicę wszystkich entities.
   * Wywołuje opcjonalny callback applyEntities po każdej stronie (żeby nie trzymać
   * wszystkiego w pamięci dla dużych pulli). Kursor utrwalany po każdej stronie —
   * tylko gdy podano callback (strona jest wtedy zaaplikowana przed przesunięciem).
   */
  async function pullAll({ applyEntities } = {}) {
    let all = [];
    let safety = 0;
    let cursor = state.lastPulledCursor;
    while (true) {
      if (++safety > 1000) throw new Error('pullAll: safety limit reached');
      const data = await pullOnce({ since: cursor });
      if (Array.isArray(data.entities) && data.entities.length) {
        if (typeof applyEntities === 'function') {
          await applyEntities(data.entities);
        } else {
          all = all.concat(data.entities);
        }
      }
      const nextCursor = data.cursor;
      if (typeof applyEntities === 'function') await commitPullCursor(nextCursor);
      if (!data.hasMore) break;
      // Guard: przy hasMore=true serwer MUSI posunąć kursor do przodu. Kursor
      // nie-rosnący zapętliłby pull tej samej strony aż do limitu bezpieczeństwa
      // (1000 zbędnych żądań). Przerywamy wcześniej i czysto.
      if (typeof nextCursor !== 'number' || nextCursor <= cursor) {
        console.warn('[JZ_SYNC] pullAll: kursor nie rośnie (' + cursor + ' → ' + nextCursor + ') mimo hasMore — przerywam');
        break;
      }
      cursor = nextCursor;
    }
    return all;
  }

  // Pełna rekonstrukcja stanu konta przez przyrostowy pull od since=0. Używane w
  // rozwiązywaniu konfliktu logowania: /api/sync/snapshot pomija bary i okna
  // (css/js pane, konsole), a przyrostowy pull niesie WSZYSTKIE 9 typów encji
  // oraz tombstone'y. Resetuje kursor do 0 i przepuszcza całość przez applyEntities.
  async function fullResyncFromServer() {
    await _commitState((s) => { s.lastPulledCursor = 0; });
    await pullAll({ applyEntities });
  }

  /**
   * Skanuje cały log zmian serwera (pull od since=0) i zwraca Set kluczy
   * `type:id` encji, które serwer ma jako tombstone (skasowane). NIE mutuje
   * state.lastPulledCursor — to narzędzie diagnostyczne dla rozwiązywania
   * konfliktu logowania: /api/sync/snapshot zwraca stan BEZ tombstone'ów,
   * więc samym snapshotem nie da się odróżnić encji lokalnej świeżo
   * utworzonej od skasowanej na innym urządzeniu. Bez tego "Scal" wypychał
   * z powrotem na konto foldery skasowane gdzie indziej (efekt „ducha").
   */
  async function fetchServerTombstones() {
    const out = new Set();
    let cursor = 0;
    let safety = 0;
    let maxNow = 0;
    while (true) {
      if (++safety > 1000) throw new Error('fetchServerTombstones: safety limit reached');
      const data = await api(`/api/sync/pull?since=${cursor}&limit=${PULL_BATCH_LIMIT}`);
      if (typeof data.now === 'number' && data.now > maxNow) maxNow = data.now;
      for (const e of (Array.isArray(data.entities) ? data.entities : [])) {
        if (e && e.payload === null && e.deletedAt) out.add(e.type + ':' + e.id);
      }
      const nextCursor = data.cursor;
      if (!data.hasMore) break;
      // Guard jak w pullAll — kursor nie-rosnący przy hasMore zapętliłby skan.
      if (typeof nextCursor !== 'number' || nextCursor <= cursor) {
        console.warn('[JZ_SYNC] fetchServerTombstones: kursor nie rośnie (' + cursor + ' → ' + nextCursor + ') mimo hasMore — przerywam');
        break;
      }
      cursor = nextCursor;
    }
    await _commitState((s) => { _bumpServerNowFor(s, maxNow); });
    return out;
  }

  /**
   * Skanuje cały log serwera (pull od since=0) i zwraca indeks kluczy
   * `type:id`: `live` — encje aktualnie żywe, `tombstoned` — skasowane.
   * Używane przez reconcile „wyślij brakujące" (options.js): encja lokalna
   * spoza OBU zbiorów to nigdy-niezsynchronizowany unikat — trzeba ją wypchnąć.
   * Encji żywych NIE re-pushujemy (enqueueChange stempluje świeży updatedAt →
   * nadpisałby nowszą wersję z innego urządzenia), otombstone'owanych też nie
   * (re-push by je wskrzesił).
   */
  async function fetchServerEntityIndex() {
    const live = new Set();
    const tombstoned = new Set();
    let cursor = 0;
    let safety = 0;
    let maxNow = 0;
    while (true) {
      if (++safety > 1000) throw new Error('fetchServerEntityIndex: safety limit reached');
      const data = await api(`/api/sync/pull?since=${cursor}&limit=${PULL_BATCH_LIMIT}`);
      if (typeof data.now === 'number' && data.now > maxNow) maxNow = data.now;
      for (const e of (Array.isArray(data.entities) ? data.entities : [])) {
        if (!e || !e.type || !e.id) continue;
        const key = e.type + ':' + e.id;
        if (e.payload === null && e.deletedAt) { tombstoned.add(key); live.delete(key); }
        else { live.add(key); tombstoned.delete(key); }
      }
      const nextCursor = data.cursor;
      if (!data.hasMore) break;
      if (typeof nextCursor !== 'number' || nextCursor <= cursor) {
        console.warn('[JZ_SYNC] fetchServerEntityIndex: kursor nie rośnie — przerywam');
        break;
      }
      cursor = nextCursor;
    }
    await _commitState((s) => { _bumpServerNowFor(s, maxNow); });
    return { live, tombstoned };
  }

  /**
   * Skanuje cały log serwera (pull od since=0) i zwraca liczności encji
   * OSIĄGALNYCH w drzewie — stan biblioteki w chmurze tak, jak widać go w
   * panelu edycji / na pulpicie. Dla każdego (type:id) decyduje ostatni wpis
   * (tombstone usuwa). Encje-sieroty (grupa bez folderu, zestaw bez grupy,
   * akcja/pasek/okno bez osiągalnego rodzica) NIE są liczone — inaczej panel
   * sync pokazywałby inną liczbę niż drzewo i pulpit. NIE mutuje
   * lastPulledCursor. Zwraca { folder, group, rule, action, bar, window }.
   */
  async function fetchServerCounts() {
    const ent = new Map(); // type:id → encja (najnowszy wpis; tombstone usuwa)
    let cursor = 0;
    let safety = 0;
    let maxNow = 0;
    while (true) {
      if (++safety > 1000) throw new Error('fetchServerCounts: safety limit reached');
      const data = await api(`/api/sync/pull?since=${cursor}&limit=${PULL_BATCH_LIMIT}`);
      if (typeof data.now === 'number' && data.now > maxNow) maxNow = data.now;
      for (const e of (Array.isArray(data.entities) ? data.entities : [])) {
        if (!e || !e.type || !e.id) continue;
        const key = e.type + ':' + e.id;
        if (e.payload === null && e.deletedAt) ent.delete(key);
        else ent.set(key, e);
      }
      const nextCursor = data.cursor;
      if (!data.hasMore) break;
      if (typeof nextCursor !== 'number' || nextCursor <= cursor) {
        console.warn('[JZ_SYNC] fetchServerCounts: kursor nie rośnie — przerywam');
        break;
      }
      cursor = nextCursor;
    }
    await _commitState((s) => { _bumpServerNowFor(s, maxNow); });
    // Pogrupuj po typie i policz osiągalność (folder → grupa → zestaw → akcja;
    // pasek/okno wg payload.parentType).
    const byType = {
      folder: [], group: [], rule: [], action: [], bar: [],
      css_pane: [], js_pane: [], js_console: [], output_console: [],
    };
    for (const e of ent.values()) {
      if (byType[e.type]) byType[e.type].push(e);
    }
    const folderIds = new Set(byType.folder.map(e => e.id));
    const reachGroupIds = new Set();
    for (const e of byType.group) {
      const fid = (e.payload && e.payload.folderId) || e.parentId;
      if (folderIds.has(fid)) reachGroupIds.add(e.id);
    }
    const reachRuleIds = new Set();
    for (const e of byType.rule) {
      const gid = (e.payload && e.payload.groupId) || e.parentId;
      if (reachGroupIds.has(gid)) reachRuleIds.add(e.id);
    }
    let actionCount = 0;
    for (const e of byType.action) {
      const rid = e.parentId || (e.payload && e.payload.ruleId);
      if (reachRuleIds.has(rid)) actionCount++;
    }
    const reachParent = (pType, pId) => {
      if (pType === 'folder') return folderIds.has(pId);
      if (pType === 'group')  return reachGroupIds.has(pId);
      if (pType === 'rule')   return reachRuleIds.has(pId);
      return false;
    };
    let barCount = 0;
    for (const e of byType.bar) {
      const p = e.payload || {};
      if (reachParent(p.parentType, e.parentId || p.parentId)) barCount++;
    }
    let windowCount = 0;
    for (const t of ['css_pane', 'js_pane', 'js_console', 'output_console']) {
      for (const e of byType[t]) {
        const p = e.payload || {};
        if (reachParent(p.parentType, e.parentId || p.parentId)) windowCount++;
      }
    }
    return {
      folder: folderIds.size,
      group: reachGroupIds.size,
      rule: reachRuleIds.size,
      action: actionCount,
      bar: barCount,
      window: windowCount,
    };
  }

  async function pushBatch(ops) {
    if (!Array.isArray(ops) || ops.length === 0) {
      return { accepted: 0, rejected: 0, results: [] };
    }
    if (ops.length > PUSH_BATCH_LIMIT) {
      throw new Error(`pushBatch: max ${PUSH_BATCH_LIMIT} ops, got ${ops.length}`);
    }
    const body = JSON.stringify({ deviceUuid: state.deviceUuid, ops });
    const data = await api('/api/sync/push', { method: 'POST', body });
    // NIE przesuwamy lastPulledCursor na data.newCursor. Skok kursora na HEAD
    // po własnym pushu pomijał encje wpisane przez inne urządzenie między
    // starym kursorem a newCursor (trwały rozjazd bibliotek). Kursor pulla
    // przesuwa wyłącznie commitPullCursor po udanym applyEntities — re-pull
    // własnych właśnie wypchniętych encji jest nieszkodliwy (applyEntities
    // idempotentne: upsert replace-by-id).
    await _commitState((s) => { _bumpServerNowFor(s, data.serverNow); });
    return data;
  }

  /**
   * Drenuje pendingDeletes + pendingChanges. Akceptowane operacje są usuwane z kolejki,
   * odrzucone zostają — ale `older_timestamp` automatycznie też usuwamy (sens nie ma
   * retry'ować z tym samym timestampem; klient powinien zrobić pull i ewentualnie
   * pushować nową edycję ze świeżym nowMs).
   */
  async function flushPending() {
    if (!state.sessionToken) {
      return { accepted: 0, rejected: 0, results: [], skipped: 'no_session' };
    }
    const ops = [];
    const opMeta = new Map(); // type:id → {name, deleted} — metadane dla historii sync
    // Najpierw usuwania, potem zmiany — żeby tombstones poszły wcześniej
    for (const t of state.pendingDeletes) {
      ops.push({ type: t.type, id: t.id, parentId: t.parentId || null,
                 updatedAt: t.deletedAt, deleted: true });
      opMeta.set(t.type + ':' + t.id, { name: t.name || null, deleted: true });
    }
    for (const c of state.pendingChanges) {
      ops.push({ type: c.type, id: c.id, parentId: c.parentId || null,
                 updatedAt: c.updatedAt, payload: c.payload, deleted: false });
      opMeta.set(c.type + ':' + c.id,
                 { name: (c.payload && (c.payload.name || c.payload.label)) || null, deleted: false });
    }
    if (ops.length === 0) {
      return { accepted: 0, rejected: 0, results: [] };
    }
    console.log('[JustZix sync] flushPending — pushing', {
      deletes: state.pendingDeletes.length,
      changes: state.pendingChanges.length,
      total: ops.length,
    });
    // Batch po PUSH_BATCH_LIMIT. Kolejkę czyścimy PRZYROSTOWO po każdym udanym
    // batchu — gdyby pushBatch rzucił w środku pętli (błąd sieci/serwera/429),
    // op-y wcześniej zaakceptowanych batchy są już usunięte z kolejki i zapisane,
    // więc nie zostaną pchnięte ponownie (re-push tombstone'a mógłby wskrzesić
    // encję, re-push zmiany — nadpisać świeższą edycję z innego urządzenia).
    const batches = [];
    for (let i = 0; i < ops.length; i += PUSH_BATCH_LIMIT) {
      batches.push(ops.slice(i, i + PUSH_BATCH_LIMIT));
    }
    const total = { accepted: 0, rejected: 0, results: [], pushedDetail: [] };
    for (const b of batches) {
      const data = await pushBatch(b);
      total.accepted += data.accepted;
      total.rejected += data.rejected;
      total.results = total.results.concat(data.results || []);
      // Detal dla historii synchronizacji — tylko op-y realnie zaakceptowane.
      for (const r of (data.results || [])) {
        if (r && r.accepted === true) {
          const m = opMeta.get(r.type + ':' + r.id);
          total.pushedDetail.push({
            op: (m && m.deleted) ? 'delete' : 'change',
            type: r.type,
            name: m ? m.name : null,
          });
        }
      }
      // Usuń op-y, których id+type pojawiły się w results tego batcha — accepted
      // lub rejected. Wszystkie reason'y odrzucenia (older_timestamp, invalid_*)
      // są trwałe; retry z tym samym op-em nic nie da. Backend zna stan.
      //
      // ALE: usuwamy tylko wpisy NIE NOWSZE niż wypchnięty op. Podczas `await
      // pushBatch` inny kontekst (options.js) mógł zrobić enqueueChange o tym
      // samym type:id ze świeższym payloadem — filtr po samym kluczu skasowałby
      // tę nową edycję bez wypchnięcia. `handledTs` trzyma updatedAt op-a, który
      // realnie poszedł; wpis z kolejki o nowszym timestampie zostaje na kolejny
      // tick. (op delete ma updatedAt = deletedAt — patrz budowa `ops`.)
      const handledKeys = new Set();
      for (const r of (data.results || [])) handledKeys.add(r.type + ':' + r.id);
      const handledTs = new Map();
      for (const op of b) {
        const k = op.type + ':' + op.id;
        if (!handledKeys.has(k)) continue;
        const prev = handledTs.get(k);
        if (prev === undefined || op.updatedAt > prev) handledTs.set(k, op.updatedAt);
      }
      await _commitState((s) => {
        s.pendingChanges = s.pendingChanges.filter(c => {
          const pu = handledTs.get(c.type + ':' + c.id);
          return pu === undefined || c.updatedAt > pu;
        });
        s.pendingDeletes = s.pendingDeletes.filter(d => {
          const pu = handledTs.get(d.type + ':' + d.id);
          return pu === undefined || d.deletedAt > pu;
        });
      });
    }
    return total;
  }

  async function snapshot() {
    const data = await api('/api/sync/snapshot');
    await _commitState((s) => {
      _bumpServerNowFor(s, data.now);
      s.lastPulledCursor = data.cursor;
    });
    return data;
  }

  // ============================================================
  // Pending queues — używane przez warstwę edytora
  // ============================================================

  /**
   * Dodaje (lub zastępuje) pending change. Ten sam type+id zostaje zdedup'owany —
   * trzymamy tylko najnowszą wersję, żeby push wysyłał aktualny payload.
   * Automatycznie usuwa odpowiadający pending delete (zmiana wygrywa).
   * Zapis idzie przez _commitState (RMW na świeżym stanie) — zwraca Promise
   * rozwiązujący się do updatedAt. Callerzy go nie awaitują (kolejka miękka).
   */
  function enqueueChange({ type, id, parentId, payload }) {
    if (!type || !id) return;
    return _commitState((s) => {
      const updatedAt = _nowMsFor(s);
      s.pendingChanges = s.pendingChanges.filter(c => !(c.type === type && c.id === id));
      s.pendingDeletes = s.pendingDeletes.filter(d => !(d.type === type && d.id === id));
      s.pendingChanges.push({ type, id, parentId: parentId || null, payload, updatedAt });
      return updatedAt;
    });
  }

  /**
   * Dodaje tombstone. Usuwa odpowiadający pending change.
   */
  function enqueueDelete({ type, id, parentId, name }) {
    if (!type || !id) return;
    return _commitState((s) => {
      const deletedAt = _nowMsFor(s);
      s.pendingChanges = s.pendingChanges.filter(c => !(c.type === type && c.id === id));
      s.pendingDeletes = s.pendingDeletes.filter(d => !(d.type === type && d.id === id));
      // `name` — tylko dla historii synchronizacji (tombstone nie niesie payloadu,
      // więc nazwę kasowanej encji zapamiętujemy w kolejce). NIE idzie do API.
      s.pendingDeletes.push({ type, id, parentId: parentId || null, name: name || null, deletedAt });
      console.log('[JustZix sync] enqueueDelete', { type, id, parentId, deletedAt });
      return deletedAt;
    });
  }

  function pendingCount() {
    return state.pendingChanges.length + state.pendingDeletes.length;
  }

  // ============================================================
  // applyEntities — bridge API entities → JustZix data model
  // ============================================================
  //
  // Mutuje chrome.storage.local.{folders, groups, rules} na podstawie listy entities
  // z /api/sync/pull. Zwraca podsumowanie {applied, skipped, deletes}.
  //
  // Reguły:
  // - entities z id+type w pendingChanges/pendingDeletes są POMINIĘTE (lokalna zmiana
  //   poczeka na push; przy konflikcie LWW backend ustali zwycięzcę).
  // - tombstone (payload=null, deletedAt>0) usuwa encję LOKALNIE wraz z dzieci:
  //     folder → kasujemy też wszystkie groups z folderId i ich rules.
  //     group  → kasujemy też wszystkie rules z groupId.
  //     rule   → kasujemy.
  //     action → usuwamy z rule.actions[].
  // - upsert (payload obiekt): zastępujemy istniejącą encję po id, lub dodajemy.
  //   Dla `action` — wstawiamy do parent rule.actions[] (parentId = ruleId).
  // - emituje 'apply-rules' do wszystkich kart przez content scripts po sukcesie.

  // Bufor akcji osieroconych — encja 'action', której parent rule jeszcze nie
  // istniał. Przeżywa kolejne wywołania applyEntities w obrębie jednego pullAll
  // (paginacja może rozdzielić rule i jego akcje na różne strony pulla).
  let _orphanActions = [];
  const ORPHAN_ACTIONS_CAP = 500; // cap bufora — analogicznie do FAILED_ENTITIES_CAP

  // Bufor encji, których zastosowanie rzuciło wyjątkiem (błąd przejściowy —
  // np. nietypowy kształt, chwilowy problem). Bez tego encja była liczona jako
  // `skipped`, kursor szedł dalej i ginęła bez retry. Tutaj wraca na początek
  // następnego applyEntities; applyEntities jest idempotentne (upsert
  // replace-by-id, tombstone'y), więc ponowienie jest bezpieczne. Cap chroni
  // przed encją trwale wadliwą (ponawianą w nieskończoność).
  let _failedEntities = [];
  const FAILED_ENTITIES_CAP = 200;

  // Serializacja applyEntities — to read-modify-write na chrome.storage.local
  // (load → mutacje → set). Dwa nakładające się wywołania (np. strony pulla)
  // nadpisywałyby sobie zapis i korumpowały współdzielony bufor _orphanActions.
  let _applyChain = Promise.resolve();
  function applyEntities(entities) {
    const run = () => _applyEntitiesImpl(entities);
    _applyChain = _applyChain.then(run, run);
    return _applyChain;
  }

  async function _applyEntitiesImpl(entities) {
    const incoming = Array.isArray(entities) ? entities : [];
    // Encje, które padły w poprzednim wywołaniu, ponawiamy na początku tego.
    const allEntities = _failedEntities.length ? _failedEntities.concat(incoming) : incoming;
    _failedEntities = [];
    if (allEntities.length === 0) {
      return { applied: 0, skipped: 0, deletes: 0, failed: 0, changes: [] };
    }
    // Load. Od v12 (Faza 1 "Okna na froncie"): + cssPanes, jsPanes, jsConsoles, outputConsoles.
    const data = await chrome.storage.local.get([
      'folders', 'groups', 'rules', 'actionBars',
      'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles',
      'aiHelper', 'theme', 'userLang', 'jzUsageStatsRemote',
    ]);
    // AI Helper — klucze API synchronizowane jako encja 'ai_keys' (singleton, od v2.13.133).
    let aiHelper = (data.aiHelper && typeof data.aiHelper === 'object') ? data.aiHelper : {};
    let aiHelperChanged = false;
    // Settings (motyw + język) — encja singleton 'settings' (od v2.13.160).
    let settingsTheme = data.theme;
    let settingsLang = data.userLang;
    let settingsChanged = false;
    // usage_stats — per-urządzenie liczniki użycia, mapa deviceUuid → payload (od v2.13.161).
    let usageRemote = (data.jzUsageStatsRemote && typeof data.jzUsageStatsRemote === 'object') ? data.jzUsageStatsRemote : {};
    let usageRemoteChanged = false;
    const folders = Array.isArray(data.folders) ? [...data.folders] : [];
    const groups = Array.isArray(data.groups) ? [...data.groups] : [];
    const rules = Array.isArray(data.rules) ? [...data.rules] : [];
    const actionBars = Array.isArray(data.actionBars) ? [...data.actionBars] : [];
    const cssPanes = Array.isArray(data.cssPanes) ? [...data.cssPanes] : [];
    const jsPanes = Array.isArray(data.jsPanes) ? [...data.jsPanes] : [];
    const jsConsoles = Array.isArray(data.jsConsoles) ? [...data.jsConsoles] : [];
    const outputConsoles = Array.isArray(data.outputConsoles) ? [...data.outputConsoles] : [];

    // Snapshot pending keys do szybkiego sprawdzenia
    const pendingKeys = new Set();
    for (const c of state.pendingChanges) pendingKeys.add(c.type + ':' + c.id);
    for (const d of state.pendingDeletes) pendingKeys.add(d.type + ':' + d.id);

    // Helper — Faza 1 "Okna na froncie" (v12): 4 nowe typy okien (css_pane, js_pane,
    // js_console, output_console). Każdy ma identyczną semantykę sync — flat collection
    // bez children/cascade. Tombstone = splice po id; upsert = replace lub push z parentId.
    const _paneCollectionFor = (type) => {
      if (type === 'css_pane')       return cssPanes;
      if (type === 'js_pane')        return jsPanes;
      if (type === 'js_console')     return jsConsoles;
      if (type === 'output_console') return outputConsoles;
      return null;
    };

    let applied = 0, skipped = 0, deletes = 0, failed = 0;
    const newOrphans = [];
    const newFailed = [];

    // Rejestr zmian dla historii synchronizacji (zakładka Status panelu sync).
    // _preIds / _preName — stan SPRZED zastosowania, by odróżnić add od edit i
    // poznać nazwę kasowanej encji (tombstone nie niesie payloadu).
    const _chg = [];
    const _HISTORY_TYPES = new Set(['folder', 'group', 'rule', 'action', 'bar',
      'css_pane', 'js_pane', 'js_console', 'output_console']);
    const _preIds = new Set();
    const _preName = new Map();
    const _regPre = (type, id, name) => {
      const k = type + ':' + id;
      _preIds.add(k);
      _preName.set(k, name || null);
    };
    for (const f of folders) _regPre('folder', f.id, f.name);
    for (const g of groups)  _regPre('group', g.id, g.name);
    for (const r of rules) {
      _regPre('rule', r.id, r.name);
      for (const a of (Array.isArray(r.actions) ? r.actions : [])) _regPre('action', a.id, a.label);
    }
    for (const b of actionBars) _regPre('bar', b.id, b.name);
    [['css_pane', cssPanes], ['js_pane', jsPanes], ['js_console', jsConsoles],
     ['output_console', outputConsoles]].forEach(([t, coll]) => {
      for (const p of coll) _regPre(t, p.id, p.name);
    });

    for (const e of allEntities) {
     // Odporność per-encja — pojedyncza wadliwa encja nie może przerwać całej
     // strony pulla. Wyjątek nie gubi encji bezpowrotnie: ląduje w
     // _failedEntities i jest ponawiana przy następnym applyEntities.
     try {
      const key = e.type + ':' + e.id;
      if (pendingKeys.has(key)) { skipped++; continue; }

      // Rejestr zmiany — przed właściwym zastosowaniem (klasyfikacja add/edit/delete).
      if (_HISTORY_TYPES.has(e.type)) {
        const _isT = e.payload === null && e.deletedAt;
        let _nm = (!_isT && e.payload) ? (e.payload.name || e.payload.label || null) : null;
        if (!_nm) _nm = _preName.get(key) || null;
        _chg.push({ op: _isT ? 'delete' : (_preIds.has(key) ? 'edit' : 'add'), type: e.type, name: _nm });
      }

      const isTombstone = e.payload === null && e.deletedAt;
      if (isTombstone) {
        deletes++;
        if (e.type === 'folder') {
          const groupsToRemove = new Set(groups.filter(g => g.folderId === e.id).map(g => g.id));
          const rulesToRemove = new Set(rules.filter(r => groupsToRemove.has(r.groupId)).map(r => r.id));
          // Set parent-id'ów które stają się "deleted scope" — używane do cascade panes (Faza 1).
          const deletedParentIds = new Set([e.id, ...groupsToRemove, ...rulesToRemove]);
          // usuń rules tych grup
          for (let i = rules.length - 1; i >= 0; i--) {
            if (rulesToRemove.has(rules[i].id)) rules.splice(i, 1);
          }
          // usuń same grupy
          for (let i = groups.length - 1; i >= 0; i--) {
            if (groups[i].folderId === e.id) groups.splice(i, 1);
          }
          // usuń folder
          for (let i = folders.length - 1; i >= 0; i--) {
            if (folders[i].id === e.id) folders.splice(i, 1);
          }
          // cascade actionBars: bary folderu + bary jego grup + bary jego rules
          for (let i = actionBars.length - 1; i >= 0; i--) {
            const b = actionBars[i];
            if (b.parentType === 'folder' && b.parentId === e.id) actionBars.splice(i, 1);
            else if (b.parentType === 'group' && groupsToRemove.has(b.parentId)) actionBars.splice(i, 1);
            else if (b.parentType === 'rule' && rulesToRemove.has(b.parentId)) actionBars.splice(i, 1);
          }
          // cascade panes (Faza 1) — okna z parentId w deletedParentIds.
          [cssPanes, jsPanes, jsConsoles, outputConsoles].forEach(coll => {
            for (let i = coll.length - 1; i >= 0; i--) {
              if (deletedParentIds.has(coll[i].parentId)) coll.splice(i, 1);
            }
          });
          // Zbuforowane akcje osieroconych usuwanych zestawów — inaczej wisiałyby w
          // _orphanActions do capa i mogłyby się re-attachować przy reużyciu id.
          _orphanActions = _orphanActions.filter(o => !rulesToRemove.has(o.parentId || (o.payload && o.payload.ruleId)));
        } else if (e.type === 'group') {
          const rulesToRemove = new Set(rules.filter(r => r.groupId === e.id).map(r => r.id));
          const deletedParentIds = new Set([e.id, ...rulesToRemove]);
          for (let i = rules.length - 1; i >= 0; i--) {
            if (rulesToRemove.has(rules[i].id)) rules.splice(i, 1);
          }
          for (let i = groups.length - 1; i >= 0; i--) {
            if (groups[i].id === e.id) groups.splice(i, 1);
          }
          // cascade actionBars: bary grupy + bary rules tej grupy
          for (let i = actionBars.length - 1; i >= 0; i--) {
            const b = actionBars[i];
            if (b.parentType === 'group' && b.parentId === e.id) actionBars.splice(i, 1);
            else if (b.parentType === 'rule' && rulesToRemove.has(b.parentId)) actionBars.splice(i, 1);
          }
          // cascade panes
          [cssPanes, jsPanes, jsConsoles, outputConsoles].forEach(coll => {
            for (let i = coll.length - 1; i >= 0; i--) {
              if (deletedParentIds.has(coll[i].parentId)) coll.splice(i, 1);
            }
          });
          _orphanActions = _orphanActions.filter(o => !rulesToRemove.has(o.parentId || (o.payload && o.payload.ruleId)));
        } else if (e.type === 'rule') {
          for (let i = rules.length - 1; i >= 0; i--) {
            if (rules[i].id === e.id) rules.splice(i, 1);
          }
          // cascade actionBars: bary tego rule
          for (let i = actionBars.length - 1; i >= 0; i--) {
            if (actionBars[i].parentType === 'rule' && actionBars[i].parentId === e.id) actionBars.splice(i, 1);
          }
          // cascade panes: panes należące do tego rule
          [cssPanes, jsPanes, jsConsoles, outputConsoles].forEach(coll => {
            for (let i = coll.length - 1; i >= 0; i--) {
              if (coll[i].parentId === e.id) coll.splice(i, 1);
            }
          });
          // Zbuforowane akcje tego zestawu — usuń, by nie czekały w _orphanActions.
          _orphanActions = _orphanActions.filter(o => (o.parentId || (o.payload && o.payload.ruleId)) !== e.id);
        } else if (e.type === 'action') {
          const ruleId = e.parentId;
          let rule = ruleId ? rules.find(r => r.id === ruleId) : null;
          // Fallback: tombstone'y akcji bywają bez parentId (enqueueDelete
          // w _diffAndEnqueue go nie ustawiał). Bez reguły nie da się usunąć
          // akcji z rule.actions[] — szukamy reguły zawierającej tę akcję po id.
          // Bez tego skasowane akcje zostawały „duchami" w rule.actions[] na
          // każdym urządzeniu poza tym, które je skasowało.
          if (!rule) {
            rule = rules.find(r => Array.isArray(r.actions) && r.actions.some(a => a.id === e.id));
          }
          if (rule && Array.isArray(rule.actions)) {
            rule.actions = rule.actions.filter(a => a.id !== e.id);
          }
          // Usuń też z bufora osieroconych — inaczej tombstone akcji nie anulowałby
          // zbuforowanego upsertu i akcja wróciłaby, gdy pojawi się jej rule.
          _orphanActions = _orphanActions.filter(o => o.id !== e.id);
        } else if (e.type === 'bar') {
          for (let i = actionBars.length - 1; i >= 0; i--) {
            if (actionBars[i].id === e.id) actionBars.splice(i, 1);
          }
          // czyszczenie orphan barId w actions
          for (const r of rules) {
            if (Array.isArray(r.actions)) {
              for (const a of r.actions) if (a.barId === e.id) delete a.barId;
            }
          }
        } else if (e.type === 'css_pane' || e.type === 'js_pane' || e.type === 'js_console' || e.type === 'output_console') {
          // Faza 1 "Okna na froncie" — flat splice po id, brak cascade.
          const coll = _paneCollectionFor(e.type);
          if (coll) {
            for (let i = coll.length - 1; i >= 0; i--) {
              if (coll[i].id === e.id) coll.splice(i, 1);
            }
          }
        } else if (e.type === 'usage_stats') {
          // Tombstone licznika użycia — backend wycofuje encję martwego
          // urządzenia (GC po nieaktywności; patrz backend/api-handoff/
          // usage-stats-device-gc.md). Usuń ducha z mapy zdalnej, by pulpit
          // przestał go sumować. id encji = deviceUuid.
          if (Object.prototype.hasOwnProperty.call(usageRemote, e.id)) {
            delete usageRemote[e.id];
            usageRemoteChanged = true;
          }
        }
        continue;
      }

      // Upsert
      const payload = e.payload || {};
      if (e.type === 'folder') {
        const idx = folders.findIndex(f => f.id === e.id);
        const merged = { ...payload, id: e.id };
        if (idx >= 0) folders[idx] = merged; else folders.push(merged);
        applied++;
      } else if (e.type === 'group') {
        const merged = { ...payload, id: e.id, folderId: e.parentId || payload.folderId };
        const idx = groups.findIndex(g => g.id === e.id);
        if (idx >= 0) groups[idx] = merged; else groups.push(merged);
        applied++;
      } else if (e.type === 'rule') {
        // Zachowaj lokalne `actions` jeśli incoming nie ma — actions chodzą jako
        // osobne encje, ale niektóre exporty zachowują nested.
        const idx = rules.findIndex(r => r.id === e.id);
        const existingActions = (idx >= 0 && Array.isArray(rules[idx].actions)) ? rules[idx].actions : [];
        const merged = {
          ...payload,
          id: e.id,
          groupId: e.parentId || payload.groupId,
          actions: Array.isArray(payload.actions) ? payload.actions : existingActions,
        };
        if (idx >= 0) rules[idx] = merged; else rules.push(merged);
        applied++;
      } else if (e.type === 'action') {
        const ruleId = e.parentId || payload.ruleId;
        const rule = ruleId ? rules.find(r => r.id === ruleId) : null;
        if (!rule) { newOrphans.push(e); continue; }
        if (!Array.isArray(rule.actions)) rule.actions = [];
        const aIdx = rule.actions.findIndex(a => a.id === e.id);
        const aMerged = { ...payload, id: e.id };
        if (aIdx >= 0) rule.actions[aIdx] = aMerged; else rule.actions.push(aMerged);
        applied++;
      } else if (e.type === 'bar') {
        // ActionBar: parentType + parentId są w payload. Walidacja parenta po stronie sanityzacji
        // w load() — tutaj akceptujemy.
        const merged = { ...payload, id: e.id, parentId: e.parentId || payload.parentId };
        const idx = actionBars.findIndex(b => b.id === e.id);
        if (idx >= 0) actionBars[idx] = merged; else actionBars.push(merged);
        applied++;
      } else if (e.type === 'css_pane' || e.type === 'js_pane' || e.type === 'js_console' || e.type === 'output_console') {
        // Faza 1 "Okna na froncie" — upsert do odpowiedniej kolekcji. parentId żyje w payload
        // (analog do ActionBar). parentType nie ma — typ parenta wynika z prefixu id (f_/g_/r_).
        const coll = _paneCollectionFor(e.type);
        if (coll) {
          const merged = { ...payload, id: e.id, parentId: e.parentId || payload.parentId };
          const idx = coll.findIndex(p => p.id === e.id);
          if (idx >= 0) coll[idx] = merged; else coll.push(merged);
          applied++;
        } else {
          skipped++;
        }
      } else if (e.type === 'ai_keys') {
        // AI Helper — klucze API. payload.keys = providerzy zaznaczeni do synchronizacji.
        // Merge do chrome.storage.local.aiHelper; providerzy spoza payloadu, ale wcześniej
        // synchronizowani, dostają syncKeys=false (klucz lokalny zostaje, przestaje syncować).
        const incoming = (payload && payload.keys && typeof payload.keys === 'object') ? payload.keys : {};
        // models — domyślny model per provider (od v2.13.144); jedzie razem z kluczem.
        const incomingModels = (payload && payload.models && typeof payload.models === 'object') ? payload.models : {};
        if (!aiHelper.keys || typeof aiHelper.keys !== 'object') aiHelper.keys = {};
        if (!aiHelper.syncKeys || typeof aiHelper.syncKeys !== 'object') aiHelper.syncKeys = {};
        if (!aiHelper.defaultModel || typeof aiHelper.defaultModel !== 'object') aiHelper.defaultModel = {};
        ['openai', 'anthropic', 'gemini'].forEach(p => {
          if (incoming[p]) {
            aiHelper.keys[p] = incoming[p];
            aiHelper.syncKeys[p] = true;
            if (incomingModels[p] !== undefined) aiHelper.defaultModel[p] = incomingModels[p];
          } else if (aiHelper.syncKeys[p]) {
            aiHelper.syncKeys[p] = false;
          }
        });
        aiHelperChanged = true;
        applied++;
      } else if (e.type === 'settings') {
        // Singleton: motyw + język interfejsu (od v2.13.160). Payload nieprzezroczysty
        // — bierzemy znane pola. UI odświeży się przez storage.onChanged (theme/userLang).
        if (payload && typeof payload === 'object') {
          if (typeof payload.theme === 'string') settingsTheme = payload.theme;
          if (typeof payload.userLang === 'string') settingsLang = payload.userLang;
          settingsChanged = true;
        }
        applied++;
      } else if (e.type === 'usage_stats') {
        // Liczniki użycia per-urządzenie (id encji = deviceUuid). Pulpit sumuje
        // wszystkie urządzenia. WŁASNEGO urządzenia tu NIE trzymamy: lokalne
        // jzUsageStats jest świeższe niż ostatnio wypchnięta encja, a dwa źródła
        // (jzUsageStatsRemote[ownUuid] + jzUsageStats) zawyżały licznik, gdy
        // pulpit nie znał jeszcze własnego deviceUuid (podwójne liczenie). Stary
        // wpis własnego uuid — zostawiony przez wersje sprzed tej naprawy —
        // też tu czyścimy, więc mapa zdalna gwarantowanie nie zawiera nas.
        if (e.id === state.deviceUuid) {
          if (Object.prototype.hasOwnProperty.call(usageRemote, e.id)) {
            delete usageRemote[e.id];
            usageRemoteChanged = true;
          }
        } else if (payload && typeof payload === 'object') {
          usageRemote[e.id] = payload;
          usageRemoteChanged = true;
        }
        applied++;
      } else {
        skipped++;
      }
     } catch (entErr) {
       console.warn('[JustZix sync] applyEntities — encja do ponowienia',
         e && e.type, e && e.id, entErr);
       failed++;
       newFailed.push(e);
     }
    }
    // Cap — encja trwale wadliwa nie może rosnąć w buforze bez końca.
    _failedEntities = newFailed.length > FAILED_ENTITIES_CAP
      ? newFailed.slice(-FAILED_ENTITIES_CAP) : newFailed;

    // Ponów akcje osierocone — po pętli wszystkie rule z tej strony są już upsertowane,
    // a _orphanActions niesie też nierozwiązane z poprzednich stron pulla. Co nadal
    // nie ma parenta, zostaje w buforze na kolejną stronę.
    const retryActions = _orphanActions.concat(newOrphans);
    _orphanActions = [];
    for (const oe of retryActions) {
      const op = oe.payload || {};
      const orid = oe.parentId || op.ruleId;
      const orule = orid ? rules.find(r => r.id === orid) : null;
      if (!orule) { _orphanActions.push(oe); continue; }
      if (!Array.isArray(orule.actions)) orule.actions = [];
      const oaIdx = orule.actions.findIndex(a => a.id === oe.id);
      const oaMerged = { ...op, id: oe.id };
      if (oaIdx >= 0) orule.actions[oaIdx] = oaMerged; else orule.actions.push(oaMerged);
      applied++;
    }
    // Cap bufora — akcja po trwale usuniętym zestawie zostałaby tu w nieskończoność
    // (nie da się odróżnić „rule jeszcze nie przyszedł" od „rule skasowany").
    if (_orphanActions.length > ORPHAN_ACTIONS_CAP) _orphanActions = _orphanActions.slice(-ORPHAN_ACTIONS_CAP);

    // Save. _jzPullTag (od v2.13.37) — marker dla options.js storage.onChanged listenera, że
    // ten write pochodzi z applyEntities (sync pull), nie z user-action ani z content.js. Bez
    // tego options.js po pull'u nie wiedziałby że ma odświeżyć `_syncSnapshot`, przez co
    // następne user-deletion entity przyniesionej przez pull nie byłoby enqueueDelete'owane
    // (snapshot stale → diff `for (id in before)` nie obejmuje świeżo-pulled entities → tombstone
    // nigdy nie idzie na backend → po kolejnym pull entity wraca → wrażenie "kasowanie nie działa").
    const pullTag = Date.now() + '_' + Math.random().toString(36).slice(2, 8);
    const toSet = {
      folders, groups, rules, actionBars,
      cssPanes, jsPanes, jsConsoles, outputConsoles,
      _jzPullTag: pullTag,
    };
    if (aiHelperChanged) toSet.aiHelper = aiHelper;
    if (settingsChanged) {
      if (typeof settingsTheme === 'string') toSet.theme = settingsTheme;
      if (typeof settingsLang === 'string') toSet.userLang = settingsLang;
    }
    if (usageRemoteChanged) toSet.jzUsageStatsRemote = usageRemote;
    await chrome.storage.local.set(toSet);

    // Powiadom karty (apply-rules) — content scripts dynamicznie reagują na zmiany.
    try {
      const tabs = await chrome.tabs.query({});
      tabs.forEach(t => {
        chrome.tabs.sendMessage(t.id, { type: 'apply-rules' }).catch(() => {});
      });
    } catch (_) { /* OK — może być wywołane bez tabs perm w niektórych kontekstach */ }

    return { applied, skipped, deletes, failed, changes: _chg.slice(0, 120) };
  }

  // ============================================================
  // Backupy
  // ============================================================

  async function createBackup({ kind, label, schemaVersion, payload }) {
    const body = JSON.stringify({
      kind: kind || 'manual',
      label: label || null,
      schemaVersion: schemaVersion || 3,
      deviceUuid: state.deviceUuid,
      payload,
    });
    return api('/api/backups', { method: 'POST', body });
  }

  async function listBackups() {
    return api('/api/backups');
  }

  async function getBackup(id) {
    return api('/api/backups/' + encodeURIComponent(id));
  }

  async function restoreBackup(id) {
    const body = JSON.stringify({ deviceUuid: state.deviceUuid });
    const data = await api('/api/backups/' + encodeURIComponent(id) + '/restore', { method: 'POST', body });
    // Po restore lokalnie wszystko jest do podmiany — wyczyść kolejki, niech caller zaaplikuje payload.
    await _commitState((s) => {
      _bumpServerNowFor(s, data.serverNow);
      if (typeof data.newCursor === 'number') s.lastPulledCursor = data.newCursor;
      s.pendingChanges = [];
      s.pendingDeletes = [];
    });
    _orphanActions = [];
    _failedEntities = [];
    return data;
  }

  async function deleteBackup(id) {
    return api('/api/backups/' + encodeURIComponent(id), { method: 'DELETE' });
  }

  // ============================================================
  // Shares (czasowe linki publiczne)
  // ============================================================
  // POST /api/shares — auth — wystawia token JZS-XXXX-XXXX-XXXX-XXXX
  // GET  /api/shares — auth — lista moich aktywnych share
  // DELETE /api/shares/{id} — auth — odwołaj
  // GET  /api/shares/{token}/preview — no auth — meta przed importem
  // GET  /api/shares/{token} — no auth — pełen payload (bumpuje download_count)

  async function createShare({ exportType, ttlSec, payload }) {
    const body = JSON.stringify({ exportType, ttlSec, payload });
    return api('/api/shares', { method: 'POST', body });
  }

  async function listShares() {
    return api('/api/shares');
  }

  async function revokeShare(id) {
    return api('/api/shares/' + encodeURIComponent(id), { method: 'DELETE' });
  }

  async function getSharePreview(token) {
    return api('/api/shares/' + encodeURIComponent(token) + '/preview', { skipAuth: true });
  }

  async function getShare(token) {
    return api('/api/shares/' + encodeURIComponent(token), { skipAuth: true });
  }

  // Crockford base32 normalization + format walidacja. Akceptuje URL
  // (https://.../s/JZS-...), pełen token, raw 16 znaków. Zwraca canonical
  // 'JZS-XXXX-XXXX-XXXX-XXXX' lub rzuca Error z komunikatem dla usera.
  function canonicalizeShareToken(input) {
    let s = String(input || '').toUpperCase().trim();
    const urlMatch = s.match(/\/S\/(JZS[-A-Z0-9]+)/i);
    if (urlMatch) s = urlMatch[1].toUpperCase();
    if (s.startsWith('JZS-')) s = s.slice(4);
    s = s.replace(/[\s\-_]+/g, '');
    s = s.replace(/[IL]/g, '1').replace(/O/g, '0').replace(/U/g, 'V');
    if (s.length !== 16 || !/^[0-9A-HJKMNPQRSTVWXYZ]+$/.test(s)) {
      throw new Error('Token JustZix musi mieć format JZS-XXXX-XXXX-XXXX-XXXX');
    }
    return 'JZS-' + s.match(/.{4}/g).join('-');
  }

  // ============================================================
  // Public API
  // ============================================================

  const JZ_SYNC = {
    // init
    init: async function () {
      if (initialized) return state;
      await loadState();
      initialized = true;
      return state;
    },
    // reload: wymusza ponowny odczyt z chrome.storage.local — używane po
    // chrome.storage.onChanged (np. background tick zaktualizował sessionToken)
    // żeby UI widziało świeży state.
    reload: async function () {
      await loadState();
      initialized = true;
      return state;
    },
    getState: () => ({ ...state }), // shallow copy — readonly
    setApiBase: async (url) => { await patchState({ apiBase: url || DEFAULT_API_BASE }); },

    // utils
    generateSyncKey,
    canonicalizeSyncKey,
    isValidSyncKey,
    nowMs,

    // auth
    register,
    login,
    logout,
    me,
    recover,
    forgetSyncKey,
    markSynced,
    isAuthenticated: () => !!state.sessionToken,
    hasStoredSyncKey: () => !!state.syncKey,
    getStoredSyncKey: () => state.syncKey,
    getLastSyncedAt: () => state.lastSyncedAt || 0,

    // account
    patchAccount,
    deleteAccount,
    emailAdd,
    emailRemove,
    revokeSession,

    // sync
    pullOnce,
    commitPullCursor,
    pullAll,
    fullResyncFromServer,
    fetchServerTombstones,
    fetchServerEntityIndex,
    fetchServerCounts,
    pushBatch,
    flushPending,
    snapshot,
    enqueueChange,
    enqueueDelete,
    pendingCount,
    applyEntities,

    // backups
    createBackup,
    listBackups,
    getBackup,
    restoreBackup,
    deleteBackup,

    // shares
    createShare,
    listShares,
    revokeShare,
    getSharePreview,
    getShare,
    canonicalizeShareToken,

    // extension version (no-auth)
    fetchExtensionVersion,
    compareVersions,

    // dashboard home-feed (no-auth)
    fetchHomeFeed,

    // wewnętrzne (export do testów)
    _ApiError: ApiError,
    _STORAGE_KEY: STORAGE_KEY,
  };

  g.JZ_SYNC = JZ_SYNC;
})(typeof window !== 'undefined' ? window : self);
