package space.nstart.pcp.routepassportconsumer.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportMessageMetadata
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportRepository
import com.fasterxml.jackson.databind.ObjectMapper as JpaJsonObjectMapper
import tools.jackson.databind.ObjectMapper
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.kotlin.KotlinModule
import java.math.BigDecimal
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID

class RoutePassportIngestionServiceTest {
    private val objectMapper: ObjectMapper = JsonMapper.builder()
        .addModule(KotlinModule.Builder().build())
        .build()
    private val jpaJsonObjectMapper = JpaJsonObjectMapper().findAndRegisterModules()

    private val kafkaTopicsProperties = KafkaTopicsProperties(
        input = "pcp.request.survey-georeference.v1",
        processedRoute = "pcp.route.in.v1",
        routeGeoreference = "pcp.route.georeference.v1",
    )

    @Test
    fun `first ingest creates route passport and two pending outbox events`() {
        val fixture = fixture()
        val routePassportRepository = mock(RoutePassportRepository::class.java)
        val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java)
        val savedRoutes = mutableListOf<RoutePassportEntity>()
        val savedOutbox = mutableListOf<RoutePassportOutboxEntity>()
        val service = service(routePassportRepository, routePassportOutboxRepository)

        doReturn(Optional.empty<RoutePassportEntity>())
            .`when`(routePassportRepository)
            .findById(fixture.routeId)
        captureRouteSaves(routePassportRepository, savedRoutes)
        captureOutboxSaves(routePassportOutboxRepository, savedOutbox)

