package space.nstart.pcp.pcp_mission_planing_service.service

import ballistics.flightLine.PointOnEarthCalculator
import ballistics.orbitalPoints.timeStepper.AbstractStepper
import ballistics.orbitalPoints.timeStepper.RungeStepper
import ballistics.types.EarthType
import ballistics.types.OrbitalPoint
import ballistics.types.Orientation
import ballistics.types.THBLPoint
import ballistics.types.WorkCSType
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.io.WKTWriter
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteAnchor
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO
import space.nstart.pcp.pcp_mission_planing_service.controller.CloneMode
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionValidationResponse
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository
import space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.configuration.CustomErrorException
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes
import java.time.Clock
import java.time.Duration
import java.time.LocalDateTime
import java.util.UUID
import kotlin.math.PI
import kotlin.math.abs


@Service
class MissionPlaningService(
    private val missionRepository: MissionRepository,
    private val modeRepository: ModeRepository,
    private val planRepository: PlanRepository,
    private val surveyModeRepository: SurveyModeRepository,
    private val dropModeRepository: DropModeRepository,
    private val dropModeSurveyRepository: DropModeSurveyRepository,
    private val modeBookingRepository: ModeBookingRepository,
    private val bookedSlotsEventPublisher: BookedSlotsEventPublisher,
    private val ballisticsService: BallisticsService,
    private val complexMissionService: ComplexMissionService,
    private val satelliteCatalogClient: SatelliteCatalogClient,
    private val satelliteMissionTypeFactory: SatelliteMissionTypeFactory,
    private val clock: Clock = Clock.systemUTC(),
    private val logger: Logger = LoggerFactory.getLogger(MissionPlaningService::class.java)
) {

    fun all(): List<MissionResponseDTO> = missionRepository.findAll().map { it.toResponse() }

    fun byId(id: UUID): MissionResponseDTO =
        missionRepository.findById(id)
            .orElseThrow { notFound(id) }
            .toResponse()

    @Transactional
    fun add(body: MissionRequestDTO): MissionResponseDTO {
        logger.info("сохранить миссию ${body.missionId} ${body.missionStart} ${body.missionStop}")
        val mission = missionRepository.save(
            MissionEntity(
                missionId = body.missionId?: UUID.randomUUID(),
                satelliteId = body.satelliteId,
                station = body.station,
                missionStart = body.missionStart,
                missionStop = body.missionStop,
                status = normalizeStatus(body.status)
            )
        )
        syncMainPlanWithMission(mission)
        return mission.toResponse()
    }

    @Transactional
    fun update(id: UUID, body: MissionRequestDTO): MissionResponseDTO {
        val mission = missionRepository.findById(id).orElseThrow { notFound(id) }
        mission.satelliteId = body.satelliteId
        mission.station = body.station
        mission.missionStart = body.missionStart
        mission.missionStop = body.missionStop
        mission.status = normalizeStatus(body.status)
        val savedMission = missionRepository.save(mission)
        syncMainPlanWithMission(savedMission)
        return savedMission.toResponse()
    }

    fun delete(id: UUID) {
        if (!missionRepository.existsById(id)) {
            throw notFound(id)
        }
        missionRepository.deleteById(id)
    }

    /**
     * Клонирует миссию и все её включения в новый `missionId`.
     *
     * Режим `replace` — полный глубокий клон: Mission + Plan + все Mode (SurveyMode/DropMode).
     * Режим `fork-forward` — клонирует только включения, чей интервал `[timeStart, timeStart+duration)`
     * пересекается с окном `[windowStart, windowStop)`. DropMode клонируется только если хотя бы одна
     * из его связанных съёмок тоже попала в клон (иначе сброс становится пустым).
     *
     * Намеренные особенности fork-forward:
     * - Времена включений переносятся как есть. Включение, начавшееся до `windowStart` и закончившееся
     *   внутри окна, клонируется со своим исходным `timeStart` — он может быть раньше `missionStart`
     *   клона. Границы миссии описывают слот целевой ЗРВ, а не оболочку времён её включений.
     * - Съёмка переносится, даже если её сброс не попал в окно: сброс будет перевыдан в новом плане.
     *   Обратное неверно — сброс без единой перенесённой съёмки отбрасывается.
     *
     * Новый `missionId` передаётся в ответе; он используется как `planId` черновика в pcp-tgu-service
     * (инвариант planId == missionId сохраняется).
     */
    @Transactional
    fun cloneMission(sourceMissionId: UUID, req: MissionCloneRequest): MissionCloneResponse {
        if (req.mode != CloneMode.REPLACE && req.mode != CloneMode.FORK_FORWARD) {
            throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown clone mode: ${req.mode}")
        }
        val isForkForward = req.mode == CloneMode.FORK_FORWARD
        if (isForkForward && (req.windowStart == null || req.windowStop == null)) {
            throw ResponseStatusException(
                HttpStatus.BAD_REQUEST,
                "windowStart and windowStop are required for fork-forward clone"
            )
        }

        val source = missionRepository.findById(sourceMissionId).orElseThrow { notFound(sourceMissionId) }
        val allModes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(sourceMissionId)

        val modesToClone: List<ModeEntity> = if (isForkForward) {
            allModes.filter { mode -> mode.intersects(req.windowStart!!, req.windowStop!!) }
        } else {
            allModes
        }

        val (newMissionStart, newMissionStop) = if (isForkForward) {
            req.windowStart!! to req.windowStop!!
        } else {
            source.missionStart to source.missionStop
        }

        val newMission = missionRepository.save(
            MissionEntity(
                satelliteId = source.satelliteId,
                station = source.station,
                missionStart = newMissionStart,
                missionStop = newMissionStop,
                status = "NEW"
            )
        )

        val newPlan = planRepository.save(
            PlanEntity(
                mission = newMission,
                planStart = newMissionStart,
                planStop = newMissionStop,
                planNumber = 1
            )
        )

        // Клонируем съёмки и строим маппинг старый id → новая сущность для перевязки сбросов.
        val surveyIdMapping = mutableMapOf<Long, SurveyModeEntity>()
        modesToClone.filterIsInstance<SurveyModeEntity>().forEach { src ->
            val cloned = surveyModeRepository.save(
                SurveyModeEntity(
                    plan = newPlan,
                    timeStart = src.timeStart,
                    revolution = src.revolution,
                    lat = src.lat,
                    long = src.long,
                    duration = src.duration,
                    contourWkt = src.contourWkt,
                    roll = src.roll,
                    status = SurveyModeStatus.PLANNED,
                    source = src.source
                )
            )
            src.id?.let { surveyIdMapping[it] = cloned }
        }

        // Клонируем сбросы; пропускаем если все их связанные съёмки вышли за окно (пустой сброс).
        modesToClone.filterIsInstance<DropModeEntity>().forEach { src ->
            val srcId = src.id ?: return@forEach
            val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(srcId)
            val remappedSurveys = links.mapNotNull { link -> link.surveyMode?.id?.let { surveyIdMapping[it] } }
            if (remappedSurveys.isEmpty()) {
                return@forEach
            }
            val newDrop = dropModeRepository.save(
                DropModeEntity(
                    plan = newPlan,
                    timeStart = src.timeStart,
                    revolution = src.revolution,
                    station = src.station,
                    duration = src.duration
                )
            )
            remappedSurveys.forEach { survey ->
                dropModeSurveyRepository.save(DropModeSurveyEntity(dropMode = newDrop, surveyMode = survey))
            }
        }

        val clonedDropCount = modesToClone.count { it is DropModeEntity }
        logger.info(
            "Клонирована миссия {}: mode={}, новый id={}, съёмок={}, сбросов={}",
            sourceMissionId, req.mode, newMission.missionId, surveyIdMapping.size, clonedDropCount
        )

        return MissionCloneResponse(newMissionId = newMission.missionId)
    }

    /**
     * Создаёт пустую миссию (без режимов) на заданное окно — для плана новой цепочки «без истории».
     * Возвращает новый missionId, который tgu использует как planId черновика; включения оператор
     * добавляет сам в редакторе. Создаётся миссия (status=NEW) и её основной план (planNumber=1).
     */
    fun createBlankMission(req: MissionBlankRequest): MissionCloneResponse {
        if (!req.missionStop.isAfter(req.missionStart)) {
            throw ResponseStatusException(HttpStatus.BAD_REQUEST, "missionStop must be after missionStart")
        }

        val mission = missionRepository.save(
            MissionEntity(
                satelliteId = req.satelliteId,
                station = null,
                missionStart = req.missionStart,
                missionStop = req.missionStop,
                status = "NEW"
            )
        )
        planRepository.save(
            PlanEntity(
                mission = mission,
                planStart = req.missionStart,
                planStop = req.missionStop,
                planNumber = 1
            )
        )

        logger.info(
            "Создана пустая миссия {}: satelliteId={}, окно [{}–{}]",
            mission.missionId, req.satelliteId, req.missionStart, req.missionStop
        )
        return MissionCloneResponse(newMissionId = mission.missionId)
    }

    fun modesByMission(id: UUID): List<ModeResponseDTO> {
        if (!missionRepository.existsById(id)) {
            throw notFound(id)
        }

        val modes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(id)
        return mapModesToResponse(modes)
    }

    fun modesBySatelliteAndInterval(
        satelliteId: Long,
        timeStart: LocalDateTime,
        timeStop: LocalDateTime
    ): List<ModeResponseDTO> {
        if (timeStop.isBefore(timeStart)) {
            throw ResponseStatusException(
                HttpStatus.BAD_REQUEST,
                "timeStop must be greater than or equal to timeStart"
            )
        }

        val modes = modeRepository
            .findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
                satelliteId,
                timeStop
            )
            .filter { it.intersects(timeStart, timeStop) }

        return mapModesToResponse(modes)
    }

    private fun MissionEntity.toResponse() = MissionResponseDTO(
        missionId = missionId,
        satelliteId = satelliteId,
        station = station,
        missionStart = missionStart,
        missionStop = missionStop,
        status = status
    )

    private fun normalizeStatus(status: String?): String = status?.trim().takeUnless { it.isNullOrEmpty() } ?: "NEW"

    private fun notFound(id: UUID) =
        ResponseStatusException(HttpStatus.NOT_FOUND, "Mission with id $id not found")

    private fun mapModesToResponse(modes: List<ModeEntity>): List<ModeResponseDTO> {
        val bookingsByModeId = bookingsByModeId(modes)
        return modes.map { it.toModeResponse(bookingsByModeId) }
    }

    private fun bookingsByModeId(modes: List<ModeEntity>): Map<Long, List<Long>> {
        val surveyModeIds = modes.filterIsInstance<SurveyModeEntity>().mapNotNull { it.id }
        if (surveyModeIds.isEmpty()) {
            return emptyMap()
        }

        return modeBookingRepository.findAllByMode_IdIn(surveyModeIds)
            .groupBy(
                keySelector = { it.mode?.id },
                valueTransform = { it.slotId }
            )
            .mapKeys { (modeId, _) -> modeId ?: -1L }
            .mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() }
    }

    /**
     * Пересечение интервала включения с окном `[intervalStart, intervalStop)` (правый конец открыт).
     * Применяется при fork-forward-клоне: включения строго вне окна не клонируются.
     */
    private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean {
        val modeStart = timeStart ?: return false
        val modeStop = modeStop() ?: return false
        return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop)
    }

    private fun ModeEntity.modeStop(): LocalDateTime? {
        val modeStart = timeStart ?: return null
        val durationSeconds = when (this) {
            is SurveyModeEntity -> duration
            is DropModeEntity -> duration
            else -> 0.0
        }.coerceAtLeast(0.0)

        return modeStart.plusNanos((durationSeconds * 1_000_000_000L).toLong())
    }

    private fun syncMainPlanWithMission(mission: MissionEntity) {
        val plan = planRepository.findFirstByMissionMissionIdOrderByIdAsc(mission.missionId)
            .orElseGet { PlanEntity(mission = mission, planNumber = 1) }

        plan.mission = mission
        plan.planStart = mission.missionStart
        plan.planStop = mission.missionStop
        plan.planNumber = 1

        planRepository.save(plan)
    }

    private fun ModeEntity.toModeResponse(bookingsByModeId: Map<Long, List<Long>> = emptyMap()): ModeResponseDTO {
        val modeId = id ?: throw IllegalStateException("Mode id is null")
        return when (this) {
            is SurveyModeEntity -> toSurveyResponse(bookingsByModeId[modeId].orEmpty())

            is DropModeEntity -> DropModeResponseDTO(
                id = modeId,
                planId = plan?.id ?: throw IllegalStateException("Plan id is null for mode $modeId"),
                timeStart = timeStart,
                revolution = revolution,
                station = station,
                duration = duration,
                surveys = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(modeId)
                    .map { link ->
                        val survey = link.surveyMode
                            ?: throw IllegalStateException("Survey mode is null for drop mode $modeId")
                        survey.id?:-1
                    }
            )
            else -> throw IllegalStateException("Unsupported mode type: ${this::class.qualifiedName}")
        }
    }

    private fun SurveyModeEntity.toSurveyResponse(bookedSlotIds: List<Long> = emptyList()): SurveyModeResponseDTO {
        val surveyId = id ?: throw IllegalStateException("Survey mode id is null")
        val surveyPlanId = plan?.id ?: throw IllegalStateException("Plan id is null for survey mode $surveyId")

        return SurveyModeResponseDTO(
            id = surveyId,
            planId = surveyPlanId,
            timeStart = timeStart,
            revolution = revolution,
            status = status.name,
            satelliteModeId = satelliteModeId,
            source = source.name,
            lat = lat,
            longitude = long,
            duration = duration,
            contourWkt = contourWkt,
            roll = roll,
            bookedSlotIds = bookedSlotIds
        )
    }




    @Transactional
    fun loadSlots(
        missionId: UUID,
        overrideSatelliteId: Long? = null,
        overrideIntervalStart: LocalDateTime? = null,
        overrideIntervalEnd: LocalDateTime? = null,
        comPlanSnapshotId: Long? = null
    ): MissionSurveyCalculationResponseDTO {
        val startedAt = UtcDateTimes.now(clock)
        logger.info(
            "Расчет плана съемки stage 5 по persisted snapshot этапа 4 для миссии {}: requestedComPlanSnapshotId={}",
            missionId,
            comPlanSnapshotId
        )
        val mission = missionRepository.findById(missionId).orElseThrow {
            notFound(missionId)
        }
        if (overrideSatelliteId != null || overrideIntervalStart != null || overrideIntervalEnd != null) {
            overrideSatelliteId?.let { mission.satelliteId = it }
            overrideIntervalStart?.let { mission.missionStart = it }
            overrideIntervalEnd?.let { mission.missionStop = it }
            missionRepository.save(mission)
        }

        val satelliteId = overrideSatelliteId ?: mission.satelliteId
        val intervalStart = overrideIntervalStart ?: mission.missionStart
            ?: throw CustomErrorException("Не задано время начала миссии")
        val intervalEnd = overrideIntervalEnd ?: mission.missionStop
            ?: throw CustomErrorException("Не задано время конца миссии")

        if (intervalEnd.isBefore(intervalStart)) {
            throw CustomErrorException("Некорректный интервал миссии: missionStop < missionStart")
        }

        val satellite = satelliteCatalogClient.getSatellite(satelliteId)
        val missionType = satelliteMissionTypeFactory.getBySatellite(satellite)
        logger.info(
            "Расчет плана съемки для миссии {} выполняется по логике типа спутника: satelliteId={}, typeCode={}, missionType={}",
            missionId,
            satelliteId,
            satellite.typeCode,
            missionType.typeCode
        )

        val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
            .orElseGet {
                planRepository.save(
                    PlanEntity(
                        mission = mission,
                        planStart = mission.missionStart,
                        planStop = mission.missionStop,
                        planNumber = 1
                    )
                )
            }
        plan.planStart = intervalStart
        plan.planStop = intervalEnd

        val planId = plan.id ?: throw IllegalStateException("Не найден план для миссии ${mission.missionId}")

        val snapshotSelection = resolveStage4Snapshot(
            satelliteId = satelliteId,
            intervalStart = intervalStart,
            intervalEnd = intervalEnd,
            explicitSnapshotId = comPlanSnapshotId
        )
        plan.stage4SnapshotId = snapshotSelection.snapshot.id
        planRepository.save(plan)

        val survs = complexMissionService.snapshotModes(
            snapshotId = snapshotSelection.snapshot.id,
            intervalStart = intervalStart,
            intervalEnd = intervalEnd
        )

        logger.info(
            "Этап 5 загружает persisted snapshot этапа 4 из pcp-complex-mission-service: missionId={}, satelliteId={}, interval=[{} - {}], selection={}, snapshotId={}, loadedModes={}, manualModes={}, modesWithBookedSlots={}",
            missionId,
            satelliteId,
            intervalStart,
            intervalEnd,
            snapshotSelection.selectionMode,
            snapshotSelection.snapshot.id,
            survs.size,
            survs.count { it.source == SurveyModeSource.MANUAL.name },
            survs.count { it.bookedSlotIds.isNotEmpty() }
        )

        val removedRecalculable = modeRepository.deleteRecalculableSurveyModesByPlanId(planId)
        logger.info(
            "Пересчёт плана {}: удалено {} пересчитываемых маршрутов съёмки, ручные (source=MANUAL) сохранены",
            planId,
            removedRecalculable
        )

        if (survs.isEmpty()) {
            val finishedAt = UtcDateTimes.now(clock)
            return MissionSurveyCalculationResponseDTO(
                missionId = missionId,
                completed = true,
                startedAt = startedAt,
                finishedAt = finishedAt,
                durationMs = Duration.between(startedAt, finishedAt).toMillis(),
                comPlanSnapshotId = snapshotSelection.snapshot.id,
                surveyModesCount = 0,
                manualModesCount = 0,
                complanModesCount = 0,
                slotsModesCount = 0,
                mixedModesCount = 0,
                bookedSlotIdsCount = 0
            )
        }

        val preparedModes = survs.map { surv ->
            missionType.prepareSurvey(satellite, surv, plan) to surv.bookedSlotIds.distinct()
        }

        val savedModes = surveyModeRepository.saveAll(
            (preparedModes.mapNotNull { it.first })
        ).toList()

        val modeBookings = savedModes.zip(preparedModes.map { it.second })
            .flatMap { (mode, slotIds) ->
                slotIds
                    .filter { it > 0 }
                    .map { slotId -> ModeBookingEntity(mode = mode, slotId = slotId) }
            }

        if (modeBookings.isNotEmpty()) {
            modeBookingRepository.saveAll(modeBookings)
        }

        logger.info(
            "Расчет плана съемки stage 5 для миссии {} завершен по persisted snapshot stage 4: selection={}, snapshotId={}, createdSurveyModes={}, preservedBookings={}",
            missionId,
            snapshotSelection.selectionMode,
            snapshotSelection.snapshot.id,
            savedModes.size,
            modeBookings.size
        )

        val finishedAt = UtcDateTimes.now(clock)
        return MissionSurveyCalculationResponseDTO(
            missionId = missionId,
            completed = true,
            startedAt = startedAt,
            finishedAt = finishedAt,
            durationMs = Duration.between(startedAt, finishedAt).toMillis(),
            comPlanSnapshotId = snapshotSelection.snapshot.id,
            surveyModesCount = savedModes.size,
            manualModesCount = survs.count { it.source == SurveyModeSource.MANUAL.name },
            complanModesCount = survs.count { it.source == SurveyModeSource.COMPLAN.name },
            slotsModesCount = survs.count { it.source == SurveyModeSource.SLOTS.name },
            mixedModesCount = survs.count { it.source == SurveyModeSource.MIXED.name },
            bookedSlotIdsCount = survs.flatMap { it.bookedSlotIds }.filter { it > 0 }.distinct().size
        )
    }

    private data class Stage4SnapshotSelection(
        val snapshot: space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO,
        val selectionMode: String
    )

    private fun resolveStage4Snapshot(
        satelliteId: Long,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime,
        explicitSnapshotId: Long?
    ): Stage4SnapshotSelection {
        val snapshot = if (explicitSnapshotId != null) {
            val explicitSnapshot = complexMissionService.snapshot(explicitSnapshotId)
            logger.info(
                "Stage 5 uses explicitly provided stage4 snapshot: satelliteId={}, interval=[{} - {}], snapshotId={}",
                satelliteId,
                intervalStart,
                intervalEnd,
                explicitSnapshotId
            )
            explicitSnapshot
        } else {
            val activeSnapshot = complexMissionService.activeSnapshot(satelliteId)
                ?: throw CustomErrorException(
                    "Active stage4 snapshot not found for satelliteId=$satelliteId and interval=[$intervalStart - $intervalEnd]"
                )
            logger.info(
                "Stage 5 auto-selected active stage4 snapshot by satelliteId and will filter modes by mission interval: satelliteId={}, interval=[{} - {}], snapshotId={}",
                satelliteId,
                intervalStart,
                intervalEnd,
                activeSnapshot.id
            )
            activeSnapshot
        }

        if (snapshot.satelliteId != satelliteId) {
            throw CustomErrorException(
                "Snapshot ${snapshot.id} does not match requested satelliteId=$satelliteId: " +
                    "snapshot.satelliteId=${snapshot.satelliteId}"
            )
        }

        return Stage4SnapshotSelection(
            snapshot = snapshot,
            selectionMode = if (explicitSnapshotId != null) "explicit" else "auto-active"
        )
    }

    private fun extractCenterLatLon(contourWkt: String?): Pair<Double, Double> {
        if (contourWkt.isNullOrBlank()) {
            return 0.0 to 0.0
        }

        val pairs = Regex("(-?\\d+(?:\\.\\d+)?)\\s+(-?\\d+(?:\\.\\d+)?)")
            .findAll(contourWkt)
            .mapNotNull { match ->
                val lon = match.groupValues[1].toDoubleOrNull()
                val lat = match.groupValues[2].toDoubleOrNull()
                if (lon == null || lat == null) null else lon to lat
            }
            .toMutableList()

        if (pairs.isEmpty()) {
            return 0.0 to 0.0
        }

        if (pairs.size > 1 && pairs.first() == pairs.last()) {
            pairs.removeAt(pairs.lastIndex)
        }

        val avgLon = pairs.map { it.first }.average()
        val avgLat = pairs.map { it.second }.average()
        return avgLat to avgLon
    }


    fun prepareSurvey(surv: SatelliteModeResponseDTO, plan: PlanEntity): SurveyModeEntity =
        satelliteMissionTypeFactory.defaultType().prepareSurvey(
            satellite = space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO(),
            surv = surv,
            plan = plan
        )


    /**
     * Построение одного маршрута съёмки по параметрам видимости точки
     * (используется вкладкой «Редактор» в pcp-tgu-ops-ui для построения включений съёмок).
     *
     * Stateless: орбитальные точки берутся из pcp-ballistics-service (extract-time), контур
     * полосы строится тем же алгоритмом [contour], что и при штатном расчёте маршрутов
     * (этап 5, [loadSlots] → snapshot этапа 4). Маршрут только возвращается клиенту для
     * отображения на картооснове (live-превью при выборе момента видимости), без персистенции.
     * Сохранение построенного маршрута выполняет [saveSurveyRoute].
     */
    fun buildSurveyRoute(req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO =
        buildContour(req).toResponse(surveyModeId = null)

    /**
     * Построение и сохранение одного маршрута съёмки в БД по идентификатору выбранного плана
     * в сервисе pcp-tgu-service.
     *
     * Идентификатор плана (`planId` из PlannedPlanEntity в pcp-tgu-service) совпадает с
     * `MissionEntity.missionId` в этом сервисе (см. BPMN createSatelliteMission: переменная
     * `satellitePlanId` → `MissionRequestDTO.missionId`). Маршрут сохраняется как
     * [SurveyModeEntity] с источником [SurveyModeSource.MANUAL] под основным планом миссии
     * (planNumber = 1) — той же таблицей `survey_modes`, что и при штатном расчёте плана.
     *
     * Адресуется только **существующая** миссия-черновик: правки редактора идут в DRAFT-клон,
     * созданный заранее (`POST /api/missions/{id}/clone`). Авто-создание живой миссии на лету
     * убрано — нельзя молча писать правки в несуществующий/живой план. Если миссии с таким id
     * нет — 404.
     *
     * Ручные маршруты (source = MANUAL) переживают пересчёт плана: [loadSlots] удаляет только
     * пересчитываемые маршруты ([ModeRepository.deleteRecalculableSurveyModesByPlanId]).
     */
    @Transactional
    fun saveSurveyRoute(planId: UUID, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO {
        // Проверяем существование миссии-черновика до дорогого расчёта контура (fail-fast на 404).
        val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) }

        val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
            .orElseGet {
                planRepository.save(
                    PlanEntity(
                        mission = mission,
                        planStart = mission.missionStart,
                        planStop = mission.missionStop,
                        planNumber = 1
                    )
                )
            }

        val built = buildContour(req)

        val saved = surveyModeRepository.save(
            SurveyModeEntity(
                plan = plan,
                timeStart = built.timeStart,
                revolution = built.revolution,
                lat = built.lat,
                long = built.longitude,
                duration = built.durationSeconds,
                contourWkt = built.contourWkt,
                roll = built.roll,
                status = SurveyModeStatus.PLANNED,
                source = SurveyModeSource.MANUAL
            )
        )

        logger.info(
            "Сохранён ручной маршрут съёмки (survey_mode id={}) для плана/миссии {}: revolution={}, timeStart={}, duration={}",
            saved.id,
            planId,
            built.revolution,
            built.timeStart,
            built.durationSeconds
        )

        return built.toResponse(surveyModeId = saved.id)
    }

    /**
     * Ручное добавление сброса в план по выбранной оператором зоне радиовидимости (ЗРВ).
     *
     * Оператор на вкладке «Редактор» выбирает ЗРВ станции (из pcp-ballistics) и набор уже
     * запланированных маршрутов съёмки, время конца которых строго меньше начала зоны. Сброс
     * сохраняется как [DropModeEntity] под основным планом миссии (planNumber = 1) со связями
     * съёмка↔сброс ([DropModeSurveyEntity]) — той же моделью, что и при штатном расчёте
     * ([calculateDrops]).
     *
     * Идентификатор плана (`planId`) совпадает с `MissionEntity.missionId` (см. saveSurveyRoute).
     * Адресуется только **существующая** миссия-черновик (DRAFT-клон, созданный заранее). Авто-создание
     * живой миссии на лету убрано — если миссии с таким id нет, возвращается 404.
     *
     * Внимание: ручные сбросы НЕ защищены от пересчёта — штатный [calculateDrops] удаляет все
     * сбросы плана ([ModeRepository.deleteDropModesByPlanId]).
     */
    @Transactional
    fun saveDropRoute(planId: UUID, req: DropRouteSaveRequestDTO): DropModeResponseDTO {
        if (req.surveyModeIds.isEmpty()) {
            throw CustomErrorException("Не выбрано ни одного маршрута съёмки для сброса")
        }

        val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) }

        val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
            .orElseGet {
                planRepository.save(
                    PlanEntity(
                        mission = mission,
                        planStart = mission.missionStart,
                        planStop = mission.missionStop,
                        planNumber = 1
                    )
                )
            }

        // Грузим выбранные съёмки и валидируем: принадлежат этому плану и заканчиваются строго
        // до начала зоны (съёмка успевает завершиться к сеансу сброса).
        val surveys = surveyModeRepository.findAllById(req.surveyModeIds)
        val foundIds = surveys.mapNotNull { it.id }.toSet()
        val missing = req.surveyModeIds.filterNot { it in foundIds }
        if (missing.isNotEmpty()) {
            throw CustomErrorException("Маршруты съёмки не найдены: $missing")
        }
        surveys.forEach { survey ->
            if (survey.plan?.id != plan.id) {
                throw CustomErrorException("Маршрут съёмки ${survey.id} не относится к плану $planId")
            }
            val start = survey.timeStart
                ?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала")
            val surveyEnd = start
                .plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
            if (!surveyEnd.isBefore(req.zoneStart)) {
                throw CustomErrorException(
                    "Маршрут съёмки ${survey.id} заканчивается ($surveyEnd) не раньше начала зоны (${req.zoneStart}) — сброс невозможен"
                )
            }
        }

        // Длительность сброса — сумма длительностей сброса по каждой съёмке (как в calculateDrops).
        val usedDuration = surveys.sumOf { calculateDropDuration(it).coerceAtLeast(0.0) }
        val zoneDuration = Duration.between(req.zoneStart, req.zoneStop).toMillis().toDouble() / 1000.0
        if (zoneDuration > 0.0 && usedDuration > zoneDuration) {
            throw CustomErrorException(
                "Суммарная длительность сброса ($usedDuration с) превышает длительность зоны ($zoneDuration с)"
            )
        }

        val dropMode = dropModeRepository.save(
            DropModeEntity(
                plan = plan,
                timeStart = req.zoneStart,
                revolution = req.revolution,
                station = req.station,
                duration = usedDuration
            )
        )
        val links = surveys.map { survey -> DropModeSurveyEntity(dropMode = dropMode, surveyMode = survey) }
        dropModeSurveyRepository.saveAll(links)

        logger.info(
            "Сохранён ручной сброс (drop_mode id={}) для плана/миссии {}: станция={}, виток={}, зона={}, съёмок={}, длительность={}",
            dropMode.id, planId, req.station, req.revolution, req.zoneStart, surveys.size, usedDuration
        )

        return DropModeResponseDTO(
            id = dropMode.id ?: 0,
            planId = plan.id ?: 0,
            timeStart = dropMode.timeStart,
            revolution = dropMode.revolution,
            station = dropMode.station,
            duration = dropMode.duration,
            surveys = surveys.mapNotNull { it.id }
        )
    }

    /**
     * Перестраивает контур существующего маршрута съёмки по новым параметрам (окно/крен/угол захвата)
     * тем же алгоритмом [buildContour], что и [buildSurveyRoute]/[saveSurveyRoute], и сохраняет
     * результат на месте (правка включения в черновике).
     *
     * Меняются: время начала, виток, центр (lat/long), длительность, контур (WKT) и крен. Статус
     * сохраняется. Источник переводится в [SurveyModeSource.MANUAL] — отредактированный оператором
     * маршрут переживает пересчёт плана ([loadSlots] удаляет только пересчитываемые, не MANUAL).
     */
    @Transactional
    fun updateSurveyRoute(modeId: Long, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO {
        val survey = surveyModeRepository.findById(modeId).orElseThrow {
            ResponseStatusException(HttpStatus.NOT_FOUND, "Survey mode with id $modeId not found")
        }

        val built = buildContour(req)
        survey.timeStart = built.timeStart
        survey.revolution = built.revolution
        survey.lat = built.lat
        survey.long = built.longitude
        survey.duration = built.durationSeconds
        survey.contourWkt = built.contourWkt
        survey.roll = built.roll
        survey.source = SurveyModeSource.MANUAL
        val saved = surveyModeRepository.save(survey)

        logger.info(
            "Перестроен маршрут съёмки (survey_mode id={}): revolution={}, timeStart={}, duration={}, roll={}",
            saved.id, built.revolution, built.timeStart, built.durationSeconds, built.roll
        )

        return built.toResponse(surveyModeId = saved.id)
    }

    /**
     * Удаляет включение (SURVEY или DROP) из плана-черновика по его id.
     *
     * Съёмка: снимаются её брони слотов ([ModeBookingEntity]) и связи съёмка↔сброс
     * ([DropModeSurveyEntity]); сброс, оставшийся без единой съёмки, тоже удаляется (осиротевший
     * сеанс не имеет смысла). Другие съёмки того же сброса не трогаются.
     *
     * Сброс: удаляются только его связи и сам сброс; съёмки независимы и сохраняются.
     */
    @Transactional
    fun deleteMode(modeId: Long) {
        val mode = modeRepository.findById(modeId).orElseThrow {
            ResponseStatusException(HttpStatus.NOT_FOUND, "Mode with id $modeId not found")
        }
        when (mode) {
            is SurveyModeEntity -> deleteSurveyMode(mode)
            is DropModeEntity -> deleteDropMode(mode)
            else -> throw ResponseStatusException(
                HttpStatus.BAD_REQUEST,
                "Unsupported mode type for id $modeId: ${mode::class.qualifiedName}"
            )
        }
    }

    private fun deleteSurveyMode(survey: SurveyModeEntity) {
        val surveyId = survey.id ?: throw IllegalStateException("Survey mode id is null")

        val bookings = modeBookingRepository.findAllByMode_IdIn(listOf(surveyId))
        if (bookings.isNotEmpty()) {
            modeBookingRepository.deleteAll(bookings)
        }

        val links = dropModeSurveyRepository.findAllBySurveyMode_Id(surveyId)
        val affectedDropIds = links.mapNotNull { it.dropMode?.id }.distinct()
        if (links.isNotEmpty()) {
            dropModeSurveyRepository.deleteAll(links)
        }
        // Сброс, у которого после удаления этой съёмки не осталось ни одной другой, — осиротел.
        affectedDropIds.forEach { dropId ->
            val hasOtherSurvey = dropModeSurveyRepository
                .findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId)
                .any { it.surveyMode?.id != null && it.surveyMode?.id != surveyId }
            if (!hasOtherSurvey) {
                dropModeRepository.deleteById(dropId)
            }
        }

        surveyModeRepository.delete(survey)
        logger.info("Удалена съёмка (survey_mode id={}), снято броней={}, осиротевших сбросов проверено={}",
            surveyId, bookings.size, affectedDropIds.size)
    }

    private fun deleteDropMode(drop: DropModeEntity) {
        val dropId = drop.id ?: throw IllegalStateException("Drop mode id is null")
        val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId)
        if (links.isNotEmpty()) {
            dropModeSurveyRepository.deleteAll(links)
        }
        dropModeRepository.delete(drop)
        logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size)
    }

    /**
     * Очистка плана. Всегда удаляет все сбросы и брони слотов; для съёмок поведение выбирает
     * оператор флагом [keepManual]:
     *  - `keepManual=true` — сохраняются ручные маршруты съёмки (source=MANUAL), удаляются только
     *    рассчитанные (как и при пересчёте плана [loadSlots]/[calculateDrops]);
     *  - `keepManual=false` — удаляются ВСЕ съёмки, включая ручные (полная очистка).
     * Сбросы удаляются первыми, чтобы снять связи до удаления съёмок и не плодить проверку
     * осиротевших сбросов.
     */
    @Transactional
    fun clearPlan(missionId: UUID, keepManual: Boolean) {
        missionRepository.findById(missionId).orElseThrow { notFound(missionId) }
        val modes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId)
        val drops = modes.filterIsInstance<DropModeEntity>()
        val surveys = modes.filterIsInstance<SurveyModeEntity>()
        val (keptSurveys, deletedSurveys) =
            if (keepManual) surveys.partition { it.source == SurveyModeSource.MANUAL }
            else emptyList<SurveyModeEntity>() to surveys
        drops.forEach { deleteDropMode(it) }
        deletedSurveys.forEach { deleteSurveyMode(it) }
        logger.info(
            "План миссии {} очищен (keepManual={}): удалено сбросов={}, съёмок={}, сохранено ручных съёмок (MANUAL)={}",
            missionId, keepManual, drops.size, deletedSurveys.size, keptSurveys.size
        )
    }

    /**
     * Валидация миссии-черновика на конфликты/наложения включений.
     *
     * MVP-заглушка: проверяет существование миссии (404, если нет) и возвращает корректный контракт
     * с **пустым** списком конфликтов. Реальные правила (пересечения по времени, видимость цели, ЗРВ
     * станции, порядок сброса, бортовые ограничения) — TODO раздела 6 дизайн-дока. Контракт
     * ([MissionValidationResponse]/[ModeConflict]) стабилен, чтобы фронт не ломался при их появлении.
     */
    fun validateMission(missionId: UUID): MissionValidationResponse {
        if (!missionRepository.existsById(missionId)) {
            throw notFound(missionId)
        }
        return MissionValidationResponse(missionId = missionId, conflicts = emptyList())
    }

    /**
     * Строит контур полосы съёмки по параметрам видимости точки.
     * Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute].
     */
    private fun buildContour(req: SurveyRouteBuildRequestDTO): BuiltSurveyRoute {
        if (req.durationSeconds <= 0.0) {
            throw CustomErrorException("Длительность съёмки должна быть положительной")
        }

        val halfNanos = (req.durationSeconds / 2.0 * 1_000_000_000L).toLong()
        val fullNanos = (req.durationSeconds * 1_000_000_000L).toLong()
        val (tn, tk) = when (req.anchor) {
            SurveyRouteAnchor.CENTERED -> req.time.minusNanos(halfNanos) to req.time.plusNanos(halfNanos)
            SurveyRouteAnchor.FORWARD -> req.time to req.time.plusNanos(fullNanos)
        }

        val orbPoints = ballisticsService.exactTime(
            req.satelliteId,
            ExactTimePositionRequestDTO(time = tn, timeStop = tk, stepMs = 5000)
        ).toList()

        if (orbPoints.isEmpty()) {
            throw CustomErrorException(
                "Нет орбитальных точек для КА ${req.satelliteId} на интервале [$tn - $tk]"
            )
        }

        val stepper = RungeStepper(
            orbPoints.map { p ->
                OrbitalPoint(
                    t = fromDateTime(p.time),
                    vit = p.revolution.toInt(),
                    r = Vector3D(p.x, p.y, p.z),
                    v = Vector3D(p.vx, p.vy, p.vz)
                )
            }.toMutableList(),
            EarthType.PZ90d02
        )

        val wkt = contour(stepper, tn, tk, req.roll, req.captureAngle)
        val (lat, lon) = extractCenterLatLon(wkt)
        val revolution = if (req.revolution != 0L) req.revolution else orbPoints.first().revolution

        return BuiltSurveyRoute(
            satelliteId = req.satelliteId,
            timeStart = tn,
            durationSeconds = req.durationSeconds,
            revolution = revolution,
            roll = req.roll,
            captureAngle = req.captureAngle,
            lat = lat,
            longitude = lon,
            contourWkt = wkt
        )
    }

    /** Результат расчёта контура одного маршрута съёмки (до сохранения в БД). */
    private data class BuiltSurveyRoute(
        val satelliteId: Long,
        val timeStart: LocalDateTime,
        val durationSeconds: Double,
        val revolution: Long,
        val roll: Double,
        val captureAngle: Double,
        val lat: Double,
        val longitude: Double,
        val contourWkt: String
    )

    private fun BuiltSurveyRoute.toResponse(surveyModeId: Long?): SurveyRouteBuildResponseDTO =
        SurveyRouteBuildResponseDTO(
            satelliteId = satelliteId,
            timeStart = timeStart,
            duration = durationSeconds,
            revolution = revolution,
            roll = roll,
            captureAngle = captureAngle,
            lat = lat,
            longitude = longitude,
            contourWkt = contourWkt,
            surveyModeId = surveyModeId
        )


    private fun contour(
        stepper: AbstractStepper,
        tn: LocalDateTime,
        tk: LocalDateTime,
        roll: Double,
        capture: Double
    ): String {
        var t = tn
        val points = mutableListOf<OrbitalPoint>()
        while (t <= tk) {
            points.add(
                stepper.calculate(fromDateTime(t))
                    ?: throw CustomErrorException("Ошибка расчета выхода на заданное время")
            )
            t = t.plusSeconds(5)
        }
        val r = points.map {
            viewParams(it, roll + capture) ?: THBLPoint(
                0.0,
                0.0,
                0.0,
                0.0
            )
        }
        val l = points.map {
            viewParams(it, roll - capture) ?: THBLPoint(
                0.0,
                0.0,
                0.0,
                0.0
            )
        }
        val geom = mutableListOf<Coordinate>()
        for (rp in r)
            geom.add(Coordinate(rp.long * 180 / PI, rp.lat * 180 / PI, 0.0))
        for (lp in l.reversed())
            geom.add(Coordinate(lp.long * 180 / PI, lp.lat * 180 / PI, 0.0))
        if (!geom.isEmpty())
            geom.add(Coordinate(geom.first().x, geom.first().y))
        val geometryFactory = GeometryFactory()
        val shell = geometryFactory.createLinearRing(geom.toTypedArray())
        val polygon = geometryFactory.createPolygon(shell)
        val wktWriter = WKTWriter()
        return wktWriter.write(polygon)
    }


    private fun viewParams(pos: OrbitalPoint, gamma: Double): THBLPoint? {
        val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
        return calculator.pointOnEarth(pos, Orientation(0.0, gamma * PI / 180.0, 0.0))
    }


    @Transactional
    fun calculateDrops(missionId : UUID){

        logger.info("Расчет плана сбросов для миссии $missionId")

        val mission = missionRepository.findById(missionId).orElseThrow {
            notFound(missionId)
        }
        val satellite = satelliteCatalogClient.getSatellite(mission.satelliteId)
        val missionType = satelliteMissionTypeFactory.getBySatellite(satellite)
        logger.info(
            "Расчет плана сбросов для миссии {} выполняется по логике типа спутника: satelliteId={}, typeCode={}, missionType={}",
            missionId,
            mission.satelliteId,
            satellite.typeCode,
            missionType.typeCode
        )
        val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1)
            .orElseGet {
                planRepository.save(
                    PlanEntity(
                        mission = mission,
                        planStart = mission.missionStart,
                        planStop = mission.missionStop,
                        planNumber = 1
                    )
                )
            }

        val planId = plan.id ?: throw IllegalStateException("Plan id is null for mission $missionId")
        modeRepository.deleteDropModesByPlanId(planId)

        val surveys = surveyModeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId)

        logger.info("Для миссии $missionId загруженно ${surveys.size} съемок для расчета плана сбросов")

        if (surveys.isEmpty()) {
            return
        }

        val zones = ballisticsService.rva(mission.satelliteId,
            mission.missionStart?: throw CustomErrorException("Не задано время начала миссии"),
            mission.missionStop?: throw CustomErrorException("Не задано время конца миссии"))
            .sortedBy { it.onStart.time }
            .toList()
        if (zones.isEmpty()) {
            logger.warn("Для миссии $missionId нет ЗРВ")
            return
        }
        logger.info("Для миссии $missionId полученно ${zones.size} ЗРВ")

        val unassigned = surveys.toMutableList()
        zones.forEach { zone ->
            val zoneStart = zone.onStart.time
            val zoneDuration = Duration.between(zone.onStart.time, zone.onStop.time).toMillis().toDouble() / 1000.0
            if (zoneDuration <= 0.0) {
                return@forEach
            }

            val selectedSurveys = mutableListOf<SurveyModeEntity>()
            var usedDuration = 0.0

            val iterator = unassigned.iterator()
            while (iterator.hasNext()) {
                val survey = iterator.next()
                val surveyStart = survey.timeStart
                if (surveyStart == null) {
                    iterator.remove()
                    continue
                }

                val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
                if (!surveyEnd.isBefore(zoneStart)) {
                    continue
                }

                val dropDuration = missionType.calculateDropDuration(satellite, survey).coerceAtLeast(0.0)
                if (usedDuration + dropDuration > zoneDuration) {
                    continue
                }

                usedDuration += dropDuration
                selectedSurveys.add(survey)
                iterator.remove()
            }

            if (selectedSurveys.isEmpty()) {
                return@forEach
            }

            val dropMode = dropModeRepository.save(
                DropModeEntity(
                    plan = plan,
                    timeStart = zoneStart,
                    revolution = zone.revolution,
                    station = zone.stationId.toString(),
                    duration = usedDuration
                )
            )

            val links = selectedSurveys.map { survey ->
                DropModeSurveyEntity(
                    dropMode = dropMode,
                    surveyMode = survey
                )
            }
            dropModeSurveyRepository.saveAll(links)
        }
        logger.info("План сбросов для миссии $missionId расчитан")

    }

    fun calculateDropDuration(surv : SurveyModeEntity) : Double =
        satelliteMissionTypeFactory.defaultType().calculateDropDuration(
            satellite = space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO(),
            survey = surv
        )


    @Transactional
    fun confirmMission(missionId: UUID) : List<Long>{
        val mission = missionRepository.findById(missionId).orElseThrow {
            notFound(missionId)
        }

        mission.status = "CONFIRMED"
        missionRepository.save(mission)

        val surveyModeIds = surveyModeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId)
            .map { it.id?:-1 }
            .distinct()

        if (surveyModeIds.isEmpty()) {
            logger.info("Для миссии $missionId нет съемок")
            return listOf()
        }

        val bookedSlotIds = modeBookingRepository.findAllByMode_IdIn(surveyModeIds)
            .map { it.slotId }
            .filter { it > 0 }
            .distinct()

        if (bookedSlotIds.isEmpty()) {
            logger.info("Для миссии $missionId нет забронированных слотов для подтверждения")
            return listOf()
        }

        bookedSlotsEventPublisher.publish(
            bookedSlotIds = bookedSlotIds,
            status = BookedSlotStatus.PROCESSED,
            missionId = missionId,
            satelliteId = mission.satelliteId
        )
        logger.info("Для миссии $missionId опубликовано событие PROCESSED по {} слотам", bookedSlotIds.size)
        return bookedSlotIds
    }

    @Transactional
    fun updateSurveyModeStatus(modeId: Long, rawStatus: SurveyModeStatus) {

        val surveyMode = surveyModeRepository.findById(modeId).orElseThrow {
            ResponseStatusException(HttpStatus.NOT_FOUND, "Survey mode with id $modeId not found")
        }

        surveyMode.status = rawStatus
        surveyModeRepository.save(surveyMode)

        val bookedSlotIds = modeBookingRepository.findAllByMode_IdIn(listOf(modeId))
            .map { it.slotId }
            .filter { it > 0 }
            .distinct()

        if (bookedSlotIds.isEmpty()) {
            logger.info("Survey mode {} updated to {} without booked slots", modeId, rawStatus)
            return
        }

        bookedSlotsEventPublisher.publish(
            bookedSlotIds = bookedSlotIds,
            status = rawStatus.toBookedSlotStatus(),
            missionId = surveyMode.plan?.mission?.missionId,
            modeId = modeId,
            satelliteId = surveyMode.plan?.mission?.satelliteId
        )
        logger.info(
            "Survey mode {} updated to {}, published booked slot event for {} slots",
            modeId,
            rawStatus,
            bookedSlotIds.size
        )
    }

    @Transactional(readOnly = true)
    fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? {
        val searchStart = routePassport.intervalBegin.minusSeconds(1)
        val searchEnd = routePassport.intervalBegin.plusSeconds(1)
        val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
            satelliteNumber = routePassport.kaShort.trim(),
            revolution = routePassport.orbitNumber,
            timeStartFrom = searchStart,
            timeStartTo = searchEnd
        )

        return candidates.minByOrNull { candidate ->
            val candidateStart = candidate.timeStart ?: return@minByOrNull Long.MAX_VALUE
            abs(Duration.between(routePassport.intervalBegin, candidateStart).toMillis())
        }
    }

    private fun normalizeSurveyModeStatus(value: String): SurveyModeStatus =
        runCatching { SurveyModeStatus.valueOf(value.trim().uppercase()) }
            .getOrElse {
                throw ResponseStatusException(
                    HttpStatus.BAD_REQUEST,
                    "Unsupported survey mode status: $value"
                )
            }

    private fun SurveyModeStatus.toBookedSlotStatus(): BookedSlotStatus = when (this) {
        SurveyModeStatus.PROCESSED -> BookedSlotStatus.FINISHED
        SurveyModeStatus.CANCELED -> BookedSlotStatus.CANCELED
        SurveyModeStatus.FAILED -> BookedSlotStatus.FAILED
        SurveyModeStatus.PLANNED, SurveyModeStatus.DROPPED -> throw IllegalStateException("$this survey mode status is not publishable")
    }

}
