package space.nstart.pcp.routepassportconsumer.service

import io.micrometer.core.instrument.simple.SimpleMeterRegistry
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
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.message.PcpKafkaEvent
import space.nstart.pcp.routepassportconsumer.config.RoutePassportOutboxProperties
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository
import com.fasterxml.jackson.databind.ObjectMapper
import java.time.Duration
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

class RoutePassportOutboxServiceTest {

    private val objectMapper = ObjectMapper().findAndRegisterModules()

    @Test
    fun `claimDueBatch returns empty when outbox is disabled`() {
        val repository = mock(RoutePassportOutboxRepository::class.java)
        val service = service(
            repository = repository,
            properties = properties(enabled = false),
        )

        val claimedRows = service.claimDueBatch()

        assertTrue(claimedRows.isEmpty())
        verify(repository, never()).findDueForUpdateSkipLocked(anyInt())
    }

    @Test
    fun `claimDueBatch locks returned rows`() {
        val repository = mock(RoutePassportOutboxRepository::class.java)
        val service = service(repository)
        val first = outbox(id = UUID.fromString("3ed5e75c-a5c0-4d63-88be-a701b5222b62"))
        val second = outbox(id = UUID.fromString("5d75d482-e402-46d4-8e2b-4c19bff6f795"))
        val originalUpdatedAt = first.updatedAt

        doReturn(listOf(first, second))
            .`when`(repository)
            .findDueForUpdateSkipLocked(50)
        stubSaveAll(repository)

        val claimedRows = service.claimDueBatch()

        assertEquals(listOf(first, second), claimedRows)
        claimedRows.forEach { outbox ->
            assertNotNull(outbox.lockedUntil)
            assertEquals(60_000, Duration.between(outbox.updatedAt, outbox.lockedUntil).toMillis())
            assertTrue(outbox.updatedAt != originalUpdatedAt)
        }
        verify(repository).saveAll(listOf(first, second))
    }

    @Test
    fun `markPublished moves row to published state`() {
        val repository = mock(RoutePassportOutboxRepository::class.java)
        val service = service(repository)
        val outbox = outbox(
            lockedUntil = LocalDateTime.of(2026, 4, 1, 10, 20),
            lastError = "previous failure",
        )

        stubFindById(repository, outbox)
        stubSave(repository)

        service.markPublished(outbox.id)

        assertEquals(RoutePassportOutboxStatus.PUBLISHED, outbox.status)
        assertNotNull(outbox.publishedAt)
        assertNull(outbox.lockedUntil)
        assertNull(outbox.lastError)
        verify(repository).save(outbox)
    }

    @Test
    fun `markRetryOrFailed below max attempts schedules pending retry`() {
        val repository = mock(RoutePassportOutboxRepository::class.java)
        val service = service(repository)
        val outbox = outbox(
            attempts = 0,
            maxAttempts = 3,
            lockedUntil = LocalDateTime.of(2026, 4, 1, 10, 20),
        )
        val exception = IllegalStateException("broker unavailable")

        stubFindById(repository, outbox)
        stubSave(repository)

        service.markRetryOrFailed(outbox.id, exception)

        assertEquals(1, outbox.attempts)
        assertEquals(RoutePassportOutboxStatus.PENDING, outbox.status)
        assertNull(outbox.lockedUntil)
        assertTrue(outbox.lastError?.contains(IllegalStateException::class.java.name) == true)
        assertTrue(outbox.lastError?.contains("broker unavailable") == true)
        assertEquals(1_000, Duration.between(outbox.updatedAt, outbox.nextAttemptAt).toMillis())
        verify(repository).save(outbox)
    }

    @Test
    fun `markRetryOrFailed at max attempts moves row to failed state`() {
        val repository = mock(RoutePassportOutboxRepository::class.java)
        val service = service(repository)
        val outbox = outbox(
            attempts = 2,
            maxAttempts = 3,
            lockedUntil = LocalDateTime.of(2026, 4, 1, 10, 20),
        )

        stubFindById(repository, outbox)
        stubSave(repository)

        service.markRetryOrFailed(outbox.id, IllegalStateException("broker unavailable"))

        assertEquals(3, outbox.attempts)
        assertEquals(RoutePassportOutboxStatus.FAILED, outbox.status)
        assertNull(outbox.lockedUntil)
        assertTrue(outbox.lastError?.contains("broker unavailable") == true)
        verify(repository).save(outbox)
    }

