import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, fetchFlightLine, type FlightLinePoint, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import {
  buildSurveyRoute,
  calculateDrops,
  calculateSurveys,
  clearPlanModes,
  deleteMode,
  saveDropRoute,
  saveSurveyRoute,
  validateMission,
  type SurveyRoute
} from "../../api/missionPlanningApi";
import { abandonDraft, createDraft, fetchDraftPlans } from "../../api/tguPlanApi";
import { TguApiError } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { parseLocal, toUtcIso } from "../../utils/localTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { GeoPoint, MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi";
import { validationToConflicts } from "./editorValidation";
import {
  addDownlink,
  addShoot,
  deleteWork,
  mutate,
  planModesToWorks,
  seedDraft
} from "./editorDraft";
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
import { DropPanel, type DropCandidateSurvey, MapInfoPanel, MapPickPanel, ShootPanel, WorkInspector } from "./EditorPanels";
import { MapPassSelector } from "./MapPassSelector";
import { EditorRail } from "./EditorRail";
import { EditorTimeline } from "./EditorTimeline";
import { EditorToolbar } from "./EditorToolbar";
import type {
  DraftHistory,
  EditorGroundStation,
  EditorMode,
  EditorTarget,
  EditorTimelineWindow,
  EditorVisibilityTrack,
  EditorVisibilityWindow,
  EditorWork,
  TguEditorTabProps
} from "./model/editorTypes";

type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick";

const HOUR_MS = 60 * 60 * 1000;
/**
 * Отступ окна редактора от границ плана при самом отдалённом масштабе: начало и конец
 * плана отстоят на час от краёв видимого окна. Это же — предел «отдаления» (zoom out).
 */
const EDITOR_WINDOW_PAD_MS = HOUR_MS;
/** Минимальная ширина окна редактора при «приближении» (zoom in): ~10 с, чтобы на оси
 *  появлялись секундные деления. */
const MIN_EDITOR_WINDOW_MS = 10 * 1000;
/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */
const SHOOT_WINDOW_MS = 6 * 60 * 1000;
/**
 * Угол захвата (полуширина полосы) по умолчанию, градусы.
 * Пока задаётся на фронте так же, как в текущем построении маршрутов; в дальнейшем
 * это значение будет определять бэкенд по профилю наблюдения КА.
 */
const DEFAULT_CAPTURE_ANGLE_DEG = 1.5;
/** Дефолтная длительность строящегося маршрута съёмки (мс). */
const DEFAULT_ROUTE_DURATION_MS = 30_000;
/**
 * Предел крена (gamma) маршрута, градусы. Параметры видимости/маршрута бэкенд считает только
 * в диапазоне |крен| ≤ 45°; за его пределами геометрия маршрута не определена, поэтому жёстко
 * зажимаем здесь — и при ручной правке, и при загрузке сохранённого маршрута в окно построения.
 */
const MAX_ROUTE_ROLL_DEG = 45;
/** Крен с зажимом в допустимый диапазон и округлением до 1 знака (как при ручном вводе). */
const clampRoll = (deg: number): number =>
  Math.round(Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) * 10) / 10;
/** Допустимый выход окна маршрута за пределы окна видимости, мс (±30 минут). */
const ROUTE_TIME_MARGIN_MS = 30 * 60_000;
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
  tracks: true,
  swath: true,
  planWorks: true,
  complexPlan: false,
  heatmap: false,
  stations: true,
  spacecraftMarkers: false,
  requests: true
};

