import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { fetchRequestsForMap } from "../../api/requestApi";
import {
  bookRequestSlots,
  cancelBookedSlots,
  fetchBookedSlotsByRequest,
  fetchRequestSlots
} from "../../api/slotsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type {
  BookedSlotView,
  RevolutionSign,
  SlotBookingRequest,
  SlotCoverageStrategy,
  SlotDTO
} from "../../model/slotTypes";
import type { TimelineRange } from "../../model/timelineTypes";
import { DateTime24Field } from "../../components/timeFields";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import type { GeoPoint, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toUtcIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { PassStepper } from "./PassStepper";
import { groupPasses } from "./slotPasses";
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
import { SlotFilterBar, type SlotSortMode } from "./SlotFilterBar";
import { CoveragePanel } from "./CoveragePanel";
import {
  buildCoverageGrid,
  coverageOfMasks,
  rankByMarginalGain,
  sampleSlotMask,
  unionMasks,
  type RankCandidate,
  type SlotMask
} from "./coverage";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { computeConflicts } from "./slotConflicts";
import { deselectStripRange, selectionAfterCheck, selectStripRange } from "./slotSelection";
import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips";
import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress";

const DAY_MS = 24 * 60 * 60 * 1000;

/** Размер чанка при бронировании больших наборов (бэк бронирует слоты по одному). */
const BOOK_CHUNK = 200;
/** Оценка высоты строки таблицы (px) для виртуализатора. */
const ROW_HEIGHT = 34;

/** Начало локальных суток момента (мс) — для группировки слотов по суткам. */
function dayStartMs(ms: number): number {
  const d = new Date(ms);
  d.setHours(0, 0, 0, 0);
  return d.getTime();
}

/** Метка суток ДД.ММ (локально). */
function dayLabel(ms: number): string {
  const d = new Date(ms);
  return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
}

type TguSlotsTabProps = {
  /** Диапазон вкладки «Планы» — начальный интервал отбора заявок и расчёта слотов. */
  range: TimelineRange;
};

/** Ключ слота: КА + номер слота + цикл — уникален в пределах расчёта и совпадает с ключом брони. */
function slotKey(slot: { satelliteId: number; slotNumber: number; cycle: number }): string {
  return `${slot.satelliteId}:${slot.slotNumber}:${slot.cycle}`;
}

export function TguSlotsTab({ range }: TguSlotsTabProps) {
  // Заявки интервала (показываются на карте, выбираются кликом) и выбранная заявка.
  const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
  const [requestsLoading, setRequestsLoading] = useState(false);
  const [requestsLoaded, setRequestsLoaded] = useState(false);
  const [requestsTotal, setRequestsTotal] = useState(0);
  const [requestsTruncated, setRequestsTruncated] = useState(false);
  const [requestsError, setRequestsError] = useState<string>();
  const [selectedRequestId, setSelectedRequestId] = useState<string>("");

  // Интервал отбора заявок — какие заявки показываем на карте (аналог фильтра вкладки «Карта»).
  const [reqFromValue, setReqFromValue] = useState(() => toLocalInputValue(range.fromMs));
  const [reqToValue, setReqToValue] = useState(() => toLocalInputValue(range.toMs));

  // Интервал и фильтры расчёта слотов выбранной заявки.
  const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs));
  const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs));
  const [revSign, setRevSign] = useState<"" | RevolutionSign>("");
  // Стратегия автоформирования покрытия (кнопка «Автоподбор»).
  const [strategy, setStrategy] = useState<SlotCoverageStrategy>("GREEDY");

  // Результаты: слоты-кандидаты и уже забронированные слоты заявки (с bookedSlotId для снятия по id).
  const [slots, setSlots] = useState<SlotDTO[]>([]);
  const [bookedSlots, setBookedSlots] = useState<BookedSlotView[]>([]);
  const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
  // Тумблер следа покрытия на карте (D4 — локальный state, вкл. по умолчанию; глобальный
  // Tgu2DMapLayersState не трогаем). Оператор может снять след, оставив только % в панели.
  const [coverageOn, setCoverageOn] = useState(true);
  // Тумблер подсветки конфликтов выбора (тусклый слой). Вкл. по умолчанию; выключение и гасит слой,
  // и пропускает пересчёт (см. conflictKeys) — заодно дешевле на больших наборах.
  const [showConflicts, setShowConflicts] = useState(true);
  // Сортировка таблицы: «время» (порядок расчёта), «вклад» (жадное ранжирование по приросту
  // покрытия, колонка +N%), «крен» (по модулю угла крена). Вклад доступен только при наличии
  // геометрии заявки (иначе чип «вклад» заблокирован в SlotFilterBar).
  const [sortMode, setSortMode] = useState<SlotSortMode>("time");

  // Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта.
  const [dayFrom, setDayFrom] = useState(1);
  const [dayTo, setDayTo] = useState(1);

  // Курсор прохождения (витка): индекс в хронологическом списке passes; -1 — проход не выбран.
  // Пока проход выбран, отображение изолировано до него; «Все» (clearPass) возвращает курсор в -1.
  const [passCursor, setPassCursor] = useState(-1);

  // Фильтры видимости (статус/КА/ветвь). Единая модель «selected = visible»: множество = что
  // видно, пусто = ничего. Дефолт — всё видно; satFilter инициализируется всеми КА при расчёте.
  const [statusFilter, setStatusFilter] = useState<ReadonlySet<SlotStatusKey>>(
    () => new Set<SlotStatusKey>(["free", "booked", "occupied"])
  );
  const [satFilter, setSatFilter] = useState<ReadonlySet<number>>(() => new Set());
  const [branchFilter, setBranchFilter] = useState<ReadonlySet<RevolutionSign>>(
    () => new Set<RevolutionSign>(["ASC", "DESC"])
  );

  const [calculating, setCalculating] = useState(false);
  const [autoFilling, setAutoFilling] = useState(false);
  const [booking, setBooking] = useState(false);
  // Снятие выбранных броней пачками (request-aware batch) — отдельный флаг «в полёте».
  const [cancellingSel, setCancellingSel] = useState(false);
  // Прогресс крупной чанковой операции (бронь / снятие выбранных) — общий бар <ChunkProgress>.
  const [progress, setProgress] = useState<{ label: string; done: number; total: number } | null>(null);
  const [pendingBook, setPendingBook] = useState<SlotBookingRequest[] | null>(null);
  // «Снять всё» убирает ВСЕ брони заявки — необратимо, поэтому всегда через подтверждение.
  // cancelling отделяет «снятие всего в полёте» от брони/снятия выбранных.
  const [pendingCancel, setPendingCancel] = useState(false);
  const [cancelling, setCancelling] = useState(false);
  const [error, setError] = useState<string>();
  const [notice, setNotice] = useState<string>();

  // Кэш распарсенных WKT-контуров по ключу слота — парсим контур один раз, а не на каждый
  // ререндер сцены (критично на тысячах слотов). Чистится при смене заявки.
  const wktCacheRef = useRef(new Map<string, GeoPoint[][]>());
  // Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки.
  const abortRef = useRef<AbortController | null>(null);
  // Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref):
  // по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения.
  const [anchorKey, setAnchorKey] = useState<string | null>(null);
  // Последний конец Shift-диапазона в таблице: повторный Shift+клик по тому же концу снимает диапазон
  // (toggle). Ref — для логики жеста, перерисовка не нужна; сбрасывается при смене якоря/набора.
  const lastShiftEndRef = useRef<string | null>(null);

  // Подсветка строки/контура по клику на карте и центрирование на слоте.
  const [activeSlotId, setActiveSlotId] = useState<string>();
  const [focusPoint, setFocusPoint] = useState<GeoPoint>();
  const focusTokenRef = useRef(0);
  const [focusToken, setFocusToken] = useState(0);
  // Скролл-контейнер списка слотов — служит scrollElement для виртуализатора.
  const listRef = useRef<HTMLDivElement>(null);

  // Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки).
  const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
  const [activeHitId, setActiveHitId] = useState<string>();

  // Меню выбора слота при плотном наложении (несколько слотов под точкой клика).
  const [slotPicks, setSlotPicks] = useState<SlotPick[]>([]);

  const hasRequest = !!selectedRequestId;

  const selectedRequest = useMemo(
    () => requestItems.find((item) => item.id === selectedRequestId),
    [requestItems, selectedRequestId]
  );

  const parsedRange = useMemo(
    () => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }),
    [fromValue, toValue]
  );
  const rangeInvalid =
    !Number.isFinite(parsedRange.fromMs) ||
    !Number.isFinite(parsedRange.toMs) ||
    parsedRange.fromMs >= parsedRange.toMs;

  // Ключи забронированных слотов — для бейджа «бронь» в таблице и слоя на карте.
  const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]);
  // Бронь по ключу слота — чтобы из выбранной/кликнутой строки достать bookedSlotId для снятия.
  const bookedByKey = useMemo(() => {
    const map = new Map<string, BookedSlotView>();
    for (const slot of bookedSlots) map.set(slotKey(slot), slot);
    return map;
  }, [bookedSlots]);

  // id полигонов слотов, отобранных галочками под бронь — постоянная подсветка на карте.
  const markedSlotIds = useMemo(
    () => new Set(Array.from(selectedKeys, (key) => `slot:${key}`)),
    [selectedKeys]
  );

  // Слот по ключу — чтобы из выбора на карте (id "slot:key"/"booked:key") восстановить полные данные.
  const slotByKey = useMemo(() => {
    const map = new Map<string, SlotDTO>();
    for (const slot of slots) map.set(slotKey(slot), slot);
    for (const slot of bookedSlots) if (!map.has(slotKey(slot))) map.set(slotKey(slot), slot);
    return map;
  }, [slots, bookedSlots]);

  // Строки таблицы = кандидаты + брони, которых нет среди кандидатов (Finding 2-A). Так бронь
  // выбираема и снимаема, даже если кандидаты ещё не рассчитаны или бронь в другом наборе.
  const displaySlots = useMemo<SlotDTO[]>(() => {
    const slotKeySet = new Set(slots.map(slotKey));
    const orphanBooked = bookedSlots.filter((slot) => !slotKeySet.has(slotKey(slot)));
    return orphanBooked.length === 0 ? slots : [...slots, ...orphanBooked];
  }, [slots, bookedSlots]);

  // bookedSlotId выбранных броней (для «Снять выбранные», чанкуется) и число выбранных кандидатов
  // под бронь (для «Забронировать»). Бронь и кандидат различаем по bookedKeys.
  const selectedBookedIds = useMemo(() => {
    const ids: number[] = [];
    for (const key of selectedKeys) {
      const booked = bookedByKey.get(key);
      if (booked) ids.push(booked.bookedSlotId);
    }
    return ids;
  }, [selectedKeys, bookedByKey]);
  const selectedBookableCount = useMemo(() => {
    let n = 0;
    for (const key of selectedKeys) {
      const slot = slotByKey.get(key);
      if (!bookedKeys.has(key) && !(slot && slotIsBusy(slot))) n += 1;
    }
    return n;
  }, [selectedKeys, bookedKeys, slotByKey]);

  // Сутки, по которым раскладываются строки таблицы (для фильтра отображения).
  const days = useMemo<SlotDay[]>(() => {
    const buckets = new Map<number, number>();
    for (const slot of displaySlots) {
      const ms = parseLocal(slot.tn);
      if (!Number.isFinite(ms)) continue;
      const start = dayStartMs(ms);
      buckets.set(start, (buckets.get(start) ?? 0) + 1);
    }
    return Array.from(buckets.entries())
      .sort(([a], [b]) => a - b)
      .map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) }));
  }, [displaySlots]);

  // Сигнатура набора суток (границы дней). Бронь меняет bookedSlots → новый референс days, но сам
  // набор суток тот же — по сигнатуре отличаем «реально другой набор» от «то же, новый массив».
  const dayKeys = useMemo(() => days.map((d) => d.dayMs).join(","), [days]);

  // Новый расчёт меняет НАБОР суток — раскрываем фильтр на весь диапазон. Сброс только при смене
  // сигнатуры (новый расчёт/иной интервал), а не на каждое изменение bookedSlots после брони.
  useEffect(() => {
    const count = dayKeys === "" ? 0 : dayKeys.split(",").length;
    setDayFrom(1);
    setDayTo(count || 1);
  }, [dayKeys]);

  const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1));
  const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1));

  // Витки (проходы) расчёта в хронологическом порядке — для пошаговой навигации ◀/▶.
  // Строим над СТАБИЛЬНЫМ displaySlots (как полосы), чтобы курсор доходил до любого витка
  // независимо от фильтров видимости и окна суток.
  const passes = useMemo(() => groupPasses(displaySlots, slotKey), [displaySlots]);

  // Новый расчёт меняет набор проходов — сбрасываем курсор (как окно суток по dayKeys).
  useEffect(() => {
    setPassCursor(-1);
  }, [dayKeys]);

  // Курсор не должен «висеть» за пределами усохшего набора (бронь/смена набора): клампим к -1.
  const effPassCursor = passCursor >= 0 && passCursor < passes.length ? passCursor : -1;
  const currentPass = effPassCursor >= 0 ? passes[effPassCursor] : null;
  // Ключи слотов текущего витка — для подсветки строк и режима изоляции.
  const passKeySet = useMemo(() => new Set(currentPass?.keys ?? []), [currentPass]);

  // Слоты в окне суток (до фильтров видимости) — база для счётчиков статусов и фильтрации.
  // Пока выбрано прохождение — показываем только его слоты (изоляция); иначе — окно суток.
  const dayWindowed = useMemo(() => {
    if (currentPass) {
      return displaySlots.filter((slot) => passKeySet.has(slotKey(slot)));
    }
    if (days.length === 0) return displaySlots;
    const fromMs = days[effDayFrom - 1].dayMs;
    const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
    return displaySlots.filter((slot) => {
      const ms = parseLocal(slot.tn);
      return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl;
    });
  }, [displaySlots, days, effDayFrom, effDayTo, currentPass, passKeySet]);

  // Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов.
  const statusCounts = useMemo<Record<SlotStatusKey, number>>(() => {
    const counts: Record<SlotStatusKey, number> = { free: 0, booked: 0, occupied: 0 };
    for (const slot of dayWindowed) counts[slotStatusKey(slot, bookedKeys.has(slotKey(slot)))] += 1;
    return counts;
  }, [dayWindowed, bookedKeys]);

  // КА в наборе (для чипов фильтра); сигнатура — чтобы инициализировать satFilter только при
  // смене набора КА (новый расчёт), а не при каждой брони.
  const satellites = useMemo(() => {
    const ids = new Set<number>();
    for (const slot of displaySlots) ids.add(slot.satelliteId);
    return Array.from(ids).sort((a, b) => a - b);
  }, [displaySlots]);
  const satSignature = satellites.join(",");

  // Новый набор КА → показываем все КА (бронирование набор КА не меняет, поэтому не сбрасываем).
  // useLayoutEffect — чтобы satFilter раскрылся ДО отрисовки и не мигнул пустой таблицей.
  useLayoutEffect(() => {
    setSatFilter(new Set(satellites));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [satSignature]);

  // Слоты, видимые в таблице и на карте: окно суток + фильтры видимости (статус/КА/ветвь).
  const filteredSlots = useMemo(
    () =>
      applySlotFilters(
        dayWindowed,
        { status: statusFilter, satellites: satFilter, branches: branchFilter },
        (slot) => bookedKeys.has(slotKey(slot)),
      ),
    [dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
  );

  // Ключи слотов, конфликтующих с текущим выбором (тот же КА + недостаточный зазор на манёвр) — гасим
  // их как подсказку «станет недоступным». Приближённая клиентская эвристика; реальная выполнимость
  // (slew/maxChainLength) — за бэкендом (it.2), поэтому слоты остаются выбираемыми. См. slotConflicts.
  //
  // Производительность: выбор берём через useDeferredValue — во время Shift+drag (selectedKeys меняется
  // на каждый pointermove) пересчёт идёт фоном/после паузы, не блокируя жест. Кандидаты — только видимый
  // filteredSlots; источник занятых интервалов — полный displaySlots (выбор может быть вне окна суток).
  // Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
  // фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
  // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
  // Нужна и для конфликтов ниже: слоты одной полосы между собой не конфликтуют.
  const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);

  const deferredSelectedKeys = useDeferredValue(selectedKeys);
  const conflictKeys = useMemo(
    () =>
      showConflicts
        ? computeConflicts(
            displaySlots,
            filteredSlots,
            slotKey,
            (slot) => deferredSelectedKeys.has(slotKey(slot)),
            (slot) => bookedKeys.has(slotKey(slot)) || slotIsBusy(slot),
            (slot) => polosa.idByKey.get(slotKey(slot)),
          )
        : new Set<string>(),
    [showConflicts, displaySlots, filteredSlots, deferredSelectedKeys, bookedKeys, polosa]
  );
  // id полигонов конфликтных слотов — приглушение на карте (параллель markedSlotIds).
  const dimmedSlotIds = useMemo(
    () => new Set(Array.from(conflictKeys, (key) => `slot:${key}`)),
    [conflictKeys]
  );

  // Конфликтные слоты нельзя добавлять в выбор под бронь (жёсткая блокировка). Держим текущий набор в
  // ref, чтобы обработчики жестов (useCallback) читали его без пересоздания на каждый ререндер выбора.
  const conflictKeysRef = useRef<ReadonlySet<string>>(conflictKeys);
  conflictKeysRef.current = conflictKeys;
  // Убирает заблокированные (конфликтные) ключи из нового набора выбора. Уже выбранные слоты в
  // conflictKeys не попадают, поэтому снятие выбора и прежний выбор не затрагиваются — режутся только
  // конфликтные слоты, которые жест попытался добавить. Мутирует переданный (свежий) Set.
  const dropConflicts = useCallback((next: Set<string>): Set<string> => {
    const blocked = conflictKeysRef.current;
    if (blocked.size > 0) for (const key of blocked) next.delete(key);
    return next;
  }, []);

  // Состояние жеста «выбор полосы мышью на карте» (Shift+drag). Держим в ref: правки идут на каждом
  // движении указателя, перерисовка не нужна. members — члены полосы якоря без BUSY (их не бронируем);
  // endKey — текущий конец (на старте = якорь; меняется в drag) — нужен commit, чтобы отличить
  // одиночный клик (end === anchor) от протяжки.
  const mapStripRef = useRef<{ anchorKey: string; anchorId: PolosaId; members: string[]; endKey: string } | null>(null);
  // Последний конец одиночного Shift-клика на карте: повтор по тому же слоту снимает его (toggle).
  const lastMapEndRef = useRef<string | null>(null);

  // Shift+нажатие по слотам на карте: якорь полосы = самый ранний по времени выбираемый слот под
  // курсором. Выбор этой полосы сбрасываем к одному якорю (далее drag растянет), как и в таблице.
  const handleStripAnchor = useCallback(
    (keys: string[]) => {
      const tnMsOf = (key: string) => {
        const slot = slotByKey.get(key);
        return slot ? parseLocal(slot.tn) : Number.NaN;
      };
      // Конфликтные слоты в выбор не берём — на них нельзя ни встать якорем, ни попасть в полосу.
      const blocked = conflictKeysRef.current;
      const selectable = keys.filter((key) => {
        const slot = slotByKey.get(key);
        return slot && !slotIsBusy(slot) && !blocked.has(key);
      });
      const anchor = pickStripAnchor(selectable, tnMsOf);
      const anchorId = anchor ? polosa.idByKey.get(anchor) : undefined;
      if (!anchor || !anchorId) {
        mapStripRef.current = null;
        return;
      }
      const members = (polosa.membersOf.get(anchorId) ?? []).filter((key) => {
        const slot = slotByKey.get(key);
        return !(slot && slotIsBusy(slot)) && !blocked.has(key);
      });
      mapStripRef.current = { anchorKey: anchor, anchorId, members, endKey: anchor };
      setAnchorKey(anchor);
      lastShiftEndRef.current = null;
      setSelectedKeys((current) => selectStripRange(current, members, anchor, anchor));
    },
    [polosa, slotByKey]
  );

  // Движение указателя в жесте: конец полосы = самый поздний по времени член ТОЙ ЖЕ полосы под
  // курсором (граница полосы соблюдается). Никого из полосы под курсором — оставляем прежний конец.
  const handleStripDrag = useCallback(
    (keys: string[]) => {
      const gesture = mapStripRef.current;
      if (!gesture) return;
      const end = pickStripEnd(polosa, gesture.anchorId, keys);
      if (!end) return;
      gesture.endKey = end;
      setSelectedKeys((current) => selectStripRange(current, gesture.members, gesture.anchorKey, end));
    },
    [polosa]
  );

  const handleStripCommit = useCallback(() => {
    const gesture = mapStripRef.current;
    mapStripRef.current = null;
    if (!gesture) return;
    // Одиночный Shift+клик (без протяжки): end === anchor. Повтор по тому же слоту снимает его
    // (anchor на pointerdown уже переотметил его — на commit гасим). Протяжку (диапазон) не тогглим.
    const isClick = gesture.endKey === gesture.anchorKey;
    if (isClick && lastMapEndRef.current === gesture.anchorKey) {
      setSelectedKeys((current) => {
        const next = new Set(current);
        next.delete(gesture.anchorKey);
        return next;
      });
      lastMapEndRef.current = null;
    } else {
      lastMapEndRef.current = isClick ? gesture.anchorKey : null;
    }
  }, []);

  // Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь
  // неактуальным → сбрасываем (как при смене сортировки), иначе Shift выделил бы не ту полосу.
  useEffect(() => {
    setAnchorKey(null);
    mapStripRef.current = null;
    lastShiftEndRef.current = null;
    lastMapEndRef.current = null;
  }, [filteredSlots]);

  // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
  const loadRequests = useCallback(async (fromInput: string, toInput: string) => {
    const fromMs = parseLocal(fromInput);
    const toMs = parseLocal(toInput);
    if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
    setRequestsLoading(true);
    setRequestsError(undefined);
    try {
      const result = await fetchRequestsForMap({ endFrom: toUtcIso(fromMs), beginTo: toUtcIso(toMs) });
      setRequestItems(result.items);
      setRequestsTotal(result.totalItems);
      setRequestsTruncated(result.truncated);
      setRequestsLoaded(true);
    } catch (caught) {
      setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки.");
    } finally {
      setRequestsLoading(false);
    }
  }, []);

  // Заявки за диапазон вкладки — грузим на входе и при смене диапазона.
  useEffect(() => {
    const fromInput = toLocalInputValue(range.fromMs);
    const toInput = toLocalInputValue(range.toMs);
    setReqFromValue(fromInput);
    setReqToValue(toInput);
    void loadRequests(fromInput, toInput);
  }, [range.fromMs, range.toMs, loadRequests]);

  const focusOn = useCallback((point?: GeoPoint) => {
    if (!point) return;
    setFocusPoint(point);
    focusTokenRef.current += 1;
    setFocusToken(focusTokenRef.current);
  }, []);

  // Кольца контура слота по ключу — из кэша, парсим WKT не более одного раза на слот.
  const ringsFor = useCallback((key: string, contour: string): GeoPoint[][] => {
    const cache = wktCacheRef.current;
    let rings = cache.get(key);
    if (rings === undefined) {
      rings = parseWktPolygon(contour);
      cache.set(key, rings);
    }
    return rings;
  }, []);

  // Сетка сэмплов покрытия по геометрии заявки (кэш на заявку, D1). null — заявка без геометрии
  // или дегенерат (0 валидных сэмплов) → панель «недоступно».
  const coverageGrid = useMemo(
    () => (selectedRequest?.geometry ? buildCoverageGrid(parseWktPolygon(selectedRequest.geometry)) : null),
    [selectedRequest?.geometry]
  );

  // Маска слота на сетке заявки, с кэшем по контуру (D2 — сигнатура grid+контур). Кэш живёт ровно
  // пока стабильна сетка: новый coverageGrid (смена заявки/геометрии) пересоздаёт Map → чистка.
  const maskFor = useMemo(() => {
    const grid = coverageGrid;
    const cache = new Map<string, SlotMask>();
    return (key: string, contour: string): SlotMask | null => {
      if (!grid) return null;
      let mask = cache.get(contour);
      if (!mask) {
        mask = sampleSlotMask(grid, ringsFor(key, contour));
        cache.set(contour, mask);
      }
      return mask;
    };
  }, [coverageGrid, ringsFor]);

  // Кольца следа покрытия для карты = union контуров (забронированное ∪ выбранное галочками),
  // независимо от фильтров вида (codex P1). Пусто, когда тумблер выключен или нет заявки.
  const coverageRings = useMemo<GeoPoint[][] | undefined>(() => {
    if (!coverageOn || !hasRequest) return undefined;
    const rings: GeoPoint[][] = [];
    const seen = new Set<string>();
    const push = (key: string, contour: string) => {
      if (seen.has(key)) return;
      seen.add(key);
      for (const ring of ringsFor(key, contour)) if (ring.length >= 3) rings.push(ring);
    };
    for (const slot of bookedSlots) push(slotKey(slot), slot.contour);
    for (const key of selectedKeys) {
      const slot = slotByKey.get(key);
      if (slot) push(key, slot.contour);
    }
    return rings;
  }, [coverageOn, hasRequest, bookedSlots, selectedKeys, slotByKey, ringsFor]);

  // % покрытия для панели: забронированным набором и набором (забронированное ∪ выбранное).
  // Семантика codex: % = покрытие union(booked ∪ selected), независимо от фильтров вида.
  const coverageStats = useMemo(() => {
    if (!coverageGrid) return { booked: null, withSelection: null, hasSelection: false };
    const bookedMasks: SlotMask[] = [];
    for (const slot of bookedSlots) {
      const mask = maskFor(slotKey(slot), slot.contour);
      if (mask) bookedMasks.push(mask);
    }
    const selMasks: SlotMask[] = [];
    let hasSelection = false;
    for (const key of selectedKeys) {
      if (bookedKeys.has(key)) continue; // бронь уже учтена
      const slot = slotByKey.get(key);
      if (!slot) continue;
      const mask = maskFor(key, slot.contour);
      if (mask) {
        selMasks.push(mask);
        hasSelection = true;
      }
    }
    return {
      booked: coverageOfMasks(coverageGrid, bookedMasks),
      withSelection: coverageOfMasks(coverageGrid, [...bookedMasks, ...selMasks]),
      hasSelection,
    };
  }, [coverageGrid, maskFor, bookedSlots, selectedKeys, bookedKeys, slotByKey]);

  // Покрытие доступно (есть геометрия заявки) — гейт сортировки «по вкладу» и колонки +N%.
  const coverageAvailable = coverageGrid !== null;

  // Ранжирование «по вкладу»: жадно (rankByMarginalGain) поверх забронированного union по ВИДИМЫМ
  // незабронированным кандидатам. Вклад фиксируется на момент ранжирования (не live — отсюда подпись
  // колонки). Считаем только в режиме сортировки «вклад» и при наличии сетки. key → gainPercent + ранг.
  const gainRanking = useMemo(() => {
    if (sortMode !== "gain" || !coverageGrid) return null;
    const bookedMasks: SlotMask[] = [];
    for (const slot of bookedSlots) {
      const mask = maskFor(slotKey(slot), slot.contour);
      if (mask) bookedMasks.push(mask);
    }
    const baseUnion = unionMasks(coverageGrid, bookedMasks);
    const candidates: RankCandidate[] = [];
    for (const slot of filteredSlots) {
      const key = slotKey(slot);
      if (bookedKeys.has(key)) continue; // бронь уже в baseUnion → её вклад 0
      const mask = maskFor(key, slot.contour);
      if (mask) candidates.push({ key, mask });
    }
    const ranked = rankByMarginalGain(coverageGrid, candidates, baseUnion);
    const order = new Map<string, number>();
    const pct = new Map<string, number>();
    ranked.forEach((r, i) => {
      order.set(r.key, i);
      pct.set(r.key, r.gainPercent);
    });
    return { order, pct };
  }, [sortMode, coverageGrid, maskFor, bookedSlots, filteredSlots, bookedKeys]);

  // Колонку +N% показываем только в режиме «вклад» и при наличии геометрии.
  const showGain = sortMode === "gain" && coverageAvailable;

  // Порядок строк таблицы по выбранной сортировке. «время» — исходный (порядок расчёта, как есть).
  // «крен» — по модулю крена (меньше = лучше геометрия), стабильно. «вклад» — жадный ранг; брони
  // (вне ранжирования) уходят в конец, сохраняя исходный порядок. sort стабилен (ES2019+).
  const sortedSlots = useMemo<SlotDTO[]>(() => {
    if (sortMode === "roll") {
      return [...filteredSlots].sort((a, b) => Math.abs(a.roll) - Math.abs(b.roll));
    }
    if (sortMode === "gain" && gainRanking) {
      const order = gainRanking.order;
      return [...filteredSlots].sort((a, b) => {
        const ra = order.get(slotKey(a));
        const rb = order.get(slotKey(b));
        if (ra == null && rb == null) return 0;
        if (ra == null) return 1;
        if (rb == null) return -1;
        return ra - rb;
      });
    }
    return filteredSlots;
  }, [sortMode, filteredSlots, gainRanking]);

  // Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
  // иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
  const rowVirtualizer = useVirtualizer({
    count: sortedSlots.length,
    getScrollElement: () => listRef.current,
    estimateSize: () => ROW_HEIGHT,
    overscan: 12
  });

  // Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы.
  // Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой
  // скролл-предок или счесть строку «достаточно близкой» и не сдвинуться.
  useEffect(() => {
    if (!activeSlotId || !activeSlotId.startsWith("slot:")) return;
    const key = activeSlotId.slice("slot:".length);
    const index = sortedSlots.findIndex((slot) => slotKey(slot) === key);
    if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" });
  }, [activeSlotId, sortedSlots, rowVirtualizer]);

  // Переход на прохождение прокручивает таблицу к началу (без подсветки строки): оператор видит
  // первый слот изолированного прохождения. Только при выбранном проходе — иначе скролл не трогаем.
  useEffect(() => {
    if (effPassCursor >= 0) rowVirtualizer.scrollToIndex(0, { align: "start" });
  }, [effPassCursor, rowVirtualizer]);

  // Загружает брони выбранной заявки (для бейджей и слоя карты).
  const loadBooked = useCallback(async (requestId: string) => {
    try {
      const booked = await fetchBookedSlotsByRequest(requestId);
      setBookedSlots(booked);
    } catch (caught) {
      setBookedSlots([]);
      setError(caught instanceof Error ? caught.message : "Не удалось загрузить брони заявки.");
    }
  }, []);

  // Смена заявки — отменяем текущий расчёт, чистим кэш контуров и результаты,
  // подтягиваем уже существующие брони.
  useEffect(() => {
    abortRef.current?.abort();
    wktCacheRef.current.clear();
    setAnchorKey(null);
    setSlots([]);
    setSelectedKeys(new Set());
    setBookedSlots([]);
    setPendingBook(null);
    setPendingCancel(false);
    setNotice(undefined);
    setError(undefined);
    setActiveSlotId(undefined);
    // Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект).
    setStatusFilter(new Set<SlotStatusKey>(["free", "booked", "occupied"]));
    setBranchFilter(new Set<RevolutionSign>(["ASC", "DESC"]));
    setSortMode("time");
    if (selectedRequestId) void loadBooked(selectedRequestId);
  }, [selectedRequestId, loadBooked]);

  const reqFromMs = parseLocal(reqFromValue);
  const reqToMs = parseLocal(reqToValue);
  const reqRangeInvalid =
    !Number.isFinite(reqFromMs) || !Number.isFinite(reqToMs) || reqFromMs >= reqToMs;

  const applyRequests = () => void loadRequests(reqFromValue, reqToValue);
  const setQuickRange = (fromMs: number, toMs: number) => {
    const fromInput = toLocalInputValue(fromMs);
    const toInput = toLocalInputValue(toMs);
    setReqFromValue(fromInput);
    setReqToValue(toInput);
    void loadRequests(fromInput, toInput);
  };
  const quickToday = () => {
    const now = new Date();
    const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
    setQuickRange(fromMs, fromMs + DAY_MS);
  };
  const quick7days = () => setQuickRange(Date.now(), Date.now() + 7 * DAY_MS);
  const quick30days = () => setQuickRange(Date.now(), Date.now() + 30 * DAY_MS);

  // Общий расчёт слотов. Без стратегии — все слоты-кандидаты («Рассчитать»). Со стратегией —
  // автоформирование покрытия: бэк отдаёт достаточный набор, кладём его в преднабор брони.
  const runCover = async (coverageStrategy?: SlotCoverageStrategy) => {
    if (!selectedRequestId || rangeInvalid) return;
    const auto = !!coverageStrategy;
    if (auto ? autoFilling : calculating) return;
    // Прерываем предыдущий расчёт, если он ещё идёт.
    abortRef.current?.abort();
    const ctrl = new AbortController();
    abortRef.current = ctrl;
    setAnchorKey(null);
    if (auto) setAutoFilling(true);
    else setCalculating(true);
    setError(undefined);
    setNotice(undefined);
    try {
      const result = await fetchRequestSlots(
        {
          requestId: selectedRequestId,
          timeStart: toUtcIso(parsedRange.fromMs),
          timeStop: toUtcIso(parsedRange.toMs),
          revSign: revSign || undefined,
          // cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать
          // покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов,
          // включая занятые бронью (all=true) — занятые показываем для обзора, бронировать нельзя.
          cov: auto,
          all: !auto,
          coverageStrategy
        },
        ctrl.signal
      );
      if (ctrl.signal.aborted) return;
      setSlots(result);
      if (auto) {
        // Автонабор → преднабор брони: предлагаем оператору весь сформированный набор.
        setSelectedKeys(new Set(result.map(slotKey)));
        setNotice(
          result.length > 0
            ? `Автоподбор (${strategyLabel(coverageStrategy!)}): предложено ${result.length} слотов — проверьте и забронируйте.`
            : "Покрытие для интервала не сформировано."
        );
      } else {
        setSelectedKeys(new Set());
        setNotice(
          result.length > 0
            ? `Найдено слотов: ${result.length}.`
            : "Для выбранного интервала слотов покрытия не найдено."
        );
      }
      // Карту НЕ перецентрируем: result[0] — первый по времени/витку, географически произвольный
      // угол, а зум до 4.5 спрятал бы остальной набор. Оставляем общий обзор заявки на месте;
      // прицельный фокус — по клику на строку таблицы (focusOn в onRowClick).
    } catch (caught) {
      // Отмена (повтор/смена заявки) — это не ошибка, молча выходим.
      if (ctrl.signal.aborted || (caught instanceof DOMException && caught.name === "AbortError")) return;
      setSlots([]);
      setError(caught instanceof Error ? caught.message : "Не удалось рассчитать слоты.");
    } finally {
      if (abortRef.current === ctrl) abortRef.current = null;
      if (auto) setAutoFilling(false);
      else setCalculating(false);
    }
  };

  const calculate = () => void runCover();
  const autoCover = () => void runCover(strategy);
  const cancelCalculation = () => abortRef.current?.abort();

  // Любая операция с бронью «в полёте» — общий гейт блокировки кнопок (бронь / снятие выбранных /
  // снятие всего). Отделён от busy (расчёт/автоподбор).
  const opBusy = booking || cancelling || cancellingSel;

  // Бронирует набор чанками (бэк бронирует по одному в транзакции — крупный набор бьём на части
  // через runChunked) и показывает прогресс. bookedSlotId берём из ответа POST.
  const performBook = async (toBook: SlotBookingRequest[]) => {
    setBooking(true);
    setError(undefined);
    setNotice(undefined);
    setProgress(toBook.length > BOOK_CHUNK ? { label: "Бронирование", done: 0, total: toBook.length } : null);
    try {
      const parts = await runChunked(
        toBook,
        BOOK_CHUNK,
        (part) => bookRequestSlots(selectedRequestId, part),
        (done, total) => setProgress({ label: "Бронирование", done, total }),
      );
      const collected = parts.flat();
      // Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы
      // отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу;
      // by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId.
      const idByKey = new Map<string, number>(
        collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId]),
      );
      const justBooked: BookedSlotView[] = slots
        .filter((slot) => idByKey.has(slotKey(slot)))
        .map((slot) => ({ ...slot, bookedSlotId: idByKey.get(slotKey(slot))! }));
      setBookedSlots((prev) => {
        const merged = new Map(prev.map((slot) => [slotKey(slot), slot]));
        for (const slot of justBooked) merged.set(slotKey(slot), slot);
        return Array.from(merged.values());
      });
      setSelectedKeys(new Set());
      setNotice(`Забронировано слотов: ${collected.length}.`);
    } catch (caught) {
      setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты.");
    } finally {
      setBooking(false);
      setProgress(null);
    }
  };

  // Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем).
  const collectSelected = (): SlotBookingRequest[] =>
    slots
      .filter(
        (slot) => selectedKeys.has(slotKey(slot)) && !bookedKeys.has(slotKey(slot)) && !slotIsBusy(slot),
      )
      .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));

  const book = () => {
    if (!selectedRequestId || opBusy || selectedBookableCount === 0) return;
    const toBook = collectSelected();
    if (toBook.length === 0) return;
    // Бронь — всегда через подтверждение (симметрично «Снять всё»), затем чанкованный POST.
    setPendingBook(toBook);
  };

  const confirmPendingBook = () => {
    const toBook = pendingBook;
    setPendingBook(null);
    if (toBook) void performBook(toBook);
  };
  const cancelPendingBook = () => setPendingBook(null);

  // Общий чанковый движок снятия: бьёт ids на части (request-aware batch), показывает прогресс
  // (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются
  // и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не
  // одним bulk-запросом (по одному HTTP нельзя показать ход на 7000 слотов). setBusy — флаг кнопки.
  const runCancel = async (ids: number[], setBusy: (value: boolean) => void, forceProgress = false) => {
    setBusy(true);
    setError(undefined);
    setNotice(undefined);
    // forceProgress — для «Снять всё»: держим бар весь процесс независимо от объёма (операция
    // делает доп. сверку by-request, поэтому заметно дольше точечного снятия даже на малом наборе).
    setProgress(forceProgress || ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null);
    try {
      const counts = await runChunked(
        ids,
        BOOK_CHUNK,
        (part) => cancelBookedSlots(selectedRequestId, part),
        (done, total) => setProgress({ label: "Снятие брони", done, total }),
      );
      const removed = counts.reduce((a, b) => a + b, 0);
      const idSet = new Set(ids);
      setSelectedKeys((prev) => {
        const next = new Set(prev);
        for (const slot of bookedSlots) if (idSet.has(slot.bookedSlotId)) next.delete(slotKey(slot));
        return next;
      });
      setBookedSlots((prev) => prev.filter((slot) => !idSet.has(slot.bookedSlotId)));
      setNotice(`Снято броней: ${removed}.`);
    } catch (caught) {
      setError(caught instanceof Error ? caught.message : "Не удалось снять бронь.");
    } finally {
      setBusy(false);
      setProgress(null);
    }
  };

  // Снять выбранные брони (по галочкам). Выбор явный — без подтверждения.
  const cancelSelected = () => {
    if (!selectedRequestId || opBusy || selectedBookedIds.length === 0) return;
    void runCancel(selectedBookedIds, setCancellingSel);
  };

  // Снять одну бронь из строки таблицы — батч из одного id (обратимо ре-бронью, без подтверждения).
  const cancelOne = (bookedSlotId: number) => {
    if (!selectedRequestId || opBusy) return;
    void runCancel([bookedSlotId], setCancellingSel);
  };

  // Снять ВСЕ брони заявки — тоже пачками (request-aware batch) с прогрессом. Необратимо →
  // подтверждение. Отдельный флаг cancelling, чтобы «Снятие…» горело именно на этой кнопке.
  // Источник истины — backend: перечитываем брони by-request и снимаем ОБЪЕДИНЕНИЕ локального
  // набора и серверного. Так «Снять всё» убирает вообще все брони заявки, даже если локальный
  // bookedSlots разошёлся с базой (bookedSlotId надёжен независимо от cycle-фрейма брони).
  const performCancelAll = async () => {
    // Мгновенная обратная связь (как у «Забронировать»/«Снять выбранные»): входим в busy и
    // показываем прогресс СРАЗУ, ещё до сетевой сверки by-request, а не после её round-trip.
    setCancelling(true);
    setError(undefined);
    setNotice(undefined);
    setProgress({ label: "Снятие брони", done: 0, total: bookedSlots.length });
    const idSet = new Set(bookedSlots.map((slot) => slot.bookedSlotId));
    try {
      const fresh = await fetchBookedSlotsByRequest(selectedRequestId);
      for (const slot of fresh) idSet.add(slot.bookedSlotId);
    } catch {
      // by-request недоступен — снимаем по локальному набору броней.
    }
    await runCancel(Array.from(idSet), setCancelling, true);
  };

  const cancelAll = () => {
    if (!selectedRequestId || opBusy || bookedSlots.length === 0) return;
    setPendingCancel(true);
  };
  const confirmPendingCancel = () => {
    setPendingCancel(false);
    void performCancelAll();
  };
  const cancelPendingCancel = () => setPendingCancel(false);

  // Отметка слота из таблицы.
  // • Обычный клик — переключает один слот и ставит его якорем полосы.
  // • Shift+клик — жест «полоса от первого к последнему»: выделяет всех членов ОДНОЙ полосы между
  //   якорем и текущим слотом (включительно). Если текущий слот из другой полосы (другой КА/крен/
  //   разрыв по времени) — выбор не меняем и подсказываем: выйти за полосу нельзя.
  const handleSlotCheck = (index: number, shiftKey: boolean) => {
    const targetKey = slotKey(sortedSlots[index]);
    if (shiftKey && anchorKey) {
      const anchorId = polosa.idByKey.get(anchorKey);
      const targetId = polosa.idByKey.get(targetKey);
      if (!anchorId || anchorId !== targetId) {
        setNotice("Выделить можно только в пределах одной полосы (один КА, один крен, подряд по времени).");
        return; // якорь оставляем — оператор может выбрать другой конец той же полосы
      }
      // Члены полосы по времени, без BUSY (их бронировать нельзя — в выделение не берём).
      const members = (polosa.membersOf.get(anchorId) ?? []).filter((k) => {
        const slot = slotByKey.get(k);
        return !(slot && slotIsBusy(slot));
      });
      if (lastShiftEndRef.current === targetKey) {
        // Повторный Shift+клик по тому же концу — снимаем выделенный диапазон [якорь..конец].
        setSelectedKeys((current) => deselectStripRange(current, members, anchorKey, targetKey));
        lastShiftEndRef.current = null;
      } else {
        // Конфликтные члены полосы в выбор не берём (dropConflicts) — их выбрать нельзя.
        setSelectedKeys((current) => dropConflicts(selectStripRange(current, members, anchorKey, targetKey)));
        lastShiftEndRef.current = targetKey;
      }
      // Якорь НЕ двигаем: остаётся первым кликом, чтобы повторный Shift+клик задавал диапазон
      // от того же якоря — ближе к нему «сжимал» выделение, дальше — расширял.
      return;
    }
    // Обычный клик по конфликтному слоту — выбрать нельзя (снять выбор не блокируем: конфликтные
    // слоты в выбор не попадают, значит targetKey тут заведомо не выбран).
    if (conflictKeysRef.current.has(targetKey)) {
      setNotice("Слот конфликтует с выбором (не хватает времени на манёвр того же КА) — выбрать нельзя.");
      return;
    }
    setSelectedKeys((current) =>
      selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, false, null)
    );
    setAnchorKey(targetKey);
    lastShiftEndRef.current = null;
    lastMapEndRef.current = null;
  };

  // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые
  // бронью) бронировать нельзя — в массовый выбор не берём.
  const selectableVisible = useMemo(() => filteredSlots.filter((slot) => !slotIsBusy(slot)), [filteredSlots]);
  const allVisibleSelected =
    selectableVisible.length > 0 && selectableVisible.every((slot) => selectedKeys.has(slotKey(slot)));
  const toggleAllVisible = () => {
    setSelectedKeys((current) => {
      const next = new Set(current);
      if (allVisibleSelected) {
        for (const slot of selectableVisible) next.delete(slotKey(slot));
      } else {
        for (const slot of selectableVisible) next.add(slotKey(slot));
        dropConflicts(next); // конфликтные видимые слоты не добавляем
      }
      return next;
    });
    setAnchorKey(null);
  };

  // Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью и конфликтных видимых), снять весь
  // выбор. Конфликты считаются по видимому набору, поэтому режем известные (dropConflicts) — бэкенд
  // остаётся финальным арбитром выполнимости.
  const selectAllDays = () =>
    setSelectedKeys(dropConflicts(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey))));
  const clearSelection = () => {
    setSelectedKeys(new Set());
    setAnchorKey(null);
  };

  // Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества).
  const toggleInSet = <T,>(set: ReadonlySet<T>, value: T): Set<T> => {
    const next = new Set(set);
    if (next.has(value)) next.delete(value);
    else next.add(value);
    return next;
  };
  // Смена сортировки меняет позиции строк — якорь Shift-выбора больше не валиден, сбрасываем.
  const changeSort = (mode: SlotSortMode) => {
    setSortMode(mode);
    setAnchorKey(null);
  };
  const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
  const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
  const toggleBranch = (branch: RevolutionSign) => setBranchFilter((prev) => toggleInSet(prev, branch));
  // Фильтры в дефолте — всё видно (на всех трёх осях выбраны все значения).
  const filtersDefault =
    statusFilter.size === 3 && branchFilter.size === 2 && satFilter.size === satellites.length;
  const resetFilters = () => {
    setStatusFilter(new Set<SlotStatusKey>(["free", "booked", "occupied"]));
    setBranchFilter(new Set<RevolutionSign>(["ASC", "DESC"]));
    setSatFilter(new Set(satellites));
  };

  // Сколько выбрано из видимых суток и сколько — на других сутках (для прозрачности охвата).
  const visibleSelectedCount = useMemo(
    () => filteredSlots.reduce((n, slot) => (selectedKeys.has(slotKey(slot)) ? n + 1 : n), 0),
    [filteredSlots, selectedKeys]
  );
  const otherDaysSelectedCount = selectedKeys.size - visibleSelectedCount;

  const selectRow = (slot: SlotDTO) => {
    const id = `slot:${slotKey(slot)}`;
    setActiveSlotId(id);
    focusOn({ lat: slot.latitude, lon: slot.longitude });
  };

  // Переход на прохождение по индексу: ставит курсор и подтягивает окно суток к проходу (сохраняя
  // ширину). Никакой слот НЕ подсвечиваем — само прохождение изолирует таблицу, а активная подсветка
  // одной строки появляется только по явному клику оператора. Карту НЕ двигаем — оператор листает
  // прохождения, не теряя текущий обзор/зум; центрирование осталось только на клике по слоту.
  const goToPass = useCallback(
    (target: number) => {
      if (passes.length === 0) return;
      const idx = Math.max(0, Math.min(target, passes.length - 1));
      setPassCursor(idx);
      const pass = passes[idx];
      // Окно суток следует за курсором: если проход вне окна — сдвигаем, сохраняя ширину диапазона.
      if (days.length > 0 && Number.isFinite(pass.startMs)) {
        const di = days.findIndex((d) => d.dayMs === dayStartMs(pass.startMs));
        if (di >= 0) {
          const dayIndex = di + 1;
          const width = effDayTo - effDayFrom;
          if (dayIndex < effDayFrom) {
            setDayFrom(dayIndex);
            setDayTo(Math.min(days.length, dayIndex + width));
          } else if (dayIndex > effDayTo) {
            setDayTo(dayIndex);
            setDayFrom(Math.max(1, dayIndex - width));
          }
        }
      }
      // Снимаем активную подсветку строки с прошлого прохождения: при переходе не должен светиться
      // никакой слот (прокрутку к началу делает отдельный эффект по effPassCursor, без подсветки).
      setActiveSlotId(undefined);
    },
    [passes, days, effDayFrom, effDayTo]
  );

  // ◀ из «не выбран» входит с конца, ▶ — с начала; дальше клампим в [0, last].
  const prevPass = () => goToPass(effPassCursor < 0 ? passes.length - 1 : effPassCursor - 1);
  const nextPass = () => goToPass(effPassCursor < 0 ? 0 : effPassCursor + 1);
  const clearPass = () => setPassCursor(-1);
  const passLabel = currentPass
    ? `КА ${currentPass.satelliteId} · прохождение ${currentPass.revolution} ${
        currentPass.revolutionSign === "DESC" ? "нисх" : "восх"
      } · ${formatLocalDateTimeSeconds(currentPass.startMs)}`
    : "";

  // Выбор заявки из инспектора — закрываем инспектор, переходим в режим расчёта слотов.
  const chooseRequest = useCallback((requestId: string) => {
    setSelectedRequestId(requestId);
    setMapHits([]);
    setActiveHitId(undefined);
  }, []);

  // Сброс окна к состоянию «заявка не выбрана, слоты не показаны».
  const resetSelection = useCallback(() => {
    setSelectedRequestId("");
    setMapHits([]);
    setActiveHitId(undefined);
    setActiveSlotId(undefined);
  }, []);

  // Сцена карты. До выбора заявки — все заявки интервала (для выбора кликом). После выбора —
  // контур заявки (слой requests), слоты-кандидаты (swath) и брони (complexPlan).
  const scene = useMemo<Tgu2DMapScene>(() => {
    const polygons: MapPolygon[] = [];
    let z = 0;

    if (!hasRequest) {
      for (const item of requestItems) {
        if (!item.geometry) continue;
        for (const ring of parseWktPolygon(item.geometry)) {
          if (ring.length < 3) continue;
          polygons.push({
            id: `request:${item.id}`,
            name: item.name,
            kind: "request",
            layer: "requests",
            points: ring,
            requestId: item.id,
            zIndex: z++
          });
        }
      }
      return { polygons, lines: [], markers: [] };
    }

    if (selectedRequest?.geometry) {
      for (const ring of parseWktPolygon(selectedRequest.geometry)) {
        if (ring.length < 3) continue;
        polygons.push({
          id: `request:${selectedRequest.id}`,
          name: selectedRequest.name,
          kind: "request",
          layer: "requests",
          points: ring,
          requestId: selectedRequest.id,
          // Контур заявки рисуем, но из выбора исключаем — иначе он «съедает» меню выбора слотов
          // (как единственный requests-hit под точкой) и любой клик внутри выбирал бы заявку.
          pickable: false,
          zIndex: z++
        });
      }
    }
    // Рисуем все отфильтрованные слоты без потолка: общую картину не режем, объём сужает
    // оператор фильтрами (статус/КА/ветвь/сутки). Таблица виртуализирована, карта — canvas.
    for (const slot of filteredSlots) {
      const key = slotKey(slot);
      for (const ring of ringsFor(key, slot.contour)) {
        if (ring.length < 3) continue;
        polygons.push({
          id: `slot:${key}`,
          name: `КА ${slot.satelliteId} · слот ${slot.slotNumber}`,
          kind: bookedKeys.has(key) ? "complexPlan" : "swath",
          layer: bookedKeys.has(key) ? "complexPlan" : "swath",
          points: ring,
          spacecraftId: String(slot.satelliteId),
          revolution: slot.revolution,
          zIndex: z++
        });
      }
    }
    // Брони вне текущего окна суток (их нет в filteredSlots) — показываем на карте отдельным слоем,
    // чтобы оператор видел все брони заявки. Те, что в окне, уже нарисованы циклом выше. НО слой
    // подчиняем фильтрам видимости: выключен статус «бронь» (или скрыт КА/ветвь) — бронь не рисуем,
    // иначе фильтр убирал бы бронь из таблицы, но не с карты.
    if (statusFilter.has("booked")) {
      const slotKeysShown = new Set(filteredSlots.map(slotKey));
      for (const slot of bookedSlots) {
        const key = slotKey(slot);
        if (slotKeysShown.has(key)) continue;
        if (!satFilter.has(slot.satelliteId)) continue;
        if (!branchFilter.has(slot.revolutionSign)) continue;
        for (const ring of ringsFor(key, slot.contour)) {
          if (ring.length < 3) continue;
          polygons.push({
            id: `booked:${key}`,
            name: `КА ${slot.satelliteId} · слот ${slot.slotNumber} (бронь)`,
            kind: "complexPlan",
            layer: "complexPlan",
            points: ring,
            spacecraftId: String(slot.satelliteId),
            revolution: slot.revolution,
            zIndex: z++
          });
        }
      }
    }
    return { polygons, lines: [], markers: [], coverageRings };
  }, [
    hasRequest,
    requestItems,
    selectedRequest,
    slots,
    filteredSlots,
    bookedSlots,
    bookedKeys,
    ringsFor,
    statusFilter,
    satFilter,
    branchFilter,
    coverageRings
  ]);

  const layers = useMemo<Tgu2DMapLayersState>(
    () => ({
      tracks: false,
      swath: true,
      planWorks: false,
      complexPlan: true,
      heatmap: false,
      stations: false,
      spacecraftMarkers: false,
      requests: true
    }),
    []
  );

  // Клик по карте. С выбранной заявкой — подсветка слота (синхрон со строкой). Без заявки —
  // открываем инспектор заявки (одиночный клик) для выбора.
  const handleObjectSelect = useCallback(
    (selection?: Tgu2DMapSelection) => {
      if (hasRequest) {
        // Одиночное попадание — подсветку строки ведём только по клику на слот; клик по пустому
        // месту снимает подсветку. Меню выбора (наложение) при этом закрываем.
        setSlotPicks([]);
        const id = selection?.id;
        if (!selection) setActiveSlotId(undefined);
        else if (id && (id.startsWith("slot:") || id.startsWith("booked:"))) setActiveSlotId(id);
        return;
      }
      if (selection && selection.type === "polygon" && selection.layer === "requests") {
        setMapHits([selection]);
        setActiveHitId(selection.id);
      } else {
        setMapHits([]);
        setActiveHitId(undefined);
      }
    },
    [hasRequest]
  );

  // Наложение объектов под точкой. С выбранной заявкой — меню выбора слота (плотные наложения);
  // без заявки — список заявок в инспекторе с раскрытием нужной.
  const handleObjectsAtPoint = useCallback(
    (selections: Tgu2DMapSelection[]) => {
      if (hasRequest) {
        const seen = new Set<string>();
        const picks: SlotPick[] = [];
        for (const sel of selections) {
          if (sel.type !== "polygon") continue;
          if (!sel.id.startsWith("slot:") && !sel.id.startsWith("booked:")) continue;
          if (seen.has(sel.id)) continue;
          const key = sel.id.slice(sel.id.indexOf(":") + 1);
          const slot = slotByKey.get(key);
          if (!slot) continue;
          seen.add(sel.id);
          picks.push({ id: sel.id, slot, booked: bookedKeys.has(key) });
        }
        setSlotPicks(picks);
        return;
      }
      const requests = selections.filter((s) => s.type === "polygon" && s.layer === "requests");
      const hits = requests.length > 0 ? requests : selections;
      setMapHits(hits);
      setActiveHitId(hits[0]?.id);
    },
    [hasRequest, slotByKey, bookedKeys]
  );

  // Выбор слота из меню наложения — подсвечиваем строку, но меню оставляем открытым
  // (с пометкой активного пункта), чтобы можно было перебрать наложенные слоты; закрыть — крестиком.
  const pickSlot = useCallback((id: string) => {
    setActiveSlotId(id);
  }, []);

  const closeSlotPick = useCallback(() => setSlotPicks([]), []);

  const toggleHit = useCallback((selection: Tgu2DMapSelection) => {
    setActiveHitId((current) => (current === selection.id ? undefined : selection.id));
  }, []);

  const closeInspector = useCallback(() => {
    setMapHits([]);
    setActiveHitId(undefined);
  }, []);

  const busy = calculating || autoFilling;
  const canCalculate = hasRequest && !rangeInvalid && !busy;
  const overlayMessage = calculating
    ? "Расчёт слотов…"
    : autoFilling
      ? "Автоподбор покрытия…"
      : !hasRequest
      ? requestsLoading
        ? "Загрузка заявок…"
        : undefined
      : slots.length === 0 && bookedSlots.length === 0
        ? "Слоты не рассчитаны — нажмите «Показать все слоты» или «Подобрать покрытие»."
        : undefined;

  // Число колонок таблицы (для colSpan распорок виртуализатора) — +1 на колонку «Вклад».
  const colCount = showGain ? 8 : 7;

  // Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка).
  const virtualRows = rowVirtualizer.getVirtualItems();
  const virtualTotal = rowVirtualizer.getTotalSize();
  const padTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
  const padBottom = virtualRows.length > 0 ? virtualTotal - virtualRows[virtualRows.length - 1].end : 0;

  return (
    <main className="tgu-map-workspace">
      <div className="tgu-map-toolbar">
        <div>
          <div className="tgu-map-toolbar__title">Слоты</div>
          <div className="tgu-map-toolbar__subtitle">Расчёт покрытия и бронирование слотов заявки</div>
        </div>
        <div className="tgu-field tgu-slots-request">
          <span>Заявка</span>
          <div className="tgu-slots-request__name" title={selectedRequest?.name}>
            {selectedRequest ? selectedRequest.name : "—"}
          </div>
        </div>
        <label className="tgu-field">
          <span>С</span>
          <DateTime24Field value={fromValue} onChange={setFromValue} />
        </label>
        <label className="tgu-field">
          <span>По</span>
          <DateTime24Field value={toValue} onChange={setToValue} />
        </label>
        <label className="tgu-field">
          <span>Ветвь</span>
          <select
            className="tgu-select"
            value={revSign}
            onChange={(event) => setRevSign(event.target.value as "" | RevolutionSign)}
          >
            <option value="">обе</option>
            <option value="ASC">восходящая</option>
            <option value="DESC">нисходящая</option>
          </select>
        </label>
        <button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate} title="Показать весь набор слотов-кандидатов для ручного отбора">
          {calculating ? "Расчёт…" : "Показать все слоты"}
        </button>
        <label className="tgu-field" title="Стратегия отбора покрытия для «Подобрать покрытие»">
          <span>Стратегия покрытия</span>
          <select
            className="tgu-select"
            value={strategy}
            onChange={(event) => setStrategy(event.target.value as SlotCoverageStrategy)}
            disabled={busy}
          >
            <option value="GREEDY">жадный</option>
            <option value="CONTINUOUS">непрерывный</option>
          </select>
        </label>
        <button className="tgu-button" type="button" onClick={autoCover} disabled={!canCalculate} title="Отобрать достаточное покрытие по стратегии и предложить под бронь">
          {autoFilling ? "Подбор…" : "Подобрать покрытие"}
        </button>
        <button
          className="tgu-button"
          type="button"
          onClick={book}
          disabled={opBusy || selectedBookableCount === 0}
          title="Забронировать выбранные слоты-кандидаты"
        >
          {booking
            ? "Бронь…"
            : `Забронировать${selectedBookableCount > 0 ? ` (${selectedBookableCount})` : ""}`}
        </button>
        <button
          className="tgu-button"
          type="button"
          onClick={cancelSelected}
          disabled={opBusy || selectedBookedIds.length === 0}
          title="Снять бронь с выбранных слотов (пачками)"
        >
          {cancellingSel
            ? "Снятие…"
            : `Снять выбранные${selectedBookedIds.length > 0 ? ` (${selectedBookedIds.length})` : ""}`}
        </button>
        <button
          className="tgu-button"
          type="button"
          onClick={cancelAll}
          disabled={opBusy || bookedSlots.length === 0}
          title="Снять все брони заявки"
        >
          {cancelling ? "Снятие…" : `Снять всё${bookedSlots.length > 0 ? ` (${bookedSlots.length})` : ""}`}
        </button>
        <div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
        {(slots.length > 0 || bookedSlots.length > 0) && (
          <div className="tgu-map-toolbar__subtitle">
            слотов {slots.length} · забронировано {bookedSlots.length}
          </div>
        )}
      </div>

      <div className="tgu-slots-body">
        <section className="tgu-slots-list">
          {hasRequest ? (
            <>
              {displaySlots.length > 0 && (
                <SlotFilterBar
                  statusCounts={statusCounts}
                  statusFilter={statusFilter}
                  onToggleStatus={toggleStatus}
                  satellites={satellites}
                  satFilter={satFilter}
                  onToggleSat={toggleSat}
                  branchFilter={branchFilter}
                  onToggleBranch={toggleBranch}
                  sortMode={sortMode}
                  onSortMode={changeSort}
                  gainAvailable={coverageAvailable}
                  isDefault={filtersDefault}
                  onReset={resetFilters}
                />
              )}
              {displaySlots.length > 0 && (
                <CoveragePanel
                  booked={coverageStats.booked}
                  withSelection={coverageStats.withSelection}
                  hasSelection={coverageStats.hasSelection}
                  available={coverageGrid !== null}
                  coverageOn={coverageOn}
                  onToggleCoverage={setCoverageOn}
                  showConflicts={showConflicts}
                  onToggleConflicts={setShowConflicts}
                />
              )}
              <div className="tgu-slots-selbar">
                <span className="tgu-slots-selbar__count">
                  Выбрано: {selectedKeys.size}
                  {otherDaysSelectedCount > 0 && (
                    <span className="tgu-slots-selbar__sub">
                      {" "}
                      (видно {visibleSelectedCount}, на др. сутки {otherDaysSelectedCount})
                    </span>
                  )}
                </span>
                <span className="tgu-slots-selbar__sub" title="Таблица: кликните первый слот, затем Shift+клик по последнему — выделится полоса между ними. На карте: Shift+зажать на слоте и протянуть мышью вдоль полосы. Выйти за полосу (другой КА/крен/разрыв по времени) нельзя.">
                  Полоса: клик+Shift в таблице · Shift+тянуть на карте
                </span>
                <div className="tgu-slots-selbar__actions">
                  <button
                    className="tgu-button tgu-button--sm"
                    type="button"
                    onClick={selectAllDays}
                    disabled={displaySlots.length === 0}
                    title="Выбрать все строки (кандидаты и брони, по всем суткам)"
                  >
                    Все сутки
                  </button>
                  <button
                    className="tgu-button tgu-button--sm"
                    type="button"
                    onClick={clearSelection}
                    disabled={selectedKeys.size === 0}
                  >
                    Снять выбор
                  </button>
                </div>
              </div>
              <div className="tgu-slots-scroll" ref={listRef}>
                {filteredSlots.length === 0 ? (
                  <div className="tgu-slots-empty">
                    {displaySlots.length === 0 ? (
                      "Слоты не рассчитаны."
                    ) : dayWindowed.length === 0 ? (
                      "На выбранные сутки слотов нет."
                    ) : (
                      <>
                        Слоты скрыты фильтром — включите статус/КА или сбросьте фильтры.
                        <div className="tgu-slots-empty__action">
                          <button type="button" className="tgu-button tgu-button--sm" onClick={resetFilters}>
                            Сбросить фильтры
                          </button>
                        </div>
                      </>
                    )}
                  </div>
                ) : (
                  <table className="tgu-slots-table">
                    <thead>
                      <tr>
                        <th>
                          <input
                            type="checkbox"
                            checked={allVisibleSelected}
                            onChange={toggleAllVisible}
                            aria-label="Выбрать видимые сутки"
                          />
                        </th>
                        <th>КА</th>
                        <th>Виток</th>
                        <th>Начало</th>
                        <th>Длит.</th>
                        <th>Крен</th>
                        {showGain && (
                          <th
                            className="tgu-slots-gain-col"
                            title="Прирост покрытия в порядке ранжирования — фиксируется на момент ранжирования, не пересчитывается при выборе"
                          >
                            Вклад
                          </th>
                        )}
                        <th>Статус</th>
                      </tr>
                    </thead>
                    <tbody>
                      {padTop > 0 && (
                        <tr aria-hidden="true" style={{ height: padTop }}>
                          <td colSpan={colCount} />
                        </tr>
                      )}
                      {virtualRows.map((vrow) => {
                        const slot = sortedSlots[vrow.index];
                        const key = slotKey(slot);
                        const id = `slot:${key}`;
                        const booked = bookedKeys.has(key);
                        const isActive = activeSlotId === id;
                        // Подсветка полосы текущего якоря: оператор видит, что выделит Shift+клик,
                        // и что за границу полосы (другой КА/крен/разрыв) выйти нельзя.
                        const polosaId = polosa.idByKey.get(key);
                        const inAnchorPolosa =
                          anchorKey != null &&
                          polosaId != null &&
                          polosaId === polosa.idByKey.get(anchorKey);
                        const isAnchorRow = anchorKey === key;
                        // Подсветка строк текущего витка (курсор ◀/▶) — оператор видит проход целиком.
                        const inPass = passKeySet.has(key);
                        // Конфликт с выбором (тот же КА, пересечение по времени) — гасим строку.
                        const isConflict = conflictKeys.has(key);
                        const rowClass = [
                          inPass ? "tgu-slots-row--pass" : "",
                          isConflict ? "tgu-slots-row--conflict" : "",
                          isActive ? "is-active" : "",
                          inAnchorPolosa ? "tgu-slots-row--strip" : "",
                          isAnchorRow ? "is-strip-anchor" : "",
                        ]
                          .filter(Boolean)
                          .join(" ");
                        return (
                          <tr
                            key={key}
                            data-slot-id={id}
                            style={{
                              height: ROW_HEIGHT,
                              ...(inAnchorPolosa && polosaId
                                ? { ["--strip-hue" as string]: String(stripHue(polosaId)) }
                                : {}),
                            }}
                            className={rowClass || undefined}
                            onClick={(event) => {
                              // Клик по строке = и подсветка на карте, и жест выбора полосы:
                              // обычный клик выбирает слот + ставит якорь, Shift+клик тянет полосу
                              // до этой строки. BUSY не выбираем (бронировать нельзя).
                              selectRow(slot);
                              if (!slotIsBusy(slot)) handleSlotCheck(vrow.index, event.shiftKey);
                            }}
                          >
                            <td onClick={(event) => event.stopPropagation()}>
                              <input
                                type="checkbox"
                                checked={selectedKeys.has(key)}
                                onChange={() => {}}
                                onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)}
                                disabled={slotIsBusy(slot)}
                                title={slotIsBusy(slot) ? "Слот недоступен — конфликт с бронью, бронировать нельзя" : undefined}
                                aria-label="Выбрать слот"
                              />
                            </td>
                            <td>{slot.satelliteId}</td>
                            <td>
                              {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} /{" "}
                              {slot.cycle}
                            </td>
                            <td>{formatLocalDateTimeSeconds(parseLocal(slot.tn))}</td>
                            <td>{slotDurationSec(slot)} с</td>
                            <td>{slot.roll.toFixed(1)}°</td>
                            {showGain && (
                              <td className="tgu-slots-gain">
                                {booked ? "—" : `+${gainRanking?.pct.get(key) ?? 0}%`}
                              </td>
                            )}
                            <td>
                              {booked ? (
                                <span className="tgu-slots-status-cell">
                                  <span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
                                  <button
                                    type="button"
                                    className="tgu-slots-cancel-one"
                                    title="Снять бронь с этого слота"
                                    aria-label="Снять бронь с этого слота"
                                    disabled={opBusy}
                                    onClick={(event) => {
                                      event.stopPropagation();
                                      const id = bookedByKey.get(key)?.bookedSlotId;
                                      if (id != null) cancelOne(id);
                                    }}
                                  >
                                    ✕
                                  </button>
                                </span>
                              ) : (
                                <span className="tgu-slots-status-cell">
                                  <span
                                    className={
                                      slot.state === "BUSY"
                                        ? "tgu-slots-badge tgu-slots-badge--occupied"
                                        : "tgu-slots-badge"
                                    }
                                  >
                                    {slotStateLabel(slot.state)}
                                  </span>
                                  {slot.state === "BUSY" &&
                                    slot.holders?.map((holder) => (
                                      <span
                                        key={holder.requestId}
                                        className="tgu-slots-holder"
                                        title={`Слот держит заявка ${holder.name ?? holder.requestId}${
                                          holder.importance != null
                                            ? ` · важность ${holder.importance.toFixed(2)}`
                                            : ""
                                        }`}
                                      >
                                        {holder.name ?? holder.requestId}
                                        {holder.importance != null && (
                                          <span className="tgu-slots-holder__imp">
                                            {holder.importance.toFixed(2)}
                                          </span>
                                        )}
                                      </span>
                                    ))}
                                </span>
                              )}
                            </td>
                          </tr>
                        );
                      })}
                      {padBottom > 0 && (
                        <tr aria-hidden="true" style={{ height: padBottom }}>
                          <td colSpan={colCount} />
                        </tr>
                      )}
                    </tbody>
                  </table>
                )}
              </div>
            </>
          ) : (
            <div className="tgu-slots-requests-panel">
              <div className="tgu-map-panel__section">
                <div className="tgu-map-panel__label">Заявки на интервал</div>
                <div className="tgu-map-requests-filter">
                  <label className="tgu-field">
                    <span>С</span>
                    <DateTime24Field value={reqFromValue} onChange={setReqFromValue} />
                  </label>
                  <label className="tgu-field">
                    <span>По</span>
                    <DateTime24Field value={reqToValue} onChange={setReqToValue} />
                  </label>
                  <div className="tgu-map-requests-filter__actions">
                    <button
                      className="tgu-button tgu-button--primary tgu-button--sm"
                      type="button"
                      onClick={applyRequests}
                      disabled={requestsLoading || reqRangeInvalid}
                    >
                      {requestsLoading ? "Загрузка..." : "Загрузить"}
                    </button>
                    <button className="tgu-button tgu-button--sm" type="button" onClick={quickToday} disabled={requestsLoading}>
                      Сегодня
                    </button>
                    <button className="tgu-button tgu-button--sm" type="button" onClick={quick7days} disabled={requestsLoading}>
                      7 дней
                    </button>
                    <button className="tgu-button tgu-button--sm" type="button" onClick={quick30days} disabled={requestsLoading}>
                      30 дней
                    </button>
                  </div>
                  {requestsError && (
                    <div className="tgu-alert tgu-alert--danger tgu-alert--compact">{requestsError}</div>
                  )}
                  {requestsLoaded && !requestsError && (
                    <div className="tgu-map-requests-filter__status">
                      {requestItems.length === 0
                        ? "Заявок не найдено."
                        : `Загружено: ${requestItems.length} из ${requestsTotal}`}
                      {requestsTruncated && (
                        <span className="tgu-map-requests-filter__truncated"> (показаны первые 5000 — уточните интервал)</span>
                      )}
                    </div>
                  )}
                  {requestItems.length > 0 && (
                    <div className="tgu-map-requests-legend">
                      <span><i className="req-optics" />OPTICS</span>
                      <span><i className="req-rsa" />RSA</span>
                      <span><i className="req-combined" />COMBINED</span>
                    </div>
                  )}
                </div>
              </div>
              <div className="tgu-slots-empty">Кликните заявку на карте, чтобы выбрать.</div>
            </div>
          )}
        </section>

        <section className="tgu-map-stage">
          {overlayMessage && (
            <div className="tgu-map-overlay">
              <span>{overlayMessage}</span>
              {busy && (
                <button type="button" className="tgu-button tgu-button--sm" onClick={cancelCalculation}>
                  Отмена
                </button>
              )}
            </div>
          )}
          {pendingBook && (
            <div className="tgu-slots-confirm">
              <span>Забронировать выбранные слоты ({pendingBook.length})?</span>
              <div className="tgu-slots-confirm__actions">
                <button className="tgu-button tgu-button--primary tgu-button--sm" type="button" onClick={confirmPendingBook}>
                  Забронировать
                </button>
                <button className="tgu-button tgu-button--sm" type="button" onClick={cancelPendingBook}>
                  Отмена
                </button>
              </div>
            </div>
          )}
          {pendingCancel && (
            <div className="tgu-slots-confirm">
              <span>Снять все брони заявки ({bookedSlots.length})? Действие необратимо.</span>
              <div className="tgu-slots-confirm__actions">
                <button className="tgu-button tgu-button--primary tgu-button--sm" type="button" onClick={confirmPendingCancel}>
                  Снять всё
                </button>
                <button className="tgu-button tgu-button--sm" type="button" onClick={cancelPendingCancel}>
                  Отмена
                </button>
              </div>
            </div>
          )}
          {progress && (
            <ChunkProgress label={progress.label} done={progress.done} total={progress.total} />
          )}
          {hasRequest && (
            <button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
              Сброс
            </button>
          )}
          {hasRequest && days.length > 0 && (
            <div className="tgu-slots-map-controls">
              <SlotDaySelector
                days={days}
                fromIndex={effDayFrom}
                toIndex={effDayTo}
                onFromChange={setDayFrom}
                onToChange={setDayTo}
              />
              {passes.length > 0 && (
                <PassStepper
                  index={effPassCursor}
                  total={passes.length}
                  label={passLabel}
                  canPrev={effPassCursor !== 0}
                  canNext={effPassCursor !== passes.length - 1}
                  onPrev={prevPass}
                  onNext={nextPass}
                  onClear={clearPass}
                />
              )}
            </div>
          )}
          <Tgu2DMapView
            scene={scene}
            layers={layers}
            redrawToken={
              hasRequest
                ? filteredSlots.length + bookedSlots.length + selectedKeys.size + (coverageOn ? 1 : 0)
                : requestItems.length
            }
            enableObjectChooser
            onObjectsAtPoint={handleObjectsAtPoint}
            onObjectSelect={handleObjectSelect}
            onStripAnchor={hasRequest ? handleStripAnchor : undefined}
            onStripDrag={hasRequest ? handleStripDrag : undefined}
            onStripCommit={hasRequest ? handleStripCommit : undefined}
            selectedObjectId={hasRequest ? activeSlotId : activeHitId}
            markedObjectIds={hasRequest ? markedSlotIds : undefined}
            dimmedObjectIds={hasRequest ? dimmedSlotIds : undefined}
            focusPoint={focusPoint}
            focusToken={focusToken}
          />
          {!hasRequest && mapHits.length > 0 && (
            <aside className="tgu-slots-inspector">
              <MapObjectInspector
                hits={mapHits}
                activeId={activeHitId}
                onToggle={toggleHit}
                onClose={closeInspector}
                requestItems={requestItems}
                onSelectRequest={chooseRequest}
              />
            </aside>
          )}
          {hasRequest && slotPicks.length > 0 && (
            <SlotPickMenu picks={slotPicks} activeId={activeSlotId} onPick={pickSlot} onClose={closeSlotPick} />
          )}
        </section>
      </div>

      <div className="tgu-map-errors">
        {notice && <div className="tgu-alert">{notice}</div>}
        {error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
      </div>
    </main>
  );
}

