package space.nstart.pcp_tgu_service.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import space.nstart.pcp_tgu_service.config.PlanningProperties
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
import space.nstart.pcp_tgu_service.domain.InsertionPoint
import space.nstart.pcp_tgu_service.domain.ObservationWindow
import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
import java.time.LocalDateTime

class PlanCalculationServiceTest {

    @Test
    fun `calculatePlans keeps neighbouring plans overlapped by one window when possible`() {
        val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)

        val plans = service.calculatePlans(
            spacecraftPoints(
                window("KPP-1", start),
                window("KPP-2", start.plusHours(8)),
                window("KPP-3", start.plusHours(16)),
                window("KPP-4", start.plusHours(23).plusMinutes(55), 15),
                window("KPP-5", start.plusHours(24).plusMinutes(10)),
                window("KPP-6", start.plusHours(32)),
                window("KPP-7", start.plusHours(40)),
                window("KPP-8", start.plusHours(47).plusMinutes(55), 15)
            )
        )

        assertChainInvariant(plans)
        assertEquals(3, plans.size)
        assertEquals(start, plans[0].startTime)
        assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
        assertEquals(start.plusHours(16), plans[1].startTime)
        assertEquals(start.plusHours(40).plusMinutes(10), plans[1].endTime)
        assertEquals("KPP-3", plans[1].kppId)
        assertEquals(start.plusHours(32), plans[2].startTime)
        assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime)
        assertEquals("KPP-6", plans[2].kppId)
    }

    @Test
    fun `calculatePlansAfter keeps one window overlap with previous plan when it can be matched`() {
        val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)
        val spacecraftPoints = spacecraftPoints(
            window("KPP-1", start),
            window("KPP-2", start.plusHours(8)),
            window("KPP-3", start.plusHours(16)),
            window("KPP-4", start.plusHours(23).plusMinutes(55), 15),
            window("KPP-5", start.plusHours(24).plusMinutes(10)),
            window("KPP-6", start.plusHours(32)),
            window("KPP-7", start.plusHours(40)),
            window("KPP-8", start.plusHours(47).plusMinutes(55), 15)
        )
        val fullPlans = service.calculatePlans(spacecraftPoints)

        val plansAfterFirst = service.calculatePlansAfter(spacecraftPoints, fullPlans.first())

        assertChainInvariant(plansAfterFirst)
        assertEquals(2, plansAfterFirst.size)
        assertEquals(start.plusHours(16), plansAfterFirst[0].startTime)
        assertEquals(start.plusHours(40).plusMinutes(10), plansAfterFirst[0].endTime)
        assertEquals("KPP-3", plansAfterFirst[0].kppId)
        assertEquals(start.plusHours(32), plansAfterFirst[1].startTime)
        assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[1].endTime)
        // Хвост не должен заканчиваться раньше принятого базового плана.
        assertTrue(plansAfterFirst.all { plan -> plan.endTime > fullPlans.first().endTime })
    }

    @Test
    fun `calculatePlansAfter rebuilds a fresh chain when the base plan is entirely in the past`() {
        // Регресс: при обновлении окна тянутся из [now, now+Nд], и принятый базовый план
        // со временем уходит в прошлое за левый край окна. Тогда хвост нельзя сопоставить
        // с базой — но расписание не должно схлопнуться в пустоту (иначе supersede погасил
        // бы всё будущее). Должна строиться свежая цепочка с самого раннего окна.
        val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
        val futureStart = LocalDateTime.of(2026, 3, 10, 10, 0)
        val spacecraftPoints = spacecraftPoints(
            window("KPP-1", futureStart),
            window("KPP-2", futureStart.plusHours(8)),
            window("KPP-3", futureStart.plusHours(16)),
            window("KPP-4", futureStart.plusHours(23).plusMinutes(55), 15),
            window("KPP-5", futureStart.plusHours(24).plusMinutes(10))
        )
        // Базовый план целиком раньше любого доступного окна (состарился и выпал из горизонта).
        val agedOutBasePlan = CalculatedPlan(
            spacecraftId = "25544",
            startTime = futureStart.minusDays(2),
            endTime = futureStart.minusDays(1),
            kppId = "KPP-OLD"
        )

        val plans = service.calculatePlansAfter(spacecraftPoints, agedOutBasePlan)

        assertChainInvariant(plans)
        assertTrue(plans.isNotEmpty(), "расписание не должно схлопнуться в пустоту: $plans")
        assertEquals(service.calculatePlans(spacecraftPoints), plans, "должна строиться та же свежая цепочка")
        assertEquals(futureStart, plans.first().startTime)
    }

    @Test
    fun `calculatePlans returns empty list when only one window is available`() {
        val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 60L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)

        val plans = service.calculatePlans(
            spacecraftPoints(
                window("KPP-1", start)
            )
        )

        assertTrue(plans.isEmpty())
    }

    @Test
    fun `calculatePlans falls back to the closest end point when tail is outside delta`() {
        val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)

        val plans = service.calculatePlans(
            spacecraftPoints(
                window("KPP-1", start),
                window("KPP-2", start.plusHours(23).plusMinutes(55), 15),
                window("KPP-3", start.plusHours(30))
            )
        )

        assertChainInvariant(plans)
        assertEquals(2, plans.size)
        assertEquals(start, plans[0].startTime)
        assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
        assertEquals(start.plusHours(23).plusMinutes(55), plans[1].startTime)
        assertEquals(start.plusHours(30).plusMinutes(10), plans[1].endTime)
    }

    @Test
    fun `calculateNearestPlanFrom uses nearest start and returns closest available end even outside delta`() {
        val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)

        val plan = service.calculateNearestPlanFrom(
            spacecraftPoints(
                window("KPP-NEAREST", start),
                window("KPP-SHORT-END", start.plusHours(2)),
                window("KPP-LONG-END", start.plusDays(2))
            ),
            fromTime = start
        )

        assertEquals(start, plan!!.startTime)
        assertEquals(start.plusHours(2).plusMinutes(10), plan.endTime)
        assertEquals("KPP-NEAREST", plan.kppId)
    }

    @Test
    fun `calculatePlans moves forward when overlap is impossible`() {
        val service = createService(targetMinutes = 60L, deltaMinutes = 10L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)

        val plans = service.calculatePlans(
            spacecraftPoints(
                window("KPP-1", start),
                window("KPP-2", start.plusMinutes(55), 15),
                window("KPP-3", start.plusHours(1).plusMinutes(30), 35)
            )
        )

        assertChainInvariant(plans)
        assertEquals(2, plans.size)
        assertEquals(start, plans[0].startTime)
        assertEquals(start.plusMinutes(55), plans[1].startTime)
    }

    @Test
    fun `calculatePlans picks duration closest to target when windows are separated by large gaps`() {
        val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)

        val plans = service.calculatePlans(
            spacecraftPoints(
                window("KPP-1", start),
                window("KPP-2", start.plusHours(1).plusMinutes(30)),
                window("KPP-3", start.plusDays(2))
            )
        )

        assertChainInvariant(plans)
        assertEquals(2, plans.size)
        assertEquals(start, plans[0].startTime)
        assertEquals(start.plusHours(1).plusMinutes(40), plans[0].endTime)
        assertEquals(start.plusHours(1).plusMinutes(30), plans[1].startTime)
        assertEquals(start.plusDays(2).plusMinutes(10), plans[1].endTime)
    }


    @Test
    fun `calculatePlans chooses plan closest to target even when only short and long options exist`() {
        val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)

        val plans = service.calculatePlans(
            spacecraftPoints(
                window("KPP-1", start),
                window("KPP-2", start.plusHours(1).plusMinutes(30)),
                window("KPP-3", start.plusDays(2))
            )
        )

        assertChainInvariant(plans)
        assertEquals(start.plusHours(1).plusMinutes(40), plans.first().endTime)
    }

    @Test
    fun `calculatePlans handles ten simultaneous windows without looping`() {
        val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)
        val simultaneousWindows = buildList {
            for (index in 1..10) {
                add(window("SIM-$index", start))
            }
            for (index in 1..10) {
                add(window("END-$index", start.plusHours(23).plusMinutes(55), 15))
            }
        }

        val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray()))

        assertChainInvariant(plans)
        assertTrue(plans.isNotEmpty())
        assertTrue(plans.size <= simultaneousWindows.size)
        assertEquals(start, plans.first().startTime)
        assertEquals(start.plusDays(1).plusMinutes(10), plans.first().endTime)
    }

    @Test
    fun `calculatePlans never nests plans when several stations see the same passes (Emissio case)`() {
        // Прод-конфигурация: цель 3 часа, допуск 1 час.
        val service = createService(targetMinutes = 180L, deltaMinutes = 60L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)
        // Пролёты примерно раз в час, каждый виден тремя станциями почти одновременно
        // (это и есть корень вложенности у Emissio): одинаковые/близкие старты, дублирующиеся ЗРВ.
        val stations = listOf("ST-A", "ST-B", "ST-C")
        val windows = buildList {
            for (passIndex in 0..8) {
                stations.forEachIndexed { stationOffset, stationId ->
                    add(
                        window(
                            pointId = "$stationId-$passIndex",
                            startTime = start.plusHours(passIndex.toLong()).plusMinutes(stationOffset.toLong())
                        )
                    )
                }
            }
        }

        val plans = service.calculatePlans(spacecraftPoints(*windows.toTypedArray()))

        assertChainInvariant(plans)
        assertTrue(plans.size >= 2, "expected a multi-plan chain, got ${plans.size}")
    }

    @Test
    fun `calculatePlans never leaves a gap between plans across a visibility gap`() {
        val service = createService(targetMinutes = 180L, deltaMinutes = 60L)
        val start = LocalDateTime.of(2026, 3, 10, 10, 0)
        // Воспроизводит форму с экрана Reflexio: раннее окно, затем большой провал
        // видимости, затем далёкий кластер. Провал допустимо перекрыть одним длинным
        // планом, но разрыва между планами быть не должно.
        val gap = start.plusDays(3)
        val windows = buildList {
            for (minutes in 0..240 step 20) {
                add(window("NEAR-$minutes", start.plusMinutes(minutes.toLong())))
            }
            for (minutes in 0..240 step 20) {
                add(window("FAR-$minutes", gap.plusMinutes(minutes.toLong())))
            }
        }

        val plans = service.calculatePlans(spacecraftPoints(*windows.toTypedArray()))

        // Главное требование: каждый следующий план стартует строго внутри предыдущего (без разрыва).
        assertChainInvariant(plans)
        // Провал перекрыт одним длинным планом: есть план, начинающийся до провала и кончающийся после.
        assertTrue(
            plans.any { plan -> plan.startTime < gap && plan.endTime > gap },
            "the visibility gap must be bridged by a single plan: $plans"
        )
        // И цепочка действительно доходит до далёкого кластера, а не обрывается до провала.
        assertTrue(
            plans.any { plan -> plan.startTime >= gap },
            "the chain must reach the far cluster after the gap: $plans"
        )
    }

    /**
     * Проверяет главный инвариант цепочки: старт и конец строго возрастают, соседние планы
     * строго перекрываются (без разрыва), и ни один план не лежит целиком внутри другого.
     */
    private fun assertChainInvariant(plans: List<CalculatedPlan>) {
        for (index in 1 until plans.size) {
            assertTrue(
                plans[index].startTime > plans[index - 1].startTime,
                "start must strictly increase at index $index: $plans"
            )
            assertTrue(
                plans[index].endTime > plans[index - 1].endTime,
                "end must strictly increase at index $index: $plans"
            )
            assertTrue(
                plans[index].startTime < plans[index - 1].endTime,
                "plans must strictly overlap (no gap) at index $index: $plans"
            )
        }
        for (outer in plans.indices) {
            for (inner in plans.indices) {
                if (outer == inner) continue
                val contains = plans[outer].startTime <= plans[inner].startTime &&
                    plans[inner].endTime <= plans[outer].endTime
                assertFalse(contains, "plan $inner must not be nested in plan $outer: $plans")
            }
        }
    }

    private fun createService(targetMinutes: Long, deltaMinutes: Long): PlanCalculationService =
        PlanCalculationService(
            PlanningProperties(
                notificationBeforeStartMinutes = 30,
                targetPlanDurationMinutes = targetMinutes,
                targetPlanDurationDeltaMinutes = deltaMinutes,
                externalUpdateEnabled = false,
                externalPointsLookaheadDays = 3,
                inMemoryHistoryDays = 14,
                externalUpdateFixedDelayMs = 0,
                notificationCheckFixedDelayMs = 30_000,
                minReissueLeadMinutes = 5,
                reissuePrepMinutes = 30,
                workerLockTtlSeconds = 300,
                waitingDecisionGraceMinutes = 30,
                layinFreezeMinutes = 10
            )
        )

    private fun spacecraftPoints(vararg windows: Pair<String, ObservationWindow>): SpacecraftPoints =
        SpacecraftPoints(
            spacecraftId = "25544",
            points = windows
                .groupBy({ it.first }, { it.second })
                .map { (pointId, observationWindows) ->
                    InsertionPoint(
                        pointId = pointId,
                        observationWindows = observationWindows
                    )
                }
        )

    private fun window(pointId: String, startTime: LocalDateTime, durationMinutes: Long = 10): Pair<String, ObservationWindow> =
        pointId to ObservationWindow(
            startTime = startTime,
            endTime = startTime.plusMinutes(durationMinutes),
            elevationAngle = 10.0
        )
}
