package space.nstart.pcp.routepassportconsumer.config

import org.apache.kafka.clients.consumer.Consumer
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.clients.producer.ProducerRecord
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.test.context.runner.ApplicationContextRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
import org.springframework.kafka.listener.DefaultErrorHandler
import org.springframework.kafka.listener.MessageListenerContainer
import org.springframework.kafka.support.KafkaHeaders
import org.springframework.kafka.support.SendResult
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import java.nio.charset.StandardCharsets
import java.util.concurrent.CompletableFuture

class KafkaConsumerConfigTest {

    private val contextRunner = ApplicationContextRunner()
        .withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration::class.java))
        .withUserConfiguration(TestPropertiesConfiguration::class.java, KafkaConsumerConfig::class.java)
        .withPropertyValues(
            "spring.kafka.bootstrap-servers=localhost:9092",
            "spring.kafka.consumer.group-id=pcp-route-processing-service",
            "spring.kafka.consumer.auto-offset-reset=earliest",
            "app.kafka.topics.input=$INPUT_TOPIC",
            "app.kafka.topics.input-dlq=$INPUT_DLQ_TOPIC",
            "app.kafka.topics.processed-route=pcp.route.in.v1",
            "app.kafka.topics.route-georeference=pcp.route.georeference.v1",
            "app.route-passport.consumer.retry.max-attempts=3",
            "app.route-passport.consumer.retry.backoff-ms=0",
            "app.route-passport.consumer.retry.max-backoff-ms=0",
        )

    @Test
    fun `should create error handler and recoverer beans`() {
        contextRunner.run { context ->
            assertThat(context).hasSingleBean(DefaultErrorHandler::class.java)
            assertThat(context).hasSingleBean(DeadLetterPublishingRecoverer::class.java)

            val retryProperties = context.getBean(RoutePassportConsumerRetryProperties::class.java)
            assertThat(retryProperties.maxAttempts).isEqualTo(3)
            assertThat(retryProperties.backoffMs).isZero()
            assertThat(retryProperties.maxBackoffMs).isZero()
        }
    }

    @Test
    fun `dead letter recoverer should publish original string record to configured input dlq topic`() {
        contextRunner.run { context ->
            val kafkaTemplate = context.kafkaTemplate()
            val recoverer = context.getBean(DeadLetterPublishingRecoverer::class.java)
            val record = consumerRecord()

            recoverer.accept(record, RoutePassportContractException("payload contract is invalid"))

            val publishedRecord = capturePublishedRecord(kafkaTemplate)
            assertThat(publishedRecord.topic()).isEqualTo(INPUT_DLQ_TOPIC)
            assertThat(publishedRecord.partition()).isEqualTo(record.partition())
            assertThat(publishedRecord.key()).isEqualTo(record.key())
            assertThat(publishedRecord.value()).isEqualTo(record.value())
            assertThat(publishedRecord.headers().lastStringHeader("pcp-service-name"))
                .isEqualTo("pcp-route-processing-service")
            assertThat(publishedRecord.headers().lastStringHeader("pcp-exception-class"))
                .isEqualTo(RoutePassportContractException::class.java.name)
            assertThat(publishedRecord.headers().lastStringHeader("pcp-exception-message"))
                .isEqualTo("payload contract is invalid")
            assertThat(publishedRecord.headers().lastStringHeader("pcp-original-topic")).isEqualTo(INPUT_TOPIC)
            assertThat(publishedRecord.headers().lastStringHeader("pcp-original-partition")).isEqualTo("3")
            assertThat(publishedRecord.headers().lastStringHeader("pcp-original-offset")).isEqualTo("42")
            assertThat(publishedRecord.headers().lastStringHeader(KafkaHeaders.DLT_ORIGINAL_TOPIC))
                .isEqualTo(INPUT_TOPIC)
            assertThat(publishedRecord.headers().lastStringHeader(KafkaHeaders.DLT_EXCEPTION_FQCN))
                .isEqualTo(RoutePassportContractException::class.java.name)
        }
    }

    @Test
    fun `route passport contract exception should be recovered without retry`() {
        contextRunner.run { context ->
            val kafkaTemplate = context.kafkaTemplate()
            val errorHandler = context.getBean(DefaultErrorHandler::class.java)

            val handled = errorHandler.handleOne(
                RoutePassportContractException("payload contract is invalid"),
                consumerRecord(),
                mockConsumer(),
                mock(MessageListenerContainer::class.java),
            )

            assertThat(handled).isTrue()
            verify(kafkaTemplate, times(1)).send(anyProducerRecord())
        }
    }

    @Test
    fun `runtime exception should be retried with bounded policy before dlq recovery`() {
        contextRunner.run { context ->
            val kafkaTemplate = context.kafkaTemplate()
            val errorHandler = context.getBean(DefaultErrorHandler::class.java)
            val record = consumerRecord()
            val consumer = mockConsumer()
            val container = mock(MessageListenerContainer::class.java)
            val exception = IllegalStateException("database is temporarily unavailable")

            assertThat(errorHandler.handleOne(exception, record, consumer, container)).isFalse()
            assertThat(errorHandler.handleOne(exception, record, consumer, container)).isFalse()
            verify(kafkaTemplate, never()).send(anyProducerRecord())

            assertThat(errorHandler.handleOne(exception, record, consumer, container)).isTrue()
            verify(kafkaTemplate, times(1)).send(anyProducerRecord())
        }
    }

    private fun consumerRecord(): ConsumerRecord<String, String> =
        ConsumerRecord(
            INPUT_TOPIC,
            3,
            42L,
            "route-key",
            """{"id":"route-1"}""",
        )

    private fun capturePublishedRecord(
        kafkaTemplate: KafkaTemplate<String, String>,
    ): ProducerRecord<String, String> {
        val captor = producerRecordCaptor()
        verify(kafkaTemplate, times(1)).send(captor.capture())
        return captor.value
    }

    @Suppress("UNCHECKED_CAST")
    private fun producerRecordCaptor(): ArgumentCaptor<ProducerRecord<String, String>> =
        ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor<ProducerRecord<String, String>>

    @Suppress("UNCHECKED_CAST")
    private fun mockConsumer(): Consumer<String, String> =
        mock(Consumer::class.java) as Consumer<String, String>

    private fun org.apache.kafka.common.header.Headers.lastStringHeader(name: String): String? =
        lastHeader(name)?.value()?.toString(StandardCharsets.UTF_8)

    @Configuration(proxyBeanMethods = false)
    @EnableConfigurationProperties(
        KafkaTopicsProperties::class,
        RoutePassportConsumerRetryProperties::class,
    )
    private class TestPropertiesConfiguration {

        @Bean
        @Suppress("UNCHECKED_CAST")
        fun kafkaTemplate(): KafkaTemplate<String, String> {
            val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
            val sendResult = mock(SendResult::class.java) as SendResult<String, String>

            `when`(kafkaTemplate.send(anyProducerRecord()))
                .thenReturn(CompletableFuture.completedFuture(sendResult))

            return kafkaTemplate
        }
    }

    private companion object {
        const val INPUT_TOPIC = "pcp.request.survey-georeference.v1"
        const val INPUT_DLQ_TOPIC = "pcp.request.survey-georeference-dlq.v1"

        @Suppress("UNCHECKED_CAST")
        fun anyProducerRecord(): ProducerRecord<String, String> =
            (any(ProducerRecord::class.java) as ProducerRecord<String, String>?)
                ?: ProducerRecord("stub-topic", "stub-key", "stub-payload")

        @Suppress("UNCHECKED_CAST")
        fun org.springframework.context.ApplicationContext.kafkaTemplate(): KafkaTemplate<String, String> =
            getBean(KafkaTemplate::class.java) as KafkaTemplate<String, String>
    }
}
