package space.nstart.pcp.slots_service.service

import ballistics.Ballistics
import ballistics.flightLine.PointOnEarthCalculator
import ballistics.orbitalPoints.timeStepper.AbstractStepper
import ballistics.orbitalPoints.timeStepper.RungeStepper
import ballistics.types.*
import ballistics.utils.astro.AstronomerJ2000
import ballistics.utils.earth.EarthPZ90d02
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import ballistics.utils.toDateTime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTReader
import org.locationtech.jts.io.WKTWriter
import org.postgresql.copy.CopyManager
import org.postgresql.core.BaseConnection
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import space.nstart.pcp.pcp_types_lib.dto.ballistics.DurationOnRevDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.IntervalDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO
import space.nstart.pcp.pcp_types_lib.dto.BookedSlotDetailsDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.*
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import space.nstart.pcp.slots_service.configuration.CustomValidationException
import space.nstart.pcp.slots_service.entity.BookedRequestEntity
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.model.ContinuousReqCoverSolver
import space.nstart.pcp.slots_service.model.Mar
import space.nstart.pcp.slots_service.model.OptimalReqCoverSolver
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import space.nstart.pcp.slots_service.model.satellite.PreparedCoverageSlot
import space.nstart.pcp.slots_service.repository.BatchCoverageSearchRequest
import space.nstart.pcp.slots_service.repository.BookedRequestRepository
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
import space.nstart.pcp.slots_service.repository.SlotRepository
import space.nstart.pcp.slots_service.util.LongitudeWrapGeometry
import java.io.StringReader
import java.time.Duration
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.sql.DataSource
import kotlin.jvm.optionals.getOrNull
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.roundToLong
import kotlin.system.measureTimeMillis


@Service
class SlotService {
    private data class CoverageSelectionResult(
        val slots: List<SlotDTO>,
        val sourceSlotIds: Int,
        val preparedSlotsBeforeOccupiedFilter: Int,
        val occupiedFilteredSlots: Int,
        val candidateSlotsBeforeSunFilter: Int,
        val sunFilteredSlots: Int,
        val solverInputSlots: Int,
        val solverOutputSlots: Int,
        val candidateSelectionDurationMs: Long,
        val solverDurationMs: Long,
        val totalDurationMs: Long
    )

    private data class SunFilterResult(
        val slots: List<SlotDTO>,
        val filteredCount: Int
    )

    private data class CoverageTargetContext(
        val targetId: Long,
        val normalizedWkt: String,
        val searchWkts: List<String>
    )

    private data class SatelliteCycleWindow(
        val satellite: AbstractSatellite,
        val cycleBegin: Long,
        val cycleEnd: Long
    )

    private data class SatelliteBaseWindow(
        val satelliteId: Long,
        val windowStart: LocalDateTime,
        val windowStop: LocalDateTime
    )

    private data class CoverageBatchContext(
        val selectedSatellites: List<AbstractSatellite>,
        val selectedSatellitesById: Map<Long, AbstractSatellite>,
        val bookedSlotsBySatelliteId: Map<Long, List<BookedSlotEntity>>,
        val rawSlotIdsByTargetIdAndSatelliteId: Map<Long, Map<Long, List<Long>>>,
        val preparedSlotsBySatelliteIdAndSourceSlotId: Map<Long, Map<Long, List<PreparedCoverageSlot>>>,
        val occupiedIntervalsBySatelliteId: Map<Long, List<OccupiedIntervalDTO>>,
        val totalUniqueRawSlots: Int,
        val totalPreparedSlots: Int
    )

    private data class IndexedCoverageBatchResult(
        val index: Int,
        val response: SlotCoverageBatchResponseDTO,
        val sourceSlotIds: Int,
        val preparedSlotsBeforeOccupiedFilter: Int,
        val occupiedFilteredSlots: Int,
        val candidateSlotsBeforeSunFilter: Int,
        val sunFilteredSlots: Int,
        val solverInputSlots: Int,
        val solverOutputSlots: Int,
        val candidateSelectionDurationMs: Long,
        val solverDurationMs: Long,
        val totalDurationMs: Long
    )

    private data class PreparedSlotsPrecomputeResult(
        val preparedSlotsBySatelliteIdAndSourceSlotId: Map<Long, Map<Long, List<PreparedCoverageSlot>>>,
        val totalPreparedSlots: Int
    )

    private data class SlotCalculationChunk(
        val index: Int,
        val durationSeconds: Long,
        val generationStart: LocalDateTime,
        val generationEndExclusive: LocalDateTime,
        val generationStepSeconds: Long,
        val sourceWindowStartSeconds: Double,
        val sourceWindowEndSeconds: Double
    )

    private data class PreparedOrbitalWindow(
        val start: LocalDateTime,
        val endInclusive: LocalDateTime,
        val points: List<OrbitalPoint>
    )

    private sealed interface SlotCalculationChunkMessage {
        val index: Int
    }

    private data class SlotCalculationChunkBatch(
        override val index: Int,
        val slots: List<Mar>
    ) : SlotCalculationChunkMessage

    private data class SlotCalculationChunkComplete(
        override val index: Int,
        val durationSeconds: Long,
        val generationStart: LocalDateTime,
        val generationEndExclusive: LocalDateTime,
        val slotCount: Int,
        val batchCount: Int,
        val computeDurationMs: Long,
        val orbitalWindowPrepareDurationMs: Long,
        val contourBuildDurationMs: Long,
        val orbitalWindowCount: Int,
        val contourCalculationCount: Int
    ) : SlotCalculationChunkMessage

    private data class SlotCalculationPendingChunk(
        val batches: MutableList<List<Mar>> = mutableListOf(),
        var completion: SlotCalculationChunkComplete? = null
    )

    private data class SlotCalculationWriteSummary(
        val totalSlots: Int,
        val flushCount: Int,
        val writeDurationMs: Long,
        val totalComputeDurationMs: Long,
        val totalOrbitalWindowPrepareDurationMs: Long,
        val totalContourBuildDurationMs: Long,
        val orbitalWindowCount: Int,
        val contourCalculationCount: Int,
        val streamedBatchCount: Int,
        val maxPendingBatches: Int,
        val heaviestChunks: List<SlotCalculationChunkComplete>
    )

    companion object {
        private const val GEOMETRY_INTERSECTION_AREA_EPSILON = 1e-8
        private const val GEOMETRY_DISTANCE_EPSILON = 1e-6
        private const val MERGE_MAX_TIME_GAP_SECONDS = 1L
        private const val ROLL_EPSILON = 0.01
        private const val DEFAULT_BATCH_PARALLELISM = 4
        private const val DEFAULT_BATCH_SPATIAL_FETCH_CHUNK_SIZE = 256
        private const val DEFAULT_SLOT_CALC_PARALLELISM = 4
        private const val DEFAULT_SLOT_CALC_CHUNK_DURATION_SECONDS = 86_400L
        private const val DEFAULT_SLOT_CALC_FLUSH_THRESHOLD = 20_000
        private const val DEFAULT_SLOT_CALC_STREAM_BATCH_SIZE = 2_000
        private const val DEFAULT_SLOT_CALC_SOURCE_MARGIN_SECONDS = 120L
        private const val CONTOUR_SAMPLE_STEP_SECONDS = 5L
    }

    private val slotCalculationDispatcher by lazy {
        Dispatchers.Default.limitedParallelism(slotCalcParallelism.coerceAtLeast(1))
    }
    private val astronomer = AstronomerJ2000(EarthType.PZ90d02)

    private val logger: Logger = LoggerFactory.getLogger(this::class.java)


    @Autowired
    private lateinit var dataSource: DataSource

    @Value("\${pcp.slot-service.batch.parallelism:$DEFAULT_BATCH_PARALLELISM}")
    private var batchParallelism: Int = DEFAULT_BATCH_PARALLELISM

    @Value("\${pcp.slot-service.batch.spatial-fetch-chunk-size:$DEFAULT_BATCH_SPATIAL_FETCH_CHUNK_SIZE}")
    private var batchSpatialFetchChunkSize: Int = DEFAULT_BATCH_SPATIAL_FETCH_CHUNK_SIZE

    @Value("\${pcp.slot-service.calc.parallelism:$DEFAULT_SLOT_CALC_PARALLELISM}")
    private var slotCalcParallelism: Int = DEFAULT_SLOT_CALC_PARALLELISM

    @Value("\${pcp.slot-service.calc.chunk-duration-seconds:$DEFAULT_SLOT_CALC_CHUNK_DURATION_SECONDS}")
    private var slotCalcChunkDurationSeconds: Long = DEFAULT_SLOT_CALC_CHUNK_DURATION_SECONDS

    @Value("\${pcp.slot-service.calc.flush-threshold:$DEFAULT_SLOT_CALC_FLUSH_THRESHOLD}")
    private var slotCalcFlushThreshold: Int = DEFAULT_SLOT_CALC_FLUSH_THRESHOLD

    @Value("\${pcp.slot-service.calc.stream-batch-size:$DEFAULT_SLOT_CALC_STREAM_BATCH_SIZE}")
    private var slotCalcStreamBatchSize: Int = DEFAULT_SLOT_CALC_STREAM_BATCH_SIZE

    @Value("\${pcp.slot-service.calc.source-margin-seconds:$DEFAULT_SLOT_CALC_SOURCE_MARGIN_SECONDS}")
    private var slotCalcSourceMarginSeconds: Long = DEFAULT_SLOT_CALC_SOURCE_MARGIN_SECONDS

    private val copyTimestampFormatter: DateTimeFormatter =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS")


    @Autowired
    private lateinit var slotRepository: SlotRepository

    @Autowired
    private lateinit var jdbcTemplate: JdbcTemplate

    @Autowired
    private lateinit var requestRepository: BookedRequestRepository

    @Autowired
    private lateinit var bookedSlotsRepository: BookedSlotsRepository

    @Autowired
    private lateinit var bookedSlotStatusService: BookedSlotStatusUpdater

    @Autowired
    private lateinit var earthService: EarthService

    @Autowired
    private lateinit var satelliteCatalogClient: SatelliteCatalogClient

    @Autowired
    private lateinit var satelliteIcService: SatelliteIcService

    @Autowired
    private lateinit var satelliteIcKafkaPublisher: SatelliteIcKafkaPublisher



    fun rvaCommon(id : Long, duration : Long, stations : List<StationDTO>): List<RadioVisibilityAreaDTO>{
        logger.info("Расчет ЗРВ для КА $id на $duration суток для ${stations.size} станций")
        val ic = initialConditionsBySatelliteId(id)
        val bal = Ballistics()
        bal.modDVType = ModDVType.KONDOR_PROGNOZ
        var r = bal.calculateOrbPoints(ic, ic.point.t, ic.point.t + duration * 86400)
        if (r != BallisticsError.OK)
            throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r")

        val ppis = stations.map { station ->
            PPI(
                station.number, 1, station.position.lat * PI / 180, station.position.long * PI / 180,
                station.position.height, station.elevationMin * PI / 180, station.elevationMax * PI / 180
            )
        }
        r = bal.calculateZRV(
            ppis,ic.point.t, ic.point.t + duration * 86400
        )
        if (r != BallisticsError.OK)
            throw CustomErrorException("Ошибка расчета ЗРВ: $r")
        return bal.zrv.map { mkRVA(it, id) }.sortedBy { it.onStart.time }
    }



    fun rvaMerge(id : Long, duration : Long, stations : List<StationDTO>): List<IntervalDTO>{
        val rva = rvaCommon(id, duration, stations)
        val intervals = mutableListOf<IntervalDTO>()

        if (rva.isEmpty()) {
            return intervals
        }

        var currentBegin = rva.first().onStart.time
        var currentEnd = rva.first().onStop.time

        for (index in 1 until rva.size) {
            val next = rva[index]
            val nextBegin = next.onStart.time
            val nextEnd = next.onStop.time

            if (nextBegin <= currentEnd) {
                if (nextEnd > currentEnd) {
                    currentEnd = nextEnd
                }
                continue
            }

            intervals.add(
                IntervalDTO(
                    begin = currentBegin,
                    end = currentEnd,
                    durationSec = Duration.between(currentBegin, currentEnd).toMillis() / 1000.0
                )
            )
            currentBegin = nextBegin
            currentEnd = nextEnd
        }

        intervals.add(
            IntervalDTO(
                begin = currentBegin,
                end = currentEnd,
                durationSec = Duration.between(currentBegin, currentEnd).toMillis() / 1000.0
            )
        )

        return intervals
    }


    fun rvaMergeOnRev(id : Long, duration : Long, stations : List<StationDTO>): List<DurationOnRevDTO>{
        val rva = rvaCommon(id, duration, stations)
        val intervals = mutableListOf<DurationOnRevDTO>()

        if (rva.isEmpty()) {
            return intervals
        }

        rva.groupBy { it.revolution }
            .toSortedMap()
            .forEach { (revolution, items) ->
                val sortedItems = items.sortedBy { it.onStart.time }
                var currentBegin = sortedItems.first().onStart.time
                var currentEnd = sortedItems.first().onStop.time
                var mergedDurationSec = 0.0

                for (index in 1 until sortedItems.size) {
                    val next = sortedItems[index]
                    val nextBegin = next.onStart.time
                    val nextEnd = next.onStop.time

                    if (nextBegin <= currentEnd) {
                        if (nextEnd > currentEnd) {
                            currentEnd = nextEnd
                        }
                        continue
                    }

                    mergedDurationSec += Duration.between(currentBegin, currentEnd).toMillis() / 1000.0
                    currentBegin = nextBegin
                    currentEnd = nextEnd
                }

                mergedDurationSec += Duration.between(currentBegin, currentEnd).toMillis() / 1000.0
                intervals.add(DurationOnRevDTO(revolution = revolution, durationSec = mergedDurationSec))
            }

        return intervals
    }


