// DramaScope — Chinese drama portal library
const { useState, useEffect, useRef, useCallback } = React;

// ── INTERNAL DATA MODEL (JSDoc) ───────────────────────────────────────────────
// Internal domain types used by DramaSourceAdapter implementations.
// Not consumed at runtime; serve as a contract for build-script output (data/*.json)
// and as documentation for adapter authors. Field names are stable across adapters.

/**
 * @typedef {Object} Drama
 * @property {number} id                  Internal stable ID (currently equals TMDB id; abstraction leak we accept until WikidataAdapter lands).
 * @property {string} slug                URL-safe идентификатор. Источник генерации в порядке приоритета: title_en → title_pinyin → translit title_ru. Из title_original (китайские иероглифы) НЕ генерируется.
 * @property {string} title_original      Оригинал (中文 / 한글 / 日本語).
 * @property {string|null} title_pinyin   Романизация (для CN/JP/KR).
 * @property {string|null} title_en       Англоязычное прокатное.
 * @property {string|null} title_ru       Русскоязычное прокатное / перевод.
 * @property {string[]} alt_titles        Альтернативные написания/варианты (включая фан-переводы, региональные релизы).
 * @property {string|null} overview_en    Из TMDB или Wikipedia EN.
 * @property {string|null} overview_ru    Авторский русский текст. По умолчанию null; заполняется вручную или build-скриптом из Wikipedia RU когда она есть.
 * @property {string|null} posterPath     Relative path; resolved via imageUrl(slug, size).
 * @property {string} firstAirDate        YYYY-MM-DD.
 * @property {number} voteAverage         0–10.
 * @property {number} voteCount
 * @property {string[]} originCountry     ISO codes: CN, HK, TW.
 * @property {Array<{id:number,name:string}>} genres
 * @property {string} status              'Returning Series' | 'Ended' | 'In Production' | 'Pilot' | 'Cancelled' | …
 * @property {number} episodeCount
 * @property {Array<{id:number,name:string}>} networks
 * @property {number} popularity
 * @property {number[]} keywordIds        References into data/keywords.json.
 * @property {Array<{personId:number,characterName:string,order:number}>} cast
 * @property {{tmdb:number,wikidata:string|null,douban:string|null,imdb:string|null}} externalIds   tmdb обязателен; остальные null если неизвестно.
 * @property {'tmdb'|'wikidata'|'manual'} _source                  Каким адаптером запись была собрана.
 * @property {string} _syncedAt                                    ISO timestamp последней синхронизации.
 */

/**
 * @typedef {Object} Person
 * @property {number} id
 * @property {string} slug
 * @property {string} name
 * @property {string} originalName
 * @property {string} biography
 * @property {string|null} profilePath
 * @property {string|null} birthday
 * @property {string|null} placeOfBirth
 * @property {number} popularity
 * @property {string[]} alsoKnownAs
 * @property {Array<{id:number,name:string,title?:string}>} knownFor
 * @property {{tmdb:number,wikidata:string|null}} externalIds
 * @property {'tmdb'|'wikidata'|'manual'} _source
 * @property {string} _syncedAt
 */

/**
 * @typedef {Object} Keyword
 * @property {number} id
 * @property {string} slug
 * @property {string} name
 * @property {number} dramaCount         Сколько дорам в текущем snapshot'е используют этот keyword.
 */

/**
 * @typedef {Object} SearchResult
 * @property {Array<Drama|Person|Keyword>} results
 * @property {number} page
 * @property {number} totalPages
 * @property {number} totalResults
 */

/**
 * @typedef {Object} HomeFeeds
 * @property {Drama[]} trending
 * @property {Drama[]} popular
 * @property {Drama[]} topRated
 * @property {Drama[]} airing
 */

/**
 * @typedef {Object} DiscoverFilters
 * @property {number[]} [genres]
 * @property {string[]} [originCountry]   ISO codes: CN, HK, TW
 * @property {number[]} [networks]
 * @property {string}   [status]
 * @property {number}   [yearFrom]
 * @property {number}   [yearTo]
 * @property {number}   [minRating]       0–10
 * @property {number}   [voteCountGte]    Minimum vote_count threshold (TMDB vote_count.gte). Перекрывает неявные 20 от minRating.
 * @property {string}   [sort]            e.g. 'popularity.desc', 'vote_average.desc'
 * @property {number[]} [castIds]
 * @property {number[]} [keywordIds]
 * @property {number}   [page]
 */

/**
 * DramaSourceAdapter — abstract data-source contract.
 *
 * Реализации:
 *   - TMDBAdapter      (tools/adapters/tmdb-adapter.js, Node-only, build-time)
 *   - JSONAdapter      (этот файл, браузер, шаг 4 миграции)
 *   - WikidataAdapter  (будущее)
 *
 * JS не предписывает наследование от @typedef — это контракт, который
 * реализации обязаны соблюдать по форме методов и возвращаемых типов.
 *
 * @typedef {Object} DramaSourceAdapter
 * @property {() => Promise<HomeFeeds>}                                                    fetchHomeFeeds
 * @property {(id:number) => Promise<Drama|null>}                                          fetchDrama
 * @property {(query:string, page?:number) => Promise<SearchResult>}                       searchDramas
 * @property {(filters:DiscoverFilters) => Promise<SearchResult>}                          discover
 * @property {(id:number) => Promise<Person|null>}                                         fetchPerson
 * @property {(id:number) => Promise<Drama[]>}                                             fetchPersonCredits
 * @property {() => Promise<Person[]>}                                                     fetchPopularActors
 * @property {(query:string) => Promise<Person[]>}                                         searchPerson
 * @property {(query:string) => Promise<Keyword[]>}                                        searchKeyword
 * @property {(posterPath:string|null, size:'w185'|'w500') => string|null}                 imageUrl
 */

// ── JSON ADAPTER ──────────────────────────────────────────────────────────────
// Browser-time реализация DramaSourceAdapter. Читает /data/*.json,
// держит каталог в памяти. Eager-loading с кэшированным Promise при первом
// async-вызове. После загрузки данных все обращения мгновенны (Map по id).
//
// Используется в production. TMDBAdapter в браузер не попадает (он Node-only
// в tools/, для build-script).
//
// JSONAdapter-specific helpers (НЕ в контракте DramaSourceAdapter):
//   - getAllDramas()           — для AI Picks (нужен весь список заголовков)
//   - getPerson(id) / getKeyword(id) — sync-getter'ы после _ensureLoaded
//   - profileImageUrl(path)    — отдельная URL-схема для фото актёров

class JSONAdapter {
  constructor({
    // Абсолютные пути (с /) — иначе на deep-link /dramas/X резолвится как /dramas/data/...
    // и Pages-роутер возвращает index.html → JSON.parse падает на <!DOCTYPE html>
    dataBaseUrl = '/data/',
    posterBaseUrl = '/assets/posters/',
    profileBaseUrl = '/assets/profiles/'
  } = {}) {
    this.dataBaseUrl = dataBaseUrl;
    this.posterBaseUrl = posterBaseUrl;
    this.profileBaseUrl = profileBaseUrl;
    this._loadPromise = null;
    this._dramas = [];
    this._dramasById = new Map();
    this._people = [];
    this._peopleById = new Map();
    this._keywords = [];
    this._keywordsById = new Map();
    this._index = null;
  }

  // ─── private ─────────────────────────────────────────────────────

