// web/screens/review1.jsx — Ревью 1 · Анализ.
// Подсасывает живые analysis_words из /v1/analysis?shootID=N и рисует их
// по группам Объект / Действие / Роль / Эмоция / Место.

const ANA_GROUPS = [
  { id: "object",  label: "Объект"   },
  { id: "action",  label: "Действие" },
  { id: "role",    label: "Роль"     },
  { id: "emotion", label: "Эмоция"   },
  { id: "place",   label: "Место"    },
];

// db.AnalysisWord.GroupKind пишется как "object" / "action" / "role" / "emotion" / "place".
function wordsByGroup(words) {
  const out = {};
  for (const g of ANA_GROUPS) out[g.id] = [];
  for (const w of words || []) {
    const g = (w.GroupKind || "").toLowerCase();
    if (out[g]) out[g].push(w);
  }
  // Внутри каждой группы — сортировка по Position.
  for (const g of ANA_GROUPS) {
    out[g.id].sort((a, b) => (a.Position || 0) - (b.Position || 0));
  }
  return out;
}

function Review1PhotoBlock({ photo, allWords, allComments, reloadComments, onAnaChanged, activeJobs }) {
  const myWords = (allWords || []).filter((w) => w.PhotoID === photo.ID);
  const grouped = wordsByGroup(myWords);
  const isFailed = photo.Stage === "failed";

  // Два типа комментариев Лены для каждой фотки (см. дизайн §21.4):
  //   "fix"  — поправка распознавания
  //   "more" — дополнительная информация
  // Pikasrv хранит в reviewComments и подмешивает в AnalysePrompt при
  // «Применить правки» (review.ApplyAll → reAnalyse).
  // Внимание: review.ListComments (RPC через zenrpc) сериализует поля в
  // lowerCamelCase: c.photoId / c.commentType / c.body. REST-эндпоинты типа
  // /v1/photos возвращают UpperCamelCase (ID, Filename). Это давало баг
  // 2026-06-02: после reload текстовое поле «Поправка распознавания» было
  // пустым, потому что myComments фильтровал по c.PhotoID (===undefined)
  // и получал пустой массив. Теперь обращаемся к камелкейс-полям из RPC.
  const myComments = (allComments || []).filter((c) => c.photoId === photo.ID);
  const findText = (kind) => {
    const c = myComments.find((c) => c.commentType === kind);
    return c ? c.body : "";
  };
  const [fix, setFix] = React.useState(findText("fix"));
  const [more, setMore] = React.useState(findText("more"));
  const [_, toast] = useToasts();

  // fixRef — всегда содержит АКТУАЛЬНОЕ значение fix (без stale closure).
  // ReanalyseButton.run() через getCurrentText() читает именно ref,
  // чтобы при клике на кнопку даже если textarea не blur'нулась,
  // мы могли явно upsert'нуть текущий текст до запуска reanalyse.
  const fixRef = React.useRef(fix);
  React.useEffect(() => { fixRef.current = fix; }, [fix]);

  // При появлении свежих comments (например после reload) подтягиваем их —
  // НО только если у нас нет «висящего» локального ввода. Иначе reload
  // после reanalyse / параллельный save могли обнулять textarea если
  // запрос успевал раньше нашего save (баг 2026-06-02).
  React.useEffect(() => {
    const server = findText("fix");
    // Если локальный fix совпадает с тем что в БД — обновляем (no-op).
    // Если различается И сервер вернул непустое — берём с сервера (свежее).
    // Если различается И сервер пустой — оставляем локальный (он или
    // только что введён, или save ещё не отработал).
    if (server !== fix && server !== "") {
      setFix(server);
    }
    const moreSrv = findText("more");
    if (moreSrv !== more && moreSrv !== "") {
      setMore(moreSrv);
    }
  }, [allComments]);

  // Сохраняем на blur через атомарный upsert. Раньше делали два запроса
  // (Delete + Add) — если Add падал, текст пропадал из БД и UI обнулял
  // textarea (баг 2026-06-02 «при клике на Перераспознать поле очищалось»).
  // upsertComment делает обе операции в одном handler'е: сначала вставка
  // новой записи, потом удаление старой; при ошибке вставки старая
  // НЕ удаляется.
  const save = async (kind, text) => {
    const existing = myComments.find((c) => c.commentType === kind);
    const orig = existing ? existing.body : "";
    if (text === orig) return;
    try {
      await api.feed.upsertComment(photo.ShootID, photo.ID, kind, text);
      reloadComments && reloadComments();
    } catch (e) {
      toast("Не удалось сохранить комментарий: " + e.message, "error");
    }
  };

  // Голосовой ввод правки распознавания — дописать + сразу сохранить.
  const appendFix = (t) => {
    setFix((prev) => {
      const next = (prev.trim() ? prev.trim() + " " : "") + t;
      api.feed.upsertComment(photo.ShootID, photo.ID, "fix", next)
        .then(() => reloadComments && reloadComments())
        .catch(() => toast("Не удалось сохранить", "error"));
      return next;
    });
  };

  return (
    <div className={"photo-block" + (isFailed ? " photo-block--failed" : "")}
         style={{ marginBottom: 14 }}>
      <div className="photo-side">
        <div className="photo-img" style={{ background: "var(--bg)" }}>
          {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" }}/>
            : null}
        </div>
        <div className="filename">
          <Icon name="image" size={11}/>
          <span>{photo.Filename}</span>
        </div>
        {/* Акцент теперь редактируется в правой колонке как 6-й блок
            (AccentBlock); здесь он не дублируется. */}
        {photo.Gist && <GistBlock gist={photo.Gist} recognition={photo.Recognition || ""}/>}

        {/* Два типа комментариев (дизайн §21.4) — Лена видит распознавание
            и может поправить ошибки или добавить контекст. Уйдёт в analyser
            при «Применить правки». */}
        {!isFailed && (
          <>
            <div className="field field-grow" style={{ marginTop: 10 }}>
              <label className="label" style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
                <span><Icon name="edit" size={10}/> Поправка распознавания</span>
                <MicButton onInsert={appendFix}/>
              </label>
              <textarea
                placeholder="напр.: это мужчина, не женщина"
                value={fix}
                onChange={(e) => setFix(e.target.value)}
                onBlur={() => save("fix", fix)}
                rows={2}
                style={{ width: "100%", padding: "6px 8px", fontSize: 12,
                         borderRadius: "var(--r)", border: "1px solid var(--line-2)",
                         resize: "vertical", fontFamily: "inherit" }}/>
              <ReanalyseButton photo={photo}
                               hasFix={(fix || "").trim().length > 0}
                               getCurrentText={() => fixRef.current}
                               onDone={onAnaChanged}
                               activeJobs={activeJobs}/>
            </div>
            <div className="field field-grow" style={{ marginTop: 8 }}>
              <label className="label">
                <Icon name="alert" size={10}/> Доп. комментарий
              </label>
              <textarea
                placeholder="напр.: место — Подмосковье"
                value={more}
                onChange={(e) => setMore(e.target.value)}
                onBlur={() => save("more", more)}
                rows={2}
                style={{ width: "100%", padding: "6px 8px", fontSize: 12,
                         borderRadius: "var(--r)", border: "1px solid var(--line-2)",
                         resize: "vertical", fontFamily: "inherit" }}/>
            </div>
          </>
        )}
      </div>

      {isFailed ? (
        <div className="failed-pane">
          <Icon name="alert" size={28} stroke={1.5} style={{ color: "var(--warn)", marginBottom: 12 }}/>
          <div style={{ fontSize: 15, fontWeight: 500 }}>Фото не распозналось</div>
          <button className="btn btn-primary" style={{ marginTop: 12 }} disabled>
            Распознать заново
          </button>
        </div>
      ) : (
        <div>
          <div className="ana-groups">
            {ANA_GROUPS.map((g) => (
              <AnaGroupBlock key={g.id}
                             group={g}
                             words={grouped[g.id] || []}
                             photoID={photo.ID}
                             onChanged={onAnaChanged}/>
            ))}
            {/* Акцент — последний блок (после Места), стилизован как ana-group.
                Одно редактируемое поле; перевод второго языка добирается на
                бэке через короткий запрос к Pika_Writer (см. accent.go). */}
            <AccentBlock photo={photo} onSaved={onAnaChanged}/>
          </div>
        </div>
      )}
    </div>
  );
}

