package space.nstart.pcp.complan.apl


import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.LineString
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTReader
import space.nstart.pcp.complan.service.BallTaskServce
import space.nstart.pcp.complan.service.RequestServiceClient
import space.nstart.pcp.complan.service.SatelliteCatalogServiceClient
import space.nstart.pcp.complan.settings.AplSettings
import space.nstart.pcp.complan.spacecraft.SCConstraints
import space.nstart.pcp.complan.spacecraft.SpaceCraft
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import space.nstart.pcp.complan.types.AplObjItem
import space.nstart.pcp.complan.types.ComplexPlanCalculationOutput
import space.nstart.pcp.complan.types.ComplexPlanCalculationRequestDTO
import space.nstart.pcp.complan.types.ComplexPlanCalculationResultDTO
import space.nstart.pcp.complan.types.ComplexPlanCalculationStatus
import space.nstart.pcp.complan.types.ContourRequestDTO
import space.nstart.pcp.complan.types.DynamicPlanCalculationMode
import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode
import space.nstart.pcp.complan.types.GeoJsonItem
import space.nstart.pcp.complan.types.GreedyMathCandidateDTO
import space.nstart.pcp.complan.types.GreedyMathDebugRequestDTO
import space.nstart.pcp.complan.types.GreedyMathDebugResultDTO
import space.nstart.pcp.complan.types.IntervalItemDTO
import space.nstart.pcp.complan.types.ObjDTO
import space.nstart.pcp.complan.types.ObjViewRequestDTO
import space.nstart.pcp.complan.types.ObservationParametersDebugFileDTO
import space.nstart.pcp.complan.types.ObservationParametersDebugRequestDTO
import space.nstart.pcp.complan.types.ObservationParametersDebugResultDTO
import space.nstart.pcp.complan.types.RequestItemDTO
import space.nstart.pcp.complan.types.SurveyContour
import space.nstart.pcp.complan.types.TestCalcMarsDebugRequestDTO
import space.nstart.pcp.complan.types.TestCalcMarsDebugResultDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import space.nstart.pcp.complan.utils.extensions.toInt
import space.nstart.pcp.complan.settings.SettingsDTO
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.util.UUID
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import kotlin.collections.get
import kotlin.compareTo
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToLong
import kotlin.math.sin
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes

private const val INTERVAL_COVERAGE_AREA_TOLERANCE = 1.0e-9
private const val INTERVAL_ROUTE_WIDTH_RATIO = 0.9
private const val EARTH_RADIUS_KM = 6371.0088
private const val ROUTE_COVERAGE_AREA_TOLERANCE_KM2 = 1.0e-6

private data class PlanBuildResult(
    val routes: List<SurveyContour>
)

private data class GreedyMathRouteBuildResult(
    val routes: List<GreedyMathBuiltRoute>,
    val failuresCount: Int
)

private data class GreedyMathBuiltRoute(
    val candidate: GreedyRouteCandidate,
    val route: SurveyContour
)

private data class GreedyMathUsefulAreaResult(
    val routes: List<GreedyMathBuiltRoute>,
    val beforeCount: Int,
    val afterCount: Int
)

private data class TestCalcMarsRouteBuildResult(
    val attemptsCount: Int,
    val routesCount: Int,
    val failuresCount: Int
)

private data class MplCalculationChunkResult(
    val intervals: List<IntervalItemDTO>,
    val mpl: List<SquareViewParamDTO>
)

private enum class CalculationTimingStage {
    INTERVAL_BUILD,
    OBSERVATION_PARAMETERS,
    GREEDY_SELECTION,
    ROUTE_BUILD,
    USEFUL_AREA_ANALYSIS
}

/**
 * Накопитель замеров одного запуска расчета.
 * Один stage может вызываться несколько раз: например, интервалы строятся отдельно для восходящего и
 * нисходящего витка. Поэтому здесь храним суммарное время по смысловым этапам, а не последний вызов.
 */
private class CalculationTimings {
    private val durationsNanos = linkedMapOf<CalculationTimingStage, Long>()

    fun <T> measure(stage: CalculationTimingStage, block: () -> T): T {
        val startedAt = System.nanoTime()
        try {
            return block()
        } finally {
            val durationNanos = System.nanoTime() - startedAt
            durationsNanos[stage] = durationsNanos.getOrDefault(stage, 0L) + durationNanos
        }
    }

    fun millis(stage: CalculationTimingStage): Long =
        Duration.ofNanos(durationsNanos.getOrDefault(stage, 0L)).toMillis()
}

private fun <T> CalculationTimings?.measure(stage: CalculationTimingStage, block: () -> T): T =
    this?.measure(stage, block) ?: block()

/**
 * Причина, по которой greedy-путь не смог закрыть конкретный интервал съемки.
 * Разделение причин важно для оператора: отсутствие МПЛ требует пересчета/расширения баллистики,
 * а лимиты и конфликты означают, что наблюдения есть, но расписание КА уже исчерпало ресурс.
 */
internal enum class GreedyIntervalFailureReason {
    NO_MPL,
    FILTERED_OUT,
    REVOLUTION_LIMIT_EXCEEDED,
    DAILY_LIMIT_EXCEEDED,
    TIME_CONFLICT,
    CONTOUR_BUILD_FAILED,
    NO_FEASIBLE_CANDIDATE
}

/**
 * Диагностическая запись по одному незакрытому интервалу.
 * Хранит и технический intervalId, и regionId исходной заявки, чтобы можно было связать отказ
 * как с построенными интервалами, так и с объектом наблюдения.
 */
internal data class GreedyIntervalFailure(
    val intervalId: UUID,
    val regionId: UUID,
    val reason: GreedyIntervalFailureReason,
    val details: String
)

/**
 * Нормализованное представление одного МПЛ-варианта как кандидата на включение.
 * Здесь заранее фиксируются ключи учета ресурса КА: satelliteId, revolution и календарные сутки.
 * Это отделяет принятие решения по расписанию от исходного DTO баллистики.
 */
internal data class GreedyRouteCandidate(
    val intervalId: UUID,
    val regionId: UUID,
    val mpl: SquareViewParamDTO,
    val satelliteId: Long,
    val revolution: Long,
    val day: LocalDate,
    val timeBegin: LocalDateTime,
    val timeEnd: LocalDateTime,
    val durationSeconds: Long,
    val gamma: Double
)

/**
 * Снимок загрузки одного аппарата после greedy-выбора.
 * Используется для объяснения результата: какие маршруты заняли ресурс, сколько секунд уже
 * израсходовано на каждом витке и в каждые сутки.
 */
internal data class GreedySatelliteWorkload(
    val satelliteId: Long,
    val revolutionUsageSeconds: Map<Long, Long>,
    val dailyUsageSeconds: Map<LocalDate, Long>,
    val routes: List<GreedyRouteCandidate>
)

/**
 * Полный результат второго пути: построенные маршруты, незакрытые интервалы с причинами
 * и загрузка аппаратов. Старый путь возвращает только маршруты; greedy-путь дополнительно
 * обязан объяснять, почему не все интервалы удалось закрыть.
 */
internal data class GreedyPlanResult(
    val routes: List<SurveyContour>,
    val failures: List<GreedyIntervalFailure>,
    val workloads: List<GreedySatelliteWorkload>
) {
    val completedIntervals: Int = routes.size
    val failedIntervals: Int = failures.size
}

/**
 * Промежуточный результат выбора кандидатов до построения геометрии SurveyContour наружу.
 * Он нужен, чтобы чисто тестировать планировщик без HTTP-запросов в ballistics за точками орбиты.
 */
internal data class GreedySelectionResult(
    val selectedCandidates: List<GreedyRouteCandidate>,
    val failures: List<GreedyIntervalFailure>,
    val workloads: List<GreedySatelliteWorkload>
)

/**
 * Сводка покрытия заявки итоговыми маршрутами greedy-расчета.
 * `lastRouteStart/End` показывают фактический последний по времени маршрут, а процент покрытия считается
 * по пересечению объединения маршрутов с геометрией заявки.
 */
internal data class RouteCoverageSummary(
    val lastRouteStart: LocalDateTime?,
    val lastRouteEnd: LocalDateTime?,
    val coveredAreaPercent: Double?
)

/**
 * Результат сверки запрошенной группировки с каталогом КА.
 * Сервис продолжает расчет по найденным аппаратам, но явно возвращает/логирует отсутствующие,
 * чтобы клиент видел, почему часть группировки не попала в план.
 */
internal data class SatelliteSelection(
    val existingIds: List<Long>,
    val missingIds: List<Long>
)

/**
 * Отбирает только те КА из запроса, которые реально есть в satellite-catalog-service.
 * Порядок найденных аппаратов сохраняется по входному запросу: он дальше влияет на порядок загрузки трасс
 * и диагностические сообщения, но не подменяет состав группировки.
 */
internal fun selectSatellitesForCalculation(
    requestedSatelliteIds: Collection<Long>,
    catalogSatelliteIds: Collection<Long>
): SatelliteSelection {
    val catalogIds = catalogSatelliteIds.toSet()
    val requestedIds = requestedSatelliteIds.distinct()
    return SatelliteSelection(
        existingIds = requestedIds.filter { it in catalogIds },
        missingIds = requestedIds.filterNot { it in catalogIds }
    )
}

/**
 * Общая точка отбора МПЛ-вариантов для обоих путей построения маршрутов.
 * Сначала варианты упорядочиваются по минимальному модулю крена, чтобы старый путь 1 и новый greedy-путь
 * одинаково предпочитали более "дешевые" наблюдения. Затем применяются пользовательские фильтры gamma/revolution.
 */
internal fun selectMplItemsForRouteBuild(
    mplItems: List<SquareViewParamDTO>,
    settings: SettingsDTO
): List<SquareViewParamDTO> =
    mplItems
        .sortedBy { abs(it.gammaMin) }
        .filter { mplItem ->
            val passesGamma = !settings.filterByGamma ||
                abs((mplItem.gammaMin + mplItem.gammaMax) / 2) <= settings.gammaValue
            val passesRevolution = !settings.filterByRevolution ||
                mplItem.revSignBegin.toInt() == settings.revolutionType
            passesGamma && passesRevolution
        }

internal fun revolutionSignsForIntervals(revolutionMode: DynamicPlanRevolutionMode): List<RevolutionSign> =
    when (revolutionMode) {
        DynamicPlanRevolutionMode.ASC -> listOf(RevolutionSign.ASC)
        DynamicPlanRevolutionMode.DESC -> listOf(RevolutionSign.DESC)
        DynamicPlanRevolutionMode.BOTH -> listOf(RevolutionSign.DESC, RevolutionSign.ASC)
    }

/**
 * Фильтр маршрутов без количественного порога полезной площади.
 * Идем от ранних маршрутов к поздним и держим остаток геометрии заявки. Маршрут остается, если его контур
 * имеет ненулевое пересечение по площади с еще не покрытой частью заявки; после этого его контур вырезается
 * из остатка.
 */
