// web/screens/review2.jsx — Ревью 2 · Тексты (§21.5).
// Привели в соответствие с дизайном 2026-06-03: page-header с
// breadcrumbs + «Перегенерировать», pipeline status strip, KwCard с
// top/banner/suspect вариациями, разделение active/spare ключей,
// «Развернуть анализ», CSV preview блок внизу.

// EditableText — contentEditable div как в дизайне (вместо textarea).
// Управляемый компонент: текст приходит из props.value, на blur вызывается
// onChange. Placeholder показывается через CSS pseudo-element (data-placeholder).
//
// Зачем contentEditable а не textarea: класс .r2-text в дизайне стилизован
// как inline-текст без рамки (transparent border, при hover серый фон, при
// focus — обводка цвета акцента). У textarea свой нативный стиль (рамка +
// fixed height по rows), который ломает визуал. ContentEditable div этого
// не имеет — он растягивается по содержимому естественно.
function EditableText({ value, onChange, className, placeholder }) {
  const ref = React.useRef(null);
  // Синхронизируем внешний value (например после reload фотки из API) с DOM
  // только если он реально изменился И элемент не в фокусе (чтобы не
  // прерывать редактирование).
  React.useEffect(() => {
    if (!ref.current) return;
    if (document.activeElement === ref.current) return;
    if (ref.current.textContent !== (value || "")) {
      ref.current.textContent = value || "";
    }
  }, [value]);
  return (
    <div ref={ref}
         className={className}
         contentEditable
         suppressContentEditableWarning
         data-placeholder={placeholder || ""}
         onBlur={(e) => {
           const next = e.currentTarget.textContent;
           if (next !== value) onChange(next);
         }}
         // Enter не должен вставлять <div> или <br> — берём текст по blur.
         onKeyDown={(e) => {
           if (e.key === "Enter" && !e.shiftKey) {
             e.preventDefault();
             e.currentTarget.blur();
           }
         }}/>
  );
}

// KwCard — карточка одного ключа в активном списке 50.
// Поддерживает четыре визуальных режима:
//   top     (i<10)         — первая десятка, тёмно-зелёный фон
//   selling (Selling=true) — продающие за пределами первой десятки, светло-зелёный
//   banner  (в наборе)     — баннерные ключи, желтоватый фон
//   suspect                — обнаружено как слитное слово, пунктир (предупреждение)
function KwCard({ idx, en, ru, top, selling, banner, suspect, onRemove,
                  onDragStart, onDragEnd, dragging,
                  // dropSide: "left" | "right" | null — куда упадёт при drop
                  // ИМЕННО на этой карточке. Подсветка показывает Лене точку
                  // вставки до того как она отпустит мышь.
                  dropSide,
                  onDragOverCard, onDropCard }) {
  const cls = "kw"
    + (top ? " top" : "")
    + (!top && selling ? " selling" : "") // selling-стиль только если НЕ top
    + (banner ? " banner" : "")
    + (suspect ? " suspect" : "")
    + (dragging ? " dragging" : "");
  const indicatorStyle = dropSide === "left"
    ? { boxShadow: "inset 3px 0 0 0 var(--accent)" }
    : dropSide === "right"
      ? { boxShadow: "inset -3px 0 0 0 var(--accent)" }
      : null;
  return (
    <span className={cls} title={ru || ""}
          draggable={true}
          onDragStart={onDragStart}
          onDragEnd={onDragEnd}
          onDragOver={onDragOverCard}
          onDrop={onDropCard}
          style={{ cursor: "grab", opacity: dragging ? 0.4 : 1, ...(indicatorStyle || {}) }}>
      <span className="num">{String(idx).padStart(2, "0")}</span>
      <div className="kw-stack">
        <span className="en">{en}</span>
        {ru && <span className="ru">{ru}</span>}
      </div>
      <button onClick={(e) => { e.stopPropagation(); onRemove(); }}
              onMouseDown={(e) => e.stopPropagation()} /* чтобы клик по ✕ не считался drag-start */
              title="Убрать в запасные"
              style={{
                marginLeft: 4, background: "transparent", border: 0,
                cursor: "pointer", color: "var(--ink-3)", fontSize: 13, lineHeight: 1,
              }}>×</button>
    </span>
  );
}

// SpareKwCard — кнопка запасного ключа под основным списком. Клик → промоут
// обратно в активные (через PUT /v1/keyword/:id/spare {Spare:false}).
function SpareKwCard({ kw, onRestore }) {
  return (
    <button className="kw spare"
            onClick={() => onRestore(kw)}
            title={`Кликни — добавится в конец списка${kw.Ru ? " (" + kw.Ru + ")" : ""}`}>
      <div className="kw-stack">
        <span className="en">{kw.En}</span>
        {kw.Ru && <span className="ru">{kw.Ru}</span>}
      </div>
    </button>
  );
}

