// web/screens/ingest.jsx — экран «Обработать новые».
// Структура повторяет дизайн (_design/unzipped/screens/ingest.jsx):
//   • статистика сверху (всего файлов / групп / дубликатов)
//   • группа = редактируемый заголовок + дата + кол-во + флаги + кнопки
//   • strip миниатюр под заголовком с подписями имён и плашкой «дубль md5»
//
// Поля от сервера (ingest.Scan возвращает ScanResultView = {groups, stale}):
//   groups: [{id, title, date, files[{filename, yandexPath, md5, modified,
//             size, mimeType, isDuplicate, dupNote}]}]
//   stale:  [{photoID, shootID, shootTitle, filename, yandexPath, oldMD5, newMD5}]
//           — фотки из активных серий с изменившимся md5 (Лена перезалила
//           оригинал). UI показывает их отдельным блоком с кнопкой обновить кеш.

function IngestScreen({ setRoute }) {
  const [scan, reload] = useAsync(() => api.ingest.Scan(), []);
  const [creating, setCreating] = React.useState(false);
  // Локальная редакция заголовков и флагов — состояние UI до отправки.
  const [edits, setEdits] = React.useState({}); // {gid: {title, medium, editorial}}
  // Локальная копия групп: split/merge редактируют её без обращения к серверу;
  // настоящие shoots создаются только при «Создать серии».
  const [groups, setGroups] = React.useState([]);
  const [splitMode, setSplitMode] = React.useState(null); // id группы в режиме «разделить»
  // Заголовки уже существующих АКТИВНЫХ серий — чтобы номер при дроблении не
  // совпал с уже созданной серией в работе (запрос Лены 2026-06-23). Архивные
  // НЕ учитываем: их имена можно переиспользовать.
  const [activeTitles, setActiveTitles] = React.useState([]);
  React.useEffect(() => {
    api.feed.shoots("false")
      .then((r) => {
        if (Array.isArray(r)) {
          setActiveTitles(r.map((s) => (s.Title || "").trim()).filter(Boolean));
        }
      })
      .catch(() => {});
  }, []);
  const [_, toast] = useToasts();

  // При новых данных скана — синкаемся: фильтруем дубликаты в отдельный
  // массив skippedFiles, активные группы кладём в groups (правка 2026-06-03).
  // Дубликаты не теряются — Лена может развернуть «Уже в работе» и вернуть
  // нужные в активные через drag&drop (например для перегенерации данных).
  const [skippedFiles, setSkippedFiles] = React.useState([]); // [{file, fromGroupId}]
  const [skippedOpen, setSkippedOpen] = React.useState(false);
  // Stale: фотки активных серий с изменившимся md5 в Я.Диске.
  const [staleFiles, setStaleFiles] = React.useState([]); // StalePhotoView[]
  const [staleBusy, setStaleBusy] = React.useState(false);
  // Когда новых файлов нет, но есть архив — раскрываем блок сразу, чтобы
  // Лена сразу видела все доступные файлы для возможной перегенерации.
  React.useEffect(() => {
    if (skippedFiles.length > 0 && groups.length === 0 && !skippedOpen) {
      setSkippedOpen(true);
    }
  }, [skippedFiles.length, groups.length]);
  React.useEffect(() => {
    // Бэк теперь возвращает {groups, stale} вместо массива групп.
    // На случай старого ответа (массив) — fallback.
    const payload = scan.data && !Array.isArray(scan.data)
      ? scan.data
      : { groups: Array.isArray(scan.data) ? scan.data : [], stale: [] };
    const list = payload.groups || [];
    const stale = payload.stale || [];
    const skipped = [];
    const filtered = list
      .map((g) => {
        const files = (g.files || []).filter((f) => {
          // Точные дубли (md5) и «возможные повторы» (тот же путь, изменён) по
          // умолчанию уводим в «уже обрабатывались» — не включаем в новую серию.
          if (f.isDuplicate || f.isPossibleRepeat) {
            skipped.push({ file: f, fromGroupId: g.id, fromGroupTitle: g.title });
            return false;
          }
          return true;
        });
        // Стабильный порядок по имени файла (префикс-дата + номер кадра
        // _DSCxxxxx) — чтобы при входе в «Обработать новые» кадры шли по порядку
        // съёмки, а не как попало с сервера (правка 2026-06-08).
        files.sort((a, b) => (a.filename || "").localeCompare(b.filename || "", undefined, { numeric: true }));
        return { ...g, files };
      })
      .filter((g) => g.files.length > 0);
    // Развести одинаковые авто-заголовки прямо на скане: внутридневное
    // дробление по EXIF даёт две серии одного дня с именем «220806», плюс
    // имя может совпасть с уже активной серией. Первый занявший базу остаётся
    // как есть (с хвостовым пробелом для дописывания «утро/вечер»), следующие
    // получают «220806 (1)», «(2)»…
    const usedTitles = activeTitles.slice();
    const numbered = filtered.map((g) => {
      const base = (g.title || "").trim().replace(/\s*\(\d+\)\s*$/, "");
      if (!base) return g;
      if (!usedTitles.includes(base)) {
        usedTitles.push(base);
        return g; // оставляем как есть (хвостовой пробел сохраняется)
      }
      const used = new Set();
      for (const t of usedTitles) {
        const tt = (t || "").trim();
        if (tt.startsWith(base)) {
          const m = tt.slice(base.length).match(/^\s*\((\d+)\)$/);
          if (m) used.add(parseInt(m[1], 10));
        }
      }
      let n = 1;
      while (used.has(n)) n++;
      const title = `${base} (${n}) `;
      usedTitles.push(title.trim());
      return { ...g, title };
    });

    setSkippedFiles(skipped);
    setStaleFiles(stale);
    setGroups(numbered);
    const next = {};
    for (const g of numbered) {
      next[g.id] = { title: g.title || "", medium: undefined, editorial: false };
    }
    setEdits(next);
    setSplitMode(null);
  }, [scan.data]);

  // «Обновить кеш» для блока изменившихся: POST /v1/photos/refresh-cache c
  // массивом {photoID, newMD5}. После успеха перевызываем Scan — блок stale
  // исчезнет (md5 в БД теперь свежий), а новая версия превью подтянется
  // лениво при следующем открытии серии.
  const refreshStale = async () => {
    if (staleFiles.length === 0 || staleBusy) return;
    setStaleBusy(true);
    try {
      const items = staleFiles.map((s) => ({ photoID: s.photoID, newMD5: s.newMD5 }));
      const r = await fetch("/v1/photos/refresh-cache", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items }),
      });
      if (!r.ok) throw new Error("HTTP " + r.status);
      const res = await r.json();
      toast(`Обновлено: ${res.refreshed}${res.errors.length ? ` (ошибок: ${res.errors.length})` : ""}`, "info");
      reload();
    } catch (e) {
      toast("Не удалось обновить: " + e.message, "error");
    } finally {
      setStaleBusy(false);
    }
  };

  // ─── Drag-and-drop файлов между группами + из «Уже в работе» ───────
  // dragFile — {file, sourceGroupId|"skipped"}. setOnDragStart, sync onDrop:
  //   onto активная группа → переместить туда
  //   onto «новая серия» зону → создать новую группу из файла
  //   onto «уже в работе» (только активный → пропуск) — не делаем, дубль
  //     пользователь решает сам по сериям
  const [dragFile, setDragFile] = React.useState(null);
  const fileDragStart = (file, sourceGroupId) => {
    setDragFile({ file, sourceGroupId });
  };
  const fileDragEnd = () => setDragFile(null);

  // Переместить файл между группами (или из skipped в активную).
  const moveFileToGroup = (targetGroupId) => {
    if (!dragFile) return;
    const { file, sourceGroupId } = dragFile;
    if (sourceGroupId === targetGroupId) return;

    // Удалить из источника.
    if (sourceGroupId === "skipped") {
      setSkippedFiles((sf) => sf.filter((x) => x.file.yandexPath !== file.yandexPath));
    } else {
      setGroups((gs) => gs.map((g) =>
        g.id === sourceGroupId
          ? { ...g, files: g.files.filter((f) => f.yandexPath !== file.yandexPath) }
          : g
      ).filter((g) => g.files.length > 0));
    }
    // Добавить в цель.
    // Для возврата из skipped — снимаем флаг isDuplicate (иначе фильтр payload его выбросит).
    const cleanFile = sourceGroupId === "skipped" ? { ...file, isDuplicate: false, isPossibleRepeat: false } : file;
    setGroups((gs) => gs.map((g) =>
      g.id === targetGroupId ? { ...g, files: [...g.files, cleanFile] } : g
    ));
    fileDragEnd();
  };

  // Перетаскивание файла в «новую серию» — создаёт новую группу с заголовком
  // по дате из имени.
  const moveFileToNewGroup = () => {
    if (!dragFile) return;
    const { file, sourceGroupId } = dragFile;
    if (sourceGroupId === "skipped") {
      setSkippedFiles((sf) => sf.filter((x) => x.file.yandexPath !== file.yandexPath));
    } else {
      setGroups((gs) => gs.map((g) =>
        g.id === sourceGroupId
          ? { ...g, files: g.files.filter((f) => f.yandexPath !== file.yandexPath) }
          : g
      ).filter((g) => g.files.length > 0));
    }
    const cleanFile = sourceGroupId === "skipped" ? { ...file, isDuplicate: false, isPossibleRepeat: false } : file;
    const newID = "manual_" + Date.now();
    const m = (file.filename || "").match(/^(\d{6})/);
    const base = m ? m[1] : "";
    // Имя новой серии: если такая дата уже есть среди групп — нумеруем «(N)».
    setGroups((gs) => {
      const title = assignSeriesTitle(gs, base);
      setEdits((e) => ({ ...e, [newID]: { title, medium: undefined, editorial: false } }));
      return [...gs, { id: newID, title, date: file.modified || "", files: [cleanFile] }];
    });
    fileDragEnd();
  };

  // splitAt — режет группу gid после индекса afterIdx (1..len-1).
  const splitAt = (gid, afterIdx) => {
    setGroups((gs) => {
      const i = gs.findIndex((g) => g.id === gid);
      if (i < 0) return gs;
      const g = gs[i];
      if (afterIdx <= 0 || afterIdx >= g.files.length) return gs;
      const a = { ...g, files: g.files.slice(0, afterIdx) };
      const newID = g.id + "_" + Date.now();
      // Заголовок отрезанного куска — корень по дате-префиксу первого файла
      // (иначе по заголовку исходной группы без суффикса) + свободный «(N)».
      const firstName = g.files[afterIdx].filename || "";
      const m = firstName.match(/^(\d{6})/);
      const base = m ? m[1] : titleRoot(effTitle(g));
      const childTitle = assignSeriesTitle(gs, base);
      const b = {
        ...g, id: newID,
        files: g.files.slice(afterIdx),
        title: childTitle,
      };
      // Заодно поднимаем edits для новой группы — наследуем флаги.
      setEdits((e) => ({ ...e, [newID]: { ...(e[gid] || {}), title: childTitle } }));
      return [...gs.slice(0, i), a, b, ...gs.slice(i + 1)];
    });
  };

  // mergeDown — склеивает текущую группу gid со следующей.
  const mergeDown = (gid) => {
    setGroups((gs) => {
      const i = gs.findIndex((g) => g.id === gid);
      if (i < 0 || i >= gs.length - 1) return gs;
      const a = gs[i], b = gs[i + 1];
      const merged = { ...a, files: [...a.files, ...b.files] };
      return [...gs.slice(0, i), merged, ...gs.slice(i + 2)];
    });
    // Удаляем edits второй группы (она больше не существует).
    setEdits((e) => {
      const next = { ...e };
      const i = groups.findIndex((g) => g.id === gid);
      if (i >= 0 && i < groups.length - 1) delete next[groups[i + 1].id];
      return next;
    });
  };

  const setEdit = (gid, patch) => {
    setEdits((e) => ({ ...e, [gid]: { ...e[gid], ...patch } }));
  };

  // ─── Нумерация имён при дроблении ──────────────────────────────────
  // Когда одну серию режут на несколько (split) или вытаскивают файл в
  // «новую серию», у кусков получался ОДИН и тот же заголовок (дата-префикс,
  // напр. «260617») — в списке серий потом не различить. Теперь отрезанные
  // куски получают суффикс «260617 (1)», «(2)»… а оригинал остаётся «260617»
  // (запрос Лены 2026-06-23).
  const effTitle = (g) => {
    const e = edits[g.id];
    const t = e && e.title !== undefined ? e.title : (g.title || "");
    return (t || "").trim();
  };
  // titleRoot убирает уже имеющийся суффикс «(N)» — чтобы при повторном
  // дроблении не плодить «260617 (1) (1)».
  const titleRoot = (t) => (t || "").trim().replace(/\s*\(\d+\)\s*$/, "");
  // assignSeriesTitle: если «голый» base ещё никем не занят — отдаём его как
  // есть; иначе подбираем наименьший свободный суффикс «base (N)». gs — текущий
  // список групп (читаем эффективные заголовки с учётом ручных правок).
  const assignSeriesTitle = (gs, base) => {
    const b = (base || "").trim();
    if (!b) return "";
    // Учитываем И группы текущего батча, И заголовки активных серий в работе.
    const titles = [...gs.map(effTitle), ...activeTitles];
    if (!titles.includes(b)) return b;
    const used = new Set();
    for (const t of titles) {
      if (t.startsWith(b)) {
        const m = t.slice(b.length).match(/^\s*\((\d+)\)$/);
        if (m) used.add(parseInt(m[1], 10));
      }
    }
    let n = 1;
    while (used.has(n)) n++;
    return `${b} (${n})`;
  };

  const totalFiles = groups.reduce((n, g) => n + (g.files || []).length, 0);

  const create = async () => {
    if (!groups.length) return;
    // Выходим из режима «разделить» если он активен. Раньше Лена нажимала
    // «Создать серии» прямо из режима разделения, и казалось что правки
    // не сохраняются (правка 2026-06-03). На самом деле они сохранялись,
    // но визуально это путало; теперь явный выход из режима делает поведение
    // более предсказуемым.
    setSplitMode(null);
    const payload = groups
      .map((g) => {
        const e = edits[g.id] || {};
        return {
          title: (e.title || g.title || "").trim(),
          contentType: e.medium || "photo",
          isEditorial: !!e.editorial,
          files: (g.files || [])
            .filter((f) => !f.isDuplicate && !f.isPossibleRepeat)
            .map((f) => ({
              filename: f.filename,
              yandexPath: f.yandexPath,
              md5: f.md5,
              modified: f.modified,
            })),
        };
      })
      .filter((g) => g.files.length > 0);

    if (payload.length === 0) {
      toast("Во всех группах только дубликаты — создавать нечего", "error");
      return;
    }

    setCreating(true);
    try {
      const ids = await api.ingest.CreateShoots(payload);
      const n = Array.isArray(ids) ? ids.length : payload.length;
      toast(`Создано ${n} серий`, "info");
      setRoute("shoots");
    } catch (e) {
      toast("Не удалось создать серии: " + e.message, "error");
    } finally {
      setCreating(false);
    }
  };

  return (
    <div className="page">
      <div className="page-header">
        <div>
          <h1 className="page-title">Обработать новые</h1>
          <div className="page-sub">
            Прочитано из <span className="mono">_Pikasrv/</span> на Яндекс Диске · сверено по md5
          </div>
        </div>
        <div className="page-actions">
          <button className="btn" onClick={() => setRoute("shoots")}>Отмена</button>
          <button className="btn" onClick={reload} disabled={scan.loading || creating}>
            <Icon name="refresh" size={13} /> Пересканировать
          </button>
          <button
            className="btn btn-accent"
            onClick={create}
            disabled={creating || scan.loading || groups.length === 0}
          >
            {creating
              ? <><Spinner /> создаём…</>
              : <><Icon name="check" size={13} stroke={2}/> Создать серии · {groups.length}</>}
          </button>
        </div>
      </div>

      {/* Статистика сверху — показываем когда есть ХОТЯ БЫ что-то: либо
          активные группы, либо файлы в «уже в работе», либо stale-фотки
          из активных серий (правка 2026-06-03 / 2026-06-05). */}
      {(groups.length > 0 || skippedFiles.length > 0 || staleFiles.length > 0) && (
        <div className="ingest-summary">
          <div className="ingest-stat">
            <div className="v">{totalFiles}</div>
            <div className="l">Новых файлов</div>
          </div>
          <div className="ingest-stat">
            <div className="v">{groups.length}</div>
            <div className="l">Предложено серий</div>
          </div>
          <div className="ingest-stat" style={{ cursor: skippedFiles.length > 0 ? "pointer" : "default" }}
               onClick={() => skippedFiles.length > 0 && setSkippedOpen(!skippedOpen)}
               title={skippedFiles.length > 0 ? "Клик — раскрыть/скрыть список" : ""}>
            <div className="v" style={{ color: "var(--ink-3)" }}>
              {skippedFiles.length}
            </div>
            <div className="l">
              Уже в работе{skippedFiles.length > 0 && (
                <Icon name={skippedOpen ? "chevronDown" : "chevron"} size={9}
                      style={{ marginLeft: 4, verticalAlign: "middle" }}/>
              )}
            </div>
          </div>
          <div className="ingest-stat">
            <div className="v">md5</div>
            <div className="l">Группировка по дате в имени</div>
          </div>
          <div className="spacer" />
          <div className="col" style={{ alignItems: "flex-end", justifyContent: "center", gap: 4 }}>
            <span className="muted" style={{ fontSize: 12 }}>Порог разрыва времени</span>
            <span className="mono" style={{ fontSize: 13 }}>1ч 30мин</span>
          </div>
        </div>
      )}

      {scan.loading && <div className="muted" style={{ padding: 24 }}><Spinner /> сканируем Я.Диск…</div>}
      {scan.error && (
        <div className="muted" style={{ padding: 24, color: "var(--warn)" }}>
          Ошибка скана: {scan.error.message}
        </div>
      )}

      {/* Stale-блок: фотки активных серий, у которых md5 в Я.Диске не
          совпадает с тем, что в БД (Лена перезалила оригинал). Кнопка
          «Обновить кеш» дёргает /v1/photos/refresh-cache и стирает 512px-копии,
          чтобы при следующем просмотре подтянуть свежую версию. */}
      {staleFiles.length > 0 && (
        <div style={{
          marginTop: 14, padding: "12px 14px",
          background: "var(--surface)",
          border: "1px solid var(--accent)", borderRadius: "var(--r-lg)",
        }}>
          <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
            <Icon name="refresh" size={14} stroke={2} style={{ color: "var(--accent)" }}/>
            <div style={{ fontWeight: 600, fontSize: 13.5 }}>
              В активных сериях изменилось фото: {staleFiles.length}
            </div>
            <div className="spacer"/>
            <button className="btn btn-sm btn-primary"
                    onClick={refreshStale}
                    disabled={staleBusy}>
              {staleBusy ? <><Spinner size={11}/> Обновляю…</> : <>Обновить кеш</>}
            </button>
          </div>
          <div className="muted" style={{ fontSize: 12, marginBottom: 8 }}>
            После обновления превью и распознавание увидят свежую версию из Я.Диска.
            Старые анализы / тексты сохраняются — если надо переделать, жми «Сбросить анализ» в серии.
          </div>
          <details>
            <summary style={{ cursor: "pointer", fontSize: 12.5, color: "var(--ink-2)" }}>
              Показать список
            </summary>
            <div style={{ marginTop: 8, fontSize: 12.5 }}>
              {staleFiles.map((sp) => (
                <div key={sp.photoID} style={{
                  display: "flex", alignItems: "center", gap: 8,
                  padding: "4px 0", borderTop: "1px dashed var(--line)",
                }}>
                  <span className="mono" style={{ color: "var(--ink-3)" }}>
                    {sp.shootTitle?.trim() || `#${sp.shootID}`}
                  </span>
                  <span>·</span>
                  <span>{sp.filename}</span>
                </div>
              ))}
            </div>
          </details>
        </div>
      )}

      {/* Placeholder «нет файлов вообще» — только если И активных нет,
          И в «уже в работе» пусто, И stale-список пуст (правка 2026-06-03,
          2026-06-05). */}
      {!scan.loading && groups.length === 0 && skippedFiles.length === 0 && staleFiles.length === 0 && !scan.error && (
        <div style={{
          marginTop: 32, padding: "48px 24px", textAlign: "center",
          color: "var(--ink-3)", background: "var(--surface)",
          border: "1px dashed var(--line-2)", borderRadius: "var(--r-lg)",
        }}>
          <Icon name="folder" size={26} className="muted-2" style={{ marginBottom: 8 }} />
          <div style={{ fontWeight: 500, fontSize: 14, color: "var(--ink-2)" }}>
            Новых файлов нет
          </div>
          <div style={{ marginTop: 4, fontSize: 12.5 }}>
            Закинь фото в <span className="mono">_Pikasrv/</span> на Я.Диске и нажми «Пересканировать».
          </div>
        </div>
      )}

      {!scan.loading && groups.length === 0 && skippedFiles.length > 0 && !scan.error && (
        <div style={{
          marginTop: 14, padding: "12px 14px",
          background: "var(--surface)",
          border: "1px solid var(--line)", borderRadius: "var(--r-lg)",
          color: "var(--ink-3)", fontSize: 13,
        }}>
          <Icon name="info" size={13} stroke={2} style={{ marginRight: 6, verticalAlign: "-2px" }}/>
          Новых файлов на Я.Диске нет — все {skippedFiles.length} уже привязаны к существующим сериям.
          Развернул архив ниже на случай если хочешь перегенерировать какой-то файл.
        </div>
      )}

      {groups.map((g, i) => (
        <Group
          key={g.id} g={g}
          edit={edits[g.id] || {}} setEdit={(p) => setEdit(g.id, p)}
          variant="below"
          inSplitMode={splitMode === g.id}
          onToggleSplit={() => setSplitMode(splitMode === g.id ? null : g.id)}
          onSplitAt={(idx) => splitAt(g.id, idx)}
          canMerge={i < groups.length - 1}
          onMergeDown={() => mergeDown(g.id)}
          dragFile={dragFile}
          onFileDragStart={(f) => fileDragStart(f, g.id)}
          onFileDragEnd={fileDragEnd}
          onAcceptFileDrop={() => moveFileToGroup(g.id)}
        />
      ))}

      {/* Drop-зона «Новая серия» — появляется во время drag. Сбросить
          файл сюда → создать отдельную серию из одного файла (потом
          можно дотащить другие). */}
      {dragFile && (
        <div onDragOver={(e) => e.preventDefault()}
             onDrop={(e) => { e.preventDefault(); moveFileToNewGroup(); }}
             style={{
               marginTop: 14, padding: "16px 14px",
               background: "var(--accent-soft)",
               border: "2px dashed var(--accent)",
               borderRadius: "var(--r-lg)",
               color: "var(--accent-2)",
               textAlign: "center", fontSize: 13,
             }}>
          <Icon name="plus" size={13} stroke={2.2}/> Бросить здесь — создать отдельную серию
        </div>
      )}

      {/* «Уже в работе» — раскрывающийся блок дубликатов с возможностью
          перетащить файл обратно в активные группы (для перегенерации
          данных по этому файлу). Правка 2026-06-03 по запросу Лены. */}
      {skippedFiles.length > 0 && skippedOpen && (
        <div style={{
          marginTop: 14, padding: "12px 14px",
          background: "var(--surface-2)", border: "1px solid var(--line)",
          borderRadius: "var(--r-lg)",
        }}>
          <div style={{
            display: "flex", alignItems: "center", gap: 8,
            fontSize: 12.5, color: "var(--ink-3)", marginBottom: 10,
          }}>
            <Icon name="info" size={13} stroke={2}/>
            <span>
              <b>{skippedFiles.length}</b> {skippedFiles.length === 1 ? "файл" : "файлов"} уже привязан{skippedFiles.length === 1 ? "" : "ы"} к существующим сериям.
              Перетащи нужный в любую группу выше или в «новую серию» — если хочешь перегенерировать данные.
            </span>
          </div>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
            {skippedFiles.map((sf) => (
              <div key={sf.file.yandexPath}
                   draggable
                   onDragStart={() => fileDragStart(sf.file, "skipped")}
                   onDragEnd={fileDragEnd}
                   style={{
                     display: "flex", flexDirection: "column",
                     width: 100, cursor: "grab",
                     opacity: dragFile && dragFile.file.yandexPath === sf.file.yandexPath ? 0.4 : 1,
                   }}
                   title={`${sf.file.filename} · был в группе ${sf.fromGroupTitle || sf.fromGroupId}`}>
                <div className="ingest-thumb" style={{
                  width: 100, height: 100,
                  background: "var(--bg)",
                  display: "flex", alignItems: "center", justifyContent: "center",
                }}>
                  <Thumb path={sf.file.yandexPath} size="M" alt={sf.file.filename} fit="contain"/>
                </div>
                <div style={{
                  marginTop: 4, fontSize: 9.5, lineHeight: 1.3,
                  color: "var(--ink-3)", textAlign: "center",
                  fontFamily: "var(--font-mono)", overflowWrap: "anywhere",
                }}>{sf.file.filename}</div>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

function Group({ g, edit, setEdit, variant,
                 inSplitMode, onToggleSplit, onSplitAt,
                 canMerge, onMergeDown,
                 dragFile, onFileDragStart, onFileDragEnd, onAcceptFileDrop }) {
  const files = g.files || [];
  const title = edit.title !== undefined ? edit.title : (g.title || "");
  const variantLabel = null;
  // Подсветка группы как drop-target во время drag (файл из другой группы
  // или из «Уже в работе»).
  const isDropTarget = dragFile && dragFile.sourceGroupId !== g.id;
  const [dragOver, setDragOver] = React.useState(false);
  return (
    <div className="ingest-group"
         onDragOver={(e) => { if (isDropTarget) { e.preventDefault(); setDragOver(true); } }}
         onDragLeave={() => setDragOver(false)}
         onDrop={(e) => {
           if (!isDropTarget) return;
           e.preventDefault();
           setDragOver(false);
           onAcceptFileDrop && onAcceptFileDrop();
         }}
         style={{
           outline: dragOver ? "2px dashed var(--accent)" : undefined,
           outlineOffset: dragOver ? 4 : undefined,
           background: dragOver ? "var(--accent-soft)" : undefined,
           transition: "background 100ms",
         }}>
      <div className="ingest-group-head">
        <Icon name="chevronDown" size={14} />
        <div
          className="ingest-group-title editable"
          contentEditable
          suppressContentEditableWarning
          spellCheck={false}
          onBlur={(e) => {
            const v = e.currentTarget.textContent.trim();
            setEdit({ title: v });
          }}
          onKeyDown={(e) => {
            if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); }
            if (e.key === "Escape") {
              e.currentTarget.textContent = title;
              e.currentTarget.blur();
            }
          }}
        >
          {title}
        </div>
        <div className="ingest-group-meta">
          <span className="mono">{formatDate(g.date)}</span>
          <span style={{ margin: "0 8px", color: "var(--ink-4)" }}>·</span>
          {files.length} файлов
        </div>
        <div className="spacer" />
        <label className="chk">
          <input type="checkbox" checked={edit.medium === "video"}
                 onChange={() => setEdit({ medium: edit.medium === "video" ? undefined : "video" })}/>
          <Icon name="film" size={11} /> видео
        </label>
        <label className="chk">
          <input type="checkbox" checked={edit.medium === "vector"}
                 onChange={() => setEdit({ medium: edit.medium === "vector" ? undefined : "vector" })}/>
          <Icon name="layers" size={11} /> vector
        </label>
        <label className="chk">
          <input type="checkbox" checked={!!edit.editorial}
                 onChange={() => setEdit({ editorial: !edit.editorial })}/>
          <Icon name="edit" size={11} /> editorial
        </label>
        <button
          className={"btn btn-sm" + (inSplitMode ? " btn-accent" : "")}
          onClick={onToggleSplit}
          disabled={files.length < 2}
          title={files.length < 2 ? "Нечего разделять — один файл" : "Кликни в зазор между плитками"}
        >
          <Icon name="split" size={11} stroke={2}/>
          {inSplitMode ? "готово" : "разделить"}
        </button>
        <button
          className="btn btn-sm"
          onClick={onMergeDown}
          disabled={!canMerge}
          title={canMerge ? "Склеить с серией ниже" : "Нет серии ниже"}
        >
          <Icon name="merge" size={11} stroke={2}/> объединить ниже
        </button>
      </div>

      {inSplitMode && (
        <div className="split-hint">
          <Icon name="alert" size={11} stroke={2} />
          Клик в мятный зазор между файлами разрежет группу. Несколько разрезов подряд — можно.
        </div>
      )}

      {variantLabel && (
        <div style={{
          margin: "4px 0 10px", fontSize: 11,
          color: "var(--accent-2)", fontWeight: 500,
        }}>
          {variantLabel}
        </div>
      )}

      <div
        className={"ingest-thumbs" + (variant === "below" ? " below" : "")
                  + (inSplitMode ? " split-mode" : "")}
        style={variant === "below" ? {
          display: "flex", flexWrap: "wrap",
          gap: inSplitMode ? 0 : 14, // в split-режиме зазоры дают сами «полоски»
          gridTemplateColumns: "none",
          alignItems: "stretch",
        } : undefined}
      >
        {files.map((f, idx) => (
          <React.Fragment key={f.yandexPath}>
            {inSplitMode && idx > 0 && <SplitGap onClick={() => onSplitAt(idx)}
                                                  hint={`Разрезать после ${files[idx - 1].filename}`}/>}
            <div draggable={!inSplitMode}
                 onDragStart={() => onFileDragStart && onFileDragStart(f)}
                 onDragEnd={() => onFileDragEnd && onFileDragEnd()}
                 style={{
                   cursor: inSplitMode ? "default" : "grab",
                   opacity: dragFile && dragFile.file.yandexPath === f.yandexPath ? 0.4 : 1,
                 }}
                 title={inSplitMode ? "" : "Перетащи в другую серию или в «новую серию» ниже"}>
              <ThumbCard f={f} variant={variant}/>
            </div>
          </React.Fragment>
        ))}
      </div>
    </div>
  );
}

// ThumbCard — одна плитка файла. Три варианта подписи под сравнение:
//   "below"         — имя под превью, в две строки (как в Проводнике)
//   "overlay-small" — имя поверх внизу, мелким шрифтом, обрезается
//   "tooltip"       — без видимой подписи, только в hover-тултипе
function ThumbCard({ f, variant }) {
  const dupCls = f.isDuplicate ? " dup" : (f.isPossibleRepeat ? " repeat" : "");
  const tip = f.filename + (f.dupNote ? " · " + f.dupNote : "");
  const badge = f.isDuplicate
    ? <span className="dup-tag">дубль md5</span>
    : (f.isPossibleRepeat ? <span className="dup-tag repeat-tag">повтор?</span> : null);

  if (variant === "below") {
    return (
      <div style={{
        display: "flex", flexDirection: "column",
        width: 120, flex: "0 0 120px",
      }} title={tip}>
        <div className={"ingest-thumb" + dupCls} style={{
          width: 120, height: 120,
          aspectRatio: "auto",      // отключаем 4:3 из styles.css
          background: "var(--bg)",
          display: "flex", alignItems: "center", justifyContent: "center",
        }}>
          <Thumb path={f.yandexPath} size="M" alt={f.filename} fit="contain"/>
          {badge}
        </div>
        {/* Шрифт подобран так, чтобы стандартное имя "260526_01.jpg"
            (13 символов) влезало в одну строку при ширине ячейки 120px.
            Более длинные имена (видео) — переносятся по слову/символу. */}
        <div style={{
          marginTop: 6, fontSize: 10, lineHeight: 1.35,
          color: "var(--ink-2)", overflowWrap: "anywhere",
          textAlign: "center",
          fontFamily: "var(--font-mono)",
        }}>{f.filename}</div>
      </div>
    );
  }

  if (variant === "overlay-small") {
    return (
      <div className={"ingest-thumb" + dupCls} title={tip}>
        <Thumb path={f.yandexPath} size="M" alt={f.filename}/>
        <span className="ic-tag" style={{ fontSize: 9, padding: "2px 4px" }}>{f.filename}</span>
        {badge}
      </div>
    );
  }

  // tooltip-only
  return (
    <div className={"ingest-thumb" + dupCls} title={tip}>
      <Thumb path={f.yandexPath} size="M" alt={f.filename}/>
      {badge}
    </div>
  );
}

// SplitGap — кликабельный зазор между файлами в режиме «разделить».
// В отличие от CSS-класса .split-gap из дизайна (он сидит в гриде и в нашем
// flex-варианте «below» с разной высотой ячеек теряется), эта кнопка имеет
// собственную постоянно-видимую вертикальную полоску.
function SplitGap({ onClick, hint }) {
  const [hover, setHover] = React.useState(false);
  return (
    <button
      onClick={onClick}
      title={hint}
      aria-label="Разрезать здесь"
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{
        width: 24, minWidth: 24,
        alignSelf: "stretch",
        background: "transparent", border: 0, padding: 0,
        position: "relative", cursor: "pointer",
      }}
    >
      <span style={{
        position: "absolute",
        left: "50%", top: 0, bottom: 28, // отступ внизу — под подпись файла
        transform: "translateX(-50%)",
        width: hover ? 4 : 2,
        background: hover ? "var(--primary, var(--accent))" : "var(--accent)",
        borderRadius: 2,
        boxShadow: hover ? "0 0 0 4px var(--accent-soft)" : "none",
        transition: "width 100ms, background 100ms, box-shadow 100ms",
      }}/>
    </button>
  );
}

// Thumb — превью файла через /v1/preview (бэк проксирует Я.Диск).
// fit: "cover" режет до краёв рамки (теряется ориентация),
//      "contain" вписывает целиком с полями (видно горизонтально/вертикально).
function Thumb({ path, size, alt, fit }) {
  if (!path) return null;
  const src = "/v1/preview?path=" + encodeURIComponent(path) + "&size=" + (size || "M");
  return <img src={src} alt={alt || ""} loading="lazy" style={{
    width: "100%", height: "100%",
    objectFit: fit || "cover",
    display: "block",
    background: "var(--bg)",
  }}/>;
}

function formatDate(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  const months = ["янв","фев","мар","апр","мая","июн","июл","авг","сен","окт","ноя","дек"];
  return d.getDate() + " " + months[d.getMonth()] + " " + d.getFullYear();
}

window.IngestScreen = IngestScreen;
window.Thumb = Thumb;
