package space.nstart.pcp.routepassportconsumer.config

import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.JsonMappingException
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.header.Headers
import org.apache.kafka.common.header.internals.RecordHeader
import org.apache.kafka.common.header.internals.RecordHeaders
import org.apache.kafka.common.serialization.StringDeserializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
import org.springframework.kafka.core.ConsumerFactory
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.listener.ContainerProperties
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
import org.springframework.kafka.listener.DefaultErrorHandler
import org.springframework.util.backoff.ExponentialBackOff
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import tools.jackson.core.JacksonException
import java.nio.charset.StandardCharsets

@Configuration
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
class KafkaConsumerConfig(
    @param:Value("\${spring.kafka.bootstrap-servers}")
    private val bootstrapServers: String,
    @param:Value("\${spring.kafka.consumer.group-id}")
    private val groupId: String,
    @param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}")
    private val autoOffsetReset: String,
) {

    @Bean
    fun consumerFactory(): ConsumerFactory<String, String> =
        DefaultKafkaConsumerFactory(
            mutableMapOf<String, Any>(
                ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
                ConsumerConfig.GROUP_ID_CONFIG to groupId,
                ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset,
                ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false,
                ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
                ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
            ),
        )

    @Bean
    fun kafkaListenerContainerFactory(
        kafkaErrorHandler: DefaultErrorHandler,
    ): ConcurrentKafkaListenerContainerFactory<String, String> =
        ConcurrentKafkaListenerContainerFactory<String, String>().apply {
            setConsumerFactory(consumerFactory())
            setCommonErrorHandler(kafkaErrorHandler)
            containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE
        }

    @Bean
    fun deadLetterPublishingRecoverer(
        kafkaTemplate: KafkaTemplate<String, String>,
        kafkaTopicsProperties: KafkaTopicsProperties,
    ): DeadLetterPublishingRecoverer =
        DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
            TopicPartition(kafkaTopicsProperties.inputDlq, record.partition())
        }.apply {
            addHeadersFunction { record, exception -> routePassportDlqHeaders(record, exception) }
        }

    @Bean
    fun kafkaErrorHandler(
        deadLetterPublishingRecoverer: DeadLetterPublishingRecoverer,
        retryProperties: RoutePassportConsumerRetryProperties,
    ): DefaultErrorHandler =
        DefaultErrorHandler(
            deadLetterPublishingRecoverer,
            routePassportConsumerBackOff(retryProperties),
        ).apply {
            addNotRetryableExceptions(
                RoutePassportContractException::class.java,
                JacksonException::class.java,
                JsonProcessingException::class.java,
                JsonMappingException::class.java,
                JsonParseException::class.java,
            )
            setCommitRecovered(true)
        }

    private fun routePassportConsumerBackOff(
        retryProperties: RoutePassportConsumerRetryProperties,
    ): ExponentialBackOff =
        ExponentialBackOff().apply {
            initialInterval = retryProperties.backoffMs
            multiplier = RETRY_BACKOFF_MULTIPLIER
            maxInterval = retryProperties.maxBackoffMs
            maxAttempts = (retryProperties.maxAttempts - 1).coerceAtLeast(0)
        }

    private fun routePassportDlqHeaders(
        record: ConsumerRecord<*, *>,
        exception: Exception,
    ): Headers =
        RecordHeaders().apply {
            addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME)
            addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name)
            addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty())
            addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic())
            addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString())
            addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString())
        }

    private fun Headers.addStringHeader(name: String, value: String) {
        add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8)))
    }

    private companion object {
        const val SERVICE_NAME = "pcp-route-processing-service"
        const val RETRY_BACKOFF_MULTIPLIER = 2.0

        const val HEADER_SERVICE_NAME = "pcp-service-name"
        const val HEADER_EXCEPTION_CLASS = "pcp-exception-class"
        const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message"
        const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic"
        const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition"
        const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset"
    }
}