internal fun filterRoutesByRemainingRequestGeometry(
    routes: List<SurveyContour>,
    requestGeometry: Geometry
): List<SurveyContour> {
    if (routes.isEmpty() || requestGeometry.isEmpty) {
        return routes
    }

    val wktReader = WKTReader()
    var remainingRequestGeometry = requestGeometry
    val keptRoutes = mutableListOf<SurveyContour>()
    routes
        .sortedWith(
            compareBy<SurveyContour> { it.time }
                .thenBy { it.duration }
                .thenBy { it.mpl.noradId }
        )
        .forEach { route ->
            if (remainingRequestGeometry.isEmpty) {
                return@forEach
            }

            val routeGeometry = route.geometry.toRouteGeometry(wktReader)
            if (routeGeometry == null) {
                keptRoutes += route
                return@forEach
            }

            val hasUsefulCoverage = runCatching {
                routeGeometry.intersection(remainingRequestGeometry).area > 0.0
            }.getOrDefault(true)
            if (!hasUsefulCoverage) {
                return@forEach
            }

            keptRoutes += route
            remainingRequestGeometry = remainingRequestGeometry.removeCoverage(routeGeometry)
        }

    return keptRoutes
}

private fun String.toRouteGeometry(wktReader: WKTReader): Geometry? =
    runCatching { wktReader.read(this).takeUnless { it.isEmpty } }.getOrNull()

private fun Geometry.removeCoverage(routeGeometry: Geometry): Geometry =
    runCatching { difference(routeGeometry) }.getOrDefault(this)

private fun Geometry.removeCoverage(routeGeometries: List<Geometry>): Geometry {
    val coverage = routeGeometries.unionGeometry() ?: return this
    return removeCoverage(coverage)
}

private fun List<Geometry>.unionGeometry(): Geometry? =
    reduceOrNull { coverage, geometry -> coverage.addCoverage(geometry) }

internal fun summarizeRouteCoverage(
    routes: List<SurveyContour>,
    requestGeometry: Geometry
): RouteCoverageSummary {
    if (routes.isEmpty() || requestGeometry.isEmpty) {
        return RouteCoverageSummary(
            lastRouteStart = null,
            lastRouteEnd = null,
            coveredAreaPercent = 0.0
        )
    }

    val lastRoute = routes.maxWithOrNull(
        compareBy<SurveyContour> { it.time }
            .thenBy { it.duration }
            .thenBy { it.mpl.noradId }
    )
    val routeUnion = WKTReader().let { wktReader ->
        routes
            .mapNotNull { it.geometry.toRouteGeometry(wktReader) }
            .unionGeometry()
    }
    val coveragePercent = runCatching {
        val requestAreaKm2 = requestGeometry.areaSquareKilometers()
        if (requestAreaKm2 <= ROUTE_COVERAGE_AREA_TOLERANCE_KM2 || routeUnion == null) {
            0.0
        } else {
            val coveredAreaKm2 = routeUnion.intersection(requestGeometry).areaSquareKilometers()
            (coveredAreaKm2 / requestAreaKm2 * 100.0).coerceIn(0.0, 100.0)
        }
    }.getOrNull()

    return RouteCoverageSummary(
        lastRouteStart = lastRoute?.time,
        lastRouteEnd = lastRoute?.endTime(),
        coveredAreaPercent = coveragePercent
    )
}

private fun SurveyContour.endTime(): LocalDateTime =
    time.plusNanos((duration * 1_000_000_000.0).roundToLong().coerceAtLeast(1L))

internal fun Geometry.areaSquareKilometers(): Double =
    when (geometryType) {
        "Polygon" -> (this as Polygon).areaSquareKilometers()
        "MultiPolygon", "GeometryCollection" -> (0 until numGeometries).sumOf { getGeometryN(it).areaSquareKilometers() }
        else -> 0.0
    }

private fun Polygon.areaSquareKilometers(): Double {
    val shellArea = abs(exteriorRing.coordinates.ringAreaSquareKilometers())
    val holesArea = (0 until numInteriorRing).sumOf { index ->
        abs(getInteriorRingN(index).coordinates.ringAreaSquareKilometers())
    }
    return max(shellArea - holesArea, 0.0)
}

private fun Array<org.locationtech.jts.geom.Coordinate>.ringAreaSquareKilometers(): Double {
    if (size < 4) {
        return 0.0
    }

    var sum = 0.0
    for (index in 0 until size - 1) {
        val current = this[index]
        val next = this[index + 1]
        val lat1 = current.y.toRadians()
        val lat2 = next.y.toRadians()
        val deltaLon = normalizeLongitudeDelta(next.x.toRadians() - current.x.toRadians())
        sum += deltaLon * (2.0 + sin(lat1) + sin(lat2))
    }
    return sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM / 2.0
}

private fun Double.toRadians(): Double = this * PI / 180.0

private fun normalizeLongitudeDelta(value: Double): Double =
    when {
        value > PI -> value - 2.0 * PI
        value < -PI -> value + 2.0 * PI
        else -> value
    }

private fun Geometry?.addCoverage(routeGeometry: Geometry): Geometry {
    val currentCoverage = this ?: return routeGeometry
    if (currentCoverage.isEmpty) {
        return routeGeometry
    }
    return runCatching { currentCoverage.union(routeGeometry) }.getOrDefault(currentCoverage)
}

/**
 * Чистая часть greedy-планировщика: на вход получает уже рассчитанные интервалы с МПЛ,
 * а на выходе возвращает выбранные включения, отказы и загрузку КА.
 *
 * Функция не ходит во внешние сервисы напрямую. Проверка `contourAvailable` передается извне,
 * чтобы production-код мог подтвердить построение контура, а unit-тесты могли проверять планирование
 * без зависимости от ballistics-service. Ограничения берутся из `SpaceCraft.constraints`; если КА не передан,
 * используется `defaultConstraints`, что сохраняет простой вызов для тестов и fallback-сценариев.
 */
internal fun selectGreedyRouteCandidates(
    intervals: List<IntervalItemDTO>,
    settings: SettingsDTO,
    spacecraft: Collection<SpaceCraft> = emptyList(),
    defaultConstraints: SCConstraints = SCConstraints(),
    revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH,
    contourAvailable: (GreedyRouteCandidate) -> Boolean = { true }
): GreedySelectionResult {
    val workload = GreedyWorkloadState(
        constraintsBySatellite = spacecraft.associate { it.id to it.constraints },
        defaultConstraints = defaultConstraints
    )
    val selectedCandidates = mutableListOf<GreedyRouteCandidate>()
    val failures = mutableListOf<GreedyIntervalFailure>()

    // Greedy-путь намеренно дает приоритет наблюдениям с минимальным модулем крена.
    // Это простое расписание, а не глобальный оптимизатор: первый принятый маршрут закрепляет ресурс КА
    // и влияет на следующие интервалы.
    intervals
        .filter { it.matches(revolutionMode) }
        .sortedWith(
            compareBy<IntervalItemDTO> { it.minGreedyRollAngle(settings) }
                .thenBy { interval -> interval.mpl.minOfOrNull { it.timeBegin } ?: LocalDateTime.MAX }
                .thenBy { it.id.toString() }
        )
        .forEach { interval ->
            if (interval.mpl.isEmpty()) {
                failures += interval.failure(GreedyIntervalFailureReason.NO_MPL, "Для интервала нет наблюдений в матрице планирования")
                return@forEach
            }

            val candidates = interval.toGreedyCandidates(settings)
            if (candidates.isEmpty()) {
                failures += interval.failure(GreedyIntervalFailureReason.FILTERED_OUT, "Все наблюдения отфильтрованы настройками gamma/revolution")
                return@forEach
            }

            val candidateFailures = mutableListOf<GreedyIntervalFailureReason>()
            val accepted = candidates
                .sortedWith(workload.greedyCandidateComparator())
                .firstOrNull { candidate ->
                    // Контур строим только после проверки ресурсов. Так не тратим дорогой вызов ballistics
                    // для кандидатов, которые заведомо не могут войти в расписание.
                    val rejectionReason = workload.rejectionReason(candidate)
                    if (rejectionReason != null) {
                        candidateFailures += rejectionReason
                        return@firstOrNull false
                    }
                    if (!contourAvailable(candidate)) {
                        candidateFailures += GreedyIntervalFailureReason.CONTOUR_BUILD_FAILED
                        return@firstOrNull false
                    }
                    true
                }

            if (accepted == null) {
                failures += interval.failure(candidateFailures)
            } else {
                workload.add(accepted)
                selectedCandidates += accepted
            }
        }

    return GreedySelectionResult(
        selectedCandidates = selectedCandidates,
        failures = failures,
        workloads = workload.snapshot()
    )
}

/**
 * Преобразует MPL-ответы баллистики в кандидатов greedy-планировщика.
 * Если баллистика вернула нулевой или отрицательный интервал времени, оставляем минимальную
 * длительность 1 секунда, чтобы такой кандидат не исчезал из расписания бесследно.
 */
private fun IntervalItemDTO.toGreedyCandidates(settings: SettingsDTO): List<GreedyRouteCandidate> =
    selectMplItemsForRouteBuild(mpl, settings).map { mplItem ->
        val durationSeconds = Duration.between(mplItem.timeBegin, mplItem.timeEnd)
            .seconds
            .coerceAtLeast(1L)
        GreedyRouteCandidate(
            intervalId = id,
            regionId = regionId,
            mpl = mplItem,
            satelliteId = mplItem.noradId,
            revolution = mplItem.revolutionBegin,
            day = mplItem.timeBegin.toLocalDate(),
            timeBegin = mplItem.timeBegin,
            timeEnd = mplItem.timeBegin.plusSeconds(durationSeconds),
            durationSeconds = durationSeconds,
            gamma = (mplItem.gammaMax + mplItem.gammaMin) / 2
        )
    }

private fun IntervalItemDTO.minGreedyRollAngle(settings: SettingsDTO): Double =
    toGreedyCandidates(settings).minOfOrNull { abs(it.gamma) } ?: Double.MAX_VALUE

private fun IntervalItemDTO.matches(revolutionMode: DynamicPlanRevolutionMode): Boolean =
    when (revolutionMode) {
        DynamicPlanRevolutionMode.BOTH -> true
        DynamicPlanRevolutionMode.ASC -> revSign == RevolutionSign.ASC
        DynamicPlanRevolutionMode.DESC -> revSign == RevolutionSign.DESC
    }

private fun IntervalItemDTO.failure(
    reason: GreedyIntervalFailureReason,
    details: String
) = GreedyIntervalFailure(
    intervalId = id,
    regionId = regionId,
    reason = reason,
    details = details
)

private fun IntervalItemDTO.failure(reasons: List<GreedyIntervalFailureReason>): GreedyIntervalFailure {
    val distinctReasons = reasons.distinct()
    val reason = when (distinctReasons.size) {
        0 -> GreedyIntervalFailureReason.NO_FEASIBLE_CANDIDATE
        1 -> distinctReasons.single()
        else -> GreedyIntervalFailureReason.NO_FEASIBLE_CANDIDATE
    }
    val details = if (distinctReasons.isEmpty()) {
        "Не найден подходящий кандидат маршрута"
    } else {
        "Причины отклонения кандидатов: ${distinctReasons.joinToString(", ")}"
    }
    return failure(reason, details)
}

private fun GreedyRouteCandidate.toDebugDto(): GreedyMathCandidateDTO =
    GreedyMathCandidateDTO(
        intervalId = intervalId,
        regionId = regionId,
        satelliteId = satelliteId,
        revolution = revolution,
        day = day,
        timeBegin = timeBegin,
        timeEnd = timeEnd,
        durationSeconds = durationSeconds,
        gamma = gamma
    )

