package space.nstart.pcp.pcp_satellites_service.service

import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.io.WKTReader
import org.locationtech.jts.io.WKTWriter
import org.locationtech.jts.operation.union.UnaryUnionOp
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_satellites_service.configuration.ComplexPlanProperties
import space.nstart.pcp.pcp_satellites_service.configuration.CustomValidationException
import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource
import space.nstart.pcp.pcp_satellites_service.model.LongTermMission
import space.nstart.pcp.pcp_satellites_service.model.SatelliteModel
import space.nstart.pcp.pcp_satellites_service.model.mode.SurveyMode
import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import java.time.Duration
import java.time.LocalDateTime
import kotlin.math.abs
import kotlin.system.measureTimeMillis

@Service
class ComplexPlanCalculationService(
    private val coverageBatchClient: CoverageBatchClient,
    private val coverageCalculationClientResolver: CoverageCalculationClientResolver,
    private val earthGridService: EarthGridService,
    private val satelliteService: SatelliteService,
    private val complexPlanQueryService: ComplexPlanQueryService,
    private val complexPlanPersistenceService: ComplexPlanPersistenceService,
    private val complexPlanProperties: ComplexPlanProperties,
    private val surveyPlacementService: SurveyPlacementService
) {

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

    companion object {
        private const val LOG_INTERVAL_SAMPLE_LIMIT = 10
        private const val ROLL_EPSILON = 0.01
    }

    fun process(
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime,
        satelliteIds: List<Long>?,
        sun: Double? = null,
        coverageSource: CoverageCalculationSource = CoverageCalculationSource.SLOTS,
        countLat: Int? = null,
        countLong: Int? = null,
        coverageStrategy: SlotCoverageStrategy? = null
    ): ComplexPlanCalculationResult =
        process(
            runId = null,
            intervalStart = intervalStart,
            intervalEnd = intervalEnd,
            satelliteIds = satelliteIds,
            sun = sun,
            coverageSource = coverageSource,
            countLat = countLat,
            countLong = countLong,
            coverageStrategy = coverageStrategy
        )

    fun process(
        runId: Long?,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime,
        satelliteIds: List<Long>?,
        sun: Double? = null,
        coverageSource: CoverageCalculationSource = CoverageCalculationSource.SLOTS,
        countLat: Int? = null,
        countLong: Int? = null,
        coverageStrategy: SlotCoverageStrategy? = null
    ): ComplexPlanCalculationResult {
        val calculationStartedAtMs = System.currentTimeMillis()
        val selectedSatelliteIds = (satelliteIds ?: satelliteService.satellites.map { it.satelliteId }).distinct()
        if (selectedSatelliteIds.isEmpty()) {
            logger.info("Расчет комплексного плана пропущен: runId={}, не задано ни одного спутника", runId)
            return ComplexPlanCalculationResult()
        }
        lateinit var workingSatellites: List<SatelliteModel>
        val buildSatellitesMs = measureTimeMillis {
            workingSatellites = buildWorkingSatellites(selectedSatelliteIds)
        }
        logger.info(
            "Stage satellites-build: runId={}, requestedSatellites={}, workingSatellites={}, durationMs={}",
            runId,
            selectedSatelliteIds.size,
            workingSatellites.size,
            buildSatellitesMs
        )
        val coverageCalculationClient = coverageCalculationClientResolver.resolve(coverageSource)

        logger.info(
            "Старт расчета комплексного плана: runId={}, satellites={}, interval=[{} - {}], chunkSize={}, sun={}, coverageSource={}, countLat={}, countLong={}, coverageStrategy={}",
            runId,
            selectedSatelliteIds.size,
            intervalStart,
            intervalEnd,
            complexPlanProperties.batchChunkSize,
            sun,
            coverageSource,
            countLat,
            countLong,
            coverageStrategy
        )
        logger.info("Шаг snapshot: runId={}, загрузка рабочего состояния вне пересчитываемого интервала", runId)
        val snapshotMs = measureTimeMillis {
            initializeWorkingModes(workingSatellites, intervalStart, intervalEnd)
        }
        logger.info(
            "Stage snapshot-load: runId={}, satellites={}, durationMs={}",
            runId,
            workingSatellites.size,
            snapshotMs
        )

        try {
            var cells = emptyList<EarthCellWithRequestsDTO>()
            val cellsLoadMs = measureTimeMillis {
                cells = earthGridService.cells(countLat, countLong)
                    .toList()
                    .sortedWith(
                        compareByDescending<EarthCellWithRequestsDTO> { it.importance }
                            .thenByDescending { it.requests.count() }
                            .thenBy { it.num }
                    )
            }
            logger.info("Stage cells-load: runId={}, cells={}, durationMs={}", runId, cells.size, cellsLoadMs)
            val requestGeometry = buildRequestsGeometry(cells)
            val totalRequestsArea = requestGeometry?.area ?: 0.0
            logger.info(
                "Stage requests-area: runId={}, totalRequestsArea={}",
                runId,
                totalRequestsArea
            )

            logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId)
            val plannedModesMs = measureTimeMillis {
                addPlannedModes(runId, workingSatellites, intervalStart, intervalEnd)
            }
            logger.info(
                "Stage planned-modes-load: runId={}, satellites={}, durationMs={}",
                runId,
                workingSatellites.size,
                plannedModesMs
            )

            val chunkSize = complexPlanProperties.batchChunkSize.coerceAtLeast(1)
            val chunkCount = (cells.size + chunkSize - 1) / chunkSize
            logger.info("Stage chunk-prepare: runId={}, chunkSize={}, chunks={}", runId, chunkSize, chunkCount)

            var processedCells = 0
            var acceptedSlots = 0
            for ((chunkIndex, chunk) in cells.chunked(chunkSize).withIndex()) {
                if (!hasRemainingCapacity(workingSatellites, intervalStart, intervalEnd)) {
                    logger.info(
                        "Ранняя остановка перед chunk {}: runId={}, ресурс ОГ исчерпан, processedCells={}",
                        chunkIndex + 1,
                        runId,
                        processedCells
                    )
                    break
                }

                val targetsChunk = buildTargetsChunk(chunk)
                var coverageByTargetId = emptyMap<Long, List<SlotDTO>>()
                val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites)
                logOccupiedIntervalsSnapshot(runId, chunkIndex + 1, chunkCount, occupiedIntervals)
                val coverageFetchMs = measureTimeMillis {
                    coverageByTargetId = coverageCalculationClient.coverageModes(
                        targets = targetsChunk,
                        intervalStart = intervalStart,
                        intervalEnd = intervalEnd,
                        satelliteIds = selectedSatelliteIds,
                        occupiedIntervals = occupiedIntervals,
                        sun = sun,
                        coverageStrategy = coverageStrategy
                    )
                }
                logger.info(
                    "Stage chunk-coverage-fetch: runId={}, chunk={}/{}, cells={}, targets={}, occupiedIntervals={}, returnedTargets={}, durationMs={}",
                    runId,
                    chunkIndex + 1,
                    chunkCount,
                    chunk.size,
                    targetsChunk.size,
                    occupiedIntervals.size,
                    coverageByTargetId.size,
                    coverageFetchMs
                )

                var acceptedForChunk = 0
                val chunkProcessMs = measureTimeMillis {
                    acceptedForChunk = processChunk(
                        runId = runId,
                        workingSatellites = workingSatellites,
                        cells = chunk,
                        coverageByTargetId = coverageByTargetId,
                        intervalStart = intervalStart,
                        intervalEnd = intervalEnd
                    )
                }
                processedCells += chunk.size
                acceptedSlots += acceptedForChunk
                logger.info(
                    "Stage chunk-process: runId={}, chunk={}/{}, cells={}, acceptedModes={}, durationMs={}",
                    runId,
                    chunkIndex + 1,
                    chunkCount,
                    chunk.size,
                    acceptedForChunk,
                    chunkProcessMs
                )
                coverageByTargetId = emptyMap()

                if (shouldStop(workingSatellites, intervalStart, intervalEnd)) {
                    logger.info(
                        "Ранняя остановка после chunk {}: runId={}, ресурс ОГ исчерпан, processedCells={}, acceptedModes={}",
                        chunkIndex + 1,
                        runId,
                        processedCells,
                        acceptedSlots
                    )
                    break
                }
            }

            var saveResult = SatelliteModeSaveResult()
            val persistMs = measureTimeMillis {
                saveResult = saveCalculatedModes(
                    runId = runId,
                    workingSatellites = workingSatellites,
                    intervalStart = intervalStart,
                    intervalEnd = intervalEnd
                )
            }
            logger.info(
                "Stage persist-modes: runId={}, processedCells={}, acceptedModes={}, persistedModes={}, durationMs={}",
                runId,
                processedCells,
                acceptedSlots,
                saveResult.calculatedModesCount,
                persistMs
            )
            var coveredArea = 0.0
            val coverageAreaMs = measureTimeMillis {
                coveredArea = calculateCoveredArea(workingSatellites, intervalStart, intervalEnd)
            }
            val coveredAreaPercent = coveredAreaPercent(totalRequestsArea, coveredArea)
            logger.info(
                "Stage coverage-area: runId={}, totalRequestsArea={}, coveredArea={}, coveredAreaPercent={}, durationMs={}",
                runId,
                totalRequestsArea,
                coveredArea,
                coveredAreaPercent,
                coverageAreaMs
            )
            logger.info(
                "Расчет комплексного плана завершен успешно: runId={}, calculatedModes={}, bookedModes={}, complanModes={}, mixedModes={}, affectedSatellites={}, processedCells={}, bookedSlotLinks={}, totalRequestsArea={}, coveredArea={}, coveredAreaPercent={}, durationMs={}",
                runId,
                saveResult.calculatedModesCount,
                saveResult.bookedModesCount,
                saveResult.complanModesCount,
                saveResult.mixedModesCount,
                saveResult.affectedSatellitesCount,
                saveResult.processedCellsCount,
                saveResult.bookedSlotLinksCount,
                totalRequestsArea,
                coveredArea,
                coveredAreaPercent,
                System.currentTimeMillis() - calculationStartedAtMs
            )
            return ComplexPlanCalculationResult(
                calculatedModesCount = saveResult.calculatedModesCount,
                bookedModesCount = saveResult.bookedModesCount,
                complanModesCount = saveResult.complanModesCount,
                mixedModesCount = saveResult.mixedModesCount,
                affectedSatellitesCount = saveResult.affectedSatellitesCount,
                processedCellsCount = saveResult.processedCellsCount,
                bookedSlotLinksCount = saveResult.bookedSlotLinksCount,
                totalRequestsArea = totalRequestsArea,
                coveredArea = coveredArea,
                coveredAreaPercent = coveredAreaPercent
            )
        } finally {
            logger.info(
                "Очистка локального рабочего состояния расчета: runId={}, elapsedMs={}",
                runId,
                System.currentTimeMillis() - calculationStartedAtMs
            )
        }
    }

    fun clear() {
        logger.info("Полная очистка persisted и временного состояния complex plan")
        complexPlanPersistenceService.clearAll()
        satelliteService.clear()
    }

    private fun addPlannedModes(
        runId: Long?,
        workingSatellites: List<SatelliteModel>,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime
    ) {
        val satelliteIds = workingSatellites.map { it.satelliteId }
        val plannedModesBySatelliteId = coverageBatchClient.plannedModes(satelliteIds, intervalStart, intervalEnd)
        val totalPlannedModes = plannedModesBySatelliteId.values.sumOf { it.size }
        logger.info(
            "Получено {} booked-маршрутов из slots-service для {} спутников",
            totalPlannedModes,
            plannedModesBySatelliteId.size
        )

        workingSatellites.forEach { satellite ->
            val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty()
            logBookedSlotsLoaded(runId, satellite.satelliteId, plannedForSatellite)
            plannedForSatellite.forEach { slot ->
                surveyPlacementService.mergeSurvey(
                    existingSurveys = satellite.longMission.surveys,
                    candidateSurvey = SurveyMode(
                        revolution = slot.revolution,
                        time = slot.tn,
                        timeStop = slot.tk,
                        roll = slot.roll,
                        latitude = slot.latitude,
                        longitude = slot.longitude,
                        duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(),
                        contourWKT = slot.contour,
                        cellNum = -1,
                        source = SatelliteModeSource.SLOTS,
                        bookedSlotIds = slot.slotIds,
                        cellNums = emptyList()
                    ),
                    satellite = satellite
                )
            }
            logger.info(
                "Добавлены booked-маршруты: runId={}, satelliteId={}, count={}",
                runId,
                satellite.satelliteId,
                plannedForSatellite.size
            )
        }
    }

    private fun buildCoverageTargets(cells: List<EarthCellWithRequestsDTO>): List<SlotCoverageTargetDTO> =
        cells.map { cell ->
            SlotCoverageTargetDTO(
                targetId = cell.targetId(),
                wkt = cellTargetContour(cell)
            )
        }

    private fun buildTargetsChunk(cells: List<EarthCellWithRequestsDTO>): List<SlotCoverageTargetDTO> =
        buildCoverageTargets(cells)

    private fun buildOccupiedIntervalsSnapshot(workingSatellites: List<SatelliteModel>): List<OccupiedIntervalDTO> =
        workingSatellites
            .asSequence()
            .flatMap { satellite ->
                satellite.longMission.surveys.asSequence().map { survey ->
                    OccupiedIntervalDTO(
                        satelliteId = satellite.satelliteId,
                        startTime = survey.time,
                        endTime = survey.timeStop,
                        roll = survey.roll,
                        source = survey.source.name
                    )
                }
            }
            .sortedWith(compareBy<OccupiedIntervalDTO> { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime })
            .toList()

    private fun logBookedSlotsLoaded(runId: Long?, satelliteId: Long, slots: List<SurveySlotDTO>) {
        if (slots.isEmpty()) {
            logger.info("Booked slots loaded: runId={}, satelliteId={}, count=0", runId, satelliteId)
            return
        }

        logger.info(
            "Booked slots loaded: runId={}, satelliteId={}, count={}, interval=[{} - {}], sample={}",
            runId,
            satelliteId,
            slots.size,
            slots.minOf { it.tn },
            slots.maxOf { it.tk },
            slots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { slot ->
                "start=${slot.tn},end=${slot.tk},roll=${slot.roll},revolution=${slot.revolution},slotIds=${slot.slotIds}"
            }
        )
    }

    private fun logOccupiedIntervalsSnapshot(
        runId: Long?,
        chunkNumber: Int,
        chunkCount: Int,
        occupiedIntervals: List<OccupiedIntervalDTO>
    ) {
        val bySource = occupiedIntervals.groupingBy { it.source ?: "UNKNOWN" }.eachCount()
        val slotsBySatellite = occupiedIntervals
            .filter { it.source == SatelliteModeSource.SLOTS.name }
            .groupingBy { it.satelliteId }
            .eachCount()

        logger.info(
            "Occupied intervals snapshot before coverage request: runId={}, chunk={}/{}, total={}, bySource={}, slotsBySatellite={}, slotsSample={}",
            runId,
            chunkNumber,
            chunkCount,
            occupiedIntervals.size,
            bySource,
            slotsBySatellite,
            occupiedIntervals
                .filter { it.source == SatelliteModeSource.SLOTS.name }
                .take(LOG_INTERVAL_SAMPLE_LIMIT)
                .map { interval ->
                    "satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}"
                }
        )
    }

    private fun processChunk(
        runId: Long?,
        workingSatellites: List<SatelliteModel>,
        cells: List<EarthCellWithRequestsDTO>,
        coverageByTargetId: Map<Long, List<SlotDTO>>,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime
    ): Int {
        var acceptedModes = 0

        for (cell in cells) {
            val resourceAvailableSatelliteIds = collectResourceAvailableSatelliteIds(workingSatellites, intervalStart, intervalEnd)
            if (resourceAvailableSatelliteIds.isEmpty()) {
                break
            }

            val activeSatelliteIds = resourceAvailableSatelliteIds.toSet()
            coverageByTargetId[cell.targetId()]
                .orEmpty()
                .asSequence()
                .filter { slot -> activeSatelliteIds.contains(slot.satelliteId) }
                .forEach { slot ->
                    val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId }
                        ?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван")

                    logComplanCandidateBookedSlotOverlaps(
                        runId = runId,
                        cell = cell,
                        slot = slot,
                        existingSurveys = satellite.longMission.surveys
                    )

                    val decision = surveyPlacementService.tryPlaceSurvey(
                        existingSurveys = satellite.longMission.surveys,
                        candidateSurvey = SurveyMode(
                            revolution = slot.revolution,
                            time = slot.tn,
                            timeStop = slot.tk,
                            roll = slot.roll,
                            latitude = slot.latitude,
                            longitude = slot.longitude,
                            duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(),
                            contourWKT = slot.contour,
                            cellNum = cell.num,
                            source = SatelliteModeSource.COMPLAN,
                            cellNums = listOf(cell.num)
                        ),
                        satellite = satellite
                    )
                    if (decision.accepted) {
                        acceptedModes++
                    } else {
                        logger.debug(
                            "Candidate rejected by placement: satelliteId={}, cellNum={}, revolution={}, reason={}",
                            slot.satelliteId,
                            cell.num,
                            slot.revolution,
                            decision.rejectionReason
                        )
                    }
                }
        }

        return acceptedModes
    }

    private fun logComplanCandidateBookedSlotOverlaps(
        runId: Long?,
        cell: EarthCellWithRequestsDTO,
        slot: SlotDTO,
        existingSurveys: List<SurveyMode>
    ) {
        val overlappingBookedSlots = existingSurveys
            .filter { survey -> survey.source == SatelliteModeSource.SLOTS }
            .filter { survey -> intersectsInTime(survey.time, survey.timeStop, slot.tn, slot.tk) }

        if (overlappingBookedSlots.isEmpty()) {
            return
        }

        logger.warn(
            "COMPLAN candidate overlaps booked SLOTS before placement: runId={}, satelliteId={}, targetId={}, cellNum={}, candidateInterval=[{} - {}], candidateRoll={}, candidateRevolution={}, overlaps={}, sameRollOverlaps={}, bookedSlotsSample={}",
            runId,
            slot.satelliteId,
            cell.targetId(),
            cell.num,
            slot.tn,
            slot.tk,
            slot.roll,
            slot.revolution,
            overlappingBookedSlots.size,
            overlappingBookedSlots.count { survey -> abs(survey.roll - slot.roll) <= ROLL_EPSILON },
            overlappingBookedSlots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { survey ->
                "start=${survey.time},end=${survey.timeStop},roll=${survey.roll},source=${survey.source},bookedSlotIds=${survey.bookedSlotIds}"
            }
        )
    }

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

    private fun hasRemainingCapacity(
        workingSatellites: List<SatelliteModel>,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime
    ): Boolean =
        collectResourceAvailableSatelliteIds(workingSatellites, intervalStart, intervalEnd).isNotEmpty()

    private fun shouldStop(
        workingSatellites: List<SatelliteModel>,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime
    ): Boolean = !hasRemainingCapacity(workingSatellites, intervalStart, intervalEnd)

    private fun initializeWorkingModes(
        workingSatellites: List<SatelliteModel>,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime
    ) {
        val workingModes = complexPlanQueryService.loadConstraintModes(
            workingSatellites.map { it.satelliteId },
            intervalStart,
            intervalEnd
        )
        workingSatellites.forEach { satellite ->
                satellite.longMission.surveys.clear()
                satellite.longMission.surveys.addAll(workingModes[satellite.satelliteId]
                    .orEmpty()
                    .filter { mode -> mode.timeStop <= intervalStart || mode.time >= intervalEnd || mode.source == SatelliteModeSource.MANUAL})
                logger.info(
                    "Загружено рабочее состояние для satelliteId={}: modesOutsideInterval={}",
                    satellite.satelliteId,
                    satellite.longMission.surveys.size
                )
            }
    }

    private fun saveCalculatedModes(
        runId: Long?,
        workingSatellites: List<SatelliteModel>,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime
    ): SatelliteModeSaveResult {
        val satelliteIds = workingSatellites.map { it.satelliteId }
        val surveysBySatelliteId = workingSatellites
            .associate { satellite -> satellite.satelliteId to satellite.longMission.surveys.toList() }
        val totalModes = surveysBySatelliteId.values.sumOf { it.size }
        logger.info(
            "Подготовлены данные к сохранению: runId={}, satellites={}, totalModes={}",
            runId,
            surveysBySatelliteId.keys,
            totalModes
        )

        return complexPlanPersistenceService.replaceModesForInterval(
            satelliteIds = satelliteIds,
            intervalStart = intervalStart,
            intervalEnd = intervalEnd,
            surveysBySatelliteId = surveysBySatelliteId,
            complexPlanRunId = runId
        )
    }

    private fun buildWorkingSatellites(satelliteIds: List<Long>): List<SatelliteModel> =
        satelliteService.satellites
            .filter { satelliteIds.contains(it.satelliteId) }
            .map { satellite ->
                SatelliteModel(
                    satelliteId = satellite.satelliteId,
                    name = satellite.name,
                    red = satellite.red,
                    green = satellite.green,
                    blue = satellite.blue,
                    longMission = LongTermMission(),
                    bls = satellite.bls,
                    scanTLE = satellite.scanTLE
                )
            }

    private fun collectResourceAvailableSatelliteIds(
        workingSatellites: List<SatelliteModel>,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime
    ): List<Long> {
        val availableSatelliteIds = linkedSetOf<Long>()
        var day = intervalStart.toLocalDate()
        val lastDay = intervalEnd.toLocalDate().minusDays(1)

        while (day <= lastDay) {
            workingSatellites.forEach { satellite ->
                if (satellite.hasResources(day)) {
                    availableSatelliteIds += satellite.satelliteId
                }
            }
            day = day.plusDays(1)
        }

        return availableSatelliteIds.toList()
    }

    private fun cellTargetContour(cell: EarthCellWithRequestsDTO): String {
        val requestContours = cell.requests
            .map { it.contour.trim() }
            .filter { it.isNotEmpty() }

        if (requestContours.isEmpty()) {
            return cell.contour
        }

        return requestContours.drop(1).fold(requestContours.first()) { merged, contour ->
            unionContours(merged, contour)
        }
    }

    private fun unionContours(baseContour: String, contourToAdd: String): String {
        return try {
            val reader = WKTReader()
            val writer = WKTWriter()
            writer.write(reader.read(baseContour).union(reader.read(contourToAdd)))
        } catch (e: Exception) {
            logger.warn("Не удалось объединить контуры маршрутов: {}", e.message)
            baseContour
        }
    }

    private fun buildRequestsGeometry(cells: List<EarthCellWithRequestsDTO>): Geometry? {
        val requestContours = cells
            .asSequence()
            .flatMap { cell -> cell.requests.asSequence() }
            .distinctBy { request -> requestFragmentKey(request.id, request.requestId, request.contour) }
            .map { request -> request.contour }
            .filter { contour -> contour.isNotBlank() }
            .toList()

        return unionGeometries(requestContours, "request")
    }

    private fun requestFragmentKey(id: Long, requestId: String, contour: String): String =
        if (id > 0) {
            "fragment:$id"
        } else {
            "request:${requestId.takeIf { it.isNotBlank() } ?: "unknown"}:$contour"
        }

    private fun calculateCoveredArea(
        workingSatellites: List<SatelliteModel>,
        intervalStart: LocalDateTime,
        intervalEnd: LocalDateTime
    ): Double {
        val coveredContours = workingSatellites
            .asSequence()
            .flatMap { satellite -> satellite.longMission.surveys.asSequence() }
            .filter { survey -> survey.source != SatelliteModeSource.MANUAL }
            .filter { survey -> survey.time < intervalEnd && intervalStart < survey.timeStop }
            .map { survey -> survey.contourWKT }
            .filter { contour -> contour.isNotBlank() }
            .toList()

        return unionGeometries(coveredContours, "covered")?.area ?: 0.0
    }

    private fun unionGeometries(contours: List<String>, context: String): Geometry? {
        val reader = WKTReader()
        val geometries = contours.mapNotNull { contour ->
            runCatching { reader.read(contour) }
                .onFailure { e -> logger.warn("Не удалось прочитать WKT {} geometry: {}", context, e.message) }
                .getOrNull()
                ?.takeIf { geometry -> !geometry.isEmpty }
        }

        if (geometries.isEmpty()) {
            return null
        }

        return runCatching {
            UnaryUnionOp.union(geometries)
        }.onFailure { e ->
            logger.warn("Не удалось объединить WKT {} geometries: {}", context, e.message)
        }.getOrNull()
    }

    private fun coveredAreaPercent(totalRequestsArea: Double, coveredArea: Double): Double =
        if (totalRequestsArea <= 0.0 || coveredArea <= 0.0) {
            0.0
        } else {
            coveredArea / totalRequestsArea * 100.0
        }

    private fun EarthCellWithRequestsDTO.targetId(): Long = if (id > 0) id else num
}