// Review2PhotoBlock — один фото-блок Ревью 2. Слева превью + Акцент + кнопка
// «Развернуть анализ»; справа — Title, Description с авто-счётчиком.
// Ниже — Keywords во всю ширину блока с разделением active / spare.
function Review2PhotoBlock({ photo, allWords, isExpanded, onToggleExpand }) {
  const [title, setTitle] = React.useState(photo.Title || "");
  const [titleRu, setTitleRu] = React.useState(photo.TitleRu || "");
  const [desc, setDesc] = React.useState(photo.Description || "");
  const [descRu, setDescRu] = React.useState(photo.DescriptionRu || "");
  React.useEffect(() => { setTitle(photo.Title || ""); }, [photo.Title]);
  React.useEffect(() => { setTitleRu(photo.TitleRu || ""); }, [photo.TitleRu]);
  React.useEffect(() => { setDesc(photo.Description || ""); }, [photo.Description]);
  React.useEffect(() => { setDescRu(photo.DescriptionRu || ""); }, [photo.DescriptionRu]);

  // Autosave Title/Description в БД при изменении state (правка 2026-06-05).
  // До неё EditableText сохранял только в локальный state — Лена правила,
  // а в БД и CSV-превью оставалось старое значение. Дебаунс 600мс чтобы
  // не лупить PUT на каждую букву.
  React.useEffect(() => {
    if (!api.feed.savePhotoTexts) return;
    const t = setTimeout(() => {
      const matches = (title === (photo.Title || "")) &&
                      (desc === (photo.Description || "")) &&
                      (titleRu === (photo.TitleRu || "")) &&
                      (descRu === (photo.DescriptionRu || ""));
      if (matches) return; // нечего сохранять — текущее совпадает с props
      api.feed.savePhotoTexts(photo.ID, {
        title, description: desc, titleRu, descriptionRu: descRu,
      }).catch((e) => {
        console.warn("save photo texts failed:", e.message);
      });
    }, 600);
    return () => clearTimeout(t);
  }, [title, desc, titleRu, descRu, photo.ID]);

  // Keywords — загружаются отдельно через /v1/keywords?photoID=N (active + spare
  // в одной выборке, фронт разделяет по полю Spare).
  const [kws, setKws] = React.useState([]);
  const [newKw, setNewKw] = React.useState("");
  const [addBusy, setAddBusy] = React.useState(false); // блок UI пока идёт RU→EN + N add'ов
  const [_, toast] = useToasts();

  // Защита от старого кэша api.js (если у юзера в браузере осталась версия
  // до 2026-06-03 — там ещё нет feed.keywords). Без этой проверки весь
  // Review2 падал с TypeError в Console (правка 2026-06-03).
  const reloadKws = React.useCallback(() => {
    if (!api.feed || !api.feed.keywords || typeof api.feed.keywords.list !== "function") {
      console.warn("api.feed.keywords недоступен — старый кэш api.js. Жми Ctrl+Shift+R");
      return;
    }
    api.feed.keywords.list(photo.ID)
      .then((r) => setKws(Array.isArray(r) ? r : []))
      .catch(() => {});
  }, [photo.ID]);
  React.useEffect(() => { reloadKws(); }, [reloadKws]);

  // Auto-polling пока есть keywords без перевода (правка 2026-06-03).
  // После «Перегенерировать» backend сначала сохраняет 50 keywords (часть
  // с переводом из словаря, часть без), потом ОДНИМ батчем переводит
  // оставшиеся через GLM — это занимает ~10-20 сек на фотку. Без polling'а
  // фронт показывает «пустые ru» и Лена думает что переводы вообще не
  // работают. Останавливаемся когда нет untranslated active keywords, или
  // через 90 сек (если GLM не отвечает — нечего бесконечно опрашивать).
  const activeKws = kws.filter((k) => !k.Spare);
  const untranslatedCount = activeKws.filter((k) => !k.Ru).length;
  React.useEffect(() => {
    if (untranslatedCount === 0 || activeKws.length === 0) return;
    const start = Date.now();
    const tick = setInterval(() => {
      if (Date.now() - start > 90000) {
        clearInterval(tick);
        return;
      }
      reloadKws();
    }, 5000);
    return () => clearInterval(tick);
  }, [untranslatedCount, activeKws.length, reloadKws]);

  const active = kws
    .filter((k) => !k.Spare)
    .sort((a, b) => (a.Position || 0) - (b.Position || 0));
  const spare = kws.filter((k) => k.Spare);

  const descLen = desc.length;
  // Окно валидации совпадает с серверным pika.DescMinLen/DescMaxLen (170-200).
  // 180-200 (исходник бота) → 150-220 (2026-06-02 из-за GLM-промахов) →
  // 170-200 (2026-06-03 — Лена сузила для стокового стандарта).
  // 2026-06-05: target Pika_Writer'а сузили до 180-195, hard-cap остался 200;
  // если модель промахнулась и выдала >200, auto-trim больше не режет —
  // Лена увидит подсветку «warn-strong» и сократит сама.
  const descOk = descLen >= 170 && descLen <= 200;
  const descOver = descLen > 200;
  const descShort = descLen > 0 && descLen < 170;

  const isBannerH = photo.BannerType === "horizontal";
  const isBannerV = photo.BannerType === "vertical";
  // Множество для KwCard.banner — какие ключи помечены как «баннерные».
  // На MVP — простой эвристикой: если у фото баннер, ключи "banner",
  // "horizontal"/"vertical" в активном списке подсвечиваем.
  const bannerSet = new Set();
  if (isBannerH || isBannerV) {
    bannerSet.add("banner");
    bannerSet.add(isBannerH ? "horizontal" : "vertical");
  }

  // Suspect — детектор слитных слов из pkg/pika/glued.go. На MVP не дёргаем
  // — оставляем пустой Set'ом; интеграция отдельно.
  const suspectSet = new Set();

  // Drag-and-drop для перестановки активных keywords.
  // Правка 2026-06-05: переписан под «drop-over-сама-карточка с left/right
  // hit-test», вместо узких 6px zone'ов между карточками. Старый вариант
  // (правка 2026-06-03) часто промахивался — мышь падала на gap flex'а и
  // drop падал «в конец списка» (жалоба Лены 2026-06-05).
  //
  // Принцип: при onDragOver карточки смотрим где курсор внутри её bounding box:
  // left half → дроп ПЕРЕД карточкой (idx), right half → ПОСЛЕ (idx+1).
  // dropTarget хранит индекс куда вставим, dropOverID — какая карточка под
  // курсором (для подсветки левой/правой границы).
  const dragIDRef = React.useRef(null);
  const [dragID, setDragID] = React.useState(null);
  const [dropTarget, setDropTarget] = React.useState(null); // {idx, side: "left"|"right"} or null

  const onDragStart = (e, id) => {
    dragIDRef.current = id;
    setDragID(id);
    try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } catch {}
  };
  const onDragEnd = () => {
    dragIDRef.current = null;
    setDragID(null);
    setDropTarget(null);
  };

  // Карточка под курсором: вычисляем по позиции X внутри bounding box.
  const onDragOverCard = (e, idx, kwID) => {
    if (dragIDRef.current == null) return;
    if (dragIDRef.current === kwID) return; // не подсвечивать саму себя
    e.preventDefault();
    e.stopPropagation();
    const rect = e.currentTarget.getBoundingClientRect();
    const side = (e.clientX - rect.left) < rect.width / 2 ? "left" : "right";
    setDropTarget((prev) =>
      prev && prev.idx === idx && prev.side === side ? prev : { idx, side });
  };

  const onDropCard = (e, idx, kwID) => {
    if (dragIDRef.current == null) return;
    e.preventDefault();
    e.stopPropagation();
    const rect = e.currentTarget.getBoundingClientRect();
    const side = (e.clientX - rect.left) < rect.width / 2 ? "left" : "right";
    const targetIdx = side === "left" ? idx : idx + 1;
    handleDrop(targetIdx);
  };

  // Drop вне любой карточки → в конец списка. Только если drop состоялся
  // именно на контейнере, а не пробулькался от ребёнка (поэтому проверяем
  // currentTarget === target).
  const onContainerDragOver = (e) => {
    if (dragIDRef.current == null) return;
    e.preventDefault();
  };
  const onContainerDrop = (e) => {
    if (dragIDRef.current == null) return;
    e.preventDefault();
    if (dropTarget) {
      handleDrop(dropTarget.side === "left" ? dropTarget.idx : dropTarget.idx + 1);
    } else {
      handleDrop(active.length);
    }
  };

  const handleDrop = async (targetIdx) => {
    const id = dragIDRef.current;
    onDragEnd();
    if (id == null) return;
    const curIdx = active.findIndex((k) => k.ID === id);
    if (curIdx < 0 || curIdx === targetIdx || (curIdx + 1) === targetIdx) return;
    // Локально пересобрать порядок и отправить на бэк.
    const reordered = [...active];
    const [moved] = reordered.splice(curIdx, 1);
    // targetIdx считался до сдвига; если перетаскиваем вниз — индекс уменьшается.
    const insertAt = targetIdx > curIdx ? targetIdx - 1 : targetIdx;
    reordered.splice(insertAt, 0, moved);
    // Оптимистично применяем порядок локально.
    setKws((prev) => {
      const byID = new Map(reordered.map((k, i) => [k.ID, i]));
      return prev.map((k) =>
        byID.has(k.ID) ? { ...k, Position: byID.get(k.ID) } : k
      );
    });
    try {
      await api.feed.keywords.reorder(photo.ID, reordered.map((k) => k.ID));
    } catch (e2) {
      toast("Не удалось переставить: " + e2.message, "error");
      reloadKws();
    }
  };

  const removeKw = async (id) => {
    try {
      await api.feed.keywords.setSpare(id, true);
      reloadKws();
    } catch (e) { toast("Не удалось: " + e.message, "error"); }
  };
  const restoreSpare = async (kw) => {
    try {
      await api.feed.keywords.setSpare(kw.ID, false);
      reloadKws();
    } catch (e) { toast("Не удалось: " + e.message, "error"); }
  };
  // Правка 2026-06-05: split по запятой + автоперевод RU→EN через
  // keyword.TranslateRuToEnBatch (словарь / GLM). RU-оригинал сохраняем в
  // keyword.Ru — иначе плашка показывает только EN без перевода (правка 2026-06-05/2).
  const addKw = async () => {
    if (addBusy) return;
    const tokens = newKw.split(/[,;]/).map((s) => s.trim()).filter(Boolean);
    if (tokens.length === 0) return;
    setAddBusy(true);
    try {
      const ruTokens = tokens.filter((t) => /[Ѐ-ӿ]/.test(t));
      let ruToEn = {};
      if (ruTokens.length > 0) {
        const res = await api.keyword.TranslateRuToEnBatch(ruTokens);
        for (const r of res) {
          if (r.en) ruToEn[r.ru.toLowerCase()] = r.en;
        }
      }
      const failed = [];
      // pairs: {en, ru} — ru заполнен только когда введён кириллицей.
      const pairs = [];
      for (const t of tokens) {
        if (/[Ѐ-ӿ]/.test(t)) {
          const en = ruToEn[t.toLowerCase()];
          if (en) pairs.push({ en, ru: t });
          else failed.push(t);
        } else {
          pairs.push({ en: t, ru: "" });
        }
      }
      if (failed.length > 0) toast("Не удалось перевести: " + failed.join(", "), "warn");
      const seen = new Set();
      for (const p of pairs) {
        const k = p.en.trim().toLowerCase();
        if (!k || seen.has(k)) continue;
        seen.add(k);
        await api.feed.keywords.add(photo.ID, p.en.trim(), p.ru);
      }
      setNewKw("");
      reloadKws();
    } catch (e) {
      toast("Не удалось добавить: " + e.message, "error");
    } finally {
      setAddBusy(false);
    }
  };

  // Группы слов анализа для «Развернуть анализ» — берём из общего allWords
  // (грузится один раз в Review2Screen через /v1/analysis?shootID=N).
  const myWords = (allWords || []).filter((w) => w.PhotoID === photo.ID);
  const grouped = {};
  for (const w of myWords) {
    const g = (w.GroupKind || "").toLowerCase();
    if (!grouped[g]) grouped[g] = [];
    grouped[g].push(w);
  }
  const groupLabels = {
    object: "ОБЪЕКТ", action: "ДЕЙСТВИЕ", role: "РОЛЬ",
    emotion: "ЭМОЦИЯ", place: "МЕСТО",
  };

  return (
    <div className="r2-block" style={{ marginBottom: 16 }}>
      <div className="r2-top">
        <div className="photo-side">
          <div className="photo-img">
            {photo.YandexPath
              ? <img src={"/v1/preview?photoID=" + photo.ID + "&path=" + encodeURIComponent(photo.YandexPath) + "&size=L"}
                     alt={photo.Filename}
                     style={{ width: "100%", height: "100%", objectFit: "contain", display: "block", background: "var(--bg)" }}/>
              : <div style={{ width: "100%", height: "100%", background: "var(--bg)" }}/>}
          </div>
          <div className="filename">
            <Icon name="image" size={11}/> {photo.Filename}
          </div>
          <div className="meta-row">
            {photo.AccentEn && (
              // Pill заменён на блок с word-wrap для длинных акцентов.
              // Перевод (AccentRu) выводится отдельным блоком ПОД плашкой,
              // серым меньшим шрифтом без рамки (правка 2026-06-03).
              <>
                <div style={{
                  fontSize: 11, padding: "4px 8px",
                  background: "var(--surface-2)",
                  border: "1px solid var(--line)",
                  borderRadius: "var(--r)",
                  wordBreak: "break-word",
                  lineHeight: 1.35,
                  width: "100%",
                }}>
                  <span className="muted" style={{ fontSize: 10, marginRight: 4 }}>Акцент:</span>
                  {photo.AccentEn}
                </div>
                {photo.AccentRu && (
                  <div style={{
                    fontSize: 10, color: "var(--ink-3)",
                    marginTop: 3, paddingLeft: 8,
                    wordBreak: "break-word", lineHeight: 1.3,
                    width: "100%",
                  }}>
                    {photo.AccentRu}
                  </div>
                )}
              </>
            )}
            {isBannerH && (
              <span className="pill accent" style={{ fontSize: 10.5 }}>горизонт. баннер</span>
            )}
            {isBannerV && (
              <span className="pill accent" style={{ fontSize: 10.5 }}>вертикал. баннер</span>
            )}
          </div>
          <button className="btn btn-sm" style={{ marginTop: 10 }} onClick={onToggleExpand}>
            {isExpanded ? "Свернуть анализ" : "Развернуть анализ"}{" "}
            <Icon name={isExpanded ? "chevronDown" : "chevron"} size={11} stroke={2}/>
          </button>
        </div>

        <div className="r2-text-col">
          {/* TITLE — как в дизайне 2026-06-03: contentEditable div вместо
              textarea. Класс .r2-text стилизует как обычный текст без рамки,
              при hover — серый фон, при focus — синяя обводка. Это inline-
              редактирование, не «форма ввода». */}
          {/* hasCyrillicEn — детект кириллицы в EN-поле; правка 2026-06-05.
              EN-поля Title и Description должны быть на английском (стоковый
              стандарт). Если Лена случайно начала писать на русском (не
              переключила раскладку или скопировала перевод), подсвечиваем
              рамкой + надписью. Сохранение не блокируем — Лена может
              отредактировать в следующий момент. */}
          <div className="r2-section">
            <div className="r2-label">
              <span>Title</span>
              <span className="r2-counter">{title.length} символов · 5–12 слов</span>
              {/[Ѐ-ӿ]/.test(title) && (
                <span style={{ marginLeft: 6, color: "var(--warn)", fontWeight: 600 }}>
                  только английский!
                </span>
              )}
            </div>
            <EditableText className={"r2-text" + (/[Ѐ-ӿ]/.test(title) ? " over-limit" : "")}
                          placeholder="заполнится после генерации текстов (EN)"
                          value={title} onChange={setTitle}/>
            <EditableText className="r2-text ru" placeholder="русский вариант"
                          value={titleRu} onChange={setTitleRu}/>
          </div>

          {/* DESCRIPTION */}
          <div className="r2-section" style={{ marginBottom: 0 }}>
            <div className="r2-label">
              <span>Description</span>
              <span className={"r2-counter " + (descOk ? "ok" : "warn")}>
                {descLen} / 170–200
                {descOk && <Icon name="check" size={11} stroke={2.4} style={{ marginLeft: 4, verticalAlign: "-2px" }}/>}
                {descOver && (
                  <span style={{ marginLeft: 6, color: "var(--warn)", fontWeight: 600 }}>
                    превышает на {descLen - 200} — сократи перед «Принять серию»
                  </span>
                )}
                {descShort && (
                  <span style={{ marginLeft: 6, color: "var(--warn)" }}>
                    короче минимума на {170 - descLen}
                  </span>
                )}
                {/[Ѐ-ӿ]/.test(desc) && (
                  <span style={{ marginLeft: 6, color: "var(--warn)", fontWeight: 600 }}>
                    только английский!
                  </span>
                )}
              </span>
            </div>
            <EditableText className={"r2-text" + (descOver || /[Ѐ-ӿ]/.test(desc) ? " over-limit" : "")}
                          placeholder="развёрнутое описание сцены, 180–195 символов (макс 200) — EN"
                          value={desc} onChange={setDesc}/>
            <EditableText className="r2-text ru" placeholder="русский вариант"
                          value={descRu} onChange={setDescRu}/>
          </div>
        </div>
      </div>

      {/* Expanded — analysis read-only (показывается по «Развернуть анализ»).
          Расположен ДО Keywords (правка 2026-06-03 по требованию Лены) —
          чтобы видеть источник из чего собрались ключи прежде чем смотреть
          сами ключи. Внутри каждой группы слова отсортированы: сначала
          selling, потом обычные синонимы (selling-карточки подсвечены
          неярким мятным фоном через класс .wcard-mini.selling). */}
      {isExpanded && myWords.length > 0 && (
        <div className="r2-expand">
          <div className="ana-head" style={{ marginBottom: 8 }}>
            <span>Анализ (источник ключей)</span>
            <span className="hero muted-2">только просмотр · продающие выделены</span>
          </div>
          <div className="r2-expand-inline">
            {["object", "action", "role", "emotion", "place"].map((g) => {
              const ws = grouped[g];
              if (!ws || ws.length === 0) return null;
              // Сортировка: сначала selling, в каждой подгруппе — по Position.
              const sorted = [...ws].sort((a, b) => {
                if (a.Selling !== b.Selling) return a.Selling ? -1 : 1;
                return (a.Position || 0) - (b.Position || 0);
              });
              return (
                <div key={g} className="r2-expand-group">
                  <span className="r2-expand-label">{groupLabels[g] || g}</span>
                  {sorted.map((w) => (
                    <span key={w.ID}
                          className={"wcard wcard-mini" + (w.Selling ? " selling" : "")}>
                      <span className="en">{w.En}</span>
                      {w.Ru && <span className="ru">{w.Ru}</span>}
                    </span>
                  ))}
                </div>
              );
            })}
          </div>
        </div>
      )}

      {/* KEYWORDS — full width */}
      <div className="r2-section">
        <div className="r2-label">
          <span>Keywords</span>
          <span className={"r2-counter " + (active.length === 50 ? "ok" : "warn")}>
            {active.length} / 50 · топ-10 продающие
            {active.length === 50 && <Icon name="check" size={11} stroke={2.4} style={{ marginLeft: 4, verticalAlign: "-2px" }}/>}
          </span>
          {untranslatedCount > 0 && (
            <span style={{
              fontSize: 11, color: "var(--accent-2)",
              marginLeft: 8, display: "inline-flex", gap: 4, alignItems: "center",
            }}>
              <Spinner/> переводим {untranslatedCount}…
            </span>
          )}
        </div>
        <div className="r2-keys"
             onDragOver={onContainerDragOver}
             onDrop={onContainerDrop}>
          {active.length === 0 && (
            <span className="muted-2" style={{ fontSize: 12, fontStyle: "italic" }}>
              ключи появятся после «Принять серию» в Ревью 1
            </span>
          )}
          {active.map((k, i) => (
            <KwCard key={k.ID}
                    idx={i + 1}
                    en={k.En}
                    ru={k.Ru}
                    top={i < 10}
                    selling={k.Selling}
                    banner={bannerSet.has(k.En)}
                    suspect={suspectSet.has(k.En)}
                    onRemove={() => removeKw(k.ID)}
                    dragging={dragID === k.ID}
                    dropSide={dropTarget && dropTarget.idx === i && dragID !== k.ID ? dropTarget.side : null}
                    onDragStart={(e) => onDragStart(e, k.ID)}
                    onDragEnd={onDragEnd}
                    onDragOverCard={(e) => onDragOverCard(e, i, k.ID)}
                    onDropCard={(e) => onDropCard(e, i, k.ID)}/>
          ))}
          {/* Трейлинг dropzone — drop в конец, занимает остаток строки.
              Большая зона (flex: 1) исключает «промахи на gap'е». */}
          {dragID != null && (
            <span onDragOver={(e) => {
                    e.preventDefault(); e.stopPropagation();
                    setDropTarget({ idx: active.length - 1, side: "right" });
                  }}
                  onDrop={(e) => { e.preventDefault(); e.stopPropagation(); handleDrop(active.length); }}
                  style={{
                    flex: "1 1 60px", minHeight: 24, alignSelf: "stretch",
                    background: dropTarget && dropTarget.idx === active.length - 1 && dropTarget.side === "right"
                      ? "var(--accent-soft)" : "transparent",
                    border: "1px dashed " + (dropTarget && dropTarget.idx === active.length - 1 && dropTarget.side === "right"
                      ? "var(--accent)" : "transparent"),
                    borderRadius: 4, transition: "background 100ms, border-color 100ms",
                  }}/>
          )}
        </div>
        <div className="kw-add">
          <input className="input" value={newKw}
                 placeholder={addBusy ? "перевожу…" : "добавить ключ (EN или RU, можно через запятую)…"}
                 title="RU переведётся автоматически. Несколько слов разделяй запятой."
                 disabled={addBusy}
                 onChange={(e) => setNewKw(e.target.value)}
                 onKeyDown={(e) => e.key === "Enter" && addKw()}/>
          <button className="btn btn-sm" onClick={addKw} disabled={addBusy || !newKw.trim()}
                  title={addBusy ? "перевожу и добавляю…" : ""}>
            {addBusy
              ? <><Spinner size={11}/> Добавляю…</>
              : <><Icon name="plus" size={11} stroke={2.2}/> Добавить</>}
          </button>
          {spare.length > 0 && (
            <span className="muted kw-spare-label">запасные:</span>
          )}
          {spare.map((k) => (
            <SpareKwCard key={k.ID} kw={k} onRestore={restoreSpare}/>
          ))}
        </div>
        <div className="muted" style={{ fontSize: 11, marginTop: 6 }}>
          × — переместить в запасные · клик по запасному — добавить в конец списка
        </div>
      </div>

      {/* Поле «Комментарий к фото» убрано по запросу Лены 2026-06-05 —
          оно ничего не сохраняло, занимало место. */}
    </div>
  );
}

