package space.nstart.pcp.slots_service.model

import org.locationtech.jts.geom.Envelope
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.index.strtree.STRtree
import org.slf4j.Logger
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import java.time.Duration
import java.time.LocalDateTime
import kotlin.math.abs
import kotlin.math.max

class ContinuousReqCoverSolver(
    private val logger: Logger,
    private val satellitesById: Map<Long, AbstractSatellite>
) {

    private data class ContinuousCandidate(
        val base: CoverageCandidate,
        val leftLongitude: Double,
        val rightLongitude: Double,
        val centerLongitude: Double,
        val trackLongitude: Double
    )

    private data class TransitionEvaluation(
        val candidate: ContinuousCandidate,
        val gainArea: Double,
        val overlapArea: Double,
        val geometryGap: Double,
        val longitudeAdvance: Double,
        val branchPenalty: Int,
        val rollDelta: Double,
        val connected: Boolean,
        val meaningfulGain: Boolean,
        val score: Double
    )

    private data class StartEvaluation(
        val candidate: ContinuousCandidate,
        val gainArea: Double,
        val previewGainArea: Double,
        val previewLength: Int,
        val rightReach: Double,
        val score: Double
    )

    private data class StartPreScore(
        val candidate: ContinuousCandidate,
        val gainArea: Double,
        val score: Double
    )

    private data class PreviewSummary(
        val gainArea: Double,
        val length: Int,
        val rightReach: Double
    )

    private data class LongitudeOrdering(
        val useSlotLongitude: Boolean
    )

    private data class PairwiseTransitionData(
        val relation: LongitudeRelation,
        val longitudeAdvance: Double,
        val branchPenalty: Int,
        val rollDelta: Double,
        val chainProgressBonus: Double,
        val connected: Boolean,
        val geometryGap: Double
    )

    private data class CandidateSearchContext(
        val groupsByKey: Map<CoverageGroupKey, CoverageGroup>,
        val candidateIndexes: CoverageCandidateIndexes,
        val longitudeOrdering: LongitudeOrdering,
        val orderingLongitudeById: DoubleArray,
        val sortedByLongitudeIds: IntArray,
        val sortedLongitudePositionsById: IntArray,
        val groupForwardNeighborIds: Array<IntArray>,
        val spatialNeighborIds: Array<IntArray>,
        val pairwiseCache: MutableMap<Long, PairwiseTransitionData>
    )

    private class LocalActiveOverlay(
        private val baseActive: BooleanArray
    ) {
        private val locallyDisabled = hashSetOf<Int>()

        fun isActive(candidateId: Int): Boolean = baseActive[candidateId] && candidateId !in locallyDisabled

        fun deactivate(candidateId: Int) {
            locallyDisabled += candidateId
        }
    }

    private class SegmentState {
        private val selected = mutableListOf<ContinuousCandidate>()
        private val selectedBySatellite = mutableMapOf<Long, MutableList<ContinuousCandidate>>()
        private val maxGroupIndexByKey = mutableMapOf<CoverageGroupKey, Int>()
        private val groupBounds = mutableMapOf<CoverageGroupKey, Pair<LocalDateTime, LocalDateTime>>()

        fun add(candidate: ContinuousCandidate) {
            selected += candidate
            selectedBySatellite.getOrPut(candidate.base.slot.satelliteId) { mutableListOf() }.add(candidate)
            maxGroupIndexByKey.merge(candidate.base.groupKey, candidate.base.groupIndex, ::maxOf)
            val currentBounds = groupBounds[candidate.base.groupKey]
            groupBounds[candidate.base.groupKey] = if (currentBounds == null) {
                candidate.base.slot.tn to candidate.base.slot.tk
            } else {
                minOf(currentBounds.first, candidate.base.slot.tn) to maxOf(currentBounds.second, candidate.base.slot.tk)
            }
        }

        fun candidates(): List<ContinuousCandidate> = selected

        fun size(): Int = selected.size

        fun selectedOnSatellite(satelliteId: Long): List<ContinuousCandidate> =
            selectedBySatellite[satelliteId] ?: emptyList()

        fun maxGroupIndex(groupKey: CoverageGroupKey): Int? = maxGroupIndexByKey[groupKey]

        fun projectedGroupDurationSeconds(groupKey: CoverageGroupKey, candidate: ContinuousCandidate): Long {
            val currentBounds = groupBounds[groupKey]
            if (currentBounds == null) {
                return Duration.between(candidate.base.slot.tn, candidate.base.slot.tk).seconds.coerceAtLeast(0L)
            }
            val start = minOf(currentBounds.first, candidate.base.slot.tn)
            val end = maxOf(currentBounds.second, candidate.base.slot.tk)
            return Duration.between(start, end).seconds.coerceAtLeast(0L)
        }
    }

    private val support = CoverageSolverSupport(logger, satellitesById)

    fun select(targetWkt: String, slots: List<SlotDTO>): List<SlotDTO> {
        if (slots.isEmpty()) {
            return emptyList()
        }

        val slotChains = mergeSlotsToChains(slots)
        logger.info("Continuous solver chains after aggregation: {}", slotChains.size)
        if (slotChains.isEmpty()) {
            return emptyList()
        }

        val targetGeometry = support.parseTargetGeometry(targetWkt)
        if (targetGeometry.isEmpty || targetGeometry.area <= CoverageSolverSupport.AREA_EPS) {
            return emptyList()
        }

        val preparedCandidates = support.buildCandidates(slotChains, targetGeometry)
        val baseCandidates = preparedCandidates.first
        if (baseCandidates.isEmpty()) {
            return emptyList()
        }
        val chainCandidates = baseCandidates.map { it.toContinuousCandidate(targetGeometry) }
        val searchContext = buildSearchContext(
            candidates = chainCandidates,
            groupsByKey = preparedCandidates.second
        )

        val active = BooleanArray(chainCandidates.size) { true }
        val selected = mutableListOf<CoverageCandidate>()
        var uncovered = targetGeometry
        var uncoveredEnvelope = targetGeometry.envelopeInternal

        val bookedSelected = mutableListOf<CoverageCandidate>()
        chainCandidates.forEach { candidate ->
            when (candidate.base.slot.state) {
                SlotStatus.BOOKED -> {
                    bookedSelected += candidate.base
                    selected += candidate.base
                    uncovered = support.safeDifference(uncovered, candidate.base.coveredGeometry)
                    uncoveredEnvelope = uncovered.envelopeInternal
                    active[candidate.base.id] = false
                }
                SlotStatus.AVAILABLE -> Unit
                else -> active[candidate.base.id] = false
            }
        }
        if (bookedSelected.isNotEmpty()) {
            support.deactivateConflicts(
                bookedSelected,
                searchContext.candidateIndexes.satelliteCandidates,
                chainCandidates.map { it.base },
                active
            )
        }

        var current = chooseStart(chainCandidates, active, targetGeometry, searchContext)
        while (current != null) {
            val segment = buildContinuousSegment(
                anchor = current,
                candidates = chainCandidates,
                active = active,
                uncovered = uncovered,
                targetGeometry = targetGeometry,
                searchContext = searchContext
            )
            if (segment.isEmpty()) {
                active[current.base.id] = false
                current = chooseStart(chainCandidates, active, targetGeometry, searchContext)
                continue
            }

            val accepted = mutableListOf<CoverageCandidate>()
            for (candidate in segment) {
                if (!active[candidate.base.id]) {
                    continue
                }
                if (!candidate.base.coveredEnvelope.intersects(uncoveredEnvelope)) {
                    active[candidate.base.id] = false
                    continue
                }

                val gainArea = support.safeIntersection(candidate.base.coveredGeometry, uncovered).area
                if (gainArea <= CoverageSolverSupport.AREA_EPS) {
                    active[candidate.base.id] = false
                    continue
                }

                selected += candidate.base
                accepted += candidate.base
                uncovered = support.safeDifference(uncovered, candidate.base.coveredGeometry)
                uncoveredEnvelope = uncovered.envelopeInternal
                active[candidate.base.id] = false

                if (uncovered.isEmpty || uncovered.area <= CoverageSolverSupport.AREA_EPS) {
                    break
                }
            }

            if (accepted.isNotEmpty()) {
                support.deactivateConflicts(
                    accepted,
                    searchContext.candidateIndexes.satelliteCandidates,
                    chainCandidates.map { it.base },
                    active
                )
            }

            if (uncovered.isEmpty || uncovered.area <= CoverageSolverSupport.AREA_EPS) {
                break
            }

            current = chooseStart(chainCandidates, active, targetGeometry, searchContext)
        }

        val reducedSelected = support.removeRedundant(selected)
        val preparedSourceCoverageMap = support.buildPreparedSourceCoverageMap(reducedSelected)
        val resultSlots = support.collectUsefulSlotsFromSelectedChains(reducedSelected, targetGeometry)
        val filteredResult = support.removeNonCoveringAndFullyCoveredResultSlots(
            slots = resultSlots,
            targetGeometry = targetGeometry,
            preparedSourceCoverages = preparedSourceCoverageMap
        )
        logger.info(
            "Continuous coverage solver result: chains={}, selectedChains={}, resultSlots={}",
            chainCandidates.size,
            selected.size,
            filteredResult.size
        )
        return filteredResult.sortedWith(compareBy<SlotDTO>(
            { it.tn },
            { it.satelliteId },
            { it.cycle },
            { it.slotNumber }
        ))
    }

    private fun chooseStart(
        candidates: List<ContinuousCandidate>,
        active: BooleanArray,
        targetGeometry: Geometry,
        searchContext: CandidateSearchContext
    ): ContinuousCandidate? {
        val available = candidates
            .asSequence()
            .filter { active[it.base.id] && it.base.slot.state == SlotStatus.AVAILABLE }
            .mapNotNull { candidate ->
                val gainArea = candidate.base.coveredGeometry.area
                if (gainArea <= CoverageSolverSupport.AREA_EPS) {
                    return@mapNotNull null
                }
                StartPreScore(
                    candidate = candidate,
                    gainArea = gainArea,
                    score = cheapStartScore(candidate, gainArea, searchContext)
                )
            }
            .sortedWith(
                compareByDescending<StartPreScore> { it.score }
                    .thenByDescending { it.gainArea }
                    .thenByDescending { orderingLongitude(it.candidate, searchContext.longitudeOrdering) }
                    .thenByDescending { it.candidate.rightLongitude }
            )
            .toList()
        if (available.isEmpty()) {
            return null
        }

        val exactEvaluations = available
            .take(START_PREVIEW_TOP_K)
            .map { start ->
                val preview = previewSegment(
                    anchor = start.candidate,
                    candidates = candidates,
                    active = active,
                    targetGeometry = targetGeometry,
                    searchContext = searchContext
                )
                val score = preview.gainArea * START_PREVIEW_GAIN_WEIGHT +
                    start.gainArea * START_GAIN_WEIGHT +
                    preview.length * START_PREVIEW_LENGTH_WEIGHT +
                    max(0.0, preview.rightReach - orderingLongitude(start.candidate, searchContext.longitudeOrdering)) *
                    START_RIGHT_REACH_WEIGHT
                StartEvaluation(
                    candidate = start.candidate,
                    gainArea = start.gainArea,
                    previewGainArea = preview.gainArea,
                    previewLength = preview.length,
                    rightReach = preview.rightReach,
                    score = score
                )
            }

        return exactEvaluations.maxWithOrNull(
            compareBy<StartEvaluation> { it.score }
                .thenBy { it.previewGainArea }
                .thenBy { it.previewLength }
                .thenBy { it.gainArea }
                .thenBy { it.rightReach }
                .thenByDescending { orderingLongitude(it.candidate, searchContext.longitudeOrdering) }
                .thenByDescending { it.candidate.rightLongitude }
                .thenByDescending { it.candidate.base.slot.tn }
                .thenByDescending { it.candidate.base.slot.slotNumber }
        )?.candidate
    }

    private fun chooseNext(
        previous: ContinuousCandidate,
        candidates: List<ContinuousCandidate>,
        active: LocalActiveOverlay,
        uncovered: Geometry,
        uncoveredEnvelope: Envelope,
        targetGeometry: Geometry,
        segmentState: SegmentState,
        searchContext: CandidateSearchContext
    ): ContinuousCandidate? {
        val evaluations = evaluateCandidates(
            previous = previous,
            candidates = candidates,
            candidateIds = neighborCandidateIds(previous, searchContext, LONGITUDE_NEIGHBOR_LIMIT),
            active = active,
            uncovered = uncovered,
            uncoveredEnvelope = uncoveredEnvelope,
            targetGeometry = targetGeometry,
            segmentState = segmentState,
            searchContext = searchContext
        )
        val boundedEvaluations = if (evaluations.isNotEmpty()) {
            evaluations
        } else {
            evaluateCandidates(
                previous = previous,
                candidates = candidates,
                candidateIds = neighborCandidateIds(previous, searchContext, EXTENDED_LONGITUDE_NEIGHBOR_LIMIT),
                active = active,
                uncovered = uncovered,
                uncoveredEnvelope = uncoveredEnvelope,
                targetGeometry = targetGeometry,
                segmentState = segmentState,
                searchContext = searchContext
            )
        }
        if (boundedEvaluations.isEmpty()) {
            return null
        }

        val connectedMeaningful = boundedEvaluations.filter { it.connected && it.meaningfulGain }
        if (connectedMeaningful.isNotEmpty()) {
            return connectedMeaningful.maxWithOrNull(connectedComparator())?.candidate
        }

        return boundedEvaluations.maxWithOrNull(fallbackComparator())?.candidate
    }

    private fun evaluateCandidates(
        previous: ContinuousCandidate,
        candidates: List<ContinuousCandidate>,
        candidateIds: IntArray,
        active: LocalActiveOverlay,
        uncovered: Geometry,
        uncoveredEnvelope: Envelope,
        targetGeometry: Geometry,
        segmentState: SegmentState,
        searchContext: CandidateSearchContext
    ): List<TransitionEvaluation> {
        if (candidateIds.isEmpty()) {
            return emptyList()
        }
        return candidateIds.asSequence()
            .map { candidates[it] }
            .filter { active.isActive(it.base.id) && it.base.slot.state == SlotStatus.AVAILABLE && it.base.id != previous.base.id }
            .filter { candidate -> canFollowInSegment(previous, candidate, segmentState) }
            .mapNotNull { candidate ->
                evaluateTransition(
                    previous = previous,
                    candidate = candidate,
                    uncovered = uncovered,
                    uncoveredEnvelope = uncoveredEnvelope,
                    targetGeometry = targetGeometry,
                    searchContext = searchContext
                )
            }
            .toList()
    }

    private fun buildContinuousSegment(
        anchor: ContinuousCandidate,
        candidates: List<ContinuousCandidate>,
        active: BooleanArray,
        uncovered: Geometry,
        targetGeometry: Geometry,
        searchContext: CandidateSearchContext
    ): List<ContinuousCandidate> {
        val segmentState = SegmentState()
        val localActive = LocalActiveOverlay(active)
        var localUncovered = uncovered
        var localUncoveredEnvelope = uncovered.envelopeInternal
        var current: ContinuousCandidate? = anchor

        while (current != null && localActive.isActive(current.base.id)) {
            if (!current.base.coveredEnvelope.intersects(localUncoveredEnvelope)) {
                localActive.deactivate(current.base.id)
                break
            }

            val gainArea = support.safeIntersection(current.base.coveredGeometry, localUncovered).area
            if (gainArea <= CoverageSolverSupport.AREA_EPS) {
                localActive.deactivate(current.base.id)
                break
            }

            segmentState.add(current)
            localActive.deactivate(current.base.id)
            localUncovered = support.safeDifference(localUncovered, current.base.coveredGeometry)
            localUncoveredEnvelope = localUncovered.envelopeInternal
            if (localUncovered.isEmpty || localUncovered.area <= CoverageSolverSupport.AREA_EPS) {
                break
            }

            current = chooseNext(
                previous = current,
                candidates = candidates,
                active = localActive,
                uncovered = localUncovered,
                uncoveredEnvelope = localUncoveredEnvelope,
                targetGeometry = targetGeometry,
                segmentState = segmentState,
                searchContext = searchContext
            )
        }

        return segmentState.candidates()
    }

    private fun previewSegment(
        anchor: ContinuousCandidate,
        candidates: List<ContinuousCandidate>,
        active: BooleanArray,
        targetGeometry: Geometry,
        searchContext: CandidateSearchContext
    ): PreviewSummary {
        val previewState = SegmentState()
        val localActive = LocalActiveOverlay(active)
        var localUncovered = targetGeometry
        var localUncoveredEnvelope = targetGeometry.envelopeInternal
        var current: ContinuousCandidate? = anchor
        var totalGain = 0.0
        var depth = 0
        var rightReach = orderingLongitude(anchor, searchContext.longitudeOrdering)

        while (current != null && localActive.isActive(current.base.id) && depth < START_LOOKAHEAD_STEPS) {
            if (!current.base.coveredEnvelope.intersects(localUncoveredEnvelope)) {
                break
            }

            val gainArea = support.safeIntersection(current.base.coveredGeometry, localUncovered).area
            if (gainArea <= CoverageSolverSupport.AREA_EPS) {
                break
            }

            totalGain += gainArea
            rightReach = max(rightReach, orderingLongitude(current, searchContext.longitudeOrdering))
            previewState.add(current)
            localActive.deactivate(current.base.id)
            localUncovered = support.safeDifference(localUncovered, current.base.coveredGeometry)
            localUncoveredEnvelope = localUncovered.envelopeInternal
            if (localUncovered.isEmpty || localUncovered.area <= CoverageSolverSupport.AREA_EPS) {
                break
            }

            current = chooseNext(
                previous = current,
                candidates = candidates,
                active = localActive,
                uncovered = localUncovered,
                uncoveredEnvelope = localUncoveredEnvelope,
                targetGeometry = targetGeometry,
                segmentState = previewState,
                searchContext = searchContext
            )
            depth++
        }

        return PreviewSummary(
            gainArea = totalGain,
            length = previewState.size(),
            rightReach = rightReach
        )
    }

    private fun evaluateTransition(
        previous: ContinuousCandidate,
        candidate: ContinuousCandidate,
        uncovered: Geometry,
        uncoveredEnvelope: Envelope,
        targetGeometry: Geometry,
        searchContext: CandidateSearchContext
    ): TransitionEvaluation? {
        if (!candidate.base.coveredEnvelope.intersects(uncoveredEnvelope)) {
            return null
        }

        val pairwise = pairwiseTransition(previous, candidate, searchContext)
        if (pairwise.relation == LongitudeRelation.BACKWARD) {
            return null
        }
        if (pairwise.relation == LongitudeRelation.SAME && !pairwise.connected) {
            return null
        }

        val gainArea = support.safeIntersection(candidate.base.coveredGeometry, uncovered).area
        if (gainArea <= CoverageSolverSupport.AREA_EPS) {
            return null
        }

        val meaningfulGain = gainArea >= meaningfulGainThreshold(targetGeometry.area)
        if (pairwise.relation == LongitudeRelation.SAME && !meaningfulGain) {
            return null
        }

        val overlapArea = candidate.base.coveredGeometry.area - gainArea
        val score = gainArea * TRANSITION_GAIN_WEIGHT -
            overlapArea * TRANSITION_OVERLAP_WEIGHT -
            pairwise.branchPenalty * TRANSITION_BRANCH_WEIGHT -
            pairwise.rollDelta * TRANSITION_ROLL_WEIGHT -
            pairwise.geometryGap * TRANSITION_GAP_WEIGHT +
            max(0.0, pairwise.longitudeAdvance) * TRANSITION_RIGHT_PROGRESS_WEIGHT +
            pairwise.chainProgressBonus * TRANSITION_CHAIN_PROGRESS_WEIGHT +
            if (pairwise.connected) TRANSITION_CONTINUITY_BONUS else 0.0

        return TransitionEvaluation(
            candidate = candidate,
            gainArea = gainArea,
            overlapArea = overlapArea,
            geometryGap = pairwise.geometryGap,
            longitudeAdvance = max(0.0, pairwise.longitudeAdvance),
            branchPenalty = pairwise.branchPenalty,
            rollDelta = pairwise.rollDelta,
            connected = pairwise.connected,
            meaningfulGain = meaningfulGain,
            score = score
        )
    }

    private fun pairwiseTransition(
        previous: ContinuousCandidate,
        candidate: ContinuousCandidate,
        searchContext: CandidateSearchContext
    ): PairwiseTransitionData {
        val key = pairwiseKey(previous.base.id, candidate.base.id)
        return searchContext.pairwiseCache.getOrPut(key) {
            val relation = longitudeRelation(previous, candidate, searchContext.longitudeOrdering)
            val longitudeAdvance = orderingLongitude(candidate, searchContext.longitudeOrdering) -
                orderingLongitude(previous, searchContext.longitudeOrdering)
            val branchPenalty = branchPenalty(previous, candidate)
            val rollDelta = abs(previous.base.slot.roll - candidate.base.slot.roll)
            val chainProgressBonus = chainProgressBonus(previous, candidate)

            if (relation == LongitudeRelation.BACKWARD) {
                return@getOrPut PairwiseTransitionData(
                    relation = relation,
                    longitudeAdvance = longitudeAdvance,
                    branchPenalty = branchPenalty,
                    rollDelta = rollDelta,
                    chainProgressBonus = chainProgressBonus,
                    connected = false,
                    geometryGap = Double.MAX_VALUE
                )
            }

            val connected = runCatching {
                previous.base.coveredGeometry.intersects(candidate.base.coveredGeometry) ||
                    previous.base.coveredGeometry.touches(candidate.base.coveredGeometry)
            }.getOrDefault(false)
            val geometryGap = if (connected) {
                0.0
            } else {
                runCatching { previous.base.coveredGeometry.distance(candidate.base.coveredGeometry) }
                    .getOrDefault(envelopeGap(previous.base.coveredEnvelope, candidate.base.coveredEnvelope))
            }

            PairwiseTransitionData(
                relation = relation,
                longitudeAdvance = longitudeAdvance,
                branchPenalty = branchPenalty,
                rollDelta = rollDelta,
                chainProgressBonus = chainProgressBonus,
                connected = connected,
                geometryGap = geometryGap
            )
        }
    }

    private fun connectedComparator(): Comparator<TransitionEvaluation> =
        compareBy<TransitionEvaluation> { it.score }
            .thenBy { it.gainArea }
            .thenBy { -it.overlapArea }
            .thenBy { -it.longitudeAdvance }
            .thenBy { -it.branchPenalty }
            .thenBy { -it.rollDelta }
            .thenBy { -it.candidate.base.slot.tn.toLocalTime().toSecondOfDay() }

    private fun fallbackComparator(): Comparator<TransitionEvaluation> =
        compareBy<TransitionEvaluation> { it.gainArea }
            .thenBy { -it.score }
            .thenBy { -it.longitudeAdvance }
            .thenBy { -it.branchPenalty }
            .thenBy { -it.rollDelta }
            .thenBy { -it.geometryGap }
            .thenBy { -it.overlapArea }

    private fun cheapStartScore(
        candidate: ContinuousCandidate,
        gainArea: Double,
        searchContext: CandidateSearchContext
    ): Double {
        val continuationCount = searchContext.groupForwardNeighborIds[candidate.base.id].size
        val spatialCount = searchContext.spatialNeighborIds[candidate.base.id].count { neighborId ->
            orderingLongitudeById(neighborId, searchContext) >= orderingLongitude(candidate, searchContext.longitudeOrdering)
        }
        val rightReach = cheapRightReach(candidate, searchContext)
        return gainArea * START_GAIN_WEIGHT +
            continuationCount * START_CHEAP_GROUP_WEIGHT +
            spatialCount * START_CHEAP_SPATIAL_WEIGHT +
            max(0.0, rightReach - orderingLongitude(candidate, searchContext.longitudeOrdering)) * START_RIGHT_REACH_WEIGHT
    }

    private fun cheapRightReach(
        candidate: ContinuousCandidate,
        searchContext: CandidateSearchContext
    ): Double {
        var rightReach = orderingLongitude(candidate, searchContext.longitudeOrdering)
        val position = searchContext.sortedLongitudePositionsById[candidate.base.id]
        val until = minOf(searchContext.sortedByLongitudeIds.size, position + START_CHEAP_RIGHT_REACH_WINDOW + 1)
        for (index in position + 1 until until) {
            rightReach = max(rightReach, orderingLongitudeById(searchContext.sortedByLongitudeIds[index], searchContext))
        }
        val group = searchContext.groupsByKey[candidate.base.groupKey]
        if (group != null && candidate.base.groupIndex < group.candidateIds.lastIndex) {
            val lastId = group.candidateIds.last()
            rightReach = max(rightReach, orderingLongitudeById(lastId, searchContext))
        }
        return rightReach
    }

    private fun neighborCandidateIds(
        previous: ContinuousCandidate,
        searchContext: CandidateSearchContext,
        longitudeWindow: Int
    ): IntArray {
        val ids = linkedSetOf<Int>()
        ids += searchContext.groupForwardNeighborIds[previous.base.id].asIterable()
        ids += searchContext.spatialNeighborIds[previous.base.id].asIterable()

        val position = searchContext.sortedLongitudePositionsById[previous.base.id]
        val until = minOf(searchContext.sortedByLongitudeIds.size, position + longitudeWindow + 1)
        for (index in position + 1 until until) {
            ids += searchContext.sortedByLongitudeIds[index]
        }

        return ids.toIntArray()
    }

    private fun branchPenalty(previous: ContinuousCandidate, candidate: ContinuousCandidate): Int =
        if (previous.base.slot.revolutionSign == candidate.base.slot.revolutionSign) 0 else 1

    private fun chainProgressBonus(previous: ContinuousCandidate, candidate: ContinuousCandidate): Double =
        when {
            candidate.base.groupKey != previous.base.groupKey -> 0.0
            candidate.base.groupIndex <= previous.base.groupIndex -> 0.0
            else -> 1.0 / (candidate.base.groupIndex - previous.base.groupIndex)
        }

    private fun meaningfulGainThreshold(targetArea: Double): Double =
        max(CoverageSolverSupport.AREA_EPS * 10.0, targetArea * MEANINGFUL_GAIN_RATIO)

    private fun mergeSlotsToChains(slots: List<SlotDTO>): List<CoverageSlotChain> =
        support.mergeSlotsToChains(slots)

    private fun canFollowInSegment(
        previous: ContinuousCandidate,
        candidate: ContinuousCandidate,
        segmentState: SegmentState
    ): Boolean {
        val candidateBase = candidate.base
        val sameGroupContinuation = candidateBase.groupKey == previous.base.groupKey &&
            candidateBase.groupIndex > previous.base.groupIndex &&
            candidateBase.slot.revolutionSign == previous.base.slot.revolutionSign &&
            abs(candidateBase.slot.roll - previous.base.slot.roll) < CoverageSolverSupport.ROLL_EPS

        if (!sameGroupContinuation && segmentState.selectedOnSatellite(candidateBase.slot.satelliteId).any { selected ->
                support.intervalsIntersect(
                    candidateBase.slot.tn,
                    candidateBase.slot.tk,
                    selected.base.blockedStart,
                    selected.base.blockedEnd
                )
            }
        ) {
            return false
        }

        val maxGroupIndex = segmentState.maxGroupIndex(candidateBase.groupKey)
        if (sameGroupContinuation && maxGroupIndex != null && candidateBase.groupIndex <= maxGroupIndex) {
            return false
        }

        val segmentDurationSeconds = segmentState.projectedGroupDurationSeconds(candidateBase.groupKey, candidate)
        return segmentDurationSeconds <= candidateBase.satellite.maxSurveyDuration.toLong()
    }

    private fun CoverageCandidate.toContinuousCandidate(targetGeometry: Geometry): ContinuousCandidate =
        ContinuousCandidate(
            base = this,
            leftLongitude = coveredEnvelope.minX,
            rightLongitude = coveredEnvelope.maxX,
            centerLongitude = (coveredEnvelope.minX + coveredEnvelope.maxX) / 2.0,
            trackLongitude = normalizeLongitudeToTarget(slot.longitude, targetGeometry)
        )

    private fun buildSearchContext(
        candidates: List<ContinuousCandidate>,
        groupsByKey: Map<CoverageGroupKey, CoverageGroup>
    ): CandidateSearchContext {
        val longitudeOrdering = determineLongitudeOrdering(candidates)
        val candidateIndexes = support.buildCandidateIndexes(candidates.map { it.base })
        val orderingLongitudeById = DoubleArray(candidates.size) { candidateId ->
            orderingLongitude(candidates[candidateId], longitudeOrdering)
        }
        val sortedByLongitudeIds = candidates.indices
            .sortedWith(
                compareBy<Int> { orderingLongitudeById[it] }
                    .thenBy { candidates[it].rightLongitude }
                    .thenBy { candidates[it].base.slot.tn }
                    .thenBy { candidates[it].base.slot.slotNumber }
            )
            .toIntArray()
        val sortedLongitudePositionsById = IntArray(candidates.size)
        sortedByLongitudeIds.forEachIndexed { index, candidateId ->
            sortedLongitudePositionsById[candidateId] = index
        }

        val groupForwardNeighborIds = Array(candidates.size) { IntArray(0) }
        for ((_, group) in groupsByKey) {
            val ids = group.candidateIds
            for (index in ids.indices) {
                val candidateId = ids[index]
                val from = index + 1
                val to = minOf(ids.size, from + GROUP_FORWARD_NEIGHBOR_LIMIT)
                groupForwardNeighborIds[candidateId] =
                    if (from >= to) IntArray(0) else ids.sliceArray(from until to)
            }
        }

        val spatialNeighborIds = buildSpatialNeighborIds(candidates, candidateIndexes.spatialIndex, longitudeOrdering)

        return CandidateSearchContext(
            groupsByKey = groupsByKey,
            candidateIndexes = candidateIndexes,
            longitudeOrdering = longitudeOrdering,
            orderingLongitudeById = orderingLongitudeById,
            sortedByLongitudeIds = sortedByLongitudeIds,
            sortedLongitudePositionsById = sortedLongitudePositionsById,
            groupForwardNeighborIds = groupForwardNeighborIds,
            spatialNeighborIds = spatialNeighborIds,
            pairwiseCache = hashMapOf()
        )
    }

    private fun buildSpatialNeighborIds(
        candidates: List<ContinuousCandidate>,
        spatialIndex: STRtree,
        longitudeOrdering: LongitudeOrdering
    ): Array<IntArray> {
        return Array(candidates.size) { candidateId ->
            val candidate = candidates[candidateId]
            val searchEnvelope = Envelope(candidate.base.coveredEnvelope)
            searchEnvelope.expandBy(
                max(candidate.base.coveredEnvelope.width * SPATIAL_ENVELOPE_EXPAND_FACTOR, MIN_SPATIAL_LONGITUDE_PADDING),
                max(candidate.base.coveredEnvelope.height * SPATIAL_ENVELOPE_EXPAND_FACTOR, MIN_SPATIAL_LATITUDE_PADDING)
            )

            @Suppress("UNCHECKED_CAST")
            val neighborIds = (spatialIndex.query(searchEnvelope) as List<Any>)
                .mapNotNull { (it as? Int) }
                .filter { it != candidateId }
                .sortedWith(
                    compareBy<Int> { envelopeGap(candidate.base.coveredEnvelope, candidates[it].base.coveredEnvelope) }
                        .thenBy { abs(orderingLongitude(candidates[it], longitudeOrdering) - orderingLongitude(candidate, longitudeOrdering)) }
                )
                .take(SPATIAL_NEIGHBOR_LIMIT)
            neighborIds.toIntArray()
        }
    }

    private fun determineLongitudeOrdering(candidates: List<ContinuousCandidate>): LongitudeOrdering {
        val distinctTrackLongitudes = candidates
            .map { it.trackLongitude }
            .sorted()
            .fold(mutableListOf<Double>()) { acc, value ->
                if (acc.none { abs(it - value) <= LONGITUDE_EPS }) {
                    acc += value
                }
                acc
            }
        return LongitudeOrdering(useSlotLongitude = distinctTrackLongitudes.size > 1)
    }

    private fun orderingLongitude(candidate: ContinuousCandidate, ordering: LongitudeOrdering): Double =
        if (ordering.useSlotLongitude) candidate.trackLongitude else candidate.rightLongitude

    private fun orderingLongitudeById(candidateId: Int, searchContext: CandidateSearchContext): Double =
        searchContext.orderingLongitudeById[candidateId]

    private fun normalizeLongitudeToTarget(longitude: Double, targetGeometry: Geometry): Double {
        val targetCenter = (targetGeometry.envelopeInternal.minX + targetGeometry.envelopeInternal.maxX) / 2.0
        return listOf(longitude - 360.0, longitude, longitude + 360.0)
            .minByOrNull { abs(it - targetCenter) }
            ?: longitude
    }

    private fun longitudeRelation(
        previous: ContinuousCandidate,
        candidate: ContinuousCandidate,
        ordering: LongitudeOrdering
    ): LongitudeRelation {
        val delta = orderingLongitude(candidate, ordering) - orderingLongitude(previous, ordering)
        return when {
            delta > LONGITUDE_EPS -> LongitudeRelation.AHEAD
            abs(delta) <= LONGITUDE_EPS -> LongitudeRelation.SAME
            else -> LongitudeRelation.BACKWARD
        }
    }

    private fun pairwiseKey(previousId: Int, candidateId: Int): Long =
        (previousId.toLong() shl 32) xor (candidateId.toLong() and 0xffffffffL)

    private fun envelopeGap(left: Envelope, right: Envelope): Double {
        val dx = when {
            left.maxX < right.minX -> right.minX - left.maxX
            right.maxX < left.minX -> left.minX - right.maxX
            else -> 0.0
        }
        val dy = when {
            left.maxY < right.minY -> right.minY - left.maxY
            right.maxY < left.minY -> left.minY - right.maxY
            else -> 0.0
        }
        return kotlin.math.hypot(dx, dy)
    }

    private enum class LongitudeRelation {
        AHEAD,
        SAME,
        BACKWARD
    }

    companion object {
        private const val LONGITUDE_EPS = 1e-9
        private const val MEANINGFUL_GAIN_RATIO = 0.001

        private const val START_GAIN_WEIGHT = 100.0
        private const val START_PREVIEW_GAIN_WEIGHT = 130.0
        private const val START_PREVIEW_LENGTH_WEIGHT = 18.0
        private const val START_RIGHT_REACH_WEIGHT = 1.0
        private const val START_LOOKAHEAD_STEPS = 4
        private const val START_PREVIEW_TOP_K = 12
        private const val START_CHEAP_GROUP_WEIGHT = 18.0
        private const val START_CHEAP_SPATIAL_WEIGHT = 4.0
        private const val START_CHEAP_RIGHT_REACH_WINDOW = 12

        private const val GROUP_FORWARD_NEIGHBOR_LIMIT = 4
        private const val SPATIAL_NEIGHBOR_LIMIT = 16
        private const val LONGITUDE_NEIGHBOR_LIMIT = 20
        private const val EXTENDED_LONGITUDE_NEIGHBOR_LIMIT = 64
        private const val SPATIAL_ENVELOPE_EXPAND_FACTOR = 1.5
        private const val MIN_SPATIAL_LONGITUDE_PADDING = 0.75
        private const val MIN_SPATIAL_LATITUDE_PADDING = 0.5

        private const val TRANSITION_GAIN_WEIGHT = 100.0
        private const val TRANSITION_OVERLAP_WEIGHT = 0.35
        private const val TRANSITION_BRANCH_WEIGHT = 12.0
        private const val TRANSITION_ROLL_WEIGHT = 1.5
        private const val TRANSITION_GAP_WEIGHT = 18.0
        private const val TRANSITION_RIGHT_PROGRESS_WEIGHT = 8.0
        private const val TRANSITION_CHAIN_PROGRESS_WEIGHT = 10.0
        private const val TRANSITION_CONTINUITY_BONUS = 28.0
    }
}