// GistBlock — карточка с GIST + клик-открывающийся поповер с полным текстом
// recognition. Поповер рендерится через ReactDOM.createPortal в document.body,
// чтобы не обрезаться overflow:hidden родителя. Click вместо hover чтобы
// можно было выделять и копировать текст.
function GistBlock({ gist, recognition }) {
  const [open, setOpen] = React.useState(false);
  const anchorRef = React.useRef(null);
  const [pos, setPos] = React.useState({ top: 0, left: 0 });

  const toggle = () => {
    if (!recognition) return;
    if (!open && anchorRef.current) {
      const r = anchorRef.current.getBoundingClientRect();
      // Прижимаем к якорю снизу; если справа не помещается — сдвигаем влево.
      const w = Math.min(560, window.innerWidth - 24);
      let left = r.left;
      if (left + w > window.innerWidth - 12) left = window.innerWidth - w - 12;
      if (left < 12) left = 12;
      setPos({ top: r.bottom + 6, left, width: w });
    }
    setOpen((v) => !v);
  };

  return (
    <>
      <div ref={anchorRef} onClick={toggle}
           style={{
             marginTop: 10, padding: "8px 10px",
             background: "var(--surface-2)", borderRadius: "var(--r)",
             fontSize: 11, color: "var(--ink-3)", lineHeight: 1.4,
             cursor: recognition ? "pointer" : "default",
             userSelect: "none",
           }}>
        <b>GIST:</b> {gist}
        {recognition && (
          <span className="muted" style={{ marginLeft: 6, fontSize: 10 }}>
            · клик — {open ? "скрыть" : "показать полный recognition"}
          </span>
        )}
      </div>

      {open && recognition && ReactDOM.createPortal(
        <>
          {/* фон-overlay: клик по нему закрывает поповер */}
          <div onClick={() => setOpen(false)}
               style={{ position: "fixed", inset: 0, zIndex: 99, background: "transparent" }}/>
          <div style={{
            position: "fixed",
            top: pos.top, left: pos.left, width: pos.width,
            maxHeight: "60vh", overflowY: "auto", zIndex: 100,
            padding: "12px 14px",
            background: "var(--surface, #fff)",
            border: "1px solid var(--line-2, #ccc)",
            borderRadius: "var(--r-lg, 8px)",
            boxShadow: "0 10px 32px rgba(0,0,0,0.22)",
            fontSize: 12, lineHeight: 1.55, color: "var(--ink-2)",
            whiteSpace: "pre-wrap", wordBreak: "break-word",
          }}>
            <div style={{
              display: "flex", justifyContent: "space-between", alignItems: "center",
              marginBottom: 8,
            }}>
              <div style={{
                fontSize: 10, color: "var(--ink-4)",
                textTransform: "uppercase", letterSpacing: "0.06em",
              }}>Полный ответ Pika_Painter</div>
              <button onClick={() => setOpen(false)}
                      style={{
                        background: "transparent", border: 0, cursor: "pointer",
                        fontSize: 14, color: "var(--ink-3)", lineHeight: 1,
                      }}>×</button>
            </div>
            {recognition}
          </div>
        </>,
        document.body
      )}
    </>
  );
}

// ReanalyseButton — кнопка «Переделать» под textarea «Поправка распознавания»
// (правка 2026-06-02 — заменила прежнюю «Перераспознать с поправкой»).
//
// Pika_Writer (текстовая модель) пересчитает акцент и переанализирует
// слова этой фотки с учётом её комментариев. Vision (Pika_Painter) НЕ
// дёргается — recognition_text остаётся прежним. ~30-60 сек.
//
// Принимает getCurrentText() — callback который возвращает АКТУАЛЬНЫЙ
// текст из textarea на момент клика (не из props, чтобы не было stale
// closure). Перед запуском reanalyse явно сохраняем текст в БД через
// upsert — это снимает race condition с blur'ом textarea, который иначе
// конкурировал с click'ом и мог либо опоздать с записью, либо запустить
// параллельный reloadComments который обнулял textarea (баг 2026-06-02
// «жму переделать, поле очищается, кнопка пропадает»).
//
// Без confirm — операция не destructive, Лена просила не пугать confirm'ами.
function ReanalyseButton({ photo, hasFix, getCurrentText, onDone, activeJobs }) {
  const [busy, setBusy] = React.useState(false);
  const [_, toast] = useToasts();
  if (!hasFix) return null;

  // Серверная очередь (правка 2026-06-05): если в JobTracker уже есть job
  // для этой фотки — кто-то жмёт «Переделать» из другой вкладки / ждёт
  // ответа сервера. Покажем спиннер и заблокируем повтор.
  const serverBusy = (activeJobs || []).some(
    (j) => j.photoId === photo.ID && (j.op || "").includes("анализ"),
  );
  const isBusy = busy || serverBusy;

  const run = async (e) => {
    if (e) { e.preventDefault(); e.stopPropagation(); }
    if (isBusy) return;
    setBusy(true);
    try {
      // 1. Гарантированно сохраняем актуальный fix-коммент.
      //    Если Лена не blur'нула textarea — текст всё равно уйдёт в БД.
      //    Если уже blur'нула и save отработал — этот upsert идемпотентен.
      const text = (getCurrentText ? getCurrentText() : "") || "";
      await api.feed.upsertComment(photo.ShootID, photo.ID, "fix", text);
      // 2. Только теперь — reanalyse, который читает свежие комментарии.
      await api.feed.reanalysePhoto(photo.ID);
      toast("Анализ обновлён", "info");
      onDone && onDone();
    } catch (e2) {
      toast("Не удалось: " + e2.message, "error");
    } finally {
      setBusy(false);
    }
  };

  // Подпись зависит от того откуда busy: если только локально (наш клик) —
  // «переделываем…»; если уже из JobTracker (другая вкладка/перезагрузка) —
  // «идёт переанализ…».
  const busyLabel = busy
    ? "переделываем…"
    : serverBusy
      ? "идёт переанализ…"
      : null;

  return (
    <button type="button" onClick={run} disabled={isBusy}
            title={serverBusy && !busy ? "сервер уже обрабатывает эту фотку — подожди" : ""}
            style={{
              marginTop: 6, padding: "5px 10px", fontSize: 11.5,
              background: isBusy ? "var(--surface-2)" : "var(--accent-soft)",
              color: "var(--accent-2)",
              border: "1px solid var(--accent)",
              borderRadius: "var(--r)", cursor: isBusy ? "wait" : "pointer",
              fontFamily: "inherit",
              display: "inline-flex", alignItems: "center", gap: 6,
            }}>
      {busyLabel
        ? <><Spinner/> {busyLabel}</>
        : <><Icon name="refresh" size={11}/> Переделать</>}
    </button>
  );
}