function Review2Screen({ shootID, setRoute, isAdmin }) {
  const [shootState] = useAsync(async () => {
    const list = await api.feed.shoots();
    return (list || []).find((s) => s.ID === shootID) || null;
  }, [shootID]);
  const [photosState, reloadPhotos] = useAsync(() => api.feed.photos(shootID), [shootID]);
  const [wordsState] = useAsync(() => api.feed.analysis(shootID), [shootID]);
  const [_, toast] = useToasts();
  const [exporting, setExporting] = React.useState(false);
  const [regenerating, setRegenerating] = React.useState(false);
  const [reshuffling, setReshuffling] = React.useState(false);
  const [expanded, setExpanded] = React.useState(null); // photoID or null

  const shoot = shootState.data;
  const photos = photosState.data || [];
  const words = wordsState.data || [];

  const exportCSV = async () => {
    setExporting(true);
    try {
      await api.shoot.AcceptReview2(shootID);
      toast("CSV отправлен в _Pikasrv/, серия отправлена на EXIF-watcher", "info");
    } catch (e) {
      toast("Не удалось выгрузить CSV: " + e.message, "error");
    } finally {
      setExporting(false);
    }
  };

  // «Перегенерировать» — переанализ Title/Description/Keywords для всех фоток
  // серии. Используем тот же AcceptReview1 что и кнопка «Принять серию» в
  // Ревью 1 — он делает Assemble (keywords) + Generate (тексты) для каждой
  // recognized фотки. Теперь генерация ФОНОВАЯ (очередь на бэке): RPC
  // возвращается сразу, тексты пишутся в фоне и видны слева в «В работе»
  // (правка 2026-06-24). После прогона shoot остаётся в review2.
  const regenerate = async () => {
    if (!confirm("Перегенерировать тексты и ключи для всей серии?\n\n" +
      "Pika_Writer перепишет Title, Description и пересоберёт 50 ключей\n" +
      "для каждой фотки. Твои ручные правки в этих полях будут потеряны.\n" +
      "Анализ слов и акценты не трогаются.\n\n" +
      "Идёт в фоне (~30-60 сек на фотку), прогресс — слева в «В работе».")) return;
    setRegenerating(true);
    try {
      await api.shoot.AcceptReview1(shootID);
      toast("Тексты переписываются в фоне — прогресс слева в «В работе»", "info");
      setRoute("shoots");
    } catch (e) {
      toast("Не удалось: " + e.message, "error");
    } finally {
      setRegenerating(false);
    }
  };

  // «Перетасовать продающие + переписать» (только админ) — для случая, когда
  // тексты вышли однотипными, потому что распределение продающих по серии не
  // отработало (похожие кадры → почти одинаковый топ продающих → samey тексты,
  // жалоба Лены по серии 61, правка 2026-06-26). Один клик делает два шага:
  //   1) Redistribute — перетасовывает продающие синонимы по серии (якорь +
  //      разнообразие), синхронно (~минуту);
  //   2) AcceptReview1 — переписывает тексты/ключи из НОВЫХ продающих (в фоне).
  // Просто «перетасовать» в Ревью 2 смысла не имеет: тексты уже сгенерированы и
  // от смены флагов selling сами не меняются — поэтому сразу и переписываем.
  const reshuffleAndRegen = async () => {
    if (!confirm("Перетасовать продающие по серии и переписать тексты?\n\n" +
      "1) Перераспределим продающие синонимы (разнообразие по серии);\n" +
      "2) Перепишем Title/Description и 50 ключей у каждой фотки.\n\n" +
      "Твои ручные правки текстов будут потеряны.\n" +
      "Шаг 1 — синхронно (~минуту), шаг 2 — в фоне (виден слева).")) return;
    setReshuffling(true);
    try {
      await api.feed.redistributeSelling(shootID); // ждём — перетасовка
      await api.shoot.AcceptReview1(shootID);       // ставит генерацию в фон
      toast("Продающие перетасованы, тексты переписываются в фоне", "info");
      setRoute("shoots");
    } catch (e) {
      toast("Не удалось: " + e.message, "error");
    } finally {
      setReshuffling(false);
    }
  };

  // CSV preview + Скачать — генерируем CSV на фронте по формату §9.7:
  // 4 колонки (filename, title, description, keywords через запятую),
  // без BOM, без заголовка, всё в кавычках, символы `/` и `'` убраны.
  // Грузим keywords по всем фоткам один раз; пересчитываем при любом
  // изменении photos (после Перегенерировать) или после удаления ключа.
  const [allKws, setAllKws] = React.useState({});
  const [refreshingCsv, setRefreshingCsv] = React.useState(false);
  // Авто-загрузка keywords для CSV-превью. ВАЖНО: зависим от СТРОКИ id-шников,
  // а не от массива photos. reloadPhotos меняет содержимое (Title/Description),
  // но НАБОР id тот же — поэтому loadKws не пересоздаётся, и авто-эффект не
  // зацикливается. Раньше (правка #42) reloadAllKws и зависел от photos, и сам
  // звал reloadPhotos → photos менялись → коллбэк пересоздавался → useEffect
  // снова его запускал → бесконечный reload-цикл «всё мигает» (фикс 2026-06-07).
  const photoIDs = photos.map((p) => p.ID).join(",");
  const loadKws = React.useCallback(async () => {
    if (!photos.length || !api.feed.keywords) return;
    setRefreshingCsv(true);
    try {
      const rs = await Promise.all(photos.map((p) =>
        api.feed.keywords.list(p.ID).then((ks) => ({ pid: p.ID, ks: Array.isArray(ks) ? ks : [] }))
      ));
      const m = {};
      for (const r of rs) m[r.pid] = r.ks;
      setAllKws(m);
    } catch (_) {
      // тихо игнорируем — старые данные останутся
    } finally {
      setRefreshingCsv(false);
    }
    // photos используется только ради набора id (.map(p=>p.ID)) — он стабилен,
    // поэтому зависимость по photoIDs корректна.
  }, [photoIDs]);
  React.useEffect(() => { loadKws(); }, [loadKws]);

  // «Внести изменения» — подтянуть свежие Title/Description (после autosave из
  // EditableText) И keywords. Тут reloadPhotos зовём явно, по клику — это не
  // создаёт цикл, потому что авто-эффект завязан на стабильный photoIDs.
  const reloadAllKws = React.useCallback(async () => {
    await reloadPhotos();
    await loadKws();
  }, [reloadPhotos, loadKws]);

  // Сборка строки CSV для одной фотки. Активные (не spare) keywords
  // через запятую в одной ячейке. Кавычки и запрещённые символы режутся.
  const buildCsvRow = (p) => {
    const clean = (s) => (s || "").replace(/[/']/g, "").replace(/"/g, "");
    const kws = (allKws[p.ID] || [])
      .filter((k) => !k.Spare)
      .sort((a, b) => (a.Position || 0) - (b.Position || 0))
      .map((k) => k.En)
      .join(", ");
    return `"${clean(p.Filename)}","${clean(p.Title)}","${clean(p.Description)}","${kws}"`;
  };

  const csvAll = photos
    .filter((p) => p.Title && p.Description)
    .map(buildCsvRow)
    .join("\n");

  // В превью теперь показываем ВСЕ строки (правка 2026-06-05 по запросу Лены —
  // блок шире, скроллируется по вертикали). Раньше резали до 2 — Лена не
  // могла проверить целиком CSV перед «Принять серию».
  const csvSample = csvAll;

  // Кнопка «Скачать» — формирует Blob и триггерит download. Имя файла —
  // <shootTitle>_preview.csv чтобы не путать с финальным `_done.csv`.
  const downloadCsv = () => {
    if (!csvAll) {
      toast("Нечего скачивать — у фоток нет текстов", "error");
      return;
    }
    const blob = new Blob([csvAll], { type: "text/csv;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = (shoot?.Title || `series-${shootID}`) + "_preview.csv";
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  };

  return (
    <div className="page" style={{ maxWidth: 1100, paddingTop: 16 }}>
      <div className="page-header">
        <div>
          <div className="row" style={{ color: "var(--ink-3)", fontSize: 12.5, marginBottom: 4 }}>
            <button className="btn-ghost btn btn-sm" onClick={() => setRoute("shoots")}>
              ← Мои съёмки
            </button>
            <span className="muted-2">/</span>
            <button className="btn-ghost btn btn-sm" onClick={() => setRoute("series", shootID)}>
              {shoot?.Title || `Серия ${shootID}`}
            </button>
            <span className="muted-2">·</span>
            <span>{photos.length} файлов</span>
          </div>
          <h1 className="page-title">Ревью 2 · Тексты</h1>
          <div className="page-sub">
            Title и Description правишь прямо в полях. Keywords — карточки с ru, ✕ убирает в запасные.
          </div>
        </div>
        <div className="page-actions">
          {isAdmin && (
            <button className="btn" onClick={reshuffleAndRegen}
                    disabled={reshuffling || regenerating || !photos.length}
                    title="Перетасовать продающие по серии (разнообразие) и переписать тексты из новых продающих. Помогает, когда тексты вышли однотипными.">
              {reshuffling
                ? <><Spinner/> тасую + переписываю…</>
                : <><Icon name="layers" size={13} stroke={2}/> Перетасовать + переписать</>}
            </button>
          )}
          <button className="btn" onClick={regenerate} disabled={regenerating || reshuffling || !photos.length}>
            {regenerating
              ? <><Spinner/> перегенерируем…</>
              : <><Icon name="refresh" size={13} stroke={2}/> Перегенерировать</>}
          </button>
          <button className="btn btn-accent" onClick={exportCSV} disabled={exporting || !photos.length}>
            {exporting
              ? <><Spinner/> выгружаю…</>
              : <><Icon name="check" size={13} stroke={2}/> Принять серию · CSV в папку</>}
          </button>
        </div>
      </div>

      {/* Pipeline status strip — этапы конвейера с зелёными/серыми индикаторами.
          Считается по тому что реально в БД на этой серии. */}
      {shoot && (
        <PipelineStrip shoot={shoot} photos={photos} words={words}/>
      )}

      {photosState.loading && <div className="muted" style={{ padding: 24 }}><Spinner/> загружаем фото…</div>}

      {!photosState.loading && photos.length === 0 && (
        <PlaceholderStub
          title="В этой серии пока нет фотографий"
          body="Создайте серию через «Обработать новые»."
        />
      )}

      <div>
        {photos.map((p) => (
          <Review2PhotoBlock key={p.ID}
                             photo={p}
                             allWords={words}
                             isExpanded={expanded === p.ID}
                             onToggleExpand={() => setExpanded(expanded === p.ID ? null : p.ID)}/>
        ))}
      </div>

      {/* CSV preview — содержимое файла который уйдёт в _Pikasrv/_done.csv.
          Правка 2026-06-05: показываем ВСЕ строки (не только первые 2),
          максимум 420px высоты с вертикальной прокруткой, плюс кнопка
          «Обновить» — после правок ключей / Title / Description Лена жмёт её
          и видит свежий CSV до «Принять серию». */}
      {photos.length > 0 && csvSample && (
        <div className="card" style={{ marginTop: 18 }}>
          <div className="card-pad" style={{ paddingBottom: 12 }}>
            <div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start" }}>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 13.5, fontWeight: 500 }}>
                  Превью CSV
                  <span className="muted" style={{ fontSize: 11, marginLeft: 8, fontWeight: 400 }}>
                    · строк: {csvAll ? csvAll.split("\n").length : 0} / {photos.length}
                  </span>
                </div>
                <div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
                  4 колонки · английский · без BOM, без заголовка · в кавычках · →{" "}
                  <span className="mono">_Pikasrv/_done.csv</span>
                </div>
              </div>
              <div style={{ display: "flex", gap: 6 }}>
                <button className="btn btn-sm" onClick={reloadAllKws}
                        disabled={refreshingCsv || !photos.length}
                        title="Подтянуть свежие ключи / Title / Description у всех фоток. Жми если меняла ключи или двигала порядок.">
                  {refreshingCsv
                    ? <><Spinner size={11}/> Обновляю…</>
                    : <><Icon name="refresh" size={12} stroke={2}/> Внести изменения</>}
                </button>
                <button className="btn btn-sm" onClick={downloadCsv}
                        disabled={!csvAll}
                        title="Скачать CSV локально (для проверки формата). На Я.Диск — через «Принять серию» сверху.">
                  <Icon name="download" size={12} stroke={2}/> Скачать
                </button>
              </div>
            </div>
          </div>
          <hr className="divider"/>
          <pre style={{
            margin: 0, padding: "12px 18px", fontFamily: "var(--font-mono)",
            fontSize: 11.5, lineHeight: 1.7, color: "var(--ink-2)",
            background: "var(--surface-2)", overflowX: "auto",
            overflowY: "auto", maxHeight: 420,
            whiteSpace: "pre", // длинные строки уходят в горизонтальный скролл
            borderBottomLeftRadius: "var(--r-lg)", borderBottomRightRadius: "var(--r-lg)",
          }}>{csvSample}</pre>
        </div>
      )}
    </div>
  );
}

// PipelineStrip — узкая полоса этапов конвейера серии. Берёт реальное
// состояние из shoot + counts photos/words. Зелёная точка — этап пройден,
// серая — ещё нет.
function PipelineStrip({ shoot, photos, words }) {
  const recCount = photos.filter((p) => p.Stage === "recognized").length;
  const total = photos.length;
  const recOk = total > 0 && recCount === total;
  const planOk = photos.some((p) => p.AccentEn);
  const anaOk = words.length > 0;
  const textsOk = photos.some((p) => p.Title);
  const stage = shoot.Stage;
  const csvOk = stage === "csv" || stage === "done";
  const exifOk = stage === "done";

  const items = [
    { l: "Распознано", v: `${recCount} / ${total}`, ok: recOk },
    { l: "План", v: planOk ? "акценты есть" : "—", ok: planOk },
    { l: "Анализ", v: anaOk ? "принят" : "—", ok: anaOk },
    { l: "Тексты", v: textsOk ? "сгенерированы" : "ждут", ok: textsOk },
    { l: "Ключи", v: textsOk ? "собраны" : "—", ok: textsOk },
    { l: "CSV", v: csvOk ? "отправлен" : "ждёт", ok: csvOk },
    { l: "EXIF", v: exifOk ? "записан" : "watcher", ok: exifOk },
  ];

  return (
    <div style={{
      display: "flex", gap: 8, padding: "10px 14px",
      background: "var(--surface)", border: "1px solid var(--line)",
      borderRadius: "var(--r-lg)", marginBottom: 14,
      alignItems: "center", fontSize: 12.5, flexWrap: "wrap",
    }}>
      {items.map((s, i) => (
        <React.Fragment key={s.l}>
          <div className="row" style={{ gap: 6 }}>
            <span className="pdot" style={{
              width: 7, height: 7, borderRadius: "50%",
              background: s.ok ? "var(--ok)" : "var(--ink-4)", display: "inline-block",
            }}/>
            <span style={{ color: s.ok ? "var(--ink)" : "var(--ink-3)" }}>
              {s.l}{" "}
              <span className="mono" style={{ color: "var(--ink-3)", marginLeft: 4 }}>{s.v}</span>
            </span>
          </div>
          {i < items.length - 1 && (
            <Icon name="chevron" size={10} className="ic" style={{ color: "var(--ink-4)" }}/>
          )}
        </React.Fragment>
      ))}
    </div>
  );
}

window.Review2Screen = Review2Screen;