internal fun filterIntervalsForGreedyMathFile(
    intervals: List<IntervalItemDTO>,
    selectedSatelliteIds: Collection<Long>,
    calculationStart: LocalDateTime,
    calculationEnd: LocalDateTime
): List<IntervalItemDTO> {
    val selectedSatelliteIds = selectedSatelliteIds.toSet()
    return intervals.map { interval ->
        interval.copy(
            mpl = interval.mpl
                .filter { mpl ->
                    mpl.noradId in selectedSatelliteIds &&
                        !mpl.timeBegin.isBefore(calculationStart) &&
                        !mpl.timeEnd.isAfter(calculationEnd)
                }
                .toMutableList()
        )
    }
}

/**
 * Mutable-состояние greedy-расписания на время одного расчета.
 * Класс инкапсулирует все проверки загрузки аппарата, чтобы основной проход по интервалам
 * оставался читаемым: выбрать кандидата, спросить state о причине отказа, принять или отклонить.
 * Временные лимиты не зашиты здесь: state читает их из SCConstraints конкретного аппарата.
 */
private class GreedyWorkloadState(
    private val constraintsBySatellite: Map<Long, SCConstraints>,
    private val defaultConstraints: SCConstraints
) {
    private val routesBySatellite = linkedMapOf<Long, MutableList<GreedyRouteCandidate>>()
    private val revolutionUsage = linkedMapOf<Pair<Long, Long>, Long>()
    private val dailyUsage = linkedMapOf<Pair<Long, LocalDate>, Long>()

    // Внутри одного интервала сначала пробуем включения с меньшим модулем крена, затем менее загруженный КА.
    // Время остается tie-breaker, чтобы одинаковые по крену варианты сохраняли предсказуемый порядок.
    fun greedyCandidateComparator(): Comparator<GreedyRouteCandidate> =
        compareBy<GreedyRouteCandidate> { abs(it.gamma) }
            .thenBy { revolutionUsageSeconds(it) }
            .thenBy { dailyUsageSeconds(it) }
            .thenBy { it.timeBegin }
            .thenBy { it.satelliteId }

    fun rejectionReason(candidate: GreedyRouteCandidate): GreedyIntervalFailureReason? {
        val constraints = constraintsFor(candidate)
        if (revolutionUsageSeconds(candidate) + candidate.durationSeconds > constraints.maxRevolutionDurationSeconds) {
            return GreedyIntervalFailureReason.REVOLUTION_LIMIT_EXCEEDED
        }
        if (dailyUsageSeconds(candidate) + candidate.durationSeconds > constraints.maxDailyDurationSeconds) {
            return GreedyIntervalFailureReason.DAILY_LIMIT_EXCEEDED
        }
        if (hasTimeConflict(candidate)) {
            return GreedyIntervalFailureReason.TIME_CONFLICT
        }
        return null
    }

    fun add(candidate: GreedyRouteCandidate) {
        routesBySatellite.getOrPut(candidate.satelliteId) { mutableListOf() } += candidate
        val revolutionKey = candidate.satelliteId to candidate.revolution
        revolutionUsage[revolutionKey] = revolutionUsage.getOrDefault(revolutionKey, 0L) + candidate.durationSeconds
        val dayKey = candidate.satelliteId to candidate.day
        dailyUsage[dayKey] = dailyUsage.getOrDefault(dayKey, 0L) + candidate.durationSeconds
    }

    fun snapshot(): List<GreedySatelliteWorkload> =
        routesBySatellite.entries
            .sortedBy { it.key }
            .map { (satelliteId, routes) ->
                GreedySatelliteWorkload(
                    satelliteId = satelliteId,
                    revolutionUsageSeconds = revolutionUsage
                        .filterKeys { it.first == satelliteId }
                        .mapKeys { it.key.second }
                        .toSortedMap(),
                    dailyUsageSeconds = dailyUsage
                        .filterKeys { it.first == satelliteId }
                        .mapKeys { it.key.second }
                        .toSortedMap(),
                    routes = routes.sortedBy { it.timeBegin }
                )
            }

    private fun revolutionUsageSeconds(candidate: GreedyRouteCandidate): Long =
        revolutionUsage.getOrDefault(candidate.satelliteId to candidate.revolution, 0L)

    private fun dailyUsageSeconds(candidate: GreedyRouteCandidate): Long =
        dailyUsage.getOrDefault(candidate.satelliteId to candidate.day, 0L)

    private fun constraintsFor(candidate: GreedyRouteCandidate): SCConstraints =
        constraintsBySatellite[candidate.satelliteId] ?: defaultConstraints

    // Маршруты одного аппарата не должны пересекаться даже частично; технологический разрыв 60 секунд
    // учитывается с обеих сторон уже выбранного маршрута.
    private fun hasTimeConflict(candidate: GreedyRouteCandidate): Boolean {
        val routeGapSeconds = constraintsFor(candidate).routeGapSeconds
        return routesBySatellite[candidate.satelliteId].orEmpty().any { existing ->
            candidate.timeBegin < existing.timeEnd.plusSeconds(routeGapSeconds) &&
                candidate.timeEnd.plusSeconds(routeGapSeconds) > existing.timeBegin
        }
    }
}

/**
 * Application-service расчета комплексного плана.
 *
 * Класс отвечает за orchestration полного сценария: загрузить заявку и КА, запросить баллистические данные,
 * построить интервалы, рассчитать матрицу планирования и передать выбранные включения в построитель контуров.
 * Геометрические и баллистические расчеты делегируются специализированным сервисам; внутри класса остаются
 * правила выбора маршрутов и сборка результата расчета.
 */
