// web/screens/series.jsx — детальная страница серии.
// Грузим серию + список её фотографий через бэк, рендерим грид с превью.
// Клик по плитке открывает боковую панель с подробностями.
//
// На стадии распознавания (до анализа) грид заменяется построчным списком —
// как в Ревью 1: каждое фото отдельной строкой, рядом GIST + полный recognition
// и поле «Поправка распознавания». Лена видит ошибку — сразу правит, и правка
// учтётся в плане вариативности и анализе при «Сделать анализ» (правка 2026-06-07).

// recoLooksFailed — фронт-зеркало recognitionLooksFailed из pkg/pipeline/analyse.go:
// распознавание считается «битым», если в тексте маркеры overflow ИЛИ у уже
// распознанного кадра нет GIST. Для таких кадров Лена описывает кадр вручную.
const RECO_ERR_MARKERS = [
  "context overflow", "prompt too large", "try /reset", "try /new", "превышен контекст",
];
function recoLooksFailed(photo) {
  const r = (photo.Recognition || "").trim().toLowerCase();
  if (!r) return false; // ещё не распознано — это «в очереди», а не «битое»
  if (RECO_ERR_MARKERS.some((m) => r.includes(m))) return true;
  return !photo.Gist || !photo.Gist.trim();
}

// SeriesRecognizeRow — одна строка списка на стадии распознавания. Слева превью
// + имя + статус, справа GIST/recognition + поле «Поправка распознавания».
// Логика автосейва правки взята из Review1PhotoBlock (upsert на blur, синк с
// сервером без затирания «висящего» локального ввода).
function SeriesRecognizeRow({ photo, idx, allComments, reloadComments, onChanged }) {
  const [_, toast] = useToasts();
  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"));

  // Подтягиваем серверный fix, но не затираем непустой локальный ввод (race с
  // blur/reload — та же защита что в Ревью 1).
  React.useEffect(() => {
    const server = findText("fix");
    if (server !== fix && server !== "") setFix(server);
  }, [allComments]);

  const save = async () => {
    const existing = myComments.find((c) => c.commentType === "fix");
    const orig = existing ? existing.body : "";
    if (fix === orig) return;
    try {
      await api.feed.upsertComment(photo.ShootID, photo.ID, "fix", fix);
      reloadComments && reloadComments();
    } catch (e) {
      toast("Не удалось сохранить: " + e.message, "error");
    }
  };

  // Голосовой ввод дописывает кусок и СРАЗУ сохраняет (микрофон не вызывает blur).
  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;
    });
  };

  const recoBad = recoLooksFailed(photo);
  const isQueued = photo.Stage === "queued";
  const isWorking = photo.Stage === "recognizing";
  const isHardFailed = photo.Stage === "failed";
  const failedLike = recoBad || isHardFailed;
  const recognizedOK = !!photo.Recognition && !recoBad && !isHardFailed;
  // Поле правки показываем когда кадр уже прошёл распознавание (успешно или с ошибкой).
  const showFix = recognizedOK || failedLike;

  const reRecognize = async () => {
    try {
      await api.feed.restartPhotoRecognize(photo.ID);
      toast("Фотка отправлена на перераспознавание", "info");
      onChanged && onChanged();
    } catch (e) {
      toast("Не удалось: " + e.message, "error");
    }
  };

  return (
    <div className={"photo-block" + (failedLike ? " photo-block--failed" : "")}>
      <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">
          <span className="mono" style={{ color: "var(--ink-4)" }}>{String(idx + 1).padStart(2, "0")}</span>
          <Icon name="image" size={11}/>
          <span>{photo.Filename}</span>
        </div>
        <div className="meta-row">
          {isQueued && <span className="pill"><span className="pdot"/>в очереди</span>}
          {isWorking && <span className="pill accent"><Spinner size={10}/> распознаётся</span>}
          {recognizedOK && <span className="pill ok"><span className="pdot"/>распознано</span>}
          {failedLike && <span className="pill warn"><span className="pdot"/>распознавание не удалось</span>}
          {photo.BannerType && photo.BannerType !== "none" && (
            <span className="pill accent" style={{ fontSize: 10.5 }}>
              {photo.BannerType === "horizontal" ? "21:9 баннер" : "9:16 баннер"}
            </span>
          )}
        </div>
        <button className="btn btn-sm" onClick={reRecognize}
                title="Перераспознать эту фотку заново">
          <Icon name="refresh" size={11} stroke={2}/> Перераспознать
        </button>
      </div>

      <div>
        {recognizedOK && photo.Gist && (
          <GistBlock gist={photo.Gist} recognition={photo.Recognition || ""}/>
        )}
        {failedLike && (
          <div style={{
            marginTop: 2, padding: "10px 12px",
            background: "var(--warn-soft)", border: "1px solid var(--warn)",
            borderRadius: "var(--r)", fontSize: 12, color: "var(--ink-2)", lineHeight: 1.5,
          }}>
            <Icon name="alert" size={12} stroke={2}
                  style={{ verticalAlign: "-2px", marginRight: 4, color: "var(--warn)" }}/>
            Распознавание не удалось{photo.Recognition ? " — модель вернула ошибку вместо описания" : ""}.
            Опиши кадр вручную в поле ниже: этот текст пойдёт в план и анализ вместо распознавания
            (перераспознать обычно бесполезно — снова переполнит контекст модели).
            {photo.Recognition && (
              <details style={{ marginTop: 6 }}>
                <summary style={{ cursor: "pointer", fontSize: 11, color: "var(--ink-3)" }}>
                  показать ответ модели
                </summary>
                <div style={{ marginTop: 4, fontSize: 11, color: "var(--ink-3)",
                              whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
                  {photo.Recognition}
                </div>
              </details>
            )}
          </div>
        )}
        {isWorking && (
          <div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
            <Spinner size={11}/> распознаётся…
          </div>
        )}
        {isQueued && (
          <div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
            в очереди на распознавание
          </div>
        )}

        {showFix && (
          <div className="field field-grow" style={{ marginTop: 10 }}>
            <label className="label" style={{ display: "flex", alignItems: "center", gap: 8 }}>
              <span><Icon name="edit" size={10}/> {failedLike ? "Описание распознавания (вручную)" : "Поправка распознавания"}</span>
              <MicButton onInsert={appendFix}/>
            </label>
            <textarea
              placeholder={failedLike
                ? "опиши что на кадре: напр. ‘женщина держит черенок хойи во мхе’"
                : "напр.: это мужчина, не женщина"}
              value={fix}
              onChange={(e) => setFix(e.target.value)}
              onBlur={save}
              rows={failedLike ? 3 : 2}
              style={{ width: "100%", padding: "6px 8px", fontSize: 12,
                       borderRadius: "var(--r)", border: "1px solid var(--line-2)",
                       resize: "vertical", fontFamily: "inherit" }}/>
            <div className="muted" style={{ fontSize: 10.5, marginTop: 4 }}>
              учтётся при «Сделать анализ» — в плане вариативности и анализе
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

function SeriesScreen({ shootID, setRoute }) {
  const [shootState, reloadShoot] = 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 [commentsState, reloadComments] = useAsync(() => api.review.ListComments(shootID), [shootID]);
  const [selected, setSelected] = React.useState(null);
  const [confirmDelShoot, setConfirmDelShoot] = React.useState(false);
  const [confirmDelPhoto, setConfirmDelPhoto] = React.useState(null);
  // «Сделать анализ» блокирует до конца анализа (очередь ждёт результат, ~минуты).
  // analysing — чтобы сразу показать «анализ идёт…» и не дать нажать повторно
  // (правка 2026-06-08: раньше кнопка не менялась, было непонятно, запустилось ли).
  const [analysing, setAnalysing] = React.useState(false);
  const [_, toast] = useToasts();

  // Локальные правки заголовка/типа — отправляются на blur/change.
  const saveMeta = async (patch) => {
    try {
      await api.feed.updateShoot(shootID, patch);
      reloadShoot();
    } catch (e) {
      toast("Не удалось сохранить: " + e.message, "error");
    }
  };
  const deleteShoot = async () => {
    try {
      await api.feed.deleteShoot(shootID);
      toast("Серия удалена", "info");
      setRoute("shoots");
    } catch (e) {
      toast("Не удалось удалить: " + e.message, "error");
    }
  };
  const deletePhoto = async (photo) => {
    try {
      await api.feed.deletePhoto(photo.ID);
      toast(`«${photo.Filename}» удалена`, "info");
      setConfirmDelPhoto(null);
      if (selected?.ID === photo.ID) setSelected(null);
      reloadPhotos();
    } catch (e) {
      toast("Не удалось удалить: " + e.message, "error");
    }
  };

  const shoot = shootState.data;

  // Живой список фоток. useAsync.reload() обнуляет data (мигание спиннером на
  // всю страницу), поэтому для фонового поллинга статусов держим отдельный
  // снимок. Свежий ответ useAsync (ручной reload серии) сбрасывает его, чтобы
  // не залипнуть на устаревшем (правка 2026-06-07).
  const [livePhotos, setLivePhotos] = React.useState(null);
  React.useEffect(() => { setLivePhotos(null); }, [photosState.data]);
  // Стабильная сортировка по имени файла. Бэкенд /v1/photos отдаёт фото без
  // гарантированного порядка, и он меняется после перераспознавания (меняется
  // updatedAt) — из-за чего строки прыгали и казалось, что распознанная фотка
  // «ушла в очередь». Сортируем по Filename (там номер кадра _DSCxxxxx) —
  // порядок фиксированный и совпадает с нумерацией камеры (правка 2026-06-07).
  const photos = (livePhotos || photosState.data || []).slice().sort(
    (a, b) => (a.Filename || "").localeCompare(b.Filename || "", undefined, { numeric: true }),
  );

  // Тихий refresh статусов без мигания — дёргаем строки после «Перераспознать».
  const refreshPhotosLive = React.useCallback(() => {
    api.feed.photos(shootID)
      .then((d) => { if (Array.isArray(d)) setLivePhotos(d); })
      .catch(() => {});
  }, [shootID]);

  // Пока есть кадры в очереди/в процессе на стадии распознавания — тихо
  // опрашиваем статусы каждые 3 сек. Распознавание идёт ПО ОДНОМУ кадру (один
  // воркер очереди), поэтому Лена видит, как статусы меняются последовательно:
  // queued → распознаётся → распознано/не удалось (правка 2026-06-07).
  const inFlightCount = photos.filter(
    (p) => p.Stage === "queued" || p.Stage === "recognizing",
  ).length;
  React.useEffect(() => {
    if (!shoot) return;
    if (stageInfo(shoot.Stage, shoot).idx !== 0) return;
    if (inFlightCount === 0) return;
    const t = setInterval(refreshPhotosLive, 3000);
    return () => clearInterval(t);
  }, [shoot, inFlightCount, refreshPhotosLive]);

  // Серийная «Поправка распознавания» — общий fix-коммент (photoID=0/null),
  // учитывается в плане и анализе для ВСЕХ кадров (как seriesFix в Ревью 1).
  // Подтягиваем из БД, но не затираем непустой локальный ввод (правка 2026-06-08).
  const [seriesFix, setSeriesFix] = React.useState("");
  React.useEffect(() => {
    const list = commentsState.data || [];
    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();
    } 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())
        .catch(() => toast("Не удалось сохранить", "error"));
      return next;
    });
  };

  // Подсчёты для верхней статистики — по точным стадиям.
  const cnt = photos.reduce((acc, p) => {
    if (p.Stage === "queued") acc.pending++;
    else if (p.Stage === "recognizing") acc.working++;
    // Битое распознавание (Stage=recognized, но текст-ошибка / нет GIST) считаем
    // как «не распозналось», а не «распознано» — иначе счётчик врал «34 распознано»
    // при том что каждая строка красная (правка 2026-06-07).
    else if (p.Stage === "failed" || recoLooksFailed(p)) acc.failed++;
    else if (p.Stage === "recognized") acc.ok++;
    else if (p.Recognition) acc.ok++; // fallback для исторических данных
    else acc.pending++;
    if (p.BannerType && p.BannerType !== "none") acc.banner++;
    return acc;
  }, { ok: 0, working: 0, pending: 0, failed: 0, banner: 0 });

  if (shootState.loading || photosState.loading) {
    return <div className="muted" style={{ padding: 48 }}><Spinner/> загружаем серию…</div>;
  }
  if (!shoot) {
    return (
      <div className="page">
        <div className="muted" style={{ padding: 24, color: "var(--warn)" }}>
          Серия #{shootID} не найдена.
        </div>
        <button className="btn" onClick={() => setRoute("shoots")}>← Мои съёмки</button>
      </div>
    );
  }

  // Передаём весь shoot (с Recognized/Photos) — stageInfo использует прогресс
  // чтобы не показывать «recognizing» когда все фотки уже готовы.
  const info = stageInfo(shoot.Stage, shoot);
  const stageLabel = STAGES[info.idx]?.label || shoot.Stage;

  // Стадия распознавания (до анализа): idx===0 = new / recognizing / распознано-
  // ждёт-анализа / failed. Тут показываем построчный список вместо грида, чтобы
  // Лена правила распознавание до отправки на «Сделать анализ» (правка 2026-06-07).
  const recognitionPhase = info.idx === 0 && photos.length > 0;

  // Кадр пригоден для анализа, если он recognized ИЛИ failed, но Лена вписала
  // ручное описание в «Поправку распознавания» (commentType="fix"). Нужно для
  // кнопки «Сделать анализ»: серия из одних битых failed-кадров с описаниями
  // тоже должна запускаться (правка 2026-06-07).
  const comments = commentsState.data || [];
  const hasManualFix = (photoID) =>
    comments.some((c) => c.photoId === photoID && c.commentType === "fix" && (c.body || "").trim());
  const analysableCount = photos.filter(
    (p) => p.Stage === "recognized" || (p.Stage === "failed" && hasManualFix(p.ID)),
  ).length;
  // Распознавание устаканилось — нет кадров в очереди/в процессе.
  const recognitionSettled =
    photos.length > 0 && photos.every((p) => p.Stage === "recognized" || p.Stage === "failed");

  // Кадры «с ошибкой» — failed ИЛИ битое распознавание (см. recoLooksFailed):
  // для кнопки «Перераспознать все с ошибкой» (правка 2026-06-07).
  const erroredCount = photos.filter((p) => p.Stage === "failed" || recoLooksFailed(p)).length;

  return (
    <div className="page">
      {/* Header */}
      <div style={{ display: "flex", gap: 10, alignItems: "center", marginBottom: 12, flexWrap: "wrap" }}>
        <button className="btn btn-sm" onClick={() => setRoute("shoots")}>← Мои съёмки</button>
        <div style={{ width: 1, height: 18, background: "var(--line)" }}/>
        {/* Заголовок — кликабельный для переименования */}
        <h1
          contentEditable suppressContentEditableWarning
          spellCheck={false}
          style={{ margin: 0, fontSize: 18, fontWeight: 500, outline: "none",
                   borderBottom: "1px dashed transparent", cursor: "text" }}
          onFocus={(e) => e.currentTarget.style.borderBottomColor = "var(--accent)"}
          onBlur={(e) => {
            e.currentTarget.style.borderBottomColor = "transparent";
            const v = e.currentTarget.textContent.trim();
            const old = shoot.Title || "";
            if (v !== old) saveMeta({ Title: v });
          }}
          onKeyDown={(e) => {
            if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); }
            if (e.key === "Escape") {
              e.currentTarget.textContent = shoot.Title || `Серия ${shoot.ID}`;
              e.currentTarget.blur();
            }
          }}
        >{shoot.Title || `Серия ${shoot.ID}`}</h1>

        {/* Тип контента — селект, и чекбокс editorial */}
        <select value={shoot.ContentType}
                onChange={(e) => saveMeta({ ContentType: e.target.value })}
                className="input"
                style={{ padding: "4px 22px 4px 8px", fontSize: 12.5, cursor: "pointer" }}>
          <option value="photo">Фото</option>
          <option value="video">Видео</option>
          <option value="vector">Vector</option>
        </select>
        <label className="chk" style={{ fontSize: 12.5 }}>
          <input type="checkbox" checked={!!shoot.IsEditorial}
                 onChange={(e) => saveMeta({ IsEditorial: e.target.checked })}/>
          editorial
        </label>

        <span className="muted" style={{ fontSize: 12.5 }}>
          {new Date(shoot.CreatedAt).toLocaleString("ru-RU", {
            day: "2-digit", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit",
          })} · {photos.length} файлов
        </span>
        <div style={{ flex: 1 }}/>
        {/* «Сбросить анализ» — удаляет ревью + анализ + тексты, сохраняет
            recognition. Стадия серии откатывается на plan. Полезно когда
            результат после правок «не идёт» и проще начать с чистого анализа. */}
        <button className="btn btn-sm"
                onClick={async () => {
                  if (!confirm(
                    "Сбросить анализ и комментарии серии «" + (shoot.Title || shoot.ID) + "»?\n\n" +
                    "Будут удалены: все слова анализа, комментарии Ревью 1, акценты,\n" +
                    "тексты Title/Description, собранные ключевые слова.\n\n" +
                    "Распознавание (recognition_text + gist) останется — можно\n" +
                    "сразу запустить «Сделать анализ» заново."
                  )) return;
                  try {
                    await api.feed.resetShootData(shoot.ID, "analysis");
                    toast("Анализ сброшен — серия готова к новому Сделать анализ", "info");
                    reloadShoot(); reloadPhotos();
                  } catch (e) { toast("Не удалось: " + e.message, "error"); }
                }}
                title="Удалить анализ + комментарии + тексты, оставить распознавание">
          <Icon name="refresh" size={11} stroke={2}/> Сбросить анализ
        </button>
        {/* «Сбросить распознавание + анализ» — полная перезагрузка: всё
            что наработала модель. Фотки откатываются на queued, шут — на new.
            Нужно потом запустить «Запустить распознавание» заново. */}
        <button className="btn btn-sm"
                onClick={async () => {
                  if (!confirm(
                    "Сбросить ВСЁ для серии «" + (shoot.Title || shoot.ID) + "»?\n\n" +
                    "Будут удалены: распознавание (recognition_text + gist) +\n" +
                    "анализ + комментарии + тексты + ключи.\n\n" +
                    "Фотки откатятся на стадию «в очереди», серия — на «новая».\n" +
                    "Нужно будет нажать «Запустить распознавание» заново."
                  )) return;
                  try {
                    await api.feed.resetShootData(shoot.ID, "full");
                    toast("Серия сброшена — готова к распознаванию с нуля", "info");
                    reloadShoot(); reloadPhotos();
                  } catch (e) { toast("Не удалось: " + e.message, "error"); }
                }}
                style={{ color: "var(--warn)", borderColor: "var(--warn)" }}
                title="Удалить ВСЕ данные модели (распознавание + анализ + тексты), фотки в очередь">
          <Icon name="refresh" size={11} stroke={2}/> Сбросить распознавание + анализ
        </button>
        {/* «В архив» доступен на ЛЮБОЙ стадии (не только csv/done) — серию в
            работе можно убрать из активного списка в один клик. Вернуть — из
            Архива кнопкой «Вернуть» (правка 2026-06-18). */}
        <button className="btn btn-sm"
                onClick={async () => {
                  if (!confirm(
                    "Отправить серию «" + (shoot.Title || shoot.ID) + "» в архив?\n\n" +
                    "Она уйдёт из активного списка. Вернуть можно из «Архива» кнопкой «Вернуть»."
                  )) return;
                  try {
                    await api.shoot.Archive(shoot.ID);
                    toast("Серия в архиве", "info");
                    setRoute("shoots");
                  } catch (e) { toast("Не удалось архивировать: " + e.message, "error"); }
                }}
                title="Отправить серию в архив (на любой стадии; вернуть можно из «Архива»)">
          <Icon name="archive" size={11} stroke={2}/> В архив
        </button>
        <button className="btn btn-sm" onClick={() => setConfirmDelShoot(true)}
                style={{ color: "var(--warn)", borderColor: "var(--warn)" }}
                title="Удалить серию насовсем (исчезнет из списка; в БД остаётся для восстановления через VT-админку)">
          <Icon name="trash" size={11} stroke={2}/> Удалить серию
        </button>
        {info.state === "waiting" && info.idx === 3 && (
          <button className="btn btn-accent btn-sm" onClick={() => setRoute("review1", shoot.ID)}>
            Открыть Ревью 1 →
          </button>
        )}
        {info.state === "waiting" && info.idx === 5 && (
          <button className="btn btn-accent btn-sm" onClick={() => setRoute("review2", shoot.ID)}>
            Открыть Ревью 2 →
          </button>
        )}
        {/* Stage csv/done — серия отработала, но Лена может зайти в Ревью 2
            чтобы перепроверить или дополнить (правка 2026-06-05 по запросу).
            Если внесёт правки — снова жмёт «Принять серию», pikasrv перезаливает
            CSV, watcher перепрописывает EXIF. */}
        {(shoot.Stage === "csv" || shoot.Stage === "done") && (
          <button className="btn btn-accent btn-sm" onClick={() => setRoute("review2", shoot.ID)}>
            Открыть Ревью 2 →
          </button>
        )}
        {info.state === "pending" && info.idx === 0 && (
          <button className="btn btn-primary btn-sm"
                  onClick={async () => {
                    try { await api.shoot.Recognize(shoot.ID); toast("Запущено", "info"); reloadShoot(); reloadPhotos(); }
                    catch (e) { toast("Не удалось: " + e.message, "error"); }
                  }}>
            <Icon name="play" size={12} stroke={2}/> Запустить распознавание
          </button>
        )}
        {/* «Сделать анализ»: показываем когда распознавание устаканилось (нет
            queued / recognizing — всё либо recognized, либо failed), а серия ещё
            не дошла до review1. failed-кадры с ручным описанием проанализируются,
            остальные failed просто пропустятся (правка 2026-06-07). Запускает
            RunPlanAndAnalyse — 1 plan-вызов + N analyse. */}
        {recognitionSettled && analysableCount > 0 && info.idx < 3 && (
          <button className="btn btn-primary btn-sm"
                  disabled={analysing}
                  style={analysing ? { cursor: "wait", opacity: 0.85 } : undefined}
                  onClick={async () => {
                    if (analysing) return;
                    setAnalysing(true);
                    toast("Анализ запущен — идёт, это займёт время", "info");
                    try {
                      await api.shoot.RunPlanAndAnalyse(shoot.ID);
                      toast("Анализ готов", "info");
                      reloadShoot(); reloadPhotos();
                    } catch (e) {
                      toast("Не удалось: " + e.message, "error");
                    } finally {
                      setAnalysing(false);
                    }
                  }}>
            {analysing
              ? <><Spinner/> анализ идёт…</>
              : <><Icon name="play" size={12} stroke={2}/> Сделать анализ</>}
          </button>
        )}
        {/* «Перераспознать все с ошибкой» — failed + битые (overflow / без GIST).
            Перераспознаёт ровно ошибочные кадры, не трогая нормальные и кадры
            в очереди/процессе. Статусы обновятся вживую (правка 2026-06-07). */}
        {erroredCount > 0 && (
          <button className="btn btn-sm"
                  style={{ color: "var(--warn)", borderColor: "var(--warn)" }}
                  onClick={async () => {
                    try {
                      const r = await api.feed.recognizeErrored(shoot.ID);
                      toast(`Перераспознаю с ошибкой: ${r.photos}`, "info");
                      refreshPhotosLive();
                    } catch (e) { toast("Не удалось: " + e.message, "error"); }
                  }}>
            <Icon name="refresh" size={12} stroke={2}/> Перераспознать все с ошибкой ({erroredCount})
          </button>
        )}

        {/* «Перезапустить распознавание всей серии» — сбрасывает все фотки
            и запускает с нуля. Удобно когда модель странно описала большую
            часть кадров. */}
        {photos.length > 0 && (
          <button className="btn btn-sm"
                  title="Сбросить распознавание всех фоток серии и пройти заново"
                  onClick={async () => {
                    if (!confirm("Перезапустить распознавание всей серии «" + (shoot.Title || shoot.ID) + "»?\n\nРаспознавание всех фоток будет сброшено и пройдено заново. План, анализ, тексты — НЕ трогаются (для полной перезагрузки используй «Удалить серию»/Reprocess).")) return;
                    try {
                      const r = await api.feed.restartShootRecognize(shoot.ID);
                      toast(`Перезапущено ${r.photos} фоток`, "info");
                      reloadShoot(); reloadPhotos();
                    } catch (e) { toast("Не удалось: " + e.message, "error"); }
                  }}>
            <Icon name="refresh" size={12} stroke={2}/> Перезапустить распознавание
          </button>
        )}
      </div>

      {/* Stats strip */}
      <div className="series-stats">
        <div className="series-stat">
          <div className="v">{photos.length}</div>
          <div className="l">файлов</div>
        </div>
        <div className="series-stat">
          <div className="v" style={{ color: "var(--ok)" }}>{cnt.ok}</div>
          <div className="l">распознано</div>
        </div>
        <div className="series-stat">
          <div className="v" style={{ color: cnt.failed ? "var(--warn)" : "var(--ink)" }}>{cnt.failed}</div>
          <div className="l">не распозналось</div>
        </div>
        <div className="series-stat">
          <div className="v" style={{ color: cnt.working ? "var(--accent-2)" : "var(--ink)" }}>{cnt.working}</div>
          <div className="l">в работе</div>
        </div>
        <div className="series-stat">
          <div className="v">{cnt.pending}</div>
          <div className="l">в очереди</div>
        </div>
        <div className="series-stat">
          <div className="v" style={{ color: cnt.banner ? "var(--accent-2)" : "var(--ink)" }}>{cnt.banner}</div>
          <div className="l">баннеров</div>
        </div>
        <div style={{ flex: 1 }}/>
        <div className="series-stat" style={{ alignItems: "flex-end", textAlign: "right" }}>
          <div className="v" style={{ fontSize: 14, fontWeight: 500 }}>
            {stageLabel}
            {info.state === "done"    && " · готово"}
            {info.state === "waiting" && " · ждёт"}
            {info.state === "running" && " · в работе"}
            {info.state === "failed"  && " · сбой"}
          </div>
          <div className="l">текущий этап</div>
        </div>
      </div>

      {/* Стадия распознавания — построчный список как в Ревью 1 (правка 2026-06-07). */}
      {recognitionPhase && (
        <div style={{ marginTop: 4 }}>
          {/* Серийная «Поправка распознавания» — общий fix-коммент на всю серию.
              Учтётся для ВСЕХ кадров и в плане вариативности, и в анализе
              (как «общий комментарий» в Ревью 1). Правка 2026-06-08. */}
          <div style={{
            marginBottom: 12, 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>
            <textarea
              placeholder="напр.: на всех кадрах хойя, а не фикус · во всех кадрах женщина, не мужчина"
              value={seriesFix}
              onChange={(e) => setSeriesFix(e.target.value)}
              onBlur={saveSeriesFix}
              rows={2}
              style={{ width: "100%", padding: "6px 8px", fontSize: 12,
                       borderRadius: "var(--r)", border: "1px solid var(--line-2)",
                       resize: "vertical", fontFamily: "inherit" }}/>
          </div>
          {photos.map((p, i) => (
            <SeriesRecognizeRow key={p.ID} photo={p} idx={i}
              allComments={commentsState.data || []}
              reloadComments={reloadComments}
              onChanged={refreshPhotosLive}/>
          ))}
        </div>
      )}

      {/* Grid + boкс. панель — для стадий после распознавания (просмотр результата). */}
      {!recognitionPhase && (
      <div className={"series-layout" + (selected ? " has-detail" : "")}>
        <div className="series-grid">
          {photos.map((p, idx) => (
            <button key={p.ID}
                    className={"series-tile" + (selected?.ID === p.ID ? " selected" : "")}
                    onClick={() => setSelected(p)}>
              <div className="series-tile-thumb">
                {p.YandexPath
                  ? <img src={"/v1/preview?photoID=" + p.ID + "&path=" + encodeURIComponent(p.YandexPath) + "&size=M"}
                         alt={p.Filename} loading="lazy"
                         style={{ width: "100%", height: "100%", objectFit: "contain", display: "block", background: "var(--bg)" }}/>
                  : <div style={{ width: "100%", height: "100%", background: "var(--bg)" }}/>}
                {p.BannerType && p.BannerType !== "none" && (
                  <span className={"series-banner-flag " + p.BannerType}>
                    {p.BannerType === "horizontal" ? "21:9" : "9:16"} баннер
                  </span>
                )}
                <span className="series-tile-idx">{String(idx + 1).padStart(2, "0")}</span>
                {p.Stage === "failed" && (
                  <span className="series-tile-status">
                    <span className="pill warn" style={{ fontSize: 10.5 }}>
                      <span className="pdot"/> не распозналось
                    </span>
                  </span>
                )}
                {p.Stage === "queued" && (
                  <span className="series-tile-status">
                    <span className="pill" style={{ fontSize: 10.5 }}>
                      <span className="pdot"/> в очереди
                    </span>
                  </span>
                )}
                {p.Stage === "recognizing" && (
                  <span className="series-tile-status">
                    <span className="pill accent" style={{ fontSize: 10.5 }}>
                      <Spinner size={10}/> распознаётся
                    </span>
                  </span>
                )}
                {p.Stage === "recognized" && (
                  <span className="series-tile-status">
                    <span className="pill ok" style={{ fontSize: 10.5 }}>
                      <span className="pdot"/> распознано
                    </span>
                  </span>
                )}
              </div>
              <div className="series-tile-meta">
                <div className="series-tile-name">{p.Filename}</div>
                {p.AccentEn && <div className="series-tile-accent">{p.AccentEn}</div>}
              </div>
            </button>
          ))}
        </div>

        {selected && (
          <aside className="series-detail">
            <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, alignItems: "center" }}>
              <div className="mono" style={{ fontSize: 12, color: "var(--ink-3)" }}>
                #{selected.ID}
              </div>
              <div style={{ display: "flex", gap: 6 }}>
                {selected.YandexPath && (
                  <a className="btn btn-sm"
                     href={"/v1/preview?photoID=" + selected.ID + "&path=" + encodeURIComponent(selected.YandexPath) + "&size=XL"}
                     target="_blank" rel="noreferrer"
                     title="Открыть локальную 512px-копию (если есть) или оригинал-превью с Я.Диска">
                    <Icon name="download" size={11} stroke={2}/>
                  </a>
                )}
                <button className="btn btn-sm"
                        title="Перераспознать эту фотку"
                        onClick={async () => {
                          try {
                            await api.feed.restartPhotoRecognize(selected.ID);
                            toast("Фотка отправлена на перераспознавание", "info");
                            reloadShoot(); reloadPhotos();
                          } catch (e) { toast("Не удалось: " + e.message, "error"); }
                        }}>
                  <Icon name="refresh" size={11} stroke={2}/>
                </button>
                <button className="btn btn-sm"
                        onClick={() => setConfirmDelPhoto(selected)}
                        style={{ color: "var(--warn)", borderColor: "var(--warn)" }}
                        title="Удалить файл из серии">
                  <Icon name="trash" size={11} stroke={2}/>
                </button>
                <button className="btn btn-sm" onClick={() => setSelected(null)}>
                  <Icon name="x" size={12} stroke={2}/>
                </button>
              </div>
            </div>
            <div style={{
              width: "100%", aspectRatio: "3/2",
              borderRadius: "var(--r)", overflow: "hidden",
              border: "1px solid var(--line)", marginBottom: 12,
              background: "var(--bg)",
            }}>
              {selected.YandexPath
                ? <img src={"/v1/preview?photoID=" + selected.ID + "&path=" + encodeURIComponent(selected.YandexPath) + "&size=L"}
                       alt={selected.Filename}
                       style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }}/>
                : null}
            </div>
            <div style={{ fontSize: 14, fontWeight: 500 }}>{selected.Filename}</div>
            <div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
              {selected.CaptureDate
                ? new Date(selected.CaptureDate).toLocaleString("ru-RU")
                : "нет даты съёмки"}
            </div>

            <div style={{ display: "flex", gap: 6, marginTop: 10, flexWrap: "wrap" }}>
              {selected.Stage === "queued" && <span className="pill"><span className="pdot"/>в очереди</span>}
              {selected.Stage === "failed" && <span className="pill warn"><span className="pdot"/>не распозналось</span>}
              {selected.Recognition && <span className="pill ok"><span className="pdot"/>распознано</span>}
              {selected.BannerType && selected.BannerType !== "none" && (
                <span className="pill accent" style={{ fontSize: 10.5 }}>
                  {selected.BannerType === "horizontal" ? "горизонт. 21:9" : "вертикал. 9:16"}
                </span>
              )}
            </div>

            <hr className="divider" style={{ margin: "14px 0" }}/>

            {selected.AccentEn && (
              <>
                <div className="r2-label"><span>Акцент</span></div>
                <div style={{ fontSize: 13, color: "var(--ink-2)", marginBottom: 12 }}>
                  {selected.AccentEn}{selected.AccentRu ? ` / ${selected.AccentRu}` : ""}
                </div>
              </>
            )}

            {selected.Title && (
              <>
                <div className="r2-label"><span>Заголовок</span></div>
                <div style={{ fontSize: 13, color: "var(--ink-2)", marginBottom: 12 }}>
                  {selected.Title}
                  {selected.TitleRu && <div className="muted" style={{ fontSize: 12, marginTop: 2 }}>{selected.TitleRu}</div>}
                </div>
              </>
            )}

            {selected.Description && (
              <>
                <div className="r2-label"><span>Описание</span></div>
                <div style={{ fontSize: 12.5, color: "var(--ink-2)", marginBottom: 12, lineHeight: 1.5 }}>
                  {selected.Description}
                </div>
              </>
            )}

            {!selected.Recognition && (
              <div style={{
                padding: "10px 12px", background: "var(--surface-2)",
                border: "1px dashed var(--line-2)", borderRadius: "var(--r)",
                fontSize: 12, color: "var(--ink-3)",
              }}>
                <Icon name="alert" size={11} stroke={2} style={{ marginRight: 4, verticalAlign: "-1px" }}/>
                Метаданные появятся после распознавания.
              </div>
            )}
          </aside>
        )}
      </div>
      )}

      {confirmDelShoot && (
        <div className="modal-overlay" onClick={() => setConfirmDelShoot(false)}>
          <div className="modal-card" onClick={(e) => e.stopPropagation()}>
            <div className="modal-icon warn"><Icon name="trash" size={20} stroke={1.8}/></div>
            <h3 className="modal-title">Удалить серию?</h3>
            <div className="modal-sub">
              <div style={{ fontSize: 13.5, color: "var(--ink-2)", marginBottom: 8 }}>
                «{shoot.Title || `Серия ${shoot.ID}`}» · {photos.length} файлов
              </div>
              Серия и все её фотографии будут помечены как удалённые. Файлы на Я.Диске
              останутся — их можно будет заново подгрузить через «Обработать новые».
            </div>
            <div className="modal-actions">
              <button className="btn" onClick={() => setConfirmDelShoot(false)}>Оставить</button>
              <button className="btn btn-danger" onClick={deleteShoot}>
                <Icon name="trash" size={12} stroke={2}/> Удалить серию
              </button>
            </div>
          </div>
        </div>
      )}

      {confirmDelPhoto && (
        <div className="modal-overlay" onClick={() => setConfirmDelPhoto(null)}>
          <div className="modal-card" onClick={(e) => e.stopPropagation()}>
            <div className="modal-icon warn"><Icon name="trash" size={20} stroke={1.8}/></div>
            <h3 className="modal-title">Удалить файл из серии?</h3>
            <div className="modal-sub">
              <div className="mono" style={{ fontSize: 13.5, color: "var(--ink-2)", marginBottom: 8 }}>
                {confirmDelPhoto.Filename}
              </div>
              Файл будет помечен как удалённый и пропадёт из серии. Оригинал на Я.Диске
              не трогаем — можно вернуть через «Обработать новые».
            </div>
            <div className="modal-actions">
              <button className="btn" onClick={() => setConfirmDelPhoto(null)}>Оставить</button>
              <button className="btn btn-danger" onClick={() => deletePhoto(confirmDelPhoto)}>
                <Icon name="trash" size={12} stroke={2}/> Удалить файл
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

window.SeriesScreen = SeriesScreen;