export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, platforms }: TguEditorTabProps) {
  const idCounterRef = useRef(0);
  const targetCounterRef = useRef(0);
  const centerRef = useRef<HTMLElement>(null);
  // Доля высоты под карту: по умолчанию карта крупная, а нижняя панель компактно
  // примыкает к низу. Подъём резайзера вверх уменьшает долю и расширяет панель.
  const [mapRatio, setMapRatio] = useState(0.8);
  const [history, setHistory] = useState<DraftHistory>(() => ({
    draft: seedDraft(selectedPlan, selectedSpacecraftId),
    past: [],
    future: []
  }));
  const [window, setWindow] = useState<EditorTimelineWindow>(() => defaultEditorWindow(selectedPlan, appliedRange));
  // Предел отдаления: окно не шире, чем план ± час (тот же диапазон, что и дефолтное окно).
  const maxWindow = useMemo<EditorTimelineWindow>(
    () =>
      selectedPlan
        ? { fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS, toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS }
        : { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs },
    [selectedPlan, appliedRange]
  );
  const [rightMode, setRightMode] = useState<EditorRightMode>("none");
  const [selectedWorkId, setSelectedWorkId] = useState<string>();
  // id редактируемого включения: окно построения открыто для правки существующего маршрута, а не
  // создания нового. При сохранении старый маршрут заменяется (удаляется в БД + локально).
  const [editingWorkId, setEditingWorkId] = useState<string>();
  // Параметры окна/крена, которыми нужно ОДНОКРАТНО предзаполнить окно построения съёмки при правке
  // (иначе эффект дефолтного окна затёр бы их при установке прохода). Сбрасывается после применения.
  const editShootPrefillRef = useRef<{ startMs: number; endMs: number; roll: number } | null>(null);
  // Аналогично для сброса: предвыбранная зона + сбрасываемые съёмки, применяются после входа в режим.
  const editDropPrefillRef = useRef<{ zone: RvaZone; surveyIds: number[] } | null>(null);
  const [target, setTarget] = useState<EditorTarget>();
  // Режим прицеливания: карта ждёт клик по опорной точке (перекрестие). Включается явно — кнопкой
  // «Указать/Сменить точку», после одного клика гаснет. Иначе карта в обычном режиме («рука»),
  // и клики не сбрасывают параметры маршрута.
  const [targetPickActive, setTargetPickActive] = useState(false);
  const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
  const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
  const [rvaZones, setRvaZones] = useState<RvaZone[]>([]);
  const [rvaLoading, setRvaLoading] = useState(false);
  const [rvaError, setRvaError] = useState<string>();
  const [selectedZone, setSelectedZone] = useState<RvaZone>();
  const [selectedDropSurveyIds, setSelectedDropSurveyIds] = useState<Set<number>>(new Set());
  const [dropSaving, setDropSaving] = useState(false);
  const [dropError, setDropError] = useState<string>();
  const [mapInfoSelection, setMapInfoSelection] = useState<Tgu2DMapSelection>();
  // Несколько объектов под точкой клика на карте: список для правой панели + раскрытый элемент.
  const [mapPickHits, setMapPickHits] = useState<Tgu2DMapSelection[]>([]);
  const [mapPickActiveId, setMapPickActiveId] = useState<string>();
  const [stationDtos, setStationDtos] = useState<StationDto[]>([]);
  const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
  const [checking, setChecking] = useState(false);
  // Идёт серверная операция над всем планом (расчёт съёмки+сброса или полная очистка) —
  // блокирует повторный запуск и кнопки правок, пока не перечитаем включения.
  const [planCalcRunning, setPlanCalcRunning] = useState(false);
  // Открыт диалог выбора режима очистки плана (всё / оставить ручные съёмки).
  const [clearDialogOpen, setClearDialogOpen] = useState(false);
  const [notice, setNotice] = useState<string>();
  const [applyError, setApplyError] = useState<string>();
  const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
  const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
  const [planModes, setPlanModes] = useState<PlanMode[]>([]);
  // Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace).
  // Пока null — редактор показывает живой план read-only; после создания все правки и валидация
  // адресуются в этот DRAFT. Отправка (promote) и удаление черновика делаются на вкладке «Планы».
  const [draftPlanId, setDraftPlanId] = useState<string>();
  const [passFrom, setPassFrom] = useState(1);
  const [passTo, setPassTo] = useState(999);
  const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
  // Статус загрузки трассы (витков): отличаем «ещё грузится» от «бэк вернул пусто на это окно»,
  // чтобы при отсутствии данных не было молчаливой пустоты вместо панели витков.
  const [flightLineLoading, setFlightLineLoading] = useState(false);
  // Команда карте центрироваться/подсветить объект выбранного включения (клик по включению).
  const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 });
  const draft = history.draft;
  const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
  const mapRange = useMemo<PassRange>(() => {
    const center = (window.fromMs + window.toMs) / 2;
    return { fromMs: center - MAP_TRACK_WINDOW_MS / 2, toMs: center + MAP_TRACK_WINDOW_MS / 2 };
  }, [window.fromMs, window.toMs]);
  const noradId = useMemo(() => {
    if (!selectedSpacecraftId) return undefined;
    const platform = platforms.find((p) => p.spacecraftId === selectedSpacecraftId);
    return platform?.noradId ?? undefined;
  }, [selectedSpacecraftId, platforms]);
  const orbitPasses = useMemo<OrbitPassInfo[]>(() => {
    if (flightLineData.length === 0) return [];
    const groups = new Map<number, FlightLinePoint[]>();
    for (const pt of flightLineData) {
      const arr = groups.get(pt.revolution) ?? [];
      arr.push(pt);
      groups.set(pt.revolution, arr);
    }
    return Array.from(groups.entries())
      .sort(([a], [b]) => a - b)
      .map(([revolution, pts], idx) => ({
        index: idx + 1,
        revolution,
        startMs: parseLocal(pts[0].time),
        endMs: parseLocal(pts[pts.length - 1].time),
      }));
  }, [flightLineData]);
  const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
  // Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне.
  const planWorks = useMemo<EditorWork[]>(() => planModesToWorks(planModes), [planModes]);
  // id режимов, уже представленных локальной (редактируемой) работой черновика — чтобы не дублировать
  // только что сохранённое включение его plan-origin копией из перечитанных planModes.
  const addedModeIds = useMemo(
    () => new Set(draft.works.map((work) => work.planModeId).filter((id): id is number => id != null)),
    [draft.works]
  );
  const displayWorks = useMemo<EditorWork[]>(
    () => [
      ...planWorks.filter((work) => work.planModeId == null || !addedModeIds.has(work.planModeId)),
      ...draft.works
    ],
    [planWorks, draft.works, addedModeIds]
  );
  const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
  const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
  const selectedWork = displayWorks.find((work) => work.id === selectedWorkId);
  // Редактируемое включение и id его режима в БД: на время правки прячем его и из сцены карты, и с
  // таймлайна — иначе исходный контур виден одновременно с живым превью (карта 2, таймлайн 1).
  const editingWork = useMemo(
    () => (editingWorkId ? displayWorks.find((work) => work.id === editingWorkId) : undefined),
    [editingWorkId, displayWorks]
  );
  const editingModeId = editingWork?.planModeId;
  // Включения для таймлайна: при правке исключаем редактируемое (его место занимает превью на карте).
  const timelineWorks = useMemo(
    () => (editingWorkId ? displayWorks.filter((work) => work.id !== editingWorkId) : displayWorks),
    [displayWorks, editingWorkId]
  );
  // Режимы для сцены карты: при правке исключаем контур редактируемого режима (показываем превью).
  const sceneModes = useMemo(
    () => (editingModeId != null ? planModes.filter((mode) => mode.id !== editingModeId) : planModes),
    [planModes, editingModeId]
  );
  const activeLabel = platformLabel(platforms, selectedSpacecraftId);
  const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
  const [shootPassesLoading, setShootPassesLoading] = useState(false);
  const [shootPassesError, setShootPassesError] = useState<string>();
  // Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, локальное).
  const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>();
  // Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную.
  const [routeRoll, setRouteRoll] = useState<number>();
  const [surveyRoute, setSurveyRoute] = useState<SurveyRoute>();
  const [routeLoading, setRouteLoading] = useState(false);
  const [routeError, setRouteError] = useState<string>();
  const visibilityTracks = useMemo(
    () => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window),
    [selectedSpacecraftId, selectedWork, shootPasses, stations, target, window]
  );
  const effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1));
  const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1));
  const mapScene = useMemo(
    () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute),
    [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute]
  );
  // Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем.
  const focusWorkOnMap = useCallback(
    (workId: string) => {
      const work = displayWorks.find((w) => w.id === workId);
      const focus = workMapFocus(work, mapScene);
      setMapFocus((prev) => ({ point: focus.point, objectId: focus.objectId, token: prev.token + 1 }));
    },
    [displayWorks, mapScene]
  );
  const contextLanes = useMemo(
    () =>
      Array.from(contextSpacecraftIds).map((spacecraftId) => ({
        spacecraftId,
        label: platformLabel(platforms, spacecraftId),
        works: []
      })),
    [contextSpacecraftIds, platforms]
  );
  // Кандидаты на сброс: маршруты съёмки плана, заканчивающиеся строго до начала выбранной зоны.
  const dropCandidates = useMemo<DropCandidateSurvey[]>(() => {
    if (!selectedZone) return [];
    const zoneStart = localDateTimeMs(selectedZone.onStart.time);
    return planModes
      .filter((m): m is PlanSurveyMode => m.type === "SURVEY" && m.timeStart != null)
      .map((m) => {
        const startMs = localDateTimeMs(m.timeStart as string);
        return {
          id: m.id,
          label: `Съёмка витка ${m.revolution}`,
          startMs,
          endMs: startMs + m.duration * 1000
        };
      })
      .filter((c) => c.endMs < zoneStart)
      .sort((a, b) => a.startMs - b.startMs);
  }, [planModes, selectedZone]);

  // Реальный состав плана берём из включений (plan modes), а не из черновика:
  // черновик сидируется заглушкой, когда у плана нет встроенных works.
  const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]);
  const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]);

  // Первая правка живого плана клонирует его в DRAFT (replace). Бэк это запрещает (422) для
  // заложенного (LAID_IN) или уже стартовавшего плана — нужен fork-forward со вкладки «Планы».
  // Предупреждаем заранее и гасим «Добавить», чтобы не доводить до ошибки сохранения. Если
  // черновик уже создан (draftPlanId) или сам план — DRAFT, правки идут напрямую и блок не нужен.
  const inPlaceEditBlockedReason = useMemo<string | undefined>(() => {
    if (!selectedPlan || draftPlanId || selectedPlan.status === "DRAFT") return undefined;
    const blocked = selectedPlan.status === "LAID_IN" || selectedPlan.startMs <= Date.now();
    return blocked
      ? "План заложен или уже стартовал — править его на месте нельзя. Создайте исправленный план через fork-forward на вкладке «Планы»."
      : undefined;
  }, [selectedPlan, draftPlanId]);

  useEffect(() => {
    setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
    setWindow(defaultEditorWindow(selectedPlan, appliedRange));
    setSelectedWorkId(undefined);
    setEditingWorkId(undefined);
    setRightMode("none");
    setTarget(undefined);
    setTargetPickActive(false);
    setSelectedShootPass(undefined);
    setSurveyRoute(undefined);
    setRouteError(undefined);
    setSelectedZone(undefined);
    setSelectedDropSurveyIds(new Set());
    setDropError(undefined);
    setRvaZones([]);
    setMapInfoSelection(undefined);
    setMapPickHits([]);
    setMapPickActiveId(undefined);
    setServiceConflicts({});
    setNotice(undefined);
    setApplyError(undefined);
    setPlanModes([]);
    setPassFrom(1);
    setPassTo(999);
  }, [appliedRange, selectedPlan, selectedSpacecraftId]);

  // Линия трассы (витки) живёт исключительно по своим входам запроса — noradId и mapRange.
  // НЕ сбрасываем её в общем reset-эффекте по ссылке selectedPlan: фоновый loadData (после
  // решения/создания черновика/обновления интервала) пересобирает объект selectedPlan с теми же
  // временами окна, mapRange по значению не меняется, и фетч бы не перезапустился — витки
  // молча пропадали бы до реальной смены окна. Привязка к [noradId, mapRange] делает это
  // идемпотентным: сброс и перезагрузка происходят вместе и только когда меняется само окно/КА.
  useEffect(() => {
    if (!noradId) {
      setFlightLineData([]);
      setFlightLineLoading(false);
      return;
    }
    let cancelled = false;
    setFlightLineLoading(true);
    void fetchFlightLine(noradId, mapRange.fromMs, mapRange.toMs)
      .then((data) => { if (!cancelled) setFlightLineData(data); })
      .catch(() => { if (!cancelled) setFlightLineData([]); })
      .finally(() => { if (!cancelled) setFlightLineLoading(false); });
    return () => { cancelled = true; };
  }, [noradId, mapRange]);

  useEffect(() => {
    const controller = new AbortController();
    void fetchRequestsForMap({
      endFrom: toUtcIso(window.fromMs),
      beginTo: toUtcIso(window.toMs)
    }).then((result) => {
      if (!controller.signal.aborted) {
        setRequestItems(result.items);
      }
    }).catch(() => {
      // silently ignore — requests are supplementary
    });
    return () => controller.abort();
  }, [window.fromMs, window.toMs]);

  useEffect(() => {
    let cancelled = false;
    void fetchStations()
      .then((items) => { if (!cancelled) setStationDtos(items); })
      .catch(() => { if (!cancelled) setStationDtos([]); });
    return () => { cancelled = true; };
  }, []);

  useEffect(() => {
    const numericNorad = noradId ? Number(noradId) : NaN;
    if (rightMode !== "shoot" || !target || !Number.isFinite(numericNorad)) {
      setShootPasses([]);
      setShootPassesError(noradId ? undefined : target ? "Не задан NORAD-идентификатор КА." : undefined);
      setShootPassesLoading(false);
      return;
    }

    let cancelled = false;
    setShootPassesLoading(true);
    setShootPassesError(undefined);
    void fetchPointVisibility(
      numericNorad,
      { id: target.id, lat: target.lat, lon: target.lon },
      window.fromMs,
      window.toMs
    )
      .then((params) => {
        if (cancelled) return;
        setShootPasses(
          params.map(visibilityWindowFromParam).sort((a, b) => a.startMs - b.startMs)
        );
      })
      .catch((error) => {
        if (cancelled) return;
        setShootPasses([]);
        setShootPassesError(error instanceof Error ? error.message : "Не удалось получить параметры видимости.");
      })
      .finally(() => {
        if (!cancelled) setShootPassesLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [rightMode, target, noradId, window.fromMs, window.toMs]);

  // При выборе нового прохода задаём дефолтное окно маршрута: 30 c вокруг момента
  // видимости, обрезанное окном видимости прохода. Дальше пользователь правит вручную.
  useEffect(() => {
    if (rightMode !== "shoot" || !selectedShootPass) {
      setRouteWindow(undefined);
      setRouteRoll(undefined);
      return;
    }
    // Правка существующего маршрута: подставляем его окно/крен один раз, не центрируем по проходу.
    const prefill = editShootPrefillRef.current;
    if (prefill) {
      editShootPrefillRef.current = null;
      setRouteWindow({ startMs: prefill.startMs, endMs: prefill.endMs });
      // Сохранённый маршрут мог быть с креном вне ±45° (легаси) — зажимаем при загрузке в окно.
      setRouteRoll(clampRoll(prefill.roll));
      return;
    }
    const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs;
    const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2;
    const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
    setRouteWindow({ startMs: mid - half, endMs: mid + half });
    setRouteRoll(clampRoll(selectedShootPass.rollDeg ?? 0));
  }, [rightMode, selectedShootPass]);

  useEffect(() => {
    const numericNorad = noradId ? Number(noradId) : NaN;
    if (rightMode !== "shoot" || !target || !selectedShootPass || !routeWindow || routeRoll == null || !Number.isFinite(numericNorad)) {
      setSurveyRoute(undefined);
      setRouteError(undefined);
      setRouteLoading(false);
      return;
    }

    const durationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);

    let cancelled = false;
    setRouteLoading(true);
    setRouteError(undefined);
    void buildSurveyRoute({
      satelliteId: numericNorad,
      // Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность.
      time: toUtcIso(routeWindow.startMs),
      durationSeconds,
      roll: routeRoll,
      captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
      revolution: selectedShootPass.revolution ?? 0,
      anchor: "FORWARD"
    })
      .then((route) => {
        if (!cancelled) setSurveyRoute(route);
      })
      .catch((error) => {
        if (cancelled) return;
        setSurveyRoute(undefined);
        setRouteError(error instanceof Error ? error.message : "Не удалось построить маршрут съёмки.");
      })
      .finally(() => {
        if (!cancelled) setRouteLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [rightMode, target, selectedShootPass, noradId, routeWindow, routeRoll]);

  // Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс».
  useEffect(() => {
    const numericNorad = noradId ? Number(noradId) : NaN;
    if (rightMode !== "downlink" || !Number.isFinite(numericNorad)) {
      setRvaZones([]);
      setRvaError(rightMode === "downlink" && !noradId ? "Не задан NORAD-идентификатор КА." : undefined);
      setRvaLoading(false);
      setSelectedZone(undefined);
      setSelectedDropSurveyIds(new Set());
      return;
    }

    let cancelled = false;
    setRvaLoading(true);
    setRvaError(undefined);
    // Правка существующего сброса: подставляем его зону и сбрасываемые съёмки один раз.
    const prefill = editDropPrefillRef.current;
    editDropPrefillRef.current = null;
    if (prefill) {
      setSelectedZone(prefill.zone);
      setSelectedDropSurveyIds(new Set(prefill.surveyIds));
    } else {
      setSelectedZone(undefined);
      setSelectedDropSurveyIds(new Set());
    }
    void fetchSatelliteRva(numericNorad, window.fromMs, window.toMs)
      .then((zones) => {
        if (!cancelled) setRvaZones(zones);
      })
      .catch((error) => {
        if (cancelled) return;
        setRvaZones([]);
        setRvaError(error instanceof Error ? error.message : "Не удалось получить зоны радиовидимости.");
      })
      .finally(() => {
        if (!cancelled) setRvaLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [rightMode, noradId, window.fromMs, window.toMs]);

  // Если выбран уже существующий черновик (status DRAFT — из режима «Черновики»/диалога цепочки),
  // редактор работает с ним напрямую; иначе черновик создаётся лениво на первую правку (replace).
  // createdImplicitlyRef различает эти случаи: авто-удалять при уходе можно только неявно созданный.
  const createdImplicitlyRef = useRef(false);
  useEffect(() => {
    setDraftPlanId(selectedPlan?.status === "DRAFT" ? selectedPlan.planId : undefined);
    createdImplicitlyRef.current = false;
    setServiceConflicts({});
  }, [selectedPlan]);

  // Зеркало draftPlanId в ref — чтобы при уходе/смене плана отменить незавершённый черновик,
  // не оставляя осиротевший DRAFT (он бы заблокировал createDraft по той же базе на 409).
  const draftPlanIdRef = useRef<string | undefined>(undefined);
  useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]);
  useEffect(() => () => {
    const stale = draftPlanIdRef.current;
    // Авто-отмена только для неявно созданного черновика; явно открытый DRAFT не трогаем.
    if (stale && createdImplicitlyRef.current) {
      // best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён).
      void abandonDraft(stale).catch(() => {});
    }
  }, [selectedPlan]);

  // Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью).
  useEffect(() => {
    // Нет выбранного плана (например, открытый черновик удалили) — чистим включения и НЕ тянем
    // режимы по устаревшему draftPlanId. Он обнулится следующим коммитом (эффект выше), но в этом
    // ещё указывает на удалённый черновик; без гарда fetchPlanModes(удалённый id) вернул бы его
    // включения обратно — редактор «не чистился» бы после удаления.
    if (!selectedPlan) {
      setPlanModes([]);
      return;
    }
    const missionId = draftPlanId ?? selectedPlan.planId;
    let cancelled = false;
    void fetchPlanModes(missionId).then((modes) => {
      if (!cancelled) setPlanModes(modes);
    }).catch(() => {
      // silently ignore — plan modes are supplementary display data
    });
    return () => { cancelled = true; };
  }, [selectedPlan, draftPlanId]);

  const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => {
    setHistory((current) => mutate(current, transform));
    setServiceConflicts({});
    setNotice(undefined);
    setApplyError(undefined);
  };

  // «Редактировать» включение: открываем то же окно, что и при построении, предзаполнив его
  // параметрами включения. Сохранение заменит исходный маршрут (см. addShootWork/addDropWork).
  const beginEditWork = (workId: string) => {
    const work = displayWorks.find((item) => item.id === workId);
    if (!work) return;
    setEditingWorkId(workId);
    setSelectedWorkId(undefined);
    setMapInfoSelection(undefined);
    clearMapPick();
    setNotice(undefined);
    setApplyError(undefined);

    if (work.kind === "shoot") {
      const lat = work.targetLat ?? work.centerLat;
      const lon = work.targetLon ?? work.centerLon;
      setTarget(
        lat != null && lon != null
          ? { id: `edit-${workId}`, name: work.targetName ?? "Цель съёмки", lat, lon }
          : undefined
      );
      // Окно/крен подставит эффект дефолтного окна по этому ref (иначе он бы их центрировал).
      editShootPrefillRef.current = { startMs: work.startMs, endMs: work.endMs, roll: work.rollDeg ?? 0 };
      // Синтетический «проход» задаёт границы окна маршрута (±30 мин) и виток для пересчёта маршрута.
      setSelectedShootPass({
        startMs: work.startMs,
        endMs: work.endMs,
        quality: 1,
        revolution: work.revolution,
        rollDeg: work.rollDeg
      });
      setModeId(work.modeId ?? DEFAULT_EDITOR_MODES[0].id);
      // Правка начинается без прицеливания: точка уже есть, карта в режиме «рука».
      setTargetPickActive(false);
      setRightMode("shoot");
      return;
    }

    // Сброс: синтезируем зону из включения (для отображения и сохранения) и предвыбираем съёмки.
    const stationNum = Number(work.stationId);
    const midMs = (work.startMs + work.endMs) / 2;
    const zone: RvaZone = {
      noradId: noradId ? Number(noradId) : 0,
      stationId: Number.isFinite(stationNum) ? stationNum : 0,
      revolution: work.revolution ?? 0,
      onStart: { time: toUtcIso(work.startMs), elevation: 0, azimuth: 0, range: 0 },
      onMaximum: { time: toUtcIso(midMs), elevation: 0, azimuth: 0, range: 0 },
      onStop: { time: toUtcIso(work.endMs), elevation: 0, azimuth: 0, range: 0 },
      duration: Math.max(1, (work.endMs - work.startMs) / 1000)
    };
    editDropPrefillRef.current = { zone, surveyIds: work.surveyIds ?? [] };
    setRightMode("downlink");
  };

  // Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
  // слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание
  // плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы».
  // Ищет уже открытый DRAFT этого КА с тем же окном, что у базового плана (replace 1:1 совпадает
  // по окну). Используется при 409 от createDraft, чтобы подхватить осиротевший черновик.
  const adoptExistingDraft = useCallback(async (basePlan: TguPlanUi): Promise<string | undefined> => {
    if (!basePlan.spacecraftId) return undefined;
    const drafts = await fetchDraftPlans(basePlan.spacecraftId).catch(() => [] as Awaited<ReturnType<typeof fetchDraftPlans>>);
    const match = drafts.find(
      (draft) =>
        Math.abs(parseLocal(draft.startTime) - basePlan.startMs) < 2000 &&
        Math.abs(parseLocal(draft.endTime) - basePlan.endMs) < 2000
    );
    return match?.planId;
  }, []);

  const ensureDraft = useCallback(async (): Promise<string> => {
    if (draftPlanId) return draftPlanId;
    if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
    // Выбран уже существующий черновик — правим его напрямую, новый клон не создаём.
    if (selectedPlan.status === "DRAFT") {
      setDraftPlanId(selectedPlan.planId);
      return selectedPlan.planId;
    }
    try {
      const created = await createDraft(selectedPlan.planId, { mode: "replace" });
      createdImplicitlyRef.current = true;
      setDraftPlanId(created.draftPlanId);
      return created.draftPlanId;
    } catch (error) {
      // 409: по этому КА уже открыт черновик на это же окно (осиротевший после прошлой сессии —
      // авто-отмена при уходе best-effort и могла не отработать). Вместо тупиковой ошибки
      // «Conflict» подхватываем существующий черновик того же окна и правим его (replace 1:1 —
      // окно совпадает с базой). Не нашли подходящий — пробрасываем читаемую ошибку.
      if (error instanceof TguApiError && error.status === 409) {
        const adopted = await adoptExistingDraft(selectedPlan);
        if (adopted) {
          createdImplicitlyRef.current = true;
          setDraftPlanId(adopted);
          return adopted;
        }
        throw new TguApiError(
          "По этому КА уже есть открытый черновик на это окно. Отмените его во вкладке «Планы» (раздел «Черновики») или продолжите редактирование там.",
          "http",
          409,
        );
      }
      throw error;
    }
  }, [draftPlanId, selectedPlan, adoptExistingDraft]);

  // Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
  // MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
  const revalidate = useCallback(async (missionId: string, works: EditorWork[]) => {
    try {
      const validation = await validateMission(missionId);
      setServiceConflicts(validationToConflicts(validation, works));
    } catch {
      // валидация вспомогательна — молча игнорируем сбой
    }
  }, []);

  // После каждой серверной правки перечитываем включения DRAFT (источник истины) и ревалидируем.
  const refreshDraftModes = useCallback(async (missionId: string) => {
    const modes = await fetchPlanModes(missionId);
    setPlanModes(modes);
    void revalidate(missionId, planModesToWorks(modes));
  }, [revalidate]);

  // Удаляет из черновика маршрут редактируемого включения (замена при правке). Свежепостроенное или
  // уже-в-черновике включение адресуется по planModeId; включение из живого плана после клонирования
  // получает новый id в черновике — находим его по типу/витку/времени старта.
  const deleteEditedModeFromDraft = async (
    draftId: string,
    draftExistedBefore: boolean,
    work: EditorWork,
    type: "SURVEY" | "DROP"
  ) => {
    if (work.planModeId != null && draftExistedBefore) {
      await deleteMode(work.planModeId).catch(() => {});
      return;
    }
    const draftModes = await fetchPlanModes(draftId);
    const match = draftModes.find(
      (m) =>
        m.type === type &&
        m.revolution === work.revolution &&
        m.timeStart != null &&
        Math.abs(parseLocal(m.timeStart) - work.startMs) < 2000
    );
    if (match) await deleteMode(match.id).catch(() => {});
  };

  const addShootWork = async () => {
    if (!target || !selectedShootPass || !modeId) return;

    // Правка существующего маршрута: его исходную версию удаляем при сохранении (замена).
    const editId = editingWorkId;
    const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined;
    const draftExistedBefore = draftPlanId != null;

    // Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id
    // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
    // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
    const numericNorad = noradId ? Number(noradId) : NaN;
    // Маршрут, сохранённый в БД черновика (несёт surveyModeId для последующего delete/patch).
    let savedRoute: SurveyRoute | undefined = surveyRoute;
    if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) {
      const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
      setRouteLoading(true);
      setRouteError(undefined);
      try {
        // Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план.
        const draftId = await ensureDraft();
        // Заменяем: сносим исходный маршрут редактируемого включения перед сохранением нового.
        if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "SURVEY");
        savedRoute = await saveSurveyRoute(draftId, {
          satelliteId: numericNorad,
          time: toUtcIso(routeWindow.startMs),
          durationSeconds: routeDurationSeconds,
          roll: routeRoll,
          captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
          revolution: selectedShootPass.revolution ?? 0,
          anchor: "FORWARD",
          // Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
          planStart: toUtcIso(selectedPlan.startMs),
          planEnd: toUtcIso(selectedPlan.endMs)
        });
        setSurveyRoute(savedRoute);
        // Ждём перечитывания planModes ДО снятия editingWorkId ниже: иначе на миг editingModeId
        // перестаёт исключать старый режим из сцены, а свежие planModes ещё не пришли — и старый
        // контур мелькает на карте, пока не сделаешь что-нибудь ещё.
        await refreshDraftModes(draftId);
      } catch (error) {
        setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
        return;
      } finally {
        setRouteLoading(false);
      }
    }

    // Окно включения в черновике — заданное вручную окно маршрута (фолбэк — окно прохода).
    const startMs = routeWindow?.startMs ?? selectedShootPass.startMs;
    const endMs = routeWindow?.endMs ?? selectedShootPass.endMs;
    const mode = modes.find((item) => item.id === modeId);
    const id = nextWorkId(idCounterRef);

    changeDraft((current) =>
      addShoot(editId ? deleteWork(current, editId) : current, {
        id,
        startMs,
        endMs,
        label: mode?.label ?? "Съёмка",
        modeId,
        target,
        // id сохранённого режима съёмки — чтобы удаление/правка адресовались в БД черновика.
        planModeId: savedRoute?.surveyModeId,
        revolution: savedRoute?.revolution ?? selectedShootPass.revolution,
        rollDeg: savedRoute?.roll ?? routeRoll,
        captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG,
        centerLat: savedRoute?.lat,
        centerLon: savedRoute?.longitude,
        volumeMb: savedRoute?.volumeMb
      })
    );
    setEditingWorkId(undefined);
    setSelectedWorkId(id);
    setRightMode("inspect");
    setTarget(undefined);
    setTargetPickActive(false);
    setSelectedShootPass(undefined);
    // Гасим превью окна построения сразу: сохранённый маршрут уже виден из planModes/черновика.
    setSurveyRoute(undefined);
    setRouteWindow(undefined);
  };

  const stationNameById = useCallback(
    (stationId: number) =>
      stationDtos.find((s) => s.number === stationId)?.name ?? `Станция ${stationId}`,
    [stationDtos]
  );

  // Метка съёмки по id режима — для перечня сбрасываемых маршрутов в инспекторе сброса.
  const surveyModeLabel = useCallback(
    (surveyModeId: number) => {
      const mode = planModes.find(
        (m): m is PlanSurveyMode => m.type === "SURVEY" && m.id === surveyModeId
      );
      return mode ? `Съёмка · виток ${mode.revolution}` : `Маршрут #${surveyModeId}`;
    },
    [planModes]
  );

  const addDropWork = async () => {
    if (!selectedPlan || !selectedZone || selectedDropSurveyIds.size === 0) return;
    const numericNorad = noradId ? Number(noradId) : NaN;
    if (!Number.isFinite(numericNorad)) {
      setDropError("Не задан NORAD-идентификатор КА.");
      return;
    }

    const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time);
    const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time);

    // Правка существующего сброса: его исходную версию удаляем при сохранении (замена).
    const editId = editingWorkId;
    const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined;
    const draftExistedBefore = draftPlanId != null;

    setDropSaving(true);
    setDropError(undefined);
    let savedDropId: number | undefined;
    let savedDropVolumeMb: number | undefined;
    try {
      // Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план.
      const draftId = await ensureDraft();
      // Заменяем: сносим исходный сброс редактируемого включения перед сохранением нового.
      if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "DROP");
      const savedDrop = await saveDropRoute(draftId, {
        satelliteId: numericNorad,
        station: String(selectedZone.stationId),
        revolution: selectedZone.revolution,
        // Зона — LocalDateTime без зоны (как вернула баллистика).
        zoneStart: selectedZone.onStart.time.slice(0, 19),
        zoneStop: selectedZone.onStop.time.slice(0, 19),
        surveyModeIds: [...selectedDropSurveyIds],
        // Окно выбранного плана — для автосоздания миссии/плана с корректным интервалом.
        planStart: toUtcIso(selectedPlan.startMs),
        planEnd: toUtcIso(selectedPlan.endMs)
      });
      savedDropId = savedDrop.id;
      savedDropVolumeMb = savedDrop.volumeMb;
      // Перечитываем включения DRAFT (источник истины) и ревалидируем. Ждём ДО снятия
      // editingWorkId ниже — иначе старая трасса сброса мелькает, пока planModes не обновились.
      await refreshDraftModes(draftId);
    } catch (error) {
      setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
      return;
    } finally {
      setDropSaving(false);
    }

    const stationNm = stationNameById(selectedZone.stationId);
    const id = nextWorkId(idCounterRef);
    changeDraft((current) =>
      addDownlink(editId ? deleteWork(current, editId) : current, {
        id,
        startMs: zoneStartMsVal,
        endMs: zoneStopMsVal,
        label: `Сброс · ${stationNm}`,
        station: { id: String(selectedZone.stationId), name: stationNm },
        planModeId: savedDropId,
        volumeMb: savedDropVolumeMb
      })
    );
    setEditingWorkId(undefined);
    setSelectedWorkId(id);
    setRightMode("inspect");
    setSelectedZone(undefined);
    setSelectedDropSurveyIds(new Set());
  };

  // «Проверить»: если черновик уже создан — авторитетная бэкенд-валидация (Ф4); иначе локальное
  // превью видимости (черновика на сервере ещё нет — правок, адресованных в DRAFT, не было).
  const runCheck = async () => {
    setChecking(true);
    setApplyError(undefined);
    try {
      if (draftPlanId) {
        const validation = await validateMission(draftPlanId);
        setServiceConflicts(validationToConflicts(validation, planWorks));
        setNotice("Проверка выполнена на сервере (валидация черновика).");
      } else {
        setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations }));
        setNotice("Проверка видимости выполнена для текущего черновика.");
      }
    } catch (error) {
      setApplyError(error instanceof Error ? error.message : "Не удалось выполнить проверку.");
    } finally {
      setChecking(false);
    }
  };

  // Запуск расчёта плана съёмки и сброса как одной операции (вкладка «Редактор»). Адресуем
  // в DRAFT (ленивое создание на первую правку), затем перечитываем включения. Пересчёт на бэке
  // сохраняет ручные маршруты съёмки (source=MANUAL) — они не затираются. Источник комплексного
  // плана (snapshot этапа 4) бэкенд выбирает сам по интервалу плана.
  const runPlanCalculation = async () => {
    if (!selectedPlan || planCalcRunning) return;
    setPlanCalcRunning(true);
    setApplyError(undefined);
    setNotice(undefined);
    try {
      const draftId = await ensureDraft();
      const surveyResult = await calculateSurveys(draftId);
      await calculateDrops(draftId);
      await refreshDraftModes(draftId);
      setNotice(
        `Расчёт выполнен: съёмок ${surveyResult.surveyModesCount} (ручных сохранено ${surveyResult.manualModesCount}); сбросы пересчитаны.`
      );
    } catch (error) {
      setApplyError(error instanceof Error ? error.message : "Не удалось рассчитать план.");
    } finally {
      setPlanCalcRunning(false);
    }
  };

  // Очистка плана. Оператор выбирает в диалоге, что снести: всё (включая ручные маршруты) или
  // только рассчитанное (ручные съёмки source=MANUAL остаются, как при пересчёте плана). Сбросы
  // удаляются в любом случае. Меняем БД черновика и локальное представление. Необратимо.
  const clearPlan = async (keepManual: boolean) => {
    if (!selectedPlan || planCalcRunning) return;
    setClearDialogOpen(false);
    setPlanCalcRunning(true);
    setApplyError(undefined);
    setNotice(undefined);
    try {
      const draftId = await ensureDraft();
      await clearPlanModes(draftId, keepManual);
      await refreshDraftModes(draftId);
      changeDraft((current) => ({ ...current, works: [] }));
      setSelectedWorkId(undefined);
      setEditingWorkId(undefined);
      setRightMode("none");
      setNotice(
        keepManual
          ? "Рассчитанные включения удалены. Ручные маршруты съёмки сохранены."
          : "План полностью очищен."
      );
    } catch (error) {
      setApplyError(error instanceof Error ? error.message : "Не удалось очистить план.");
    } finally {
      setPlanCalcRunning(false);
    }
  };

  // Удаление включения (правая панель, с подтверждением): персистнутые режимы (с planModeId)
  // удаляются в БД черновика (Ф3, с каскадами drop↔survey), затем убираются из локального
  // представления. Plan-origin включения адресуются по planModeId только когда черновик уже
  // существует (draftPlanId): тогда включения принадлежат именно ему. Несохранённые (planModeId
  // нет) — только локально.
  const handleDeleteWork = async (workId: string) => {
    const work = displayWorks.find((item) => item.id === workId);
    if (!work) return;
    const confirmMessage =
      work.kind === "downlink"
        ? "Удалить включение сброса? Действие необратимо."
        : "Удалить маршрут съёмки? Действие необратимо.";
    if (!globalThis.confirm(confirmMessage)) return;
    if (work.planModeId != null && draftPlanId) {
      try {
        await deleteMode(work.planModeId);
        await refreshDraftModes(draftPlanId);
      } catch (error) {
        setApplyError(error instanceof Error ? error.message : "Не удалось удалить включение.");
        return;
      }
    }
    changeDraft((current) => deleteWork(current, workId));
    setSelectedWorkId(undefined);
    if (editingWorkId === workId) setEditingWorkId(undefined);
    setRightMode("none");
  };

  // Масштабирование окна относительно точки-якоря (центр окна для кнопок, точка под
  // курсором для колеса мыши). Ширину зажимаем в [MIN_EDITOR_WINDOW_MS; ширина maxWindow],
  // затем двигаем окно так, чтобы оно не вышло за план ± час.
  const zoomAt = useCallback(
    (factor: number, anchorMs: number) => {
      setWindow((current) => {
        const span = current.toMs - current.fromMs;
        const maxSpan = maxWindow.toMs - maxWindow.fromMs;
        const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan);
        const ratio = span > 0 ? (anchorMs - current.fromMs) / span : 0.5;
        let fromMs = anchorMs - ratio * nextSpan;
        let toMs = fromMs + nextSpan;
        if (fromMs < maxWindow.fromMs) {
          fromMs = maxWindow.fromMs;
          toMs = fromMs + nextSpan;
        }
        if (toMs > maxWindow.toMs) {
          toMs = maxWindow.toMs;
          fromMs = toMs - nextSpan;
        }
        return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs };
      });
    },
    [maxWindow]
  );

  const zoom = useCallback(
    (factor: number) => {
      setWindow((current) => {
        const center = (current.fromMs + current.toMs) / 2;
        const span = current.toMs - current.fromMs;
        const maxSpan = maxWindow.toMs - maxWindow.fromMs;
        const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan);
        let fromMs = center - nextSpan / 2;
        let toMs = center + nextSpan / 2;
        if (fromMs < maxWindow.fromMs) {
          fromMs = maxWindow.fromMs;
          toMs = fromMs + nextSpan;
        }
        if (toMs > maxWindow.toMs) {
          toMs = maxWindow.toMs;
          fromMs = toMs - nextSpan;
        }
        return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs };
      });
    },
    [maxWindow]
  );

  // Горизонтальный сдвиг окна перетаскиванием: ширину окна не меняем, левую границу зажимаем
  // в [maxWindow.fromMs; maxWindow.toMs − ширина], чтобы не выехать за план ± час.
  const panTo = useCallback(
    (fromMs: number) => {
      setWindow((current) => {
        const span = current.toMs - current.fromMs;
        let from = Math.min(Math.max(fromMs, maxWindow.fromMs), maxWindow.toMs - span);
        if (from < maxWindow.fromMs) from = maxWindow.fromMs;
        return { fromMs: from, toMs: from + span };
      });
    },
    [maxWindow]
  );

  // Включение (съёмка/сброс), соответствующее объекту карты: контур маршрута (planWorks) или
  // линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined.
  const workForSelection = useCallback(
    (selection: Tgu2DMapSelection): EditorWork | undefined => {
      const modeId =
        selection.type === "polygon" && selection.layer === "planWorks"
          ? selection.modeId
          : selection.type === "line" && selection.kind === "drop"
            ? selection.modeId
            : undefined;
      if (modeId == null) return undefined;
      return displayWorks.find((w) => w.planModeId === modeId);
    },
    [displayWorks]
  );

  const clearMapPick = useCallback(() => {
    setMapPickHits([]);
    setMapPickActiveId(undefined);
  }, []);

  const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
    if (rightMode === "shoot") return;
    clearMapPick();
    if (!selection) {
      if (rightMode === "mapInfo" || rightMode === "mapPick") {
        setRightMode("none");
        setMapInfoSelection(undefined);
      }
      return;
    }
    // Клик по маршруту съёмки (контур) или сбросу (линия) на карте = клик по включению на
    // таймлайне: открываем тот же инспектор включения (одинаковая информация в обе стороны),
    // а не краткую карточку объекта.
    const work = workForSelection(selection);
    if (work) {
      setMapInfoSelection(undefined);
      setSelectedWorkId(work.id);
      setRightMode("inspect");
      setNotice(undefined);
      return;
    }
    setMapInfoSelection(selection);
    setRightMode("mapInfo");
    setSelectedWorkId(undefined);
  }, [rightMode, clearMapPick, workForSelection]);

  // Несколько объектов под точкой клика — показываем их списком в правой панели (оператор
  // раскрывает нужный, чтобы увидеть его данные). Здесь только наполняем список.
  const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => {
    if (rightMode === "shoot") return;
    setMapPickHits(selections);
    setMapPickActiveId(undefined);
    setRightMode("mapPick");
    setSelectedWorkId(undefined);
    setMapInfoSelection(undefined);
    setNotice(undefined);
    setMapFocus((prev) => ({ ...prev, objectId: undefined }));
  }, [rightMode]);

  // Раскрытие/сворачивание элемента списка: подсвечиваем объект на карте (без перецентрирования)
  // и выделяем соответствующее включение на таймлайне.
  const togglePickItem = useCallback((selection: Tgu2DMapSelection) => {
    setMapPickActiveId((current) => {
      if (current === selection.id) {
        setSelectedWorkId(undefined);
        setMapFocus((prev) => ({ ...prev, objectId: undefined }));
        return undefined;
      }
      setSelectedWorkId(workForSelection(selection)?.id);
      setMapFocus((prev) => ({ ...prev, objectId: selection.id }));
      return selection.id;
    });
  }, [workForSelection]);

  const handleResizerDown = useCallback((e: React.MouseEvent) => {
    e.preventDefault();
    const startY = e.clientY;
    const startRatio = mapRatio;

    const onMove = (ev: MouseEvent) => {
      const el = centerRef.current;
      if (!el) return;
      const dy = ev.clientY - startY;
      setMapRatio(Math.max(0.15, Math.min(0.86, startRatio + dy / el.clientHeight)));
    };

    const onUp = () => {
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
    };

    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
  }, [mapRatio]);

  // Инспектор включения — общий и для одиночного выбора, и для раскрытого элемента в списке выбора.
  const renderWorkInspector = (work: EditorWork, onClose: () => void) => (
    <WorkInspector
      work={work}
      modes={modes}
      stations={stations}
      spacecraftLabel={activeLabel}
      surveyModeLabel={surveyModeLabel}
      conflicts={conflicts[work.id] ?? []}
      readOnly={work.origin === "plan"}
      canDelete={draftPlanId != null}
      canEdit={!inPlaceEditBlockedReason}
      onEdit={beginEditWork}
      onDelete={(workId) => { void handleDeleteWork(workId); }}
      onClose={onClose}
    />
  );

  // Детальная карточка объекта из списка выбора: включение (съёмка/сброс) → инспектор,
  // прочее (заявка/станция) → карточка объекта карты.
  const renderPickDetail = (selection: Tgu2DMapSelection) => {
    const work = workForSelection(selection);
    if (work) return renderWorkInspector(work, () => togglePickItem(selection));
    return (
      <MapInfoPanel
        selection={selection}
        requestItems={requestItems}
        onClose={() => togglePickItem(selection)}
      />
    );
  };

  // Редактор включений работает только при выбранном плане (план == миссия, в которую кладутся
  // включения). Без плана — плейсхолдер: иначе после удаления открытого черновика вкладка
  // оставалась бы «активной» по выбранному КА (витки, режим съёмки), хотя редактировать нечего.
  if (!selectedPlan) {
    return (
      <main className="tgu-editor-workspace">
        <div className="tgu-empty">Выберите КА и план, чтобы открыть редактор включений.</div>
      </main>
    );
  }

  return (
    <main className="tgu-editor-workspace">
      <EditorToolbar
        selectedSpacecraftId={selectedSpacecraftId}
        selectedPlan={selectedPlan}
        platforms={platforms}
        checking={checking}
        planCalcRunning={planCalcRunning}
        calcBlockedReason={inPlaceEditBlockedReason}
        conflictCount={conflictCount(conflicts)}
        windowLabel={formatDuration(window.toMs - window.fromMs)}
        rightMode={rightMode}
        onSetRightMode={(mode) => {
          setRightMode(mode);
          setSelectedWorkId(undefined);
          setEditingWorkId(undefined);
          // Новая съёмка начинается с прицеливания (точки ещё нет); прочие режимы — без перекрестия.
          setTargetPickActive(mode === "shoot");
          setTarget(undefined);
          setMapInfoSelection(undefined);
          clearMapPick();
          setNotice(undefined);
        }}
        onCheck={runCheck}
        onCalculatePlan={() => { void runPlanCalculation(); }}
        onClearPlan={() => setClearDialogOpen(true)}
        onZoom={zoom}
      />

      <div className="tgu-editor-notices">
        {notice && <div className="tgu-alert tgu-alert--success tgu-editor-notice">{notice}</div>}
        {applyError && <div className="tgu-alert tgu-alert--danger tgu-editor-notice">{applyError}</div>}
      </div>

      <div className="tgu-editor-layout" style={{ gridTemplateColumns: rightMode === "none" ? "17rem minmax(0, 1fr) 0" : "17rem minmax(0, 1fr) 21rem" }}>
        <EditorRail
          selectedSpacecraftId={selectedSpacecraftId}
          selectedPlan={selectedPlan}
          platforms={platforms}
          surveyCount={planSurveyCount}
          downlinkCount={planDownlinkCount}
          conflictCount={conflictCount(conflicts)}
          contextSpacecraftIds={contextSpacecraftIds}
          onToggleContext={(spacecraftId) => {
            setContextSpacecraftIds((current) => {
              const next = new Set(current);
              if (next.has(spacecraftId)) {
                next.delete(spacecraftId);
              } else {
                next.add(spacecraftId);
              }
              return next;
            });
          }}
        />

        <section
          className="tgu-editor-center"
          ref={centerRef}
          style={{ gridTemplateRows: `${(mapRatio * 100).toFixed(1)}% minmax(0, 1fr)` }}
        >
          <div className="tgu-editor-map-stage">
            {rightMode === "shoot" && targetPickActive && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
            {orbitPasses.length > 0 ? (
              <MapPassSelector
                passes={orbitPasses}
                fromIndex={effectiveFrom}
                toIndex={effectiveTo}
                onFromChange={setPassFrom}
                onToChange={setPassTo}
              />
            ) : noradId ? (
              <div className="map-pass-selector map-pass-selector--empty">
                <span className="map-pass-selector__title">Витки</span>
                <span className="map-pass-selector__empty-note">
                  {flightLineLoading ? "Загрузка трассы…" : "Нет данных трассы на это окно"}
                </span>
              </div>
            ) : null}
            <Tgu2DMapView
              scene={mapScene}
              layers={EDITOR_MAP_LAYERS}
              redrawToken={0}
              enableObjectChooser
              onObjectsAtPoint={handleObjectsAtPoint}
              pickMode={rightMode === "shoot" && targetPickActive}
              pickTarget={targetPickActive ? target ?? undefined : undefined}
              onPickPoint={(point) => {
                // Опорная точка прицеливания — всегда одна: каждый клик заменяет
                // предыдущее перекрестие, цели не накапливаются.
                targetCounterRef.current += 1;
                setTarget({
                  id: `target-${targetCounterRef.current}`,
                  name: "Опорная точка прицеливания",
                  lat: point.lat,
                  lon: point.lon
                });
                setSelectedShootPass(undefined);
                setModeId(DEFAULT_EDITOR_MODES[0].id);
                setNotice(undefined);
                // Прицеливание НЕ гасим: пока не выбран проход, точку можно свободно переставлять
                // повторными кликами по карте. «Рука» включится при выборе строки прохождения.
              }}
              onObjectSelect={handleMapObjectSelect}
              focusPoint={mapFocus.point}
              focusToken={mapFocus.token}
              selectedObjectId={mapFocus.objectId}
            />
            <div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
          </div>
          <EditorTimeline
            activeLabel={activeLabel}
            works={timelineWorks}
            selectedWorkId={selectedWorkId}
            window={window}
            planStartMs={selectedPlan?.startMs}
            planEndMs={selectedPlan?.endMs}
            conflicts={conflicts}
            contextLanes={contextLanes}
            visibilityTracks={visibilityTracks}
            onSelectWork={(workId) => {
              setSelectedWorkId(workId);
              setRightMode("inspect");
              setNotice(undefined);
              focusWorkOnMap(workId);
            }}
            onClearSelection={() => {
              if (rightMode === "inspect") {
                setSelectedWorkId(undefined);
                setRightMode("none");
                setMapFocus((prev) => ({ ...prev, objectId: undefined }));
              }
            }}
            onWheelZoom={zoomAt}
            onPanTo={panTo}
          />
        </section>

        {rightMode !== "none" && (
          <aside className="tgu-editor-side-panel">
            {rightMode === "inspect" && selectedWork &&
              renderWorkInspector(selectedWork, () => {
                setSelectedWorkId(undefined);
                setRightMode("none");
              })}
            {rightMode === "inspect" && !selectedWork && <div className="tgu-empty tgu-empty--compact">Включение не выбрано.</div>}
            {rightMode === "shoot" && (
              <ShootPanel
                target={target}
                passes={shootPasses}
                loading={shootPassesLoading}
                error={shootPassesError}
                selectedPass={selectedShootPass}
                modeId={modeId}
                modes={modes}
                routeWindow={routeWindow}
                routeRoll={routeRoll}
                passStartMs={selectedShootPass?.startMs}
                passEndMs={selectedShootPass?.endMs}
                routeMinMs={selectedShootPass != null ? selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS : undefined}
                routeMaxMs={selectedShootPass != null ? selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS : undefined}
                routeLoading={routeLoading}
                routeError={routeError}
                route={surveyRoute}
                editing={editingWorkId != null}
                picking={targetPickActive}
                onPickTarget={() => setTargetPickActive(true)}
                addBlockedReason={inPlaceEditBlockedReason}
                onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))}
                onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))}
                onRouteShift={(deltaMs) =>
                  setRouteWindow((current) => {
                    if (!current || selectedShootPass == null) return current;
                    // Двигаем окно целиком, сохраняя длительность; не выходим за ±30 мин от окна видимости.
                    const minMs = selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS;
                    const maxMs = selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS;
                    let shift = deltaMs;
                    if (current.endMs + shift > maxMs) shift = maxMs - current.endMs;
                    if (current.startMs + shift < minMs) shift = minMs - current.startMs;
                    return { startMs: current.startMs + shift, endMs: current.endMs + shift };
                  })
                }
                onRouteRollChange={(deltaDeg) =>
                  setRouteRoll((current) => clampRoll((current ?? 0) + deltaDeg))
                }
                onRouteRollSet={(deg) =>
                  setRouteRoll((current) => (Number.isFinite(deg) ? clampRoll(deg) : current))
                }
                onSelectPass={(pass) => {
                  setSelectedShootPass(pass);
                  // Проход выбран — точка зафиксирована: выходим из прицеливания, карта снова «рука».
                  if (pass) setTargetPickActive(false);
                }}
                onModeChange={setModeId}
                onAdd={addShootWork}
                onClose={() => {
                  setRightMode("none");
                  setEditingWorkId(undefined);
                  setTarget(undefined);
                  setTargetPickActive(false);
                  setSelectedShootPass(undefined);
                }}
              />
            )}
            {rightMode === "downlink" && (
              <DropPanel
                zones={rvaZones}
                loading={rvaLoading}
                error={rvaError}
                selectedZone={selectedZone}
                onSelectZone={(zone) => {
                  setSelectedZone(zone);
                  setSelectedDropSurveyIds(new Set());
                  setDropError(undefined);
                }}
                stationName={stationNameById}
                zoneStartMs={(zone) => localDateTimeMs(zone.onStart.time)}
                zoneStopMs={(zone) => localDateTimeMs(zone.onStop.time)}
                candidates={dropCandidates}
                selectedSurveyIds={selectedDropSurveyIds}
                onToggleSurvey={(id) =>
                  setSelectedDropSurveyIds((prev) => {
                    const next = new Set(prev);
                    if (next.has(id)) next.delete(id);
                    else next.add(id);
                    return next;
                  })
                }
                saving={dropSaving}
                saveError={dropError}
                editing={editingWorkId != null}
                addBlockedReason={inPlaceEditBlockedReason}
                onAdd={addDropWork}
                onClose={() => {
                  setRightMode("none");
                  setEditingWorkId(undefined);
                  setSelectedZone(undefined);
                  setSelectedDropSurveyIds(new Set());
                  setDropError(undefined);
                }}
              />
            )}
            {rightMode === "mapInfo" && mapInfoSelection && (
              <MapInfoPanel
                selection={mapInfoSelection}
                requestItems={requestItems}
                onClose={() => {
                  setRightMode("none");
                  setMapInfoSelection(undefined);
                }}
              />
            )}
            {rightMode === "mapPick" && mapPickHits.length > 0 && (
              <MapPickPanel
                hits={mapPickHits}
                activeId={mapPickActiveId}
                onToggle={togglePickItem}
                onClose={() => {
                  setRightMode("none");
                  clearMapPick();
                  setSelectedWorkId(undefined);
                  setMapFocus((prev) => ({ ...prev, objectId: undefined }));
                }}
                renderDetail={renderPickDetail}
              />
            )}
          </aside>
        )}
      </div>

      {clearDialogOpen && (
        <div
          className="tgu-chain-dialog__backdrop"
          role="dialog"
          aria-modal="true"
          aria-label="Очистить план"
          onClick={() => setClearDialogOpen(false)}
        >
          <div className="tgu-chain-dialog tgu-editor-clear-dialog" onClick={(e) => e.stopPropagation()}>
            <div className="tgu-chain-dialog__header">
              <h2>Очистить план</h2>
            </div>
            <p className="tgu-editor-clear-dialog__text">
              Что удалить из плана? Сбросы удаляются в любом случае. Действие необратимо.
            </p>
            <div className="tgu-editor-clear-dialog__choices">
              <button
                className="tgu-button tgu-button--primary"
                type="button"
                onClick={() => { void clearPlan(true); }}
              >
                Оставить ручные съёмки
                <small>Удалить только рассчитанные включения</small>
              </button>
              <button
                className="tgu-button tgu-button--reject"
                type="button"
                onClick={() => { void clearPlan(false); }}
              >
                Удалить всё
                <small>Включая построенные вручную маршруты</small>
              </button>
            </div>
            <div className="tgu-chain-dialog__actions">
              <button className="tgu-button" type="button" onClick={() => setClearDialogOpen(false)}>
                Отмена
              </button>
            </div>
          </div>
        </div>
      )}
    </main>
  );
}

