import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import {
  bookRequestSlots,
  cancelRequestBooking,
  fetchBookedSlotsByRequest,
  fetchRequestSlots
} from "../../api/slotsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { RevolutionSign, 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";

const DAY_MS = 24 * 60 * 60 * 1000;

/** Начало локальных суток момента (мс) — для группировки слотов по суткам. */
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 [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 [booking, setBooking] = useState(false);
  const [error, setError] = useState<string>();
  const [notice, setNotice] = useState<string>();

  // Подсветка строки/контура по клику на карте и центрирование на слоте.
  const [activeSlotId, setActiveSlotId] = useState<string>();
  const [focusPoint, setFocusPoint] = useState<GeoPoint>();
  const focusTokenRef = useRef(0);
  const [focusToken, setFocusToken] = useState(0);
  // Контейнер списка слотов — по нему ищем активную строку (data-slot-id) и скроллим её в зону видимости.
  const listRef = useRef<HTMLElement>(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 "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]);

  // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
  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) return;
    const container = listRef.current;
    const row = container?.querySelector<HTMLElement>(`[data-slot-id="${activeSlotId}"]`);
    if (!container || !row) return;
    const cRect = container.getBoundingClientRect();
    const rRect = row.getBoundingClientRect();
    if (rRect.top >= cRect.top && rRect.bottom <= cRect.bottom) return; // строка уже целиком видна
    const delta = rRect.top + rRect.height / 2 - (cRect.top + container.clientHeight / 2);
    container.scrollBy({ top: delta, behavior: "smooth" });
  }, [activeSlotId]);

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

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

  // Смена заявки — сбрасываем результаты и подтягиваем уже существующие брони.
  useEffect(() => {
    setSlots([]);
    setSelectedKeys(new Set());
    setBookedSlots([]);
    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 calculate = async () => {
    if (!selectedRequestId || rangeInvalid || calculating) return;
    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
      });
      setSlots(result);
      setSelectedKeys(new Set());
      setNotice(
        result.length > 0
          ? `Найдено слотов: ${result.length}.`
          : "Для выбранного интервала слотов покрытия не найдено."
      );
      if (result.length > 0) {
        focusOn({ lat: result[0].latitude, lon: result[0].longitude });
      }
    } catch (caught) {
      setSlots([]);
      setError(caught instanceof Error ? caught.message : "Не удалось рассчитать слоты.");
    } finally {
      setCalculating(false);
    }
  };

  const book = async () => {
    if (!selectedRequestId || booking || selectedKeys.size === 0) return;
    const toBook = slots
      .filter((slot) => selectedKeys.has(slotKey(slot)))
      .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));
    if (toBook.length === 0) return;
    setBooking(true);
    setError(undefined);
    setNotice(undefined);
    try {
      const booked = await bookRequestSlots(selectedRequestId, toBook);
      // Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle
      // рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле
      // (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и
      // бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты
      // (с контуром), что вернул POST, не перечитывая by-request.
      const bookedSet = new Set(booked.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(`Забронировано слотов: ${booked.length}.`);
    } catch (caught) {
      setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты.");
    } finally {
      setBooking(false);
    }
  };

  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);
    }
  };

  const toggleSlot = (key: string) => {
    setSelectedKeys((current) => {
      const next = new Set(current);
      if (next.has(key)) next.delete(key);
      else next.add(key);
      return next;
    });
  };

  // «Выбрать все» работает по видимым (отфильтрованным) слотам.
  const allSelected =
    filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot)));
  const toggleAll = () => {
    setSelectedKeys(allSelected ? new Set() : new Set(filteredSlots.map(slotKey)));
  };

  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 parseWktPolygon(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 parseWktPolygon(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]);

  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 canCalculate = hasRequest && !rangeInvalid && !calculating;
  const overlayMessage = calculating
    ? "Расчёт слотов…"
    : !hasRequest
      ? requestsLoading
        ? "Загрузка заявок…"
        : undefined
      : slots.length === 0 && bookedSlots.length === 0
        ? "Слоты не рассчитаны — нажмите «Рассчитать слоты»."
        : undefined;

  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>
        <button
          className="tgu-button"
          type="button"
          onClick={book}
          disabled={booking || selectedKeys.size === 0}
        >
          Забронировать{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" ref={listRef}>
          {hasRequest ? (
            filteredSlots.length === 0 ? (
              <div className="tgu-slots-empty">
                {slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
              </div>
            ) : (
              <table className="tgu-slots-table">
                <thead>
                  <tr>
                    <th>
                      <input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать все" />
                    </th>
                    <th>КА</th>
                    <th>Виток</th>
                    <th>Слот / цикл</th>
                    <th>Начало</th>
                    <th>Конец</th>
                    <th>Крен</th>
                    <th>Статус</th>
                  </tr>
                </thead>
                <tbody>
                  {filteredSlots.map((slot) => {
                    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}
                        className={isActive ? "is-active" : undefined}
                        onClick={() => selectRow(slot)}
                      >
                        <td onClick={(event) => event.stopPropagation()}>
                          <input
                            type="checkbox"
                            checked={selectedKeys.has(key)}
                            onChange={() => toggleSlot(key)}
                            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>
                    );
                  })}
                </tbody>
              </table>
            )
          ) : (
            <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">{overlayMessage}</div>}
          {hasRequest && (
            <button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
              Сброс
            </button>
          )}
          {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}
            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 slotStateLabel(state: string): string {
  switch (state) {
    case "AVAILABLE":
      return "свободен";
    case "BOOKED":
      return "бронь";
    case "OCCUPIED":
      return "занят";
    default:
      return state || "—";
  }
}