  async _ensureLoaded() {
    if (this._loadPromise) return this._loadPromise;
    this._loadPromise = (async () => {
      try {
        const fetchJson = async (name) => {
          // cache-bust обязательно: иначе после edit-сейва из попапа браузер
          // показывает кэш и кажется что «не сохранилось» (хотя файл уже обновлён).
          const r = await fetch(this.dataBaseUrl + name + '?t=' + Date.now(), { cache: 'no-store' });
          if (!r.ok) throw new Error(`${name}: HTTP ${r.status}`);
          return r.json();
        };

        // Dramas + Actors — теперь из D1 через API. CF edge-cache на 5 мин.
        // NO cache-bust — пусть кэш работает.
        const apiBase = (window.__dsApi && window.__dsApi.base) || '';
        const fetchFromApi = async (path, jsonFallback) => {
          if (!apiBase) return fetchJson(jsonFallback);
          try {
            const r = await fetch(apiBase + path);
            if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`);
            return await r.json();
          } catch (e) {
            console.warn(`[adapter] ${path} failed, fallback to ${jsonFallback}`, e);
            return fetchJson(jsonFallback);
          }
        };

        // ── ПРОГРЕССИВНАЯ ЗАГРУЗКА ──
        // Сначала грузим только TOP-80 дорам + TOP-100 актёров по popularity —
        // этого достаточно для всей главной (Trending/Popular/TopRated/Airing
        // карусели берут по 20 элементов из топа). Это ~150КБ вместо ~7МБ.
        // Главная рендерится за 1-2 сек вместо 5-10.
        //
        // Полный каталог догружается в фоне через 200мс — к моменту когда юзер
        // нажмёт «Поиск» или «Каталог», данные уже есть. Если ещё нет — компонент
        // сам подождёт через слушатель 'ds-full-data-loaded'.
        const [dramas, people, keywords, index] = await Promise.all([
          fetchFromApi('/api/dramas?limit=80', 'dramas.json'),
          fetchFromApi('/api/actors?limit=100', 'people.json'),
          fetchJson('keywords.json'),
          fetchJson('index.json')
        ]);

        // ── фоновая догрузка ПОЛНОГО каталога ──
        const self = this;
        const loadFullInBackground = () => {
          // Параллельно тащим полные списки, обновляем мапы, шлём событие
          Promise.all([
            fetchFromApi('/api/dramas', 'dramas.json').catch(() => null),
            fetchFromApi('/api/actors', 'people.json').catch(() => null),
          ]).then(([fullDramas, fullPeople]) => {
            if (Array.isArray(fullDramas) && fullDramas.length > self._dramas.length) {
              // Перепишем posterPath так же как для первой загрузки
              for (const d of fullDramas) {
                if (d.slug && d.posterPath && !d.posterPath.startsWith('http')) {
                  d.posterPath = d.slug + '.webp';
                }
              }
              // Сохраняем _full флаг для тех, что уже были догружены отдельно через fetchDrama
              const oldById = self._dramasById;
              self._dramas = fullDramas;
              self._dramasById = new Map();
              for (const d of fullDramas) {
                const prev = oldById.get(d.id);
                if (prev && prev._full) self._dramasById.set(d.id, prev);
                else self._dramasById.set(d.id, d);
              }
            }
            if (Array.isArray(fullPeople) && fullPeople.length > self._people.length) {
              for (const p of fullPeople) {
                if (p.slug && p.profilePath && !p.profilePath.startsWith('http')) {
                  p.profilePath = p.slug + '.webp';
                }
              }
              self._people = fullPeople;
              self._peopleById = new Map();
              for (const p of fullPeople) self._peopleById.set(p.id, p);
            }
            // Уведомляем что полные данные доступны. Флаг — для компонентов,
            // которым нужно отличить «фильмография пуста» от «ещё грузится»
            // (например, страница актёра).
            self._fullDataLoaded = true;
            try { window.dispatchEvent(new CustomEvent('ds-full-data-loaded')); } catch {}
          }).catch(() => { /* silent fail — топ-80 уже есть, страница работает */ });
        };
        setTimeout(loadFullInBackground, 200);

        // Перепишем posterPath/profilePath в локальный идентификатор {slug}.webp.
        // ИСКЛЮЧЕНИЕ: если posterPath уже полный URL (http*) — оставляем как есть
        // (это значит постер загружен через админку в R2).
        for (const d of dramas) {
          if (d.slug && d.posterPath && !d.posterPath.startsWith('http')) {
            d.posterPath = d.slug + '.webp';
          }
        }
        for (const p of people) {
          if (p.slug && p.profilePath && !p.profilePath.startsWith('http')) {
            p.profilePath = p.slug + '.webp';
          }
        }

        this._dramas = dramas;
        this._people = people;
        this._keywords = keywords;
        this._index = index;
        for (const d of dramas) this._dramasById.set(d.id, d);
        for (const p of people) this._peopleById.set(p.id, p);
        for (const k of keywords) this._keywordsById.set(k.id, k);
      } catch (e) {
        // Сбросим promise, чтобы следующий вызов мог попытаться снова.
        this._loadPromise = null;
        throw new Error(`Couldn't load site data. Please reload the page. (${e.message})`);
      }
    })();
    return this._loadPromise;
  }

  // Нормализация для substring-поиска: NFD + strip combining diacriticals + lowercase.
  // Для CJK-символов нормализация — no-op (точное совпадение).
  _normalize(s) {
    return (s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
  }

  _paginate(arr, page = 1) {
    const PAGE_SIZE = 30;
    const totalResults = arr.length;
    const totalPages = Math.max(1, Math.ceil(totalResults / PAGE_SIZE));
    const p = Math.max(1, Math.min(page, totalPages));
    return {
      results: arr.slice((p - 1) * PAGE_SIZE, p * PAGE_SIZE),
      page: p,
      totalPages,
      totalResults
    };
  }

  // ─── sync helpers (вызывать только после _ensureLoaded) ──────────

  // Глобальный фильтр по privacy-настройкам юзера (скрыть Boys/Girls).
  // Применяется ко всем спискам что отдаёт адаптер. window.__dsUserPrivacy
  // выставляется ProfilePage после GET /api/me/profile.
  _applyUserPrefs(list) {
    if (typeof window === 'undefined' || !window.__dsUserPrivacy) return list;
    const pp = window.__dsUserPrivacy;
    if (!pp.hide_boys_genre && !pp.hide_girls_genre) return list;
    return list.filter(d => {
      const names = new Set((d.genres || []).map(g => g && g.name && String(g.name).toLowerCase()));
      if (pp.hide_boys_genre && names.has('boys')) return false;
      if (pp.hide_girls_genre && names.has('girls')) return false;
      return true;
    });
  }

  getAllDramas() { return this._applyUserPrefs(this._dramas); }
  getPerson(id) { return this._peopleById.get(id) || null; }
  getKeyword(id) { return this._keywordsById.get(id) || null; }

  // Обновить запись в кеше (вызывается из AdminDramaEditor после save,
  // чтобы public-поиск/каталог сразу находил по новому title без перезагрузки).
  updateDramaInCache(patch) {
    if (!patch || patch.id == null) return;
    const idx = this._dramas.findIndex(d => d.id === patch.id);
    if (idx >= 0) {
      this._dramas[idx] = { ...this._dramas[idx], ...patch };
      this._dramasById.set(patch.id, this._dramas[idx]);
    }
    // Обновляем и __dsSearchPool — это отдельный кеш для typeahead navbar-поиска
    // (грузится из data/dramas.json напрямую, в адаптере его не было).
    if (typeof window !== 'undefined' && window.__dsSearchPool && Array.isArray(window.__dsSearchPool.dramas)) {
      const arr = window.__dsSearchPool.dramas;
      const i = arr.findIndex(d => d && d.id === patch.id);
      if (i >= 0) arr[i] = { ...arr[i], ...patch };
    }
    if (typeof window !== 'undefined') {
      window.dispatchEvent(new CustomEvent('ds-drama-updated', { detail: { id: patch.id } }));
    }
  }

  // ─── SIMILAR DRAMAS — правила Marina (май 2026) ─────────────────────
  // 1. Если у текущей дорамы есть один из EXCLUSIVE-жанров/тегов
  //    (Boys, Girls, Bromance, History, Crime/Detective, Costume, Comedy, Romance) —
  //    у похожих ОБЯЗАТЕЛЬНО должен быть такой же. Иначе её не показываем.
  // 2. Дополнительная защита для Boys и Girls: эти метки нигде не должны
  //    «утекать» в другие подборки. Если у текущей нет Boys — все candidate
  //    с Boys отсеиваются. То же для Girls.
  // 3. Сортировка: больше пересечений по genres → выше; tiebreak — popularity.
  async getSimilar(dramaId, limit = 20) {
    await this._ensureLoaded();
    // Coercion: id может прилететь как строка (из URL hash) или число (из D1).
    // Сравниваем по Number(), иначе `131037 === '131037'` → false и getSimilar
    // вернёт пусто. Аналогично задаче #412 (fetchDrama coercion).
    const numId = Number(dramaId);
    const cur = this._dramas.find(d => Number(d.id) === numId);
    if (!cur) return { results: [] };

    // Каждый "labels" набор содержит id жанра и lowercase name (EN или RU) —
    // чтобы матчинг работал независимо от того, как сохранён genre у конкретной дорамы.
    const labelsOf = (d) => {
      const set = new Set();
      for (const g of (d.genres || [])) {
        if (!g) continue;
        if (g.id != null) set.add('id:' + g.id);
        if (g.name) set.add('n:' + String(g.name).toLowerCase().trim());
      }
      return set;
    };

    // Эксклюзивные группы — каждая представлена набором эквивалентных меток
    // (id + EN-name + RU-name). Если у текущей есть ХОТЯ БЫ ОДНА метка из группы,
    // у похожей должна быть хотя бы одна из той же группы.
    //
    // ВАЖНО: убрали bromance и costume — у нас 0 дорам с этими тегами,
    // и любая дорама с ними получала пустой результат (Marina, июнь 2026).
    // Когда массово протегируем — вернём обратно. До тех пор bromance/costume
    // влияют на overlap-score (есть совпадение → выше в выдаче), но не REQUIRE.
    const EXCLUSIVE_GROUPS = {
      boys:     ['id:9008', 'n:boys', 'n:парни', 'n:bl'],
      girls:    ['id:9007', 'n:girls', 'n:девушки', 'n:gl'],
      history:  ['id:36', 'n:history', 'n:история', 'n:исторический', 'n:исторические', 'n:историческое'],
      crime:    ['id:80', 'n:crime', 'n:криминал', 'n:детектив'],
      comedy:   ['id:35', 'n:comedy', 'n:комедия'],
      romance:  ['id:10749', 'n:romance', 'n:романтика', 'n:мелодрама', 'n:любовь'],
    };
    const BANNED_IF_NOT_OWN_KEYS = ['boys', 'girls'];
    const groupHas = (set, group) => group.some(k => set.has(k));

    const curLabels = labelsOf(cur);
    const curHas = {};
    for (const key of Object.keys(EXCLUSIVE_GROUPS)) {
      curHas[key] = groupHas(curLabels, EXCLUSIVE_GROUPS[key]);
    }

    const pool = this._applyUserPrefs(this._dramas);

    // Один проход: собираем кандидатов И с эксклюзивным фильтром, И без.
    // Если строгая выборка пустая (редкий эксклюзивный жанр без пары в базе) —
    // fallback на мягкую, чтобы блок «Похожие» никогда не был пустым.
    const strict = [];   // banned + exclusive + overlap≥1
    const soft   = [];   // banned + overlap≥1 (без exclusive-проверки)

    for (const d of pool) {
      if (d.id === cur.id) continue;
      if (!d.posterPath) continue;
      const dl = labelsOf(d);

      // (2) Banned: Boys/Girls не утекают в подборки где у current их нет
      let banned = false;
      for (const b of BANNED_IF_NOT_OWN_KEYS) {
        if (!curHas[b] && groupHas(dl, EXCLUSIVE_GROUPS[b])) { banned = true; break; }
      }
      if (banned) continue;

      // Overlap по жанрам — общий для обеих веток
      let overlap = 0;
      for (const g of dl) if (curLabels.has(g)) overlap++;
      if (overlap === 0) continue;

      soft.push({ d, overlap });

      // (1) Exclusive: каждая эксклюзивная группа current'а должна быть у кандидата
      let exclMiss = false;
      for (const key of Object.keys(EXCLUSIVE_GROUPS)) {
        if (curHas[key] && !groupHas(dl, EXCLUSIVE_GROUPS[key])) { exclMiss = true; break; }
      }
      if (!exclMiss) strict.push({ d, overlap });
    }

    // Fallback: если строгий фильтр дал слишком мало (< 5) — берём мягкую выборку.
    // Это покрывает случай когда у current редкий жанр без массы парных дорам.
    const candidates = strict.length >= 5 ? strict : soft;

    candidates.sort((a, b) =>
      (b.overlap - a.overlap) ||
      ((b.d.popularity || 0) - (a.d.popularity || 0))
    );

    return { results: candidates.slice(0, limit).map(c => c.d) };
  }

  // ─── DramaSourceAdapter contract ─────────────────────────────────

  async fetchHomeFeeds() {
    await this._ensureLoaded();
    const all = this._applyUserPrefs(this._dramas);
    const byPop = [...all].sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
    const byRating = [...all]
      .filter((d) => (d.voteCount || 0) >= 50)
      .sort((a, b) => (b.voteAverage || 0) - (a.voteAverage || 0));
    const currentYear = new Date().getUTCFullYear();
    const recent = byPop.filter((d) => {
      const y = parseInt((d.firstAirDate || '').slice(0, 4), 10);
      return y >= currentYear - 2;
    });
    const airing = byPop.filter((d) =>
      d.status === 'Returning Series' || d.status === 'In Production'
    );
    // Дорамы со статусом "Ожидается" (или upcoming) — для блока "Ожидается релиз"
    const upcoming = byPop.filter((d) => {
      const st = (d.status || '').toLowerCase();
      return st === 'ожидается' || st === 'upcoming' || st === 'expected' || st === 'planned';
    });
    // Дорамы со статусом "Приостановлено" — для блока "Релиз приостановлен"
    const suspended = byPop.filter((d) => {
      const st = (d.status || '').toLowerCase();
      return st === 'приостановлено' || st === 'on hold' || st === 'paused' || st === 'suspended';
    });
    return {
      trending: recent.slice(0, 20),
      popular: byPop.slice(0, 20),
      topRated: byRating.slice(0, 20),
      airing: airing.slice(0, 20),
      upcoming: upcoming.slice(0, 20),
      suspended: suspended.slice(0, 20)
    };
  }

  /**
   * Топ-N дорам по стране, отсортированный по voteAverage (server-side).
   * В отличие от fetchHomeFeeds().topRated (глобальный топ-20 — Корея забивает
   * все слоты), этот метод возвращает топ ВНУТРИ выбранной страны и включает
   * overview_ru/_en (которых нет в /api/dramas).
   * Используется на /rankings (3 вкладки CN/KR/TH).
   * @param {string} country ISO-2 код страны: 'CN' | 'KR' | 'TH' | 'JP'
   * @param {number} limit   Максимум дорам в ответе (default 20, capped 50)
   * @param {number} minVotes Фильтр шума: исключить дорамы с <N голосов (default 50)
   */
  async fetchTopByCountry(country, limit = 20, minVotes = 50) {
    const code = String(country || '').toUpperCase();
    if (!/^[A-Z]{2}$/.test(code)) return [];
    const apiBase = (typeof window !== 'undefined' && window.__dsApi?.base) || '';
    if (!apiBase) {
      // dev/JSON-only fallback: фильтруем локальный кеш
      await this._ensureLoaded();
      return [...this._dramas]
        .filter(d => {
          const c = d.originCountry || d.country || [];
          const arr = Array.isArray(c) ? c : [c];
          return arr.some(x => String(x || '').toUpperCase() === code)
            && (d.voteCount || 0) >= minVotes
            && typeof d.voteAverage === 'number';
        })
        .sort((a, b) => (b.voteAverage || 0) - (a.voteAverage || 0) || (b.voteCount || 0) - (a.voteCount || 0))
        .slice(0, limit);
    }
    try {
      const url = `${apiBase}/api/dramas/top-by-country?country=${code}&limit=${limit}&min_votes=${minVotes}`;
      const r = await fetch(url);
      if (!r.ok) return [];
      const json = await r.json();
      return Array.isArray(json?.items) ? json.items : [];
    } catch {
      return [];
    }
  }

  async fetchDrama(id) {
    await this._ensureLoaded();
    // Type coercion — id может прийти как string из library, а в _dramasById
    // ключи — number. Без приведения Map.get не найдёт даже валидную дораму
    // → DSDetailPage будет пустая или LibraryBlock пометит как orphan.
    const idNum = Number(id);
    let cached = this._dramasById.get(idNum) || this._dramasById.get(String(id));

    // Если id не в кэше — возможно полный каталог ещё догружается в фоне
    // (см. loadFullInBackground в _ensureLoaded). Это критично для
    // deep-link'ов: пользователь открыл прямо /#/drama/279388, top-80 уже
    // загружен но 279388 не в нём → fetchDrama возвращает null → DSDetailPage
    // рендерит пустоту.
    // Решение: ждём событие ds-full-data-loaded (до 10 сек) и повторяем поиск.
    if (!cached && typeof window !== 'undefined' && this._dramas.length < 1000) {
      await new Promise((resolve) => {
        let done = false;
        const finish = () => {
          if (done) return;
          done = true;
          window.removeEventListener('ds-full-data-loaded', finish);
          resolve();
        };
        window.addEventListener('ds-full-data-loaded', finish);
        setTimeout(finish, 10000);
      });
      cached = this._dramasById.get(idNum) || this._dramasById.get(String(id));
    }

    if (!cached) return null;
    // Privacy: если юзер включил hide_boys_genre/hide_girls_genre — дораму с
    // соответствующим жанром не показываем даже по deep-link (Marina, май 2026).
    if (this._applyUserPrefs([cached]).length === 0) return null;

    // Helper: догружает свежие данные с сервера, обновляет cache, dispatch'ит
    // событие ds-drama-updated чтобы React перерисовался. Используется и для
    // первой загрузки (когда _full=false), и для SWR-обновления стейлого кеша.
    const refetchFull = async () => {
      if (!cached.slug) return null;
      try {
        const apiBase = (typeof window !== 'undefined' && window.__dsApi?.base) || '';
        const r = await fetch(apiBase + '/api/dramas/' + encodeURIComponent(cached.slug));
        if (!r.ok) return null;
        const j = await r.json();
        const full = j.item || j;
        if (!full || !full.id) return null;
        if (full.slug && full.posterPath && !full.posterPath.startsWith('http')) {
          full.posterPath = full.slug + '.webp';
        }
        full._full = true;
        full._fullAt = Date.now();
        if (!full.posterPath && cached.posterPath) full.posterPath = cached.posterPath;
        this._dramasById.set(full.id, full);
        const idx = this._dramas.findIndex(d => d.id === full.id);
        if (idx >= 0) this._dramas[idx] = full;
        // Bump для React'а — DSDetailPage уже подписан на ds-drama-updated.
        if (typeof window !== 'undefined') {
          window.dispatchEvent(new CustomEvent('ds-drama-updated', { detail: { id: full.id } }));
        }
        return full;
      } catch { return null; }
    };

    // Если в listing уже есть heavy-поля (overview_ru/cast/trailers) — отдаём как есть.
    // Иначе догружаем полные данные через /api/dramas/:slug и кэшируем в _dramasById.
    if (cached._full) {
      // SWR: если кешу больше 60 сек — фоном обновляем, юзер получит свежие
      // данные через ds-drama-updated. Lex'ы / правки админки доезжают до
      // юзеров без необходимости перезагружать вкладку.
      const STALE_MS = 60 * 1000;
      if (!cached._fullAt || Date.now() - cached._fullAt > STALE_MS) {
        refetchFull();  // fire-and-forget, не блокирует возврат cached
      }
      return cached;
    }
    if (!cached.slug) return cached;  // нет slug — не сможем догрузить
    const fresh = await refetchFull();
    return fresh || cached;
  }

  async searchDramas(query, page = 1) {
    await this._ensureLoaded();
    const q = this._normalize(query);
    if (!q) return { results: [], page: 1, totalPages: 1, totalResults: 0 };
    const qRaw = (query || '').toLowerCase();  // для title_original (CJK без нормализации)
    const pool = this._applyUserPrefs(this._dramas);
    const matches = pool.filter((d) => {
      if (d.title_en && this._normalize(d.title_en).includes(q)) return true;
      if (d.title_ru && this._normalize(d.title_ru).includes(q)) return true;
      if (d.title_pinyin && this._normalize(d.title_pinyin).includes(q)) return true;
      if (d.title_original && d.title_original.toLowerCase().includes(qRaw)) return true;
      if (d.alt_titles && d.alt_titles.some((t) => this._normalize(t).includes(q))) return true;
      return false;
    });
    matches.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
    return this._paginate(matches, page);
  }

  async discover(filters = {}) {
    await this._ensureLoaded();

    // Advanced Search ищет по полному каталогу. Если в _dramas только top-80
    // (прогрессивная загрузка ещё не догрузила фон) — ждём событие
    // ds-full-data-loaded до 10 сек. Иначе фильтры дают неправильное «2 китайских»
    // вместо реальных сотен.
    if (typeof window !== 'undefined' && this._dramas.length < 1000) {
      await new Promise((resolve) => {
        let done = false;
        const finish = () => {
          if (done) return;
          done = true;
          window.removeEventListener('ds-full-data-loaded', finish);
          resolve();
        };
        window.addEventListener('ds-full-data-loaded', finish);
        setTimeout(finish, 10000);
      });
    }

    let arr = this._applyUserPrefs([...this._dramas]);

    if (filters.genres && filters.genres.length) {
      // Сматчиваем по id ИЛИ по name. У некоторых дорам (тайские/корейские из
      // doramy-пайплайна) genres могут быть только в формате [{name:"..."}]
      // без id — иначе они никогда не попадут в результат.
      const wantIds = new Set(filters.genres);
      const wantNames = new Set();
      const dict = (typeof window !== 'undefined' && Array.isArray(window.__dsGenresList))
        ? window.__dsGenresList : [];
      for (const id of filters.genres) {
        const found = dict.find(g => g.id === id);
        if (found?.name) wantNames.add(found.name.toLowerCase());
      }
      arr = arr.filter((d) => (d.genres || []).some((g) => {
        if (g && wantIds.has(g.id)) return true;
        if (g && g.name && wantNames.has(String(g.name).toLowerCase())) return true;
        return false;
      }));
    }
    if (filters.originCountry && filters.originCountry.length) {
      const want = new Set(filters.originCountry);
      arr = arr.filter((d) => (d.originCountry || []).some((c) => want.has(c)));
    }
    if (filters.networks && filters.networks.length) {
      const want = new Set(filters.networks);
      arr = arr.filter((d) => (d.networks || []).some((n) => want.has(n.id)));
    }
    if (filters.status !== undefined && filters.status !== '' && filters.status !== null) {
      // Группы статусов (варианты написания/языка), а не одна строка — иначе
      // «Отменено» не ловило ни «Canceled» (амер.), ни «Приостановлено».
      //   0 → Выходит, 2 → Ожидается, 3 → Завершено, 4 → Отменено (+приостановлено).
      const STATUS_GROUPS = {
        '0': ['returning series', 'ongoing', 'airing', 'выходит', 'returning'],
        '2': ['in production', 'planned', 'ожидается', 'upcoming', 'анонс', 'pilot'],
        '3': ['ended', 'завершено', 'completed', 'released'],
        '4': ['cancelled', 'canceled', 'приостановлено', 'on hold', 'paused', 'suspended', 'отменено', 'релиз приостановлен'],
      };
      const grp = STATUS_GROUPS[String(filters.status)];
      if (grp) {
        arr = arr.filter((d) => d.status && grp.includes(String(d.status).trim().toLowerCase()));
      } else {
        const STATUS_MAP = { '0': 'Returning Series', '1': 'Planned', '2': 'In Production', '3': 'Ended', '4': 'Cancelled', '5': 'Pilot' };
        const target = STATUS_MAP[filters.status] || filters.status;
        arr = arr.filter((d) => d.status === target);
      }
    }
    if (filters.yearFrom) {
      arr = arr.filter((d) => {
        const y = parseInt((d.firstAirDate || '').slice(0, 4), 10);
        return y && y >= filters.yearFrom;
      });
    }
    if (filters.yearTo) {
      arr = arr.filter((d) => {
        const y = parseInt((d.firstAirDate || '').slice(0, 4), 10);
        return y && y <= filters.yearTo;
      });
    }
    if (filters.minRating && filters.minRating > 0) {
      arr = arr.filter((d) => (d.voteAverage || 0) >= filters.minRating);
    }
    if (filters.voteCountGte) {
      arr = arr.filter((d) => (d.voteCount || 0) >= filters.voteCountGte);
    }
    if (filters.castIds && filters.castIds.length) {
      const want = new Set(filters.castIds);
      arr = arr.filter((d) => (d.cast || []).some((c) => want.has(c.personId)));
    }
    if (filters.keywordIds && filters.keywordIds.length) {
      const want = new Set(filters.keywordIds);
      arr = arr.filter((d) => (d.keywordIds || []).some((k) => want.has(k)));
    }

    if (filters.sort) {
      const [rawField, dir] = filters.sort.split('.');
      const FIELD_MAP = {
        popularity: 'popularity',
        vote_average: 'voteAverage',
        vote_count: 'voteCount',
        first_air_date: 'firstAirDate'
      };
      const field = FIELD_MAP[rawField] || rawField;
      const mult = dir === 'asc' ? 1 : -1;
      arr.sort((a, b) => {
        const va = a[field], vb = b[field];
        if (typeof va === 'string' || typeof vb === 'string') {
          return mult * String(va || '').localeCompare(String(vb || ''));
        }
        return mult * ((va || 0) - (vb || 0));
      });
    }

    return this._paginate(arr, filters.page || 1);
  }

  async fetchPerson(idOrSlug) {
    await this._ensureLoaded();
    if (idOrSlug == null) return null;
    let direct = this._peopleById.get(idOrSlug);
    if (direct) return direct;
    let bySlug = (typeof idOrSlug === 'string') ? this._people.find(p => p.slug === idOrSlug) : null;
    if (bySlug) return bySlug;

    // Не нашли — возможно полный каталог ещё догружается. См. fetchDrama.
    // Это ключевой фикс для deep-link'ов: прямая загрузка /#/actor/XXXX где
    // актёр не в top-100 — компонент рендерил пустоту.
    if (typeof window !== 'undefined' && this._people.length < 1000) {
      await new Promise((resolve) => {
        let done = false;
        const finish = () => {
          if (done) return;
          done = true;
          window.removeEventListener('ds-full-data-loaded', finish);
          resolve();
        };
        window.addEventListener('ds-full-data-loaded', finish);
        setTimeout(finish, 10000);
      });
      direct = this._peopleById.get(idOrSlug);
      if (direct) return direct;
      if (typeof idOrSlug === 'string') {
        bySlug = this._people.find(p => p.slug === idOrSlug);
        if (bySlug) return bySlug;
      }
    }
    return null;
  }

  async fetchPersonCredits(id) {
    await this._ensureLoaded();
    // Применяем privacy-фильтр и к фильмографии актёра: если юзер скрыл Boys/Girls,
    // в фильмографии тоже не должно быть дорам с этими жанрами.
    return this._applyUserPrefs(this._dramas).filter((d) =>
      (d.cast || []).some((c) => c.personId === id)
    );
  }

  async fetchPopularActors() {
    await this._ensureLoaded();
    // Исключаем режиссёров: тех, у кого occupation содержит "режисс"/"director",
    // ИЛИ кто упомянут в какой-то дораме в crew с должностью режиссёра.
    // Логика синхронизирована с tools/upload-directors.html.
    const DIRECTOR_RE = /режисс|director/i;
    const directorIds = new Set();
    for (const d of this._dramas) {
      for (const c of (d.crew || [])) {
        if (c && c.personId && DIRECTOR_RE.test(c.job || '')) {
          directorIds.add(c.personId);
        }
      }
    }
    const isDirector = (p) => {
      if (directorIds.has(p.id)) return true;
      const occ = (p.occupation || []).join(' ');
      return DIRECTOR_RE.test(occ);
    };
    return [...this._people]
      .filter(p => !isDirector(p))
      .sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
  }

  async searchPerson(query) {
    await this._ensureLoaded();
    const q = this._normalize(query);
    if (!q) return [];
    // Поиск по началу имени: вводишь «х» — все на «х», «хэ» — на «хэ» и т.д.
    // Без популярности. Совпадение, если имя (рус/лат) ИЛИ любое его слово
    // начинается с запроса. Сортировка: сначала те, что с самого начала, потом
    // те, где с начала слова; внутри — по алфавиту.
    const rank = (s) => {
      if (!s) return 0;
      const n = this._normalize(s);
      if (n.startsWith(q)) return 2;
      if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
      return 0;
    };
    const scored = [];
    for (const p of this._people) {
      let sc = Math.max(rank(p.name), rank(p.name_ru));
      if (sc < 2 && p.alsoKnownAs && p.alsoKnownAs.length) sc = Math.max(sc, ...p.alsoKnownAs.map(rank));
      if (sc > 0) scored.push({ p, sc, key: this._normalize(p.name_ru || p.name) });
    }
    scored.sort((a, b) => (b.sc - a.sc) || a.key.localeCompare(b.key, 'ru'));
    return scored.map((x) => x.p);
  }

  async searchKeyword(query) {
    await this._ensureLoaded();
    const q = this._normalize(query);
    if (!q) return [];
    return this._keywords.filter((k) =>
      k.name && this._normalize(k.name).includes(q)
    );
  }

  imageUrl(path, size) {
    if (!path) return null;
    // Если path уже полный URL (http* — например R2-аплоад из админки) —
    // возвращаем как есть, игнорируя size (R2 хранит один размер).
    if (path.startsWith('http')) return path;
    // Используем только w185 везде — w500 не льётся в R2, не закоммичен в git.
    // Разница для пользователя минимальна (постер 300px на retina — еле заметно
    // мыльно). Экономия: 183 MB на диске + вдвое меньше R2-операций.
    // Если когда-то понадобится HQ для детальной — добавим srcset.
    return `${this.posterBaseUrl}w185/${path}`;
  }

  // JSONAdapter-specific: профили лежат плоско в assets/profiles/{slug}.webp,
  // без size-папок. Используется компонентами для фото актёров.
  profileImageUrl(path) {
    if (!path) return null;
    if (path.startsWith('http')) return path;  // R2-аплоад
    return `${this.profileBaseUrl}${path}`;
  }
}

// ── ADAPTER HOOK ──────────────────────────────────────────────────────────────
// React-хук поверх DramaSourceAdapter. fetcher принимает window.adapter.
// Возвращает {data, loading, error}. Гонка с unmount защищена флагом cancelled.
function useAdapter(fetcher, deps = []) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  useEffect(() => {
    setLoading(true); setError(null);
    let cancelled = false;
    fetcher(window.adapter)
      .then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
      .catch((e) => { if (!cancelled) { setError(e); setLoading(false); } });
    return () => { cancelled = true; };
  }, deps);
  return { data, loading, error };
}

// ── SMALL UI ──────────────────────────────────────────────────────────────────
function DSBadge({ label, variant = 'blue' }) {
  const map = {
    blue: { bg: 'rgba(20,135,203,0.18)', color: '#1487cb', border: 'rgba(20,135,203,0.45)' },
    // Тёмно-голубой для CTA на тёмном hero (например "Подробнее" на постере).
    // Заливка плотная sky-blue из палитры, текст белый.
    darkblue: { bg: 'rgba(15,60,130,0.65)', color: '#ffffff', border: 'rgba(15,60,130,0.85)' },
    green: { bg: 'rgba(61,214,140,0.12)', color: '#3dd68c', border: 'rgba(61,214,140,0.25)' },
    red: { bg: 'rgba(255,107,107,0.12)', color: '#ff6b6b', border: 'rgba(255,107,107,0.25)' },
    gold: { bg: 'rgba(245,197,24,0.12)', color: '#f5c518', border: 'rgba(245,197,24,0.25)' },
    gray: { bg: 'rgba(255,255,255,0.07)', color: '#8ba8d4', border: 'rgba(255,255,255,0.1)' },
    purple: { bg: 'rgba(192,132,252,0.12)', color: '#c084fc', border: 'rgba(192,132,252,0.25)' }
  };
  const s = map[variant] || map.blue;
  return (
    <span className="ds-badge" style={{
      display: 'inline-block', padding: '2px 9px', borderRadius: 4,
      background: s.bg, color: s.color, border: `1px solid ${s.border}`,
      fontSize: 11, fontWeight: 400, letterSpacing: '0.3px', whiteSpace: 'nowrap'
    }}>{label}</span>);

}

function DSStars({ vote }) {
  return (
    <span style={{ color: '#f5c518', fontSize: 13, display: 'flex', alignItems: 'center', gap: 4 }}>
      ★ <span style={{ color: '#e0e0e0', fontSize: 12 }}>{Math.round(vote * 10) / 10}</span>
    </span>);

}

function DSSpinner() {
  return (
    <div style={{ display: 'flex', justifyContent: 'center', padding: '40px 0' }}>
      <div style={{ width: 30, height: 30, border: '3px solid rgba(74,158,255,0.15)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
    </div>);

}

const POSTER_GRADIENTS = [
['#1a0a1a', '#3a1040'], ['#2a1010', '#4a1820'], ['#0e1f44', '#1a3a6e'],
['#1a1040', '#3a205c'], ['#14102a', '#2a2050'], ['#0a2030', '#1a4060'],
['#0a2010', '#1a4030'], ['#1a1a10', '#3a3020'], ['#0e2a1a', '#1a5030']];


function DSPlaceholderPoster({ seed = 1, width = '100%', height = '100%', style = {} }) {
  // Единая заглушка-постер (картинка Marina). Используется везде, где у дорамы нет постера.
  return (
    <img src="./assets/drama-placeholder.png" alt="" loading="lazy"
      style={{ width, height, objectFit: 'cover', display: 'block', background: 'var(--bg3)', ...style }} />
  );
}

function DSPlaceholderBackdrop({ seed = 1, style = {} }) {
  const clrs = [['#12082a', '#221044'], ['#060e28', '#0d1e44'], ['#041822', '#0a2c3a']];
  const [c1, c2] = clrs[(seed - 1) % clrs.length];
  const uid = `bd${seed}`;
  return (
    <svg width="100%" height="100%" viewBox="0 0 1280 720" preserveAspectRatio="xMidYMid slice" style={style}>
      <defs>
        <linearGradient id={uid} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stopColor={c1} /><stop offset="100%" stopColor={c2} />
        </linearGradient>
      </defs>
      <rect width="1280" height="720" fill={`url(#${uid})`} />
      {Array.from({ length: 18 }).map((_, i) =>
      <line key={i} x1="0" y1={i * 42} x2="1280" y2={i * 42} stroke="rgba(255,255,255,0.025)" strokeWidth="1" />
      )}
    </svg>);

}

// ── DRAMA CARD ────────────────────────────────────────────────────────────────
const FLAG = { CN: '🇨🇳', HK: '🇭🇰', TW: '🇹🇼', KR: '🇰🇷', JP: '🇯🇵', TH: '🇹🇭', MO: '🇲🇴', SG: '🇸🇬', MY: '🇲🇾', VN: '🇻🇳', PH: '🇵🇭', ID: '🇮🇩' };
// Названия стран (ISO-код → {en, ru}) — для отображения в плитке «СТРАНА»
const COUNTRY_NAME = {
  CN: { en: 'China', ru: 'Китай' },
  HK: { en: 'Hong Kong', ru: 'Гонконг' },
  TW: { en: 'Taiwan', ru: 'Тайвань' },
  MO: { en: 'Macau', ru: 'Макао' },
  KR: { en: 'South Korea', ru: 'Южная Корея' },
  JP: { en: 'Japan', ru: 'Япония' },
  TH: { en: 'Thailand', ru: 'Таиланд' },
  SG: { en: 'Singapore', ru: 'Сингапур' },
  MY: { en: 'Malaysia', ru: 'Малайзия' },
  VN: { en: 'Vietnam', ru: 'Вьетнам' },
  PH: { en: 'Philippines', ru: 'Филиппины' },
  ID: { en: 'Indonesia', ru: 'Индонезия' }
};

function DramaCard({ item, onClick, size = 'md', onToggleWatch, inWatchlist }) {
  // Fallback: если локальный постер не подгрузился — используем wd_image (Wikimedia Commons)
  const [hov, setHov] = useState(false);
  const [imgErr, setImgErr] = useState(false);
  const localPoster = item.posterPath ? window.adapter.imageUrl(item.posterPath, size === 'lg' ? 'w500' : 'w185') : null;
  const posterUrl = imgErr ? (item.wd_image || null) : (localPoster || item.wd_image || null);
  const widths = { sm: 128, md: 165, lg: 195 };
  const w = widths[size] || 165;
  const h = Math.round(w * 1.5);
  const year = (item.firstAirDate || '').slice(0, 4);
  const flag = (item.originCountry || []).map((c) => FLAG[c]).find(Boolean) || '🇨🇳';
  const { lang, t } = (window.useI18n ? window.useI18n() : { lang: 'en', t: (s) => s });
  const displayTitle = (lang === 'ru' && item.title_ru) ? item.title_ru : (item.title_en || item.title_pinyin || item.title_original || '');

  // ── Статус из библиотеки пользователя — бейдж слева от названия ──
  // Читаем localStorage `ds_library_<email|guest>` и слушаем 'ds-library-changed',
  // который диспатчит LibraryStatusButton после сохранения. Так бейдж обновляется
  // на ВСЕХ видимых карточках на сайте сразу после смены статуса.
  const _libAuth = window.useAuth ? window.useAuth() : { user: null };
  const _libKey = `ds_library_${_libAuth?.user?.email || 'guest'}`;
  const readLibStatus = () => {
    if (!item?.id) return null;
    try {
      const arr = JSON.parse(localStorage.getItem(_libKey) || '[]');
      return arr.find(x => x.id === item.id)?.status || null;
    } catch { return null; }
  };
  const [libStatus, setLibStatus] = useState(readLibStatus);
  useEffect(() => {
    const refresh = () => setLibStatus(readLibStatus());
    refresh(); // на случай смены пользователя (email→guest или наоборот)
    window.addEventListener('ds-library-changed', refresh);
    window.addEventListener('storage', refresh);
    return () => {
      window.removeEventListener('ds-library-changed', refresh);
      window.removeEventListener('storage', refresh);
    };
  }, [item?.id, _libKey]);
  const libStatuses = (window.__dsLibStatusesArr || window.LIBRARY_STATUSES || []);
  const libInfo = libStatus ? libStatuses.find(s => s.id === libStatus) : null;

  // ── Inline-выбор статуса библиотеки при hover на постер ──
  const [statusPickerOpen, setStatusPickerOpen] = useState(false);
  const pickerRef = useRef(null);
  const effectiveHov = hov || statusPickerOpen;
  useEffect(() => {
    if (!statusPickerOpen) return;
    const close = (e) => {
      if (pickerRef.current && !pickerRef.current.contains(e.target)) setStatusPickerOpen(false);
    };
    document.addEventListener('mousedown', close);
    return () => document.removeEventListener('mousedown', close);
  }, [statusPickerOpen]);
  const setLibraryStatus = (newStatus) => {
    if (!_libAuth?.user) {
      window.dispatchEvent(new CustomEvent('ds-open-signup'));
      setStatusPickerOpen(false);
      return;
    }
    try {
      const arr = JSON.parse(localStorage.getItem(_libKey) || '[]');
      let next;
      if (newStatus === null) {
        next = arr.filter(x => x.id !== item.id);
      } else {
        const data = {
          id: item.id, title_en: item.title_en, title_ru: item.title_ru,
          title_original: item.title_original, posterPath: item.posterPath,
          firstAirDate: item.firstAirDate, voteAverage: item.voteAverage,
          originCountry: item.originCountry,
          status: newStatus, addedAt: new Date().toISOString()
        };
        const existing = arr.find(x => x.id === item.id);
        next = existing
          ? arr.map(x => x.id === item.id ? { ...x, ...data } : x)
          : [...arr, data];
      }
      localStorage.setItem(_libKey, JSON.stringify(next));
      window.dispatchEvent(new CustomEvent('ds-library-changed'));
    } catch (e) {}
    setStatusPickerOpen(false);
  };

  return (
    <div className="ds-drama-card" onClick={onClick} onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)} style={{
      flex: `0 0 ${w}px`, width: w, cursor: 'pointer', position: 'relative',
      transform: effectiveHov ? 'translateY(-4px)' : 'none', transition: 'transform 0.22s',
      zIndex: effectiveHov ? 10 : 1
    }}>
      <div className="ds-drama-card__poster" style={{ width: w, height: h, borderRadius: 14, overflow: 'hidden', background: '#0a0e1c',
        boxShadow: hov ? '0 12px 32px rgba(0,0,0,0.45)' : '0 4px 14px rgba(0,0,0,0.22)', transition: 'box-shadow 0.22s' }}>
        {posterUrl ?
        <img src={posterUrl} alt={displayTitle} onError={() => setImgErr(true)} className="ds-poster" style={{ width: '100%', height: '100%', display: 'block' }} /> :
        <DSPlaceholderPoster seed={(item.id || 1) % 9 + 1} width="100%" height="100%" />
        }
        {(() => {
          const itemYear = (item.firstAirDate || '').slice(0, 4);
          const isTop = (item.popularity || 0) >= 70;
          const isNew = itemYear === '2026';
          const badges = [];
          // ── PREMIERE: status='Выходит с...' с известной датой релиза ──
          // (Marina, июнь 2026) Для дорам, у которых анонсирована конкретная дата
          // премьеры: вместо «Ожидается» показываем NEW + голубую плашку
          // «Выходит с 9 июня» (форматируем дату на языке UI).
          const _stForPremiere = (item.status || '').toLowerCase().replace(/\s+/g, ' ').trim();
          const _isPremiere = _stForPremiere === 'выходит с...'
                           || _stForPremiere === 'выходит с'
                           || _stForPremiere.startsWith('выходит с ');
          const _formatReleaseDate = (dateStr) => {
            if (!dateStr) return '';
            const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})/);
            if (!m) return '';
            const day = parseInt(m[3], 10);
            const month = parseInt(m[2], 10) - 1;
            const langCur = (window.useI18n ? window.useI18n().lang : 'en');
            const months = langCur === 'ru'
              ? ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря']
              : ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
            return `${day} ${months[month]}`;
          };
          const _premiereDate = _isPremiere ? _formatReleaseDate(item.firstAirDate) : '';
          // ═══════════════════════════════════════════════════════════════
          // 🔒 CARD-BADGES — ЗАФИКСИРОВАНО Marina'й 2026-05-27 ~12:16.
          // НЕ менять без явного запроса. Бэкап:
          //   outputs/DramaScope_backup_2026-05-27_1216_card-badges-locked/
          //
          // Стили:
          //   fontSize 9px, fontWeight 500, letterSpacing 0.2px (noUpper:true)
          //   padding '2px 7px', borderRadius 4, БЕЗ text-shadow
          //
          // Статусы (только 3, остальные скрыты):
          //   синий   — Выходит / Ongoing / Returning Series / In Production
          //   гол.    — Ожидается / Upcoming / Planned / Pilot  (НЕЖНО-голубой #4d7ba8→#6a99c4)
          //   красный — Приостановлено / On hold / Suspended / Cancelled
          //   (Завершено / Ended — НЕ показываем)
          //
          // TOP / NEW: тот же размер 9px, NEW с noUpper:true для одинаковых
          // пропорций со статусом.
          // ═══════════════════════════════════════════════════════════════
          // Primary (TOP или NEW). NEW — те же визуальные пропорции что у статусов.
          // Для premiere (Выходит с...): принудительно NEW, даже если год ≠ 2026.
          if (isTop && !_isPremiere) badges.push({ label: 'TOP', bg: 'linear-gradient(135deg, #ff6b35, #ff3d3d)', glow: 'rgba(255,61,61,0.45)' });
          else if (isNew || _isPremiere) badges.push({ label: 'NEW', bg: 'linear-gradient(135deg, #0abab5, #1ed4ce)', glow: 'rgba(10,186,181,0.35)', noUpper: true });
          // Статус — отдельный бейдж ПОД основным. Только для 3 статусов:
          //   синий=Выходит, жёлтый=Ожидается, красный=Приостановлено.
          // «Завершено» и все остальные — НЕ показываем (по запросу Marina).
          const st = (item.status || '').toLowerCase();
          const _t = (window.useI18n ? window.useI18n().t : (s) => s);
          if (_isPremiere && _premiereDate) {
            // Premiere — голубая плашка «Выходит с DD месяц». Использует ту же
            // палитру что и «Ожидается» (нежно-голубой). Под NEW бейджем сверху.
            const lng = (window.useI18n ? window.useI18n().lang : 'en');
            const lbl = lng === 'ru' ? `Выходит с ${_premiereDate}` : `Airing from ${_premiereDate}`;
            badges.push({ label: lbl, bg: 'linear-gradient(135deg, #4d7ba8, #6a99c4)', glow: 'rgba(106,153,196,0.5)', noUpper: true });
          } else if (st === 'returning series' || st === 'in production' || st === 'выходит' || st === 'ongoing') {
            badges.push({ label: _t('Ongoing'), bg: 'linear-gradient(135deg, #1e3a5f, #2b5d8e)', glow: 'rgba(43,93,142,0.5)', noUpper: true });
          } else if (st === 'ожидается' || st === 'upcoming' || st === 'expected' || st === 'planned' || st === 'pilot') {
            badges.push({ label: _t('Upcoming'), bg: 'linear-gradient(135deg, #4d7ba8, #6a99c4)', glow: 'rgba(106,153,196,0.5)', noUpper: true });
          } else if (st === 'приостановлено' || st === 'отменено' || st === 'on hold' || st === 'paused' || st === 'suspended' || st === 'canceled' || st === 'cancelled') {
            badges.push({ label: _t('On hold'), bg: 'linear-gradient(135deg, #5c1d2e, #7d2944)', glow: 'rgba(125,41,68,0.5)', noUpper: true });
          }
          // st === 'ended' / 'завершено' / 'finished' — никакого бейджа.
          if (badges.length === 0) return null;
          return (
            <div style={{ position: 'absolute', top: 6, left: 6, zIndex: 3, display: 'flex', flexDirection: 'column', gap: 4 }}>
              {badges.map((b, i) => (
                <div key={i} style={{
                  background: b.bg,
                  borderRadius: 4, padding: '2px 7px',
                  fontSize: b.size != null ? b.size : (b.noUpper ? 9 : 9), fontWeight: 500, color: '#fff',
                  letterSpacing: b.noUpper ? 0.2 : 0.8,
                  boxShadow: `0 2px 8px ${b.glow}`,
                  width: 'fit-content'
                }}>{b.label}</div>
              ))}
            </div>
          );
        })()}
        {/* Флаг страны убран по запросу — на его место (правый нижний угол) переехала heart-кнопка. */}
        {/* DramaScope rating-плашка (PosterRatingBadge) — показывается только если есть пользовательские оценки. */}
        {window.PosterRatingBadge && (
          <window.PosterRatingBadge dramaId={item.id} voteAverage={item.voteAverage} />
        )}
        {/* Compact heart в правом верхнем углу постера, над рейтингом.
            Рейтинг сдвинут ниже через CSS-override .ds-drama-card__poster .ds-poster-rating-badge. */}
        {window.FavoriteDramaButton && (
          <div style={{ position: 'absolute', top: 6, right: 6, zIndex: 5 }}>
            <window.FavoriteDramaButton item={item} compact />
          </div>
        )}
        <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.3) 50%, transparent 100%)', opacity: effectiveHov ? 1 : 0, transition: 'opacity 0.2s', pointerEvents: 'none' }} />
        {effectiveHov && !statusPickerOpen &&
        <div ref={pickerRef} style={{ position: 'absolute', bottom: 8, left: 8 }}>
            {/* Заголовок дорамы убран — он и так читается под постером, дублировать
               на ховере не нужно (Marina, #149). Остаётся только круглая кнопка. */}
            {/* Круглая иконка-кнопка библиотеки — в стиле action-buttons со страницы дорамы.
               Без текста, только иконка статуса (если выбран) или плюс. Клик → раскрывает picker. */}
            <button
              type="button"
              className="ds-card-lib-btn"
              aria-label={libInfo ? t(libInfo.label) : (lang === 'ru' ? 'В библиотеку' : 'Add to Watchlist')}
              title={libInfo ? t(libInfo.label) : (lang === 'ru' ? 'В библиотеку' : 'Add to Watchlist')}
              onClick={(e) => { e.preventDefault(); e.stopPropagation(); setStatusPickerOpen(true); }}
              onMouseDown={(e) => e.stopPropagation()}
              style={{
                width: 32, height: 32, borderRadius: '50%', flexShrink: 0, padding: 0,
                background: libInfo ? `${libInfo.color}33` : 'rgba(20,135,203,0.22)',
                color: libInfo ? libInfo.color : '#7bc3ff',
                border: `1.5px solid ${libInfo ? libInfo.color : 'rgba(123,195,255,0.7)'}`,
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
                boxShadow: '0 2px 6px rgba(0,0,0,0.35)',
              }}>
              {libInfo && window.LIB_ICONS && window.LIB_ICONS[libInfo.id]
                ? <span style={{ display: 'inline-flex', alignItems: 'center', transform: 'scale(0.85)' }}>{window.LIB_ICONS[libInfo.id]}</span>
                : <span style={{ fontSize: 18, fontWeight: 300, lineHeight: 1 }}>+</span>}
            </button>
          </div>
        }
        {effectiveHov && statusPickerOpen &&
        <div ref={pickerRef}
          className="ds-card-status-picker"
          onClick={(e) => e.stopPropagation()}
          onMouseDown={(e) => e.stopPropagation()}
          style={{
            position: 'absolute', bottom: 8, left: 8, right: 8,
            zIndex: 10,
            background: 'rgba(8,12,26,0.95)',
            border: '1px solid rgba(140,180,235,0.22)',
            borderRadius: 8, padding: 4,
            boxShadow: '0 8px 24px rgba(0,0,0,0.6)'
          }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '2px 6px 4px' }}>
              <span style={{ fontSize: 10, fontWeight: 700, color: '#9fcbe9', textTransform: 'uppercase', letterSpacing: 1 }}>
                {lang === 'ru' ? 'Статус' : 'Status'}
              </span>
              <button type="button"
                onClick={(e) => { e.preventDefault(); e.stopPropagation(); setStatusPickerOpen(false); }}
                style={{
                  width: 18, height: 18, borderRadius: 4, padding: 0,
                  display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                  background: 'transparent', color: '#e6ecf7', border: 'none', cursor: 'pointer',
                  fontSize: 12, lineHeight: 1
                }}>×</button>
            </div>
            {libStatuses.filter(s => s.id !== 'favorite').map(s => (
              <button key={s.id}
                type="button"
                onClick={(e) => { e.preventDefault(); e.stopPropagation(); setLibraryStatus(s.id); }}
                onMouseDown={(e) => e.stopPropagation()}
                style={{
                  width: '100%', padding: '5px 8px', borderRadius: 5, fontSize: 11, textAlign: 'left',
                  display: 'flex', alignItems: 'center', gap: 6, marginBottom: 1,
                  color: libStatus === s.id ? s.color : '#e6ecf7',
                  background: libStatus === s.id ? `${s.color}22` : 'transparent',
                  border: 'none', cursor: 'pointer', fontWeight: 400
                }}
                onMouseEnter={(e) => { if (libStatus !== s.id) e.currentTarget.style.background = 'rgba(74,158,255,0.14)'; }}
                onMouseLeave={(e) => { if (libStatus !== s.id) e.currentTarget.style.background = 'transparent'; }}>
                <span style={{ display: 'inline-flex', alignItems: 'center', color: s.color, transform: 'scale(0.8)' }}>{window.LIB_ICONS && window.LIB_ICONS[s.id]}</span>
                <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t(s.label)}</span>
              </button>
            ))}
            {libStatus && (
              <>
                <div style={{ height: 1, background: 'rgba(255,255,255,0.08)', margin: '3px 4px' }} />
                <button type="button"
                  onClick={(e) => { e.preventDefault(); e.stopPropagation(); setLibraryStatus(null); }}
                  onMouseDown={(e) => e.stopPropagation()}
                  style={{
                    width: '100%', padding: '5px 8px', borderRadius: 5, fontSize: 10, textAlign: 'left',
                    color: '#e05858', background: 'transparent', border: 'none', cursor: 'pointer'
                  }}>{lang === 'ru' ? '× Убрать из библиотеки' : '× Remove from Library'}</button>
              </>
            )}
          </div>
        }
      </div>
      {(() => {
        const st = (item.status || '').toLowerCase();
        const eligibleNotify = ['ожидается', 'upcoming', 'expected', 'planned',
                                'приостановлено', 'on hold', 'paused', 'suspended'].includes(st)
                               && !!window.ReleaseNotifyButton;
        // Когда есть кнопка «Сообщить» — фиксируем мин. высоту инфо-блока, чтобы у
        // карточек в ряду кнопки выровнялись по нижнему краю (margin-top:auto).
        // Высота: title (до 3 строк ~46px) + year (~14px) + paddings + кнопка (~24px).
        return (
          <div style={{
            padding: '8px 2px 4px',
            display: 'flex', flexDirection: 'column',
            minHeight: eligibleNotify ? 100 : undefined,
          }}>
            <div className="ds-drama-card-title" data-hov={hov ? '1' : '0'} style={{
              fontSize: 12, fontWeight: 500,
              lineHeight: 1.3,
              wordBreak: 'break-word',
              overflowWrap: 'break-word',
              transition: 'color 0.15s'
            }}>
              {libInfo && (
                <span
                  title={t(libInfo.label)}
                  style={{
                    display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                    width: 18, height: 18, borderRadius: 4,
                    background: libInfo.color || '#888',
                    color: libInfo.iconColor || '#06204a',
                    boxShadow: '0 1px 3px rgba(0,0,0,0.35)',
                    verticalAlign: 'middle',
                    marginRight: 5,
                    float: 'left'
                  }}>
                  {window.LIB_ICONS && window.LIB_ICONS[libInfo.id]
                    ? <span style={{ display: 'inline-flex', transform: 'scale(0.72)' }}>{window.LIB_ICONS[libInfo.id]}</span>
                    : <span style={{ fontSize: 11, fontWeight: 800, lineHeight: 1 }}>{(libInfo.id || '?').charAt(0).toUpperCase()}</span>}
                </span>
              )}
              {displayTitle}
              {/* Иконка Мао — показывается если у дорамы есть саммари в БД */}
              {window._MaoIcon && <window._MaoIcon dramaId={item.id} size={16} marginLeft={6} />}
            </div>
            {year && <div style={{ fontSize: 11, color: '#4d6a9a', marginTop: 1 }}>{year}</div>}
            {eligibleNotify && (
              <div style={{ marginTop: 'auto', paddingTop: 6 }} onClick={(e) => e.stopPropagation()}>
                <window.ReleaseNotifyButton drama={item} compact={true} />
              </div>
            )}
          </div>
        );
      })()}
    </div>);

}