function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow {
  const timeMs = parseLocal(param.time);
  const half = SHOOT_WINDOW_MS / 2;
  return {
    startMs: timeMs - half,
    endMs: timeMs + half,
    quality: 0,
    revolution: param.revolution,
    rollDeg: param.gamma,
    sunAngleDeg: param.sunAngle,
    range: param.range,
    revSign: param.revSign
  };
}

function defaultEditorWindow(
  selectedPlan: TguEditorTabProps["selectedPlan"],
  appliedRange: TguEditorTabProps["appliedRange"]
): EditorTimelineWindow {
  if (selectedPlan) {
    return {
      fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS,
      toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS
    };
  }

  return { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs };
}

function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorGroundStation[] {
  if (!selectedPlan?.kppId) {
    return [];
  }

  return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }];
}

/**
 * LocalDateTime без зоны от бэка трактуем как локальное настенное время (как и поле `time`
 * в ballisticsApi): зоны ЗРВ приходят без таймзоны, времена съёмок — с offset (его учитываем);
 * приводим к одной шкале в локальной зоне браузера.
 */
function localDateTimeMs(s: string): number {
  return parseLocal(s);
}

function buildVisibilityTracks(
  target: EditorTarget | undefined,
  shootPasses: EditorVisibilityWindow[],
  selectedWork: DraftHistory["draft"]["works"][number] | undefined,
  stations: EditorGroundStation[],
  spacecraftId: string | undefined,
  window: EditorTimelineWindow
): EditorVisibilityTrack[] {
  const tracks: EditorVisibilityTrack[] = [];
  if (target) {
    tracks.push({
      id: "target",
      label: target.name,
      kind: "target",
      color: "var(--now)",
      windows: shootPasses
    });
  }

  if (selectedWork?.kind === "downlink" && selectedWork.stationId) {
    const station = stations.find((item) => item.id === selectedWork.stationId);
    if (station?.lat !== undefined && station.lon !== undefined) {
      tracks.push({
        id: "station",
        label: station.name,
        kind: "station",
        color: "var(--accent)",
        windows: stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs })
      });
    }
  }

  return tracks;
}

