import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import {
  buildSurveyRoute,
  deleteMode,
  saveDropRoute,
  saveSurveyRoute,
  validateMission,
  type SurveyRoute
} from "../../api/missionPlanningApi";
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
import { parseLocal, toLocalIso } 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,
  duplicateWork,
  mutate,
  planModesToWorks,
  redo,
  seedDraft,
  undo,
  updateWork
} from "./editorDraft";
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
import { DropPanel, type DropCandidateSurvey, MapInfoPanel, 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,
  EditorWorkPatch,
  TguEditorTabProps
} from "./model/editorTypes";

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

const HOUR_MS = 60 * 60 * 1000;
/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */
const SHOOT_WINDOW_MS = 6 * 60 * 1000;
/**
 * Угол захвата (полуширина полосы) по умолчанию, градусы.
 * Пока задаётся на фронте так же, как в текущем построении маршрутов; в дальнейшем
 * это значение будет определять бэкенд по профилю наблюдения КА.
 */
const DEFAULT_CAPTURE_ANGLE_DEG = 1.5;
/** Дефолтная длительность строящегося маршрута съёмки (мс). */
const DEFAULT_ROUTE_DURATION_MS = 30_000;
/** Предел крена (gamma) маршрута при ручной правке, градусы. */
const MAX_ROUTE_ROLL_DEG = 89;
/** Допустимый выход окна маршрута за пределы окна видимости, мс (±30 минут). */
const ROUTE_TIME_MARGIN_MS = 30 * 60_000;
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
  tracks: true,
  swath: true,
  planWorks: true,
  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 [rightMode, setRightMode] = useState<EditorRightMode>("none");
  const [selectedWorkId, setSelectedWorkId] = useState<string>();
  const [target, setTarget] = useState<EditorTarget>();
  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 [stationDtos, setStationDtos] = useState<StationDto[]>([]);
  const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
  const [checking, setChecking] = useState(false);
  const [applying, setApplying] = useState(false);
  const [canceling, setCanceling] = 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 [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);
  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, planModes, effectiveFrom, effectiveTo, surveyRoute),
    [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, 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]);

  // «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
  // локальные несохранённые твики без DRAFT применять нечего.
  const dirty = Boolean(draftPlanId);
  const addedCount = draft.works.filter((work) => work.isNew).length;
  // Реальный состав плана берём из включений (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]);

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

  useEffect(() => {
    if (!noradId) return;
    let cancelled = false;
    void fetchFlightLine(noradId, mapRange.fromMs, mapRange.toMs)
      .then((data) => { if (!cancelled) setFlightLineData(data); })
      .catch(() => { if (!cancelled) setFlightLineData([]); });
    return () => { cancelled = true; };
  }, [noradId, mapRange]);

  useEffect(() => {
    const controller = new AbortController();
    void fetchRequestsForMap({
      endFrom: toLocalIso(window.fromMs),
      beginTo: toLocalIso(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 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(Math.round((selectedShootPass.rollDeg ?? 0) * 10) / 10);
  }, [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: toLocalIso(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);
    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(() => {
    const missionId = draftPlanId ?? selectedPlan?.planId;
    if (!missionId) return;
    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);
  };

  const onChangeWork = (workId: string, patch: EditorWorkPatch) => {
    changeDraft((current) => updateWork(current, workId, patch));
  };

  // Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
  // слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание
  // плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы».
  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;
    }
    const created = await createDraft(selectedPlan.planId, { mode: "replace" });
    createdImplicitlyRef.current = true;
    setDraftPlanId(created.draftPlanId);
    return created.draftPlanId;
  }, [draftPlanId, selectedPlan]);

  // Бэкенд-валидация черновика (Ф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]);

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

    // Персистенция построенного маршрута съёмки в 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();
        savedRoute = await saveSurveyRoute(draftId, {
          satelliteId: numericNorad,
          time: toLocalIso(routeWindow.startMs),
          durationSeconds: routeDurationSeconds,
          roll: routeRoll,
          captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
          revolution: selectedShootPass.revolution ?? 0,
          anchor: "FORWARD",
          // Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
          planStart: toLocalIso(selectedPlan.startMs),
          planEnd: toLocalIso(selectedPlan.endMs)
        });
        setSurveyRoute(savedRoute);
        void 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(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
      })
    );
    setSelectedWorkId(id);
    setRightMode("inspect");
    setTarget(undefined);
    setSelectedShootPass(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);

    setDropSaving(true);
    setDropError(undefined);
    let savedDropId: number | undefined;
    try {
      // Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план.
      const draftId = await ensureDraft();
      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: toLocalIso(selectedPlan.startMs),
        planEnd: toLocalIso(selectedPlan.endMs)
      });
      savedDropId = savedDrop.id;
      // Перечитываем включения DRAFT (источник истины) и ревалидируем.
      void 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(current, {
        id,
        startMs: zoneStartMsVal,
        endMs: zoneStopMsVal,
        label: `Сброс · ${stationNm}`,
        station: { id: String(selectedZone.stationId), name: stationNm },
        planModeId: savedDropId
      })
    );
    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);
    }
  };

  // «Применить» = promote черновика (Ф5): DRAFT уходит в живую цепочку и выдаётся. Под
  // оптимистичным локом базы и гардом точки невозврата (409/422 с пояснением приходят с бэка).
  const applyDraft = async () => {
    if (!draftPlanId) {
      setApplyError("Нет правок для применения — черновик ещё не создан.");
      setNotice(undefined);
      return;
    }

    setApplying(true);
    setApplyError(undefined);
    setNotice(undefined);
    try {
      const result = await promoteDraft(draftPlanId);
      setNotice(`Черновик применён: план ${result.planId} отправлен на выдачу.`);
      setDraftPlanId(undefined);
      setServiceConflicts({});
      setHistory((current) => ({ ...current, past: [], future: [] }));
    } catch (error) {
      setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана.");
    } finally {
      setApplying(false);
    }
  };

  // «Отмена» = отказ от черновика (Ф6): удаляет DRAFT и клон-миссию на сервере, возвращает
  // редактор к живому плану (read-only) и сбрасывает локальные правки.
  const cancelDraft = async () => {
    if (!draftPlanId) return;
    setCanceling(true);
    setApplyError(undefined);
    setNotice(undefined);
    try {
      await abandonDraft(draftPlanId);
      setDraftPlanId(undefined);
      setServiceConflicts({});
      setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
      setSelectedWorkId(undefined);
      setRightMode("none");
      setNotice("Черновик отменён — правки отброшены.");
    } catch (error) {
      setApplyError(error instanceof Error ? error.message : "Не удалось отменить черновик.");
    } finally {
      setCanceling(false);
    }
  };

  // Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3,
  // с каскадами drop↔survey), затем убираются из локального представления. Несохранённые
  // (planModeId нет) — только локально.
  const handleDeleteWork = async (workId: string) => {
    const work = draft.works.find((item) => item.id === workId);
    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);
    setRightMode("none");
  };

  const zoom = (factor: number) => {
    setWindow((current) => {
      const center = (current.fromMs + current.toMs) / 2;
      const half = ((current.toMs - current.fromMs) / 2) * factor;
      return { fromMs: center - half, toMs: center + half };
    });
  };

  const pan = (factor: number) => {
    setWindow((current) => {
      const delta = (current.toMs - current.fromMs) * factor;
      return { fromMs: current.fromMs + delta, toMs: current.toMs + delta };
    });
  };

  const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
    if (rightMode === "shoot") return;
    if (!selection) {
      if (rightMode === "mapInfo") {
        setRightMode("none");
        setMapInfoSelection(undefined);
      }
      return;
    }
    setMapInfoSelection(selection);
    setRightMode("mapInfo");
    setSelectedWorkId(undefined);
  }, [rightMode]);

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

  if (!selectedSpacecraftId && !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}
        canUndo={history.past.length > 0}
        canRedo={history.future.length > 0}
        checking={checking}
        conflictCount={conflictCount(conflicts)}
        addedCount={addedCount}
        dirty={dirty}
        applying={applying}
        canceling={canceling}
        windowLabel={formatDuration(window.toMs - window.fromMs)}
        rightMode={rightMode}
        onSetRightMode={(mode) => {
          setRightMode(mode);
          setSelectedWorkId(undefined);
          setMapInfoSelection(undefined);
          setNotice(undefined);
        }}
        onUndo={() => setHistory((current) => undo(current))}
        onRedo={() => setHistory((current) => redo(current))}
        onCheck={runCheck}
        onApply={applyDraft}
        onCancel={cancelDraft}
        onZoom={zoom}
        onPan={pan}
      />

      <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" ? "15.5rem minmax(0, 1fr) 0" : "15.5rem 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" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
            {orbitPasses.length > 0 && (
              <MapPassSelector
                passes={orbitPasses}
                fromIndex={effectiveFrom}
                toIndex={effectiveTo}
                onFromChange={setPassFrom}
                onToChange={setPassTo}
              />
            )}
            <Tgu2DMapView
              scene={mapScene}
              layers={EDITOR_MAP_LAYERS}
              redrawToken={0}
              pickMode={rightMode === "shoot"}
              pickTarget={target ?? 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={displayWorks}
            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 }));
              }
            }}
          />
        </section>

        {rightMode !== "none" && (
          <aside className="tgu-editor-side-panel">
            {rightMode === "inspect" && selectedWork && (
              <WorkInspector
                work={selectedWork}
                modes={modes}
                stations={stations}
                spacecraftLabel={activeLabel}
                surveyModeLabel={surveyModeLabel}
                conflicts={conflicts[selectedWork.id] ?? []}
                readOnly={selectedWork.origin === "plan"}
                onUpdate={onChangeWork}
                onDelete={(workId) => { void handleDeleteWork(workId); }}
                onDuplicate={(workId) => {
                  const id = nextWorkId(idCounterRef);
                  changeDraft((current) => duplicateWork(current, workId, id));
                  setSelectedWorkId(id);
                }}
                onClose={() => {
                  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}
                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) =>
                    Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, (current ?? 0) + deltaDeg))
                  )
                }
                onRouteRollSet={(deg) =>
                  setRouteRoll((current) =>
                    Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current
                  )
                }
                onSelectPass={setSelectedShootPass}
                onModeChange={setModeId}
                onAdd={addShootWork}
                onClose={() => {
                  setRightMode("none");
                  setTarget(undefined);
                  setSelectedShootPass(undefined);
                }}
                onClearTarget={() => {
                  setTarget(undefined);
                  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}
                onAdd={addDropWork}
                onClose={() => {
                  setRightMode("none");
                  setSelectedZone(undefined);
                  setSelectedDropSurveyIds(new Set());
                  setDropError(undefined);
                }}
              />
            )}
            {rightMode === "mapInfo" && mapInfoSelection && (
              <MapInfoPanel
                selection={mapInfoSelection}
                requestItems={requestItems}
                onClose={() => {
                  setRightMode("none");
                  setMapInfoSelection(undefined);
                }}
              />
            )}
          </aside>
        )}
      </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 - 3 * HOUR_MS,
      toMs: selectedPlan.endMs + 3 * HOUR_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],
      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}`,
      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 };
}
