package space.nstart.pcp.pcp_coverage_scheme_service.service

import ballistics.flightLine.PointOnEarthCalculator
import ballistics.types.EarthType
import ballistics.types.Orientation
import ballistics.types.OrbitalPoint
import ballistics.types.WorkCSType
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import org.junit.jupiter.api.Test
import org.locationtech.jts.geom.Geometry
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageBallisticsClient
import space.nstart.pcp.pcp_coverage_scheme_service.client.CoverageSatelliteClient
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageObservationDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import java.time.LocalDateTime
import kotlin.math.PI
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class CoverageSchemeBuilderServiceTest {

    private val geometrySupport = GeometrySupport()

    @Test
    fun `buildCoverageScheme uses dominant branch and sorts by left boundary`() {
        val service = CoverageSchemeBuilderService(
            ballisticsClient = FakeBallisticsClient(
                orbitalPointsByRequest = mapOf(
                    requestKey(11L, LocalDateTime.of(2026, 4, 14, 10, 0)) to reverseOrbitalPoints(),
                    requestKey(22L, LocalDateTime.of(2026, 4, 14, 9, 55)) to reverseOrbitalPoints(),
                    requestKey(33L, LocalDateTime.of(2026, 4, 14, 10, 2)) to sampleOrbitalPoints()
                )
            ),
            satelliteClient = FakeSatelliteClient(
                mapOf(
                    11L to 1.5,
                    22L to 1.5,
                    33L to 1.5
                )
            ),
            geometrySupport = geometrySupport
        )

        val result = service.buildCoverageScheme(
            observations = listOf(
                observation(
                    satelliteId = 11L,
                    timeBegin = LocalDateTime.of(2026, 4, 14, 10, 0),
                    timeEnd = LocalDateTime.of(2026, 4, 14, 10, 5),
                    branch = RevolutionSign.ASC,
                    gammaMin = -2.0,
                    gammaMax = 6.0,
                    contour = "POLYGON ((195.5 2.0, 196.5 2.0, 196.5 4.2, 195.5 4.2, 195.5 2.0))"
                ),
                observation(
                    satelliteId = 22L,
                    timeBegin = LocalDateTime.of(2026, 4, 14, 9, 55),
                    timeEnd = LocalDateTime.of(2026, 4, 14, 10, 1),
                    branch = RevolutionSign.ASC,
                    gammaMin = 1.0,
                    gammaMax = 5.0,
                    contour = "POLYGON ((195.5 -0.2, 196.5 -0.2, 196.5 2.1, 195.5 2.1, 195.5 -0.2))"
                ),
                observation(
                    satelliteId = 33L,
                    timeBegin = LocalDateTime.of(2026, 4, 14, 10, 2),
                    timeEnd = LocalDateTime.of(2026, 4, 14, 10, 7),
                    branch = RevolutionSign.DESC,
                    gammaMin = 3.0,
                    gammaMax = 7.0,
                    contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
                )
            )
        )

        assertEquals(2, result.size)
        assertEquals(listOf(22L, 11L), result.map { it.satelliteId })
        assertEquals(5.0, result.first().roll)
        assertEquals(6.0, result.last().roll)
        assertTrue(result.all { it.revolutionSign == RevolutionSign.ASC })
    }

    @Test
    fun `buildCoverageScheme moves overlapping next contour to the right while preserving continuity`() {
        val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0)
        val mplPointRequests = mutableListOf<ObjViewRequestDTO>()
        val service = CoverageSchemeBuilderService(
            ballisticsClient = FakeBallisticsClient(
                orbitalPointsByRequest = mapOf(
                    requestKey(11L, requestTime) to reverseOrbitalPoints(),
                    requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints()
                ),
                mplPointsBySatellite = mapOf(
                    11L to listOf(
                        PointViewParamDTO(
                            noradId = 11L,
                            time = requestTime,
                            gamma = 6.0
                        )
                    ),
                    22L to listOf(
                        PointViewParamDTO(
                            noradId = 22L,
                            time = requestTime.plusMinutes(10),
                            gamma = 6.0
                        )
                    )
                ),
                capturedMplPointRequests = mplPointRequests
            ),
            satelliteClient = FakeSatelliteClient(
                mapOf(
                    11L to 1.5,
                    22L to 1.5
                )
            ),
            geometrySupport = geometrySupport
        )

        val result = service.buildCoverageScheme(
            observations = listOf(
                observation(
                    satelliteId = 11L,
                    timeBegin = requestTime,
                    timeEnd = requestTime.plusMinutes(5),
                    branch = RevolutionSign.ASC,
                    gammaMin = 4.0,
                    gammaMax = 4.0,
                    contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
                ),
                observation(
                    satelliteId = 22L,
                    timeBegin = requestTime.plusMinutes(10),
                    timeEnd = requestTime.plusMinutes(15),
                    branch = RevolutionSign.ASC,
                    gammaMin = 0.0,
                    gammaMax = 8.0,
                    contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
                )
            ),
            rollStepDegrees = 1.0
        )

        assertEquals(2, result.size)
        assertEquals(4.0, result.first().roll)
        assertEquals(7.5, result.last().roll)
        assertEquals(1, mplPointRequests.size)

        val firstContour = geometrySupport.parseAndNormalizeTarget(result.first().contour)
        val secondContour = geometrySupport.parseAndNormalizeTarget(result.last().contour)
        assertTrue(secondContour.area > 0.0)
        assertTrue(firstContour.area > 0.0)
        assertPositionEquals(
            expectedRightmostStartPosition(firstContour),
            mplPointRequests.single().obj.position
        )
        assertTrue(requireNotNull(mplPointRequests.single().obj.position).long in 0.0..360.0)
    }

    @Test
    fun `buildCoverageScheme uses minimum roll and right-side anchor for desc branch`() {
        val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0)
        val mplPointRequests = mutableListOf<ObjViewRequestDTO>()
        val service = CoverageSchemeBuilderService(
            ballisticsClient = FakeBallisticsClient(
                orbitalPointsByRequest = mapOf(
                    requestKey(11L, requestTime) to reverseOrbitalPoints(),
                    requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints()
                ),
                mplPointsBySatellite = mapOf(
                    22L to listOf(
                        PointViewParamDTO(
                            noradId = 22L,
                            time = requestTime.plusMinutes(10),
                            gamma = -6.0
                        )
                    )
                ),
                capturedMplPointRequests = mplPointRequests
            ),
            satelliteClient = FakeSatelliteClient(
                mapOf(
                    11L to 1.5,
                    22L to 1.5
                )
            ),
            geometrySupport = geometrySupport
        )

        val result = service.buildCoverageScheme(
            observations = listOf(
                observation(
                    satelliteId = 11L,
                    timeBegin = requestTime,
                    timeEnd = requestTime.plusMinutes(5),
                    branch = RevolutionSign.DESC,
                    gammaMin = -7.0,
                    gammaMax = -2.0,
                    contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))"
                ),
                observation(
                    satelliteId = 22L,
                    timeBegin = requestTime.plusMinutes(10),
                    timeEnd = requestTime.plusMinutes(15),
                    branch = RevolutionSign.DESC,
                    gammaMin = -8.0,
                    gammaMax = 0.0,
                    contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))"
                )
            )
        )

        assertEquals(2, result.size)
        assertEquals(-7.0, result.first().roll)
        assertEquals(-4.5, result.last().roll)
        assertEquals(1, mplPointRequests.size)
        assertPositionEquals(
            expectedRightmostStartPosition(geometrySupport.parseAndNormalizeTarget(result.first().contour)),
            mplPointRequests.single().obj.position
        )
        assertTrue(requireNotNull(mplPointRequests.single().obj.position).long in 0.0..360.0)
    }

    @Test
    fun `buildCoverageScheme skips subsequent contour when required sign is unavailable`() {
        val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0)
        val service = CoverageSchemeBuilderService(
            ballisticsClient = FakeBallisticsClient(
                orbitalPointsByRequest = mapOf(
                    requestKey(11L, requestTime) to reverseOrbitalPoints(),
                    requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints()
                )
            ),
            satelliteClient = FakeSatelliteClient(
                mapOf(
                    11L to 1.5,
                    22L to 1.5
                )
            ),
            geometrySupport = geometrySupport
        )

        val result = service.buildCoverageScheme(
            observations = listOf(
                observation(
                    satelliteId = 11L,
                    timeBegin = requestTime,
                    timeEnd = requestTime.plusMinutes(5),
                    branch = RevolutionSign.ASC,
                    gammaMin = 4.0,
                    gammaMax = 8.0,
                    contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
                ),
                observation(
                    satelliteId = 22L,
                    timeBegin = requestTime.plusMinutes(10),
                    timeEnd = requestTime.plusMinutes(15),
                    branch = RevolutionSign.ASC,
                    gammaMin = -8.0,
                    gammaMax = -2.0,
                    contour = "POLYGON ((196.5 -0.1, 197.3 -0.1, 197.3 4.0, 196.5 4.0, 196.5 -0.1))"
                )
            )
        )

        assertEquals(1, result.size)
        assertEquals(8.0, result.single().roll)
    }

    @Test
    fun `buildCoverageScheme splits long observation by satellite max duration and mmi`() {
        val start = LocalDateTime.of(2026, 4, 14, 10, 0, 0)
        val service = CoverageSchemeBuilderService(
            ballisticsClient = FakeBallisticsClient(
                orbitalPointsByRequest = mapOf(
                    requestKey(11L, start) to reverseOrbitalPoints(),
                    requestKey(11L, start.plusSeconds(70)) to sampleOrbitalPoints(),
                    requestKey(11L, start.plusSeconds(140)) to shiftedReverseOrbitalPoints()
                )
            ),
            satelliteClient = FakeSatelliteClient(
                captureAnglesBySatellite = mapOf(11L to 1.5),
                maxSurveyDurationSecondsBySatellite = mapOf(11L to 60L),
                mmiSecondsBySatellite = mapOf(11L to 10L)
            ),
            geometrySupport = geometrySupport
        )

        val result = service.buildCoverageScheme(
            observations = listOf(
                observation(
                    satelliteId = 11L,
                    timeBegin = start,
                    timeEnd = start.plusSeconds(180),
                    branch = RevolutionSign.ASC,
                    gammaMin = 1.0,
                    gammaMax = 5.0,
                    contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
                )
            )
        )

        assertEquals(3, result.size)
        assertEquals(start, result[0].tn)
        assertEquals(start.plusSeconds(60), result[0].tk)
        assertEquals(start.plusSeconds(70), result[1].tn)
        assertEquals(start.plusSeconds(130), result[1].tk)
        assertEquals(start.plusSeconds(140), result[2].tn)
        assertEquals(start.plusSeconds(180), result[2].tk)
    }

    @Test
    fun `buildCoverageScheme skips candidate when contour does not intersect required area`() {
        val start = LocalDateTime.of(2026, 4, 14, 10, 0, 0)
        val service = CoverageSchemeBuilderService(
            ballisticsClient = FakeBallisticsClient(
                orbitalPointsByRequest = mapOf(
                    requestKey(11L, start) to sampleOrbitalPoints()
                )
            ),
            satelliteClient = FakeSatelliteClient(
                captureAnglesBySatellite = mapOf(11L to 1.5)
            ),
            geometrySupport = geometrySupport
        )

        val result = service.buildCoverageScheme(
            observations = listOf(
                observation(
                    satelliteId = 11L,
                    timeBegin = start,
                    timeEnd = start.plusMinutes(5),
                    branch = RevolutionSign.ASC,
                    gammaMin = 1.0,
                    gammaMax = 5.0,
                    contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
                )
            )
        )

        assertTrue(result.isEmpty())
    }

    @Test
    fun `buildCoverageScheme skips candidate when overlap with existing coverage is above ninety percent`() {
        val start = LocalDateTime.of(2026, 4, 14, 10, 0, 0)
        val service = CoverageSchemeBuilderService(
            ballisticsClient = FakeBallisticsClient(
                orbitalPointsByRequest = mapOf(
                    requestKey(11L, start) to reverseOrbitalPoints(),
                    requestKey(22L, start.plusMinutes(10)) to reverseOrbitalPoints()
                )
            ),
            satelliteClient = FakeSatelliteClient(
                captureAnglesBySatellite = mapOf(
                    11L to 1.5,
                    22L to 1.5
                )
            ),
            geometrySupport = geometrySupport
        )

        val result = service.buildCoverageScheme(
            observations = listOf(
                observation(
                    satelliteId = 11L,
                    timeBegin = start,
                    timeEnd = start.plusMinutes(5),
                    branch = RevolutionSign.ASC,
                    gammaMin = 4.0,
                    gammaMax = 4.0,
                    contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))"
                ),
                observation(
                    satelliteId = 22L,
                    timeBegin = start.plusMinutes(10),
                    timeEnd = start.plusMinutes(15),
                    branch = RevolutionSign.ASC,
                    gammaMin = 4.0,
                    gammaMax = 4.0,
                    contour = "POLYGON ((190 -5, 200 -5, 200 10, 190 10, 190 -5))"
                )
            )
        )

        assertEquals(1, result.size)
        assertEquals(11L, result.single().satelliteId)
    }

    @Test
    fun `buildCoverageScheme falls back to minimum roll when mplPoint returns multiple records`() {
        val requestTime = LocalDateTime.of(2026, 4, 14, 10, 0)
        val service = CoverageSchemeBuilderService(
            ballisticsClient = FakeBallisticsClient(
                orbitalPointsByRequest = mapOf(
                    requestKey(11L, requestTime) to reverseOrbitalPoints(),
                    requestKey(22L, requestTime.plusMinutes(10)) to reverseOrbitalPoints()
                ),
                mplPointsBySatellite = mapOf(
                    22L to listOf(
                        PointViewParamDTO(noradId = 22L, time = requestTime.plusMinutes(10), gamma = 4.0),
                        PointViewParamDTO(noradId = 22L, time = requestTime.plusMinutes(11), gamma = 5.0)
                    )
                )
            ),
            satelliteClient = FakeSatelliteClient(
                mapOf(
                    11L to 1.5,
                    22L to 1.5
                )
            ),
            geometrySupport = geometrySupport
        )

        val result = service.buildCoverageScheme(
            observations = listOf(
                observation(
                    satelliteId = 11L,
                    timeBegin = requestTime,
                    timeEnd = requestTime.plusMinutes(5),
                    branch = RevolutionSign.ASC,
                    gammaMin = 4.0,
                    gammaMax = 4.0,
                    contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
                ),
                observation(
                    satelliteId = 22L,
                    timeBegin = requestTime.plusMinutes(10),
                    timeEnd = requestTime.plusMinutes(15),
                    branch = RevolutionSign.ASC,
                    gammaMin = 0.0,
                    gammaMax = 8.0,
                    contour = "POLYGON ((195.5 -0.1, 196.3 -0.1, 196.3 4.0, 195.5 4.0, 195.5 -0.1))"
                )
            )
        )

        assertEquals(2, result.size)
        assertEquals(8.0, result.last().roll)
    }

    private class FakeBallisticsClient(
        private val orbitalPointsByRequest: Map<Pair<Long, LocalDateTime>, List<OrbPointDTO>>,
        private val mplPointsBySatellite: Map<Long, List<PointViewParamDTO>> = emptyMap(),
        private val capturedMplPointRequests: MutableList<ObjViewRequestDTO> = mutableListOf()
    ) : CoverageBallisticsClient {
        override fun mplSquare(req: ObjViewRequestDTO): List<SquareViewParamDTO> = emptyList()

        override fun mplPoint(req: ObjViewRequestDTO): List<PointViewParamDTO> {
            capturedMplPointRequests += req
            return req.satellites.singleOrNull()?.let { mplPointsBySatellite[it].orEmpty() } ?: emptyList()
        }

        override fun flightLine(
            satelliteId: Long,
            timeStart: LocalDateTime,
            timeStop: LocalDateTime
        ): List<FlightLineDTO> = emptyList()

        override fun exactTime(satelliteId: Long, request: ExactTimePositionRequestDTO): List<OrbPointDTO> =
            orbitalPointsByRequest[satelliteId to request.time].orEmpty()
    }

    private class FakeSatelliteClient(
        private val captureAnglesBySatellite: Map<Long, Double>,
        private val maxSurveyDurationSecondsBySatellite: Map<Long, Long> = emptyMap(),
        private val mmiSecondsBySatellite: Map<Long, Long> = emptyMap()
    ) : CoverageSatelliteClient {
        override fun satellite(satelliteId: Long): SatelliteInfoDTO =
            SatelliteInfoDTO(
                noradId = satelliteId,
                captureAngle = captureAnglesBySatellite.getValue(satelliteId),
                maxSurveyDurationSeconds = maxSurveyDurationSecondsBySatellite[satelliteId] ?: 300L,
                mmiSeconds = mmiSecondsBySatellite[satelliteId] ?: 10L
            )
    }

    private fun observation(
        satelliteId: Long,
        timeBegin: LocalDateTime,
        timeEnd: LocalDateTime,
        branch: RevolutionSign,
        gammaMin: Double,
        gammaMax: Double,
        contour: String
    ) = CoverageObservationDTO(
        observation = SquareViewParamDTO(
            noradId = satelliteId,
            revolutionBegin = satelliteId,
            timeBegin = timeBegin,
            timeEnd = timeEnd,
            gammaMin = gammaMin,
            gammaMax = gammaMax,
            revSignBegin = branch,
            revSignEnd = branch
        ),
        intersectionContourWkts = listOf(contour)
    )

    private fun requestKey(satelliteId: Long, time: LocalDateTime) = satelliteId to time

    private fun expectedBoundaryPosition(
        branch: RevolutionSign,
        orbitalPoints: List<OrbPointDTO>,
        roll: Double,
        capture: Double
    ): PositionDTO {
        val firstPoint = orbitalPoints.first().toOrbitalPoint()
        val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
        val gamma = when (branch) {
            RevolutionSign.ASC -> roll - capture
            RevolutionSign.DESC -> roll + capture
        }
        val pointOnEarth = calculator.pointOnEarth(firstPoint, Orientation(0.0, gamma * PI / 180.0, 0.0))!!
        return PositionDTO(
            lat = pointOnEarth.lat * 180.0 / PI,
            long = pointOnEarth.long * 180.0 / PI,
            height = 0.0
        )
    }

    private fun expectedRightmostStartPosition(contour: Geometry): PositionDTO {
        val coordinates = contour.coordinates
        val rightmostStartPoint = listOf(
            coordinates.first(),
            coordinates[coordinates.size - 2]
        ).maxByOrNull { it.x }!!
        return PositionDTO(
            lat = rightmostStartPoint.y,
            long = rightmostStartPoint.x,
            height = 0.0
        )
    }

    private fun assertPositionEquals(expected: PositionDTO, actual: PositionDTO?) {
        fun normalizeToStandardLongitude(longitude: Double): Double {
            var normalized = longitude
            while (normalized > 180.0) normalized -= 360.0
            while (normalized <= -180.0) normalized += 360.0
            return normalized
        }

        requireNotNull(actual)
        assertEquals(expected.lat, actual.lat, 1e-9)
        assertEquals(normalizeToStandardLongitude(expected.long), normalizeToStandardLongitude(actual.long), 1e-9)
    }

    private fun sampleOrbitalPoints() = listOf(
        orbPoint(
            time = LocalDateTime.of(2026, 4, 14, 10, 0),
            revolution = 42L,
            x = -6603039.949,
            y = -1870023.148,
            z = 0.0,
            vx = -401.529,
            vy = 1413.431,
            vz = 7547.696
        ),
        orbPoint(
            time = LocalDateTime.of(2026, 4, 14, 10, 1),
            revolution = 42L,
            x = -6595000.0,
            y = -1860000.0,
            z = 452000.0,
            vx = -420.0,
            vy = 1390.0,
            vz = 7540.0
        )
    )

    private fun reverseOrbitalPoints() = listOf(
        orbPoint(
            time = LocalDateTime.of(2026, 4, 14, 10, 0),
            revolution = 42L,
            x = -6595000.0,
            y = -1860000.0,
            z = 452000.0,
            vx = 420.0,
            vy = -1390.0,
            vz = -7540.0
        ),
        orbPoint(
            time = LocalDateTime.of(2026, 4, 14, 10, 1),
            revolution = 42L,
            x = -6603039.949,
            y = -1870023.148,
            z = 0.0,
            vx = 401.529,
            vy = -1413.431,
            vz = -7547.696
        )
    )

    private fun shiftedReverseOrbitalPoints() = listOf(
        orbPoint(
            time = LocalDateTime.of(2026, 4, 14, 10, 0),
            revolution = 42L,
            x = -6495000.0,
            y = -1760000.0,
            z = 452000.0,
            vx = 420.0,
            vy = -1390.0,
            vz = -7540.0
        ),
        orbPoint(
            time = LocalDateTime.of(2026, 4, 14, 10, 1),
            revolution = 42L,
            x = -6503039.949,
            y = -1770023.148,
            z = 0.0,
            vx = 401.529,
            vy = -1413.431,
            vz = -7547.696
        )
    )

    private fun orbPoint(
        time: LocalDateTime,
        revolution: Long,
        x: Double,
        y: Double,
        z: Double,
        vx: Double,
        vy: Double,
        vz: Double
    ) = OrbPointDTO(
        time = time,
        revolution = revolution,
        vx = vx,
        vy = vy,
        vz = vz,
        x = x,
        y = y,
        z = z
    )

    private fun OrbPointDTO.toOrbitalPoint() = OrbitalPoint(
        t = fromDateTime(time),
        vit = revolution.toInt(),
        r = Vector3D(x, y, z),
        v = Vector3D(vx, vy, vz)
    )
}