const MAP_TRACK_WINDOW_MS = 24 * 60 * 60 * 1000;

function buildEditorMapScene(
  spacecraftId: string | undefined,
  flightLineData: FlightLinePoint[],
  orbitPasses: OrbitPassInfo[],
  stationDtos: StationDto[],
  requestItems: RequestMapItem[],
  planModes: PlanMode[],
  fromIndex: number,
  toIndex: number,
  surveyRoute?: SurveyRoute
): Tgu2DMapScene {
  const visibleRevolutions = new Set(
    orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!)
  );

  const byRevolution = new Map<number, FlightLinePoint[]>();
  for (const pt of flightLineData) {
    if (!visibleRevolutions.has(pt.revolution)) continue;
    const arr = byRevolution.get(pt.revolution) ?? [];
    arr.push(pt);
    byRevolution.set(pt.revolution, arr);
  }

  const lines: MapLine[] = [];
  const polygons: MapPolygon[] = [];
  let zIndex = 0;

  for (const [revolution, pts] of byRevolution) {
    const passIdx = orbitPasses.find((p) => p.revolution === revolution)?.index ?? 0;

    lines.push({
      id: `${spacecraftId}:track:${revolution}`,
      layer: "tracks",
      points: pts.map((p) => ({ lat: p.lat, lon: p.long })),
      spacecraftId,
      passIndex: passIdx,
      zIndex: zIndex++
    });

    const leftEdge = pts.map((p) => ({ lat: p.latLeft, lon: p.longLeft }));
    const rightEdge = [...pts].reverse().map((p) => ({ lat: p.latRight, lon: p.longRight }));
    polygons.push({
      id: `${spacecraftId}:swath:${revolution}`,
      name: `Полоса обзора витка ${revolution}`,
      kind: "swath",
      layer: "swath",
      points: [...leftEdge, ...rightEdge],
      spacecraftId,
      revolution,
      zIndex: zIndex++
    });
  }

  for (const mode of planModes) {
    if (mode.type !== "SURVEY" || !mode.contourWkt) continue;
    const rings = parseWktPolygon(mode.contourWkt);
    for (const ring of rings) {
      if (ring.length < 3) continue;
      polygons.push({
        id: `planWork:${mode.id}:${zIndex}`,
        name: `Съёмка витка ${mode.revolution}`,
        kind: "planWork",
        layer: "planWorks",
        points: ring,
        status: "PLANNED",
        modeId: mode.id,
        zIndex: zIndex++
      });
    }
  }

  // Сбросы (DROP) — яркая толстая линия на трассе: берём точки трассы за интервал сеанса
  // [timeStart, timeStart + duration]. Это и есть участок витка, над которым идёт сброс.
  for (const mode of planModes) {
    if (mode.type !== "DROP" || mode.timeStart == null) continue;
    const startMs = localDateTimeMs(mode.timeStart);
    const endMs = startMs + mode.duration * 1000;
    const pts = flightLineData
      .map((p) => ({ t: parseLocal(p.time), lat: p.lat, lon: p.long }))
      .filter((p) => p.t >= startMs && p.t <= endMs)
      .sort((a, b) => a.t - b.t)
      .map((p) => ({ lat: p.lat, lon: p.lon }));
    if (pts.length < 2) continue;
    lines.push({
      id: `drop:${mode.id}`,
      name: `Сброс · виток ${mode.revolution}`,
      layer: "tracks",
      kind: "drop",
      modeId: mode.id,
      points: pts,
      zIndex: zIndex++
    });
  }

  for (const item of requestItems) {
    const rings = parseWktPolygon(item.geometry);
    for (const ring of rings) {
      if (ring.length < 3) continue;
      polygons.push({
        id: `request:${item.id}:${zIndex}`,
        name: item.name,
        kind: "request",
        layer: "requests",
        points: ring,
        requestId: item.id,
        surveyType: item.surveyType,
        zIndex: zIndex++
      });
    }
  }

  // Построенный (но ещё не сохранённый) маршрут съёмки — показываем поверх работ плана
  // отдельным цветом (статус ISSUING). Рендерится в слое planWorks, поэтому виден без
  // дополнительной настройки слоёв.
  if (surveyRoute?.contourWkt) {
    const rings = parseWktPolygon(surveyRoute.contourWkt);
    for (const ring of rings) {
      if (ring.length < 3) continue;
      polygons.push({
        id: `surveyRoute:${zIndex}`,
        name: `Маршрут съёмки (виток ${surveyRoute.revolution})`,
        kind: "planWork",
        layer: "planWorks",
        points: ring,
        status: "ISSUING",
        zIndex: zIndex++
      });
    }
  }

  const markers = buildStationMarkers(stationDtos);

  return { polygons, lines, markers };
}