    fun rva(id : Long, duration : Long, station : StationDTO) : List<RadioVisibilityAreaDTO> {

        logger.info("Расчет ЗРВ для КА $id на $duration суток")

        val sat = satelliteById(id)
        val ic = initialConditionsBySatelliteId(id)
        val bal = Ballistics()
//        val capture = 1.5
        val durations = listOf<Long>(10)
        bal.modDVType = ModDVType.KONDOR_PROGNOZ
        var r = bal.calculateOrbPoints(ic, ic.point.t, ic.point.t + duration * 86400)
        if (r != BallisticsError.OK)
            throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r")
        r = bal.calculateZRV(
            listOf(
                PPI(
                    station.number, 1, station.position.lat * PI / 180, station.position.long * PI / 180,
                    station.position.height, station.elevationMin * PI / 180, station.elevationMax * PI / 180
                )
            ),
            ic.point.t, ic.point.t + duration * 86400
        )
        if (r != BallisticsError.OK)
            throw CustomErrorException("Ошибка расчета ЗРВ: $r")
        return bal.zrv.map { mkRVA(it, id) }
    }

    fun publishInitialConditions(satelliteId: Long, t : LocalDateTime, onASC : Boolean) {
        satelliteById(satelliteId)
        val initialConditions = initialConditionsBySatelliteId(satelliteId)

        val tInt = fromDateTime(t)

        if (tInt < initialConditions.point.t)
            throw CustomValidationException("Время запроса не должно быть меньше времени прогнозной орбиты (${toDateTime(initialConditions.point.t)})")

        val bal = Ballistics()
        bal.modDVType = ModDVType.KONDOR_PROGNOZ
        val r = bal.calculateOrbPoints(initialConditions, initialConditions.point.t, tInt+3 * 60 * 60)
        if (r != BallisticsError.OK)
            throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r")

        val p = bal.points.filter { it.t > tInt }.minByOrNull { it.t } ?:
            throw CustomErrorException("Не найдена точка массива ВУЗов")

        satelliteIcKafkaPublisher.publish(
            SatelliteICDTO(
                satelliteId = satelliteId,
                ic = InitialConditions(
                    point = p,
                    sBall = initialConditions.sBall,
                    f81 = initialConditions.f81,
                ).toDto()
            )
        )
    }

    fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO(
        toDateTime(p.t),
        p.elevation * 180 / PI,
        p.azimuth * 180 / PI,
        p.range
    )

    fun mkRVA(p : ZRV, id : Long) = RadioVisibilityAreaDTO(
        noradId = id,
        stationId = p.ppi.toLong(),
        revolution = p.vit.toLong(),
        onStart = mkTP(p.zoneIn),
        onMaximum = mkTP(p.zoneMax),
        onStop = mkTP(p.zoneOut),
        duration = (p.zoneOut.t - p.zoneIn.t)
    )

    fun init(sats : List<Long>, slotDuration: Long, cycleDuration : Long, revolutionDuration : Long, recover : Boolean? ) {
        for (k in sats) {
            logger.info("запуск для $k")
            CoroutineScope(slotCalculationDispatcher).launch {
                satelliteById(k)

                logger.info("старт для $k")
                val ic = initialConditionsBySatelliteId(k)
                calcSlots(k, ic, slotDuration, cycleDuration, revolutionDuration, true)
            }
        }
    }

    private suspend fun calcSlots(id: Long, ic: InitialConditions, slotDuration: Long, cycleDuration : Long, revolutionDuration : Long, recov: Boolean) {
        val sat = satelliteById(id)
        val bal = Ballistics()
        bal.modDVType = ModDVType.KONDOR_PROGNOZ

        sat.durationCalc = cycleDuration
        sat.cycleRevs = revolutionDuration
        sat.tnCalc = toDateTime(ic.point.t)
        sat.slotDuration = slotDuration
        updateSlotsProfile(sat)
        val durations = listOf(sat.slotDuration)


        var r = bal.calculateOrbPoints(ic, ic.point.t, ic.point.t + sat.durationCalc * 86400 + 3 * 60  * 60)
        if (r != BallisticsError.OK)
            throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r")
        r = bal.calculateFlightLine(ic.point.t, ic.point.t + sat.durationCalc * 86400 + 3 * 60  * 60)
        if (r != BallisticsError.OK)
            throw CustomErrorException("Ошибка расчета прогноза программной орбиты: $r")
        val rev = if (sat.cycleRevs.toInt() < bal.revolutions.toList().size) sat.cycleRevs.toInt() else bal.revolutions.toList().size - 1
        val tEnd = toDateTime(bal.revolutions.toList()[rev].vuz.t)
        val sourcePoints = bal.points.toList()
        if (sourcePoints.isEmpty()) {
            throw CustomErrorException("Не созданы опорные точки орбиты для расчета слотов")
        }

        val calculationStart = toDateTime(bal.revolutions.first().vuz.t)
        val sourcePointStepSeconds = resolveSourcePointStepSeconds(sourcePoints)
        val allChunks = durations.flatMap { duration ->
            buildSlotCalculationChunks(
                calculationStart = calculationStart,
                calculationEndExclusive = tEnd,
                durationSeconds = duration,
                sourcePointStepSeconds = sourcePointStepSeconds
            )
        }.mapIndexed { index, chunk ->
            chunk.copy(index = index)
        }

        logger.info(
            "calcSlots start: satelliteId={}, chunks={}, durations={}, calcParallelism={}, chunkDurationSeconds={}, flushThreshold={}, streamBatchSize={}, sourcePointStepSeconds={}, sourceMarginSeconds={}, recov={}",
            id,
            allChunks.size,
            durations,
            slotCalcParallelism.coerceAtLeast(1),
            slotCalcChunkDurationSeconds.coerceAtLeast(1),
            slotCalcFlushThreshold.coerceAtLeast(1),
            slotCalcStreamBatchSize.coerceAtLeast(1),
            sourcePointStepSeconds,
            slotCalcSourceMarginSeconds.coerceAtLeast(0),
            recov
        )

        lateinit var writeSummary: SlotCalculationWriteSummary
        val totalMs = measureTimeMillis {
            coroutineScope {
                val resultsChannel = Channel<SlotCalculationChunkMessage>(slotCalcParallelism.coerceAtLeast(1) * 2)
                val writer = async {
                    writeCalculatedSlots(
                        satelliteId = id,
                        results = resultsChannel,
                        totalChunks = allChunks.size,
                        coveringType = 0
                    )
                }

                val producers = allChunks.map { chunk ->
                    async(slotCalculationDispatcher) {
                        computeSlotCalculationChunk(
                            satellite = sat,
                            satelliteId = id,
                            chunk = chunk,
                            sourcePoints = sourcePoints,
                            results = resultsChannel
                        )
                    }
                }

                val producerResult = runCatching { producers.awaitAll() }
                if (producerResult.isSuccess) {
                    resultsChannel.close()
                } else {
                    resultsChannel.close(producerResult.exceptionOrNull())
                }

                writeSummary = try {
                    writer.await()
                } catch (writerError: Throwable) {
                    producerResult.exceptionOrNull()?.let { throw it }
                    throw writerError
                }
                producerResult.getOrThrow()
            }
        }
        logger.info(
            "calcSlots finish: satelliteId={}, chunks={}, slots={}, flushes={}, streamedBatches={}, maxPendingBatches={}, orbitalWindows={}, contourCalculations={}, orbitalWindowPrepareMs={}, contourBuildMs={}, computeCpuTotalMs={}, writeDurationMs={}, totalDurationMs={}, heaviestChunks={}",
            id,
            allChunks.size,
            writeSummary.totalSlots,
            writeSummary.flushCount,
            writeSummary.streamedBatchCount,
            writeSummary.maxPendingBatches,
            writeSummary.orbitalWindowCount,
            writeSummary.contourCalculationCount,
            writeSummary.totalOrbitalWindowPrepareDurationMs,
            writeSummary.totalContourBuildDurationMs,
            writeSummary.totalComputeDurationMs,
            writeSummary.writeDurationMs,
            totalMs,
            writeSummary.heaviestChunks.joinToString(separator = ",") {
                "${it.index}:${it.slotCount}:${it.computeDurationMs}:${it.batchCount}"
            }.ifBlank { "none" }
        )
    }

    private fun batchInsert(
        copyManager: CopyManager,
        slots: List<Mar>,
        startSlotNum: Long,
        coveringType: Int
    ): Long {
        if (slots.isEmpty()) return startSlotNum

        val copySql = """
        COPY slot (
            satellite_id,
            covering_type,
            slot_num,
            cycle,
            tn,
            tk,
            roll,
            contour_wkt,
            revolution,
            revolution_sign,
            latitude,
            longitude
        )
        FROM STDIN WITH (
            FORMAT text,
            DELIMITER E'\t'
        )
    """.trimIndent()

        val payload = buildCopyPayload(slots, startSlotNum, coveringType)

        StringReader(payload).use { reader ->
            copyManager.copyIn(copySql, reader)
        }

        return startSlotNum + slots.size
    }

    private suspend fun writeCalculatedSlots(
        satelliteId: Long,
        results: ReceiveChannel<SlotCalculationChunkMessage>,
        totalChunks: Int,
        coveringType: Int
    ): SlotCalculationWriteSummary = withContext(Dispatchers.IO) {
        dataSource.connection.use { connection ->
            val previousAutoCommit = connection.autoCommit
            connection.autoCommit = false
            try {
                deleteSlotsBySatellite(connection, satelliteId)
                val copyManager = CopyManager(connection.unwrap(BaseConnection::class.java))
                val pending = linkedMapOf<Int, SlotCalculationPendingChunk>()
                val buffer = ArrayList<Mar>(slotCalcFlushThreshold.coerceAtLeast(1))
                var nextChunkIndex = 0
                var nextSlotNum = 1L
                var flushCount = 0
                var totalSlots = 0
                var totalComputeDurationMs = 0L
                var totalOrbitalWindowPrepareDurationMs = 0L
                var totalContourBuildDurationMs = 0L
                var orbitalWindowCount = 0
                var contourCalculationCount = 0
                var streamedBatchCount = 0
                var pendingBatchCount = 0
                var maxPendingBatches = 0
                val heaviestChunks = mutableListOf<SlotCalculationChunkComplete>()

                val writeDurationMs = measureTimeMillis {
                    for (message in results) {
                        val pendingChunk = pending.getOrPut(message.index) { SlotCalculationPendingChunk() }
                        when (message) {
                            is SlotCalculationChunkBatch -> {
                                if (pendingChunk.completion != null) {
                                    throw CustomErrorException("Получен batch после completion для чанка ${message.index}")
                                }
                                pendingChunk.batches += message.slots
                                pendingBatchCount++
                                maxPendingBatches = maxOf(maxPendingBatches, pendingBatchCount)
                            }
                            is SlotCalculationChunkComplete -> {
                                if (pendingChunk.completion != null) {
                                    throw CustomErrorException("Duplicate completion для чанка ${message.index}")
                                }
                                pendingChunk.completion = message
                                totalSlots += message.slotCount
                                streamedBatchCount += message.batchCount
                                totalComputeDurationMs += message.computeDurationMs
                                totalOrbitalWindowPrepareDurationMs += message.orbitalWindowPrepareDurationMs
                                totalContourBuildDurationMs += message.contourBuildDurationMs
                                orbitalWindowCount += message.orbitalWindowCount
                                contourCalculationCount += message.contourCalculationCount
                                trackHeaviestChunks(heaviestChunks, message)
                            }
                        }

                        val (readyChunks, newNextChunkIndex) = drainReadyChunkResults(pending, nextChunkIndex)
                        readyChunks.forEach { readyChunk ->
                            val completion = readyChunk.completion
                                ?: throw CustomErrorException("Чанк без completion готов к записи: index=${nextChunkIndex}")
                            logger.debug(
                                "calcSlots stage=chunk-compute, satelliteId={}, chunkIndex={}, durationSeconds={}, slots={}, chunkBatches={}, orbitalWindows={}, contourCalculations={}, windowPrepareDurationMs={}, contourBuildDurationMs={}, computeDurationMs={}, generationStart={}, generationEndExclusive={}",
                                satelliteId,
                                completion.index,
                                completion.durationSeconds,
                                completion.slotCount,
                                completion.batchCount,
                                completion.orbitalWindowCount,
                                completion.contourCalculationCount,
                                completion.orbitalWindowPrepareDurationMs,
                                completion.contourBuildDurationMs,
                                completion.computeDurationMs,
                                completion.generationStart,
                                completion.generationEndExclusive
                            )
                            readyChunk.batches.forEach { chunkBatch ->
                                pendingBatchCount--
                                buffer.addAll(chunkBatch)
                                while (buffer.size >= slotCalcFlushThreshold.coerceAtLeast(1)) {
                                    val batch = ArrayList(buffer.subList(0, slotCalcFlushThreshold.coerceAtLeast(1)))
                                    nextSlotNum = batchInsert(copyManager, batch, nextSlotNum, coveringType)
                                    buffer.subList(0, batch.size).clear()
                                    flushCount++
                                }
                            }
                        }
                        nextChunkIndex = newNextChunkIndex
                    }

                    if (nextChunkIndex != totalChunks) {
                        throw CustomErrorException("Не все чанки расчета слотов были обработаны: processed=$nextChunkIndex, total=$totalChunks")
                    }
                    if (pending.isNotEmpty()) {
                        throw CustomErrorException("Остались необработанные чанки расчета слотов: ${pending.keys.sorted()}")
                    }
                    if (pendingBatchCount != 0) {
                        throw CustomErrorException("В pending очереди остались необработанные batch-и расчета слотов: $pendingBatchCount")
                    }
                    if (buffer.isNotEmpty()) {
                        nextSlotNum = batchInsert(copyManager, buffer.toList(), nextSlotNum, coveringType)
                        buffer.clear()
                        flushCount++
                    }
                }

                connection.commit()
                SlotCalculationWriteSummary(
                    totalSlots = totalSlots,
                    flushCount = flushCount,
                    writeDurationMs = writeDurationMs,
                    totalComputeDurationMs = totalComputeDurationMs,
                    totalOrbitalWindowPrepareDurationMs = totalOrbitalWindowPrepareDurationMs,
                    totalContourBuildDurationMs = totalContourBuildDurationMs,
                    orbitalWindowCount = orbitalWindowCount,
                    contourCalculationCount = contourCalculationCount,
                    streamedBatchCount = streamedBatchCount,
                    maxPendingBatches = maxPendingBatches,
                    heaviestChunks = heaviestChunks.sortedByDescending { it.computeDurationMs }
                )
            } catch (e: Exception) {
                connection.rollback()
                throw e
            } finally {
                connection.autoCommit = previousAutoCommit
            }
        }
    }