// WordCard — карточка слова в группе анализа. Поддерживает:
//   • drag по карточке: можно перенести в "Продающие" / "Синонимы" (в той же
//     категории) и переставить внутри ряда (точное место — между двумя карточками)
//   • soft-delete по ✕ с 5-секундным окном «Откатить» (потом hard-delete)
function WordCard({ w, onToggleSelling, onDelete, onDragStart, onDragEnd, dragging, highlight,
                    // dropSide: "left" | "right" | null — для подсветки точки вставки
                    // прямо на карточке (правка 2026-06-05, как в Ревью 2). Раньше
                    // были узкие 8px Gap-zone'ы — Лена в них промахивалась.
                    dropSide, onDragOverCard, onDropCard }) {
  const [pending, setPending] = React.useState(false); // флаг «в очереди на удаление»
  const timerRef = React.useRef(null);
  // wasDraggingRef — флаг что только что закончился drag. Браузер после
  // dragend всё равно может выстрелить click; без этого флага каждое
  // перетаскивание ещё и переключало бы selling.
  const wasDraggingRef = React.useRef(false);
  const [_, toast] = useToasts();

  const handleDeleteClick = () => {
    if (pending) return;
    setPending(true);
    timerRef.current = setTimeout(async () => {
      try {
        await api.feed.deleteAnalysisWord(w.ID);
        onDelete && onDelete(w.ID); // родитель уберёт из state
      } catch (e) {
        toast("Не удалось удалить: " + e.message, "error");
        setPending(false);
      }
    }, 5000);
  };
  const handleUndo = () => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = null;
    setPending(false);
  };
  React.useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);

  // Клик по самой карточке — переключает selling и роняет слово в конец
  // нового ряда (правка 2026-05-31). Drag и pending игнорируем.
  const handleCardClick = (e) => {
    if (pending) return;
    if (wasDraggingRef.current) { wasDraggingRef.current = false; return; }
    onToggleSelling && onToggleSelling();
  };

  // Подсветка точки вставки слева/справа толстой бирюзовой линией.
  const indicatorStyle = dropSide === "left"
    ? { boxShadow: "inset 3px 0 0 0 var(--accent)" }
    : dropSide === "right"
      ? { boxShadow: "inset -3px 0 0 0 var(--accent)" }
      : null;

  return (
    <div className={"wcard" + (w.Selling ? " selling" : "") + (dragging ? " dragging" : "") + (highlight ? " wcard--flash" : "")}
         title={(w.Ru ? w.Ru + " · " : "") + "клик — перенести в " + (w.Selling ? "синонимы" : "продающие")}
         draggable={!pending}
         onClick={handleCardClick}
         onDragStart={(e) => { wasDraggingRef.current = true; onDragStart && onDragStart(e, w); }}
         onDragEnd={(e) => { onDragEnd && onDragEnd(e); /* флаг сбросится в handleCardClick на следующем клике */ }}
         onDragOver={onDragOverCard}
         onDrop={onDropCard}
         style={{
           opacity: pending ? 0.35 : 1,
           transition: "opacity 150ms",
           position: "relative",
           cursor: pending ? "default" : "pointer",
           ...(indicatorStyle || {}),
         }}>
      <span className="en">{w.En}</span>
      {w.Ru && <span className="ru">{w.Ru}</span>}

      {pending ? (
        <button onClick={(e) => { e.stopPropagation(); handleUndo(); }}
                style={{
                  position: "absolute", top: 2, right: 2,
                  padding: "1px 5px", fontSize: 9, lineHeight: 1.2,
                  background: "var(--accent-soft)", color: "var(--accent-2)",
                  border: "1px solid var(--accent)", borderRadius: 99,
                  cursor: "pointer", fontFamily: "inherit",
                }}>
          ⟲ Откатить
        </button>
      ) : (
        <button onClick={(e) => { e.stopPropagation(); handleDeleteClick(); }}
                title="Удалить (через 5 сек, можно откатить)"
                style={{
                  position: "absolute", top: 1, right: 2,
                  width: 14, height: 14, padding: 0,
                  fontSize: 10, lineHeight: 1,
                  background: "transparent", border: 0, cursor: "pointer",
                  color: "var(--ink-4)", opacity: 0.6,
                }}>
          ✕
        </button>
      )}
    </div>
  );
}