// ── CAROUSEL ──────────────────────────────────────────────────────────────────
function DSCarousel({ title, items, onSelect, icon = '', badge, viewAll, onToggleWatch, watchlist }) {
  const scrollRef = useRef(null);
  const [canLeft, setCanLeft] = useState(false);
  const [canRight, setCanRight] = useState(true);

  const check = () => {
    const el = scrollRef.current;if (!el) return;
    setCanLeft(el.scrollLeft > 8);
    setCanRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 8);
  };
  const scroll = (dir) => {
    scrollRef.current?.scrollBy({ left: dir * 560, behavior: 'smooth' });
    setTimeout(check, 350);
  };

  if (!items || !items.length) return null;
  const { t } = (window.useI18n ? window.useI18n() : { t: (s) => s });

  return (
    <div style={{ marginBottom: 44 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, padding: '0 24px' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <h2 style={{ fontSize: 17, fontWeight: 700, color: "rgb(34, 78, 159)" }}>{t(title)}</h2>
          {badge && <DSBadge label={t(badge)} variant="gold" />}
        </div>
        {viewAll && (
          <button
            onClick={viewAll}
            onMouseEnter={(e) => { e.currentTarget.style.textDecoration = 'underline'; }}
            onMouseLeave={(e) => { e.currentTarget.style.textDecoration = 'none'; }}
            style={{
              fontSize: 13, fontWeight: 700, color: 'var(--accent)',
              background: 'transparent', border: 0, cursor: 'pointer',
              padding: '4px 8px', whiteSpace: 'nowrap',
            }}>
            {t('View all →')}
          </button>
        )}
      </div>
      <div style={{ position: 'relative' }}>
        {canLeft &&
        <button onClick={() => scroll(-1)} style={{ position: 'absolute', left: 4, top: '40%', transform: 'translateY(-50%)', zIndex: 20, width: 34, height: 34, borderRadius: '50%', background: 'rgba(8,10,22,0.92)', border: '1px solid rgba(74,158,255,0.3)', color: '#fff', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(8px)' }}>‹</button>
        }
        {canRight &&
        <button onClick={() => scroll(1)} style={{ position: 'absolute', right: 4, top: '40%', transform: 'translateY(-50%)', zIndex: 20, width: 34, height: 34, borderRadius: '50%', background: 'rgba(8,10,22,0.92)', border: '1px solid rgba(74,158,255,0.3)', color: '#fff', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(8px)' }}>›</button>
        }
        <div ref={scrollRef} onScroll={check} style={{ display: 'flex', gap: 14, overflowX: 'auto', overflowY: 'visible', padding: '18px 24px 12px', scrollbarWidth: 'none', msOverflowStyle: 'none', fontSize: "18px" }}>
          {items.map((item) =>
          <DramaCard
            key={item.id}
            item={item}
            onClick={() => onSelect(item)}
            onToggleWatch={onToggleWatch}
            inWatchlist={watchlist?.some((w) => w.id === item.id)} />

          )}
        </div>
      </div>
    </div>);

}

// ── TIME AGO ──────────────────────────────────────────────────────────────────
function timeAgo(dateStr) {
  const d = new Date(dateStr);
  if (isNaN(d)) return dateStr;
  const h = Math.floor((Date.now() - d.getTime()) / 3600000);
  if (h < 1) return 'just now';
  if (h < 24) return `${h} hours ago`;
  const days = Math.floor(h / 24);
  if (days === 1) return 'yesterday';
  if (days < 7) return `${days} days ago`;
  return dateStr;
}

// Аккуратная дата публикации новости (локализованная). Принимает date ИЛИ
// published_at (API отдаёт published_at). Возвращает '' если даты нет.
function fmtNewsDate(dateStr) {
  if (!dateStr) return '';
  const d = new Date(String(dateStr).replace(' ', 'T'));
  if (isNaN(d)) return '';
  const loc = (typeof window !== 'undefined' && window.__dsLang === 'ru') ? 'ru-RU' : 'en-US';
  try { return d.toLocaleDateString(loc, { day: 'numeric', month: 'short', year: 'numeric' }); }
  catch { return ''; }
}

// ── NEWS CARD ─────────────────────────────────────────────────────────────────
function NewsCard({ news, variant = 'hero', onClick }) {
  const { lang } = useI18n();
  if (!news) return null;
  // EN-сайт показывает английскую версию новости с фолбэком на русскую.
  const isEn = lang === 'en';
  const title = isEn ? (news.title_en || news.title) : news.title;
  const excerpt = isEn ? (news.excerpt_en || news.excerpt) : news.excerpt;
  // cover_image может быть полным URL (R2) или относительным путём (legacy news.json)
  const coverUrl = news.cover_image
    ? (/^https?:\/\//i.test(news.cover_image) ? news.cover_image : `/assets/${news.cover_image}`)
    : news.image;

  const HotBadge = () => news.hot && (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 3,
      padding: '2px 7px', borderRadius: 999,
      background: 'linear-gradient(135deg,#ff6b35,#ff3d3d)',
      color: '#fff', fontSize: variant === 'hero' ? 9.5 : 8.5, fontWeight: 800,
      letterSpacing: 0.8, textTransform: 'uppercase',
      lineHeight: 1, boxShadow: '0 1px 4px rgba(255,61,61,0.4)'
    }}>🔥 HOT</span>
  );

  // Закладка — сохраняет новость в Сохранённые новости (drawer-блок).
  // Авторизация: для гостей открываем sign-up. Уже залогиненные — toggle.
  const SaveBookmark = ({ size = 16 }) => {
    const auth = (window.useAuth ? window.useAuth() : { user: null });
    const user = auth.user;
    const email = user?.email;
    const [saved, setSaved] = useState(() => !!(email && window.__dsIsNewsSaved && window.__dsIsNewsSaved(email, news.id)));
    useEffect(() => {
      setSaved(!!(email && window.__dsIsNewsSaved && window.__dsIsNewsSaved(email, news.id)));
      const h = () => setSaved(!!(email && window.__dsIsNewsSaved && window.__dsIsNewsSaved(email, news.id)));
      window.addEventListener('ds-saved-news-changed', h);
      return () => window.removeEventListener('ds-saved-news-changed', h);
    }, [email, news.id]);
    const onClickSave = (e) => {
      e.preventDefault(); e.stopPropagation();
      if (!user) { auth.openSignUp && auth.openSignUp(); return; }
      // Сохраняем минимальный набор полей чтобы блок показал обложку и название
      const payload = {
        id: news.id, title: title, excerpt: excerpt,
        coverImage: coverUrl, date: news.date || news.published_at, category: news.category
      };
      window.__dsToggleSavedNews && window.__dsToggleSavedNews(email, payload);
    };
    return (
      <button onClick={onClickSave}
        aria-label={saved ? 'Убрать из сохранённых' : 'Сохранить новость'}
        title={saved ? 'Убрать из сохранённых' : 'Сохранить в плейлист'}
        style={{
          width: size + 12, height: size + 12, padding: 0,
          background: saved ? 'rgba(20,135,203,0.18)' : 'rgba(255,255,255,0.10)',
          border: `1px solid ${saved ? '#1487cb' : 'rgba(255,255,255,0.22)'}`,
          color: saved ? '#1487cb' : 'currentColor',
          borderRadius: 999, cursor: 'pointer',
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          transition: 'all 0.15s', flexShrink: 0
        }}>
        <svg width={size} height={size} viewBox="0 0 24 24" fill={saved ? 'currentColor' : 'none'}
          stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
        </svg>
      </button>
    );
  };

  if (variant === 'hero') {
    return (
      <div onClick={onClick} style={{
        position: 'relative', width: 440, height: 200,
        margin: '14px 0 70px 0', borderRadius: 12, overflow: 'hidden',
        backgroundImage: coverUrl ? `url(${coverUrl})` : 'linear-gradient(135deg,#2c3e50 0%, #4a6178 100%)',
        // 'top center' — чтобы был виден верх фото (лица актёров), а не центр
        // (на портретных коллажах yesasia.ru центр часто приходится на одежду/руки).
        backgroundSize: 'cover', backgroundPosition: 'top center',
        boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
        cursor: onClick ? 'pointer' : 'default'
      }}>
        <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(90deg, rgba(15,25,35,0.92) 0%, rgba(15,25,35,0.7) 45%, rgba(15,25,35,0.25) 85%, transparent 100%)' }} />
        <div style={{ position: 'absolute', top: 14, right: 14, zIndex: 2, color: '#fff' }}>
          <SaveBookmark size={16} />
        </div>
        <div style={{ position: 'absolute', left: 18, right: 18, top: 18, bottom: 14, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
          <div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
              <div style={{ color: '#9fe9e0', fontSize: 11, fontWeight: 700, letterSpacing: 1.2 }}>NEWS</div>
              <HotBadge />
            </div>
            <div style={{ color: '#fff', fontSize: 16, fontWeight: 700, lineHeight: 1.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', marginBottom: 8 }}>{title}</div>
            {excerpt && (
              <div style={{ color: 'rgba(220,225,235,0.82)', fontSize: 13, lineHeight: 1.45, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{excerpt}</div>
            )}
          </div>
          <div style={{ color: 'rgba(220,225,235,0.78)', fontSize: 11, display: 'flex', alignItems: 'center', gap: 14 }}>
            <span>{fmtNewsDate(news.date || news.published_at)}</span>
            {/* Лайки и комменты — показываем ТОЛЬКО если есть реальные числа
                из API (> 0). Заглушки 31/23 убраны — пока счётчики на backend
                не реализованы, метаданные просто не отображаются. */}
            {news.likes > 0 && (
              <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
                <span style={{ color: '#ff7a8a' }}>♥</span><span>{news.likes}</span>
              </span>
            )}
            {news.comments > 0 && (
              <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
                💬 <span>{news.comments}</span>
              </span>
            )}
          </div>
        </div>
      </div>
    );
  }

  return (
    <div onClick={onClick} style={{
      padding: '10px 0', borderBottom: '1px solid rgba(0,0,0,0.06)',
      cursor: onClick ? 'pointer' : 'default',
      display: 'flex', gap: 8, alignItems: 'flex-start'
    }}>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
          <span style={{ color: '#6b6b6b', fontSize: 9.5, fontWeight: 700, letterSpacing: 0.6, textTransform: 'uppercase' }}>{news.category}</span>
          <HotBadge />
        </div>
        <div style={{ color: '#1f1f1f', fontSize: 13, fontWeight: 600, lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{title}</div>
        <div style={{ color: '#8a8a8a', fontSize: 10.5, marginTop: 4 }}>{fmtNewsDate(news.date || news.published_at)}</div>
      </div>
      <SaveBookmark size={14} />
    </div>
  );
}

// ── FEATURED HERO ─────────────────────────────────────────────────────────────
function DSFeatured({ item, onSelect, onExplore, onActors, onAI }) {
  const [imgErr, setImgErr] = useState(false);
  // Подтягиваем свежие новости из D1 через /api/news. Fallback на static
  // data/news.json если API не отвечает. Это чтобы новость, добавленная через
  // админку, сразу появлялась в hero (раньше шёл хардкод-чтение JSON).
  const [latestNews, setLatestNews] = useState(window.__dsNews || null);
  useEffect(() => {
    const apiBase = (typeof window !== 'undefined' && window.__dsApi?.base) || '';
    const url = apiBase ? `${apiBase}/api/news?limit=20` : '/data/news.json?t=' + Date.now();
    fetch(url)
      .then(r => r.ok ? r.json() : null)
      .then(data => {
        // Worker может вернуть {items: [...]} или просто [...]
        const arr = Array.isArray(data) ? data : (data && Array.isArray(data.items) ? data.items : null);
        if (arr && arr.length) {
          const sorted = [...arr].sort((a, b) => (b.date || b.published_at || '').localeCompare(a.date || a.published_at || ''));
          window.__dsNews = sorted;
          setLatestNews(sorted);
        }
      })
      .catch(() => {
        // Fallback на статический JSON если API упал
        fetch('/data/news.json?t=' + Date.now())
          .then(r => r.ok ? r.json() : null)
          .then(arr => {
            if (Array.isArray(arr) && arr.length) {
              const sorted = [...arr].sort((a, b) => (b.date || '').localeCompare(a.date || ''));
              window.__dsNews = sorted;
              setLatestNews(sorted);
            }
          })
          .catch(() => {});
      });
  }, []);

  // ── HERO CAROUSEL ────────────────────────────────────────────────────────
  // Читаем data/hero-slides.json: [{image, dramaId}].
  // Меняются только фон и плашка справа (top-drama). Всё остальное статично.
  // Картинки одни и те же для day/night — оригинальные фоны хорошо смотрятся в обеих темах.
  const [slides, setSlides] = useState(null);
  const [slideIdx, setSlideIdx] = useState(0);
  const slideTimerRef = useRef(null);
  const [slideDramas, setSlideDramas] = useState({}); // {dramaId: drama}

  useEffect(() => {
    fetch('/data/hero-slides.json?t=' + Date.now())
      .then(r => r.ok ? r.json() : null)
      .then(arr => {
        if (Array.isArray(arr) && arr.length) setSlides(arr);
      })
      .catch(() => {});
  }, []);

  // Подгружаем дораму для каждого slide.dramaId, чтобы плашка справа знала title/poster/rating.
  // Триггеримся не только на изменение slides, но и на 'ds-full-data-loaded' — иначе при первой
  // загрузке (до того как фронт получил полный каталог) fetchDrama возвращает null и активный
  // слайд фолбэчится на случайную дораму ("Король пустыни" эффект).
  useEffect(() => {
    const load = () => {
      if (!slides || !slides.length) return;
      const need = slides.filter(s => s.dramaId && !slideDramas[s.dramaId]);
      if (!need.length) return;
      Promise.all(need.map(s => window.adapter.fetchDrama(s.dramaId).catch(() => null))).then(results => {
        const next = { ...slideDramas };
        results.forEach((d, i) => { if (d) next[need[i].dramaId] = d; });
        setSlideDramas(next);
      });
    };
    load();
    window.addEventListener('ds-full-data-loaded', load);
    return () => window.removeEventListener('ds-full-data-loaded', load);
  }, [slides, slideDramas]);

  // Авто-ротация — каждые 7с. Стоп если только 1 слайд.
  useEffect(() => {
    if (!slides || slides.length < 2) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    slideTimerRef.current = setInterval(() => {
      setSlideIdx(i => (i + 1) % slides.length);
    }, 7000);
    return () => clearInterval(slideTimerRef.current);
  }, [slides]);

  const goSlide = (n) => {
    if (!slides || !slides.length) return;
    clearInterval(slideTimerRef.current);
    setSlideIdx(((n % slides.length) + slides.length) % slides.length);
    // Перезапустить таймер
    if (slides.length > 1) {
      slideTimerRef.current = setInterval(() => {
        setSlideIdx(i => (i + 1) % slides.length);
      }, 7000);
    }
  };

  // Определяем активный фон и активную дораму для плашки.
  // НЕ фолбэчимся на item (родительская дорама) — иначе пока slideDramas не загружены
  // плашка показывает чужую дораму. Лучше пустой объект и условный рендер ниже.
  const activeSlide = (slides && slides[slideIdx]) || null;
  const activeDrama = activeSlide ? (slideDramas[activeSlide.dramaId] || {}) : item;
  const backdropUrl = activeSlide ? '/' + activeSlide.image.replace(/^\.?\/?/, '') : "/assets/cherry-blossom-banner.png";

  const year = (activeDrama.firstAirDate || '').slice(0, 4);
  const { lang, t } = (window.useI18n ? window.useI18n() : { lang: 'en', t: (s) => s });
  const synopsis = ((lang === 'ru' && activeDrama.overview_ru) ? activeDrama.overview_ru : activeDrama.overview_en) || '';
  const short = synopsis.length > 180 ? synopsis.slice(0, 180) + '…' : synopsis;
  const displayTitle = (lang === 'ru' && activeDrama.title_ru) ? activeDrama.title_ru : (activeDrama.title_en || activeDrama.title_pinyin || activeDrama.title_original || '');
  const miniPoster = window.adapter.imageUrl(activeDrama.posterPath, 'w185');

  return (
    <div className="ds-hero-banner" style={{ position: 'relative', width: '100%', height: 'clamp(280px,35vw,560px)', overflow: 'hidden' }}>
      {/* Слои фонов карусели — все рендерим, активный с opacity:1 (плавный fade) */}
      {slides && slides.length > 0 ? (
        <div className="ds-hero-slides-wrap" style={{ position: 'absolute', inset: 0 }}>
          {slides.map((s, i) => (
            <img
              key={s.image + i}
              src={'/' + s.image.replace(/^\.?\/?/, '')}
              alt=""
              className="ds-backdrop"
              style={{
                position: 'absolute', inset: 0,
                width: '100%', height: '100%',
                objectFit: 'cover', objectPosition: 'center 28%',
                opacity: i === slideIdx ? 1 : 0,
                transition: 'opacity 1s ease-in-out'
              }}
            />
          ))}
        </div>
      ) : backdropUrl ? (
        <img src={backdropUrl} alt="" onError={() => setImgErr(true)} className="ds-backdrop" style={{ width: '100%', height: '100%', display: 'block' }} />
      ) : (
        <DSPlaceholderBackdrop seed={(activeDrama.id || 1) % 3 + 1} style={{ position: 'absolute', inset: 0 }} />
      )}

      {/* Карусель: точки-индикаторы + стрелки prev/next (только если слайдов > 1) */}
      {slides && slides.length > 1 && (
        <>
          <button
            className="ds-hero-slide-arrow"
            onClick={() => goSlide(slideIdx - 1)}
            aria-label="Previous"
            style={{
              position: 'absolute', left: 20, top: '50%', transform: 'translateY(-50%)', zIndex: 6,
              width: 44, height: 44, borderRadius: '50%',
              background: 'rgba(0,0,0,0.32)', border: '1px solid rgba(255,255,255,0.22)',
              color: '#fff', fontSize: 22, lineHeight: 1, opacity: 0.65,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              backdropFilter: 'blur(4px)', cursor: 'pointer', transition: 'opacity 0.2s'
            }}
            onMouseEnter={(e) => e.currentTarget.style.opacity = 1}
            onMouseLeave={(e) => e.currentTarget.style.opacity = 0.65}
          >‹</button>
          <button
            className="ds-hero-slide-arrow"
            onClick={() => goSlide(slideIdx + 1)}
            aria-label="Next"
            style={{
              position: 'absolute', right: 20, top: '50%', transform: 'translateY(-50%)', zIndex: 6,
              width: 44, height: 44, borderRadius: '50%',
              background: 'rgba(0,0,0,0.32)', border: '1px solid rgba(255,255,255,0.22)',
              color: '#fff', fontSize: 22, lineHeight: 1, opacity: 0.65,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              backdropFilter: 'blur(4px)', cursor: 'pointer', transition: 'opacity 0.2s'
            }}
            onMouseEnter={(e) => e.currentTarget.style.opacity = 1}
            onMouseLeave={(e) => e.currentTarget.style.opacity = 0.65}
          >›</button>
          <div className="ds-hero-slide-dots" style={{
            position: 'absolute', left: '50%', bottom: 18, transform: 'translateX(-50%)', zIndex: 6,
            display: 'flex', gap: 8, padding: '6px 10px',
            background: 'rgba(0,0,0,0.18)', borderRadius: 999, backdropFilter: 'blur(6px)'
          }}>
            {slides.map((_, i) => (
              <button
                key={i}
                onClick={() => goSlide(i)}
                aria-label={'Slide ' + (i + 1)}
                style={{
                  width: i === slideIdx ? 30 : 22, height: 3, borderRadius: 2, padding: 0, border: 0,
                  background: i === slideIdx ? '#fff' : 'rgba(255,255,255,0.55)',
                  cursor: 'pointer', transition: 'background 0.2s, width 0.2s'
                }}
              />
            ))}
          </div>
        </>
      )}

      {/* Upgrade to Premium — top-right corner of hero. Скрыт до запуска монетизации.
          Чтобы вернуть: window.__dsPremiumEnabled = true (в index.html). */}
      {window.__dsPremiumEnabled && (
      <button className="ds-hero-premium" onClick={() => window.__dsGoPage && window.__dsGoPage('premium')} style={{
        position: 'absolute', top: 99, right: 32, zIndex: 5,
        display: 'flex', alignItems: 'center', gap: 7,
        padding: '9px 16px', borderRadius: 999,
        background: 'linear-gradient(135deg, rgba(34,22,62,0.85) 0%, rgba(58,40,104,0.85) 100%)',
        color: '#ffd770',
        fontSize: 13, fontWeight: 700,
        border: '1px solid rgba(255,215,112,0.45)',
        boxShadow: '0 4px 18px rgba(0,0,0,0.4), inset 0 0 0 1px rgba(255,215,112,0.08)',
        backdropFilter: 'blur(10px)', WebkitBackdropFilter: 'blur(10px)',
        cursor: 'pointer', letterSpacing: '0.2px'
      }}>
        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
          <path d="M3 18l2-11 5 6 2-9 2 9 5-6 2 11z"/>
          <path d="M3 21h18"/>
        </svg>
        {t('Upgrade to Premium')}
      </button>
      )}
      <div className="ds-hero-overlay" style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to right, rgba(255,247,241,0.45) 0%, rgba(255,247,241,0.18) 45%, rgba(255,247,241,0.05) 80%, transparent 100%)' }} />
      <div className="ds-hero-overlay" style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(251,239,230,0.85) 0%, rgba(251,239,230,0.15) 30%, transparent 60%)' }} />

      <div className="ds-hero-content" style={{ position: 'absolute', bottom: 0, left: 0, padding: '0 48px 52px', maxWidth: 680 }}>
        <NewsCard news={(latestNews || window.NEWS_DATA || [])[0]} variant="hero" onClick={() => window.__dsGoPage && window.__dsGoPage('news')} />
        {/* CTA-кнопки (Все дорамы / Все актёры / Спросить Мао) скрыты по просьбе Marina —
            display: none перебивает все CSS-правила. Чтобы вернуть: убрать display:'none'. */}
        <div className="ds-hero-cta-row" style={{ display: 'none', gridTemplateColumns: '1fr 1fr', gap: 10, width: 440, maxWidth: '100%' }}>
          <button onClick={onExplore} className="ds-btn-primary" style={{ fontSize: 13.5, justifyContent: 'center' }}>{t('Explore Dramas')}</button>
          <button onClick={onActors} className="ds-btn-secondary" style={{ fontSize: 13.5, justifyContent: 'center' }}>{t('Browse Actors')}</button>
          <button onClick={onAI} className="ds-ai-recommend" style={{
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 16, gridColumn: 'span 2'
          }}>✦ {t('Ask Mao for Recommendations')}</button>
        </div>

      </div>

      {/* Top Drama mini card — right-bottom corner of hero. Меняется вместе с фоном карусели. */}
      <div className="ds-hero-topdrama" style={{ position: 'absolute', right: 32, bottom: 52, display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderRadius: 12, border: '1px solid rgba(140,180,235,0.18)', maxWidth: 360, cursor: 'pointer', background: 'rgba(20, 32, 61, 0.85)', color: '#eef3ff', boxShadow: '0 6px 22px rgba(0,0,0,0.45)', backdropFilter: 'blur(8px)', transition: 'opacity 0.4s' }}
      onClick={() => onSelect(activeDrama)}>
        <div style={{ width: 40, height: 40, borderRadius: 6, overflow: 'hidden', flexShrink: 0 }}>
          {miniPoster ?
          <img src={miniPoster} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> :
          <DSPlaceholderPoster seed={(activeDrama.id || 1) % 9 + 1} width="40" height="40" />
          }
        </div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{display:'flex', alignItems:'center', gap:8, marginBottom:2}}>
            <span style={{
              display:'inline-flex',
              alignItems:'center',
              gap:5,
              padding:'2px 7px',
              borderRadius:999,
              background:'rgba(94, 215, 198, 0.22)',
              border:'1px solid rgba(94, 215, 198, 0.55)',
              color:'#9fe9e0',
              fontSize:9,
              fontWeight:700,
              letterSpacing:'0.5px',
              textTransform:'uppercase',
              lineHeight:1
            }}>
              <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
                <path d="M7 4h10v5a5 5 0 0 1-10 0V4z"/>
                <path d="M17 5h3v3a3 3 0 0 1-3 3"/>
                <path d="M7 5H4v3a3 3 0 0 0 3 3"/>
                <path d="M12 14v4"/>
                <path d="M9 18h6"/>
              </svg>
              Top Drama
            </span>
            <span style={{color:'#c0d4f5', fontSize:11, fontWeight:400, lineHeight:1}}>
              {year || '—'}
            </span>
          </div>
          <div style={{ fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayTitle}</div>
        </div>
        <span style={{ color: 'var(--accent)', fontSize: 12 }}>→</span>
      </div>
    </div>);

}

// ── DETAIL PAGE ───────────────────────────────────────────────────────────────
// Кнопка-закладка для всего саундтрека дорамы. По аналогии с FactBookmarkButton:
// localStorage + server sync. Активная — закрашена тиффани #0abab5.
function _OstBookmarkButton({ dramaId, lang }) {
  const useAuth = window.useAuth || (() => ({ user: null, openSignUp: () => {} }));
  const { user, openSignUp } = useAuth();
  const isRu = lang === 'ru';
  const key = `ds_fav_drama_osts_${user?.email || 'guest'}`;
  const [ids, setIds] = useState(() => {
    try { return JSON.parse(localStorage.getItem(key)) || []; } catch { return []; }
  });
  useEffect(() => {
    const refresh = () => {
      try { setIds(JSON.parse(localStorage.getItem(key)) || []); } catch {}
    };
    refresh();
    window.addEventListener('ds-fav-drama-osts-changed', refresh);
    window.addEventListener('storage', refresh);
    return () => {
      window.removeEventListener('ds-fav-drama-osts-changed', refresh);
      window.removeEventListener('storage', refresh);
    };
  }, [key]);
  const isFav = ids.includes(Number(dramaId));
  const toggle = async () => {
    if (!user) { openSignUp(); return; }
    const next = isFav ? ids.filter(x => x !== Number(dramaId)) : [...ids, Number(dramaId)];
    try { localStorage.setItem(key, JSON.stringify(next)); } catch {}
    window.dispatchEvent(new CustomEvent('ds-fav-drama-osts-changed'));
    try {
      const apiBase = (window.__dsApi?.base || window.DS_API_BASE || '');
      if (!apiBase) return;
      const csrf = window.__dsCsrf || '';
      await fetch(`${apiBase}/api/favorites/drama-osts/${dramaId}`, {
        method: isFav ? 'DELETE' : 'POST',
        credentials: 'include',
        headers: csrf ? { 'X-CSRF-Token': csrf } : {},
      });
    } catch (e) { console.warn('[fav drama-osts sync]', e); }
  };
  const tip = !user
    ? (isRu ? 'Войди, чтобы сохранить' : 'Sign in to save')
    : isFav ? (isRu ? 'Убрать из библиотеки' : 'Remove from library')
            : (isRu ? 'Сохранить саундтрек' : 'Save soundtrack');
  return (
    <button
      onClick={toggle}
      title={tip}
      aria-label={tip}
      style={{
        width: 30, height: 30, borderRadius: '50%',
        background: isFav ? '#0abab5' : 'rgba(0,0,0,0.04)',
        color: isFav ? '#fff' : '#475569',
        border: 'none', cursor: 'pointer', flexShrink: 0,
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        transition: 'all 0.15s',
      }}
      onMouseEnter={e => { if (!isFav) e.currentTarget.style.background = 'rgba(10,186,181,0.10)'; }}
      onMouseLeave={e => { if (!isFav) e.currentTarget.style.background = 'rgba(0,0,0,0.04)'; }}
    >
      <svg width="14" height="14" viewBox="0 0 24 24"
        fill={isFav ? 'currentColor' : 'none'}
        stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
        <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
      </svg>
    </button>
  );
}

// ── DramaSoundtrackSection ───────────────────────────────────────────────
// Таб «Саундтрек» на странице дорамы. Переключатель плееров YouTube/Spotify/
// Apple Music (только те что есть в БД). YouTube — дефолт, у всех работает.
// Если активный embed не загрузился (РФ + Spotify, например) — автопереключение
// на YouTube + тихое сообщение «недоступно в твоём регионе».
function DramaSoundtrackSection({ items, lang, dramaId }) {
  if (!items || items.length === 0) {
    return (
      <div style={{ padding: '48px 0', color: 'var(--text3)', textAlign: 'center', fontSize: 14 }}>
        {lang === 'ru' ? 'Саундтрек не добавлен' : 'No soundtrack yet'}
      </div>
    );
  }
  const PLATFORM_LABELS = {
    youtube: 'YouTube',
    soundcloud: 'SoundCloud',
    spotify: 'Spotify',
    apple_music: 'Apple Music',
    yandex: 'Яндекс.Музыка',
  };
  const PLATFORM_ORDER = ['youtube', 'soundcloud', 'yandex', 'spotify', 'apple_music'];
  const grouped = {};
  for (const it of items) grouped[it.platform] = it;
  const available = PLATFORM_ORDER.filter(p => grouped[p]);

  // По умолчанию активен YouTube (работает у всех), либо первый доступный.
  const [active, setActive] = useState(grouped.youtube ? 'youtube' : available[0]);
  const [iframeFailed, setIframeFailed] = useState(false);

  const current = grouped[active];
  if (!current) return null;

  // URL embed по платформе
  const buildEmbedUrl = (item) => {
    if (item.platform === 'spotify') {
      // open.spotify.com/embed/(playlist|album|track)/{id}
      const kind = item.embed_kind || 'playlist';
      return `https://open.spotify.com/embed/${kind}/${item.embed_id}?utm_source=generator`;
    }
    if (item.platform === 'apple_music') {
      // embed.music.apple.com/{country}/(album|playlist|song)/{any}/{id}
      const kind = item.embed_kind === 'track' ? 'song' : (item.embed_kind || 'album');
      const country = item.embed_country || 'us';
      return `https://embed.music.apple.com/${country}/${kind}/_/${item.embed_id}`;
    }
    if (item.platform === 'yandex') {
      // music.yandex.ru/iframe/#album/{id}
      // music.yandex.ru/iframe/#playlist/{login}/{kind}  (embed_id хранится как "login/kind")
      // music.yandex.ru/iframe/#track/{trackId}/{albumId} (embed_id хранится как "trackId/albumId")
      const kind = item.embed_kind || 'album';
      return `https://music.yandex.ru/iframe/#${kind}/${item.embed_id}`;
    }
    if (item.platform === 'soundcloud') {
      // w.soundcloud.com/player/?url=encoded(https://soundcloud.com/{path})
      const fullUrl = `https://soundcloud.com/${item.embed_id}`;
      const enc = encodeURIComponent(fullUrl);
      return `https://w.soundcloud.com/player/?url=${enc}&color=%23007aff&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false&visual=false`;
    }
    // YouTube — playlist или single video
    if (item.embed_kind === 'playlist') {
      return `https://www.youtube-nocookie.com/embed/videoseries?list=${item.embed_id}&rel=0`;
    }
    return `https://www.youtube-nocookie.com/embed/${item.embed_id}?rel=0`;
  };

  // Высота плеера зависит от платформы
  const playerHeight = current.platform === 'spotify' ? 380
                     : current.platform === 'apple_music' ? 450
                     : current.platform === 'yandex' ? 450
                     : current.platform === 'soundcloud' ? (current.embed_kind === 'playlist' ? 450 : 166)
                     : 0;  // YouTube — aspect ratio 16:9 (paddingBottom: 56.25%)

  const switchTab = (p) => {
    setActive(p);
    setIframeFailed(false);
  };

  return (
    <div style={{ marginTop: 28, paddingBottom: 32 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#007aff' }}>
          <path d="M9 17V5l12-2v12"/>
          <circle cx="6" cy="17" r="3"/>
          <circle cx="18" cy="15" r="3"/>
        </svg>
        <h3 style={{ fontSize: 15, fontWeight: 700, color: 'var(--text2)', margin: 0 }}>
          {lang === 'ru' ? 'Саундтрек' : 'Soundtrack'}
        </h3>
        {current.title_ru || current.title_en ? (
          <span style={{ fontSize: 13, color: 'var(--text3)' }}>
            · {lang === 'ru' ? (current.title_ru || current.title_en) : (current.title_en || current.title_ru)}
            {current.artist ? ` — ${current.artist}` : ''}
          </span>
        ) : null}
        {/* Кнопка-закладка для всего саундтрека дорамы */}
        {dramaId && <_OstBookmarkButton dramaId={dramaId} lang={lang} />}
      </div>

      {/* Platform switcher — pill tabs, видна только если >1 платформы */}
      {available.length > 1 && (
        <div style={{ display: 'flex', gap: 6, marginBottom: 14, flexWrap: 'wrap' }}>
          {available.map(p => (
            <button
              key={p}
              onClick={() => switchTab(p)}
              style={{
                padding: '7px 14px', borderRadius: 999,
                fontSize: 12, fontWeight: 600,
                background: active === p ? '#007aff' : 'rgba(0,0,0,0.04)',
                color: active === p ? '#fff' : 'var(--text2)',
                border: 'none', cursor: 'pointer', transition: 'all 0.15s',
              }}
            >
              {PLATFORM_LABELS[p]}
            </button>
          ))}
        </div>
      )}

      {/* Embed */}
      <div style={{
        background: '#ffffff', borderRadius: 14, overflow: 'hidden',
        boxShadow: '0 1px 2px rgba(15,23,42,0.04), 0 12px 32px rgba(15,23,42,0.08)',
        maxWidth: 720,
      }}>
        {current.platform === 'youtube' ? (
          <div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, background: '#000' }}>
            <iframe
              key={current.embed_id}
              src={buildEmbedUrl(current)}
              title="Soundtrack"
              allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
              allowFullScreen
              onError={() => setIframeFailed(true)}
              style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
            />
          </div>
        ) : (
          <iframe
            key={`${current.platform}-${current.embed_id}`}
            src={buildEmbedUrl(current)}
            title="Soundtrack"
            allow="autoplay; clipboard-write; encrypted-media"
            allowFullScreen
            onError={() => {
              setIframeFailed(true);
              if (grouped.youtube && active !== 'youtube') switchTab('youtube');
            }}
            style={{
              width: '100%', height: playerHeight, border: 0,
              borderRadius: 14, background: '#fff',
            }}
          />
        )}
      </div>
      {iframeFailed && (
        <div style={{ marginTop: 10, fontSize: 12, color: 'var(--text3)' }}>
          {lang === 'ru' ? 'Плеер недоступен в твоём регионе — попробуй другой источник.' : 'Player unavailable in your region — try another source.'}
        </div>
      )}
    </div>
  );
}

function DSTrailerSection({ trailers }) {
  const [activeIdx, setActiveIdx] = useState(0);
  if (!trailers || trailers.length === 0) return null;
  const active = trailers[activeIdx];
  const PLAYER_W = 480;
  return (
    <div style={{ marginTop: 28 }}>
      <h3 style={{ fontSize: 15, fontWeight: 700, color: 'var(--text2)', marginBottom: 12 }}>Трейлеры</h3>
      <div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
        <div style={{ width: PLAYER_W, maxWidth: '100%', flexShrink: 0 }}>
          <div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, borderRadius: 10, overflow: 'hidden', background: '#000' }}>
            <iframe
              key={active.youtube_id}
              src={`https://www.youtube-nocookie.com/embed/${active.youtube_id}?rel=0`}
              title={active.title || 'Trailer'}
              allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
              allowFullScreen
              style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
            />
          </div>
          {active.title && active.title !== 'Trailer' &&
            <div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 6 }}>{active.title}</div>
          }
        </div>
        {trailers.length > 1 &&
          <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: PLAYER_W * 0.5625, overflowY: 'auto', minWidth: 180, flex: 1 }}>
            {trailers.map((t, i) =>
              <button key={t.youtube_id} onClick={() => setActiveIdx(i)} style={{
                display: 'flex', gap: 8, alignItems: 'center', padding: 4, borderRadius: 6,
                background: i === activeIdx ? 'rgba(94,215,198,0.12)' : 'var(--bg3)',
                border: `1px solid ${i === activeIdx ? 'var(--accent)' : 'transparent'}`,
                cursor: 'pointer', textAlign: 'left'
              }}>
                <img src={`https://i.ytimg.com/vi/${t.youtube_id}/mqdefault.jpg`} alt="" style={{ width: 56, height: 32, objectFit: 'cover', borderRadius: 3, flexShrink: 0 }} />
                <span style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.35 }}>{t.title || 'Trailer'}</span>
              </button>
            )}
          </div>
        }
      </div>
    </div>
  );
}

// ── JUSTIFIED PHOTO TILE ──────────────────────────────────────────────────────
// Один тайл сетки фото. Принимает URL, сам определяет соотношение сторон
// по натуральным размерам картинки и выставляет flex-basis пропорционально.
// Если isLast=true и hiddenCount>0 — поверх тайла рендерится тёмный overlay «+N».
function PhotoJustifiedTile({ url, isLast, hiddenCount, onClick, tileHeight = 180 }) {
  const [ratio, setRatio] = useState(16 / 9);
  return (
    <div onClick={onClick} style={{
      height: tileHeight,
      // Justified-gallery: flex-grow по соотношению сторон → ряды заполняют контейнер,
      // а соотношение между тайлами сохраняется. Пустого места справа в строке не остаётся.
      flex: `${ratio} 1 ${tileHeight * ratio * 0.7}px`,
      minWidth: tileHeight * 0.7,
      // Ограничиваем рост: одинокая картинка в последнем ряду не растягивается
      // на всю ширину — максимум ~1.4× от натуральной ширины при текущей высоте.
      maxWidth: tileHeight * ratio * 1.4,
      borderRadius: 10,
      overflow: 'hidden',
      cursor: 'pointer',
      position: 'relative',
      background: 'var(--bg3)',
      transition: 'transform 0.15s'
    }}
    onMouseEnter={(e) => { if (!isLast) e.currentTarget.style.transform = 'scale(1.015)'; }}
    onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}>
      <img src={url} alt=""
        onLoad={(e) => {
          const w = e.target.naturalWidth, h = e.target.naturalHeight;
          if (w && h) setRatio(w / h);
        }}
        style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
      {isLast && hiddenCount > 0 && (
        <div style={{
          position: 'absolute', inset: 0,
          background: 'rgba(0,0,0,0.62)',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          color: '#fff', fontSize: 28, fontWeight: 800, letterSpacing: '-0.5px',
          textShadow: '0 2px 8px rgba(0,0,0,0.4)'
        }}>+{hiddenCount}</div>
      )}
    </div>
  );
}

// ── DRAMA MEDIA SECTION ───────────────────────────────────────────────────────
// Два таба «Кадры» / «Доп. изображения» + сетка превью + лайтбокс.
// Использует drama.gallery (стилы → assets/dramas/gallery/{slug}/...)
// и drama.gallery_extras (доп. → assets/dramas/gallery-extras/{slug}/...).
function DramaMediaSection({ show, t, lang }) {
  const stills = show.gallery || [];
  const extras = show.gallery_extras || [];
  const posters = show.posters_alt || [];
  const tabs = [
    posters.length > 0 && { key: 'posters', label: lang === 'ru' ? 'Постеры' : 'Posters', folder: 'posters-alt', files: posters },
    stills.length > 0 && { key: 'stills', label: lang === 'ru' ? 'Кадры' : 'Stills', folder: 'gallery', files: stills },
    extras.length > 0 && { key: 'extras', label: lang === 'ru' ? 'Доп. изображения' : 'Extras', folder: 'gallery-extras', files: extras },
  ].filter(Boolean);
  const [tab, setTab] = useState(tabs[0]?.key);
  const [lbIdx, setLbIdx] = useState(null);
  const [expanded, setExpanded] = useState(false);

  const active = tabs.find(x => x.key === tab) || tabs[0];
  // Резолвер URL фото — синхронизирован с редактором (_photoFullUrl).
  // В БД элемент может быть полным URL (R2 https://… или /posters/…), путём
  // с ./, ИЛИ голым именем файла (poster-1.webp) для старых статических фото.
  // Голые имена префиксим /assets/dramas/<folder>/<slug>/, остальное — as-is.
  const photoUrl = (f) => {
    const u = (f && typeof f === 'object') ? (f.url || '') : String(f || '');
    if (!u) return '';
    if (/^https?:\/\//i.test(u) || u.startsWith('/') || u.startsWith('./')) return u;
    return `/assets/dramas/${active.folder}/${show.slug}/${u}`;
  };
  const urls = active ? active.files.map(photoUrl).filter(Boolean) : [];
  // Лимит «2 ряда» делается через maxHeight ниже, фото в массиве — все.

  // ⚠ Хуки должны вызываться ДО любого early return — иначе порядок хуков
  // прыгает между ререндерами и React валится с ошибкой, что ломает ВСЮ страницу
  // (в т.ч. навбар с кнопкой «Войти»).
  useEffect(() => {
    if (lbIdx === null) return;
    const onKey = (e) => {
      if (e.key === 'Escape') setLbIdx(null);
      else if (e.key === 'ArrowLeft') setLbIdx(i => (i - 1 + urls.length) % urls.length);
      else if (e.key === 'ArrowRight') setLbIdx(i => (i + 1) % urls.length);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [lbIdx, urls.length]);

  if (tabs.length === 0) return null;

  return (
    <div className="ds-media-section" style={{ marginTop: 48, marginBottom: 24 }}>
      <h2 style={{
        fontSize: 26, fontWeight: 800, marginBottom: 18, letterSpacing: '-0.5px',
        display: 'flex', alignItems: 'center', gap: 10
      }}>
        <span>{lang === 'ru' ? 'Медиа' : 'Media'}</span>
        <span style={{ fontSize: 15, fontWeight: 500, color: 'var(--text3)' }}>{urls.length}</span>
        <span style={{ fontSize: 18, color: 'var(--text3)', fontWeight: 500 }}>›</span>
      </h2>
      <div style={{ display: 'flex', gap: 8, marginBottom: 18, flexWrap: 'wrap' }}>
        {tabs.map(x => (
          <button key={x.key} onClick={() => { setTab(x.key); setExpanded(false); }} style={{
            padding: '8px 18px',
            borderRadius: 999,
            background: tab === x.key ? 'var(--text)' : 'rgba(140,180,235,0.06)',
            color: tab === x.key ? 'var(--bg)' : 'var(--text2)',
            fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer',
            transition: 'all 0.15s'
          }}>{x.label}</button>
        ))}
      </div>
      {/* Justified gallery: тайлы одинаковой высоты, ширина по натуральному соотношению.
          Изначально показываем только 2 ряда (maxHeight), остальное под стрелкой ↓. */}
      <div style={{ position: 'relative' }}>
        <div className="ds-media-grid" style={{
          display: 'flex',
          flexWrap: 'wrap',
          gap: 10,
          maxHeight: expanded ? 'none' : 370,    // 2 ряда по ~180px + gap 10
          overflow: 'hidden',
          transition: 'max-height 0.3s'
        }}>
          {urls.map((u, i) => (
            <PhotoJustifiedTile key={u} url={u} onClick={() => setLbIdx(i)} />
          ))}
        </div>
        {/* Fade поверх нижнего края — повыше и плотнее к низу, цвет совпадает
            с фоном секции (.ds-drama-tabs-section). В night fallback на var(--bg). */}
        {!expanded && urls.length > 4 && (
          <div style={{
            position: 'absolute', left: 0, right: 0, bottom: 0,
            height: 180, pointerEvents: 'none',
            background: 'linear-gradient(to bottom, transparent 0%, var(--media-fade-color, var(--bg)) 92%, var(--media-fade-color, var(--bg)) 100%)'
          }} />
        )}
      </div>
      {urls.length > 4 && (
        <div style={{ marginTop: 14, display: 'flex', justifyContent: 'center' }}>
          <button onClick={() => setExpanded(v => !v)}
            aria-label={expanded ? (lang === 'ru' ? 'Свернуть' : 'Collapse') : (lang === 'ru' ? 'Показать ещё' : 'Show more')}
            title={expanded ? (lang === 'ru' ? 'Свернуть' : 'Collapse') : (lang === 'ru' ? `Показать ещё (всего ${urls.length})` : `Show all ${urls.length}`)}
            style={{
              width: 44, height: 44, borderRadius: '50%',
              background: 'rgba(8,12,26,0.85)',
              border: '1px solid rgba(140,180,235,0.30)',
              color: '#fff', fontSize: 20, lineHeight: 1, padding: 0,
              cursor: 'pointer',
              boxShadow: '0 4px 14px rgba(0,0,0,0.4)',
              transition: 'transform 0.15s',
              display: 'flex', alignItems: 'center', justifyContent: 'center'
            }}
            onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.08)'; }}
            onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}>
            {expanded ? '↑' : '↓'}
          </button>
        </div>
      )}
      {lbIdx !== null && (
        <div onClick={() => setLbIdx(null)} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          {urls.length > 1 && (
            <button onClick={(e) => { e.stopPropagation(); setLbIdx(i => (i - 1 + urls.length) % urls.length); }}
              style={{ position: 'absolute', left: 20, top: '50%', transform: 'translateY(-50%)', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '50%', width: 48, height: 48, fontSize: 22, cursor: 'pointer', zIndex: 2 }}>‹</button>
          )}
          {/* Обёртка вокруг фото — кнопка ✕ позиционируется относительно фото,
              а не вьюпорта, чтобы быть прямо в правом-верхнем углу картинки. */}
          <div onClick={(e) => e.stopPropagation()} style={{ position: 'relative', display: 'inline-block', maxWidth: '90vw', maxHeight: '90vh' }}>
            <img src={urls[lbIdx]} alt="" style={{ display: 'block', maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 6 }} />
            <button onClick={(e) => { e.stopPropagation(); setLbIdx(null); }}
              aria-label="Close"
              style={{ position: 'absolute', top: 12, right: 12, zIndex: 3,
                background: 'rgba(0,0,0,0.65)', color: '#fff',
                border: '1px solid rgba(255,255,255,0.35)',
                borderRadius: '50%', width: 40, height: 40, fontSize: 22, lineHeight: 1,
                cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
                backdropFilter: 'blur(8px)' }}>×</button>
          </div>
          {urls.length > 1 && (
            <button onClick={(e) => { e.stopPropagation(); setLbIdx(i => (i + 1) % urls.length); }}
              style={{ position: 'absolute', right: 20, top: '50%', transform: 'translateY(-50%)', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '50%', width: 48, height: 48, fontSize: 22, cursor: 'pointer', zIndex: 2 }}>›</button>
          )}
          <div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', color: 'rgba(255,255,255,0.7)', fontSize: 12 }}>{lbIdx + 1} / {urls.length}</div>
        </div>
      )}
    </div>
  );
}

// ── RELATED WORK CARD ─────────────────────────────────────────────────────────
// Карточка для одной записи из show.related_works. Если заметка длинная —
// скрывается под лёгкий fade-кат с раскрывающей стрелкой.
function DramaRelatedWorkCard({ item, color, lang }) {
  const [expanded, setExpanded] = useState(false);
  const NOTE_LIMIT = 110;
  const noteLong = item.note && item.note.length > NOTE_LIMIT;
  const Wrapper = item.url ? 'a' : 'div';
  const linkProps = item.url ? { href: item.url, target: '_blank', rel: 'noreferrer' } : {};
  return (
    <Wrapper {...linkProps} style={{
      display: 'block',
      padding: '14px 16px',
      borderRadius: 12,
      background: `${color}10`,
      border: `1px solid ${color}40`,
      textDecoration: 'none',
      color: 'inherit',
      transition: 'transform 0.15s, background 0.15s'
    }}
    onMouseEnter={(e) => {
      if (item.url) {
        e.currentTarget.style.transform = 'translateY(-2px)';
        e.currentTarget.style.background = `${color}1f`;
      }
    }}
    onMouseLeave={(e) => {
      e.currentTarget.style.transform = 'none';
      e.currentTarget.style.background = `${color}10`;
    }}>
      <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', marginBottom: item.note ? 8 : 0 }}>
        {item.title}
        {item.url && <span style={{ fontSize: 11, color, marginLeft: 6, opacity: 0.7 }}>↗</span>}
      </div>
      {item.note && (
        <>
          <div style={{
            position: 'relative',
            fontSize: 12.5, color: 'var(--text2)', lineHeight: 1.6,
            maxHeight: expanded || !noteLong ? 'none' : 56,
            overflow: 'hidden'
          }}>
            {item.note}
            {!expanded && noteLong && (
              <div style={{
                position: 'absolute', left: 0, right: 0, bottom: 0,
                height: 40, pointerEvents: 'none',
                background: `linear-gradient(to top, ${color}1f 0%, transparent 100%)`
              }} />
            )}
          </div>
          {noteLong && (
            <button
              onClick={(e) => { e.preventDefault(); e.stopPropagation(); setExpanded(v => !v); }}
              style={{
                marginTop: 6,
                padding: '4px 10px',
                background: 'transparent',
                border: 'none',
                color, fontSize: 11, fontWeight: 600,
                display: 'inline-flex', alignItems: 'center', gap: 4,
                cursor: 'pointer'
              }}>
              {expanded ? (lang === 'ru' ? 'Свернуть' : 'Show less') : (lang === 'ru' ? 'Читать дальше' : 'Read more')}
              <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
                style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}>
                <polyline points="6 9 12 15 18 9" />
              </svg>
            </button>
          )}
        </>
      )}
      {item.url && !item.note && (
        <div style={{ fontSize: 11, color: 'var(--text3)', wordBreak: 'break-all', opacity: 0.7 }}>
          {item.url.replace(/^https?:\/\//, '').slice(0, 60)}{item.url.length > 60 ? '…' : ''}
        </div>
      )}
    </Wrapper>
  );
}

// ── DRAMA TABS NAV ────────────────────────────────────────────────────────────
// Горизонтальная панель табов: «Медиа / Видео / Каст / ...».
// Десктоп: обычная панель с зелёным баром под активным.
// Мобиль: небесно-голубая планка с тёмно-синим шрифтом и круглой кнопкой-стрелкой справа,
//         которая скроллит к следующим табам.
function DramaTabsNav({ tabs, active, onChange }) {
  const scrollRef = useRef(null);
  const [canLeft, setCanLeft] = useState(false);
  const [canRight, setCanRight] = useState(false);
  const check = () => {
    const el = scrollRef.current;
    if (!el) return;
    setCanLeft(el.scrollLeft > 4);
    setCanRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
  };
  useEffect(() => {
    check();
    const onR = () => check();
    window.addEventListener('resize', onR);
    return () => window.removeEventListener('resize', onR);
  }, [tabs.length]);
  const scrollRight = () => {
    scrollRef.current?.scrollBy({ left: 180, behavior: 'smooth' });
    setTimeout(check, 350);
  };
  const scrollLeft = () => {
    scrollRef.current?.scrollBy({ left: -180, behavior: 'smooth' });
    setTimeout(check, 350);
  };
  return (
    <div className="ds-drama-tabs-wrap" style={{ position: 'relative', marginTop: 36, marginBottom: 28 }}>
      <div ref={scrollRef} onScroll={check} className="ds-drama-tabs" style={{
        display: 'flex', gap: 32,
        borderBottom: '1px solid rgba(140,180,235,0.12)',
        overflowX: 'auto',
        WebkitOverflowScrolling: 'touch'
      }}>
        {tabs.map(t => {
          const isActive = active === t.key;
          return (
            <button key={t.key} onClick={() => onChange(t.key)}
              className={`ds-drama-tab${isActive ? ' is-active' : ''}`}
              style={{
                position: 'relative',
                padding: '10px 0 10px',
                fontSize: 13,
                fontWeight: isActive ? 700 : 500,
                color: isActive ? 'var(--text)' : 'var(--text2)',
                background: 'transparent',
                border: 'none',
                cursor: 'pointer',
                whiteSpace: 'nowrap',
                transition: 'color 0.15s'
              }}>
              {t.label}
              {isActive && <span className="ds-drama-tab-underline" style={{
                position: 'absolute', left: 0, right: 0, bottom: -1,
                height: 2, borderRadius: 2,
                background: '#1487cb'
              }} />}
            </button>
          );
        })}
      </div>
      {/* Стрелка ВЛЕВО — видна только на мобиле и только когда есть куда скроллить назад */}
      {canLeft && (
        <button
          onClick={scrollLeft}
          aria-label="Scroll tabs left"
          className="ds-drama-tabs-arrow-left"
        >
          ‹
        </button>
      )}
      {/* Стрелка ВПРАВО — видна только на мобиле и только когда есть куда скроллить вперёд */}
      {canRight && (
        <button
          onClick={scrollRight}
          aria-label="Scroll tabs right"
          className="ds-drama-tabs-arrow"
        >
          ›
        </button>
      )}
    </div>);
}

// Альтернативные названия (subscript + alt-titles). На мобиле жёстко 2 строки
// с многоточием — клик/тап раскрывает все строки. Это даёт предсказуемое
// положение рейтинговой плашки ниже (она не «плавает» от длины alt-блока).
function _AltBlock({ subscript, altTitles }) {
  const [expanded, setExpanded] = useState(false);
  const titlesStr = (altTitles || []).join(' · ');
  return (
    <div
      className={`ds-drama-altblock${expanded ? ' is-expanded' : ''}`}
      onClick={() => setExpanded(v => !v)}
      title={expanded ? 'Свернуть' : 'Показать полностью'}
      style={{ cursor: 'pointer' }}
    >
      {subscript && <div style={{ fontSize: 14, color: '#d8e2f5', marginTop: 4, textShadow: '0 1px 6px rgba(0,0,0,0.6)' }}>{subscript}</div>}
      {titlesStr && <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.65)', marginTop: 3, lineHeight: 1.55, textShadow: '0 1px 4px rgba(0,0,0,0.5)' }}>{titlesStr}</div>}
    </div>
  );
}

// ── Мао-чат про конкретную дораму ─────────────────────────────────────────
// Inline-компонент на странице дорамы. Использует серверный endpoint
// /api/mao/drama-chat который грузит саммари серий из D1 и зовёт OpenAI.
//
// Сначала проверяет /api/mao/drama-info/:id — есть ли вообще саммари. Если нет
// (для большинства дорам пока) — рендерит ничего. Если есть — кнопка «Спросить
// Мао об этой дораме», по клику разворачивается чат.
function _DramaMaoChat({ dramaId, dramaTitle }) {
  const { t, lang } = (window.useI18n ? window.useI18n() : { t: (s) => s, lang: 'ru' });
  const isRu = lang === 'ru';
  const [info, setInfo] = useState(null);
  const [open, setOpen] = useState(false);
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');
  const [loading, setLoading] = useState(false);
  const scrollRef = useRef(null);
  const apiBase = (typeof window !== 'undefined' && window.__dsApi?.base) || '';

  // Узнаём — есть ли у этой дорамы саммари. Без них кнопку не показываем.
  useEffect(() => {
    if (!dramaId || !apiBase) return;
    let cancelled = false;
    fetch(`${apiBase}/api/mao/drama-info/${dramaId}`)
      .then(r => r.ok ? r.json() : null)
      .then(d => { if (!cancelled && d) setInfo(d); })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [dramaId, apiBase]);

  useEffect(() => {
    if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  }, [messages]);

  // Открыли чат — приветствие
  useEffect(() => {
    if (open && messages.length === 0 && info?.has_summaries) {
      setMessages([{
        role: 'mao',
        text: isRu
          ? `Привет! Я Мао 🐾 Спрашивай меня про «${dramaTitle}» — я знаю серии ${info.min_ep}–${info.max_ep}. Что хочешь обсудить?`
          : `Hi! I'm Mao 🐾 Ask me about "${dramaTitle}" — I know episodes ${info.min_ep}–${info.max_ep}. What would you like to discuss?`
      }]);
    }
  }, [open]);

  if (!info || !info.has_summaries) return null;

  const send = async () => {
    const q = input.trim();
    if (!q || loading) return;
    setInput('');
    const newMsgs = [...messages, { role: 'user', text: q }];
    setMessages(newMsgs);
    setLoading(true);
    try {
      const r = await fetch(`${apiBase}/api/mao/drama-chat`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          dramaId,
          message: q,
          history: messages.slice(-8),
          lang
        }),
      });
      const data = await r.json();
      const reply = data?.reply || data?.message || (isRu
        ? 'Что-то у меня лапки заплетаются, попробуй ещё раз 🐾'
        : "I'm tongue-tied, try again 🐾");
      setMessages([...newMsgs, { role: 'mao', text: reply }]);
    } catch {
      setMessages([...newMsgs, { role: 'mao', text: isRu
        ? 'Связь с интернетом подвела… 🐾'
        : 'Lost connection… 🐾' }]);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{
      marginBottom: 18,
      border: '1px solid rgba(140,180,235,0.10)',
      borderRadius: 12,
      background: 'rgba(167,139,250,0.04)',
      overflow: 'hidden'
    }}>
      {!open ? (
        <button onClick={() => setOpen(true)} style={{
          width: '100%', padding: '14px 18px',
          background: 'transparent', border: 0, cursor: 'pointer',
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          fontSize: 14, fontWeight: 600, color: 'var(--text)'
        }}>
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
            <span style={{ fontSize: 22 }}>🐱</span>
            {isRu ? 'Спросить Мао об этой дораме' : 'Ask Mao about this drama'}
          </span>
          <span style={{ fontSize: 12, color: 'var(--text3)' }}>
            {isRu ? `знает серии ${info.min_ep}–${info.max_ep}` : `knows ep ${info.min_ep}–${info.max_ep}`}
          </span>
        </button>
      ) : (
        <div>
          <div style={{
            padding: '10px 14px', borderBottom: '1px solid rgba(140,180,235,0.08)',
            display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            fontSize: 13, fontWeight: 600, color: 'var(--text)'
          }}>
            <span>🐱 {isRu ? 'Мао' : 'Mao'} · {dramaTitle}</span>
            <button onClick={() => { setOpen(false); setMessages([]); }} style={{
              background: 'transparent', border: 0, fontSize: 18, cursor: 'pointer', color: 'var(--text3)', padding: 4
            }} aria-label="close">×</button>
          </div>
          <div ref={scrollRef} style={{
            maxHeight: 360, overflowY: 'auto', padding: '14px',
            display: 'flex', flexDirection: 'column', gap: 10
          }}>
            {messages.map((m, i) => (
              <div key={i} style={{
                alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
                maxWidth: '85%',
                padding: '8px 12px', borderRadius: 12,
                background: m.role === 'user' ? 'rgba(74,158,255,0.15)' : 'rgba(167,139,250,0.12)',
                color: 'var(--text)', fontSize: 13.5, lineHeight: 1.5,
                whiteSpace: 'pre-wrap'
              }}>{m.text}</div>
            ))}
            {loading && (
              <div style={{ alignSelf: 'flex-start', fontSize: 13, color: 'var(--text3)', padding: '8px 12px' }}>
                🐱 {isRu ? 'думаю…' : 'thinking…'}
              </div>
            )}
          </div>
          <div style={{ display: 'flex', gap: 8, padding: 10, borderTop: '1px solid rgba(140,180,235,0.08)' }}>
            <input
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
              placeholder={isRu ? 'Спроси про сюжет, героев, отношения…' : 'Ask about plot, characters…'}
              disabled={loading}
              style={{
                flex: 1, padding: '8px 12px', borderRadius: 8,
                border: '1px solid var(--border)', background: 'var(--bg2)',
                color: 'var(--text)', fontSize: 13
              }}
            />
            <button onClick={send} disabled={loading || !input.trim()} style={{
              padding: '8px 14px', borderRadius: 8, border: 0,
              background: 'var(--accent)', color: '#fff',
              fontSize: 13, fontWeight: 600,
              cursor: (loading || !input.trim()) ? 'not-allowed' : 'pointer',
              opacity: (loading || !input.trim()) ? 0.6 : 1
            }}>{isRu ? 'Спросить' : 'Send'}</button>
          </div>
        </div>
      )}
    </div>
  );
}
if (typeof window !== 'undefined') window._DramaMaoChat = _DramaMaoChat;

// ── Глобальный Set дорам у которых есть саммари в БД Мао ──────────────────
// Грузится один раз при первом mount любого компонента который использует
// _MaoIcon. Кеш в window.__dsMaoIds (Set<number>). Update — через событие
// 'ds-mao-ids-loaded' (когда новая дорама получила саммари — fronend сам
// перезагрузит, либо при reload страницы).
if (typeof window !== 'undefined' && !window.__dsMaoIds) {
  window.__dsMaoIds = null;       // Set когда загрузится, null до этого
  window.__dsMaoIdsLoading = false;
  window.__dsLoadMaoIds = () => {
    if (window.__dsMaoIdsLoading || window.__dsMaoIds) return;
    window.__dsMaoIdsLoading = true;
    const apiBase = window.__dsApi?.base || '';
    if (!apiBase) { window.__dsMaoIdsLoading = false; return; }
    fetch(`${apiBase}/api/mao/dramas-with-summaries`)
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        const ids = Array.isArray(d?.ids) ? d.ids : [];
        window.__dsMaoIds = new Set(ids.map(Number));
        try { window.dispatchEvent(new CustomEvent('ds-mao-ids-loaded', { detail: { ids } })); } catch {}
      })
      .catch(() => { window.__dsMaoIds = new Set(); })
      .finally(() => { window.__dsMaoIdsLoading = false; });
  };
}