function strategyLabel(strategy: SlotCoverageStrategy): string {
  return strategy === "CONTINUOUS" ? "непрерывный" : "жадный";
}

/** Длительность слота в секундах (tk − tn). Слоты короткие, поэтому показываем секунды. */
function slotDurationSec(slot: SlotDTO): number {
  return Math.max(0, Math.round((parseLocal(slot.tk) - parseLocal(slot.tn)) / 1000));
}

/** Слот занят бронью на этом проходе КА (state BUSY): показываем для обзора, но забронировать
 *  нельзя — исключаем из выбора под бронь. Бронь самой заявки приходит как BOOKED, не BUSY. */
function slotIsBusy(slot: Pick<SlotDTO, "state">): boolean {
  return slot.state === "BUSY";
}

/** Детерминированный оттенок (0..359) полосы по её StripId — для приглушённого рельса слева,
 *  чтобы соседние полосы визуально различались. Чистый хэш строки, без состояния. */
function stripHue(stripId: string): number {
  let h = 0;
  for (let i = 0; i < stripId.length; i++) h = (h * 31 + stripId.charCodeAt(i)) >>> 0;
  return h % 360;
}

function slotStateLabel(state: string): string {
  switch (state) {
    case "AVAILABLE":
      return "свободен";
    case "BOOKED":
      return "бронь";
    case "BUSY":
      return "недоступен";
    default:
      return state || "—";
  }
}
