import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { fetchRequestsForMap } from "../../api/requestApi";
import {
  bookRequestSlots,
  cancelRequestBooking,
  fetchBookedSlotsByRequest,
  fetchRequestSlots
} from "../../api/slotsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type {
  BookedSlotDTO,
  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 { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
import { chunk, selectionAfterCheck } from "./slotSelection";

const DAY_MS = 24 * 60 * 60 * 1000;

/** Размер чанка при бронировании больших наборов (бэк бронирует слоты по одному). */
const BOOK_CHUNK = 200;
/** Выше этого числа слотов бронь требует подтверждения. */
const BOOK_CONFIRM_THRESHOLD = 500;
/** Выше этого числа полигонов в окне суток показываем подсказку «сузьте сутки». */
const MAP_HINT_THRESHOLD = 800;
/** Оценка высоты строки таблицы (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 [withCoverage, setWithCoverage] = useState(false);
  // Стратегия автоформирования покрытия (кнопка «Автоподбор»).
  const [strategy, setStrategy] = useState<SlotCoverageStrategy>("GREEDY");

  // Результаты: слоты-кандидаты и уже забронированные слоты заявки.
  const [slots, setSlots] = useState<SlotDTO[]>([]);
  const [bookedSlots, setBookedSlots] = useState<SlotDTO[]>([]);
  const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());

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

  const [calculating, setCalculating] = useState(false);
  const [autoFilling, setAutoFilling] = useState(false);
  const [booking, setBooking] = useState(false);
  // Прогресс чанкованной брони «X/Y» и набор, ждущий подтверждения (крупная бронь).
  const [bookProgress, setBookProgress] = useState<string>();
  const [pendingBook, setPendingBook] = useState<SlotBookingRequest[] | null>(null);
  const [error, setError] = useState<string>();
  const [notice, setNotice] = useState<string>();

  // Кэш распарсенных WKT-контуров по ключу слота — парсим контур один раз, а не на каждый
  // ререндер сцены (критично на тысячах слотов). Чистится при смене заявки.
  const wktCacheRef = useRef(new Map<string, GeoPoint[][]>());
  // Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки.
  const abortRef = useRef<AbortController | null>(null);
  // Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots).
  const anchorIndexRef = useRef<number | 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]);

  // 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]);

  // Сутки, по которым раскладываются рассчитанные слоты (для фильтра отображения).
  const days = useMemo<SlotDay[]>(() => {
    const buckets = new Map<number, number>();
    for (const slot of slots) {
      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) }));
  }, [slots]);

  // Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон.
  useEffect(() => {
    setDayFrom(1);
    setDayTo(days.length || 1);
  }, [days]);

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

  // Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте.
  const filteredSlots = useMemo(() => {
    if (days.length === 0) return slots;
    const fromMs = days[effDayFrom - 1].dayMs;
    const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
    return slots.filter((slot) => {
      const ms = parseLocal(slot.tn);
      return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl;
    });
  }, [slots, days, effDayFrom, effDayTo]);

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

  // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
  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: toLocalIso(fromMs), beginTo: toLocalIso(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]);

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

  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;
  }, []);

  // Загружает брони выбранной заявки (для бейджей и слоя карты).
  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();
    anchorIndexRef.current = null;
    setSlots([]);
    setSelectedKeys(new Set());
    setBookedSlots([]);
    setPendingBook(null);
    setNotice(undefined);
    setError(undefined);
    setActiveSlotId(undefined);
    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;
    anchorIndexRef.current = null;
    if (auto) setAutoFilling(true);
    else setCalculating(true);
    setError(undefined);
    setNotice(undefined);
    try {
      const result = await fetchRequestSlots(
        {
          requestId: selectedRequestId,
          timeStart: toLocalIso(parsedRange.fromMs),
          timeStop: toLocalIso(parsedRange.toMs),
          revSign: revSign || undefined,
          cov: withCoverage,
          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}.`
            : "Для выбранного интервала слотов покрытия не найдено."
        );
      }
      if (result.length > 0) {
        focusOn({ lat: result[0].latitude, lon: result[0].longitude });
      }
    } 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();

  // Бронирует набор чанками (бэк бронирует слоты по одному в одной транзакции — крупный набор
  // бьём на части, чтобы не упереться в таймаут/долгую транзакцию) и показывает прогресс.
  const performBook = async (toBook: SlotBookingRequest[]) => {
    setBooking(true);
    setError(undefined);
    setNotice(undefined);
    setBookProgress(toBook.length > BOOK_CHUNK ? `0/${toBook.length}` : undefined);
    try {
      const collected: BookedSlotDTO[] = [];
      let done = 0;
      for (const part of chunk(toBook, BOOK_CHUNK)) {
        const booked = await bookRequestSlots(selectedRequestId, part);
        collected.push(...booked);
        done += part.length;
        if (toBook.length > BOOK_CHUNK) setBookProgress(`${done}/${toBook.length}`);
      }
      // Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle
      // рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле
      // (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и
      // бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты
      // (с контуром), что вернул POST, не перечитывая by-request.
      const bookedSet = new Set(collected.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`));
      const justBooked = slots.filter((slot) => bookedSet.has(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);
      setBookProgress(undefined);
    }
  };

  const collectSelected = (): SlotBookingRequest[] =>
    slots
      .filter((slot) => selectedKeys.has(slotKey(slot)))
      .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));

  const book = () => {
    if (!selectedRequestId || booking || selectedKeys.size === 0) return;
    const toBook = collectSelected();
    if (toBook.length === 0) return;
    // Крупная бронь — сначала подтверждение, потом чанкованный POST.
    if (toBook.length > BOOK_CONFIRM_THRESHOLD) {
      setPendingBook(toBook);
      return;
    }
    void performBook(toBook);
  };

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

  const cancelBooking = async () => {
    if (!selectedRequestId || booking || bookedSlots.length === 0) return;
    setBooking(true);
    setError(undefined);
    setNotice(undefined);
    try {
      const cancelled = await cancelRequestBooking(selectedRequestId);
      // Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()).
      setBookedSlots([]);
      setNotice(`Снято броней: ${cancelled}.`);
    } catch (caught) {
      setError(caught instanceof Error ? caught.message : "Не удалось снять бронь.");
    } finally {
      setBooking(false);
    }
  };

  // Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до
  // текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки.
  const handleSlotCheck = (index: number, shiftKey: boolean) => {
    setSelectedKeys((current) =>
      selectionAfterCheck(current, (i) => slotKey(filteredSlots[i]), index, shiftKey, anchorIndexRef.current)
    );
    anchorIndexRef.current = index;
  };

  // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) слоты.
  const allVisibleSelected =
    filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot)));
  const toggleAllVisible = () => {
    setSelectedKeys((current) => {
      const next = new Set(current);
      if (allVisibleSelected) for (const slot of filteredSlots) next.delete(slotKey(slot));
      else for (const slot of filteredSlots) next.add(slotKey(slot));
      return next;
    });
    anchorIndexRef.current = null;
  };
  // Выбрать все слоты расчёта (по всем суткам), снять весь выбор.
  const selectAllDays = () => setSelectedKeys(new Set(slots.map(slotKey)));
  const clearSelection = () => {
    setSelectedKeys(new Set());
    anchorIndexRef.current = null;
  };

  // Сколько выбрано из видимых суток и сколько — на других сутках (для прозрачности охвата).
  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 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++
        });
      }
    }
    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++
        });
      }
    }
    // Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно.
    const slotKeysShown = new Set(slots.map(slotKey));
    for (const slot of bookedSlots) {
      const key = slotKey(slot);
      if (slotKeysShown.has(key)) 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: [] };
  }, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys, ringsFor]);

  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;

  // Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка).
  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;
  // Слишком много полигонов в окне суток — подсказываем сузить (карту не блокируем).
  const mapOverloaded = filteredSlots.length > MAP_HINT_THRESHOLD;

  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>
        <label className="tgu-checkbox" title="Считать площадь покрытия (дольше)">
          <input
            type="checkbox"
            checked={withCoverage}
            onChange={(event) => setWithCoverage(event.target.checked)}
          />
          <span>Покрытие</span>
        </label>
        <button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate}>
          {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={booking || selectedKeys.size === 0}
        >
          {booking
            ? bookProgress
              ? `Бронь… ${bookProgress}`
              : "Бронь…"
            : `Забронировать${selectedKeys.size > 0 ? ` (${selectedKeys.size})` : ""}`}
        </button>
        <button
          className="tgu-button"
          type="button"
          onClick={cancelBooking}
          disabled={booking || bookedSlots.length === 0}
        >
          Снять бронь{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 ? (
            <>
              <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>
                <div className="tgu-slots-selbar__actions">
                  <button
                    className="tgu-button tgu-button--sm"
                    type="button"
                    onClick={selectAllDays}
                    disabled={slots.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">
                    {slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
                  </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>
                        <th>Крен</th>
                        <th>Статус</th>
                      </tr>
                    </thead>
                    <tbody>
                      {padTop > 0 && (
                        <tr aria-hidden="true" style={{ height: padTop }}>
                          <td colSpan={8} />
                        </tr>
                      )}
                      {virtualRows.map((vrow) => {
                        const slot = filteredSlots[vrow.index];
                        const key = slotKey(slot);
                        const id = `slot:${key}`;
                        const booked = bookedKeys.has(key);
                        const isActive = activeSlotId === id;
                        return (
                          <tr
                            key={key}
                            data-slot-id={id}
                            style={{ height: ROW_HEIGHT }}
                            className={isActive ? "is-active" : undefined}
                            onClick={() => selectRow(slot)}
                          >
                            <td onClick={(event) => event.stopPropagation()}>
                              <input
                                type="checkbox"
                                checked={selectedKeys.has(key)}
                                onChange={() => {}}
                                onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)}
                                aria-label="Выбрать слот"
                              />
                            </td>
                            <td>{slot.satelliteId}</td>
                            <td>
                              {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"}
                            </td>
                            <td>
                              {slot.slotNumber} / {slot.cycle}
                            </td>
                            <td>{formatLocalDateTime(parseLocal(slot.tn))}</td>
                            <td>{formatLocalDateTime(parseLocal(slot.tk))}</td>
                            <td>{slot.roll.toFixed(1)}°</td>
                            <td>
                              {booked ? (
                                <span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
                              ) : (
                                <span className="tgu-slots-badge">{slotStateLabel(slot.state)}</span>
                              )}
                            </td>
                          </tr>
                        );
                      })}
                      {padBottom > 0 && (
                        <tr aria-hidden="true" style={{ height: padBottom }}>
                          <td colSpan={8} />
                        </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>
          )}
          {hasRequest && (
            <button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
              Сброс
            </button>
          )}
          {hasRequest && mapOverloaded && (
            <div className="tgu-slots-map-hint">
              Много слотов в окне ({filteredSlots.length}) — сузьте сутки для удобной карты.
            </div>
          )}
          {hasRequest && days.length > 0 && (
            <SlotDaySelector
              days={days}
              fromIndex={effDayFrom}
              toIndex={effDayTo}
              onFromChange={setDayFrom}
              onToChange={setDayTo}
            />
          )}
          <Tgu2DMapView
            scene={scene}
            layers={layers}
            redrawToken={hasRequest ? filteredSlots.length + bookedSlots.length : requestItems.length}
            enableObjectChooser
            onObjectsAtPoint={handleObjectsAtPoint}
            onObjectSelect={handleObjectSelect}
            selectedObjectId={hasRequest ? activeSlotId : activeHitId}
            markedObjectIds={hasRequest ? markedSlotIds : 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" ? "непрерывный" : "жадный";
}

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