// ── Иконка Мао (peeking cat из логотипа) — маленький бейдж рядом с названием
// дорамы. Показывается только если у дорамы есть саммари в БД. Tooltip —
// "Мао знает эту дораму, спроси в чате внизу".
function _MaoIcon({ dramaId, size = 22, marginLeft = 8 }) {
  const [tick, setTick] = useState(0);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    if (!window.__dsMaoIds && window.__dsLoadMaoIds) window.__dsLoadMaoIds();
    const onLoaded = () => setTick(n => n + 1);
    window.addEventListener('ds-mao-ids-loaded', onLoaded);
    return () => window.removeEventListener('ds-mao-ids-loaded', onLoaded);
  }, []);
  if (typeof window === 'undefined') return null;
  const ids = window.__dsMaoIds;
  if (!ids || !ids.has(Number(dramaId))) return null;
  const lang = window.__dsLang || 'ru';
  const tip = lang === 'ru'
    ? 'Мао знает эту дораму — спроси его в чате внизу справа 🐾'
    : 'Mao knows this drama — ask in the chat at bottom-right 🐾';
  return (
    <span
      title={tip}
      aria-label={tip}
      style={{
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        marginLeft, verticalAlign: 'middle', flexShrink: 0
      }}>
      {/* Детектив-Мао с лупой — Marina выбрала эту иконку 1 июня 2026.
          Одна версия для day/night (фиолетовая палитра работает на обеих темах). */}
      <img
        className="ds-mao-icon"
        src={`./assets/cats/cat-4.png?v=20260601`}
        alt=""
        style={{ width: size, height: size, objectFit: 'contain', display: 'block' }}
      />
    </span>
  );
}
if (typeof window !== 'undefined') window._MaoIcon = _MaoIcon;

