package space.nstart.pcp_tgu_service.service

import io.camunda.client.CamundaClient
import io.camunda.client.api.CamundaFuture
import io.camunda.client.api.command.CreateProcessInstanceCommandStep1
import io.camunda.client.api.response.ProcessInstanceEvent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.SimpleTransactionStatus
import org.springframework.transaction.support.TransactionTemplate
import space.nstart.pcp_tgu_service.config.PlanningProperties
import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobEntity
import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobStatus
import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID

class AcceptedPlanSlotsUpdateFlowTest {

    @Test
    fun `enqueue inserts accepted plan slots update job only once`() {
        val repository = mock(AcceptedPlanSlotsUpdateJobRepository::class.java)
        val planId = UUID.randomUUID()
        val service = AcceptedPlanSlotsUpdateJobService(repository)

        service.enqueue(planId)
        service.enqueue(planId)

        verify(repository, times(2)).insertPendingIfAbsent(
            eqNonNull(planId),
            anyNonNull()
        )
    }

    @Test
    fun `process starter starts updateAcceptedPlanSlots with satellitePlanId variable`() {
        val planId = UUID.randomUUID()
        val capturedVariables = mutableListOf<Any>()
        val camundaClient = mock(CamundaClient::class.java)
        val createCommand = mock(CreateProcessInstanceCommandStep1::class.java)
        val step2 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep2::class.java)
        val step3 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep3::class.java)
        val future = mock(CamundaFuture::class.java) as CamundaFuture<ProcessInstanceEvent>
        val processInstanceEvent = mock(ProcessInstanceEvent::class.java)

        `when`(camundaClient.newCreateInstanceCommand()).thenReturn(createCommand)
        `when`(createCommand.bpmnProcessId(AcceptedPlanSlotsUpdateProcessStarter.UPDATE_ACCEPTED_PLAN_SLOTS_PROCESS_ID)).thenReturn(step2)
        `when`(step2.latestVersion()).thenReturn(step3)
        `when`(step3.variables(anyNonNull<Map<String, String>>())).thenAnswer { invocation ->
            capturedVariables += invocation.arguments[0]
            step3
        }
        `when`(step3.send()).thenReturn(future)
        `when`(future.join()).thenReturn(processInstanceEvent)
        `when`(processInstanceEvent.getProcessInstanceKey()).thenReturn(123L)
        val starter = AcceptedPlanSlotsUpdateProcessStarter(camundaClient)

        val processInstanceKey = starter.start(planId)

        assertEquals(123L, processInstanceKey)
        verify(createCommand).bpmnProcessId("updateAcceptedPlanSlots")
        assertEquals(mapOf("satellitePlanId" to planId.toString()), capturedVariables.single())
    }

    @Test
    fun `worker claims one job and does not let second worker claim same locked job`() {
        val planId = UUID.randomUUID()
        val jobStore = linkedMapOf(
            planId to AcceptedPlanSlotsUpdateJobEntity(
                planId = planId,
                status = AcceptedPlanSlotsUpdateJobStatus.PENDING
            )
        )
        val repository = jobRepository(jobStore)
        val starter = mock(AcceptedPlanSlotsUpdateProcessStarter::class.java)
        val firstWorker = worker(repository, starter)
        val secondWorker = worker(repository, starter)

        val firstClaim = firstWorker.claimJobs(limit = 10)
        val secondClaim = secondWorker.claimJobs(limit = 10)

        assertEquals(listOf(planId), firstClaim.map { job -> job.planId })
        assertTrue(secondClaim.isEmpty())
        assertEquals(AcceptedPlanSlotsUpdateJobStatus.RUNNING, jobStore.getValue(planId).status)
    }

    @Test
    fun `worker starts BPMN and marks job done`() {
        val planId = UUID.randomUUID()
        val jobStore = linkedMapOf(
            planId to AcceptedPlanSlotsUpdateJobEntity(
                planId = planId,
                status = AcceptedPlanSlotsUpdateJobStatus.PENDING
            )
        )
        val repository = jobRepository(jobStore)
        val starter = mock(AcceptedPlanSlotsUpdateProcessStarter::class.java)
        `when`(starter.start(planId)).thenReturn(456L)
        val worker = worker(repository, starter)

        worker.processPendingJobs()

        assertEquals(AcceptedPlanSlotsUpdateJobStatus.DONE, jobStore.getValue(planId).status)
        assertEquals(456L, jobStore.getValue(planId).processInstanceKey)
        verify(starter).start(planId)
    }

    private fun worker(
        repository: AcceptedPlanSlotsUpdateJobRepository,
        starter: AcceptedPlanSlotsUpdateProcessStarter
    ): AcceptedPlanSlotsUpdateWorker =
        AcceptedPlanSlotsUpdateWorker(
            planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10),
            acceptedPlanSlotsUpdateJobRepository = repository,
            acceptedPlanSlotsUpdateProcessStarter = starter,
            transactionTemplate = transactionTemplate()
        )

    private fun jobRepository(
        store: MutableMap<UUID, AcceptedPlanSlotsUpdateJobEntity>
    ): AcceptedPlanSlotsUpdateJobRepository {
        val repository = mock(AcceptedPlanSlotsUpdateJobRepository::class.java)
        `when`(
            repository.findClaimableForUpdate(
                anyNonNull(),
                org.mockito.ArgumentMatchers.anyInt()
            )
        ).thenAnswer { invocation ->
            val now = invocation.arguments[0] as LocalDateTime
            val limit = invocation.arguments[1] as Int
            store.values
                .filter { job ->
                    job.status == AcceptedPlanSlotsUpdateJobStatus.PENDING ||
                        (
                            job.status == AcceptedPlanSlotsUpdateJobStatus.RUNNING &&
                                job.lockedUntil != null &&
                                job.lockedUntil!!.isBefore(now) &&
                                job.processInstanceKey == null
                            )
                }
                .sortedBy { job -> job.createdAt }
                .take(limit)
        }
        `when`(repository.findById(anyNonNull())).thenAnswer { invocation ->
            Optional.ofNullable(store[invocation.arguments[0]])
        }
        `when`(repository.save(anyNonNull())).thenAnswer { invocation ->
            val job = invocation.arguments[0] as AcceptedPlanSlotsUpdateJobEntity
            store[job.planId] = job
            job
        }
        `when`(repository.saveAll<AcceptedPlanSlotsUpdateJobEntity>(anyNonNull())).thenAnswer { invocation ->
            val jobs = invocation.arguments[0] as Iterable<AcceptedPlanSlotsUpdateJobEntity>
            jobs.forEach { job -> store[job.planId] = job }
            jobs.toList()
        }
        return repository
    }

    private fun transactionTemplate(): TransactionTemplate =
        TransactionTemplate(
            object : PlatformTransactionManager {
                override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus()
                override fun commit(status: TransactionStatus) = Unit
                override fun rollback(status: TransactionStatus) = Unit
            }
        )

    @Suppress("UNCHECKED_CAST")
    private fun <T> anyNonNull(): T {
        org.mockito.ArgumentMatchers.any<T>()
        return null as T
    }

    private fun <T> eqNonNull(value: T): T {
        org.mockito.ArgumentMatchers.eq(value)
        return value
    }
}