@Service
class BasicPlanCalculator(
    /**Заявки для расчета комплексного плана*/
    private val catalog : MutableList<RequestItemDTO>,

    private val aplCatalog : MutableList<AplObjItem>,
    /**Группировка КА (SpaceCraftGroup)*/
    private val scg : MutableList<SpaceCraft>
) {
    /** Логирование*/
    private val log = LoggerFactory.getLogger(this::class.java)
    private val captureAngleCalculator = CaptureAngleCalculator()

    private var writeLog : Boolean = false
    private var activeCalculationStart: LocalDateTime? = null
    private var activeCalculationEnd: LocalDateTime? = null

    @Value("\${complex-plan.interval-coverage-check-enabled:false}")
    private var intervalCoverageCheckEnabled: Boolean = false

    @Value("\${complex-plan.observation-parameters.parallel-enabled:false}")
    private var parallelObservationParametersEnabled: Boolean = false

    @Value("\${complex-plan.observation-parameters.chunk-size:50}")
    private var observationParametersChunkSize: Int = 50

    @Value("\${complex-plan.observation-parameters.parallelism:4}")
    private var observationParametersParallelism: Int = 4

    @Autowired
    private lateinit var ballTask: BallTaskServce

    @Autowired
    private lateinit var surveyContourBuilder: SurveyContourBuilder

    @Autowired
    private lateinit var requestServiceClient: RequestServiceClient

    @Autowired
    private lateinit var satelliteCatalogServiceClient: SatelliteCatalogServiceClient

    @Autowired
    private lateinit var aplSettings: AplSettings

    @Autowired
    private lateinit var geoJsonLayerWriter: GeoJsonLayerWriter

    @Autowired
    private lateinit var observationParametersDebugFileWriter: ObservationParametersDebugFileWriter

    /**
     * Публичный путь 1, который сохраняет существующий контракт сервиса.
     * Он строит все маршруты по доступным МПЛ-вариантам после фильтрации настроек и возвращает только контуры,
     * без анализа суточной/витковой загрузки аппаратов.
     */
    fun calculate(request: ComplexPlanCalculationRequestDTO): ComplexPlanCalculationOutput {
        if (request.calculationMode == DynamicPlanCalculationMode.GREEDY) {
            return calculateGreedyOutput(request)
        }

        val startedAt = Instant.now()
        val timings = CalculationTimings()
        try {
            val selectedSatelliteIds = prepareCalculationData(request)
            val status = when {
                catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND
                selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES
                else -> ComplexPlanCalculationStatus.COMPLETED
            }
            val planResult = if (status == ComplexPlanCalculationStatus.COMPLETED) {
                buildPlan(
                    filterCoveredRoutes = request.filterCoveredRoutes,
                    revolutionMode = request.revolutionMode,
                    timings = timings
                )
            } else {
                PlanBuildResult(routes = emptyList())
            }
            val durationMs = Duration.between(startedAt, Instant.now()).toMillis()
            logCalculationTimings(
                request = request,
                status = status,
                selectedSatelliteIds = selectedSatelliteIds,
                routesCount = planResult.routes.size,
                durationMs = durationMs,
                timings = timings
            )

            return ComplexPlanCalculationOutput(
                result = ComplexPlanCalculationResultDTO(
                    status = status,
                    requestId = request.requestId,
                    satelliteIds = selectedSatelliteIds,
                    missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) },
                    calculationStart = request.calculationStart,
                    calculationEnd = request.calculationEnd,
                    calculationMode = request.calculationMode,
                    revolutionMode = request.revolutionMode,
                    filterCoveredRoutes = request.filterCoveredRoutes,
                    routesCount = planResult.routes.size,
                    durationMs = durationMs
                ),
                routes = planResult.routes,
                intervals = calculationIntervals()
            )
        } catch (error: Exception) {
            logCalculationFailureTimings(request, Duration.between(startedAt, Instant.now()).toMillis(), timings, error)
            throw error
        }
    }

    /**
     * Debug-сценарий для сравнения последовательного и chunked-parallel расчета МПЛ.
     * Он выполняет только подготовку данных, построение интервалов и расчет параметров наблюдения,
     * не строит маршруты и не сохраняет run в БД.
     */
    fun calculateObservationParametersDebug(
        request: ObservationParametersDebugRequestDTO
    ): ObservationParametersDebugResultDTO {
        val startedAt = Instant.now()
        val timings = CalculationTimings()
        val selectedSatelliteIds = prepareCalculationData(
            ComplexPlanCalculationRequestDTO(
                requestId = request.requestId,
                satelliteIds = request.satelliteIds,
                calculationStart = request.calculationStart,
                calculationEnd = request.calculationEnd
            )
        )
        val status = when {
            catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND
            selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES
            else -> ComplexPlanCalculationStatus.COMPLETED
        }

        if (status == ComplexPlanCalculationStatus.COMPLETED) {
            getIntervalsForAplCatalog(
                timings = timings,
                parallelObservationParameters = request.parallelBallisticsCalculation,
                observationParametersChunkSizeOverride = request.chunkSize,
                observationParametersParallelismOverride = request.parallelism
            )
        }

        val intervals = aplCatalog.flatMap { it.intevals }
        val intervalsCount = intervals.size
        val observationParametersCount = aplCatalog.sumOf { aplItem ->
            aplItem.intevals.sumOf { interval -> interval.mpl.size }
        }
        val missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) }
        val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) {
            observationParametersDebugFileWriter.write(
                ObservationParametersDebugFileDTO(
                    createdAt = UtcDateTimes.now(),
                    requestId = request.requestId,
                    satelliteIds = selectedSatelliteIds,
                    missingSatelliteIds = missingSatelliteIds,
                    calculationStart = request.calculationStart,
                    calculationEnd = request.calculationEnd,
                    parallelBallisticsCalculation = request.parallelBallisticsCalculation,
                    chunkSize = request.chunkSize,
                    parallelism = request.parallelism,
                    intervalsCount = intervalsCount,
                    observationParametersCount = observationParametersCount,
                    intervals = intervals
                )
            ).toString()
        } else {
            null
        }
        val chunksCount = if (request.parallelBallisticsCalculation) {
            observationParametersChunksCount(request.chunkSize)
        } else {
            0
        }
        val totalMs = Duration.between(startedAt, Instant.now()).toMillis()
        log.info(
            "Debug-расчет параметров наблюдения завершен: requestId={}, satellites={}, status={}, parallelBallisticsCalculation={}, intervals={}, observationParameters={}, chunks={}, totalMs={}, intervalsMs={}, observationParametersMs={}",
            request.requestId,
            selectedSatelliteIds,
            status,
            request.parallelBallisticsCalculation,
            intervalsCount,
            observationParametersCount,
            chunksCount,
            totalMs,
            timings.millis(CalculationTimingStage.INTERVAL_BUILD),
            timings.millis(CalculationTimingStage.OBSERVATION_PARAMETERS)
        )

        return ObservationParametersDebugResultDTO(
            requestId = request.requestId,
            satelliteIds = selectedSatelliteIds,
            missingSatelliteIds = missingSatelliteIds,
            calculationStart = request.calculationStart,
            calculationEnd = request.calculationEnd,
            status = status,
            parallelBallisticsCalculation = request.parallelBallisticsCalculation,
            intervalsCount = intervalsCount,
            observationParametersCount = observationParametersCount,
            chunksCount = chunksCount,
            intervalsMs = timings.millis(CalculationTimingStage.INTERVAL_BUILD),
            observationParametersMs = timings.millis(CalculationTimingStage.OBSERVATION_PARAMETERS),
            totalMs = totalMs,
            savedToFile = resultFilePath != null,
            resultFilePath = resultFilePath
        )
    }

    /**
     * Debug-сценарий greedy-математики.
     *
     * В отличие от обычного GREEDY-расчета, здесь не считается матрица планирования, не пишутся geo-layers
     * и не создается запись run в БД. Endpoint нужен для быстрой диагностики greedy-выбора по заранее
     * сохраненным интервалам с МПЛ; при включенном filterCoveredRoutes он строит маршруты только для
     * проверки пересечения с еще не покрытой геометрией заявки.
     */
    fun calculateGreedyMathDebug(request: GreedyMathDebugRequestDTO): GreedyMathDebugResultDTO {
        val startedAt = Instant.now()
        val timings = CalculationTimings()
        val selectedSatelliteIds = prepareCalculationData(
            request = ComplexPlanCalculationRequestDTO(
                requestId = request.requestId,
                satelliteIds = request.satelliteIds,
                calculationStart = request.calculationStart,
                calculationEnd = request.calculationEnd,
                revolutionMode = request.revolutionMode
            ),
            writeLayers = false,
            loadBallisticsData = false
        )
        val status = when {
            catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND
            selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES
            else -> ComplexPlanCalculationStatus.COMPLETED
        }

        val observationParametersFile = observationParametersDebugFileWriter.filePathFor(request.requestId)
        val intervals = if (status == ComplexPlanCalculationStatus.COMPLETED) {
            val artifact = observationParametersDebugFileWriter.read(observationParametersFile)
            filterIntervalsForGreedyMathFile(
                intervals = artifact.intervals,
                selectedSatelliteIds = selectedSatelliteIds,
                calculationStart = request.calculationStart,
                calculationEnd = request.calculationEnd
            )
        } else {
            emptyList()
        }
        val selection = if (status == ComplexPlanCalculationStatus.COMPLETED) {
            timings.measure(CalculationTimingStage.GREEDY_SELECTION) {
                selectGreedyRouteCandidates(
                    intervals = intervals,
                    settings = aplSettings.settings,
                    spacecraft = scg,
                    revolutionMode = request.revolutionMode
                )
            }
        } else {
            GreedySelectionResult(
                selectedCandidates = emptyList(),
                failures = emptyList(),
                workloads = emptyList()
            )
        }
        val routeBuildResult = if (status == ComplexPlanCalculationStatus.COMPLETED && request.filterCoveredRoutes) {
            timings.measure(CalculationTimingStage.ROUTE_BUILD) {
                buildRoutesForGreedyMath(selection.selectedCandidates)
            }
        } else {
            GreedyMathRouteBuildResult(routes = emptyList(), failuresCount = 0)
        }
        val usefulAreaResult = if (status == ComplexPlanCalculationStatus.COMPLETED && request.filterCoveredRoutes) {
            timings.measure(CalculationTimingStage.USEFUL_AREA_ANALYSIS) {
                applyRemainingRequestGeometryFilterToGreedyMathRoutes(routeBuildResult.routes)
            }
        } else {
            GreedyMathUsefulAreaResult(
                routes = routeBuildResult.routes,
                beforeCount = routeBuildResult.routes.size,
                afterCount = routeBuildResult.routes.size
            )
        }
        val selectedCandidates = if (request.filterCoveredRoutes) {
            usefulAreaResult.routes.map { it.candidate }
        } else {
            selection.selectedCandidates
        }

        val intervalsCount = intervals.size
        val observationParametersCount = intervals.sumOf { interval -> interval.mpl.size }
        val totalMs = Duration.between(startedAt, Instant.now()).toMillis()
        log.info(
            "Debug-расчет greedy-математики из файла завершен: requestId={}, satellites={}, status={}, revolutionMode={}, observationParametersFile={}, intervals={}, observationParameters={}, selectedCandidates={}, failures={}, filterCoveredRoutes={}, routesBeforeUsefulArea={}, routesAfterUsefulArea={}, routeBuildFailures={}, totalMs={}, greedySelectionMs={}, routeBuildMs={}, usefulAreaAnalysisMs={}",
            request.requestId,
            selectedSatelliteIds,
            status,
            request.revolutionMode,
            observationParametersFile,
            intervalsCount,
            observationParametersCount,
            selectedCandidates.size,
            selection.failures.size,
            request.filterCoveredRoutes,
            usefulAreaResult.beforeCount,
            usefulAreaResult.afterCount,
            routeBuildResult.failuresCount,
            totalMs,
            timings.millis(CalculationTimingStage.GREEDY_SELECTION),
            timings.millis(CalculationTimingStage.ROUTE_BUILD),
            timings.millis(CalculationTimingStage.USEFUL_AREA_ANALYSIS)
        )

        return GreedyMathDebugResultDTO(
            requestId = request.requestId,
            satelliteIds = selectedSatelliteIds,
            missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) },
            calculationStart = request.calculationStart,
            calculationEnd = request.calculationEnd,
            status = status,
            revolutionMode = request.revolutionMode,
            observationParametersFilePath = if (status == ComplexPlanCalculationStatus.COMPLETED) observationParametersFile.toString() else null,
            intervalsCount = intervalsCount,
            observationParametersCount = observationParametersCount,
            selectedCandidatesCount = selectedCandidates.size,
            failuresCount = selection.failures.size,
            selectedCandidates = selectedCandidates.map { it.toDebugDto() },
            filterCoveredRoutes = request.filterCoveredRoutes,
            routesBeforeUsefulAreaFilterCount = usefulAreaResult.beforeCount,
            routesAfterUsefulAreaFilterCount = usefulAreaResult.afterCount,
            routeBuildFailuresCount = routeBuildResult.failuresCount,
            greedySelectionMs = timings.millis(CalculationTimingStage.GREEDY_SELECTION),
            routeBuildMs = timings.millis(CalculationTimingStage.ROUTE_BUILD),
            usefulAreaAnalysisMs = timings.millis(CalculationTimingStage.USEFUL_AREA_ANALYSIS),
            totalMs = totalMs
        )
    }

    private fun buildRoutesForGreedyMath(
        selectedCandidates: List<GreedyRouteCandidate>
    ): GreedyMathRouteBuildResult {
        var failuresCount = 0
        val routes = selectedCandidates.mapNotNull { candidate ->
            val contour = calculateSurveyContour(candidate.toContourRequest())
            if (contour == null) {
                failuresCount += 1
                null
            } else {
                GreedyMathBuiltRoute(
                    candidate = candidate,
                    route = SurveyContour(
                        mpl = candidate.mpl,
                        time = candidate.timeBegin,
                        duration = candidate.durationSeconds.toDouble(),
                        rev = candidate.revolution,
                        gamma = candidate.gamma,
                        geometry = contour
                    )
                )
            }
        }

        return GreedyMathRouteBuildResult(routes = routes, failuresCount = failuresCount)
    }

    private fun applyRemainingRequestGeometryFilterToGreedyMathRoutes(
        routes: List<GreedyMathBuiltRoute>
    ): GreedyMathUsefulAreaResult {
        val requestGeometry = requestCoverageGeometry()
        if (requestGeometry == null || requestGeometry.isEmpty) {
            log.warn("Фильтрация полезной площади в greedy-math пропущена: нет геометрии заявки")
            return GreedyMathUsefulAreaResult(routes = routes, beforeCount = routes.size, afterCount = routes.size)
        }

        val remainingRoutes = routes.toMutableList()
        val filteredRoutes = filterRoutesByRemainingRequestGeometry(
            routes = routes.map { it.route },
            requestGeometry = requestGeometry
        ).mapNotNull { surveyRoute ->
            val index = remainingRoutes.indexOfFirst { it.route == surveyRoute }
            if (index < 0) {
                null
            } else {
                remainingRoutes.removeAt(index)
            }
        }

        return GreedyMathUsefulAreaResult(
            routes = filteredRoutes,
            beforeCount = routes.size,
            afterCount = filteredRoutes.size
        )
    }

    /**
     * Debug-сценарий замера построения маршрутов по всем параметрам видимости из файлового артефакта.
     *
     * В отличие от greedy-math, здесь нет greedy-выбора и ресурсных ограничений: каждый MPL, оставшийся после
     * фильтрации по КА и временному интервалу, независимо отправляется в построение контура маршрута.
     */
    fun calculateTestCalcMarsDebug(request: TestCalcMarsDebugRequestDTO): TestCalcMarsDebugResultDTO {
        val startedAt = Instant.now()
        val timings = CalculationTimings()
        val selectedSatelliteIds = prepareCalculationData(
            request = ComplexPlanCalculationRequestDTO(
                requestId = request.requestId,
                satelliteIds = request.satelliteIds,
                calculationStart = request.calculationStart,
                calculationEnd = request.calculationEnd
            ),
            writeLayers = false,
            loadBallisticsData = false
        )
        val status = when {
            catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND
            selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES
            else -> ComplexPlanCalculationStatus.COMPLETED
        }

        val observationParametersFile = observationParametersDebugFileWriter.filePathFor(request.requestId)
        val intervals = if (status == ComplexPlanCalculationStatus.COMPLETED) {
            val artifact = observationParametersDebugFileWriter.read(observationParametersFile)
            filterIntervalsForGreedyMathFile(
                intervals = artifact.intervals,
                selectedSatelliteIds = selectedSatelliteIds,
                calculationStart = request.calculationStart,
                calculationEnd = request.calculationEnd
            )
        } else {
            emptyList()
        }
        val routeBuildResult = if (status == ComplexPlanCalculationStatus.COMPLETED) {
            timings.measure(CalculationTimingStage.ROUTE_BUILD) {
                buildRoutesForAllObservationParameters(intervals)
            }
        } else {
            TestCalcMarsRouteBuildResult(attemptsCount = 0, routesCount = 0, failuresCount = 0)
        }

        val intervalsCount = intervals.size
        val observationParametersCount = intervals.sumOf { interval -> interval.mpl.size }
        val totalMs = Duration.between(startedAt, Instant.now()).toMillis()
        log.info(
            "Debug-построение маршрутов по всем параметрам видимости завершено: requestId={}, satellites={}, status={}, observationParametersFile={}, intervals={}, observationParameters={}, routeBuildAttempts={}, routes={}, failures={}, routeBuildMs={}, totalMs={}",
            request.requestId,
            selectedSatelliteIds,
            status,
            observationParametersFile,
            intervalsCount,
            observationParametersCount,
            routeBuildResult.attemptsCount,
            routeBuildResult.routesCount,
            routeBuildResult.failuresCount,
            timings.millis(CalculationTimingStage.ROUTE_BUILD),
            totalMs
        )

        return TestCalcMarsDebugResultDTO(
            requestId = request.requestId,
            satelliteIds = selectedSatelliteIds,
            missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) },
            calculationStart = request.calculationStart,
            calculationEnd = request.calculationEnd,
            status = status,
            observationParametersFilePath = if (status == ComplexPlanCalculationStatus.COMPLETED) observationParametersFile.toString() else null,
            intervalsCount = intervalsCount,
            observationParametersCount = observationParametersCount,
            routeBuildAttemptsCount = routeBuildResult.attemptsCount,
            routesCount = routeBuildResult.routesCount,
            routeBuildFailuresCount = routeBuildResult.failuresCount,
            routeBuildMs = timings.millis(CalculationTimingStage.ROUTE_BUILD),
            totalMs = totalMs
        )
    }

    private fun buildRoutesForAllObservationParameters(
        intervals: List<IntervalItemDTO>
    ): TestCalcMarsRouteBuildResult {
        var attemptsCount = 0
        var routesCount = 0
        var failuresCount = 0
        intervals.forEach { interval ->
            interval.mpl.forEach { mpl ->
                attemptsCount += 1
                val contour = calculateSurveyContour(mpl.toContourRequest())
                if (contour == null) {
                    failuresCount += 1
                } else {
                    routesCount += 1
                }
            }
        }

        return TestCalcMarsRouteBuildResult(
            attemptsCount = attemptsCount,
            routesCount = routesCount,
            failuresCount = failuresCount
        )
    }

    private fun calculateGreedyOutput(request: ComplexPlanCalculationRequestDTO): ComplexPlanCalculationOutput {
        val startedAt = Instant.now()
        val timings = CalculationTimings()
        try {
            val selectedSatelliteIds = prepareCalculationData(request)
            val status = when {
                catalog.isEmpty() -> ComplexPlanCalculationStatus.REQUEST_NOT_FOUND
                selectedSatelliteIds.isEmpty() -> ComplexPlanCalculationStatus.NO_AVAILABLE_SATELLITES
                else -> ComplexPlanCalculationStatus.COMPLETED
            }
            val planResult = if (status == ComplexPlanCalculationStatus.COMPLETED) {
                calcGreedyMars(
                    filterCoveredRoutes = request.filterCoveredRoutes,
                    revolutionMode = request.revolutionMode,
                    timings = timings
                )
            } else {
                GreedyPlanResult(routes = emptyList(), failures = emptyList(), workloads = emptyList())
            }
            val coverageSummary = if (status == ComplexPlanCalculationStatus.COMPLETED) {
                requestCoverageGeometry()?.let { summarizeRouteCoverage(planResult.routes, it) }
            } else {
                null
            }
            val durationMs = Duration.between(startedAt, Instant.now()).toMillis()
            logCalculationTimings(
                request = request,
                status = status,
                selectedSatelliteIds = selectedSatelliteIds,
                routesCount = planResult.routes.size,
                durationMs = durationMs,
                timings = timings
            )

            return ComplexPlanCalculationOutput(
                result = ComplexPlanCalculationResultDTO(
                    status = status,
                    requestId = request.requestId,
                    satelliteIds = selectedSatelliteIds,
                    missingSatelliteIds = request.satelliteIds.distinct().filterNot { selectedSatelliteIds.contains(it) },
                    calculationStart = request.calculationStart,
                    calculationEnd = request.calculationEnd,
                    calculationMode = request.calculationMode,
                    revolutionMode = request.revolutionMode,
                    filterCoveredRoutes = request.filterCoveredRoutes,
                    routesCount = planResult.routes.size,
                    lastRouteStart = coverageSummary?.lastRouteStart,
                    lastRouteEnd = coverageSummary?.lastRouteEnd,
                    coveredAreaPercent = coverageSummary?.coveredAreaPercent,
                    durationMs = durationMs
                ),
                routes = planResult.routes,
                intervals = calculationIntervals()
            )
        } catch (error: Exception) {
            logCalculationFailureTimings(request, Duration.between(startedAt, Instant.now()).toMillis(), timings, error)
            throw error
        }
    }

    private fun logCalculationTimings(
        request: ComplexPlanCalculationRequestDTO,
        status: ComplexPlanCalculationStatus,
        selectedSatelliteIds: List<Long>,
        routesCount: Int,
        durationMs: Long,
        timings: CalculationTimings
    ) {
        log.info(
            "Расчет комплексного плана завершен: requestId={}, mode={}, revolutionMode={}, status={}, satellites={}, routes={}, totalMs={}, intervalsMs={}, observationParametersMs={}, greedySelectionMs={}, usefulAreaAnalysisMs={}",
            request.requestId,
            request.calculationMode,
            request.revolutionMode,
            status,
            selectedSatelliteIds,
            routesCount,
            durationMs,
            timings.millis(CalculationTimingStage.INTERVAL_BUILD),
            timings.millis(CalculationTimingStage.OBSERVATION_PARAMETERS),
            timings.millis(CalculationTimingStage.GREEDY_SELECTION),
            timings.millis(CalculationTimingStage.USEFUL_AREA_ANALYSIS)
        )
    }

    private fun logCalculationFailureTimings(
        request: ComplexPlanCalculationRequestDTO,
        durationMs: Long,
        timings: CalculationTimings,
        error: Exception
    ) {
        log.warn(
            "Расчет комплексного плана прерван: requestId={}, mode={}, revolutionMode={}, totalMs={}, intervalsMs={}, observationParametersMs={}, greedySelectionMs={}, usefulAreaAnalysisMs={}, errorType={}, errorMessage={}",
            request.requestId,
            request.calculationMode,
            request.revolutionMode,
            durationMs,
            timings.millis(CalculationTimingStage.INTERVAL_BUILD),
            timings.millis(CalculationTimingStage.OBSERVATION_PARAMETERS),
            timings.millis(CalculationTimingStage.GREEDY_SELECTION),
            timings.millis(CalculationTimingStage.USEFUL_AREA_ANALYSIS),
            error.javaClass.simpleName,
            error.message
        )
    }

    /**
     * Вход во второй путь расчета. Сейчас он отделен от старого REST-контракта и возвращает расширенный результат
     * greedy-планирования: построенные маршруты, диагностические отказы и загрузку аппаратов.
     */
    internal fun calculateGreedy(request: ComplexPlanCalculationRequestDTO): GreedyPlanResult {
        val selectedSatelliteIds = prepareCalculationData(request)
        if (catalog.isEmpty() || selectedSatelliteIds.isEmpty()) {
            return GreedyPlanResult(
                routes = emptyList(),
                failures = emptyList(),
                workloads = emptyList()
            )
        }
        return calcGreedyMars(
            filterCoveredRoutes = request.filterCoveredRoutes,
            revolutionMode = request.revolutionMode
        )
    }

    private fun clearAllData() {
        catalog.clear()
        scg.clear()
        aplCatalog.clear()
        activeCalculationStart = null
        activeCalculationEnd = null
    }

    /**
     * Формирует рабочую группировку КА для расчета.
     * Здесь не создаются "пустые" аппараты для отсутствующих NORAD: такие КА исключаются до загрузки
     * баллистики, иначе дальнейший расчет получил бы трассы и МПЛ для невалидного состава группировки.
     */
    private fun fetchSatellites(requestedSatelliteIds: Collection<Long>): List<Long> {
        val requestedSatelliteIds = requestedSatelliteIds.distinct()
        if (requestedSatelliteIds.isEmpty()) {
            log.warn("Расчет комплексного плана прекращен: в запросе не задан список КА")
            return emptyList()
        }

        val catalogSatelliteIds = satelliteCatalogServiceClient.getSatellites()
            .map { it.id }
            .toSet()
        val selectedSatelliteIds = selectSatellitesForCalculation(requestedSatelliteIds, catalogSatelliteIds)

        selectedSatelliteIds.missingIds.forEach { satelliteId ->
            log.warn("КА {} отсутствует в satellite-catalog-service и исключен из расчета", satelliteId)
        }
        if (selectedSatelliteIds.existingIds.isEmpty()) {
            log.warn("Расчет комплексного плана прекращен: ни один КА из запроса {} не найден в satellite-catalog-service", requestedSatelliteIds)
            return emptyList()
        }

        selectedSatelliteIds.existingIds.forEach { satelliteId ->
            scg.add(SpaceCraft(satelliteId))
        }
        return selectedSatelliteIds.existingIds
    }

    private fun loadOrbitAvailability() {
        scg.forEach { ka ->
            val a = ballTask.getOrbitAvailability(ka.id)
            ka.timeStart = a.timeStart
            ka.timeStop = a.timeStop
        }
    }

    private fun loadFlightLines(
        calculationStart: LocalDateTime,
        calculationEnd: LocalDateTime
    ) {
        scg.forEach { ka ->
            ka.flightLine.addAll(ballTask.getFlightLine(ka.id, calculationStart, calculationEnd))
        }
    }

    /**
     * Единая подготовка состояния калькулятора перед любым вариантом расчета.
     *
     * Метод очищает mutable-состояние предыдущего запуска, загружает заявку, сверяет группировку с каталогом,
     * подтягивает доступность орбит и трассы полета. После него `catalog`, `scg` и `aplCatalog` согласованы
     * между собой и могут использоваться как вход для старого или greedy-пути.
     */
    private fun prepareCalculationData(
        request: ComplexPlanCalculationRequestDTO,
        writeLayers: Boolean = true,
        loadBallisticsData: Boolean = true
    ): List<Long> {
        clearAllData()
        activeCalculationStart = request.calculationStart
        activeCalculationEnd = request.calculationEnd
        loadRequest(request.requestId)
        if (catalog.isEmpty()) {
            log.warn("Расчет комплексного плана прекращен: заявка {} не найдена", request.requestId)
            return emptyList()
        }
        val selectedSatelliteIds = fetchSatellites(request.satelliteIds)
        if (selectedSatelliteIds.isEmpty()) {
            return emptyList()
        }

        if (loadBallisticsData) {
            loadOrbitAvailability()
            loadFlightLines(request.calculationStart, request.calculationEnd)
            scg.forEach { s ->
                log.info(
                    "Загружена трасса КА: satelliteId={}, orbitStart={}, orbitEnd={}, points={}",
                    s.id,
                    s.timeStart,
                    s.timeStop,
                    s.flightLine.size
                )
            }
        }
        if (writeLayers) {
            geoJsonLayerWriter.write(GeoJsonItem.createRequestLay(catalog), "Requests")
        }
        log.info("Загружена заявка для расчета комплексного плана: {}", request.requestId)
        aplCatalog.addAll(convertCatalogToAplCatalog(catalog))

        if (writeLayers) {
            geoJsonLayerWriter.write(GeoJsonItem.createAplCatalogLay(aplCatalog), "aplCatalog")
        }
        log.info("Подготовлено объектов APL-каталога: {}", aplCatalog.size)
        return selectedSatelliteIds
    }

    private fun loadRequest(requestId: UUID) {
        requestServiceClient.getRequest(requestId)?.let { catalog.add(it) }
    }

    private fun convertCatalogToAplCatalog(value : List<RequestItemDTO>) : List<AplObjItem> {
        return value.map { AplObjItem(it) }
    }

    private fun calculationIntervals(): List<IntervalItemDTO> =
        aplCatalog.flatMap { aplItem -> aplItem.intevals }

    /**
     * Старый путь построения плана после подготовки данных.
     *
     * Метод намеренно оставляет прежнее поведение: интервалы и МПЛ считаются для всей заявки,
     * а затем строятся маршруты по всем подходящим МПЛ-вариантам. Здесь нет учета занятости КА,
     * поэтому один аппарат может получить пересекающиеся маршруты; этот путь используется как полный
     * отладочный слой маршрутов и как сохраненный контракт старого расчета.
     */
    private fun buildPlan(
        log : Boolean = true,
        filterCoveredRoutes: Boolean = false,
        revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH,
        timings: CalculationTimings? = null
    ): PlanBuildResult {
        writeLog = log
        if (scg.isEmpty()) {
            this.log.warn("Расчет комплексного плана прекращен: нет КА для расчета")
            return PlanBuildResult(routes = emptyList())
        }

        //Посчитать интервалы для района по группировке
        getIntervalsForAplCatalog(timings, revolutionMode = revolutionMode)

        //Пока что строим все маршруты, для отладки
        val survList = calcAllMars()
        val routes = applyCoveredRouteFilter(survList, filterCoveredRoutes, timings)
        val surveyLayer = GeoJsonItem.createSurveyLay(routes)

        geoJsonLayerWriter.write(surveyLayer, "Surveys")
        return PlanBuildResult(routes = routes)
    }

    /**
     * Второй путь построения маршрутов.
     *
     * Сначала переиспользует общий расчет интервалов и матрицы планирования, затем запускает greedy-выбор:
     * каждый интервал закрывается не более чем одним включением, выбранные включения сразу занимают ресурс КА,
     * а поздние интервалы уже видят эту загрузку. Контур маршрута строится в момент проверки кандидата, потому
     * что отсутствие точек орбиты или невозможность рассчитать контур тоже делает кандидат непригодным.
     */
    private fun calcGreedyMars(
        log: Boolean = true,
        filterCoveredRoutes: Boolean = false,
        revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH,
        timings: CalculationTimings? = null
    ): GreedyPlanResult {
        writeLog = log
        if (scg.isEmpty()) {
            this.log.warn("Расчет комплексного плана прекращен: нет КА для расчета")
            return GreedyPlanResult(routes = emptyList(), failures = emptyList(), workloads = emptyList())
        }

        getIntervalsForAplCatalog(timings, revolutionMode = revolutionMode)

        val contours = linkedMapOf<GreedyRouteCandidate, String>()
        val selection = timings.measure(CalculationTimingStage.GREEDY_SELECTION) {
            selectGreedyRouteCandidates(
                intervals = aplCatalog.flatMap { it.intevals },
                settings = aplSettings.settings,
                spacecraft = scg,
                revolutionMode = revolutionMode
            ) { candidate ->
                val surveyContourTask = candidate.toContourRequest()
                val contour = calculateSurveyContour(surveyContourTask) ?: return@selectGreedyRouteCandidates false
                contours[candidate] = contour
                true
            }
        }
        val routes = selection.selectedCandidates.map { candidate ->
            SurveyContour(
                mpl = candidate.mpl,
                rev = candidate.revolution,
                gamma = candidate.gamma,
                time = candidate.timeBegin,
                duration = candidate.durationSeconds.toDouble(),
                geometry = contours.getValue(candidate)
            )
        }
        val filteredRoutes = applyCoveredRouteFilter(routes, filterCoveredRoutes, timings)

        geoJsonLayerWriter.write(GeoJsonItem.createSurveyLay(filteredRoutes), "GreedySurveys")
        return GreedyPlanResult(
            routes = filteredRoutes,
            failures = selection.failures,
            workloads = selection.workloads
        )
    }

    private fun applyCoveredRouteFilter(
        routes: List<SurveyContour>,
        filterCoveredRoutes: Boolean,
        timings: CalculationTimings? = null
    ): List<SurveyContour> {
        if (!filterCoveredRoutes) {
            return routes
        }

        val requestGeometry = requestCoverageGeometry()
        if (requestGeometry == null) {
            log.warn("Фильтрация уже покрытых маршрутов пропущена: нет геометрии заявки")
            return routes
        }

        val filteredRoutes = timings.measure(CalculationTimingStage.USEFUL_AREA_ANALYSIS) {
            filterRoutesByRemainingRequestGeometry(routes, requestGeometry)
        }
        log.info(
            "Фильтрация уже покрытых маршрутов: было={}, осталось={}",
            routes.size,
            filteredRoutes.size
        )
        return filteredRoutes
    }

    private fun requestCoverageGeometry(): Geometry? =
        aplCatalog
            .map { it.geometry }
            .takeIf { it.isNotEmpty() }
            ?.reduce { coverage, geometry -> coverage.addCoverage(geometry) }

    /**
     * Рассчитывает маршруты по всем прохождениям интервалов для пути 1.
     *
     * Для каждого интервала перебираются все МПЛ-варианты, прошедшие фильтры настроек.
     * Каждый успешный вариант превращается в отдельный SurveyContour; пересечения маршрутов и лимиты работы КА
     * здесь специально не проверяются, чтобы старый путь оставался "полным" представлением доступных наблюдений.
     **/
    private fun calcAllMars() : List<SurveyContour> {
        val result = mutableListOf<SurveyContour>()
        aplCatalog.forEach { aplItem ->
            aplItem.intevals.forEach { intervalItem ->
                if (intervalItem.mpl.isEmpty()) {
                    log.warn("Маршрут не строится: для интервала {} нет наблюдений", intervalItem.id)
                    return@forEach
                }
                selectMplItemsForRouteBuild(intervalItem.mpl, aplSettings.settings).forEach { mplItem ->
                    val surveyContourTask = ContourRequestDTO(
                        satelliteId = mplItem.noradId,
                        time = mplItem.timeBegin,
                        gamma = (mplItem.gammaMax + mplItem.gammaMin) / 2,
                        duration = max(Duration.between(mplItem.timeBegin, mplItem.timeEnd).seconds.toDouble(), 1.0),
                        routeWidthKm = aplSettings.settings.marWidth,
                        step = aplSettings.settings.contourStepBuild,
                    )
                    val contour = calculateSurveyContour(surveyContourTask) ?: return@forEach
                    result.add(SurveyContour(
                        mpl = mplItem,
                        rev = mplItem.revolutionBegin,
                        gamma = surveyContourTask.gamma,
                        time = surveyContourTask.time,
                        duration = surveyContourTask.duration,
                        geometry = contour,
                    ))
                }
            }
        }
        return result
    }

    /**
     * Строит геометрию маршрута по выбранному включению.
     *
     * Метод запрашивает у ballistics-service точки орбиты на интервале включения, рассчитывает угол захвата
     * под заданную ширину маршрута и передает данные в SurveyContourBuilder. Null означает, что маршрут
     * нельзя использовать в плане: не хватило орбитальных точек или не удалось вычислить геометрию съемки.
     */
    private fun calculateSurveyContour(request: ContourRequestDTO): String? {
        val durationMs = (request.duration * 1000.0).roundToLong().coerceAtLeast(1L)
        val stepMs = (request.step * 1000.0).roundToLong().coerceAtLeast(1L)
        val orbitalPoints = ballTask.getExactTime(
            request.satelliteId,
            ExactTimePositionRequestDTO(
                time = request.time,
                timeStop = request.time.plusNanos(durationMs * 1_000_000),
                stepMs = stepMs
            )
        )
        if (orbitalPoints.isEmpty()) {
            log.warn(
                "Контур маршрута не построен: баллистика не вернула точки орбиты, satelliteId={}, time={}, duration={}",
                request.satelliteId,
                request.time,
                request.duration
            )
            return null
        }

        val captureAngle = captureAngleCalculator.calculateCaptureAngle(request.routeWidthKm, orbitalPoints)
        if (captureAngle == null) {
            log.warn(
                "Контур маршрута не построен: не удалось рассчитать угол захвата, satelliteId={}, routeWidthKm={}",
                request.satelliteId,
                request.routeWidthKm
            )
            return null
        }

        return surveyContourBuilder.build(
            points = orbitalPoints,
            roll = request.gamma,
            capture = captureAngle
        )
    }

    /**
     * Адаптер greedy-кандидата к контракту построения контура.
     * Ширина маршрута и шаг дискретизации берутся из общих APL-настроек, чтобы оба пути строили геометрию
     * с одинаковыми параметрами.
     */
    private fun GreedyRouteCandidate.toContourRequest() =
        ContourRequestDTO(
            satelliteId = satelliteId,
            time = timeBegin,
            gamma = gamma,
            duration = durationSeconds.toDouble(),
            routeWidthKm = aplSettings.settings.marWidth,
            step = aplSettings.settings.contourStepBuild,
        )

    private fun SquareViewParamDTO.toContourRequest() =
        ContourRequestDTO(
            satelliteId = noradId,
            time = timeBegin,
            gamma = (gammaMax + gammaMin) / 2,
            duration = max(Duration.between(timeBegin, timeEnd).seconds.toDouble(), 1.0),
            routeWidthKm = aplSettings.settings.marWidth,
            step = aplSettings.settings.contourStepBuild,
        )