    private fun deleteSlotsBySatellite(connection: java.sql.Connection, satelliteId: Long) {
        connection.prepareStatement("DELETE FROM slot WHERE satellite_id = ?").use { statement ->
            statement.setLong(1, satelliteId)
            statement.executeUpdate()
        }
    }

    private fun trackHeaviestChunks(
        heaviestChunks: MutableList<SlotCalculationChunkComplete>,
        result: SlotCalculationChunkComplete
    ) {
        heaviestChunks += result
        heaviestChunks.sortByDescending { it.computeDurationMs }
        if (heaviestChunks.size > 3) {
            heaviestChunks.removeAt(heaviestChunks.lastIndex)
        }
    }

    private fun drainReadyChunkResults(
        pending: MutableMap<Int, SlotCalculationPendingChunk>,
        nextChunkIndex: Int
    ): Pair<List<SlotCalculationPendingChunk>, Int> {
        val readyChunks = mutableListOf<SlotCalculationPendingChunk>()
        var currentChunkIndex = nextChunkIndex
        while (true) {
            val readyChunk = pending[currentChunkIndex] ?: break
            if (readyChunk.completion == null) {
                break
            }
            pending.remove(currentChunkIndex)
            readyChunks += readyChunk
            currentChunkIndex++
        }
        return readyChunks to currentChunkIndex
    }

    private fun buildSlotCalculationChunks(
        calculationStart: LocalDateTime,
        calculationEndExclusive: LocalDateTime,
        durationSeconds: Long,
        sourcePointStepSeconds: Long
    ): List<SlotCalculationChunk> {
        if (calculationStart >= calculationEndExclusive) {
            return emptyList()
        }

        val generationStepSeconds = (durationSeconds / 2).coerceAtLeast(1)
        val chunkSpanSeconds = slotCalcChunkDurationSeconds
            .coerceAtLeast(generationStepSeconds)
            .let { configuredSeconds ->
                val stepsPerChunk = (configuredSeconds / generationStepSeconds).coerceAtLeast(1)
                stepsPerChunk * generationStepSeconds
            }
        val sourceMarginSeconds = maxOf(
            slotCalcSourceMarginSeconds.coerceAtLeast(0),
            durationSeconds + CONTOUR_SAMPLE_STEP_SECONDS,
            sourcePointStepSeconds * 2
        )

        val chunks = mutableListOf<SlotCalculationChunk>()
        var chunkStart = calculationStart
        var chunkIndex = 0
        while (chunkStart < calculationEndExclusive) {
            val chunkEndExclusive = minOf(chunkStart.plusSeconds(chunkSpanSeconds), calculationEndExclusive)
            chunks += SlotCalculationChunk(
                index = chunkIndex++,
                durationSeconds = durationSeconds,
                generationStart = chunkStart,
                generationEndExclusive = chunkEndExclusive,
                generationStepSeconds = generationStepSeconds,
                sourceWindowStartSeconds = fromDateTime(chunkStart.minusSeconds(sourceMarginSeconds)),
                sourceWindowEndSeconds = fromDateTime(chunkEndExclusive.plusSeconds(durationSeconds + sourceMarginSeconds))
            )
            chunkStart = chunkEndExclusive
        }
        return chunks
    }

    private fun resolveSourcePointStepSeconds(sourcePoints: List<OrbitalPoint>): Long =
        sourcePoints
            .zipWithNext()
            .mapNotNull { (left, right) ->
                val delta = (right.t - left.t).roundToLong()
                delta.takeIf { it > 0 }
            }
            .minOrNull()
            ?.coerceAtLeast(1)
            ?: 60L

    private suspend fun computeSlotCalculationChunk(
        satellite: AbstractSatellite,
        satelliteId: Long,
        chunk: SlotCalculationChunk,
        sourcePoints: List<OrbitalPoint>,
        results: SendChannel<SlotCalculationChunkMessage>
    ) {
        val filteredPoints = sourcePoints.filter { point ->
            point.t >= chunk.sourceWindowStartSeconds && point.t <= chunk.sourceWindowEndSeconds
        }
        if (filteredPoints.isEmpty()) {
            throw CustomErrorException(
                "Не найдены опорные точки орбиты для чанка расчета слотов: satelliteId=$satelliteId, chunkIndex=${chunk.index}"
            )
        }

        val pz = EarthPZ90d02()
        val stepper = RungeStepper(filteredPoints.toMutableList(), EarthType.PZ90d02)
        val pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
        val geometryFactory = GeometryFactory()
        val wktWriter = WKTWriter()
        val producerBatchSize = slotCalcStreamBatchSize.coerceAtLeast(1)
        val batchBuffer = ArrayList<Mar>(producerBatchSize)
        var emittedSlotCount = 0
        var emittedBatchCount = 0
        var orbitalWindowPrepareDurationMs = 0L
        var contourBuildDurationMs = 0L
        var orbitalWindowCount = 0
        var contourCalculationCount = 0
        val computeDurationMs = measureTimeMillis {
            var tn = chunk.generationStart
            while (tn < chunk.generationEndExclusive) {
                val tk = tn.plusSeconds(chunk.durationSeconds)
                val op = stepper.calculate(fromDateTime(tn))
                    ?: throw CustomErrorException("Ошибка расчета выхода на заданное время для чанка ${chunk.index}")
                val pointOnSurface = pz.xyz2blh(op.r)
                if (abs(pointOnSurface.lat * 180 / PI) < 85) {
                    lateinit var preparedWindow: PreparedOrbitalWindow
                    orbitalWindowPrepareDurationMs += measureTimeMillis {
                        preparedWindow = prepareOrbitalWindow(stepper, tn, tk, op)
                    }
                    orbitalWindowCount++

                    var angleIndex = 0
                    while (angleIndex < satellite.angles.size) {
                        val gammaMin = satellite.angles[angleIndex]
                        val gammaMax = satellite.angles[angleIndex + 1]
                        val gamma = (gammaMax + gammaMin) / 2
                        val capture = abs(gammaMax - gammaMin) / 2

                        var contour = ""
                        contourBuildDurationMs += measureTimeMillis {
                            contour = contour(
                                window = preparedWindow,
                                roll = gamma,
                                capture = capture,
                                pointOnEarthCalculator = pointOnEarthCalculator,
                                geometryFactory = geometryFactory,
                                wktWriter = wktWriter
                            )
                        }
                        contourCalculationCount++

                        if (contour.isNotEmpty()) {
                            val normalizedContour = LongitudeWrapGeometry.normalizeToContinuous360Wkt(contour)
                            if (!normalizedContour.contains("MULTI")) {
                                val viewPoint = viewParams(pointOnEarthCalculator, op, gamma)
                                batchBuffer += Mar(
                                    sat = satelliteId,
                                    tn = tn,
                                    tk = tk,
                                    roll = gamma,
                                    contour = normalizedContour,
                                    poly = null,
                                    vit = op.vit.toLong(),
                                    revolutionSign = if (op.v.z > 0) RevolutionSign.ASC else RevolutionSign.DESC,
                                    cycle = 0,
                                    slotNum = 0,
                                    latitude = viewPoint!!.lat * 180 / PI,
                                    longitude = viewPoint.long * 180 / PI
                                )
                                if (batchBuffer.size >= producerBatchSize) {
                                    emittedSlotCount += emitSlotCalculationChunkBatch(
                                        chunkIndex = chunk.index,
                                        slots = batchBuffer,
                                        results = results
                                    )
                                    emittedBatchCount++
                                }
                            } else {
                                logger.warn("{} : {}", satelliteId, normalizedContour)
                            }
                        }
                        angleIndex += 2
                    }
                }
                tn = tn.plusSeconds(chunk.generationStepSeconds)
            }
        }
        if (batchBuffer.isNotEmpty()) {
            emittedSlotCount += emitSlotCalculationChunkBatch(
                chunkIndex = chunk.index,
                slots = batchBuffer,
                results = results
            )
            emittedBatchCount++
        }
        results.send(
            SlotCalculationChunkComplete(
                index = chunk.index,
                durationSeconds = chunk.durationSeconds,
                generationStart = chunk.generationStart,
                generationEndExclusive = chunk.generationEndExclusive,
                slotCount = emittedSlotCount,
                batchCount = emittedBatchCount,
                computeDurationMs = computeDurationMs,
                orbitalWindowPrepareDurationMs = orbitalWindowPrepareDurationMs,
                contourBuildDurationMs = contourBuildDurationMs,
                orbitalWindowCount = orbitalWindowCount,
                contourCalculationCount = contourCalculationCount
            )
        )
    }


    private fun buildCopyPayload(
        slots: List<Mar>,
        startSlotNum: Long,
        coveringType: Int
    ): String {
        val sb = StringBuilder(slots.size * 350)

        slots.forEachIndexed { index, slot ->
            sb.append(slot.sat).append('\t')
            sb.append(coveringType).append('\t')
            sb.append(startSlotNum + index).append('\t')
            sb.append(1).append('\t')
            sb.append(formatCopyTimestamp(slot.tn)).append('\t')
            sb.append(formatCopyTimestamp(slot.tk)).append('\t')
            sb.append(slot.roll).append('\t')
            sb.append(escapeCopyText(slot.contour)).append('\t')
            sb.append(slot.vit).append('\t')
            sb.append(slot.revolutionSign.toString()).append('\t')
            sb.append(slot.latitude).append('\t')
            sb.append(slot.longitude).append('\n')
        }

        return sb.toString()
    }

    private fun formatCopyTimestamp(value: LocalDateTime): String =
        value.format(copyTimestampFormatter)

    private suspend fun emitSlotCalculationChunkBatch(
        chunkIndex: Int,
        slots: MutableList<Mar>,
        results: SendChannel<SlotCalculationChunkMessage>
    ): Int {
        if (slots.isEmpty()) {
            return 0
        }
        val batch = ArrayList(slots)
        slots.clear()
        results.send(
            SlotCalculationChunkBatch(
                index = chunkIndex,
                slots = batch
            )
        )
        return batch.size
    }

    private fun escapeCopyText(value: String): String {
        val sb = StringBuilder(value.length + 16)
        value.forEach { ch ->
            when (ch) {
                '\\' -> sb.append("\\\\")
                '\t' -> sb.append("\\t")
                '\n' -> sb.append("\\n")
                '\r' -> sb.append("\\r")
                else -> sb.append(ch)
            }
        }
        return sb.toString()
    }


//    private fun batchInsert(slots: List<Mar>, startSlotNum: Long, coveringType: Int): Long {
//        var currentSlotNum = startSlotNum
//
//        var offset = 0
//        while (offset < slots.size) {
//            val end = minOf(offset + SLOT_INSERT_BATCH_SIZE, slots.size)
//            val chunk = slots.subList(offset, end)
//            jdbcTemplate.batchUpdate(
//                """
//                INSERT INTO slot (
//                    satellite_id,
//                    covering_type,
//                    slot_num,
//                    cycle,
//                    tn,
//                    tk,
//                    roll,
//                    contour_wkt,
//                    revolution,
//                    revolution_sign,
//                    latitude,
//                    longitude
//                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
//                """.trimIndent(),
//                object : BatchPreparedStatementSetter {
//                    override fun setValues(ps: java.sql.PreparedStatement, i: Int) {
//                        val slot = chunk[i]
//                        ps.setLong(1, slot.sat)
//                        ps.setInt(2, coveringType)
//                        ps.setLong(3, currentSlotNum + i)
//                        ps.setLong(4, 1)
//                        ps.setTimestamp(5, Timestamp.valueOf(slot.tn))
//                        ps.setTimestamp(6, Timestamp.valueOf(slot.tk))
//                        ps.setDouble(7, slot.roll)
//                        ps.setString(8, slot.contour)
//                        ps.setLong(9, slot.vit)
//                        ps.setString(10, slot.revolutionSign.toString())
//                        ps.setDouble(11, slot.latitude)
//                        ps.setDouble(12, slot.longitude)
//                    }
//                    override fun getBatchSize(): Int = chunk.size
//                }
//            )
//            currentSlotNum += chunk.size
//            offset = end
//        }
//
//        return currentSlotNum
//    }

