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

@Service
class PlanCalculationService(
    private val planningProperties: PlanningProperties
) {

    /** Количество ЗРВ, на которое следующий план должен заходить на предыдущий, если это возможно. */
    private val overlapShiftByWindows: Int = 1

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

        return calculatePlansFromCandidates(
            spacecraftId = spacecraftPoints.spacecraftId,
            candidates = candidates,
            initialStartIndex = 0
        )
    }

    /**
     * Строит часть цепочки после уже известного плана.
     *
     * Если предыдущий план удалось сопоставить с текущими окнами наблюдения,
     * следующий план начинается с нахлестом на одну ЗРВ.
     * Если сопоставление не удалось, используется безопасный fallback без нахлеста.
     */
    fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
        /** Все доступные кандидаты начала/окончания плана для указанного КА. */
        val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)
        /** Индекс кандидата, с которого начинался предыдущий план. */
        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 initialStartIndex: Int = if (
            previousStartIndex >= 0 &&
            previousEndIndex > previousStartIndex
        ) {
            resolveNextStartIndex(
                currentStartIndex = previousStartIndex,
                endIndex = previousEndIndex
            )
        } else {
            candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime }
        }

        if (initialStartIndex == -1) {
            return emptyList()
        }

        return calculatePlansFromCandidates(
            spacecraftId = spacecraftPoints.spacecraftId,
            candidates = candidates,
            initialStartIndex = initialStartIndex
        )
    }

    /**
     * Возвращает план, который начинается строго с ближайшей закладки не раньше `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
        ) ?: return null

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

    /** Преобразует отсортированных кандидатов в последовательную цепочку планов. */
    private fun calculatePlansFromCandidates(
        spacecraftId: String,
        candidates: List<StartCandidate>,
        initialStartIndex: Int
    ): List<CalculatedPlan> {
        if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) {
            return emptyList()
        }

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

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

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

            /** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */
            val nextStartIndex: Int = resolveNextStartIndex(
                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
                    )
                }
            }
            .sortedBy { candidate -> candidate.startTime }

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

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

    /**
     * Возвращает безопасный индекс старта следующего плана.
     *
     * Если длина текущего плана позволяет, следующий план стартует на одну ЗРВ раньше конца текущего.
     * Если такой нахлест невозможен, сервис просто двигается вперед на следующий кандидат,
     * чтобы не зациклиться и не повторить тот же самый план.
     */
    private fun resolveNextStartIndex(currentStartIndex: Int, endIndex: Int): Int =
        maxOf(currentStartIndex + 1, endIndex - overlapShiftByWindows)

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

        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)
            }
        )
    }

    /** Возвращает модуль разницы между двумя длительностями. */
    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
            )
    }
}