        service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord)

        assertEquals(1, savedRoutes.size)
        assertEquals(fixture.routeId, savedRoutes.single().routeId)
        assertEquals("ROUTE-001", savedRoutes.single().routeNameFull)
        assertTrue(savedRoutes.single().polarisation.isArray)
        assertEquals("""["VH","VV"]""", savedRoutes.single().polarisation.toString())
        assertTrue(savedRoutes.single().rawPayload?.isObject == true)
        assertEquals("PROCESSED", savedRoutes.single().routeStatus)

        assertEquals(2, savedOutbox.size)
        assertEquals(
            listOf(PcpKafkaEvent.ModeStatusChangedEvent.name, PcpKafkaEvent.RouteGeoRefEvent.name),
            savedOutbox.map { it.eventType },
        )
        assertEquals(
            listOf("pcp.route.in.v1", "pcp.route.georeference.v1"),
            savedOutbox.map { it.topic },
        )
        savedOutbox.forEach { outbox ->
            assertEquals(fixture.routeId, outbox.routeId)
            assertEquals(fixture.routeId.toString(), outbox.messageKey)
            assertEquals(RoutePassportOutboxStatus.PENDING, outbox.status)
            assertEquals(0, outbox.attempts)
            assertEquals(10, outbox.maxAttempts)
            assertNotNull(outbox.nextAttemptAt)
        }
    }

    @Test
    fun `duplicate ingest updates route passport without creating duplicate outbox rows`() {
        val fixture = fixture()
        val existingEntity = existingRouteEntity(fixture.routeId).apply {
            createdAt = LocalDateTime.of(2026, 1, 1, 1, 0)
            updatedAt = LocalDateTime.of(2026, 1, 1, 1, 0)
            routeNameFull = "OLD-ROUTE"
        }
        val routePassportRepository = mock(RoutePassportRepository::class.java)
        val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java)
        val savedRoutes = mutableListOf<RoutePassportEntity>()
        val service = service(routePassportRepository, routePassportOutboxRepository)

        doReturn(Optional.of(existingEntity))
            .`when`(routePassportRepository)
            .findById(fixture.routeId)
        doReturn(true)
            .`when`(routePassportOutboxRepository)
            .existsByRouteIdAndEventType(fixture.routeId, PcpKafkaEvent.ModeStatusChangedEvent.name)
        doReturn(true)
            .`when`(routePassportOutboxRepository)
            .existsByRouteIdAndEventType(fixture.routeId, PcpKafkaEvent.RouteGeoRefEvent.name)
        captureRouteSaves(routePassportRepository, savedRoutes)

        service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord)

        assertEquals(1, savedRoutes.size)
        assertEquals("ROUTE-001", existingEntity.routeNameFull)
        assertEquals(LocalDateTime.of(2026, 1, 1, 1, 0), existingEntity.createdAt)
        assertTrue(existingEntity.updatedAt.isAfter(existingEntity.createdAt))
        verify(routePassportOutboxRepository, never()).save(anyRoutePassportOutboxEntity())
    }

    @Test
    fun `ingest stores source record metadata in route passport`() {
        val fixture = fixture()
        val routePassportRepository = mock(RoutePassportRepository::class.java)
        val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java)
        val savedRoutes = mutableListOf<RoutePassportEntity>()
        val service = service(routePassportRepository, routePassportOutboxRepository)

        doReturn(Optional.empty<RoutePassportEntity>())
            .`when`(routePassportRepository)
            .findById(fixture.routeId)
        captureRouteSaves(routePassportRepository, savedRoutes)

        service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord)

        val routeEntity = savedRoutes.single()
        assertEquals("pcp.request.survey-georeference.v1", routeEntity.sourceTopic)
        assertEquals(3, routeEntity.sourcePartition)
        assertEquals(42L, routeEntity.sourceOffset)
        assertEquals("source-key-1", routeEntity.sourceKey)
        assertEquals(jpaJsonObjectMapper.readTree(fixture.rawPayload), routeEntity.rawPayload)
        assertEquals("trace-1", routeEntity.traceId)
        assertEquals("corr-1", routeEntity.correlationId)
    }

    @Test
    fun `outbox payload keeps downstream kafka message contract`() {
        val fixture = fixture()
        val routePassportRepository = mock(RoutePassportRepository::class.java)
        val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java)
        val savedOutbox = mutableListOf<RoutePassportOutboxEntity>()
        val service = service(routePassportRepository, routePassportOutboxRepository)

        doReturn(Optional.empty<RoutePassportEntity>())
            .`when`(routePassportRepository)
            .findById(fixture.routeId)
        captureRouteSaves(routePassportRepository, mutableListOf())
        captureOutboxSaves(routePassportOutboxRepository, savedOutbox)

        service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord)

        val modeStatusPayload = savedOutbox.first().payload
        assertTrue(modeStatusPayload.isObject)
        assertEquals(PcpKafkaEvent.ModeStatusChangedEvent.name, modeStatusPayload.path("type").asText())
        assertEquals("trace-1", modeStatusPayload.path("traceId").asText())
        assertEquals("corr-1", modeStatusPayload.path("correlationId").asText())
        assertEquals("route-processing", modeStatusPayload.path("source").asText())
        assertEquals("pcp", modeStatusPayload.path("group").asText())
        assertTrue(modeStatusPayload.path("data").isObject)
        assertEquals(fixture.routeId.toString(), modeStatusPayload.path("data").path("routeId").asText())
        assertEquals("ROUTE-001", modeStatusPayload.path("data").path("routeNameFull").asText())
    }

    private fun service(
        routePassportRepository: RoutePassportRepository,
        routePassportOutboxRepository: RoutePassportOutboxRepository,
    ): RoutePassportIngestionService =
        RoutePassportIngestionService(
            routePassportRepository = routePassportRepository,
            routePassportOutboxRepository = routePassportOutboxRepository,
            objectMapper = objectMapper,
            kafkaTopicsProperties = kafkaTopicsProperties,
        )

    private fun captureRouteSaves(
        routePassportRepository: RoutePassportRepository,
        savedRoutes: MutableList<RoutePassportEntity>,
    ) {
        doAnswer { invocation ->
            val entity = invocation.arguments[0] as RoutePassportEntity
            savedRoutes += entity
            entity
        }.`when`(routePassportRepository).save(anyRoutePassportEntity())
    }

    private fun captureOutboxSaves(
        routePassportOutboxRepository: RoutePassportOutboxRepository,
        savedOutbox: MutableList<RoutePassportOutboxEntity>,
    ) {
        doAnswer { invocation ->
            val entity = invocation.arguments[0] as RoutePassportOutboxEntity
            savedOutbox += entity
            entity
        }.`when`(routePassportOutboxRepository).save(anyRoutePassportOutboxEntity())
    }

    private fun anyRoutePassportEntity(): RoutePassportEntity =
        any(RoutePassportEntity::class.java) ?: existingRouteEntity(UUID.randomUUID())

    private fun anyRoutePassportOutboxEntity(): RoutePassportOutboxEntity =
        any(RoutePassportOutboxEntity::class.java) ?: RoutePassportOutboxEntity(
            id = UUID.randomUUID(),
            routeId = UUID.randomUUID(),
            eventType = PcpKafkaEvent.ModeStatusChangedEvent.name,
            topic = "topic",
            messageKey = "key",
            payload = jpaJsonObjectMapper.readTree("{}"),
            status = RoutePassportOutboxStatus.PENDING,
            attempts = 0,
            maxAttempts = 10,
            nextAttemptAt = LocalDateTime.now(),
            createdAt = LocalDateTime.now(),
            updatedAt = LocalDateTime.now(),
        )

    private fun fixture(): IngestionFixture {
        val routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03")
        val routePassportDto = RoutePassportDto(
            routeId = routeId,
            kaShort = "S1A",
            kaFull = "sentinel-1a",
            routeNameFull = "ROUTE-001",
            routeNameShort = "S1A-GRDH",
            orbitNumber = 59796L,
            orbitState = "Descending",
            intervalBegin = LocalDateTime.of(2026, 4, 1, 10, 15, 0),
            intervalEnd = LocalDateTime.of(2026, 4, 1, 10, 16, 30),
            rollAngle = AngleRangeDto(
                min = BigDecimal("-15.5"),
                max = BigDecimal("-12.0"),
            ),
            visirAngle = AngleRangeDto(
                min = BigDecimal("21.0"),
                max = BigDecimal("24.5"),
            ),
            resolutionRange = BigDecimal("10.0"),
            resolutionAzimuth = BigDecimal("10.0"),
            polarisation = listOf("VH", "VV"),
            processingLevel = "GRD",
            geometry = "POLYGON((0 0, 1 0, 1 1, 0 0))",
        )

        return IngestionFixture(
            routeId = routeId,
            parsedMessage = ParsedRoutePassportMessage(
                metadata = RoutePassportMessageMetadata(
                    traceId = "trace-1",
                    correlationId = "corr-1",
                ),
                routePassportDto = routePassportDto,
            ),
            rawPayload = """{"data":{"id":"ROUTE-001"}}""",
            sourceRecord = RoutePassportSourceRecordMetadata(
                topic = "pcp.request.survey-georeference.v1",
                partition = 3,
                offset = 42L,
                key = "source-key-1",
            ),
        )
    }

    private fun existingRouteEntity(routeId: UUID): RoutePassportEntity =
        RoutePassportEntity(
            routeId = routeId,
            routeNameFull = "ROUTE-OLD",
            routeNameShort = "OLD",
            kaShort = "S1A",
            kaFull = "sentinel-1a",
            orbitNumber = 1L,
            orbitState = "Descending",
            intervalBegin = LocalDateTime.of(2026, 1, 1, 0, 0),
            intervalEnd = LocalDateTime.of(2026, 1, 1, 0, 1),
            rollAngleMin = BigDecimal.ZERO,
            rollAngleMax = BigDecimal.ONE,
            visirAngleMin = BigDecimal.ZERO,
            visirAngleMax = BigDecimal.ONE,
            resolutionRange = BigDecimal.TEN,
            resolutionAzimuth = BigDecimal.TEN,
            polarisation = jpaJsonObjectMapper.readTree("""["VH"]"""),
            processingLevel = "GRD",
            geometryWkt = "POLYGON((0 0, 1 0, 1 1, 0 0))",
            routeStatus = "PROCESSED",
            createdAt = LocalDateTime.of(2026, 1, 1, 0, 0),
            updatedAt = LocalDateTime.of(2026, 1, 1, 0, 0),
        )

    private data class IngestionFixture(
        val routeId: UUID,
        val parsedMessage: ParsedRoutePassportMessage,
        val rawPayload: String,
        val sourceRecord: RoutePassportSourceRecordMetadata,
    )
}