    private fun prepareOrbitalWindow(
        stepper: AbstractStepper,
        tn: LocalDateTime,
        tk: LocalDateTime,
        initialPoint: OrbitalPoint? = null
    ): PreparedOrbitalWindow {
        val durationSeconds = Duration.between(tn, tk).seconds.coerceAtLeast(0)
        val expectedPointCount = ((durationSeconds / CONTOUR_SAMPLE_STEP_SECONDS) + 1).toInt()
        val points = ArrayList<OrbitalPoint>(expectedPointCount)

        var t = tn
        if (initialPoint != null) {
            points += initialPoint
            t = t.plusSeconds(CONTOUR_SAMPLE_STEP_SECONDS)
        }
        while (t <= tk) {
            points += stepper.calculate(fromDateTime(t))
                ?: throw CustomErrorException("Ошибка расчета выхода на заданное время")
            t = t.plusSeconds(CONTOUR_SAMPLE_STEP_SECONDS)
        }

        return PreparedOrbitalWindow(
            start = tn,
            endInclusive = tk,
            points = points
        )
    }

    private fun contour(
        stepper: AbstractStepper,
        tn: LocalDateTime,
        tk: LocalDateTime,
        roll: Double,
        capture: Double
    ): String =
        contour(
            window = prepareOrbitalWindow(stepper, tn, tk),
            roll = roll,
            capture = capture
        )