function platformLabel(platforms: TguEditorTabProps["platforms"], spacecraftId?: string): string {
  if (!spacecraftId) {
    return "КА не выбран";
  }
  const platform = platforms.find((item) => item.spacecraftId === spacecraftId);
  return platform?.name ?? platform?.noradId ?? spacecraftId;
}

function nextWorkId(ref: MutableRefObject<number>): string {
  ref.current += 1;
  return `NW-${String(ref.current).padStart(4, "0")}`;
}

/**
 * Объект карты, соответствующий включению, и точка для центрирования:
 * - съёмка → контур (planWorks) по modeId либо только что построенный маршрут; точка — центр контура;
 * - сброс → яркая линия сброса (drop) по modeId; точка — середина линии.
 */
function workMapFocus(work: EditorWork | undefined, scene: Tgu2DMapScene): { point?: GeoPoint; objectId?: string } {
  if (!work) return {};
  if (work.kind === "shoot") {
    const polygon =
      work.planModeId != null
        ? scene.polygons.find((p) => p.modeId === work.planModeId)
        : scene.polygons.find((p) => p.id.startsWith("surveyRoute:"));
    const point: GeoPoint | undefined =
      work.centerLat != null && work.centerLon != null
        ? { lat: work.centerLat, lon: work.centerLon }
        : polygon
          ? polygonCentroid(polygon.points)
          : undefined;
    return { point, objectId: polygon?.id };
  }

  const line =
    work.planModeId != null
      ? scene.lines.find((l) => l.kind === "drop" && l.modeId === work.planModeId)
      : undefined;
  const point = line && line.points.length > 0 ? line.points[Math.floor(line.points.length / 2)] : undefined;
  return { point, objectId: line?.id };
}

function polygonCentroid(points: GeoPoint[]): GeoPoint | undefined {
  if (points.length === 0) return undefined;
  let lat = 0;
  let lon = 0;
  for (const p of points) {
    lat += p.lat;
    lon += p.lon;
  }
  return { lat: lat / points.length, lon: lon / points.length };
}