//    GEOMETRYCOLLECTION(POLYGON ((37.397461 56.28301, 36.260376 55.862982, 36.5625 55.279115, 38.056641 55.059495, 39.100342 55.702355, 38.551025 56.12106, 37.397461 56.28301)),POLYGON ((36.86286657854737 56.12843639411393, 36.83227584529376 56.06693470293402, 36.801769154413996 56.00542660700639, 36.771346053487264 55.94391213659794, 36.74100609322191 55.88239132176715, 36.7107488274289 55.82086419236608, 36.68057381299534 55.75933077804233, 36.65047748499836 55.697791028093725, 36.620465549539595 55.63624509308851, 36.590534553670196 55.57469295704776, 36.5606840662853 55.51313464863579, 36.530913659227544 55.45157019631645, 36.5012229072623 55.38999962835492, 36.05873799410527 55.44841679624824, 36.08776466001127 55.51007901448405, 36.116868663812426 55.57173546329989, 36.14605042058473 55.63338611616689, 36.17531034827233 55.69503094636904, 36.20464886771195 55.7566699270015, 36.23406927671903 55.81830286567251, 36.2635661497812 55.87993003349437, 36.29314289525238 55.94155127319149, 36.32279994575266 56.00316655692413, 36.35253773692209 56.06477585666016, 36.38235670744684 56.12637914417322, 36.41225729908539 56.18797639104081, 36.86286657854737 56.12843639411393)),POLYGON ((37.374094185539015 56.31342214091461, 37.34257322335814 56.25203255665248, 37.311140177791955 56.19063611901584, 37.27979457498279 56.12923286076635, 37.24853594435422 56.06782281443821, 37.2173638185832 56.006406012340285, 37.18627773357249 55.94498248655842, 37.15527722842326 55.8835522689574, 37.124361845408075 55.82211539118318, 37.09353112994406 55.760671884664916, 37.06278146917312 55.699221550268874, 37.03211863032975 55.637764844266734, 37.00153911332062 55.57630159881958, 36.971042475842964 55.514831844328995, 36.94062827860779 55.45335561098408, 36.91029608531463 55.39187292876364, 36.8800454626266 55.3303838274379, 36.84987598014567 55.268888336570605, 36.8197872103881 55.20738648552083, 36.379148592017664 55.267077245057735, 36.408583235948925 55.3286731444561, 36.43809633502882 55.39026303419816, 36.46768830703589 55.45184688666682, 36.49735957261246 55.51342467405175, 36.52711055528883 55.57499636834763, 36.5569416815079 55.63656194135231, 36.58685338064979 55.69812136466492, 36.61684608505684 55.75967460968404, 36.64692313704502 55.821221636182564, 36.677079056189065 55.88276240905972, 36.70731729614335 55.944296919800024, 36.737638301319635 56.00582513882355, 36.76804251922152 56.067347036345836, 36.79853040047113 56.1288625823759, 36.82910239883583 56.190371746714234, 36.859758971255395 56.25187449895081, 36.8905005778693 56.313370808462935, 36.921327682044286 56.37486064441323, 37.374094185539015 56.31342214091461)),POLYGON ((37.787105567891494 56.2943724136141, 37.754932366663105 56.23307750856126, 37.72284943290911 56.171775390991364, 37.69085628204084 56.110466095512024, 37.658952432810715 56.04914965649078, 37.62713740728392 55.98782610805759, 37.595410730810535 55.92649548410685, 37.56377193199783 55.86515781829983, 37.53222054268282 55.80381314406674, 37.500756097905246 55.742461494608996, 37.46937813588054 55.68110290290138, 37.43808285804391 55.61973697526561, 37.40687638023442 55.558364560473585, 37.37575501805301 55.49698529721743, 37.34471832212136 55.435599217402284, 37.31376584610594 55.374206352711425, 37.28289714669266 55.31280673460816, 37.252111783561865 55.251400394338035, 37.2214093193634 55.18998736293067, 37.19078931969217 55.12856767120184, 36.74856083531451 55.19001519767402, 36.77852941972015 55.25153242497892, 36.8085782423774 55.31304335144423, 36.83870772830057 55.374547948028976, 36.868918305401714 55.43604618548985, 36.89921040451503 55.497538034379346, 36.92958445942145 55.55902346504384, 36.96004090687345 55.620502447621675, 36.99058018662027 55.68197495204115, 37.02120582013822 55.74344113126988, 37.05191198966952 55.80490055835292, 37.08270232946442 55.866353419047556, 37.11357729148183 55.92779968226204, 37.14453733080749 55.98923931669039, 37.17558290568056 56.05067229081021, 37.20671447752059 56.1120985728807, 37.23793251095473 56.173518130940444, 37.269237473845166 56.234930932805426, 37.30062983731688 56.296336946066745, 37.33211007578567 56.35773613808844, 37.787105567891494 56.2943724136141)),POLYGON ((38.18357851116821 56.238305292902886, 38.15080596978912 56.1771044662348, 38.11812574313705 56.11589607789891, 38.08553733810567 56.05468016425039, 38.053040264969816 55.993456761393055, 38.02063403735725 55.93222590518176, 37.98831817222048 55.87098763122467, 37.95609218980901 55.809741974885654, 37.923955613641695 55.74848897128653, 37.891907970479565 55.68722865530936, 37.859948790298716 55.62596106159879, 37.82807412884916 55.56468561621346, 37.79629036770221 55.50340353379011, 37.76459367795362 55.44211427234814, 37.732983602144024 55.38081786541905, 37.70145968589752 55.319514346301915, 37.67002147789626 55.25820374806554, 37.63866852985523 55.19688610355058, 37.607400396497326 55.135561445371565, 37.5762166355285 55.074229805919025, 37.12980564236885 55.137827942962545, 37.160336512693966 55.1992613094187, 37.19094954599485 55.260688065626496, 37.2216451754285 55.322108181015984, 37.25242383709086 55.383521624804956, 37.283285970041376 55.44492836599694, 37.31423201632777 55.5063283733792, 37.34526242101109 55.567721615520746, 37.37637763219099 55.62910806077039, 37.40758131032927 55.69048804126235, 37.43886738378673 55.75186076731732, 37.47023962699517 55.81322660346214, 37.5016985004354 55.87458551698282, 37.53324446775766 55.9359374749404, 37.56487799580866 55.99728244416882, 37.59659955465852 56.058620391272754, 37.62840961762824 56.11995128262538, 37.66030866131739 56.1812750843662, 37.69229716563195 56.24259176239874, 37.724375613812555 56.30390128238838, 38.18357851116821 56.238305292902886)),POLYGON ((38.582585827456654 56.18208716204893, 38.54921371955402 56.12098401805472, 38.51593595610682 56.05987295332675, 38.482752035723024 55.99875400600691, 38.44966146042929 55.93762721397423, 38.4166637356425 55.87649261484728, 38.38375837014157 55.81535024598666, 38.35094487603953 55.754200144497354, 38.318222768755845 55.693042347231156, 38.28559156698904 55.63187689078899, 38.25305079268958 55.57070381152328, 38.22059635452304 55.50952235570751, 38.18823490324764 55.44833410310417, 38.155962464025315 55.38713833143868, 38.123778571519395 55.32593507590641, 38.09168276351322 55.26472437145969, 38.05967458088429 55.20350625281004, 38.02775356757917 55.142280754430324, 37.995919270588224 55.08104791055691, 37.96417123992089 55.01980775519185, 37.51122893311556 55.0859407720278, 37.54231936322492 55.14728746650345, 37.57349385706382 55.20862723408846, 37.604752855704376 55.26996004265604, 37.63609680319629 55.33128585985703, 37.66752614659179 55.392604653117886, 37.6990413359705 55.453916389638565, 37.73064282446458 55.51522103639046, 37.76233106828427 55.576518560114295, 37.794109867353576 55.63780947065399, 37.82597289450051 55.69909261852606, 37.857924064777656 55.76036854547385, 37.88996384693743 55.82163721713235, 37.92209271294185 55.88289859890087, 37.9543111379895 55.94415265594074, 37.9866196005429 56.00539935317305, 38.019018582355926 56.0666386552764, 38.0515085685017 56.127870526684646, 38.08409004740064 56.18909493158441, 38.116763510848806 56.2503118339129, 38.582585827456654 56.18208716204893)),POLYGON ((38.97070889678638 56.124061264496646, 38.936759513360876 56.06305604447322, 38.90290640080406 56.00204254761551, 38.8691490500906 55.94102081382274, 38.83548695564598 55.87999088272002, 38.801919615318184 55.81895279366094, 38.76844653034923 55.75790658573004, 38.735067205347356 55.696852297745345, 38.7017811482592 55.635789968260774, 38.66858787034229 55.574719635568655, 38.635486886138 55.513641337702076, 38.602473954394696 55.45255414499122, 38.56955600258029 55.39145999444591, 38.53672890705141 55.33035798744899, 38.5039921951914 55.26924816083357, 38.47134539753496 55.20813055117938, 38.43878804774269 55.147005194815065, 38.40631968257565 55.0858721278204, 38.373939841870104 55.02473138602862, 38.34164806851274 54.96358300502852, 37.88013249183751 55.03256667072851, 37.91175916265749 55.09382689046877, 37.94347169622526 55.155079870755806, 37.975270540920874 55.216325577937354, 38.00715614813623 55.277563978129045, 38.03912897229984 55.33879503721227, 38.071189470901885 55.40001872083204, 38.10333810451957 55.46123499439476, 38.135575336842656 55.52244382306615, 38.167905109271416 55.583645889513114, 38.20032083319652 55.64483969421595, 38.23282656626062 55.70602595135891, 38.26542278483659 55.76720462496017, 38.29810996854188 55.828375678791794, 38.33088860026559 55.88953907637734, 38.36375916619584 55.95069478098961, 38.39672215584743 56.01184275564819, 38.429778062089746 56.07298296311718, 38.462927381174985 56.13411536590266, 38.49617061276655 56.195239926250295, 38.97070889678638 56.124061264496646)),POLYGON ((39.267241022679436 55.93659989554567, 39.23295492350333 55.875672345247565, 39.19876579464455 55.81473626082574, 39.16467312754646 55.75379168326779, 39.13067641706992 55.69283865328208, 39.09677516146532 55.631877211300434, 39.06296886234483 55.57090739748049, 39.02925702465498 55.50992925170841, 38.99563915664947 55.44894281360122, 38.96211085484352 55.387946980969744, 38.92867935137203 55.32694404037118, 38.895340361454764 55.26593292080997, 38.86209340628324 55.20491366066265, 38.38705715890368 55.27756464905193, 38.4196183637626 55.33870345518393, 38.45226934722532 55.39983454668984, 38.48501057934671 55.46095788725798, 38.51784615452785 55.5220743277203, 38.55076919630254 55.583182027620005, 38.58378391641012 55.644281869462944, 38.616890797695774 55.70537381575066, 38.650090326275546 55.76645782872894, 38.68338299156361 55.827533870385516, 38.7167692862995 55.888601902447554, 38.750249706575865 55.949661886379296, 38.783824751866504 56.01071378337954, 39.267241022679436 55.93659989554567)))

    /**
     * Строит геометрические интервалы съемки для всех объектов APL-каталога на одном направлении витка.
     *
     * Интервалы строятся по трассе первого КА из группировки: это существующее допущение старого алгоритма,
     * которое определяет разбиение объекта на участки. Дальше матрица планирования уже считается по всей
     * выбранной группировке КА, поэтому здесь формируется только геометрическая основа для последующего МПЛ.
     */
    private fun calcIntervalsForApl(
        flightLine: Pair<LineString, LineString>,
        revSign : RevolutionSign,
        writeLayers: Boolean = true
    ) {
        if (writeLog) { log.info("Построение интервалов начало") }
        val intBuild = _root_ide_package_.space.nstart.pcp.complan.apl.IntervalsBuilder(false)
        val coverageChecker = if (intervalCoverageCheckEnabled) IntervalCoverageChecker() else null
        val coverageGapItems = mutableListOf<GeoJsonItem>()

        for (i in 0..<aplCatalog.size) {
            val interval = mutableListOf<Polygon>()
            when (revSign) {
                RevolutionSign.ASC -> {
                    interval.addAll(intBuild.getIntervals(contour = aplCatalog[i].geometry as Polygon,
                        flightLine = flightLine.second,
                        offsetKm = aplSettings.settings.marWidth * INTERVAL_ROUTE_WIDTH_RATIO,
                        overlapKm = aplSettings.settings.marOverlap))
                }
                RevolutionSign.DESC -> {
                    interval.addAll(intBuild.getIntervals(contour = aplCatalog[i].geometry as Polygon,
                        flightLine = flightLine.first,
                        offsetKm = aplSettings.settings.marWidth * INTERVAL_ROUTE_WIDTH_RATIO,
                        overlapKm = aplSettings.settings.marOverlap))
                }
            }

            if (coverageChecker != null) {
                val uncoveredGeometry = coverageChecker.findUncoveredGeometry(aplCatalog[i].geometry, interval.map { it as Geometry })
                if (uncoveredGeometry.isEmpty || uncoveredGeometry.area <= INTERVAL_COVERAGE_AREA_TOLERANCE) {
                    if (writeLog) { log.info("Интервалы полностью покрывают участок {} для {}", aplCatalog[i].id, revSign) }
                } else {
                    log.warn(
                        "Интервалы не полностью покрывают участок {} для {}. Остаточная площадь: {}",
                        aplCatalog[i].id,
                        revSign,
                        uncoveredGeometry.area
                    )
                    coverageGapItems.addAll(createCoverageGapItems(aplCatalog[i], revSign, uncoveredGeometry))
                }
            }

            aplCatalog[i].intevals.addAll(
                interval.map {
                    IntervalItemDTO(
                        regionId = aplCatalog[i].id,
                        geometry = it.toString(),
                        revSign = revSign
                    )
                }
            )

            if (writeLog) { log.info("Обработан участок №$i имя участка ${aplCatalog[i].name}") }
        }
        if (intervalCoverageCheckEnabled && writeLayers) {
            geoJsonLayerWriter.write(coverageGapItems, "UncoveredIntervals${revSign.name}")
        }
        if (writeLog) { log.info("Построение интервалов конец") }
    }

    /**
     * Разворачивает результат проверки покрытия в GeoJSON-объекты для диагностического слоя.
     * Слой нужен только при включенной проверке покрытия интервалов и помогает увидеть части заявки,
     * которые не были накрыты построенными интервалами.
     */
    private fun createCoverageGapItems(
        aplItem: AplObjItem,
        revSign: RevolutionSign,
        uncoveredGeometry: Geometry
    ): List<GeoJsonItem> =
        collectPolygonalGeometries(uncoveredGeometry).map { geometry ->
            GeoJsonItem(
                geometry = geometry,
                properties = mapOf(
                    "UUID" to aplItem.id.toString(),
                    "Name" to aplItem.name,
                    "RevSign" to revSign.name,
                    "Area" to geometry.area
                )
            )
        }

    /**
     * Достает из произвольной JTS-геометрии только полигональные части.
     * Coverage-checker может вернуть GeometryCollection, поэтому перед записью слоя результат нормализуется
     * до отдельных Polygon/MultiPolygon элементов.
     */
    private fun collectPolygonalGeometries(geometry: Geometry): List<Geometry> =
        when (geometry) {
            is Polygon -> listOf(geometry)
            is MultiPolygon -> listOf(geometry)
            else -> (0 until geometry.numGeometries).flatMap { collectPolygonalGeometries(geometry.getGeometryN(it)) }
        }

    private fun calculateObservationParameters(
        parallelObservationParameters: Boolean = parallelObservationParametersEnabled,
        observationParametersChunkSizeOverride: Int? = null,
        observationParametersParallelismOverride: Int? = null
    ) {
        val calculationStart = activeCalculationStart ?: scg.first().timeStart
        val calculationEnd = activeCalculationEnd ?: scg.first().timeStart.plusDays(aplSettings.settings.daysCalc.toLong())
        if (parallelObservationParameters) {
            calculateObservationParametersParallelChunked(
                calculationStart,
                calculationEnd,
                observationParametersChunkSizeOverride,
                observationParametersParallelismOverride
            )
        } else {
            calculateObservationParametersSequential(calculationStart, calculationEnd)
        }
    }

    /**
     * Текущая последовательная реализация расчета МПЛ оставлена отдельным методом без изменения поведения:
     * один объект APL-каталога отправляется в баллистику со всеми своими интервалами, следующий объект ждет
     * завершения предыдущего.
     */
    private fun calculateObservationParametersSequential(
        calculationStart: LocalDateTime,
        calculationEnd: LocalDateTime
    ) {
        aplCatalog.filter { it.intevals.isNotEmpty() }.forEach { aplItem ->
            val mplCalcRes = calculateMplForIntervals(aplItem.intevals, calculationStart, calculationEnd)
            putMplToIntervals(aplItem.intevals, mplCalcRes)
        }
    }

    /**
     * Альтернативный вариант B: интервалы режутся на независимые пачки и считаются в баллистике параллельно
     * с ограниченным parallelism. Результаты применяются к interval.mpl только после завершения worker-задач,
     * чтобы не мутировать APL-каталог из нескольких потоков.
     */
    private fun calculateObservationParametersParallelChunked(
        calculationStart: LocalDateTime,
        calculationEnd: LocalDateTime,
        observationParametersChunkSizeOverride: Int? = null,
        observationParametersParallelismOverride: Int? = null
    ) {
        val chunkSize = (observationParametersChunkSizeOverride ?: observationParametersChunkSize).coerceAtLeast(1)
        val chunks = aplCatalog
            .flatMap { aplItem -> aplItem.intevals.chunked(chunkSize) }
            .filter { it.isNotEmpty() }
        if (chunks.isEmpty()) {
            return
        }

        val parallelism = (observationParametersParallelismOverride ?: observationParametersParallelism)
            .coerceAtLeast(1)
            .coerceAtMost(chunks.size)
        log.info(
            "Расчет матрицы выполняется параллельно: chunks={}, chunkSize={}, parallelism={}",
            chunks.size,
            chunkSize,
            parallelism
        )
        val executor = Executors.newFixedThreadPool(parallelism) { task ->
            Thread(task, "dynamic-plan-mpl-worker").apply { isDaemon = true }
        }
        try {
            val futures = chunks.map { intervals ->
                executor.submit<MplCalculationChunkResult> {
                    MplCalculationChunkResult(
                        intervals = intervals,
                        mpl = calculateMplForIntervals(intervals, calculationStart, calculationEnd)
                    )
                }
            }
            futures.map { future ->
                try {
                    future.get()
                } catch (error: ExecutionException) {
                    val cause = error.cause
                    if (cause is Exception) {
                        throw cause
                    }
                    throw error
                }
            }.forEach { result ->
                putMplToIntervals(result.intervals, result.mpl)
            }
        } finally {
            executor.shutdownNow()
        }
    }

    private fun observationParametersChunksCount(observationParametersChunkSizeOverride: Int? = null): Int {
        val chunkSize = (observationParametersChunkSizeOverride ?: observationParametersChunkSize).coerceAtLeast(1)
        return aplCatalog.sumOf { aplItem ->
            val intervalsCount = aplItem.intevals.size
            if (intervalsCount == 0) {
                0
            } else {
                (intervalsCount + chunkSize - 1) / chunkSize
            }
        }
    }

    private fun calculateMplForIntervals(
        intervals: List<IntervalItemDTO>,
        calculationStart: LocalDateTime,
        calculationEnd: LocalDateTime
    ): List<SquareViewParamDTO> {
        if (intervals.isEmpty()) {
            return emptyList()
        }

        val intervalForMplCalc = ObjViewRequestDTO(
            satellites = scg.map { ka -> ka.id }.toList(),
            sunAngleMin = -90.0,
            timeStart = calculationStart,
            timeStop = calculationEnd,
            objects = intervals.map { oneInterval ->
                ObjDTO(
                    id = oneInterval.id,
                    position = PositionDTO(lat = 0.0, long = 0.0, height = 0.0),
                    contourWKT = oneInterval.geometry.toString()
                )
            }
        )

        return ballTask.getMplForObjects(intervalForMplCalc).map {
            SquareViewParamDTO(
                noradId = it.noradId,
                objectId = it.objectId,
                revolutionBegin = it.revolutionBegin,
                timeBegin = it.timeBegin.minusSeconds(aplSettings.settings.marBeginCorrectionTime),
                revolutionEnd = it.revolutionEnd,
                revSignBegin = it.revSignBegin,
                revSignEnd = it.revSignEnd,
                timeEnd = it.timeEnd.plusSeconds(aplSettings.settings.marEndCorrectionTime),
                gammaMin = it.gammaMin,
                gammaMax = it.gammaMax
            )
        }
    }

    private fun putMplToIntervals(
        intervals: List<IntervalItemDTO>,
        mplCalcRes: List<SquareViewParamDTO>
    ) {
        val mplByObjectId = mplCalcRes.groupBy { it.objectId }
        intervals.forEach { oneInterval ->
            oneInterval.mpl.addAll(
                mplByObjectId[oneInterval.id.toString()].orEmpty().filter {
                    it.revSignBegin == oneInterval.revSign || it.revSignEnd == oneInterval.revSign
                }
            )
        }
    }

    /**
     * Общая подготовка интервалов и матрицы планирования для обоих путей расчета.
     *
     * Метод строит интервалы по выбранному направлению витка, затем одним запросом на объект получает МПЛ для
     * всех интервалов этого объекта и раскладывает результаты обратно по intervalId. Коррекции времени начала/конца
     * применяются сразу здесь, поэтому downstream-код работает уже с фактической длительностью включения.
     */
    private fun getIntervalsForAplCatalog(
        timings: CalculationTimings? = null,
        parallelObservationParameters: Boolean = parallelObservationParametersEnabled,
        observationParametersChunkSizeOverride: Int? = null,
        observationParametersParallelismOverride: Int? = null,
        writeLayers: Boolean = true,
        revolutionMode: DynamicPlanRevolutionMode = DynamicPlanRevolutionMode.BOTH
    ) {
        val flightLine = scg.first().createFlightLine()

        if (writeLog) { log.info("Построение трассы полета") }

        val revolutionSigns = revolutionSignsForIntervals(revolutionMode)
        revolutionSigns.forEach { revSign ->
            timings.measure(CalculationTimingStage.INTERVAL_BUILD) {
                calcIntervalsForApl(flightLine, revSign = revSign, writeLayers = writeLayers)
            }
        }

        //тут шаг 5
        //Пройтись по каждому интервалу, получить матрицу планирования
        //записать результаты в интервал
        if (writeLog) { log.info("Расчет матрицы начало") }
        timings.measure(CalculationTimingStage.OBSERVATION_PARAMETERS) {
            calculateObservationParameters(
                parallelObservationParameters,
                observationParametersChunkSizeOverride,
                observationParametersParallelismOverride
            )
        }

        if (writeLog) { log.info("Расчет матрицы конец") }
        aplCatalog.forEach { aplItem ->
            aplItem.intevals.forEach {
                if (it.mpl.size == 0) {
                    log.info("Для интервала {} нет наблюдений", it.id)
                }
            }
        }

        if (writeLayers) {
            revolutionSigns.forEach { revSign ->
                val intervals = aplCatalog.flatMap { aplItem -> aplItem.intevals.filter { it.revSign == revSign } }
                geoJsonLayerWriter.write(GeoJsonItem.createIntervalsLay(intervals), revSign.intervalLayerName())
            }
        }

        if (writeLog) { log.info("Слой AllIntervals записан") }
    }

    private fun RevolutionSign.intervalLayerName(): String =
        when (this) {
            RevolutionSign.ASC -> "AllIntervalsAsc"
            RevolutionSign.DESC -> "AllIntervalsDESC"
        }

}