    private fun contour(
        window: PreparedOrbitalWindow,
        roll: Double,
        capture: Double
    ): String =
        contour(
            window = window,
            roll = roll,
            capture = capture,
            pointOnEarthCalculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit),
            geometryFactory = GeometryFactory(),
            wktWriter = WKTWriter()
        )

    private fun contour(
        window: PreparedOrbitalWindow,
        roll: Double,
        capture: Double,
        pointOnEarthCalculator: PointOnEarthCalculator,
        geometryFactory: GeometryFactory,
        wktWriter: WKTWriter
    ): String {
        try {
            val rightCoordinates = ArrayList<Coordinate>(window.points.size)
            val leftCoordinates = ArrayList<Coordinate>(window.points.size)

            window.points.forEach { point ->
                val rightPoint = viewParams(pointOnEarthCalculator, point, roll + capture) ?: THBLPoint(
                    0.0,
                    0.0,
                    0.0,
                    0.0
                )
                rightCoordinates += Coordinate(rightPoint.long * 180 / PI, rightPoint.lat * 180 / PI, 0.0)

                val leftPoint = viewParams(pointOnEarthCalculator, point, roll - capture) ?: THBLPoint(
                    0.0,
                    0.0,
                    0.0,
                    0.0
                )
                leftCoordinates += Coordinate(leftPoint.long * 180 / PI, leftPoint.lat * 180 / PI, 0.0)
            }

            val geom = ArrayList<Coordinate>(rightCoordinates.size + leftCoordinates.size + 1)
            geom.addAll(rightCoordinates)
            for (index in leftCoordinates.indices.reversed()) {
                geom += leftCoordinates[index]
            }
            if (geom.isNotEmpty()) {
                geom += Coordinate(geom.first().x, geom.first().y)
            }

            val shell = geometryFactory.createLinearRing(geom.toTypedArray())
            val polygon = geometryFactory.createPolygon(shell)
            return wktWriter.write(polygon)
        } catch (e: Exception) {
            logger.warn("Ошибка при расчете контура", e)
            return ""
        }
    }

    private fun viewParams(pos: OrbitalPoint, gamma: Double): THBLPoint? {
        val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
        return viewParams(calculator, pos, gamma)
    }

    private fun viewParams(
        calculator: PointOnEarthCalculator,
        pos: OrbitalPoint,
        gamma: Double
    ): THBLPoint? =
        calculator.pointOnEarth(pos, Orientation(0.0, gamma * PI / 180.0, 0.0))

    fun polySlots(
        wkt: String,
        timeStart: LocalDateTime,
        timeStop: LocalDateTime,
        sign: RevolutionSign?,
        cov: Boolean? = null,
        states: List<SlotStatus>? = null,
        sats : List<Long>? = null,
        sun : Double? = null,
        coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY,
        includeBusy: Boolean = false
    ): Iterable<SlotDTO> {
        val target = buildCoverageTargets(
            items = listOf(SlotCoverageTargetDTO(targetId = 1, wkt = wkt))
        ).first()
        val batchContext = buildCoverageBatchContext(
            targets = listOf(target),
            timeStart = timeStart,
            timeStop = timeStop,
            sats = sats,
            occupiedIntervals = emptyList(),
            sign = sign,
            states = states
        )
        val selection = selectCoverageSlots(
            target = target,
            batchContext = batchContext,
            cov = cov,
            sun = sun,
            coverageStrategy = coverageStrategy,
            includeBusy = includeBusy
        )
        logger.info(
            "polySlots finish: targetId={}, candidateSlots={}, sunFilteredSlots={}, returnedSlots={}, sun={}",
            target.targetId,
            selection.candidateSlotsBeforeSunFilter,
            selection.sunFilteredSlots,
            selection.slots.size,
            sun
        )
        return selection.slots
    }

    fun polySlotsBatch(body: SlotCoverageBatchRequestDTO): List<SlotCoverageBatchResponseDTO> {
        if (body.items.isEmpty()) {
            return emptyList()
        }

        val startedAtMs = System.currentTimeMillis()
        try {
            var result = emptyList<SlotCoverageBatchResponseDTO>()
            val totalMs = measureTimeMillis {
                var targets = emptyList<CoverageTargetContext>()
                val targetsMs = measureTimeMillis {
                    targets = buildCoverageTargets(body.items)
                }
                logger.info(
                    "polySlotsBatch start: targets={}, satellites={}, stage=build-coverage-targets, durationMs={}, sun={}",
                    targets.size,
                    selectedSatelliteCount(body.satellites),
                    targetsMs,
                    body.sun
                )

                var batchContext = CoverageBatchContext(
                    selectedSatellites = emptyList(),
                    selectedSatellitesById = emptyMap(),
                    bookedSlotsBySatelliteId = emptyMap(),
                    rawSlotIdsByTargetIdAndSatelliteId = emptyMap(),
                    preparedSlotsBySatelliteIdAndSourceSlotId = emptyMap(),
                    occupiedIntervalsBySatelliteId = emptyMap(),
                    totalUniqueRawSlots = 0,
                    totalPreparedSlots = 0
                )
                val contextMs = measureTimeMillis {
                    batchContext = buildCoverageBatchContext(
                        targets = targets,
                        timeStart = body.timeStart,
                        timeStop = body.timeStop,
                        sats = body.satellites,
                        occupiedIntervals = body.occupiedIntervals.orEmpty(),
                        sign = body.revSign,
                        states = null
                    )
                }
                logger.info(
                    "polySlotsBatch stage=build-batch-context, targets={}, satellites={}, occupiedIntervals={}, uniqueRawSlots={}, preparedSlots={}, durationMs={}",
                    targets.size,
                    batchContext.selectedSatellites.size,
                    batchContext.occupiedIntervalsBySatelliteId.values.sumOf { it.size },
                    batchContext.totalUniqueRawSlots,
                    batchContext.totalPreparedSlots,
                    contextMs
                )

                val effectiveParallelism = resolveBatchParallelism(targets.size)
                var indexedResults = emptyList<IndexedCoverageBatchResult>()
                val selectMs = measureTimeMillis {
                    indexedResults = runBlocking {
                        kotlinx.coroutines.withContext(Dispatchers.Default.limitedParallelism(effectiveParallelism)) {
                            targets.mapIndexed { index, target ->
                                async {
                                    val selection = selectCoverageSlots(
                                        target = target,
                                        batchContext = batchContext,
                                        cov = body.cov,
                                        sun = body.sun,
                                        coverageStrategy = body.coverageStrategy
                                    )
                                    IndexedCoverageBatchResult(
                                        index = index,
                                        response = SlotCoverageBatchResponseDTO(
                                            targetId = target.targetId,
                                            slots = selection.slots
                                        ),
                                        sourceSlotIds = selection.sourceSlotIds,
                                        preparedSlotsBeforeOccupiedFilter = selection.preparedSlotsBeforeOccupiedFilter,
                                        occupiedFilteredSlots = selection.occupiedFilteredSlots,
                                        candidateSlotsBeforeSunFilter = selection.candidateSlotsBeforeSunFilter,
                                        sunFilteredSlots = selection.sunFilteredSlots,
                                        solverInputSlots = selection.solverInputSlots,
                                        solverOutputSlots = selection.solverOutputSlots,
                                        candidateSelectionDurationMs = selection.candidateSelectionDurationMs,
                                        solverDurationMs = selection.solverDurationMs,
                                        totalDurationMs = selection.totalDurationMs
                                    )
                                }
                            }.awaitAll()
                        }
                    }
                }
                val orderedResults = indexedResults.sortedBy { it.index }
                val totalSourceSlotIds = orderedResults.sumOf { it.sourceSlotIds }
                val totalPreparedSlotsBeforeOccupiedFilter = orderedResults.sumOf { it.preparedSlotsBeforeOccupiedFilter }
                val totalOccupiedFilteredSlots = orderedResults.sumOf { it.occupiedFilteredSlots }
                val totalCandidateSlotsBeforeSunFilter = orderedResults.sumOf { it.candidateSlotsBeforeSunFilter }
                val totalSunFilteredSlots = orderedResults.sumOf { it.sunFilteredSlots }
                val totalSolverInputSlots = orderedResults.sumOf { it.solverInputSlots }
                val totalSolverOutputSlots = orderedResults.sumOf { it.solverOutputSlots }
                val totalCandidateSelectionMs = orderedResults.sumOf { it.candidateSelectionDurationMs }
                val totalSolverMs = orderedResults.sumOf { it.solverDurationMs }
                val slowestTargets = orderedResults
                    .sortedByDescending { it.totalDurationMs }
                    .take(10)
                    .joinToString(separator = "; ") { item ->
                        "target=${item.response.targetId},totalMs=${item.totalDurationMs},collectMs=${item.candidateSelectionDurationMs},solverMs=${item.solverDurationMs},raw=${item.sourceSlotIds},prepared=${item.preparedSlotsBeforeOccupiedFilter},occupiedFiltered=${item.occupiedFilteredSlots},beforeSun=${item.candidateSlotsBeforeSunFilter},sunFiltered=${item.sunFilteredSlots},solverInput=${item.solverInputSlots},selected=${item.solverOutputSlots}"
                    }
                val heaviestCandidateTargets = orderedResults
                    .sortedByDescending { it.solverInputSlots }
                    .take(10)
                    .joinToString(separator = "; ") { item ->
                        "target=${item.response.targetId},solverInput=${item.solverInputSlots},solverMs=${item.solverDurationMs},selected=${item.solverOutputSlots}"
                    }
                logger.info(
                    "polySlotsBatch stage=select-total, targets={}, satellites={}, parallelism={}, wallDurationMs={}, candidateSelectionCpuTotalMs={}, solverCpuTotalMs={}, returnedTargets={}, returnedSlots={}, sourceSlotIds={}, preparedBeforeOccupied={}, occupiedFilteredSlots={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, solverInputSlots={}, solverOutputSlots={}",
                    targets.size,
                    batchContext.selectedSatellites.size,
                    effectiveParallelism,
                    selectMs,
                    totalCandidateSelectionMs,
                    totalSolverMs,
                    orderedResults.size,
                    orderedResults.sumOf { it.response.slots.size },
                    totalSourceSlotIds,
                    totalPreparedSlotsBeforeOccupiedFilter,
                    totalOccupiedFilteredSlots,
                    totalCandidateSlotsBeforeSunFilter,
                    totalSunFilteredSlots,
                    totalSolverInputSlots,
                    totalSolverOutputSlots
                )
                logger.info(
                    "polySlotsBatch target-metrics slowestTargets=[{}]",
                    slowestTargets.ifBlank { "none" }
                )
                logger.info(
                    "polySlotsBatch target-metrics heaviestCandidateTargets=[{}]",
                    heaviestCandidateTargets.ifBlank { "none" }
                )
                result = orderedResults.map { it.response }
            }
            logger.info(
                "polySlotsBatch finish: targets={}, returnedTargets={}, returnedSlots={}, totalDurationMs={}",
                body.items.size,
                result.size,
                result.sumOf { it.slots.size },
                totalMs
            )
            return result
        } catch (ex: Exception) {
            logger.error(
                "polySlotsBatch failed: targets={}, satellites={}, occupiedIntervals={}, coverageStrategy={}, elapsedMs={}",
                body.items.size,
                selectedSatelliteCount(body.satellites),
                body.occupiedIntervals.orEmpty().size,
                body.coverageStrategy,
                System.currentTimeMillis() - startedAtMs,
                ex
            )
            throw ex
        }
    }

    private fun normalizeWktLongitudeTo360(wkt: String): String {
        return LongitudeWrapGeometry.normalizeToContinuous360Wkt(wkt)
    }

    private fun buildCoverageTargets(items: List<SlotCoverageTargetDTO>): List<CoverageTargetContext> =
        items
            .distinctBy { it.targetId }
            .map { item ->
                val normalizedWkt = normalizeWktLongitudeTo360(item.wkt)
                CoverageTargetContext(
                    targetId = item.targetId,
                    normalizedWkt = normalizedWkt,
                    searchWkts = LongitudeWrapGeometry.buildSearchVariantsWkt(normalizedWkt)
                )
            }

    private fun buildCoverageBatchContext(
        targets: List<CoverageTargetContext>,
        timeStart: LocalDateTime,
        timeStop: LocalDateTime,
        sats: List<Long>?,
        occupiedIntervals: List<OccupiedIntervalDTO>,
        sign: RevolutionSign?,
        states: List<SlotStatus>?
    ): CoverageBatchContext {
        val selectedSatellites = loadSatellites(sats)
        if (selectedSatellites.isEmpty() || targets.isEmpty()) {
            return CoverageBatchContext(
                selectedSatellites = selectedSatellites,
                selectedSatellitesById = emptyMap(),
                bookedSlotsBySatelliteId = emptyMap(),
                rawSlotIdsByTargetIdAndSatelliteId = emptyMap(),
                preparedSlotsBySatelliteIdAndSourceSlotId = emptyMap(),
                occupiedIntervalsBySatelliteId = emptyMap(),
                totalUniqueRawSlots = 0,
                totalPreparedSlots = 0
            )
        }

        val selectedSatellitesById = selectedSatellites.associateBy { it.id }

        val cycleWindows = selectedSatellites.associate { satellite ->
            satellite.id to SatelliteCycleWindow(
                satellite = satellite,
                cycleBegin = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc,
                cycleEnd = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc
            )
        }

        val minCycle = cycleWindows.values.minOf { it.cycleBegin }
        val maxCycle = cycleWindows.values.maxOf { it.cycleEnd }
        val selectedSatelliteIds = selectedSatellites.map { it.id }

        lateinit var bookedSlotsBySatelliteId: Map<Long, List<BookedSlotEntity>>
        val bookedFetchMs = measureTimeMillis {
            val bookedSlots = bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(
                selectedSatelliteIds,
                minCycle,
                maxCycle
            )
            val groupedBookedSlots = bookedSlots.groupBy { it.slot.satelliteId }
            bookedSlotsBySatelliteId = selectedSatelliteIds.associateWith { satelliteId ->
                val window = cycleWindows.getValue(satelliteId)
                groupedBookedSlots[satelliteId]
                    .orEmpty()
                    .filter { booked -> booked.cycle in window.cycleBegin..window.cycleEnd }
            }
        }
        logger.info(
            "polySlotsBatch stage=spatial-booked-fetch, satellites={}, durationMs={}",
            selectedSatelliteIds.size,
            bookedFetchMs
        )

        lateinit var rawSlotIdsByTargetIdAndSatelliteId: Map<Long, Map<Long, List<Long>>>
        lateinit var uniqueSlotsBySatelliteId: Map<Long, LinkedHashMap<Long, SlotEntity>>
        var satelliteBaseWindowCount = 0
        var searchRequestCount = 0
        val slotsFetchMs = measureTimeMillis {
            val satelliteBaseWindows = cycleWindows.values
                .flatMap { window ->
                    (window.cycleBegin..window.cycleEnd).map { cycle ->
                        SatelliteBaseWindow(
                            satelliteId = window.satellite.id,
                            windowStart = timeStart.minusDays(cycle * window.satellite.durationCalc),
                            windowStop = timeStop.minusDays(cycle * window.satellite.durationCalc)
                        )
                    }
                }
                .distinct()
            satelliteBaseWindowCount = satelliteBaseWindows.size
            val searchRequests = targets
                .flatMap { target ->
                    target.searchWkts.flatMap { searchWkt ->
                        satelliteBaseWindows.map { baseWindow ->
                            BatchCoverageSearchRequest(
                                targetId = target.targetId,
                                satelliteId = baseWindow.satelliteId,
                                windowStart = baseWindow.windowStart,
                                windowStop = baseWindow.windowStop,
                                polygonWkt = searchWkt
                            )
                        }
                    }
                }
                .distinct()
            searchRequestCount = searchRequests.size
            val slotRows = slotRepository.findTargetSlotRows(
                searchRequests = searchRequests,
                chunkSize = batchSpatialFetchChunkSize.coerceAtLeast(1)
            )

            val slotIdsByTargetId = targets.associate { target ->
                target.targetId to linkedMapOf<Long, LinkedHashSet<Long>>()
            }.toMutableMap()
            val uniqueSlotsBySatellite = linkedMapOf<Long, LinkedHashMap<Long, SlotEntity>>()

            slotRows.forEach { row ->
                val slotsBySatelliteId = slotIdsByTargetId.getValue(row.targetId)
                val uniqueSatelliteSlots = uniqueSlotsBySatellite.getOrPut(row.satelliteId) { linkedMapOf() }
                val slot = uniqueSatelliteSlots.getOrPut(row.slotId) { row.toSlotEntity() }
                val slotId = slot.slotId ?: return@forEach
                val satelliteSlotIds = slotsBySatelliteId.getOrPut(slot.satelliteId) { linkedSetOf() }
                satelliteSlotIds += slotId
            }

            rawSlotIdsByTargetIdAndSatelliteId = targets.associate { target ->
                val slotsBySatelliteId = slotIdsByTargetId.getValue(target.targetId)
                target.targetId to buildMap {
                    selectedSatelliteIds.forEach { satelliteId ->
                        val slotIds = slotsBySatelliteId[satelliteId]?.toList().orEmpty()
                        if (slotIds.isNotEmpty()) {
                            put(satelliteId, slotIds)
                        }
                    }
                }
            }
            uniqueSlotsBySatelliteId = uniqueSlotsBySatellite
            val rawRowsBySatellite = slotRows.groupingBy { it.satelliteId }.eachCount()
            val heaviestSatellites = rawRowsBySatellite.entries
                .sortedByDescending { it.value }
                .take(3)
                .joinToString(separator = ",") { "${it.key}:${it.value}" }
            logger.info(
                "polySlotsBatch stage=spatial-slot-fetch-detail, targets={}, searchVariants={}, satelliteWindows={}, searchRequests={}, rowsRead={}, uniqueRawSlots={}, heaviestSatellites={}",
                targets.size,
                targets.sumOf { it.searchWkts.size },
                satelliteBaseWindows.size,
                searchRequests.size,
                slotRows.size,
                uniqueSlotsBySatellite.values.sumOf { it.size },
                heaviestSatellites.ifBlank { "none" }
            )
        }
        logger.info(
            "polySlotsBatch stage=spatial-slot-fetch, targets={}, searchVariants={}, satelliteWindows={}, searchRequests={}, satellites={}, chunkSize={}, durationMs={}",
            targets.size,
            targets.sumOf { it.searchWkts.size },
            satelliteBaseWindowCount,
            searchRequestCount,
            selectedSatelliteIds.size,
            batchSpatialFetchChunkSize.coerceAtLeast(1),
            slotsFetchMs
        )

        lateinit var preparedSlotsPrecompute: PreparedSlotsPrecomputeResult
        val precomputePreparedMs = measureTimeMillis {
            preparedSlotsPrecompute = precomputePreparedSlotsBySatellite(
                selectedSatelliteIds = selectedSatelliteIds,
                uniqueSlotsBySatelliteId = uniqueSlotsBySatelliteId,
                bookedSlotsBySatelliteId = bookedSlotsBySatelliteId,
                selectedSatellitesById = selectedSatellitesById,
                timeStart = timeStart,
                timeStop = timeStop,
                sign = sign,
                states = states
            )
        }
        logger.info(
            "polySlotsBatch stage=precompute-prepared-slots, satellites={}, uniqueRawSlots={}, preparedSlots={}, durationMs={}",
            selectedSatelliteIds.size,
            uniqueSlotsBySatelliteId.values.sumOf { it.size },
            preparedSlotsPrecompute.totalPreparedSlots,
            precomputePreparedMs
        )

        return CoverageBatchContext(
            selectedSatellites = selectedSatellites,
            selectedSatellitesById = selectedSatellitesById,
            bookedSlotsBySatelliteId = bookedSlotsBySatelliteId,
            rawSlotIdsByTargetIdAndSatelliteId = rawSlotIdsByTargetIdAndSatelliteId,
            preparedSlotsBySatelliteIdAndSourceSlotId = preparedSlotsPrecompute.preparedSlotsBySatelliteIdAndSourceSlotId,
            occupiedIntervalsBySatelliteId = OccupiedIntervalPrefilter.normalizeBySatellite(
                occupiedIntervals = occupiedIntervals,
                satellitesById = selectedSatellitesById
            ),
            totalUniqueRawSlots = uniqueSlotsBySatelliteId.values.sumOf { it.size },
            totalPreparedSlots = preparedSlotsPrecompute.totalPreparedSlots
        )
    }

    private fun space.nstart.pcp.slots_service.repository.BatchCoverageSlotRow.toSlotEntity(): SlotEntity =
        SlotEntity(
            slotId = slotId,
            slotNum = slotNum,
            cycle = cycle,
            satelliteId = satelliteId,
            coveringType = coveringType,
            tn = tn,
            tk = tk,
            roll = roll,
            contour = contour,
            revolution = revolution,
            revolutionSign = revolutionSign,
            latitude = latitude,
            longitude = longitude
        )

    private fun precomputePreparedSlotsBySatellite(
        selectedSatelliteIds: List<Long>,
        uniqueSlotsBySatelliteId: Map<Long, LinkedHashMap<Long, SlotEntity>>,
        bookedSlotsBySatelliteId: Map<Long, List<BookedSlotEntity>>,
        selectedSatellitesById: Map<Long, AbstractSatellite>,
        timeStart: LocalDateTime,
        timeStop: LocalDateTime,
        sign: RevolutionSign?,
        states: List<SlotStatus>?
    ): PreparedSlotsPrecomputeResult {
        var totalPreparedSlots = 0
        val preparedBySatellite = selectedSatelliteIds.associateWith { satelliteId ->
            val satellite = selectedSatellitesById[satelliteId] ?: return@associateWith emptyMap()
            val uniqueSlots = uniqueSlotsBySatelliteId[satelliteId]?.values?.toList().orEmpty()
            if (uniqueSlots.isEmpty()) {
                return@associateWith emptyMap()
            }
            val preparedSlots = satellite.prepareCoverageSlots(
                slots = uniqueSlots,
                bookedSlots = bookedSlotsBySatelliteId[satelliteId].orEmpty(),
                timeStart = timeStart,
                timeStop = timeStop,
                sign = sign,
                states = states
            )
            totalPreparedSlots += preparedSlots.size
            preparedSlots.groupBy(
                keySelector = { it.sourceSlotId },
                valueTransform = { it }
            )
        }
        return PreparedSlotsPrecomputeResult(
            preparedSlotsBySatelliteIdAndSourceSlotId = preparedBySatellite,
            totalPreparedSlots = totalPreparedSlots
        )
    }

    private fun selectCoverageSlots(
        target: CoverageTargetContext,
        batchContext: CoverageBatchContext,
        cov: Boolean?,
        sun: Double?,
        coverageStrategy: SlotCoverageStrategy?,
        // true — добавить в результат и занятые бронью слоты (BUSY) для обзора. В расчёт покрытия
        // (solver) они не попадают: selectedSlots остаётся только из AVAILABLE/BOOKED.
        includeBusy: Boolean = false
    ): CoverageSelectionResult {
        val totalStartedAtMs = System.currentTimeMillis()
        val selectedSlots = mutableListOf<SlotDTO>()
        val busySlots = mutableListOf<SlotDTO>()
        val targetSlotIdsBySatelliteId = batchContext.rawSlotIdsByTargetIdAndSatelliteId[target.targetId].orEmpty()
        val sourceSlotIds = targetSlotIdsBySatelliteId.values.sumOf { it.size }
        var preparedSlotsBeforeOccupiedFilter = 0
        var occupiedFilteredSlots = 0
        var candidateSlotsBeforeSunFilter = 0
        var sunFilteredSlots = 0
        val candidateSelectionMs = measureTimeMillis {
            targetSlotIdsBySatelliteId.forEach { (satelliteId, sourceSlotIdsForSatellite) ->
                if (sourceSlotIdsForSatellite.isEmpty()) {
                    return@forEach
                }
                val satellite = batchContext.selectedSatellitesById[satelliteId] ?: return@forEach
                val preparedSlotsBySourceSlotId =
                    batchContext.preparedSlotsBySatelliteIdAndSourceSlotId[satelliteId].orEmpty()
                val preparedSlotsIncludingBlocked = sourceSlotIdsForSatellite.flatMap { slotId ->
                    preparedSlotsBySourceSlotId[slotId].orEmpty()
                }.map { it.slot }
                preparedSlotsBeforeOccupiedFilter += preparedSlotsIncludingBlocked.size

                // BUSY слоты возникают, в частности, когда слот по времени попадает внутрь
                // активного забронированного интервала. Такие слоты не должны попадать
                // в дальнейший расчет покрытия: booked-слоты учитываются как BOOKED,
                // а все остальные слоты внутри их времени считаются заблокированными.
                val preparedSlots = preparedSlotsIncludingBlocked.filter { slot ->
                    slot.state == SlotStatus.AVAILABLE || slot.state == SlotStatus.BOOKED
                }
                val blockedByBookedSlots = preparedSlotsIncludingBlocked.size - preparedSlots.size

                val filteredSlots = OccupiedIntervalPrefilter.filterCandidates(
                    candidateSlots = preparedSlots,
                    occupiedIntervals = batchContext.occupiedIntervalsBySatelliteId[satelliteId].orEmpty(),
                    satellite = satellite
                )
                occupiedFilteredSlots += blockedByBookedSlots + (preparedSlots.size - filteredSlots.size)
                candidateSlotsBeforeSunFilter += filteredSlots.size
                val sunFilterResult = filterBySunAngle(filteredSlots, sun)
                sunFilteredSlots += sunFilterResult.filteredCount
                selectedSlots.addAll(sunFilterResult.slots)
                // «Показать все слоты»: добавляем и занятые бронью (BUSY) для обзора — тот же
                // sun-фильтр, чтобы не показывать заведомо непригодные по Солнцу.
                if (includeBusy) {
                    val busyPrepared = preparedSlotsIncludingBlocked.filter { it.state == SlotStatus.BUSY }
                    busySlots.addAll(filterBySunAngle(busyPrepared, sun).slots)
                }
            }
        }
        logger.debug(
            "polySlotsBatch stage=collect-prepared-slots, targetId={}, satellites={}, sourceSlotIds={}, preparedBeforeOccupied={}, occupiedFilteredSlots={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, candidateSlots={}, durationMs={}",
            target.targetId,
            targetSlotIdsBySatelliteId.size,
            sourceSlotIds,
            preparedSlotsBeforeOccupiedFilter,
            occupiedFilteredSlots,
            candidateSlotsBeforeSunFilter,
            sunFilteredSlots,
            selectedSlots.size,
            candidateSelectionMs
        )

        if (cov == true) {
            val effectiveCoverageStrategy = coverageStrategy ?: SlotCoverageStrategy.GREEDY
            try {
                var result = listOf<SlotDTO>()
                val solverInputSlots = selectedSlots.size
                val solverMs = measureTimeMillis {
                    result = when (effectiveCoverageStrategy) {
                        SlotCoverageStrategy.GREEDY -> {
                            val solver = OptimalReqCoverSolver(logger, batchContext.selectedSatellitesById)
                            solver.select(target.normalizedWkt, selectedSlots)
                        }
                        SlotCoverageStrategy.CONTINUOUS -> {
                            val solver = ContinuousReqCoverSolver(logger, batchContext.selectedSatellitesById)
                            solver.select(target.normalizedWkt, selectedSlots)
                        }
                    }
                }
                val totalMs = System.currentTimeMillis() - totalStartedAtMs
                logger.info(
                    "polySlotsBatch target-metrics targetId={}, strategy={}, satellites={}, sourceSlotIds={}, preparedBeforeOccupied={}, occupiedFilteredSlots={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, solverInputSlots={}, solverOutputSlots={}, collectMs={}, solverMs={}, totalMs={}",
                    target.targetId,
                    effectiveCoverageStrategy,
                    targetSlotIdsBySatelliteId.size,
                    sourceSlotIds,
                    preparedSlotsBeforeOccupiedFilter,
                    occupiedFilteredSlots,
                    candidateSlotsBeforeSunFilter,
                    sunFilteredSlots,
                    solverInputSlots,
                    result.size,
                    candidateSelectionMs,
                    solverMs,
                    totalMs
                )
                return CoverageSelectionResult(
                    slots = result,
                    sourceSlotIds = sourceSlotIds,
                    preparedSlotsBeforeOccupiedFilter = preparedSlotsBeforeOccupiedFilter,
                    occupiedFilteredSlots = occupiedFilteredSlots,
                    candidateSlotsBeforeSunFilter = candidateSlotsBeforeSunFilter,
                    sunFilteredSlots = sunFilteredSlots,
                    solverInputSlots = solverInputSlots,
                    solverOutputSlots = result.size,
                    candidateSelectionDurationMs = candidateSelectionMs,
                    solverDurationMs = solverMs,
                    totalDurationMs = totalMs
                )
            } catch (e: CustomErrorException) {
                logger.error(
                    "polySlotsBatch stage=solver-failed, targetId={}, strategy={}, candidates={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, message={}",
                    target.targetId,
                    effectiveCoverageStrategy,
                    selectedSlots.size,
                    candidateSlotsBeforeSunFilter,
                    sunFilteredSlots,
                    e.message,
                    e
                )
                throw e
            } catch (e: Exception) {
                logger.error(
                    "polySlotsBatch stage=solver-failed, targetId={}, strategy={}, candidates={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}",
                    target.targetId,
                    effectiveCoverageStrategy,
                    selectedSlots.size,
                    candidateSlotsBeforeSunFilter,
                    sunFilteredSlots,
                    e
                )
                throw CustomErrorException(
                    "Ошибка расчета покрытия targetId=${target.targetId}, strategy=$effectiveCoverageStrategy: ${e.message ?: e::class.simpleName}"
                )
            }
        }

        val totalMs = System.currentTimeMillis() - totalStartedAtMs
        logger.info(
            "polySlotsBatch target-metrics targetId={}, strategy=NONE, satellites={}, sourceSlotIds={}, preparedBeforeOccupied={}, occupiedFilteredSlots={}, candidateSlotsBeforeSunFilter={}, sunFilteredSlots={}, solverInputSlots={}, solverOutputSlots={}, collectMs={}, solverMs=0, totalMs={}",
            target.targetId,
            targetSlotIdsBySatelliteId.size,
            sourceSlotIds,
            preparedSlotsBeforeOccupiedFilter,
            occupiedFilteredSlots,
            candidateSlotsBeforeSunFilter,
            sunFilteredSlots,
            selectedSlots.size,
            selectedSlots.size,
            candidateSelectionMs,
            totalMs
        )
        // Без покрытия («Показать все слоты»): при includeBusy возвращаем и занятые бронью слоты.
        val resultSlots = if (includeBusy) selectedSlots + busySlots else selectedSlots
        return CoverageSelectionResult(
            slots = resultSlots,
            sourceSlotIds = sourceSlotIds,
            preparedSlotsBeforeOccupiedFilter = preparedSlotsBeforeOccupiedFilter,
            occupiedFilteredSlots = occupiedFilteredSlots,
            candidateSlotsBeforeSunFilter = candidateSlotsBeforeSunFilter,
            sunFilteredSlots = sunFilteredSlots,
            solverInputSlots = selectedSlots.size,
            solverOutputSlots = resultSlots.size,
            candidateSelectionDurationMs = candidateSelectionMs,
            solverDurationMs = 0L,
            totalDurationMs = totalMs
        )
    }

    private fun resolveBatchParallelism(targetCount: Int): Int {
        if (targetCount <= 1) {
            return 1
        }
        return batchParallelism.coerceAtLeast(1).coerceAtMost(targetCount)
    }

    private fun filterBySunAngle(candidateSlots: List<SlotDTO>, requestedSun: Double?): SunFilterResult {
        if (requestedSun == null) {
            return SunFilterResult(slots = candidateSlots, filteredCount = 0)
        }

        val filteredSlots = candidateSlots.filter { slot ->
            calculateSunAngleDegrees(slot) > requestedSun
        }
        return SunFilterResult(
            slots = filteredSlots,
            filteredCount = candidateSlots.size - filteredSlots.size
        )
    }

    private fun calculateSunAngleDegrees(slot: SlotDTO): Double {
        val time = fromDateTime(slot.tn)
        val position = astronomer.earth.blh2xyz(
            slot.latitude / 180.0 * PI,
            slot.longitude / 180.0 * PI,
            0.0
        )
        val angle = astronomer.sunAngle(time, position)
        return angle * 180.0 / PI
    }

    fun reqCover(
        id: String,
        timeStart: LocalDateTime,
        timeStop: LocalDateTime,
        sign: RevolutionSign?,
        cov: Boolean?,
        sats : List<Long>? = null,
        sun : Double? = null,
        coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY,
        // true — вернуть и занятые бронью слоты (BUSY) для обзора («Показать все слоты»).
        // Бронировать их нельзя, в расчёт покрытия они не идут.
        includeBusy: Boolean = false
    ): Iterable<SlotDTO> {
        logger.info("Запрос покрытия заявки $id (includeBusy=$includeBusy)")
        val req = earthService.reqcells(id)?.request?.contour ?: throw CustomErrorException("Нет такой заявки!!!!")
        logger.info("Получена заявка : $req")
        val slots = polySlots(
            req,
            timeStart,
            timeStop,
            sign,
            cov,
            if (includeBusy) listOf(SlotStatus.AVAILABLE, SlotStatus.BOOKED, SlotStatus.BUSY)
            else listOf(SlotStatus.AVAILABLE, SlotStatus.BOOKED),
            sats,
            sun,
            coverageStrategy,
            includeBusy
        )
        return if (includeBusy) enrichHolders(slots) else slots
    }

    // Обогащает держателей BUSY-слотов именем/важностью заявки одним батч-запросом (M3 — обзор
    // конфликтов). prepareCoverageSlots проставляет держателям только requestId; здесь добавляем
    // name/importance. Деградация (request-service недоступен) — держатель остаётся с одним id.
    private fun enrichHolders(slots: Iterable<SlotDTO>): Iterable<SlotDTO> {
        val slotList = slots.toList()
        val holderIds = slotList
            .flatMap { it.holders.orEmpty() }
            .map { it.requestId }
            .toSet()
        if (holderIds.isEmpty()) return slotList
        val infoById = earthService.requestHolders(holderIds)
        if (infoById.isEmpty()) return slotList
        return slotList.map { slot ->
            val holders = slot.holders ?: return@map slot
            slot.copy(holders = holders.map { infoById[it.requestId] ?: it })
        }
    }




    @Transactional(readOnly = true)
    fun allBooked() = bookedSlotsRepository.findAll().map { it.toDTO() }


    @Transactional(readOnly = true)
    fun searchBookedSlots(
        timeStart: LocalDateTime?,
        timeStop: LocalDateTime?,
        requestId: String?,
        satelliteIds: List<Long>?
    ): List<BookedSlotDetailsDTO> {
        if ((timeStart == null) != (timeStop == null)) {
            throw CustomValidationException("Для фильтра по времени должны быть заданы timeStart и timeStop")
        }
        if (timeStart != null && timeStop != null && timeStop < timeStart) {
            throw CustomValidationException("Параметр timeStop должен быть больше или равен timeStart")
        }

        val requestedSatelliteIds = satelliteIds.orEmpty().filter { it > 0 }.distinct()
        val satellites = if (requestedSatelliteIds.isEmpty()) {
            satelliteCatalogClient.allSatellites()
        } else {
            loadSatellites(requestedSatelliteIds)
        }
        if (satellites.isEmpty()) {
            return emptyList()
        }

        val satellitesById = satellites.associateBy { it.id }
        val selectedSatelliteIds = satellites.map { it.id }
        val normalizedRequestId = requestId?.trim()?.takeIf { it.isNotEmpty() }
        val hasInterval = timeStart != null && timeStop != null

        val bookedSlots = if (hasInterval) {
            val cycleWindows = satellites.associate { satellite ->
                val cycleBegin = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc
                val cycleEnd = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc
                satellite.id to (cycleBegin - 1 to cycleEnd + 1)
            }
            val minCycle = cycleWindows.values.minOf { it.first }
            val maxCycle = cycleWindows.values.maxOf { it.second }
            bookedSlotsRepository.findBookingDetailsBySatelliteIdsAndCycleBetween(
                satelliteIds = selectedSatelliteIds,
                cycleBegin = minCycle,
                cycleEnd = maxCycle
            )
        } else {
            bookedSlotsRepository.findBookingDetailsBySatelliteIds(selectedSatelliteIds)
        }

        return bookedSlots
            .asSequence()
            .filter { booked -> normalizedRequestId == null || booked.requests.orEmpty().any { it.requestId == normalizedRequestId } }
            .mapNotNull { booked ->
                val satellite = satellitesById[booked.slot.satelliteId] ?: return@mapNotNull null
                booked.toDetails(satellite)
            }
            .filter { details ->
                if (!hasInterval) {
                    true
                } else {
                    val start = details.timeStart
                    val stop = details.timeStop
                    start != null && stop != null && stop > timeStart!! && start < timeStop!!
                }
            }
            .sortedWith(
                compareBy<BookedSlotDetailsDTO> { it.timeStart ?: LocalDateTime.MAX }
                    .thenBy { it.satelliteId }
                    .thenBy { it.slotNum }
                    .thenBy { it.cycle }
                    .thenBy { it.bookedSlotId }
            )
            .toList()
    }

    private fun BookedSlotEntity.toDetails(satellite: AbstractSatellite): BookedSlotDetailsDTO {
        val timeStart = slot.tn.plusDays(cycle * satellite.durationCalc)
        val timeStop = slot.tk.plusDays(cycle * satellite.durationCalc)
        return BookedSlotDetailsDTO(
            bookedSlotId = bookedSlotId ?: 0,
            satelliteId = slot.satelliteId,
            slotNum = slot.slotNum,
            cycle = cycle,
            timeStart = timeStart,
            timeStop = timeStop,
            durationSeconds = Duration.between(timeStart, timeStop).seconds,
            roll = slot.roll,
            revolution = slot.revolution + cycle * satellite.cycleRevs,
            revolutionSign = slot.revolutionSign,
            status = status,
            requestIds = requests.orEmpty().map { it.requestId }.distinct().sorted()
        )
    }

    @Transactional(readOnly = true)
    fun allByInterval(
        satelliteId: Long,
        timeStart: LocalDateTime,
        timeStop: LocalDateTime
    ): List<SlotDTO> {
        if (timeStop < timeStart) {
            throw CustomValidationException("Параметр timeStop должен быть больше или равен timeStart")
        }

        val satellite = satelliteById(satelliteId)
        val startCycle = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc
        val stopCycle = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc
        val cycleBegin = startCycle - 1
        val cycleEnd = stopCycle + 1

        return (cycleBegin..cycleEnd)
            .flatMap { cycle ->
                val baseWindowStart = timeStart.minusDays(cycle * satellite.durationCalc)
                val baseWindowStop = timeStop.minusDays(cycle * satellite.durationCalc)
                slotRepository
                    .findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc(
                        satelliteId = satelliteId,
                        timeStart = baseWindowStart,
                        timeStop = baseWindowStop
                    )
                    .map { slot -> slot.shiftedToCycle(cycle, satellite) }
            }
            .filter { slot -> slot.tk > timeStart && slot.tn < timeStop }
            .sortedWith(
                compareBy<SlotDTO> { it.tn }
                    .thenBy { it.tk }
                    .thenBy { it.satelliteId }
                    .thenBy { it.slotNumber }
                    .thenBy { it.cycle }
            )
    }

    @Transactional(readOnly = true)
    fun slotCalculationSummaries(satelliteIds: List<Long>): List<SlotCalculationSummaryDTO> {
        val distinctSatelliteIds = satelliteIds.distinct()
        if (distinctSatelliteIds.isEmpty()) {
            return emptyList()
        }

        val summariesBySatelliteId = distinctSatelliteIds
            .map { satelliteId -> slotRepository.slotCalculationSummary(satelliteId) }
            .associate { summary ->
                summary.satelliteId to SlotCalculationSummaryDTO.calculated(
                    satelliteId = summary.satelliteId,
                    slotCount = summary.slotCount,
                    minTime = summary.minTime,
                    maxTime = summary.maxTime,
                    minRevolution = summary.minRevolution,
                    maxRevolution = summary.maxRevolution,
                )
            }

        return distinctSatelliteIds.map { satelliteId ->
            summariesBySatelliteId[satelliteId] ?: SlotCalculationSummaryDTO(satelliteId = satelliteId)
        }
    }

    @Transactional(readOnly = true)
    fun bookedBySatelliteAndInterval(
        satelliteId: Long,
        timeStart: LocalDateTime,
        timeStop: LocalDateTime
    ): List<SurveySlotDTO> {
        if (timeStop < timeStart)
            throw CustomValidationException("Параметр timeStop должен быть больше или равен timeStart")

        val satellite = satelliteById(satelliteId)

        val startCycle = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc
        val stopCycle = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc
        val cycleBegin = startCycle - 1
        val cycleEnd = stopCycle + 1

        val bookedSlots = bookedSlotsRepository.findBySlot_SatelliteIdAndCycleBetween(satelliteId, cycleBegin, cycleEnd)
        return buildSurveyChains(satellite, bookedSlots, timeStart, timeStop)
    }

    @Transactional(readOnly = true)
    fun bookedBySatellitesAndInterval(
        satelliteIds: List<Long>,
        timeStart: LocalDateTime,
        timeStop: LocalDateTime
    ): List<SatelliteMissionBatchResponseDTO> {
        if (timeStop < timeStart)
            throw CustomValidationException("Параметр timeStop должен быть больше или равен timeStart")

        if (satelliteIds.isEmpty()) {
            return emptyList()
        }

        val selectedSatellites = loadSatellites(satelliteIds)

        val cycleWindows = selectedSatellites.associate { satellite ->
            val startCycle = Duration.between(satellite.tnCalc, timeStart!!).toDays() / satellite.durationCalc
            val stopCycle = Duration.between(satellite.tnCalc, timeStop!!).toDays() / satellite.durationCalc
            satellite.id to (startCycle - 1 to stopCycle + 1)
        }
        val minCycle = cycleWindows.values.minOf { it.first }
        val maxCycle = cycleWindows.values.maxOf { it.second }
        val bookedSlots = bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(
            selectedSatellites.map { it.id },
            minCycle,
            maxCycle
        ).groupBy { it.slot.satelliteId }

        return selectedSatellites.map { satellite ->
                SatelliteMissionBatchResponseDTO(
                    satelliteId = satellite.id,
                    slots = buildSurveyChains(
                        satellite = satellite,
                        bookedSlots = bookedSlots[satellite.id].orEmpty(),
                        timeStart = timeStart,
                        timeStop = timeStop
                    )
                )
            }
    }

    private fun buildSurveyChains(
        satellite: AbstractSatellite,
        bookedSlots: List<BookedSlotEntity>,
        timeStart: LocalDateTime,
        timeStop: LocalDateTime
    ): List<SurveySlotDTO> {
        val orderedSlots = bookedSlots
            .asSequence()
            .map { booked ->
                val tn = booked.slot.tn.plusDays(booked.cycle * satellite.durationCalc)
                val tk = booked.slot.tk.plusDays(booked.cycle * satellite.durationCalc)
                SlotDTO(
                    cycle = booked.cycle,
                    satelliteId = booked.slot.satelliteId,
                    tn = tn,
                    tk = tk,
                    roll = booked.slot.roll,
                    contour = booked.slot.contour,
                    revolution = booked.slot.revolution + booked.cycle * satellite.cycleRevs,
                    revolutionSign = RevolutionSign.valueOf(booked.slot.revolutionSign),
                    slotNumber = booked.slot.slotNum,
                    state = SlotStatus.BOOKED,
                    latitude = booked.slot.latitude,
                    longitude = booked.slot.longitude,
                ) to (booked.bookedSlotId ?: 0)
            }
            .filter { (slot, _) -> slot.tk > timeStart && slot.tn < timeStop }
            .sortedWith(
                compareBy<Pair<SlotDTO, Long>> { it.first.roll }
                    .thenBy { it.first.tn }
                    .thenBy { it.first.tk }
                    .thenBy { it.first.revolution }
                    .thenBy { it.first.slotNumber }
                    .thenBy { it.second }
            )
            .toList()

        if (orderedSlots.isEmpty()) {
            return emptyList()
        }

        val chains = mutableListOf<SurveySlotDTO>()
        val firstSlot = orderedSlots.first().first
        var chainStart = firstSlot.tn
        var chainEnd = firstSlot.tk
        var chainRoll = firstSlot.roll
        var chainContour = firstSlot.contour
        var chainRevolution = firstSlot.revolution
        var chainRevolutionSign = firstSlot.revolutionSign
        val chainSlotIds = mutableListOf(orderedSlots.first().second)

        for ((slot, bookedSlotId) in orderedSlots.drop(1)) {
            val canMergeTime = canMergeInTime(chainStart, chainEnd, slot.tn, slot.tk)
            val sameRoll = sameRoll(slot.roll, chainRoll)

            if (sameRoll && canMergeTime) {
                val overlapsInTime = intersectsInTime(chainStart, chainEnd, slot.tn, slot.tk)
                val geometryMergeAssessment = assessMergeableGeometry(
                    leftContour = chainContour,
                    rightContour = slot.contour,
                    satelliteId = satellite.id,
                    allowContinuityWithoutArea = !overlapsInTime
                )
                if (!geometryMergeAssessment.mergeable) {
                    if (geometryMergeAssessment.geometryChecked) {
                        logChainMergeSkippedWithoutSufficientGeometry(
                            satelliteId = satellite.id,
                            chainStart = chainStart,
                            chainEnd = chainEnd,
                            chainRoll = chainRoll,
                            chainSlotIds = chainSlotIds,
                            slot = slot,
                            bookedSlotId = bookedSlotId,
                            intersectionArea = geometryMergeAssessment.intersectionArea ?: 0.0,
                            distance = geometryMergeAssessment.distance,
                            overlapsInTime = overlapsInTime
                        )
                    }
                    chains.add(buildSurveyChain(satellite.id, orderedSlots, chainStart, chainEnd, chainRoll, chainContour, chainRevolution, chainRevolutionSign, chainSlotIds))
                    chainStart = slot.tn
                    chainEnd = slot.tk
                    chainRoll = slot.roll
                    chainContour = slot.contour
                    chainRevolution = slot.revolution
                    chainRevolutionSign = slot.revolutionSign
                    chainSlotIds.clear()
                    chainSlotIds.add(bookedSlotId)
                    continue
                }
                val mergedContour = unionContoursIfPolygon(
                    baseContour = chainContour,
                    contourToAdd = slot.contour,
                    satelliteId = satellite.id,
                    chainStart = chainStart,
                    chainEnd = chainEnd,
                    chainRoll = chainRoll,
                    chainSlotIds = chainSlotIds,
                    slot = slot,
                    bookedSlotId = bookedSlotId
                )
                if (mergedContour == null) {
                    chains.add(buildSurveyChain(satellite.id, orderedSlots, chainStart, chainEnd, chainRoll, chainContour, chainRevolution, chainRevolutionSign, chainSlotIds))
                    chainStart = slot.tn
                    chainEnd = slot.tk
                    chainRoll = slot.roll
                    chainContour = slot.contour
                    chainRevolution = slot.revolution
                    chainRevolutionSign = slot.revolutionSign
                    chainSlotIds.clear()
                    chainSlotIds.add(bookedSlotId)
                    continue
                }
                chainStart = minOf(chainStart, slot.tn)
                chainEnd = maxOf(chainEnd, slot.tk)
                chainContour = mergedContour
                chainSlotIds.add(bookedSlotId)
                continue
            }

            chains.add(buildSurveyChain(satellite.id, orderedSlots, chainStart, chainEnd, chainRoll, chainContour, chainRevolution, chainRevolutionSign, chainSlotIds))
            chainStart = slot.tn
            chainEnd = slot.tk
            chainRoll = slot.roll
            chainContour = slot.contour
            chainRevolution = slot.revolution
            chainRevolutionSign = slot.revolutionSign
            chainSlotIds.clear()
            chainSlotIds.add(bookedSlotId)
        }

        chains.add(buildSurveyChain(satellite.id, orderedSlots, chainStart, chainEnd, chainRoll, chainContour, chainRevolution, chainRevolutionSign, chainSlotIds))
        return chains
    }

    private data class GeometryMergeAssessment(
        val mergeable: Boolean,
        val intersectionArea: Double? = null,
        val touchesOrIntersects: Boolean = false,
        val distance: Double? = null,
        val geometryChecked: Boolean = true
    )

    private fun buildSurveyChain(
        satelliteId: Long,
        orderedSlots: List<Pair<SlotDTO, Long>>,
        chainStart: LocalDateTime,
        chainEnd: LocalDateTime,
        chainRoll: Double,
        chainContour: String,
        chainRevolution: Long,
        chainRevolutionSign: RevolutionSign,
        chainSlotIds: List<Long>
    ): SurveySlotDTO {
        val slotBegin = orderedSlots
            .filter { booked -> chainSlotIds.contains(booked.second) }
            .map { it.first }
            .sortedBy { slot -> slot.tn }

        return SurveySlotDTO(
            satelliteId = satelliteId,
            tn = chainStart,
            tk = chainEnd,
            roll = chainRoll,
            contour = chainContour,
            revolution = chainRevolution,
            revolutionSign = chainRevolutionSign,
            slotIds = chainSlotIds.distinct(),
            latitude = slotBegin.first().latitude,
            longitude = slotBegin.first().longitude
        )
    }

    private fun canMergeInTime(
        start1: LocalDateTime,
        stop1: LocalDateTime,
        start2: LocalDateTime,
        stop2: LocalDateTime
    ): Boolean {
        val firstStop = minOf(stop1, stop2)
        val secondStart = maxOf(start1, start2)
        if (!secondStart.isAfter(firstStop)) {
            return true
        }
        val gapSeconds = Duration.between(firstStop, secondStart).seconds
        return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS
    }

    private fun sameRoll(left: Double, right: Double): Boolean = abs(left - right) <= ROLL_EPSILON

    private fun intersectsInTime(
        start1: LocalDateTime,
        stop1: LocalDateTime,
        start2: LocalDateTime,
        stop2: LocalDateTime
    ): Boolean = start1 < stop2 && start2 < stop1

    private fun assessMergeableGeometry(
        leftContour: String,
        rightContour: String,
        satelliteId: Long,
        allowContinuityWithoutArea: Boolean
    ): GeometryMergeAssessment {
        return try {
            val reader = WKTReader()
            val leftGeometry = reader.read(leftContour)
            val rightGeometry = reader.read(rightContour)
            logInvalidGeometryIfNeeded(satelliteId, "chain", leftGeometry)
            logInvalidGeometryIfNeeded(satelliteId, "candidate", rightGeometry)
            val intersectionArea = leftGeometry.intersection(rightGeometry).area
            val touchesOrIntersects = leftGeometry.intersects(rightGeometry) || leftGeometry.touches(rightGeometry)
            val distance = if (touchesOrIntersects) 0.0 else leftGeometry.distance(rightGeometry)
            GeometryMergeAssessment(
                mergeable = intersectionArea > GEOMETRY_INTERSECTION_AREA_EPSILON ||
                    (allowContinuityWithoutArea && (touchesOrIntersects || distance <= GEOMETRY_DISTANCE_EPSILON)),
                intersectionArea = intersectionArea,
                touchesOrIntersects = touchesOrIntersects,
                distance = distance
            )
        } catch (e: Exception) {
            logger.warn("Не удалось проверить непрерывность геометрий цепочки: {}", e.message)
            GeometryMergeAssessment(mergeable = false, geometryChecked = false)
        }
    }

    private fun unionContoursIfPolygon(
        baseContour: String,
        contourToAdd: String,
        satelliteId: Long,
        chainStart: LocalDateTime,
        chainEnd: LocalDateTime,
        chainRoll: Double,
        chainSlotIds: List<Long>,
        slot: SlotDTO,
        bookedSlotId: Long
    ): String? {
        return try {
            val reader = WKTReader()
            val baseGeometry = reader.read(baseContour)
            val geometryToAdd = reader.read(contourToAdd)
            val unionGeometry = baseGeometry.union(geometryToAdd)
            if (unionGeometry !is Polygon) {
                logger.warn(
                    "Chain merge skipped because union produced non-polygon geometry: satelliteId={}, geometryType={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotIds={}, slotId={}",
                    satelliteId,
                    unionGeometry.geometryType,
                    chainStart,
                    chainEnd,
                    slot.tn,
                    slot.tk,
                    chainRoll,
                    slot.roll,
                    chainSlotIds,
                    bookedSlotId
                )
                return null
            }
            WKTWriter().write(unionGeometry)
        } catch (e: Exception) {
            logger.warn("Не удалось объединить контуры цепочки: {}", e.message)
            null
        }
    }

    private fun logInvalidGeometryIfNeeded(
        satelliteId: Long,
        geometryRole: String,
        geometry: Geometry
    ) {
        if (geometry.isValid) {
            return
        }
        logger.warn(
            "Chain merge geometry is invalid: satelliteId={}, geometryRole={}, geometryType={}",
            satelliteId,
            geometryRole,
            geometry.geometryType
        )
    }

    private fun logChainMergeSkippedWithoutSufficientGeometry(
        satelliteId: Long,
        chainStart: LocalDateTime,
        chainEnd: LocalDateTime,
        chainRoll: Double,
        chainSlotIds: List<Long>,
        slot: SlotDTO,
        bookedSlotId: Long,
        intersectionArea: Double,
        distance: Double?,
        overlapsInTime: Boolean
    ) {
        if (overlapsInTime) {
            logger.warn(
                "Chain merge skipped because contours lack sufficient areal intersection despite temporal overlap: satelliteId={}, intersectionArea={}, epsilon={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotIds={}, slotId={}",
                satelliteId,
                intersectionArea,
                GEOMETRY_INTERSECTION_AREA_EPSILON,
                chainStart,
                chainEnd,
                slot.tn,
                slot.tk,
                chainRoll,
                slot.roll,
                chainSlotIds,
                bookedSlotId
            )
            return
        }

        logger.warn(
            "Chain merge skipped because contours are not continuous enough despite mergeable time window: satelliteId={}, intersectionArea={}, areaEpsilon={}, distance={}, distanceEpsilon={}, maxTimeGapSeconds={}, chainStart={}, chainEnd={}, slotStart={}, slotEnd={}, chainRoll={}, slotRoll={}, chainSlotIds={}, slotId={}",
            satelliteId,
            intersectionArea,
            GEOMETRY_INTERSECTION_AREA_EPSILON,
            distance,
            GEOMETRY_DISTANCE_EPSILON,
            MERGE_MAX_TIME_GAP_SECONDS,
            chainStart,
            chainEnd,
            slot.tn,
            slot.tk,
            chainRoll,
            slot.roll,
            chainSlotIds,
            bookedSlotId
        )
    }

    @Transactional(readOnly = true)
    fun bookedByReqId(id : String) : List<BookedSlotViewDTO>  {
        // Время/виток брони сдвигаем на cycle БРОНИ (как resolveBookedSlotStartTime / shiftedToCycle /
        // toDetails). Иначе by-request отдаёт слот в БАЗОВОМ cycle-фрейме, а request-cover перечисляет
        // слоты уже сдвинутыми — отсюда баг «слоты бронируются на не те даты»: дата брони, восстановленная
        // из by-request, на cycle*durationCalc суток раньше расчётной. cycle в DTO остаётся циклом брони —
        // чтобы ключ sat:slotNum:cycle на фронте совпал с ответом POST booking/request.
        return requestRepository.findByRequestId(id).map { req ->
            val booked = req.bookedSlot
            val satellite = runCatching { satelliteById(booked.slot.satelliteId) }.getOrNull()
            booked.toShiftedViewDTO(satellite)
        }
    }


    @Transactional(readOnly = true)
    fun bookedById(id: Long) = bookedSlotsRepository.findByBookedSlotId(id)
        .map { it.toDTO() }
        .orElseThrow { CustomValidationException("Нет забронированного слота с идентификатором $id") }

    @Transactional
    fun bookSlot(req: SlotBookingRequestDTO): BookedSlotDTO {
        var slot: SlotEntity

        println(req)
        logger.info("запрос бронирования слота для заявки ${req.requestId}")

        var tms = measureTimeMillis {
            slot = slotRepository.findBySatelliteIdAndSlotNum(req.satelliteId, req.slotNum).getOrNull()
                ?: (throw CustomValidationException("Слот с заданными параметрами не существует."))
        }
        logger.info("Запрос слота за $tms мс")
        var bookedSlot: BookedSlotEntity?
        tms = measureTimeMillis {
            bookedSlot = bookedSlotsRepository.findBySlot_SlotIdAndCycle(slot.slotId ?: -1, req.cycle)
        }
        logger.info("Запрос забронированного слота за $tms мс")
        val slotDest =  (bookedSlot ?: bookedSlotsRepository.save(
            BookedSlotEntity(
                slot, req.cycle
            )
        ))
        requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(slotDest.bookedSlotId?:-1, req.requestId?:"test")?:
            requestRepository.save(BookedRequestEntity(null, slotDest, req.requestId?:"test"))
        return slotDest.toDTO()
    }

    @Transactional
    fun bookReq(req : BookingRequestDTO) : Iterable<BookedSlotDTO>{
        logger.info("Запрос на бронирование по заявке ${req.requestId} ${req.slots.count()} слотов")
        return req.slots.map { slot ->
            bookSlot(slot.apply { requestId = req.requestId })
        }
    }


    /**
     * Снимает одну бронь-связь заявки. Если слот забронирован только под эту заявку
     * (requests.size == 1) — удаляем сам слот; иначе рвём только связь заявки, а слот
     * остаётся у других заявок. Единый источник правды для cancelReq / cancelReqSlots /
     * cancelSlot — нельзя удалять BookedSlot по id напрямую, иначе снос брони у чужой заявки.
     */
    private fun removeBookedRequest(link: BookedRequestEntity) {
        if (link.bookedSlot.requests?.size == 1)
            bookedSlotsRepository.deleteByBookedSlotId(link.bookedSlot.bookedSlotId ?: -1)
        else
            requestRepository.deleteByBookedSlotRequestId(link.bookedSlotRequestId ?: -1)
    }

    @Transactional
    fun cancelReq(req : String) : Int{
        logger.info("Запрос на отмену бронирование по заявке ${req}")
        var cnt = 0
        requestRepository.findByRequestId(req).forEach { link ->
            removeBookedRequest(link)
            ++cnt
        }
        return cnt
    }

    /**
     * Снимает брони заявки по списку bookedSlotId (фронт «Снять выбранные», чанк 200).
     * request-aware через removeBookedRequest — безопасно для броней, шаренных между заявками.
     * id без брони у этой заявки молча пропускаются; возвращает число фактически снятых.
     */
    @Transactional
    fun cancelReqSlots(requestId : String, bookedSlotIds : List<Long>) : Int {
        logger.info("Запрос на отмену ${bookedSlotIds.size} броней заявки ${requestId}")
        var cnt = 0
        for (id in bookedSlotIds) {
            val link = requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(id, requestId) ?: continue
            removeBookedRequest(link)
            ++cnt
        }
        return cnt
    }

    @Transactional
    fun cancelSlot(requestId : String, bookedSlotId : Long) : Int =
        cancelReqSlots(requestId, listOf(bookedSlotId))

    fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? {
        val satellite = runCatching { satelliteById(bookedSlot.slot.satelliteId) }.getOrNull() ?: return null
        return bookedSlot.slot.tn.plusDays(bookedSlot.cycle * satellite.durationCalc)
    }










    private fun loadSatellites(ids: List<Long>?): List<AbstractSatellite> = when {
        ids == null -> satelliteCatalogClient.allSatellites()
        ids.isEmpty() -> emptyList()
        else -> satelliteCatalogClient.satellites(ids)
    }

    private fun selectedSatelliteCount(ids: List<Long>?): Int =
        if (ids == null) {
            satelliteCatalogClient.allSatellites().size
        } else {
            ids.distinct().size
        }

    private fun satelliteById(id: Long): AbstractSatellite =
        satelliteCatalogClient.satellite(id)

    // После пересчета параметров синхронизируем slot-profile в каталоге спутников с текущим состоянием КА.
    private fun updateSlotsProfile(satellite: AbstractSatellite) {
        satelliteCatalogClient.updateSlotProfile(
            satellite.id,
            SatelliteSlotProfileDTO(
                cycleRevs = satellite.cycleRevs,
                tnCalc = satellite.tnCalc,
                slotDuration = satellite.slotDuration,
                durationCalcDays = satellite.durationCalc,
                maxChainLength = satellite.maxChainLength,
                defaultAngles = satellite.toSlotAngles()
            )
        )
    }

    private fun AbstractSatellite.toSlotAngles(): List<SatelliteSlotAngleDTO> {
        if (angles.size % 2 != 0) {
            throw CustomValidationException("Для КА $id список углов slot-profile должен содержать четное количество значений")
        }

        return angles.chunked(2).map { pair ->
            SatelliteSlotAngleDTO(
                angleBegin = pair[0],
                angleEnd = pair[1]
            )
        }
    }

    private fun initialConditionsBySatelliteId(satelliteId: Long): InitialConditions =
        satelliteIcService.initialConditions(satelliteId)

    private fun InitialConditions.toDto(): InitialConditionsDTO =
        InitialConditionsDTO(
            orbPoint = OrbPointDTO(
                time = toDateTime(point.t),
                revolution = point.vit.toLong(),
                vx = point.v.x,
                vy = point.v.y,
                vz = point.v.z,
                x = point.r.x,
                y = point.r.y,
                z = point.r.z
            ),
            // Some catalog records do not provide ballistic coefficients; publish DTO defaults instead of nulls.
            sBall = sBall ?: InitialConditionsDTO().sBall,
            f81 = f81 ?: InitialConditionsDTO().f81
        )

    // Бронь для фронта со сдвигом на cycle брони (виток + tn/tk). satellite == null (КА не в каталоге) —
    // отдаём базовый фрейм без сдвига: лучше показать слот, чем уронить весь список by-request. Контур и
    // координаты не сдвигаем — наземный след повторяется каждый цикл (симметрично shiftedToCycle).
    private fun BookedSlotEntity.toShiftedViewDTO(satellite: AbstractSatellite?): BookedSlotViewDTO {
        val shiftDays = satellite?.let { cycle * it.durationCalc } ?: 0L
        val revShift = satellite?.let { cycle * it.cycleRevs } ?: 0L
        return BookedSlotViewDTO(
            bookedSlotId = bookedSlotId ?: 0,
            cycle = cycle,
            satelliteId = slot.satelliteId,
            tn = slot.tn.plusDays(shiftDays),
            tk = slot.tk.plusDays(shiftDays),
            roll = slot.roll,
            contour = slot.contour,
            revolution = slot.revolution + revShift,
            revolutionSign = RevolutionSign.valueOf(slot.revolutionSign),
            slotNumber = slot.slotNum,
            state = SlotStatus.BOOKED,
            latitude = slot.latitude,
            longitude = slot.longitude
        )
    }

    private fun SlotEntity.shiftedToCycle(cycle: Long, satellite: AbstractSatellite): SlotDTO =
        SlotDTO(
            cycle = cycle,
            satelliteId = satelliteId,
            tn = tn.plusDays(cycle * satellite.durationCalc),
            tk = tk.plusDays(cycle * satellite.durationCalc),
            roll = roll,
            contour = contour,
            revolution = revolution + cycle * satellite.cycleRevs,
            revolutionSign = RevolutionSign.valueOf(revolutionSign),
            slotNumber = slotNum,
            state = SlotStatus.AVAILABLE,
            latitude = latitude,
            longitude = longitude
        )
}