    @Test
    fun `markRetryOrFailed caps exponential backoff by maxBackoffMs`() {
        val repository = mock(RoutePassportOutboxRepository::class.java)
        val service = service(
            repository = repository,
            properties = properties(
                initialBackoffMs = 1_000,
                maxBackoffMs = 1_500,
            ),
        )
        val outbox = outbox(
            attempts = 2,
            maxAttempts = 10,
            lockedUntil = LocalDateTime.of(2026, 4, 1, 10, 20),
        )

        stubFindById(repository, outbox)
        stubSave(repository)

        service.markRetryOrFailed(outbox.id, IllegalStateException("broker unavailable"))

        assertEquals(3, outbox.attempts)
        assertEquals(RoutePassportOutboxStatus.PENDING, outbox.status)
        assertEquals(1_500, Duration.between(outbox.updatedAt, outbox.nextAttemptAt).toMillis())
    }

    private fun service(
        repository: RoutePassportOutboxRepository,
        properties: RoutePassportOutboxProperties = properties(),
    ): RoutePassportOutboxService =
        RoutePassportOutboxService(
            routePassportOutboxRepository = repository,
            outboxProperties = properties,
            routePassportMetrics = RoutePassportMetrics(SimpleMeterRegistry()),
        )

    private fun properties(
        enabled: Boolean = true,
        initialBackoffMs: Long = 1_000,
        maxBackoffMs: Long = 60_000,
    ): RoutePassportOutboxProperties =
        RoutePassportOutboxProperties(
            enabled = enabled,
            batchSize = 50,
            fixedDelayMs = 1_000,
            lockTimeoutMs = 60_000,
            initialBackoffMs = initialBackoffMs,
            maxBackoffMs = maxBackoffMs,
        )

    private fun stubFindById(
        repository: RoutePassportOutboxRepository,
        outbox: RoutePassportOutboxEntity,
    ) {
        doReturn(Optional.of(outbox))
            .`when`(repository)
            .findById(outbox.id)
    }

    private fun stubSave(repository: RoutePassportOutboxRepository) {
        doAnswer { invocation ->
            @Suppress("UNCHECKED_CAST")
            invocation.arguments[0] as RoutePassportOutboxEntity
        }
            .`when`(repository)
            .save(anyOutboxEntity())
    }

    private fun stubSaveAll(repository: RoutePassportOutboxRepository) {
        doAnswer { invocation ->
            @Suppress("UNCHECKED_CAST")
            invocation.arguments[0] as Iterable<RoutePassportOutboxEntity>
        }.`when`(repository).saveAll(anyOutboxIterable())
    }

    private fun outbox(
        id: UUID = UUID.fromString("3ed5e75c-a5c0-4d63-88be-a701b5222b62"),
        attempts: Int = 0,
        maxAttempts: Int = 10,
        lockedUntil: LocalDateTime? = null,
        lastError: String? = null,
    ): RoutePassportOutboxEntity {
        val routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03")
        val now = LocalDateTime.of(2026, 4, 1, 10, 15, 0)

        return RoutePassportOutboxEntity(
            id = id,
            routeId = routeId,
            eventType = PcpKafkaEvent.ModeStatusChangedEvent.name,
            topic = "pcp.route.in.v1",
            messageKey = routeId.toString(),
            payload = objectMapper.readTree("""{"type":"ModeStatusChangedEvent","data":{"routeNameFull":"ROUTE-001"}}"""),
            headers = null,
            status = RoutePassportOutboxStatus.PENDING,
            attempts = attempts,
            maxAttempts = maxAttempts,
            nextAttemptAt = now,
            lockedUntil = lockedUntil,
            lastError = lastError,
            createdAt = now,
            updatedAt = now,
        )
    }

    private fun anyOutboxEntity(): RoutePassportOutboxEntity =
        any(RoutePassportOutboxEntity::class.java) ?: outbox()

    @Suppress("UNCHECKED_CAST")
    private fun anyOutboxIterable(): Iterable<RoutePassportOutboxEntity> =
        (any(Iterable::class.java) as Iterable<RoutePassportOutboxEntity>?) ?: emptyList()
}
