// ds-sync.jsx — двусторонний sync user-data между localStorage и worker API.
//
// Архитектура:
//   - Существующий код (LibraryBlock, FavoritesBlock, рейтинги, плейлисты)
//     остаётся НЕТРОНУТЫМ. Он по-прежнему пишет/читает localStorage и
//     диспатчит свои custom events.
//   - Этот модуль слушает те же events и debounced PUSHит данные на worker.
//     При логине делает PULL + merge, чтобы данные с других устройств
//     попали в локальный кэш.
//   - При неудаче (нет сети, не залогинен) — silent fail. localStorage
//     остаётся primary store.
//
// Merge-стратегия (на login):
//   1. Pull серверные данные.
//   2. Для каждой коллекции: union(local, server). При коллизии local wins
//      (пользователь сейчас здесь активно — его данные свежие).
//   3. Записать merged в localStorage.
//   4. Push merged → server (чтобы серверные данные тоже обновились).
//
// После login: только push, без периодического pull (cross-device sync
// в v1 не нужен, рарити-кейс).

(function () {
  const { useEffect, useRef } = React;

  // ── Конфигурация ────────────────────────────────────────────────────────
  const DEBOUNCE_MS = 1200;
  const SYNC_LOG = false; // включить для отладки

  function log(...args) { if (SYNC_LOG) console.log('[ds-sync]', ...args); }

  // Все таймеры по ключу — чтобы быстрые подряд изменения батчились.
  const pendingTimers = new Map();
  function debounce(key, fn) {
    if (pendingTimers.has(key)) clearTimeout(pendingTimers.get(key));
    pendingTimers.set(key, setTimeout(() => {
      pendingTimers.delete(key);
      fn();
    }, DEBOUNCE_MS));
  }

  // ── Defensive sync flags ───────────────────────────────────────────────
  // Кого pull уже завершился — для них пуши разрешены. Пока не завершился —
  // ВСЕ change-события игнорируем. Это закрывает race-condition на login:
  // юзер клацает что-то за 200мс до окончания pull → push с не-полной
  // локалкой → перетёр сервер. Не. Сначала pull, потом разрешаем push.
  const pullCompleted = new Set();
  function pushAllowed(email) {
    if (!pullCompleted.has(email)) {
      // Это НЕ ошибка — это защита. pullAndMerge сам диспатчит change-ивенты
      // (для обновления UI после pull), которые триггерят push-handler'ы.
      // Эти push'ы блокируются гардом, потому что внутри pullAndMerge уже
      // вызываются явные pushLibrary/etc. после merge. Логируем только в debug.
      log('push skipped (pull not done yet) for', email);
      return false;
    }
    return true;
  }

  // Защита от стирания сервера пустым массивом:
  // Если локально 0 элементов И на сервере >0 — НЕ пушим, а наоборот
  // восстанавливаем локально с сервера. Это покрывает кейс: юзер открыл
  // в новом браузере / приватном окне / у локалки потерлась, и фронт
  // случайно бы пушнул [].
  async function refusePushIfEmpty(localCount, serverFetchFn, restoreFn, label) {
    if (localCount > 0) return false; // безопасно — есть что пушить
    try {
      const remote = await serverFetchFn();
      const serverCount = remote.count;
      if (serverCount > 0) {
        console.warn(`[ds-sync] ABORTED empty push of ${label}: server has ${serverCount} items. Restoring locally instead.`);
        if (restoreFn) restoreFn(remote.data);
        return true; // ABORT push
      }
    } catch (e) {
      // Сеть упала — лучше НЕ пушить пустоту (рискованно), чем рискнуть стереть.
      console.warn(`[ds-sync] ${label}: empty local + server unreachable → skipping push (safety).`);
      return true;
    }
    return false; // и локально пусто, и серверно пусто — пушим спокойно
  }

  // ── Шорткаты ────────────────────────────────────────────────────────────
  function api() { return window.__dsApi; }
  function lsGet(key, fallback) {
    try {
      const raw = localStorage.getItem(key);
      if (raw == null) return fallback;
      return JSON.parse(raw);
    } catch { return fallback; }
  }
  function lsSet(key, value) {
    try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
  }

  // ── Сканер всех ds_ratings_*_<email> и ds_actor_ratings_*_<email> ──────
  // Возвращает: { drama: {tid: obj}, actor: {tid: obj} }
  function scanLocalRatings(email) {
    const out = { drama: {}, actor: {} };
    const suffix = '_' + email;
    for (let i = 0; i < localStorage.length; i++) {
      const k = localStorage.key(i);
      if (!k || !k.endsWith(suffix)) continue;
      let kind, prefix;
      if (k.startsWith('ds_ratings_')) { kind = 'drama'; prefix = 'ds_ratings_'; }
      else if (k.startsWith('ds_actor_ratings_')) { kind = 'actor'; prefix = 'ds_actor_ratings_'; }
      else continue;
      const tid = k.slice(prefix.length, k.length - suffix.length);
      const obj = lsGet(k, null);
      if (obj && typeof obj === 'object') out[kind][tid] = obj;
    }
    return out;
  }

  function ratingsKey(kind, tid, email) {
    const prefix = kind === 'drama' ? 'ds_ratings_' : 'ds_actor_ratings_';
    return `${prefix}${tid}_${email}`;
  }

  // ── PUSH функции ────────────────────────────────────────────────────────
  async function pushLibrary(email) {
    if (!email) return;
    const items = lsGet(`ds_library_${email}`, []);
    if (!Array.isArray(items)) return;
    // ── Defensive: empty push guard ────────────────────────────────────────
    const aborted = await refusePushIfEmpty(items.length,
      async () => {
        const r = await api().fetch('/api/library');
        return { count: (r.items || []).length, data: r.items || [] };
      },
      (serverItems) => {
        const restored = serverItems.map(r => ({
          id: r.drama_id, drama_id: r.drama_id, status: r.status,
          episode: r.episode, notes: r.notes, rating: r.rating,
        }));
        lsSet(`ds_library_${email}`, restored);
        window.dispatchEvent(new CustomEvent('ds-library-changed'));
      },
      'library'
    );
    if (aborted) return;
    // localStorage хранит как [{id, status, ...}], сервер ждёт {items: [{drama_id, status, ...}]}
    const payload = {
      items: items
        .filter(x => x && (x.id != null || x.drama_id != null))
        .map(x => ({
          drama_id: x.drama_id != null ? x.drama_id : x.id,
          status: x.status,
          episode: x.episode != null ? x.episode : null,
          notes: x.notes || null,
          rating: x.rating != null ? x.rating : null,
        })),
    };
    try {
      await api().fetch('/api/library', { method: 'PUT', body: JSON.stringify(payload) });
      log('pushed library', payload.items.length);
    } catch (e) { log('pushLibrary fail', e.message); }
  }

  // ВАЖНО: в localStorage любимые хранятся как массив ОБЪЕКТОВ
  // (`{id, title_ru, posterPath, slug, ...}` для дорам / `{id, name, photoUrl, ...}`
  // для актёров), но сервер хранит только массив ID. Поэтому при push'е
  // извлекаем .id из каждого объекта.
  function extractFavIds(arr) {
    if (!Array.isArray(arr)) return [];
    return arr
      .map(x => {
        if (typeof x === 'number') return x;
        if (typeof x === 'string') return parseInt(x, 10);
        if (x && typeof x === 'object' && x.id != null) return parseInt(x.id, 10);
        return NaN;
      })
      .filter(Number.isFinite);
  }

  async function pushFavorites(kind, email) {
    if (!email) return;
    const key = kind === 'dramas' ? `ds_fav_dramas_${email}` : `ds_fav_people_${email}`;
    const arr = lsGet(key, []);
    const ids = extractFavIds(arr);
    // ── Defensive: empty push guard ────────────────────────────────────────
    const aborted = await refusePushIfEmpty(ids.length,
      async () => {
        const r = await api().fetch(`/api/favorites/${kind}`);
        return { count: (r.ids || []).length, data: r.ids || [] };
      },
      (serverIds) => {
        // Восстанавливаем stub-объекты (минимум — id), полные данные подтянутся
        // когда юзер откроет каталог.
        const restored = serverIds.map(id => ({ id: parseInt(id, 10), addedAt: new Date().toISOString() }));
        lsSet(key, restored);
        window.dispatchEvent(new CustomEvent(kind === 'dramas' ? 'ds-fav-dramas-changed' : 'ds-fav-people-changed'));
      },
      `favorites/${kind}`
    );
    if (aborted) return;
    try {
      await api().fetch(`/api/favorites/${kind}`, {
        method: 'PUT',
        body: JSON.stringify({ ids }),
      });
      log('pushed favorites', kind, ids.length);
    } catch (e) { log('pushFavorites fail', kind, e.message); }
  }

  async function pushPlaylists(email) {
    if (!email) return;
    const lists = lsGet(`ds_user_lists_${email}`, []);
    if (!Array.isArray(lists)) return;
    // Серверного "PUT all" нет — но мы можем поочерёдно upsert каждый,
    // а потом удалить серверные id, которых нет локально.
    try {
      // Получаем серверный snapshot, считаем diff.
      const remote = await api().fetch('/api/playlists');
      const remoteItems = remote.items || [];
      // ── Defensive: empty push guard ────────────────────────────────────
      // Если локально 0 плейлистов И на сервере есть — НЕ затираем сервер,
      // а восстанавливаем локально. Иначе пользователь, у которого «удалились»
      // плейлисты в одной сессии, потеряет их везде.
      if (lists.length === 0 && remoteItems.length > 0) {
        console.warn(`[ds-sync] ABORTED empty push of playlists: server has ${remoteItems.length} items. Restoring locally instead.`);
        const restored = remoteItems.map(p => ({
          id: p.id, name: p.name,
          dramaIds: Array.isArray(p.drama_ids) ? p.drama_ids : [],
          createdAt: p.created_at,
        }));
        lsSet(`ds_user_lists_${email}`, restored);
        window.dispatchEvent(new CustomEvent('ds-userlists-changed'));
        return;
      }
      const remoteIds = new Set(remoteItems.map(p => p.id));
      const localIds = new Set(lists.map(p => p.id).filter(Boolean));
      // Удалить серверные, которых нет локально
      for (const id of remoteIds) {
        if (!localIds.has(id)) {
          try { await api().fetch(`/api/playlists/${encodeURIComponent(id)}`, { method: 'DELETE' }); } catch {}
        }
      }
      // Upsert каждый локальный
      for (const p of lists) {
        if (!p || !p.id || !p.name) continue;
        try {
          await api().fetch('/api/playlists', {
            method: 'POST',
            body: JSON.stringify({
              id: p.id, name: p.name,
              drama_ids: Array.isArray(p.dramaIds) ? p.dramaIds : (Array.isArray(p.drama_ids) ? p.drama_ids : []),
            }),
          });
        } catch {}
      }
      log('pushed playlists', lists.length);
    } catch (e) { log('pushPlaylists fail', e.message); }
  }

  async function pushRating(kind, targetId, email) {
    if (!email || !targetId) return;
    const obj = lsGet(ratingsKey(kind, targetId, email), {});
    try {
      await api().fetch(`/api/ratings/${kind}/${targetId}`, {
        method: 'PUT',
        body: JSON.stringify(obj || {}),
      });
      log('pushed rating', kind, targetId);
    } catch (e) { log('pushRating fail', e.message); }
  }

  // ── PULL + MERGE при логине ────────────────────────────────────────────
  // local wins на коллизиях. Возвращает Promise.
  async function pullAndMerge(email) {
    if (!email) return;
    log('pull&merge start for', email);

    // 1) LIBRARY
    try {
      const remote = await api().fetch('/api/library');
      const remoteItems = (remote.items || []).map(r => ({
        id: r.drama_id, drama_id: r.drama_id, status: r.status,
        episode: r.episode, notes: r.notes, rating: r.rating,
      }));
      const local = lsGet(`ds_library_${email}`, []);
      const byId = new Map();
      for (const it of remoteItems) byId.set(String(it.drama_id), it);
      // Local wins
      for (const it of (Array.isArray(local) ? local : [])) {
        if (it && (it.id != null || it.drama_id != null)) {
          const id = it.drama_id != null ? it.drama_id : it.id;
          byId.set(String(id), it);
        }
      }
      const merged = Array.from(byId.values());
      lsSet(`ds_library_${email}`, merged);
      window.dispatchEvent(new CustomEvent('ds-library-changed'));
      // Push merged → server
      await pushLibrary(email);
    } catch (e) { log('pull library fail', e.message); }

    // 2) FAVORITES (dramas + people)
    // На pull: сервер возвращает только массив id, а localStorage хранит
    // объекты с title/poster и т.п. Чтобы UI продолжал работать после pull —
    // восстанавливаем объекты из window.__dsAllDramas / __dsAllPeople (если
    // подгружены) либо создаём stub-объект (id + базовые поля), а полная
    // карточка подтянется в следующий заход на каталог.
    for (const kind of ['dramas', 'people']) {
      try {
        const remote = await api().fetch(`/api/favorites/${kind}`);
        const localKey = kind === 'dramas' ? `ds_fav_dramas_${email}` : `ds_fav_people_${email}`;
        const local = lsGet(localKey, []);
        const localArr = Array.isArray(local) ? local : [];

        // Map существующих локальных объектов по id для быстрого lookup
        const byId = new Map();
        for (const it of localArr) {
          const id = it && typeof it === 'object' ? it.id : it;
          if (id != null) byId.set(String(id), it);
        }
        // Для каждого id с сервера: если в local его нет — добавить объект
        // (восстановленный из каталога или stub).
        const catalog = kind === 'dramas' ? window.__dsAllDramas : window.__dsAllPeople;
        const cMap = (Array.isArray(catalog) && catalog.length)
          ? new Map(catalog.map(x => [String(x.id), x]))
          : null;
        for (const rid of (remote.ids || [])) {
          const sid = String(rid);
          if (byId.has(sid)) continue;
          // Восстанавливаем по каталогу
          if (cMap && cMap.has(sid)) {
            const d = cMap.get(sid);
            if (kind === 'dramas') {
              byId.set(sid, {
                id: d.id, title_en: d.title_en, title_ru: d.title_ru,
                posterPath: d.slug ? d.slug + '.webp' : null,
                slug: d.slug, firstAirDate: d.firstAirDate,
                addedAt: new Date().toISOString(),
              });
            } else {
              byId.set(sid, {
                id: d.id, name: d.name_ru || d.name,
                photoUrl: d.slug ? `./assets/profiles/${d.slug}.webp` : null,
                addedAt: new Date().toISOString(),
              });
            }
          } else {
            // Stub — UI покажет id, заполнится при следующем заходе на карточку
            byId.set(sid, { id: parseInt(sid, 10), addedAt: new Date().toISOString() });
          }
        }
        const merged = Array.from(byId.values());
        lsSet(localKey, merged);
        window.dispatchEvent(new CustomEvent(kind === 'dramas' ? 'ds-fav-dramas-changed' : 'ds-fav-people-changed'));
        await pushFavorites(kind, email);
      } catch (e) { log('pull favorites fail', kind, e.message); }
    }

    // 3) PLAYLISTS
    try {
      const remote = await api().fetch('/api/playlists');
      const remoteItems = (remote.items || []).map(p => ({
        id: p.id, name: p.name,
        dramaIds: Array.isArray(p.drama_ids) ? p.drama_ids : [],
        createdAt: p.created_at,
      }));
      const local = lsGet(`ds_user_lists_${email}`, []);
      const byId = new Map();
      for (const p of remoteItems) byId.set(String(p.id), p);
      for (const p of (Array.isArray(local) ? local : [])) {
        if (p && p.id) byId.set(String(p.id), p);
      }
      const merged = Array.from(byId.values());
      lsSet(`ds_user_lists_${email}`, merged);
      window.dispatchEvent(new CustomEvent('ds-userlists-changed'));
      await pushPlaylists(email);
    } catch (e) { log('pull playlists fail', e.message); }

    // 4) RATINGS (drama + actor)
    try {
      const remote = await api().fetch('/api/ratings');
      // remote: { drama: {tid: {dim:val}}, actor: {...} }
      const local = scanLocalRatings(email);
      const tidsToPush = { drama: new Set(), actor: new Set() };
      for (const kind of ['drama', 'actor']) {
        for (const tid of Object.keys(remote[kind] || {})) {
          const remoteObj = remote[kind][tid] || {};
          const localObj = local[kind][tid] || {};
          // additive merge: local wins на коллизиях, серверное добавляется если у local нет
          const merged = { ...remoteObj, ...localObj };
          lsSet(ratingsKey(kind, tid, email), merged);
          tidsToPush[kind].add(tid);
        }
        // Местные tids, которых нет на сервере — добавить в push-список
        for (const tid of Object.keys(local[kind])) tidsToPush[kind].add(tid);
      }
      // Уведомить UI
      window.dispatchEvent(new CustomEvent('ds-ratings-changed'));
      window.dispatchEvent(new CustomEvent('ds-actor-ratings-changed'));
      // Push merged → server (по одному PUT на каждый dramaId/actorId)
      for (const kind of ['drama', 'actor']) {
        for (const tid of tidsToPush[kind]) {
          try {
            const obj = lsGet(ratingsKey(kind, tid, email), {});
            await api().fetch(`/api/ratings/${kind}/${tid}`, {
              method: 'PUT', body: JSON.stringify(obj),
            });
          } catch {}
        }
      }
      log('pulled+pushed ratings, drama:', tidsToPush.drama.size, 'actor:', tidsToPush.actor.size);
    } catch (e) { log('pull ratings fail', e.message); }

    // Помечаем pull как завершённый — теперь push-ивенты разрешены
    pullCompleted.add(email);
    log('pull&merge done; push enabled for', email);
  }

  // ── Event subscribers ──────────────────────────────────────────────────
  // Каждый возвращает функцию-cleanup.
  function subscribeAll(email) {
    const handlers = [];
    const add = (event, fn) => {
      const wrapped = (e) => fn(e);
      window.addEventListener(event, wrapped);
      handlers.push(() => window.removeEventListener(event, wrapped));
    };
    // Все push-events игнорируются пока pullAndMerge не завершился — это
    // защита от race на login (юзер кликнул что-то за 200мс до окончания pull).
    add('ds-library-changed', () => { if (pushAllowed(email)) debounce('library', () => pushLibrary(email)); });
    add('ds-fav-dramas-changed', () => { if (pushAllowed(email)) debounce('fav-dramas', () => pushFavorites('dramas', email)); });
    add('ds-fav-people-changed', () => { if (pushAllowed(email)) debounce('fav-people', () => pushFavorites('people', email)); });
    add('ds-userlists-changed', () => { if (pushAllowed(email)) debounce('playlists', () => pushPlaylists(email)); });
    add('ds-ratings-changed', (e) => {
      const tid = e?.detail?.dramaId;
      if (!tid) return; // без id — не знаем, что пушить (на pull обновили всё)
      if (!pushAllowed(email)) return;
      debounce(`rating-drama-${tid}`, () => pushRating('drama', tid, email));
    });
    add('ds-actor-ratings-changed', (e) => {
      const tid = e?.detail?.actorId;
      if (!tid) return;
      if (!pushAllowed(email)) return;
      debounce(`rating-actor-${tid}`, () => pushRating('actor', tid, email));
    });
    return () => handlers.forEach(h => h());
  }

  // ── React-компонент (рендерится в App), активирует sync по user ──────
  function DSDataSync() {
    const { user } = window.useAuth ? window.useAuth() : { user: null };
    const cleanupRef = useRef(null);

    useEffect(() => {
      // Остановить предыдущий sync (если был — другой юзер)
      if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; }
      if (!user || !user.email) return;
      const email = user.email;
      // Сбрасываем флаг pullCompleted — для нового user-сессии нужен свежий pull
      // прежде чем разрешить push (защита от race / устаревшего флага).
      pullCompleted.delete(email);
      // 1) Подписаться на события (push'ы будут ждать pullAllowed === true)
      cleanupRef.current = subscribeAll(email);
      // 2) Pull + merge (асинхронно, не блокируем UI). По окончании
      //    pullAndMerge() добавит email в pullCompleted → push'ы разрешены.
      pullAndMerge(email).catch(() => {});
      return () => {
        if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; }
        pullCompleted.delete(email);
      };
    }, [user && user.email]);

    return null;
  }

  // Export
  window.DSDataSync = DSDataSync;
  window.__dsSync = { pullAndMerge, pushLibrary, pushFavorites, pushRating, pushPlaylists };
})();