// AccentBlock — последний блок правой колонки Ревью 1. Стилизован как обычная
// ana-group (Объект / Действие / …), но без рядов «Продающие/Синонимы» —
// акцент это одна короткая фраза, а не набор слов.
//
// Одно редактируемое поле — Лена пишет на удобном языке. Перевод во второй
// язык докатывается на бэке через короткий запрос к Pika_Writer (см.
// pkg/app/accent.go) и показывается мелким серым под основным полем —
// так же как `.ru` у обычных wcard.
//
// AccentEn критичен для детерминированной сборки 50 ключей (pkg/pika режет
// его на токены), поэтому второе поле должно быть заполнено всегда.
function AccentBlock({ photo, onSaved }) {
  // text — то что в textarea сейчас. Инициализируем «русским первым», потому
  // что Лена русскоязычная: если есть AccentRu — показываем его, иначе EN.
  const initial = photo.AccentRu || photo.AccentEn || "";
  const [text, setText] = React.useState(initial);
  const [busy, setBusy] = React.useState(false);
  const [_, toast] = useToasts();

  React.useEffect(() => {
    setText(photo.AccentRu || photo.AccentEn || "");
  }, [photo.AccentRu, photo.AccentEn]);

  // «Другой язык» для подписи под полем — то, что НЕ совпадает с text.
  // Простая эвристика: текущий text сравниваем с обоими полями фото;
  // показываем тот, что отличается. Если совпадают (или модель ещё не
  // перевела) — показываем оба или ничего.
  const otherLang = (() => {
    const ru = photo.AccentRu || "";
    const en = photo.AccentEn || "";
    if (text === ru && en) return en;
    if (text === en && ru) return ru;
    if (ru && en && ru !== en) return ru === text ? en : ru;
    return ""; // нечего показывать
  })();

  const save = async () => {
    const cur = text.trim();
    const oldPrimary = (photo.AccentRu || photo.AccentEn || "").trim();
    if (cur === oldPrimary) return;
    setBusy(true);
    try {
      await api.feed.saveAccent(photo.ID, cur);
      onSaved && onSaved();
    } catch (e) {
      toast("Не удалось сохранить акцент: " + e.message, "error");
    } finally { setBusy(false); }
  };

  // Голосовой ввод акцента — дописать к текущему тексту и сразу сохранить.
  const appendAccent = (t) => {
    setText((prev) => {
      const next = (prev.trim() ? prev.trim() + " " : "") + t;
      setBusy(true);
      api.feed.saveAccent(photo.ID, next.trim())
        .then(() => onSaved && onSaved())
        .catch(() => toast("Не удалось сохранить акцент", "error"))
        .finally(() => setBusy(false));
      return next;
    });
  };

  return (
    <div className="ana-group">
      <div className="ana-head" style={{ display: "flex", alignItems: "center" }}>
        <span>Акцент</span>
        {/* Подсказка — нижний регистр и светлее: .ana-head задаёт uppercase
            на всё содержимое, поэтому здесь переопределяем inline. */}
        <span style={{
          fontSize: 9.5, marginLeft: 6, color: "var(--ink-4)",
          textTransform: "none", letterSpacing: 0, fontWeight: 400,
        }}>
          продающий угол · можно править на любом языке
        </span>
        {busy && <span style={{ marginLeft: 6 }}><Spinner/></span>}
        <span style={{ flex: 1 }}/>
        <MicButton onInsert={appendAccent}/>
      </div>
      <div className="ana-row">
        <div className="ana-row-label">
          {busy ? "переводим…" : "основной"}
        </div>
        <div style={{ flex: 1, minWidth: 0 }}>
          {/* textarea вместо input — длинные акценты типа «женщина показывает
              укоренённый черенок во мхе. растение хойя» не влезали в одну
              строку (правка 2026-06-02). Auto-resize по содержимому, минимум
              2 строки. Ctrl+Enter = blur (сохраняет); просто Enter — перевод
              строки внутри. */}
          <textarea value={text}
                 onChange={(e) => setText(e.target.value)}
                 onBlur={save}
                 onKeyDown={(e) => {
                   if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
                     e.preventDefault(); e.target.blur();
                   }
                 }}
                 placeholder="напр. ‘осознанное уединение у воды’ — пиши как удобно (сохраняется автоматически)"
                 disabled={busy}
                 rows={2}
                 style={{ width: "100%", padding: "6px 8px", fontSize: 12,
                          border: "1px solid var(--line-2)", borderRadius: "var(--r)",
                          fontFamily: "inherit", resize: "vertical",
                          minHeight: 38, lineHeight: 1.4 }}/>
          {otherLang && (
            <div style={{ fontSize: 10, color: "var(--ink-3)", marginTop: 3, paddingLeft: 2 }}>
              перевод: {otherLang}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// AnaGroupBlock — один блок «Объект» / «Действие» / etc. с двумя рядами
// (Продающие / Синонимы), drag-and-drop между рядами, удалением слов и
// добавлением нового. Drag только внутри своей категории.
//
// Обёрнут в React.memo с компаратором ПО ЗНАЧЕНИЮ (см. низ функции): родитель
// (Review1Screen) пере-рендерится каждые 3с из-за polling /v1/jobs и каждый раз
// отдаёт НОВЫЙ массив words (grouped = wordsByGroup(...)). Без memo это
// пересобирало DOM карточек ровно в момент, когда Лена начинает тащить слово, и
// браузер обрывал зарождающийся нативный drag — «слово не хватается», тем чаще
// чем больше слов в группе (баг 2026-06-23). Компаратор сравнивает words по
// сигнатуре-значению, поэтому ре-рендер от поллинга больше не доходит до блока.
function AnaGroupBlockImpl({ group, words, photoID, onChanged }) {
  // Локальное состояние, синхронизируется с props.words при изменении сервера.
  // Ресинкаемся ТОЛЬКО когда реально поменялся состав серверных слов, а не на
  // каждый ре-рендер родителя. Иначе: родитель пересоздаёт массив words на
  // каждый рендер (grouped = wordsByGroup(...)), а экран ре-рендерится каждые
  // 3с из-за polling /v1/jobs → useEffect([words]) срабатывал по смене ссылки и
  // сбрасывал items к серверному снимку. Свежедобавленное локально слово (его
  // ещё нет в снимке) «появлялось и сразу пропадало», а drag-перенос
  // откатывался (баг 2026-06-23). Сигнатура по value → строка сравнивается по
  // значению, эффект молчит пока состав не изменится по-настоящему.
  const wordsSig = words.map((w) => `${w.ID}:${w.Selling ? 1 : 0}:${w.En}:${w.Ru || ""}`).join("|");
  const [items, setItems] = React.useState(words);
  React.useEffect(() => {
    // Серверный снимок как база, но СОХРАНЯЕМ локально добавленные слова,
    // которых в снимке ещё нет (иначе свежее слово пропадёт до reloadWords).
    // И НИКОГДА не ресинкаемся пока идёт перетаскивание (dragIDRef задан):
    // иначе пересборка списка под курсором срывает захват карточки — именно
    // это давало «через 3 сек слово перестаёт хвататься» в группах с большим
    // числом слов (баг 2026-06-23, группа «Объект» с двумя героями).
    if (dragIDRef.current != null) return;
    setItems((prev) => {
      const serverIDs = new Set(words.map((w) => w.ID));
      const localOnly = prev.filter((w) => !serverIDs.has(w.ID));
      return localOnly.length ? [...words, ...localOnly] : words;
    });
  }, [wordsSig]);

  // Drag state. dragID держим И в state (для рендера зазоров и подсветки),
  // И в ref (для надёжного чтения внутри handler-ов dragOver/drop). Без ref
  // между dragstart и первым dragover React успевает rerender не всегда, и
  // handleDrop видит stale dragID = null → новое слово не получалось
  // переместить сразу после добавления. Ref решает эту race.
  const dragIDRef = React.useRef(null);
  const itemsRef = React.useRef(items);
  React.useEffect(() => { itemsRef.current = items; }, [items]);
  const [dragID, setDragID] = React.useState(null);
  const [dropTarget, setDropTarget] = React.useState(null); // { row: "selling"|"syn", index: number }
  const [_, toast] = useToasts();
  // Подсветка дубликата: AddWordRow ставит сюда ID карточки, у которой
  // совпало слово, на ~1 секунду — карточка пульсирует мятным, потом флаг
  // сбрасывается. Чтобы повторное нажатие на тот же дубль снова подсвечивало,
  // храним ещё nonce — он меняется каждый клик, перезапуская анимацию.
  const [flash, setFlash] = React.useState({ id: null, nonce: 0 });
  const flashDuplicate = (id) => {
    setFlash((s) => ({ id, nonce: s.nonce + 1 }));
    setTimeout(() => setFlash((s) => s.id === id ? { id: null, nonce: s.nonce } : s), 1100);
  };

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

  // Hit-test внутри карточки: левая половина → дроп ПЕРЕД (index=i),
  // правая → ПОСЛЕ (index=i+1). Правка 2026-06-05 — то же что в Ревью 2.
  const onDragOverCard = (e, row, index, wID) => {
    if (dragIDRef.current == null) return;
    if (dragIDRef.current === wID) 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" ? index : index + 1;
    setDropTarget((prev) =>
      prev && prev.row === row && prev.index === targetIdx && prev._side === side
        ? prev
        : { row, index: targetIdx, _side: side });
  };
  const onDropCard = (e, row, index, wID) => {
    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" ? index : index + 1;
    handleDrop(row, targetIdx);
  };

  const handleDrop = async (row, index) => {
    // Берём id из ref — он гарантированно актуален, в отличие от state.
    const id = dragIDRef.current;
    const dragged = itemsRef.current.find((w) => w.ID === id);
    if (!dragged) { onDragEnd(); return; }
    const wantSelling = row === "selling";
    if (dragged.Selling !== wantSelling) {
      // Меняем selling через RPC ToggleSelling.
      try { await api.review.ToggleSelling(dragged.ID); }
      catch (e) { toast("Не удалось переместить: " + e.message, "error"); onDragEnd(); return; }
    }
    // Локально перерассчитаем порядок (визуально). Position в БД мы здесь не
    // сохраняем — это упрощение для MVP; в следующей итерации добавим
    // batch-update Position через отдельный RPC.
    setItems((prev) => {
      const out = prev.filter((w) => w.ID !== dragged.ID);
      const samePart = out.filter((w) => w.Selling === wantSelling);
      const otherPart = out.filter((w) => w.Selling !== wantSelling);
      samePart.splice(index, 0, { ...dragged, Selling: wantSelling });
      return [...samePart, ...otherPart];
    });
    onDragEnd();
  };

  const removeLocal = (id) => {
    setItems((prev) => prev.filter((w) => w.ID !== id));
    // НЕ дёргаем onChanged → reloadWords здесь: DELETE уже прошёл, локально
    // удалили; reload вызвал бы лишний round-trip. Свежий снимок придёт сам
    // при возврате на экран или при «Применить правки».
  };
  const addLocal = (w) => {
    // То же что и в removeLocal: НЕ зовём onChanged. До правки 2026-05-31
    // вызов reloadWords здесь срабатывал между моментом, когда новое слово
    // отрендерилось как WordCard, и моментом, когда Лена пробовала его
    // потащить — useEffect пересинхронизировал items с props.words, новая
    // карточка теряла handler'ы drag-and-drop. Без reload карточка живёт
    // в локальном state до следующего обновления props.words и нормально
    // перетаскивается сразу после добавления.
    setItems((prev) => [...prev, w]);
  };
  const toggleSelling = async (w) => {
    try {
      await api.review.ToggleSelling(w.ID);
      // После переключения слово уходит в **конец** своего нового ряда —
      // именно этого Лена ждёт по UX (правка 2026-05-31): клик по слову в
      // «Продающих» → оно падает вниз в конец «Синонимов», и наоборот.
      // Реализуется простым переносом в конец массива items: рендер
      // фильтрует items по Selling, порядок внутри ряда — порядок items.
      setItems((prev) => {
        const out = prev.filter((x) => x.ID !== w.ID);
        return [...out, { ...w, Selling: !w.Selling }];
      });
    } catch (e) { toast("Не удалось: " + e.message, "error"); }
  };

  // Защита от дублей по ID: одинаковый React-ключ (wc-<id>) у двух карточек
  // ломает реконсиляцию и обработчики drag для всей группы («слово не
  // лифтуется»). Подстраховка на случай рассинхрона items со снимком сервера.
  const seenID = new Set();
  const uniqItems = items.filter((w) => {
    if (seenID.has(w.ID)) return false;
    seenID.add(w.ID);
    return true;
  });
  const selling = uniqItems.filter((w) => w.Selling);
  const syn = uniqItems.filter((w) => !w.Selling);

  // Drag&drop через hit-test внутри карточки (правка 2026-06-05, как в
  // Ревью 2). Раньше были узкие 8px Gap-zone'ы — Лена в них промахивалась.

  const renderRow = (row, list) => (
    <div className={"ana-cards" + (dropTarget?.row === row ? " drop-target" : "")}
         onDragOver={(e) => {
           // Сюда событие доходит ТОЛЬКО когда курсор над свободной зоной ряда
           // (карточки глушат всплытие в onDragOverCard). Это в т.ч. нижняя
           // полоска-паддинг — постоянная зона приёма для забитого под край
           // ряда. Целимся «в конец» и подсвечиваем (баг 2026-06-23: в полный
           // ряд нельзя было бросить слово).
           if (dragIDRef.current == null) return;
           e.preventDefault();
           if (!(dropTarget?.row === row && dropTarget?.index === list.length)) {
             setDropTarget({ row, index: list.length });
           }
         }}
         onDrop={(e) => {
           if (dragIDRef.current == null) return;
           e.preventDefault();
           handleDrop(row, dropTarget?.row === row ? dropTarget.index : list.length);
         }}>
      {list.length === 0 && (
        <span className="muted-2" style={{ fontSize: 11.5, fontStyle: "italic", padding: "4px 2px" }}>
          {row === "selling" ? "нет продающих" : "—"}
        </span>
      )}
      {list.map((w, i) => {
        // dropSide для подсветки границы карточки: left если dropTarget.index===i,
        // right если index===i+1 (и это не сама перетаскиваемая).
        let side = null;
        if (dragID !== w.ID && dropTarget?.row === row) {
          if (dropTarget.index === i) side = "left";
          else if (dropTarget.index === i + 1) side = "right";
        }
        return (
          <WordCard w={w}
                    key={"wc-" + w.ID + "-" + (flash.id === w.ID ? flash.nonce : 0)}
                    dragging={dragID === w.ID}
                    highlight={flash.id === w.ID}
                    dropSide={side}
                    onDragStart={onDragStart}
                    onDragEnd={onDragEnd}
                    onDragOverCard={(e) => onDragOverCard(e, row, i, w.ID)}
                    onDropCard={(e) => onDropCard(e, row, i, w.ID)}
                    onToggleSelling={() => toggleSelling(w)}
                    onDelete={removeLocal}/>
        );
      })}
      {/* Бывшая drag-only «хвостовая» зона приёма УБРАНА (баг 2026-06-23): она
          появлялась только во время перетаскивания и в забитом ряду вызывала
          перенос строки → скачок высоты → срыв захвата нижнего ряда; а при
          полном ряде сама сжималась в ноль (flex-shrink) и бросок не ловила.
          Теперь «бросок в конец» ловит сам контейнер: его постоянная нижняя
          полоса (.ana-cards padding-bottom) + onDragOver/onDrop ниже. */}
    </div>
  );

  return (
    <div className="ana-group">
      <div className="ana-head"><span>{group.label}</span></div>
      <div className="ana-row selling">
        <div className="ana-row-label">Продающие · {selling.length}</div>
        {renderRow("selling", selling)}
      </div>
      <div className="ana-row">
        <div className="ana-row-label">Синонимы · {syn.length}</div>
        {renderRow("syn", syn)}
      </div>
      <AddWordRow photoID={photoID} group={group.id}
                  existingWords={items}
                  onAdded={addLocal}
                  onDuplicate={flashDuplicate}/>
    </div>
  );
}

// Сигнатура слов по ЗНАЧЕНИЮ — для memo-компаратора (и идентична wordsSig внутри
// блока). Реордер при drag в БД не сохраняется (Position не пишем), серверные
// позиции стабильны, поэтому отсутствие Position в сигнатуре не мешает.
function anaWordsSig(ws) {
  return (ws || []).map((w) => `${w.ID}:${w.Selling ? 1 : 0}:${w.En}:${w.Ru || ""}`).join("|");
}

// React.memo: пере-рендериваем блок только когда реально изменился состав слов
// (по значению), сменилась фотка/группа или колбэк. onChanged (reloadWords) —
// стабильный useCallback на [shootID], так что сравнение по идентичности тут ОК.
// Собственные drag/toggle/добавление идут через внутренний setState и memo им не
// мешает (memo гасит только ре-рендеры, инициированные родителем).
const AnaGroupBlock = React.memo(AnaGroupBlockImpl, (prev, next) =>
  prev.photoID === next.photoID &&
  prev.group.id === next.group.id &&
  prev.onChanged === next.onChanged &&
  anaWordsSig(prev.words) === anaWordsSig(next.words)
);

// AddWordRow — поле ввода нового слова + кнопка "+". Создаёт слово(а) в
// синонимах текущей группы.
//
// Правка 2026-06-05 (по запросу Лены):
//   • Split по запятой / точке с запятой — «бокал, женщина» создаёт 2 слова.
//   • Кириллица БОЛЬШЕ НЕ блокируется: каждый кириллический токен сначала
//     уходит в keyword.TranslateRuToEnBatch (словарь → GLM fallback) и в БД
//     попадает уже EN. UI показывает спиннер пока ждём перевод.
//   • Дубликат проверяется после split+translate, по полученным EN. Если
//     хотя бы один — дубль, мы добавляем остальные и сигналим про дубль.
function AddWordRow({ photoID, group, existingWords, onAdded, onDuplicate }) {
  const [input, setInput] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [_, toast] = useToasts();

  // tokens — для подсветки/preview: показываем сколько слов уйдёт.
  const tokens = input.split(/[,;]/).map((s) => s.trim()).filter(Boolean);
  const hasCyrillic = /[Ѐ-ӿ]/.test(input);

  const submit = async () => {
    if (tokens.length === 0 || busy) return;
    setBusy(true);
    try {
      // 1) Развести кириллические → EN. Латинские идут как есть.
      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;
        }
      }
      // 2) Финальный список пар {en, ru} в порядке ввода. ru заполнен только
      //    для кириллического ввода (оригинал) — чтобы перевод показался сразу.
      //    Для EN-ввода ru="" — бэкенд подтянет из словаря / GLM (правка 2026-06-08).
      const enPairs = [];
      const failed = [];
      for (const t of tokens) {
        if (/[Ѐ-ӿ]/.test(t)) {
          const en = ruToEn[t.toLowerCase()];
          if (en) enPairs.push({ en, ru: t });
          else failed.push(t);
        } else {
          enPairs.push({ en: t, ru: "" });
        }
      }
      if (failed.length > 0) {
        toast("Не удалось перевести: " + failed.join(", "), "warn");
      }
      if (enPairs.length === 0) {
        setBusy(false);
        return;
      }
      // 3) Дедуп с уже существующими.
      const existingLow = new Set((existingWords || []).map((w) => (w.En || "").trim().toLowerCase()));
      const seen = new Set();
      let firstDupID = null;
      const toCreate = [];
      for (const p of enPairs) {
        const key = p.en.trim().toLowerCase();
        if (!key || seen.has(key)) continue;
        seen.add(key);
        if (existingLow.has(key)) {
          if (!firstDupID) {
            const dup = (existingWords || []).find((w) => (w.En || "").trim().toLowerCase() === key);
            if (dup) firstDupID = dup.ID;
          }
          continue;
        }
        toCreate.push({ en: p.en.trim(), ru: p.ru });
      }
      // 4) Создаём (один за другим, чтобы Position шёл правильно).
      for (const p of toCreate) {
        const w = await api.feed.addAnalysisWord(photoID, group, p.en, p.ru);
        onAdded && onAdded(w);
      }
      if (firstDupID && toCreate.length === 0) {
        // Был только дубль — сигналим как раньше, input оставляем.
        onDuplicate && onDuplicate(firstDupID);
      } else {
        setInput("");
        if (firstDupID) onDuplicate && onDuplicate(firstDupID);
      }
    } catch (e) {
      toast("Не удалось добавить: " + e.message, "error");
    } finally {
      setBusy(false);
    }
  };

  return (
    <div style={{ marginTop: 4 }}>
      <div style={{ display: "flex", gap: 4, alignItems: "center" }}>
        <input value={input}
               onChange={(e) => setInput(e.target.value)}
               onKeyDown={(e) => e.key === "Enter" && submit()}
               placeholder="новое слово (EN или RU, можно через запятую)"
               disabled={busy}
               title={hasCyrillic ? "RU → переведу автоматически через словарь / GLM" : "перевод подтянет Pika_Writer"}
               style={{ flex: 1, padding: "3px 6px", fontSize: 11,
                        border: "1px solid var(--line-2)", borderRadius: "var(--r)",
                        outline: "none" }}/>
        <button onClick={submit}
                disabled={busy || tokens.length === 0}
                title={busy ? "перевожу…" : tokens.length > 1 ? `добавить ${tokens.length} слов` : "добавить"}
                style={{
                  padding: "3px 10px", fontSize: 12,
                  background: "var(--accent)", color: "white",
                  border: 0, borderRadius: "var(--r)",
                  cursor: busy ? "wait" : "pointer",
                  fontFamily: "inherit",
                  opacity: busy ? 0.6 : 1,
                  minWidth: 28,
                }}>{busy ? "…" : "+"}</button>
      </div>
      {tokens.length > 1 && !busy && (
        <div style={{ fontSize: 10, color: "var(--ink-3)", marginTop: 3, paddingLeft: 4 }}>
          добавлю {tokens.length} слов{tokens.length > 4 ? "" : "а"}: {tokens.join(" · ")}
        </div>
      )}
    </div>
  );
}

function Review1Screen({ shootID, setRoute, isAdmin }) {
  const [shootState, reloadShoot] = useAsync(async () => {
    const list = await api.feed.shoots();
    return (list || []).find((s) => s.ID === shootID) || null;
  }, [shootID]);
  const [photosState] = useAsync(() => api.feed.photos(shootID), [shootID]);
  const [wordsState, reloadWords] = useAsync(() => api.feed.analysis(shootID), [shootID]);
  const [commentsState, reloadComments] = useAsync(() => api.review.ListComments(shootID), [shootID]);
  const [accepting, setAccepting] = React.useState(false);
  // mountedRef — жив ли экран. «Принять серию» ждёт генерацию минуты; если Лена
  // за это время ушла в другую серию, по завершении НЕ выдёргиваем её обратно
  // (баг 2026-06-17): редирект и setState только пока экран на месте.
  const mountedRef = React.useRef(true);
  React.useEffect(() => () => { mountedRef.current = false; }, []);
  // Polling /v1/jobs каждые 3 сек — чтобы кнопка «Переделать» знала, что
  // эта же фотка уже обрабатывается (правка 2026-06-05). Без этого Лена
  // могла нажать «Переделать» дважды в разных вкладках; теперь видит спиннер.
  const [activeJobs, setActiveJobs] = React.useState([]);
  React.useEffect(() => {
    let alive = true;
    const tick = () => {
      if (typeof api.jobs !== "function") return;
      api.jobs().then((r) => { if (alive) setActiveJobs(Array.isArray(r) ? r : []); }).catch(() => {});
    };
    tick();
    const t = setInterval(tick, 3000);
    return () => { alive = false; clearInterval(t); };
  }, []);
  // Серийный (общий) комментарий «к этой партии» — тип "fix" без photoID.
  // Применяется ко ВСЕМ фоткам серии при «Переписать всю серию».
  const [seriesFix, setSeriesFix] = React.useState("");
  // seriesFixRef — актуальное значение для applyAll(), не stale closure.
  const seriesFixRef = React.useRef("");
  React.useEffect(() => { seriesFixRef.current = seriesFix; }, [seriesFix]);
  const [applying, setApplying] = React.useState(false);
  const [_, toast] = useToasts();

  const shoot = shootState.data;
  const photos = photosState.data || [];
  const words = wordsState.data || [];
  const failed = photos.filter((p) => p.Stage === "failed").length;
  const recognized = photos.filter((p) => p.Stage === "recognized").length;

  // Подтягиваем серийный fix-коммент из БД, но НЕ обнуляем локальный ввод
  // если в БД его ещё нет (race с blur/applyAll — то же что для per-photo
  // fix, см. Review1PhotoBlock).
  React.useEffect(() => {
    const list = commentsState.data || [];
    // RPC review.ListComments возвращает поля в lowerCamelCase.
    const ser = list.find((c) => c.commentType === "fix" && (!c.photoId || c.photoId === 0));
    const server = ser ? ser.body : "";
    if (server !== seriesFix && server !== "") {
      setSeriesFix(server);
    }
  }, [commentsState.data]);

  const saveSeriesFix = async () => {
    try {
      await api.feed.upsertComment(shootID, 0, "fix", seriesFix);
      reloadComments && reloadComments();
    } catch (e) { toast("Не удалось сохранить: " + e.message, "error"); }
  };
  // Голосовой ввод общего комментария — дописать + сразу сохранить.
  const appendSeriesFix = (t) => {
    setSeriesFix((prev) => {
      const next = (prev.trim() ? prev.trim() + " " : "") + t;
      api.feed.upsertComment(shootID, 0, "fix", next)
        .then(() => reloadComments && reloadComments())
        .catch(() => toast("Не удалось сохранить", "error"));
      return next;
    });
  };

  const applyAll = async () => {
    if (applying) return;
    setApplying(true);
    try {
      // 1. Гарантированно сохраняем актуальный серийный fix-коммент.
      //    Берём из ref'а — он содержит АКТУАЛЬНОЕ значение даже если
      //    textarea ещё не blur'нулась.
      await api.feed.upsertComment(shootID, 0, "fix", seriesFixRef.current || "");
      // 2. Серверный ApplyAll: для каждой фотки — mini-plan (с учётом fix-
      //    комментариев) + ReAnalyse. Акценты пересчитаются с учётом
      //    общего комментария.
      // Через api.review.ApplyAll — с токеном Authorization2 (иначе /rpc/ 401).
      await api.review.ApplyAll(shootID);
      toast("Серия переписана — слова и акценты обновлены", "info");
      reloadWords && reloadWords();
      reloadComments && reloadComments();
    } catch (e) {
      toast("Не удалось: " + e.message, "error");
    } finally { setApplying(false); }
  };

  const accept = async () => {
    setAccepting(true);
    try {
      // Через api.shoot.AcceptReview1 (а не прямой fetch) — иначе не уходит
      // токен Authorization2 и /rpc/ отвечает 401 (фаза 2).
      // Теперь генерация идёт в ФОНЕ (очередь на бэке): RPC возвращается сразу,
      // тексты пишутся в фоне и видны слева в «В работе». Можно спокойно уйти —
      // обрыв запроса/навигация больше не рвут генерацию (правка 2026-06-24).
      await api.shoot.AcceptReview1(shootID);
      toast("Серия принята → тексты генерируются в фоне", "info");
      // Уводим в СПИСОК серий (а не в карточку серии): генерация фоновая, её
      // прогресс виден и слева в «В работе», и в списке. На карточке держать
      // незачем — там всё равно ничего не поменять, пока серия в работе
      // (выбор Лены 2026-06-24).
      if (mountedRef.current) setRoute("shoots");
    } catch (e) {
      toast("Не удалось принять: " + e.message, "error");
    } finally {
      if (mountedRef.current) setAccepting(false);
    }
  };

  const [redistributing, setRedistributing] = React.useState(false);
  // Видимость перераспределения: сервер регистрирует серийный job
  // (photoId = -shootID, op «перераспределение продающих»). Пока он в activeJobs
  // — кнопка показывает «распределяем…» (в т.ч. если запущено из другой вкладки),
  // а слева висит строка «В работе». Завершение ловит сам синхронный запрос ниже.
  const redistribJobActive = activeJobs.some(
    (j) => j.photoId === -shootID || (j.shootId === shootID && (j.op || "").indexOf("перераспределение") >= 0)
  );

  // «Перераспределить продающие» (только админ) — серийный шаг распределения
  // продающих по серии. Запрос СИНХРОННЫЙ: ждём всю операцию (кнопка крутится
  // спиннером, слева в «В работе» висит job), по ответу сразу обновляем слова.
  // Синонимы/тексты не трогает.
  const redistribute = async () => {
    setRedistributing(true);
    try {
      await api.feed.redistributeSelling(shootID); // дожидается завершения
      reloadWords && reloadWords();
      toast("Перераспределение завершено", "info");
    } catch (e) {
      toast("Не удалось: " + e.message, "error");
    } finally {
      setRedistributing(false);
    }
  };

  return (
    <div className="page" style={{ maxWidth: 1100, paddingTop: 16 }}>
      <div style={{ display: "flex", gap: 10, alignItems: "center", marginBottom: 12, flexWrap: "wrap" }}>
        <button className="btn btn-sm" onClick={() => setRoute("series", shootID)}>
          ← {shoot?.Title || `Серия ${shootID}`}{photos.length ? ` · ${photos.length} файлов` : ""}
        </button>
        <div style={{ width: 1, height: 18, background: "var(--line)" }}/>
        <h1 style={{ margin: 0, fontSize: 16, fontWeight: 500 }}>Ревью 1 · Анализ</h1>
        <div style={{ flex: 1 }}/>
        <div className="muted" style={{ fontSize: 12 }}>
          {recognized} распознано
          {failed > 0 && <> · <b style={{ color: "var(--warn)" }}>{failed} ошибок</b></>}
          {" · "}{words.length} слов анализа
        </div>
        <div style={{ width: 1, height: 18, background: "var(--line)" }}/>
        {isAdmin && (
          <button className="btn btn-sm" onClick={redistribute}
                  disabled={redistributing || redistribJobActive || accepting || applying || words.length === 0}
                  title="Перераспределить продающие синонимы по серии (якорь + разнообразие). Синонимы и тексты не трогает.">
            {(redistributing || redistribJobActive)
              ? <><Spinner/> распределяем…</>
              : <>↻ Перераспределить продающие</>}
          </button>
        )}
        <button className="btn btn-accent btn-sm" onClick={accept}
                disabled={accepting || applying || words.length === 0}
                title={applying
                  ? "Серия сейчас переписывается — дождись завершения"
                  : words.length === 0 ? "Нет данных анализа" : ""}>
          {accepting
            ? <><Spinner/> принимаем…</>
            : applying
              ? <>Принять серию · ждём правок…</>
              : <>Принять серию · Тексты + ключи →</>}
        </button>
      </div>

      {/* Серийный (общий) комментарий + кнопка «Переписать всю серию».
          Кнопка теперь на одной строке с textarea (правка 2026-06-03 —
          раньше она была на уровне label, что выглядело несогласованно). */}
      {photos.length > 0 && words.length > 0 && (
        <div style={{
          marginBottom: 14, padding: 10,
          background: "var(--surface-2)", border: "1px solid var(--line)",
          borderRadius: "var(--r)",
        }}>
          <label className="label" style={{ marginBottom: 4, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
            <span>
              <Icon name="edit" size={10}/> Общий комментарий ко всей серии
              <span className="muted" style={{ fontSize: 10.5, marginLeft: 6, fontWeight: 400 }}>
                — учтётся при «Переписать всю серию»
              </span>
            </span>
            <MicButton onInsert={appendSeriesFix}/>
          </label>
          <div style={{ display: "flex", gap: 8, alignItems: "stretch" }}>
            <textarea
              placeholder="напр.: на всех фото женщина, а не мужчина · во всех кадрах мать с ребёнком"
              value={seriesFix}
              onChange={(e) => setSeriesFix(e.target.value)}
              onBlur={saveSeriesFix}
              rows={2}
              style={{ flex: 1, padding: "6px 8px", fontSize: 12,
                       borderRadius: "var(--r)", border: "1px solid var(--line-2)",
                       resize: "vertical", fontFamily: "inherit" }}/>
            <button type="button" className="btn btn-sm"
                    onClick={applyAll} disabled={applying || accepting}
                    title={accepting
                      ? "Серия сейчас принимается — дождись завершения"
                      : ""}
                    style={{
                      background: (applying || accepting) ? "var(--surface)" : "var(--accent-soft)",
                      color: "var(--accent-2)", border: "1px solid var(--accent)",
                      padding: "8px 12px", whiteSpace: "nowrap",
                      cursor: (applying || accepting) ? "wait" : "pointer",
                    }}>
              {applying
                ? <><Spinner/> переписываем…</>
                : accepting
                  ? <>ждём приёма…</>
                  : <><Icon name="refresh" size={11}/> Переписать всю серию</>}
            </button>
          </div>
        </div>
      )}

      {(photosState.loading || wordsState.loading) && (
        <div className="muted" style={{ padding: 24 }}><Spinner/> загружаем данные…</div>
      )}

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

      {!photosState.loading && !wordsState.loading && photos.length > 0 && words.length === 0 && (
        <PlaceholderStub
          title="Анализ ещё не запущен"
          body={
            <>
              Кадры распознаны, но не прошли через шаг анализа.
              На странице серии нажми <b>«Сделать анализ»</b> (или вызови
              <span className="mono"> shoot.RunPlanAndAnalyse</span>) — после
              чего вернись сюда.
            </>
          }
          hint="Анализ занимает ~1 минуту на фотку (Pika_Writer)."
        />
      )}

      <div>
        {photos.map((p) => <Review1PhotoBlock key={p.ID} photo={p}
                                allWords={words}
                                allComments={commentsState.data || []}
                                reloadComments={reloadComments}
                                onAnaChanged={reloadWords}
                                activeJobs={activeJobs}/>)}
      </div>
    </div>
  );
}

window.Review1Screen = Review1Screen;