// Глобальный bus для drama-контекста чата Мао.
// DSDetailPage публикует сюда {id, title, hasSummaries, minEp, maxEp} —
// MaoChat (внизу справа) подписывается через window.addEventListener
// и меняет приветствие + переключает endpoint на /api/mao/drama-chat.
if (typeof window !== 'undefined' && !window.__dsMaoContext) {
  window.__dsMaoContext = { current: null };
  window.__dsSetMaoContext = (ctx) => {
    window.__dsMaoContext.current = ctx;
    try { window.dispatchEvent(new CustomEvent('ds-mao-context', { detail: ctx })); } catch {}
  };
}

function DSDetailPage({ apiKey, itemId, onBack, onSelectActor, onToggleWatch, watchlist }) {
  // apiKey оставлен в сигнатуре для backwards-compat — App ещё его прокидывает.
  // В 4B-2 уберём и из сигнатуры, и из вызывающего кода.
  // ds-drama-updated bump: после save в попапе админки updateDramaInCache
  // dispatch'ит событие → инкрементим _adminTick → useAdapter перевычитывает
  // данные (фикс «изменения видны только через 5-10 мин», Marina 1 июня 2026).
  const [_adminTick, _setAdminTick] = useState(0);
  useEffect(() => {
    const onUpdated = (e) => {
      if (!e?.detail?.id || e.detail.id === itemId) _setAdminTick(t => t + 1);
    };
    window.addEventListener('ds-drama-updated', onUpdated);
    return () => window.removeEventListener('ds-drama-updated', onUpdated);
  }, [itemId]);
  const { data: show, loading } = useAdapter((a) => a.fetchDrama(itemId), [itemId, _adminTick]);

  // Публикуем drama-контекст для глобального MaoChat (внизу справа). Юзер
  // ВСЕГДА на странице дорамы — Мао знает про какую (для драм без саммари
  // отвечает заглушкой типа "ещё не смотрел, добавлю в библиотеку").
  // Подписка из MaoChat: window.addEventListener('ds-mao-context', e => ...)
  useEffect(() => {
    if (typeof window === 'undefined' || !window.__dsSetMaoContext) return;
    if (!show?.id) return;
    const apiBase = (window.__dsApi?.base) || '';
    const lang = (window.__dsLang || 'ru');
    const title = (lang === 'ru' && show.title_ru) ? show.title_ru
      : (show.title_en || show.title_original || show.title_pinyin || 'drama');
    let cancelled = false;
    // Узнаём есть ли саммари — это определит режим (real chat vs stub + log).
    const setCtx = (info) => {
      if (cancelled) return;
      window.__dsSetMaoContext({
        dramaId: show.id,
        title,
        hasSummaries: !!info?.has_summaries,
        minEp: info?.min_ep || null,
        maxEp: info?.max_ep || null,
        episodeCount: info?.episode_count || 0,
      });
    };
    if (apiBase) {
      fetch(`${apiBase}/api/mao/drama-info/${show.id}`)
        .then(r => r.ok ? r.json() : null)
        .then(setCtx)
        .catch(() => setCtx(null));
    } else {
      setCtx(null);
    }
    // Cleanup при уходе со страницы дорамы — сбрасываем контекст
    return () => {
      cancelled = true;
      if (window.__dsSetMaoContext) window.__dsSetMaoContext(null);
    };
  }, [show?.id]);

  // Similar — через адаптер getSimilar (правила Marina, май 2026: эксклюзивные
  // жанры — Boys, Girls, Bromance, History, Crime, Costume, Comedy, Romance —
  // должны совпадать; Boys/Girls дополнительно не утекают в чужие подборки).
  //
  // ВАЖНО: на старте грузится только top-80 дорам по popularity (см. _ensureLoaded).
  // Если current drama не в топ-80 (например "Безмолвное чтение"), её ещё нет
  // в кеше → getSimilar вернёт пусто. Поэтому пересчитываем, когда фоновая
  // загрузка полного каталога завершится (событие 'ds-full-data-loaded').
  const [_similarTick, _setSimilarTick] = useState(0);
  useEffect(() => {
    const onFull = () => _setSimilarTick(t => t + 1);
    window.addEventListener('ds-full-data-loaded', onFull);
    return () => window.removeEventListener('ds-full-data-loaded', onFull);
  }, []);
  const { data: similar } = useAdapter(
    (a) => show?.id ? a.getSimilar(show.id, 20) : Promise.resolve({ results: [] }),
    [show?.id, _similarTick]
  );

  const { lang, t } = (window.useI18n ? window.useI18n() : { lang: 'en', t: (s) => s });
  const [fullOverview, setFullOverview] = useState(false);
  // Если в URL стоит ?tab=soundtrack — открыть таб саундтрека (используется
  // когда переходим из drawer-библиотеки «Любимые саундтреки»).
  const initialTab = (() => {
    try {
      const h = window.location.hash || '';
      const q = h.split('?')[1] || '';
      const p = new URLSearchParams(q);
      const t = p.get('tab');
      if (t === 'soundtrack' || t === 'media' || t === 'video' || t === 'cast' || t === 'related' || t === 'discussions' || t === 'reviews' || t === 'myrating' || t === 'facts') return t;
    } catch {}
    return 'cast';
  })();
  const [activeTab, setActiveTab] = useState(initialTab);

  // OST для дорамы — грузим один раз при смене show.id. Если список не пустой,
  // показываем таб «Саундтрек». См. worker/content.js → /api/ost/by-drama/:id.
  const [ostItems, setOstItems] = useState([]);
  const [ostLoaded, setOstLoaded] = useState(false);
  useEffect(() => {
    if (!show?.id) return;
    setOstItems([]); setOstLoaded(false);
    const apiBase = (window.__dsApi?.base || window.DS_API_BASE || '');
    if (!apiBase) { setOstLoaded(true); return; }
    fetch(`${apiBase}/api/ost/by-drama/${show.id}`)
      .then(r => r.ok ? r.json() : null)
      .then(j => { setOstItems(j?.items || []); })
      .catch(() => {})
      .finally(() => setOstLoaded(true));
  }, [show?.id]);

  // Факты для дорамы — таб «Факты» появляется только если есть факты,
  // где этот drama_id засветился: либо как related_drama_id, либо как
  // legacy topic='drama' + topic_id. См. worker/content.js → GET /api/facts?drama_id=X.
  const [factsItems, setFactsItems] = useState([]);
  useEffect(() => {
    if (!show?.id) return;
    setFactsItems([]);
    const apiBase = (window.__dsApi?.base || window.DS_API_BASE || '');
    if (!apiBase) return;
    fetch(`${apiBase}/api/facts?drama_id=${show.id}&limit=50`)
      .then(r => r.ok ? r.json() : null)
      .then(j => { setFactsItems(j?.items || []); })
      .catch(() => {});
  }, [show?.id]);
  // Similar Dramas — горизонтальный скролл со стрелками (как в DSCarousel)
  const simScrollRef = useRef(null);
  const [simCanLeft, setSimCanLeft] = useState(false);
  const [simCanRight, setSimCanRight] = useState(false);
  const checkSimScroll = () => {
    const el = simScrollRef.current;
    if (!el) return;
    setSimCanLeft(el.scrollLeft > 8);
    setSimCanRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 8);
  };
  const scrollSim = (dir) => {
    simScrollRef.current?.scrollBy({ left: dir * 360, behavior: 'smooth' });
    setTimeout(checkSimScroll, 350);
  };
  const isMobile = typeof window !== 'undefined' && window.innerWidth <= 640;
  const overviewLimit = isMobile ? 100 : 400;

  // ВНИМАНИЕ: ВСЕ хуки должны быть ДО early-return,
  // иначе React видит разный порядок хуков между рендерами loading/loaded
  // и валит компонент (а с ним и весь сайт, включая кнопку Войти в навбаре).
  const simItems = (similar?.results || []).filter((s) => s.id !== show?.id && s.posterPath).slice(0, 8);

  // Пересчитываем видимость стрелок Similar Dramas при появлении данных и ресайзе
  useEffect(() => {
    checkSimScroll();
    const onR = () => checkSimScroll();
    window.addEventListener('resize', onR);
    return () => window.removeEventListener('resize', onR);
  }, [simItems.length]);

  // useAuth — хук, вызываем БЕЗУСЛОВНО (без `window.useAuth ?` тернарника),
  // чтобы React не считал его условным. ds-auth.jsx загружается синхронно
  // через babel-standalone ДО первого рендера, так что window.useAuth всегда определён.
  const _auth = window.useAuth();
  const userEmailForRating = _auth?.user?.email;

  // ── Статус из библиотеки — бейдж слева от названия в hero ──
  const _libKey = `ds_library_${_auth?.user?.email || 'guest'}`;
  const _readLibStatus = () => {
    if (!show?.id) return null;
    try {
      const arr = JSON.parse(localStorage.getItem(_libKey) || '[]');
      return arr.find(x => x.id === show.id)?.status || null;
    } catch { return null; }
  };
  const [libStatus, setLibStatus] = useState(_readLibStatus);
  useEffect(() => {
    const refresh = () => setLibStatus(_readLibStatus());
    refresh();
    window.addEventListener('ds-library-changed', refresh);
    window.addEventListener('storage', refresh);
    return () => {
      window.removeEventListener('ds-library-changed', refresh);
      window.removeEventListener('storage', refresh);
    };
  }, [show?.id, _libKey]);
  const libStatuses = (window.__dsLibStatusesArr || window.LIBRARY_STATUSES || []);
  const libInfo = libStatus ? libStatuses.find(s => s.id === libStatus) : null;

  // Reactive tick — заставляет inline userRatings/userAvg перечитаться
  // когда пользователь поставит/уберёт оценку в DramaRatingPanel ниже.
  // Без этого блок «Рейтинг DramaScope / Твой рейтинг» в hero не обновлялся
  // до перезагрузки страницы.
  const [, _setRatingsTick] = useState(0);
  useEffect(() => {
    const refresh = () => _setRatingsTick(t => t + 1);
    window.addEventListener('ds-ratings-changed', refresh);
    window.addEventListener('storage', refresh);
    return () => {
      window.removeEventListener('ds-ratings-changed', refresh);
      window.removeEventListener('storage', refresh);
    };
  }, []);

  // ── SEO: пустые дорамы (без описания) — noindex для Google ──
  // У 602 дорам в каталоге нет overview_ru. Чтобы Google не индексировал «thin content»
  // (страница с заглушкой «Описание скоро появится»), ставим <meta name="robots" content="noindex,follow">.
  // Сама страница остаётся доступной юзерам через прямые ссылки, поиск, каталог.
  // follow — чтобы Google всё равно ходил по ссылкам отсюда (актёры, похожие и т.д.).
  // Когда описание появится — meta автоматически снимется.
  useEffect(() => {
    if (!show?.id) return;
    const ru = (show.overview_ru || '').trim();
    const en = (show.overview_en || '').trim();
    const isEmpty = ru.length < 50 && en.length < 50;
    const META_ID = 'ds-drama-noindex';
    const old = document.getElementById(META_ID);
    if (old) old.remove();
    if (isEmpty) {
      const meta = document.createElement('meta');
      meta.id = META_ID;
      meta.name = 'robots';
      meta.content = 'noindex,follow';
      document.head.appendChild(meta);
    }
    return () => {
      const m = document.getElementById(META_ID);
      if (m) m.remove();
    };
  }, [show?.id, show?.overview_ru, show?.overview_en]);

  // Server-side агрегация рейтинга MaoDrama — реальное среднее + COUNT(DISTINCT user_id)
  // по user_ratings (overall и остальные dimensions). Подгружаем при загрузке дорамы
  // и при ds-ratings-changed (после голосования).
  const [_serverAgg, _setServerAgg] = useState({ avg: null, count: 0 });
  // Серверная "Твоя оценка" — берём из /api/ratings/drama/:id (всё что сервер
  // действительно знает по моему user_id) и усредняем по тем же правилам, что
  // и серверный MaoDrama-агрегат. Это гарантирует совпадение цифр при единственном
  // голосе (Marina, май 2026: «1 голос → mao = my»). localStorage-userAvg
  // оставлен как fallback для гостей и пока сервер ещё не ответил.
  const [_serverMyAvg, _setServerMyAvg] = useState(null);
  useEffect(() => {
    if (!show?.id) return;
    let aborted = false;
    const apiBase = (window.__dsApi?.base || window.DS_API_BASE || '');
    const loadAgg = () => {
      fetch(`${apiBase}/api/dramas/${show.id}/ratings-aggregate`, { credentials: 'include' })
        .then(r => r.ok ? r.json() : null)
        .then(j => { if (!aborted && j) _setServerAgg({ avg: j.avg, count: j.count || 0 }); })
        .catch(() => {});
    };
    const loadMine = () => {
      fetch(`${apiBase}/api/ratings/drama/${show.id}`, { credentials: 'include' })
        .then(r => r.ok ? r.json() : null)
        .then(j => {
          if (aborted) return;
          if (!j || typeof j !== 'object') { _setServerMyAvg(null); return; }
          const vals = Object.values(j).filter(v => typeof v === 'number' && v > 0);
          _setServerMyAvg(vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : null);
        })
        .catch(() => {});
    };
    loadAgg();
    loadMine();
    const onChanged = () => { loadAgg(); loadMine(); };
    window.addEventListener('ds-ratings-changed', onChanged);
    return () => { aborted = true; window.removeEventListener('ds-ratings-changed', onChanged); };
  }, [show?.id]);

  if (loading) return <div style={{ paddingTop: 100 }}><DSSpinner /></div>;
  if (!show) return null;

  const heroBackdropUrl = "/assets/drama-hero.png";
  // Defensive: фильтруем не-объекты и записи без personId — после редактирования
  // в попапе cast мог прийти со странной формой и крашить рендер всей страницы.
  // Скрываем «пустышки» в касте: записи без имени персоны (ни в одном языке) и
  // без имени персонажа — они рисуют пустые карточки «—» и не кликабельны. Сюда
  // же попадают осиротевшие ссылки после чистки базы (персоны уже нет).
  // ВАЖНО: фильтруем только когда список людей уже загружен — иначе на ранней
  // прогрессивной загрузке временно спрятались бы реальные актёры.
  // _fullyLoaded: полный список людей подгружен (а не только топ-80 на первом
  // рендере). Фильтруем мёртвые ссылки только после полной загрузки, иначе
  // временно спрятались бы реальные актёры, которых ещё не подтянуло.
  const _fullyLoaded = !!(window.adapter && window.adapter._people && window.adapter._people.length > 1000);
  const cast = (show.cast || [])
    .filter(c => c && typeof c === 'object' && c.personId != null)
    .filter(c => {
      if (!_fullyLoaded) return true;
      const p = window.adapter.getPerson(c.personId);
      if (!p) return false;   // человека нет в базе → мёртвая ссылка («—», не кликается) → прячем
      const named = (p.name && p.name.trim()) || (p.name_ru && p.name_ru.trim()) || (p.originalName && p.originalName.trim());
      if (named) return true;
      return !!(c.characterName && c.characterName.trim());
    });

  // Среднее юзерское по локальной выставленной оценке (то же localStorage, что в RatingHistogram)
  const userRatings = (() => {
    if (!userEmailForRating || !show?.id) return {};
    try { return JSON.parse(localStorage.getItem(`ds_ratings_${show.id}_${userEmailForRating}`)) || {}; } catch { return {}; }
  })();
  const ratedValues = Object.values(userRatings).filter(v => typeof v === 'number' && v > 0);
  const userAvg = ratedValues.length > 0 ? ratedValues.reduce((s, v) => s + v, 0) / ratedValues.length : null;
  const fmtK = (n) => n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K' : n.toLocaleString();
  const flag = (show.originCountry || []).map((c) => FLAG[c]).find(Boolean) || '🇨🇳';
  const inWatch = watchlist?.some((w) => w.id === show.id);

  const displayTitle = (lang === 'ru' && show.title_ru) ? show.title_ru : (show.title_en || show.title_pinyin || show.title_original || '');
  const displayOverview = ((lang === 'ru' && show.overview_ru) ? show.overview_ru : show.overview_en) || (lang === 'ru' ? 'Описание этой дорамы скоро появится.' : 'A detailed synopsis will be added soon.');
  const posterUrl = window.adapter.imageUrl(show.posterPath, 'w500');
  const subscript = [show.title_original, show.title_pinyin].filter(Boolean).join(' · ');
  const altTitles = (() => {
    const skip = new Set([displayTitle, show.title_original, show.title_pinyin].filter(Boolean).map(s => s.toLowerCase().trim()));
    const seen = new Set();
    const out = [];
    const pool = [
      ...(show.title_en && lang === 'ru' ? [show.title_en] : []),
      ...(show.title_ru && lang !== 'ru' ? [show.title_ru] : []),
      ...(show.alt_titles || [])
    ];
    for (const tt of pool) {
      const k = (tt || '').toLowerCase().trim();
      if (!k || seen.has(k) || skip.has(k)) continue;
      seen.add(k);
      out.push(tt);
    }
    return out;
  })();

  return (
    <div>
      {window.DSDramaEditor && <window.DSDramaEditor show={show} />}
      <div style={{ padding: '14px 24px 0', maxWidth: 1280, margin: '0 auto' }}>
        <button onClick={onBack} style={{ color: 'var(--accent)', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>← Back</button>
      </div>
      <div className="ds-drama-hero-backdrop" style={{ position: 'relative', height: 'clamp(240px,30vw,420px)', overflow: 'hidden' }}>
        {/* Backdrop image — затемнён brightness(0.55), чтобы фон не «перекрикивал» текст */}
        <img src={heroBackdropUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center', display: 'block', filter: 'brightness(0.55)' }} />
        {/* Существующие 3 градиента-overlay */}
        <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to right, rgba(5,9,18,0.95) 0%, rgba(5,9,18,0.90) 25%, rgba(5,9,18,0.75) 45%, rgba(5,9,18,0.55) 60%, rgba(5,9,18,0.32) 75%, rgba(5,9,18,0.12) 90%, transparent 100%)' }} />
        <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 1, background: 'radial-gradient(ellipse 110% 130% at 22% 55%, rgba(5,9,18,0.88) 0%, rgba(5,9,18,0.75) 35%, rgba(5,9,18,0.50) 60%, rgba(5,9,18,0.25) 80%, rgba(5,9,18,0.08) 92%, transparent 100%)' }} />
        <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(5,9,18,0.92) 0%, rgba(5,9,18,0.22) 30%, transparent 60%)' }} />
        {/* Единый тёмный slой над всем фоном — гарантирует читаемость текста справа */}
        <div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.45)', pointerEvents: 'none', zIndex: 2 }} />
      </div>
      <div className="ds-drama-page-wrap" style={{ maxWidth: 1280, margin: '0 auto', padding: '0 80px' }}>
        <div className="ds-drama-hero-grid" style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: 28, marginTop: 'clamp(-340px, calc(80px - 30vw), -160px)', position: 'relative', zIndex: 2, alignItems: 'start' }}>
          {/* ─── ЛЕВАЯ КОЛОНКА: ПОСТЕР + alt-карусель ─── */}
          <div className="ds-drama-poster-col" style={{ alignSelf: 'start', marginBottom: 0 }}>
            <div className="ds-poster-platform" style={{
              background: 'linear-gradient(180deg, #9fcbe9 0%, #f5fbff 100%)',
              borderRadius: 12,
              padding: '5px 5px 12px',
              boxShadow: '0 4px 14px rgba(20,135,203,0.12)'
            }}>
            <div className="ds-drama-hero-poster" style={{ position: 'relative', borderRadius: 14, overflow: 'hidden', aspectRatio: '2 / 3', height: 'auto', boxShadow: '0 8px 26px rgba(0,0,0,0.25)' }}>
              {posterUrl ?
              <img src={posterUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} /> :
              <DSPlaceholderPoster seed={(show.id || 1) % 9 + 1} width="100%" height="100%" />
              }
              {window.PosterRatingBadge && <window.PosterRatingBadge dramaId={show.id} voteAverage={show.voteAverage} />}
              {/* Compact heart-кнопка — над рейтингом (top:6 right:6). */}
              {window.FavoriteDramaButton && (
                <div style={{ position: 'absolute', top: 6, right: 6, zIndex: 5 }}>
                  <window.FavoriteDramaButton item={show} compact />
                </div>
              )}
            </div>
            </div>
            {/* Карусель альтернативных постеров — также продублирована в таб «Медиа» → «Постеры». */}
            {show.posters_alt && show.posters_alt.length > 0 && window.ActorGalleryStrip && (
              <div className="ds-drama-carousel-wrap" style={{ marginTop: 12, width: '100%' }}>
                <window.ActorGalleryStrip slug={show.slug} files={show.posters_alt} basePath="/assets/dramas/posters-alt" />
              </div>
            )}
          </div>

          {/* ─── ПРАВАЯ КОЛОНКА: заголовок, рейтинги, кнопки, жанры, плашки ─── */}
          <div className="ds-drama-hero-info" style={{ paddingTop: 0, display: 'flex', flexDirection: 'column', justifyContent: 'flex-start' }}>
            <div className="ds-hero-text-wrap" style={{ alignSelf: 'flex-start', marginBottom: 8 }}>
              <div className="ds-drama-title-row" style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
                {window.LibraryStatusButton && <window.LibraryStatusButton item={show} square />}
                <h1 style={{ fontSize: 'clamp(20px,2.5vw,34px)', fontWeight: 800, lineHeight: 1.2, display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap' }}>
                  <span>{displayTitle}</span>
                  {/* Mao-иконка — справа от названия. Видна только если у дорамы
                      есть саммари в БД (компонент проверяет window.__dsMaoIds). */}
                  <_MaoIcon dramaId={show.id} size={32} marginLeft={10} />
                </h1>
              </div>
              {(subscript || altTitles.length > 0) && (
                <_AltBlock subscript={subscript} altTitles={altTitles} />
              )}

            {/* Рейтинги (DS + Your) */}
            <div className="ds-drama-ratings-wrap" style={{
              display: 'inline-flex', gap: 20, alignItems: 'flex-start',
              alignSelf: 'flex-start',
              marginTop: 2, marginBottom: 6,
              padding: '6px 12px',
              border: '1px solid rgba(140,180,235,0.18)',
              borderRadius: 8,
              background: 'rgba(140,180,235,0.04)'
            }}>
              <div>
                <div style={{ fontSize: 8.5, fontWeight: 700, letterSpacing: '1px', color: 'var(--text3)', textTransform: 'uppercase', marginBottom: 2 }}>
                  {lang === 'ru' ? 'Рейтинг MaoDrama' : 'MaoDrama Rating'}
                </div>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
                  <svg width="15" height="15" viewBox="0 0 24 24" fill="#f5c518" style={{ transform: 'translateY(2px)', flexShrink: 0 }}>
                    <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
                  </svg>
                  {/* DramaScope rating = среднее всех пользователей сайта. Пока backend агрегации
                      нет, на клиенте доступна только своя оценка → используем userAvg.
                      Когда появится /api/ratings/stats — заменим на server-side aggregate. */}
                  <span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.3px' }}>
                    {(_serverAgg.avg ?? userAvg) ? (_serverAgg.avg ?? userAvg).toFixed(1) : '—'}
                  </span>
                  <span style={{ fontSize: 9.5, color: 'var(--text3)', fontWeight: 500 }}>/10</span>
                </div>
                {/* Кол-во проголосовавших — реальный count distinct user_id из user_ratings
                    (server-side agg). voteCount из dramas.D1 не используем — он содержит MDL-цифру. */}
                {(_serverAgg.count > 0) && (() => {
                  const cnt = _serverAgg.count;
                  const last = cnt % 10, last2 = cnt % 100;
                  const ruWord = (last2 >= 11 && last2 <= 14) ? 'голосов'
                    : (last === 1) ? 'голос'
                    : (last >= 2 && last <= 4) ? 'голоса'
                    : 'голосов';
                  const enWord = cnt === 1 ? 'vote' : 'votes';
                  return (
                    <div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 0 }}>
                      {cnt} {lang === 'ru' ? ruWord : enWord}
                    </div>
                  );
                })()}
              </div>
              <div style={{ width: 1, alignSelf: 'stretch', background: 'rgba(140,180,235,0.22)' }} />
              <button
                onClick={() => {
                  setActiveTab('myrating');
                  requestAnimationFrame(() => requestAnimationFrame(() => {
                    const el = document.querySelector('.ds-drama-tabs-wrap, .ds-drama-tabs');
                    if (el) {
                      const navH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-height')) || 72;
                      const top = el.getBoundingClientRect().top + window.scrollY - navH - 8;
                      window.scrollTo({ top, behavior: 'smooth' });
                    }
                  }));
                }}
                style={{ display: 'block', background: 'transparent', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}>
                <div style={{ fontSize: 8.5, fontWeight: 700, letterSpacing: '1px', color: 'var(--text3)', textTransform: 'uppercase', marginBottom: 2 }}>
                  {lang === 'ru' ? 'Твой рейтинг' : 'Your Rating'}
                </div>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
                  {/* Приоритет: серверный _serverMyAvg (правда сервера) → fallback на localStorage userAvg
                      (пока сервер не ответил или юзер гость). Это гарантирует совпадение с MaoDrama-рейтингом
                      при единственном голосе. */}
                  {(() => {
                    const myAvg = (_serverMyAvg != null) ? _serverMyAvg : userAvg;
                    return myAvg ? (
                      <>
                        <svg width="15" height="15" viewBox="0 0 24 24" fill="#4a9eff" stroke="#7bc3ff" strokeWidth="1.6" strokeLinejoin="round" style={{ transform: 'translateY(2px)', flexShrink: 0 }}>
                          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
                        </svg>
                        <span style={{ fontSize: 15, fontWeight: 700, color: '#fff', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.3px' }}>
                          {myAvg.toFixed(1)}
                        </span>
                        <span style={{ fontSize: 9.5, color: 'var(--text3)', fontWeight: 500 }}>/10</span>
                      </>
                    ) : (
                      <>
                        <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#7bc3ff" strokeWidth="1.6" strokeLinejoin="round" style={{ transform: 'translateY(2px)', flexShrink: 0 }}>
                          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
                        </svg>
                        <span style={{ fontSize: 11, fontWeight: 400, color: '#7bc3ff', textDecoration: 'underline', textUnderlineOffset: '2px' }}>
                          {lang === 'ru' ? 'Оценить' : 'Rate'}
                        </span>
                      </>
                    );
                  })()}
                </div>
              </button>
            </div>

            {/* Жанры */}
            {(show.genres || []).length > 0 && (
              <div className="ds-drama-genres-row" style={{ display: 'flex', gap: 5, alignItems: 'center', marginBottom: 10, flexWrap: 'wrap' }}>
                {(show.genres || []).map((g) => (
                  <span key={g.id} style={{
                    display: 'inline-block', padding: '1px 7px', borderRadius: 999,
                    background: 'rgba(255,255,255,0.10)',
                    color: '#ffffff',
                    border: '1px solid rgba(255,255,255,0.22)',
                    fontSize: 10.5, fontWeight: 500, letterSpacing: '0.2px', whiteSpace: 'nowrap',
                    lineHeight: 1.6,
                    textShadow: '0 1px 4px rgba(0,0,0,0.5)'
                  }}>{t(g.name)}</span>
                ))}
              </div>
            )}

            {/* Год + статус — в одну строку (флаг убран по запросу) */}
            <div className="ds-drama-stats" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 10 }}>
              {show.firstAirDate && <span className="ds-year">{show.firstAirDate.slice(0, 4)}</span>}
              {show.status && (() => {
                // Учитываем и TMDB-варианты (EN), и русские (вводятся в админке).
                const rawLower = String(show.status).toLowerCase().replace(/\s+/g, ' ').trim();
                // PREMIERE: «Выходит с...» с известной датой — подставляем дату
                // (например «Выходит с 9 июня»). Marina, июнь 2026.
                const _isPremiereStatus = rawLower === 'выходит с...' || rawLower === 'выходит с' || rawLower.startsWith('выходит с ');
                let label, cls;
                if (_isPremiereStatus) {
                  const m = String(show.firstAirDate || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
                  if (m) {
                    const day = parseInt(m[3], 10);
                    const month = parseInt(m[2], 10) - 1;
                    const monthsRu = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
                    const monthsEn = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
                    const months = lang === 'ru' ? monthsRu : monthsEn;
                    label = lang === 'ru' ? `Выходит с ${day} ${months[month]}` : `Airing from ${day} ${months[month]}`;
                  } else {
                    label = lang === 'ru' ? 'Скоро премьера' : 'Coming soon';
                  }
                  cls = 'is-upcoming';
                } else if (rawLower === 'ended' || rawLower === 'завершено') {
                  label = 'Finished airing'; cls = 'is-completed';                       // зелёный
                } else if (rawLower === 'returning series' || rawLower === 'in production' || rawLower === 'выходит') {
                  label = 'Ongoing'; cls = 'is-ongoing';                                 // голубой
                } else if (rawLower === 'canceled' || rawLower === 'cancelled' || rawLower === 'suspended'
                        || rawLower === 'приостановлено' || rawLower === 'отменено') {
                  label = 'Suspended'; cls = 'is-suspended';                             // красный
                } else if (rawLower === 'planned' || rawLower === 'pilot' || rawLower === 'ожидается') {
                  label = 'Upcoming'; cls = 'is-upcoming';                               // жёлтый
                } else {
                  label = show.status; cls = 'is-upcoming';
                }
                // Premiere лейбл уже локализован — не пропускаем через t().
                return <span className={`ds-status-badge ${cls}`}>{_isPremiereStatus ? label : t(label)}</span>;
              })()}
            </div>

            {show.networks?.length > 0 && (
              <div className="ds-drama-meta" style={{ fontSize: 13, color: '#ffffff', marginTop: 6, marginBottom: 4, textShadow: '0 1px 6px rgba(0,0,0,0.6)' }}>
                <span className="ds-label" style={{ color: '#9fcbe9', fontWeight: 700 }}>{t('Network:')}</span>{' '}
                {show.networks.map((n) =>
                  <span key={n.id} className="ds-network-name" style={{ color: '#ffffff' }}>{n.name}</span>
                ).reduce((prev, curr) => [prev, ', ', curr])}
              </div>
            )}
            </div>

            {/* Мини-плашки: дата премьеры / серии / страна + кнопки действия в одной строке */}
            <div className="ds-drama-chips-actions-wrap" style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 16, marginTop: 0, marginBottom: 10 }}>
              <div className="ds-drama-chips-row" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
                {(() => {
                  // Продолжительность серии (минуты). TMDB-style episodeRunTime — массив,
                  // или ручное поле runtime (вводится в попап-редакторе).
                  let runtimeMin = 0;
                  if (Array.isArray(show.episodeRunTime) && show.episodeRunTime.length > 0) {
                    const valid = show.episodeRunTime.map(Number).filter(n => !isNaN(n) && n > 0);
                    if (valid.length) runtimeMin = Math.round(valid.reduce((s, n) => s + n, 0) / valid.length);
                  } else {
                    runtimeMin = Number(show.runtime) || Number(show.episode_run_time) || 0;
                  }
                  // «Серий» теперь включает в скобках длительность серии,
                  // отдельный чип «Продолжительность» убран.
                  const epLabel = show.episodeCount > 0
                    ? (runtimeMin > 0
                        ? `${show.episodeCount} ${lang === 'ru' ? `(по ${runtimeMin} мин.)` : `(${runtimeMin} min ea.)`}`
                        : String(show.episodeCount))
                    : null;
                  return [
                    epLabel && ['Episodes', epLabel],
                    (show.originCountry || []).length > 0 && ['Country', (show.originCountry || []).map((c) => {
                      return COUNTRY_NAME[c] ? (lang === 'ru' ? COUNTRY_NAME[c].ru : COUNTRY_NAME[c].en) : c;
                    }).join(', ')]
                  ].filter(Boolean).map(([k, v]) => (
                    <div key={k} style={{
                      display: 'inline-flex', alignItems: 'baseline', gap: 5,
                      background: 'var(--bg2)', borderRadius: 6, padding: '5px 9px',
                      border: '1px solid rgba(74,158,255,0.08)'
                    }}>
                      <span style={{ fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{t(k)}</span>
                      <span className="ds-drama-chip-value" style={{ fontSize: 12, fontWeight: 400, color: '#1e6fd9' }}>{v}</span>
                    </div>
                  ));
                })()}
              </div>
              <div className="ds-drama-actions-row" style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginLeft: 'auto' }}>
                {window.FavoriteDramaButton && <window.FavoriteDramaButton item={show} />}
                {window.AddToListButton && <window.AddToListButton dramaId={show.id} />}
                {window.ShareMenu && <window.ShareMenu compact title={displayTitle} />}
                {window.ReleaseNotifyButton && <window.ReleaseNotifyButton drama={show} />}
              </div>
              {/* Статус показа (Завершено / Идёт / Ожидается / Приостановлено) —
                  отображается на МОБИЛЕ под иконками. На десктопе уже есть .ds-drama-stats. */}
              {show.status && (() => {
                const rawLower = String(show.status).toLowerCase().replace(/\s+/g, ' ').trim();
                // PREMIERE: «Выходит с...» с известной датой — подставляем дату
                // (например «Выходит с 9 июня»). Marina, июнь 2026.
                const _isPremiereStatus = rawLower === 'выходит с...' || rawLower === 'выходит с' || rawLower.startsWith('выходит с ');
                let label, color, premiereLabel = null;
                if (_isPremiereStatus) {
                  const m = String(show.firstAirDate || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
                  if (m) {
                    const day = parseInt(m[3], 10);
                    const month = parseInt(m[2], 10) - 1;
                    const monthsRu = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
                    const monthsEn = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
                    const months = lang === 'ru' ? monthsRu : monthsEn;
                    premiereLabel = lang === 'ru' ? `Выходит с ${day} ${months[month]}` : `Airing from ${day} ${months[month]}`;
                  } else {
                    premiereLabel = lang === 'ru' ? 'Скоро премьера' : 'Coming soon';
                  }
                  color = '#7eb5e0';
                } else if (rawLower === 'ended' || rawLower === 'завершено') {
                  label = 'Finished airing'; color = '#3dd68c';                          // зелёный
                } else if (rawLower === 'returning series' || rawLower === 'in production' || rawLower === 'выходит') {
                  label = 'Ongoing'; color = '#7bc3ff';                                  // голубой
                } else if (rawLower === 'planned' || rawLower === 'pilot' || rawLower === 'ожидается') {
                  label = 'Upcoming'; color = '#7eb5e0';                                 // нежно-голубой
                } else if (rawLower === 'canceled' || rawLower === 'cancelled' || rawLower === 'suspended'
                        || rawLower === 'приостановлено' || rawLower === 'отменено') {
                  label = 'Suspended'; color = '#e05858';                                // красный
                } else {
                  label = show.status; color = '#8a98a8';
                }
                return (
                  <div className="ds-drama-airing-status" style={{
                    display: 'none',  /* видно только на мобиле, см. @media */
                    alignItems: 'center', gap: 6,
                    padding: '6px 12px',
                    borderRadius: 8,
                    fontSize: 12, fontWeight: 700, letterSpacing: '0.4px',
                    textTransform: 'uppercase',
                    background: color + '24',
                    border: '1px solid ' + color,
                    color: color
                  }}>
                    <span style={{ width: 7, height: 7, borderRadius: '50%', background: color, flexShrink: 0 }} />
                    {premiereLabel || t(label)}
                  </div>
                );
              })()}
            </div>
          </div>
        </div>

        {/* Описание + актёры — двухколоночный блок под hero */}
        {/* Описание (полная ширина — каст переехал в таб) */}
        <div className="ds-desc-block" style={{ marginTop: 12 }}>
          <div style={{
            marginBottom: 18,
            border: '1px solid rgba(140,180,235,0.10)',
            borderRadius: 12,
            background: 'rgba(140,180,235,0.02)',
            overflow: 'hidden'
          }}>
            <div style={{
              position: 'relative',
              maxHeight: fullOverview ? 'none' : 240,
              overflow: 'hidden',
              fontSize: 12.5, lineHeight: 1.55, color: 'var(--text2)',
              padding: '10px 14px'
            }}>
              {window.RichText
                ? <window.RichText text={displayOverview} />
                : <p style={{ whiteSpace: 'pre-wrap' }}>{displayOverview}</p>}
              {!fullOverview && displayOverview.length > overviewLimit && (
                <div className="ds-drama-overview-fade" style={{
                  position: 'absolute', left: 0, right: 0, bottom: 0,
                  height: '70%', pointerEvents: 'none',
                  background: 'linear-gradient(to top, rgba(8,12,26,0.95) 0%, rgba(140,180,235,0.02) 100%)'
                }} />
              )}
            </div>
            {displayOverview.length > overviewLimit &&
              <button onClick={() => setFullOverview((v) => !v)}
              className="ds-drama-overview-toggle"
              style={{
                width: '100%',
                padding: '10px 0',
                background: 'transparent',
                border: 'none',
                borderTop: '1px solid rgba(140,180,235,0.08)',
                color: '#0abab5', fontSize: 13, fontWeight: 600,
                display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
                cursor: 'pointer', transition: 'background 0.15s'
              }}
              onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(10,186,181,0.05)'; }}
              onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
              >
                {fullOverview ? t('Show less') : t('Read more')}
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
                  style={{ transform: fullOverview ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}>
                  <polyline points="6 9 12 15 18 9" />
                </svg>
              </button>
            }
          </div>
        </div>

      </div>
      {/* /закрываем ds-drama-page-wrap — секция табов выходит на уровень острова */}

      {/* Табы + контент в едином светло-сером блоке (Apple iPad-style) */}
      <div className="ds-drama-tabs-section">
        <div className="ds-drama-tabs-inner" style={{ maxWidth: 1280, margin: '0 auto', padding: '0 80px' }}>
        {/* Табы: Медиа / Видео / Каст / Саундтрек / Связанные / Обсуждения / Отзывы.
            Таб «Саундтрек» показывается только если у дорамы есть OST (см. worker/content.js). */}
        <DramaTabsNav
          tabs={[
            { key: 'myrating', label: lang === 'ru' ? 'Моя оценка' : 'My rating' },
            { key: 'media', label: lang === 'ru' ? 'Медиа' : 'Media' },
            { key: 'video', label: lang === 'ru' ? 'Видео' : 'Video' },
            { key: 'cast', label: lang === 'ru' ? 'Каст' : 'Cast' },
            ...(ostItems.length > 0 ? [{ key: 'soundtrack', label: lang === 'ru' ? 'Саундтрек' : 'Soundtrack' }] : []),
            ...(factsItems.length > 0 ? [{ key: 'facts', label: lang === 'ru' ? 'Факты' : 'Facts' }] : []),
            { key: 'related', label: lang === 'ru' ? 'Связанные произведения' : 'Related works' },
            { key: 'discussions', label: lang === 'ru' ? 'Обсуждения' : 'Discussions' },
            { key: 'reviews', label: lang === 'ru' ? 'Отзывы' : 'Reviews' },
          ]}
          active={activeTab}
          onChange={setActiveTab}
        />

        <div className="ds-tab-content" style={{ minHeight: 200 }}>
          {activeTab === 'media' && <DramaMediaSection show={show} t={t} lang={lang} />}

          {activeTab === 'video' && (
            show.trailers && show.trailers.length > 0
              ? <DSTrailerSection trailers={show.trailers} />
              : <div style={{ padding: '48px 0', color: 'var(--text3)', textAlign: 'center', fontSize: 14 }}>
                  {lang === 'ru' ? 'Видео пока нет' : 'No videos yet'}
                </div>
          )}

          {activeTab === 'cast' && (
            (cast.length > 0 || (show.crew && show.crew.length > 0))
              ? (() => {
                  const renderCastCard = (c) => {
                    const person = window.adapter.getPerson(c.personId);
                    const photoUrl = person?.profilePath ? window.adapter.profileImageUrl(person.profilePath) : null;
                    const personName = lang === 'ru' ? (person?.name_ru || person?.name) : person?.name;
                    const displayName = String(personName || c.characterName || c.job || '—');
                    const subline = personName ? String(c.characterName || c.job || '') : '';
                    const initial = (displayName.trim().charAt(0) || '?').toUpperCase();
                    // Рейтинг актёра (★) — единый помощник (как на странице актёра и в каталоге).
                    const _actorRating = (window.__dsActorRating ? window.__dsActorRating(c.personId) : null);
                    return (
                      <div key={`${c.personId}-${c.job || c.characterName || ''}`} onClick={() => person && onSelectActor && onSelectActor(c.personId)} style={{ flex: '0 0 110px', maxWidth: 110, textAlign: 'center', cursor: person ? 'pointer' : 'default', position: 'relative' }}>
                        <div style={{ width: 100, height: 140, borderRadius: 14, overflow: 'hidden', marginBottom: 6, background: 'var(--bg3)', marginLeft: 'auto', marginRight: 'auto', boxShadow: '0 4px 14px rgba(0,0,0,0.22)', position: 'relative' }}>
                          {photoUrl
                            ? <img src={photoUrl} alt={person?.name || ''} className="ds-avatar" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
                            : <img src="./assets/actor-placeholder.png" alt="" className="ds-avatar" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
                          {/* Личный рейтинг актёра — плашка ★ в левом верхнем углу. */}
                          {_actorRating && (
                            <div style={{ position: 'absolute', top: 6, left: 6, zIndex: 5,
                              background: 'rgba(8,12,26,0.85)', color: '#ffd770',
                              fontSize: 11, fontWeight: 500, padding: '3px 7px', borderRadius: 7,
                              display: 'flex', alignItems: 'center', gap: 3,
                              backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)' }}>
                              <span style={{ fontSize: 11, color: '#ffb700' }}>★</span><span>{_actorRating}</span>
                            </div>
                          )}
                          {/* Heart в правом верхнем углу фото. */}
                          {person && window.FavoritePersonButton && (
                            <div style={{ position: 'absolute', top: 6, right: 6, zIndex: 5 }}>
                              <window.FavoritePersonButton actorId={c.personId} actorName={person.name} photoUrl={photoUrl} compact />
                            </div>
                          )}
                          {/* Subscribe «+» в левом нижнем углу — аналог "В библиотеку" у дорам. */}
                          {person && window.FollowButton && (
                            <div style={{ position: 'absolute', bottom: 8, left: 8, zIndex: 5 }}>
                              <window.FollowButton actorId={c.personId} actorName={person.name} actorNameRu={person.name_ru} photoUrl={photoUrl} round />
                            </div>
                          )}
                        </div>
                        <div style={{ fontSize: 11, fontWeight: 500, lineHeight: 1.3, display: 'flex', alignItems: 'center', justifyContent: 'center', maxWidth: '100%', overflow: 'hidden' }}>
                          {/* FavoritePersonMark рядом с именем убран — состояние «лайкнул» теперь показывает compact heart на фото. */}
                          <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}>{displayName}</span>
                        </div>
                        {subline && <div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{subline}</div>}
                      </div>
                    );
                  };
                  const CastRow = ({ items, opacity = 1 }) => (
                    <div className="ds-cast-row" style={{ display: 'flex', gap: 14, flexWrap: 'wrap', opacity }}>
                      {items.map(renderCastCard)}
                    </div>
                  );
                  const mainCast = cast.filter(c => c.main);
                  const supportingCast = cast.filter(c => !c.main);
                  const crew = Array.isArray(show.crew) ? show.crew : [];
                  return (
                    <div className="ds-cast-sections" style={{ marginBottom: 20 }}>
                      {mainCast.length > 0 && (
                        <div className="ds-cast-section">
                          <h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{t('Main cast')}</h3>
                          <CastRow items={mainCast} />
                        </div>
                      )}
                      {supportingCast.length > 0 && (
                        <div className="ds-cast-section">
                          <h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{t('Supporting cast')}</h3>
                          <CastRow items={supportingCast} opacity={0.92} />
                        </div>
                      )}
                      {mainCast.length === 0 && supportingCast.length > 0 === false && cast.length > 0 && (
                        <div className="ds-cast-section">
                          <h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{t('Supporting cast')}</h3>
                          <CastRow items={cast} />
                        </div>
                      )}
                      {crew.length > 0 && (
                        <div className="ds-cast-section">
                          <h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 12, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{t('Crew')}</h3>
                          <CastRow items={crew} opacity={0.92} />
                        </div>
                      )}
                    </div>
                  );
                })()
              : <div style={{ padding: '48px 0', color: 'var(--text3)', textAlign: 'center', fontSize: 14 }}>
                  {lang === 'ru' ? 'Каст не указан' : 'No cast info'}
                </div>
          )}

          {activeTab === 'soundtrack' && (
            <DramaSoundtrackSection items={ostItems} lang={lang} dramaId={show.id} />
          )}

          {activeTab === 'facts' && (
            window.FactsListSection
              ? <window.FactsListSection items={factsItems} lang={lang} onSelectActor={onSelectActor} onSelect={onSelect} />
              : <div style={{ padding: '48px 0', color: 'var(--text3)', textAlign: 'center', fontSize: 14 }}>—</div>
          )}

          {activeTab === 'related' && (() => {
            const items = show.related_works || [];
            if (items.length === 0) {
              return (
                <div style={{ padding: '60px 0', color: 'var(--text3)', textAlign: 'center', fontSize: 14 }}>
                  {lang === 'ru' ? 'Пока не указаны связанные произведения' : 'No related works yet'}
                </div>
              );
            }
            const KIND_LABELS_RU = { drama: 'Дорама', movie: 'Фильм', show: 'Шоу', concert: 'Концерт', book: 'Книга', other: 'Другое' };
            const KIND_LABELS_EN = { drama: 'Drama', movie: 'Movie', show: 'Show', concert: 'Concert', book: 'Book', other: 'Other' };
            const KIND_ICONS = { drama: '📺', movie: '🎬', show: '🎤', concert: '🎵', book: '📖', other: '🔗' };
            const KIND_COLORS = {
              drama: '#1487cb', movie: '#4FA1DC', show: '#a78bfa',
              concert: '#f06292', book: '#5BA6BC', other: '#8E9DC4'
            };
            // Группируем по типу для красоты
            const groups = {};
            for (const it of items) {
              const k = it.kind || 'other';
              if (!groups[k]) groups[k] = [];
              groups[k].push(it);
            }
            const order = ['drama', 'movie', 'show', 'concert', 'book', 'other'];
            // Резолвим dramaId через adapter cache. Для старых записей без dramaId
            // парсим из url ('#/drama/123' → 123). Coercion id к Number — в _dramasById
            // ключи могут быть как number, так и string (см. задачу #412).
            const resolveDrama = (it) => {
              let did = it.dramaId;
              if (!did && typeof it.url === 'string') {
                const m = it.url.match(/#\/drama\/(\d+)/);
                if (m) did = m[1];
              }
              if (!did) return null;
              const map = window.adapter?._dramasById;
              if (!map) return null;
              return map.get(Number(did)) || map.get(String(did)) || null;
            };
            return (
              <div style={{ paddingTop: 8, paddingBottom: 24 }}>
                {order.filter(k => groups[k]).map(k => {
                  const labels = lang === 'ru' ? KIND_LABELS_RU : KIND_LABELS_EN;
                  const color = KIND_COLORS[k];
                  // Для k='drama' — постеры через DramaCard (с лайком/библиотекой).
                  // Для остальных kind — текстовые плашки как раньше.
                  const isDrama = k === 'drama';
                  return (
                    <div key={k} style={{ marginBottom: 28 }}>
                      <h3 style={{
                        fontSize: 13, fontWeight: 700, marginBottom: 14,
                        color: color, textTransform: 'uppercase', letterSpacing: '1px',
                        display: 'flex', alignItems: 'center', gap: 8
                      }}>
                        <span style={{ fontSize: 18 }}>{KIND_ICONS[k]}</span>
                        {labels[k]} <span style={{ color: 'var(--text3)', fontWeight: 500, letterSpacing: 0 }}>· {groups[k].length}</span>
                      </h3>
                      <div style={{
                        display: 'grid',
                        gridTemplateColumns: isDrama
                          ? 'repeat(auto-fill, minmax(160px, 1fr))'
                          : 'repeat(auto-fill, minmax(280px, 1fr))',
                        gap: isDrama ? 18 : 14,
                        paddingTop: isDrama ? 18 : 0  // место для heart-выступа
                      }}>
                        {groups[k].map(it => {
                          if (isDrama) {
                            const d = resolveDrama(it);
                            if (d) {
                              return (
                                <DramaCard
                                  key={it.id}
                                  item={d}
                                  onClick={() => window.__dsSelectItem(d.id)}
                                />
                              );
                            }
                            // Дорама в каталоге не найдена (удалена/ещё не загружена) —
                            // показываем плашку как fallback.
                          }
                          return <DramaRelatedWorkCard key={it.id} item={it} color={color} lang={lang} />;
                        })}
                      </div>
                    </div>
                  );
                })}
              </div>
            );
          })()}

          {activeTab === 'discussions' && (
            <div style={{ padding: '60px 0', color: 'var(--text3)', textAlign: 'center', fontSize: 14 }}>
              {lang === 'ru' ? 'Обсуждения скоро появятся' : 'Discussions coming soon'}
            </div>
          )}

          {activeTab === 'reviews' && (
            window.ReviewsSection
              ? <window.ReviewsSection dramaId={show.id} />
              : <div style={{ padding: '48px 0', color: 'var(--text3)', textAlign: 'center', fontSize: 14 }}>
                  {lang === 'ru' ? 'Отзывов пока нет' : 'No reviews yet'}
                </div>
          )}

          {activeTab === 'myrating' && (
            <div className="ds-myrating-tab">
              {window.RecommendBlock && <window.RecommendBlock dramaId={show.id} />}
              <div id="ds-rate-panel-anchor">
                {window.DramaRatingPanel && <window.DramaRatingPanel dramaId={show.id} />}
              </div>
              {/* Кнопки «Моя реакция» — диаграмма community переехала в ds-stats-row */}
              {window.DramaMyReactionPanel && <window.DramaMyReactionPanel dramaId={show.id} />}
              {!window.RecommendBlock && !window.DramaRatingPanel && !window.DramaMyReactionPanel && (
                <div style={{ padding: '48px 0', color: 'var(--text3)', textAlign: 'center', fontSize: 14 }}>
                  {lang === 'ru' ? 'Компоненты ещё не загружены' : 'Components not loaded yet'}
                </div>
              )}
            </div>
          )}
        </div>
        </div>
      </div>
      {/* /ds-drama-tabs-section */}

      {/* Снова открываем page-wrap для контента ниже */}
      <div className="ds-drama-page-wrap" style={{ maxWidth: 1280, margin: '0 auto', padding: '0 80px' }}>

        {/* Актёрский состав переехал вниз — после плашек рейтинг+зрители, перед похожими дорамами */}

        {/* AI Recommendations promo card (после каста, как на главной) */}
        <div className="ds-mao-drama-promo" style={{ position: 'relative', marginTop: 30, marginBottom: 16 }}>
          <img
            src={lang === 'ru' ? 'assets/mao/mao-drama.png' : 'assets/mao/mao-drama-en.png'}
            alt="Mao"
            className="ds-mao-drama-img"
            style={{
              position: 'absolute', top: -70, right: 18, width: 240, height: 'auto',
              filter: 'drop-shadow(0 6px 16px rgba(0,0,0,0.55))', pointerEvents: 'none', zIndex: 2
            }}
          />
          <div className="ds-ai-card" onClick={() => window.__dsGoPage && window.__dsGoPage('ai')} style={{
            padding: 20, cursor: 'pointer',
            transition: 'transform 0.2s', position: 'relative'
          }}
          onMouseEnter={(e) => e.currentTarget.style.transform = 'translateY(-2px)'}
          onMouseLeave={(e) => e.currentTarget.style.transform = 'none'}>
            {window.MaoSoonBadge && <window.MaoSoonBadge top={10} right={10} />}
            <div className="ds-mao-drama-text" style={{ fontSize: 13, fontWeight: 700, marginBottom: 6, display: 'flex', alignItems: 'center', paddingRight: 250 }}>
              <span className="ds-menu-diamond" style={{ animation: 'pulse 2s infinite' }} /> {t('Mao Recommendations')}
            </div>
            <h3 className="ds-mao-drama-text" style={{ fontWeight: 700, marginBottom: 6, fontSize: 17, paddingRight: 250 }}>{t('Find dramas like')} {displayTitle}</h3>
            <p className="ds-mao-drama-text" style={{ lineHeight: 1.6, marginBottom: 10, fontSize: 14, paddingRight: 250 }}>
              {t("I'll suggest similar shows based on your taste and this drama's vibe.")}
            </p>
            {window.DramaAIButtons && (
              <div className="ds-mao-tags-wrap" style={{ marginBottom: 12, paddingRight: 250 }} onClick={(e) => e.stopPropagation()}>
                <window.DramaAIButtons bare dramaTitle={displayTitle} />
              </div>
            )}
            <button style={{ fontSize: 12, borderRadius: 999, padding: '9px 20px', fontWeight: 600 }}>{t('Try My Picks →')}</button>
          </div>
        </div>

        {/* RecommendBlock / DramaRatingPanel / DramaReactionPanel переехали в таб «Моя оценка».
            DramaAIButtons теперь встроен в ds-mao-drama-promo выше (см. bare={true}). */}

      </div>
      {/* /закрываем page-wrap — стат-секция выходит на уровень острова */}

        {/* Сводки «Рейтинг» + «Количество зрителей» + «Реакция на дораму» — в светло-серой плашке Apple-style */}
        {(window.RatingHistogram || window.ViewersSummary || window.DramaReactionPanel) && (
          <div className="ds-drama-stats-section">
            <div className="ds-drama-stats-inner" style={{ maxWidth: 1280, margin: '0 auto', padding: '0 80px' }}>
              <div className="ds-stats-row" style={{
                display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))',
                gap: 12, alignItems: 'stretch'
              }}>
                {window.RatingHistogram && <window.RatingHistogram dramaId={show.id} voteAverage={show.voteAverage} voteCount={show.voteCount} />}
                {window.ViewersSummary && <window.ViewersSummary dramaId={show.id} />}
                {window.DramaReactionPanel && <window.DramaReactionPanel dramaId={show.id} />}
              </div>
            </div>
          </div>
        )}

      <div className="ds-drama-page-wrap" style={{ maxWidth: 1280, margin: '0 auto', padding: '0 80px' }}>
        {/* /снова открываем page-wrap для контента ниже */}

        {simItems.length > 0 &&
        <div style={{ marginTop: 32, marginBottom: 48 }}>
            <h2 style={{ fontSize: 16, fontWeight: 700, marginBottom: 14 }}>{t('Similar Dramas')}</h2>
            <div style={{ position: 'relative' }}>
              {simCanLeft &&
                <button onClick={() => scrollSim(-1)} aria-label="Scroll left" style={{
                  position: 'absolute', left: -6, top: '40%', transform: 'translateY(-50%)', zIndex: 20,
                  width: 36, height: 36, borderRadius: '50%',
                  background: 'rgba(8,10,22,0.92)', border: '1px solid rgba(74,158,255,0.30)',
                  color: '#fff', fontSize: 20, lineHeight: 1,
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                  cursor: 'pointer', backdropFilter: 'blur(8px)',
                  boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
                }}>‹</button>
              }
              {simCanRight &&
                <button onClick={() => scrollSim(1)} aria-label="Scroll right" style={{
                  position: 'absolute', right: -6, top: '40%', transform: 'translateY(-50%)', zIndex: 20,
                  width: 36, height: 36, borderRadius: '50%',
                  background: 'rgba(8,10,22,0.92)', border: '1px solid rgba(74,158,255,0.30)',
                  color: '#fff', fontSize: 20, lineHeight: 1,
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                  cursor: 'pointer', backdropFilter: 'blur(8px)',
                  boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
                }}>›</button>
              }
              <div ref={simScrollRef} onScroll={checkSimScroll} style={{ display: 'flex', gap: 12, overflowX: 'auto', overflowY: 'visible', paddingTop: 18, paddingBottom: 8, scrollbarWidth: 'none', WebkitOverflowScrolling: 'touch' }}>
                {simItems.map((s) => <DramaCard key={s.id} item={s} onClick={() => window.__dsSelectItem(s.id)} />)}
              </div>
            </div>
          </div>
        }
      </div>
    </div>);

}

// Export to window
Object.assign(window, {
  FLAG, useAdapter,
  DSBadge, DSStars, DSSpinner, DSPlaceholderPoster, DSPlaceholderBackdrop,
  DramaCard, DSCarousel, DSFeatured, DSDetailPage, DSTrailerSection, DramaTabsNav, PhotoJustifiedTile
});