package space.nstart.pcp_tgu_service.service

/** Рассчитывает цепочки планов по окнам видимости КА с учётом целевой длительности и дельты. */
import org.springframework.stereotype.Service
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
import space.nstart.pcp_tgu_service.config.PlanningProperties
import space.nstart.pcp_tgu_service.domain.InsertionPoint
import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
import java.time.Duration
import java.time.LocalDateTime

/**
 * Строит цепочку планов по окнам видимости КА.
 *
 * Главный инвариант цепочки: у соседних планов строго возрастают и старт, и конец
 * (`start[i] < start[i+1]` и `end[i] < end[i+1]`). Отсюда математически следует, что
 * ни один план не может лежать целиком внутри другого. Перекрытие сохраняется: следующий
 * план стартует на ЗРВ, попадающей внутрь предыдущего плана.
 *
 * Окна разных станций не сливаются — старт плана привязан к конкретной ЗРВ один к одному.
 */
@Service
class PlanCalculationService(
    private val planningProperties: PlanningProperties
) {

    /**
     * Возвращает «сырые» окна ЗРВ КА (наблюдательные окна точек закладки как есть), без построения
     * цепочки и без подбора целевой длительности: одно окно = один [CalculatedPlan] `[startTime, endTime]`
     * с `kppId = pointId`. Из этих окон оператор вручную набирает план новой цепочки (старт и конец —
     * границы реальных ЗРВ), и против них же валидируется выбранное окно.
     */
    fun rawWindows(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> =
        buildCandidates(spacecraftPoints).map { candidate ->
            candidate.toCalculatedPlan(spacecraftPoints.spacecraftId, candidate.endTime)
        }

    /** Строит полную цепочку планов для КА по всем доступным окнам видимости. */
    fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> {
        /** Все доступные кандидаты начала/окончания плана для указанного КА. */
        val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)

        return buildChain(
            spacecraftId = spacecraftPoints.spacecraftId,
            candidates = candidates,
            fromStartIndex = 0,
            initialLowerEndBound = null
        )
    }

    /**
     * Строит часть цепочки после уже известного (принятого) плана.
     *
     * Первый новый план обязан стартовать строго внутри принятого плана (нахлёст без разрыва)
     * и заканчиваться строго позже его конца (без вложенности). Если предыдущий план удалось
     * сопоставить с текущими окнами — продолжаем с нахлестом на одну ЗРВ; иначе берём последнюю
     * ЗРВ строго внутри принятого плана. Разрыв между принятым планом и хвостом не допускается.
     */
    fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
        /** Все доступные кандидаты начала/окончания плана для указанного КА. */
        val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)

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

        // Базовый (принятый) план полностью в прошлом относительно доступных окон — он
        // больше не ограничивает будущее (нахлёст с ним невозможен и не нужен). Строим
        // свежую цепочку с самого раннего окна. Иначе проекция схлопнулась бы в пустоту.
        if (previousPlan.endTime <= candidates.first().startTime) {
            return buildChain(
                spacecraftId = spacecraftPoints.spacecraftId,
                candidates = candidates,
                fromStartIndex = 0,
                initialLowerEndBound = null
            )
        }

        /** Индекс кандидата, с которого начинался предыдущий план. */
        val previousStartIndex: Int = candidates.indexOfFirst { candidate ->
            candidate.pointId == previousPlan.kppId && candidate.startTime == previousPlan.startTime
        }
        /** Индекс кандидата, по которому заканчивался предыдущий план. */
        val previousEndIndex: Int = candidates.withIndex()
            .firstOrNull { indexedCandidate ->
                indexedCandidate.index > previousStartIndex &&
                    indexedCandidate.value.endTime == previousPlan.endTime
            }
            ?.index
            ?: -1

        /** Старт хвоста при удачном сопоставлении базового плана с текущими окнами. */
        val matchedStartIndex: Int = if (previousStartIndex >= 0 && previousEndIndex > previousStartIndex) {
            resolveNextStartIndex(
                candidates = candidates,
                currentStartIndex = previousStartIndex,
                endIndex = previousEndIndex
            )
        } else {
            previousStartIndex
        }

        /** Стартовый индекс для пересчёта следующего хвоста цепочки. */
        val fromStartIndex: Int = if (matchedStartIndex > previousStartIndex) {
            matchedStartIndex
        } else {
            // База не сопоставилась или нахлёст по её окнам невозможен: стартуем с последней ЗРВ
            // строго внутри принятого плана — нахлёст с базой без разрыва и без вложенности.
            candidates.indices.lastOrNull { index ->
                candidates[index].startTime > previousPlan.startTime &&
                    candidates[index].startTime < previousPlan.endTime
            } ?: -1
        }

        if (fromStartIndex !in candidates.indices) {
            // База перекрывается с окнами, но якорь хвоста подобрать не удалось — не
            // схлопываемся в пустоту, а строим свежую цепочку с самого раннего окна.
            return buildChain(
                spacecraftId = spacecraftPoints.spacecraftId,
                candidates = candidates,
                fromStartIndex = 0,
                initialLowerEndBound = null
            )
        }

        return buildChain(
            spacecraftId = spacecraftPoints.spacecraftId,
            candidates = candidates,
            fromStartIndex = fromStartIndex,
            initialLowerEndBound = previousPlan.endTime
        )
    }

    /**
     * Возвращает план, который начинается строго с ближайшей закладки не раньше `fromTime`.
     *
     * Сервис не перескакивает на более позднюю закладку.
     * Если ближайшая не попадает в целевое окно, всё равно выбирается
     * лучший доступный конец, наиболее близкий к целевой длительности.
     */
    fun calculateNearestPlanFrom(spacecraftPoints: SpacecraftPoints, fromTime: LocalDateTime): CalculatedPlan? {
        /** Все доступные кандидаты начала/окончания плана для указанного КА. */
        val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)
        /** Индекс ближайшей закладки, которая начинается не раньше указанного времени. */
        val nearestStartIndex: Int = candidates.indexOfFirst { candidate -> candidate.startTime >= fromTime }

        if (nearestStartIndex == -1 || nearestStartIndex >= candidates.lastIndex) {
            return null
        }

        /** Индекс лучшего кандидата окончания для ближайшей закладки. */
        val endIndex: Int = findEndCandidateIndexOrNull(
            candidates = candidates,
            startIndex = nearestStartIndex,
            lowerEndBound = null
        ) ?: return null

        return candidates[nearestStartIndex].toCalculatedPlan(
            spacecraftId = spacecraftPoints.spacecraftId,
            endTime = candidates[endIndex].endTime
        )
    }

    /**
     * Строит последовательную цепочку планов, начиная с `fromStartIndex`.
     *
     * Поддерживает инвариант строгого роста старта и конца: следующий план обязан
     * стартовать строго позже и заканчиваться строго позже предыдущего, поэтому ни один
     * план не оказывается внутри другого.
     */
    private fun buildChain(
        spacecraftId: String,
        candidates: List<StartCandidate>,
        fromStartIndex: Int,
        initialLowerEndBound: LocalDateTime?
    ): List<CalculatedPlan> {
        if (candidates.isEmpty() || fromStartIndex !in candidates.indices || fromStartIndex >= candidates.lastIndex) {
            return emptyList()
        }

        /** Результирующая цепочка планов. */
        val resultPlans: MutableList<CalculatedPlan> = mutableListOf()
        /** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */
        var currentStartIndex: Int = fromStartIndex
        /** Конец последнего добавленного плана — нижняя граница для конца следующего. */
        var lowerEndBound: LocalDateTime? = initialLowerEndBound

        while (currentStartIndex < candidates.lastIndex) {
            /** Текущий кандидат старта плана. */
            val currentCandidate: StartCandidate = candidates[currentStartIndex]
            /** Индекс найденного кандидата окончания плана со строго растущим концом. */
            val endIndex: Int = findEndCandidateIndexOrNull(
                candidates = candidates,
                startIndex = currentStartIndex,
                lowerEndBound = lowerEndBound
            ) ?: break

            resultPlans += currentCandidate.toCalculatedPlan(
                spacecraftId = spacecraftId,
                endTime = candidates[endIndex].endTime
            )
            lowerEndBound = candidates[endIndex].endTime

            /** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */
            val nextStartIndex: Int = resolveNextStartIndex(
                candidates = candidates,
                currentStartIndex = currentStartIndex,
                endIndex = endIndex
            )

            if (nextStartIndex <= currentStartIndex) {
                break
            }

            currentStartIndex = nextStartIndex
        }

        return resultPlans
    }

    /** Разворачивает все окна видимости в отсортированный список кандидатов начала и конца плана. */
    private fun buildCandidates(spacecraftPoints: SpacecraftPoints): List<StartCandidate> =
        spacecraftPoints.points
            .flatMap { point: InsertionPoint ->
                point.observationWindows.map { observationWindow ->
                    StartCandidate(
                        pointId = point.pointId,
                        startTime = observationWindow.startTime,
                        endTime = observationWindow.endTime
                    )
                }
            }
            .sortedWith(compareBy({ candidate -> candidate.startTime }, { candidate -> candidate.endTime }))

    /** Возвращает целевую длительность плана из настроек. */
    private fun targetDuration(): Duration =
        Duration.ofMinutes(planningProperties.targetPlanDurationMinutes)

    /** Возвращает начальный допуск вокруг целевой длительности плана. */
    private fun initialDelta(): Duration =
        Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes)

    /**
     * Возвращает индекс старта следующего плана так, чтобы он гарантированно стартовал
     * строго внутри текущего плана (нахлёст без разрыва).
     *
     * Сначала пробуем нахлёст на одну ЗРВ: самая поздняя ЗРВ строго раньше конечной ЗРВ
     * текущего плана. Если такой нет — берём самую позднюю ЗРВ, начинающуюся строго раньше
     * конца текущего плана (нахлёст всё равно сохраняется). Нахлёст считается по времени, а не
     * по плоскому индексу, поэтому устойчив к тому, что одну ЗРВ видят несколько станций сразу.
     *
     * Если ни одна ЗРВ не начинается внутри текущего плана, продолжить цепочку без разрыва
     * невозможно — возвращаем `currentStartIndex`, и вызывающий обрывает цепочку.
     */
    private fun resolveNextStartIndex(
        candidates: List<StartCandidate>,
        currentStartIndex: Int,
        endIndex: Int
    ): Int {
        /** Время старта текущего плана. */
        val currentStartTime: LocalDateTime = candidates[currentStartIndex].startTime
        /** Время конца текущего плана. */
        val currentEndTime: LocalDateTime = candidates[endIndex].endTime
        /** Время старта конечной ЗРВ текущего плана. */
        val endStartTime: LocalDateTime = candidates[endIndex].startTime

        /** Предпочтительный нахлёст: последняя ЗРВ строго перед конечной ЗРВ. */
        val oneWindowOverlap: Int? = candidates.indices.lastOrNull { index ->
            candidates[index].startTime > currentStartTime &&
                candidates[index].startTime < endStartTime
        }
        if (oneWindowOverlap != null) {
            return oneWindowOverlap
        }

        /** Запасной нахлёст: последняя ЗРВ, начинающаяся строго раньше конца текущего плана. */
        val anyOverlap: Int? = candidates.indices.lastOrNull { index ->
            candidates[index].startTime > currentStartTime &&
                candidates[index].startTime < currentEndTime
        }
        if (anyOverlap != null) {
            return anyOverlap
        }

        // Нахлёст невозможен — обрываем цепочку, чтобы не было разрыва между планами.
        return currentStartIndex
    }

    /**
     * Находит лучший конечный кандидат для плана, начинающегося с `startIndex`.
     *
     * Рассматриваются только концы строго позже старта плана и строго позже конца предыдущего
     * плана (`lowerEndBound`) — это гарантирует строгий рост конца цепочки и отсутствие вложенности.
     * Сначала сервис старается попасть в целевое окно `target ± delta`; если это невозможно,
     * берётся любой допустимый конец, наиболее близкий к целевой длительности.
     */
    private fun findEndCandidateIndexOrNull(
        candidates: List<StartCandidate>,
        startIndex: Int,
        lowerEndBound: LocalDateTime?
    ): Int? {
        /** Кандидат, с которого начинается рассматриваемый план. */
        val startCandidate: StartCandidate = candidates[startIndex]
        /** Допустимые кандидаты окончания: позже старта, позже предыдущего конца. */
        val allEndCandidates: List<IndexedValue<StartCandidate>> = candidates.withIndex()
            .filter { indexedCandidate ->
                indexedCandidate.index > startIndex &&
                    indexedCandidate.value.endTime > startCandidate.startTime &&
                    (lowerEndBound == null || indexedCandidate.value.endTime > lowerEndBound)
            }

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

        /** Допуск относительно целевой длительности плана. */
        val currentDelta: Duration = initialDelta()
        /** Все кандидаты окончания, попавшие в настроенное окно допуска. */
        val matchingCandidates: List<IndexedValue<StartCandidate>> = allEndCandidates
            .filter { indexedCandidate ->
                isWithinTargetWindow(
                    startTime = startCandidate.startTime,
                    candidateTime = indexedCandidate.value.endTime,
                    delta = currentDelta
                )
            }

        return selectPreferredEndCandidate(
            candidates = if (matchingCandidates.isNotEmpty()) matchingCandidates else allEndCandidates,
            startTime = startCandidate.startTime
        )?.index
    }

    /** Проверяет, попадает ли длительность до конца кандидата в текущее окно допуска. */
    private fun isWithinTargetWindow(
        startTime: LocalDateTime,
        candidateTime: LocalDateTime,
        delta: Duration
    ): Boolean {
        /** Фактическая длительность плана от текущего старта до рассматриваемого конца. */
        val duration: Duration = Duration.between(startTime, candidateTime)
        /** Минимально допустимая длительность плана. */
        val minDuration: Duration = targetDuration().minus(delta)
        /** Максимально допустимая длительность плана. */
        val maxDuration: Duration = targetDuration().plus(delta)
        return duration >= minDuration && duration <= maxDuration
    }

    /**
     * Выбирает лучший конец по минимальному отклонению от целевой длительности.
     *
     * При равной близости предпочтение отдаётся более длинному плану, а затем более раннему концу,
     * чтобы выбор был детерминированным.
     */
    private fun selectPreferredEndCandidate(
        candidates: List<IndexedValue<StartCandidate>>,
        startTime: LocalDateTime
    ): IndexedValue<StartCandidate>? {
        /** Целевая длительность плана из настроек. */
        val targetDuration: Duration = targetDuration()

        return candidates.minWithOrNull(
            compareBy<IndexedValue<StartCandidate>> { candidate ->
                durationDistance(
                    first = Duration.between(startTime, candidate.value.endTime),
                    second = targetDuration
                )
            }.thenByDescending { candidate ->
                Duration.between(startTime, candidate.value.endTime)
            }.thenBy { candidate ->
                candidate.value.endTime
            }
        )
    }

    /** Возвращает модуль разницы между двумя длительностями. */
    private fun durationDistance(first: Duration, second: Duration): Duration =
        first.minus(second).abs()

    private data class StartCandidate(
        /** Идентификатор пункта закладки, которому принадлежит окно. */
        val pointId: String,

        /** Время начала окна наблюдения. */
        val startTime: LocalDateTime,

        /** Время окончания окна наблюдения. */
        val endTime: LocalDateTime
    ) {
        /** Преобразует кандидата в расчётный план с заданным временем окончания. */
        fun toCalculatedPlan(spacecraftId: String, endTime: LocalDateTime): CalculatedPlan =
            CalculatedPlan(
                spacecraftId = spacecraftId,
                startTime = startTime,
                endTime = endTime,
                kppId = pointId
            )
    }
}
