package space.nstart.pcp_tgu_service.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
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.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID

class PlanIssueAndDecisionFlowTest {

    @Test
    fun `repeated manual issue of the same plan supersedes its in-flight attempt`() {
        val spacecraftId = "25544"
        // Старт через 12 часов — далеко за порогом заморозки, перевыдача того же плана разрешена.
        val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED)
        val planStore = linkedMapOf(plan.planId to plan)
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val worker = issueWorker(planStore, attemptStore, state)

        val firstResponse = worker.issuePlanNow(plan.planId)
        // Тот же план, attempt #1 ещё «в полёте» — ручная перевыдача не блокируется, а замещает её.
        val secondResponse = worker.issuePlanNow(plan.planId)

        assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo)
        assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo)
        assertNotEquals(firstResponse.attemptId, secondResponse.attemptId)
        assertEquals(secondResponse.attemptId, state.activeAttemptId)
        assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
    }

    @Test
    fun `accepted decision moves plan to AWAITING_LAYIN without anchoring base or touching slots`() {
        val spacecraftId = "25544"
        val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
        val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId)
        val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java)
        `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0)
        val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
        val useCase = decisionUseCase(plan, attempt, state, processedRepository, slotsUpdateJobService)
        val message = decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED)

        useCase.handle(message)
        useCase.handle(message)

        assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
        assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status)
        // ACCEPT больше НЕ делает план базой и не двигает версию — это делает только LAID_IN.
        assertNull(state.currentBasePlanId)
        assertEquals(0, state.chainVersion)
        // Слоты проставляются на закладке (LAID_IN), а не на ACCEPT.
        verify(slotsUpdateJobService, never()).enqueue(plan.planId)
    }

    @Test
    fun `layin confirmed makes plan the base bumps version and supersedes overlapping competitors`() {
        val spacecraftId = "25544"
        val laidIn = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN)
        // Конкурент-коррекция того же окна (пересекается) — должна уйти в SUPERSEDED.
        val competitor = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
        // Несвязанное будущее окно — не трогаем.
        val unrelated = plannedPlan(spacecraftId, time(20), time(21), PlannedPlanStatus.AWAITING_LAYIN)
        val store = linkedMapOf(laidIn.planId to laidIn, competitor.planId to competitor, unrelated.planId to unrelated)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 4)
        val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java)
        `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0)
        val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
        val useCase = HandlePlanDecisionUseCase(
            processedPlanDecisionEventRepository = processedRepository,
            planIssueAttemptRepository = attemptRepository(linkedMapOf()),
            plannedPlanRepository = plannedRepository(store),
            spacecraftPlanningStateJpaRepository = stateRepository(state),
            acceptedPlanSlotsUpdateJobService = slotsUpdateJobService,
            planningProperties = planningProperties()
        )
        val message = feedbackMessage(spacecraftId, laidIn.planId)

        useCase.handleLayinConfirmed(message)
        useCase.handleLayinConfirmed(message) // повтор того же eventId — no-op (идемпотентность)

        assertEquals(PlannedPlanStatus.LAID_IN, laidIn.status)
        assertEquals(laidIn.planId, state.currentBasePlanId)
        assertEquals(5, state.chainVersion)
        assertEquals(PlannedPlanStatus.SUPERSEDED, competitor.status)
        assertEquals(PlannedPlanStatus.AWAITING_LAYIN, unrelated.status)
        // Слоты проставляются по факту закладки — ровно один раз (второй вызов идемпотентен).
        verify(slotsUpdateJobService).enqueue(laidIn.planId)
    }

    @Test
    fun `layin confirmed rejected with 409 when another plan is already laid in within 10 minutes`() {
        val spacecraftId = "25544"
        val target = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN)
        // Уже заложенный план со стартом на 5 минут позже — в пределах ±10 мин → подтвердить нельзя.
        val alreadyLaidIn = plannedPlan(spacecraftId, time(12).plusMinutes(5), time(13), PlannedPlanStatus.LAID_IN)
        val store = linkedMapOf(target.planId to target, alreadyLaidIn.planId to alreadyLaidIn)
        val state = SpacecraftPlanningStateEntity(
            spacecraftId = spacecraftId,
            currentBasePlanId = alreadyLaidIn.planId,
            chainVersion = 1
        )
        val useCase = HandlePlanDecisionUseCase(
            processedPlanDecisionEventRepository = processedRepository(),
            planIssueAttemptRepository = attemptRepository(linkedMapOf()),
            plannedPlanRepository = plannedRepository(store),
            spacecraftPlanningStateJpaRepository = stateRepository(state),
            acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
            planningProperties = planningProperties()
        )

        val ex = assertThrows(ResponseStatusException::class.java) {
            useCase.handleLayinConfirmed(feedbackMessage(spacecraftId, target.planId))
        }

        assertEquals(HttpStatus.CONFLICT, ex.statusCode)
        assertEquals(PlannedPlanStatus.AWAITING_LAYIN, target.status) // не заложен
        assertEquals(1, state.chainVersion) // версия цепочки не двигалась
    }

    @Test
    fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() {
        val spacecraftId = "25544"
        val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION)
        val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId)
        val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
        val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)

        useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED))

        assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status)
        assertEquals(PlannedPlanStatus.REJECTED, plan.status)
        // Активная выдача снята, перевыдача НЕ взведена, цепочка на паузе — ждёт ручного исправленного плана.
        assertNull(state.activePlanId)
        assertNull(state.activeAttemptId)
        assertNull(state.urgentReissueFrom)
        assertTrue(state.pausedAfterReject)
        verify(slotsUpdateJobService, never()).enqueue(plan.planId)
    }

    @Test
    fun `layin failed on current base marks LAYIN_FAILED and arms urgent reissue idempotently`() {
        assertNegativeFeedbackArmsReissue(PlannedPlanStatus.LAYIN_FAILED) { useCase, message ->
            useCase.handleLayinFailed(message)
        }
    }

    @Test
    fun `plan not accepted on current base marks NOT_ACCEPTED and arms urgent reissue idempotently`() {
        assertNegativeFeedbackArmsReissue(PlannedPlanStatus.NOT_ACCEPTED) { useCase, message ->
            useCase.handleNotAccepted(message)
        }
    }

    @Test
    fun `negative feedback for a plan that is not the current base is ignored`() {
        val spacecraftId = "25544"
        val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN)
        val stalePlan = plannedPlan(spacecraftId, time(2), time(3), PlannedPlanStatus.REJECTED)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId)
        val useCase = HandlePlanDecisionUseCase(
            processedPlanDecisionEventRepository = processedRepository(),
            planIssueAttemptRepository = attemptRepository(linkedMapOf()),
            plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base, stalePlan.planId to stalePlan)),
            spacecraftPlanningStateJpaRepository = stateRepository(state),
            acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
            planningProperties = planningProperties()
        )

        useCase.handleLayinFailed(feedbackMessage(spacecraftId, stalePlan.planId))

        assertEquals(PlannedPlanStatus.REJECTED, stalePlan.status)
        assertNull(state.urgentReissueFrom)
    }

    @Test
    fun `decision for a superseded plan records the attempt but does not move the chain`() {
        val spacecraftId = "25544"
        // План уже вытеснен заложенной коррекцией (SUPERSEDED, не in-flight) — запоздавшее решение
        // по его attempt записывается, но цепочку не двигает и базу не трогает.
        val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.SUPERSEDED)
        val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
        val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)

        useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED))

        assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
        assertEquals(PlannedPlanStatus.SUPERSEDED, plan.status)
        assertNull(state.currentBasePlanId)
        verify(slotsUpdateJobService, never()).enqueue(plan.planId)
    }

    @Test
    fun `terminal attempt is not reprocessed by new decision event`() {
        val spacecraftId = "25544"
        val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN)
        val attempt = attempt(plan.planId, PlanIssueAttemptStatus.ACCEPTED)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = plan.planId)
        val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
        val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)

        useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED))

        assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
        assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status)
        assertEquals(plan.planId, state.currentBasePlanId)
        assertNull(state.urgentReissueFrom)
        verify(slotsUpdateJobService, never()).enqueue(plan.planId)
    }

    private fun assertNegativeFeedbackArmsReissue(
        expectedStatus: PlannedPlanStatus,
        invoke: (HandlePlanDecisionUseCase, PlanExecutionFeedbackMessage) -> Unit
    ) {
        val spacecraftId = "25544"
        val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId)
        val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java)
        `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0)
        val useCase = HandlePlanDecisionUseCase(
            processedPlanDecisionEventRepository = processedRepository,
            planIssueAttemptRepository = attemptRepository(linkedMapOf()),
            plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base)),
            spacecraftPlanningStateJpaRepository = stateRepository(state),
            acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
            planningProperties = planningProperties()
        )
        val message = feedbackMessage(spacecraftId, base.planId)

        invoke(useCase, message)
        invoke(useCase, message)

        assertEquals(expectedStatus, base.status)
        assertNotNull(state.urgentReissueFrom)
    }

    private fun decisionUseCase(
        plan: PlannedPlanEntity,
        attempt: PlanIssueAttemptEntity,
        state: SpacecraftPlanningStateEntity,
        processedRepository: ProcessedPlanDecisionEventRepository = processedRepository(),
        slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
    ): HandlePlanDecisionUseCase =
        HandlePlanDecisionUseCase(
            processedPlanDecisionEventRepository = processedRepository,
            planIssueAttemptRepository = attemptRepository(linkedMapOf(attempt.attemptId to attempt)),
            plannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)),
            spacecraftPlanningStateJpaRepository = stateRepository(state),
            acceptedPlanSlotsUpdateJobService = slotsUpdateJobService,
            planningProperties = planningProperties()
        )

    private fun issueWorker(
        planStore: MutableMap<UUID, PlannedPlanEntity>,
        attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
        state: SpacecraftPlanningStateEntity
    ): IssueDuePlanWorker {
        val starter = mock(BpmnPlanIssueProcessStarter::class.java)
        `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
            val p = invocation.arguments[0] as PlannedPlanEntity
            val a = invocation.arguments[1] as PlanIssueAttemptEntity
            ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId)
        }
        return IssueDuePlanWorker(
            planningProperties = planningProperties(),
            spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java),
            plannedPlanRepository = plannedRepository(planStore),
            planIssueAttemptRepository = attemptRepository(attemptStore),
            spacecraftPlanningStateJpaRepository = stateRepository(state),
            planProjectionService = mock(PlanProjectionService::class.java),
            bpmnPlanIssueProcessStarter = starter,
            transactionTemplate = transactionTemplate()
        )
    }

    private fun processedRepository(): ProcessedPlanDecisionEventRepository {
        val repository = mock(ProcessedPlanDecisionEventRepository::class.java)
        `when`(repository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1)
        return repository
    }

    private fun plannedRepository(store: MutableMap<UUID, PlannedPlanEntity>): PlannedPlanRepository {
        val repository = mock(PlannedPlanRepository::class.java)
        `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) }
        `when`(repository.lockByPlanId(anyNonNull())).thenAnswer { invocation -> store[invocation.arguments[0]] }
        `when`(repository.save(anyNonNull())).thenAnswer { invocation ->
            val plan = invocation.arguments[0] as PlannedPlanEntity
            store[plan.planId] = plan
            plan
        }
        `when`(repository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
            val sc = invocation.arguments[0] as String
            @Suppress("UNCHECKED_CAST")
            val statuses = invocation.arguments[1] as Collection<PlannedPlanStatus>
            store.values.filter { plan -> plan.spacecraftId == sc && plan.status in statuses }
        }
        `when`(repository.saveAll(anyNonNull<MutableList<PlannedPlanEntity>>())).thenAnswer { invocation ->
            @Suppress("UNCHECKED_CAST")
            val plans = invocation.arguments[0] as Iterable<PlannedPlanEntity>
            plans.forEach { plan -> store[plan.planId] = plan }
            plans.toMutableList()
        }
        return repository
    }

    private fun attemptRepository(store: MutableMap<UUID, PlanIssueAttemptEntity>): PlanIssueAttemptRepository {
        val repository = mock(PlanIssueAttemptRepository::class.java)
        `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) }
        `when`(repository.lockByAttemptId(anyNonNull())).thenAnswer { invocation -> store[invocation.arguments[0]] }
        `when`(repository.findTopByPlanIdOrderByAttemptNoDesc(anyNonNull())).thenAnswer { invocation ->
            store.values.filter { attempt -> attempt.planId == invocation.arguments[0] }.maxByOrNull { attempt -> attempt.attemptNo }
        }
        `when`(repository.save(anyNonNull())).thenAnswer { invocation ->
            val attempt = invocation.arguments[0] as PlanIssueAttemptEntity
            store[attempt.attemptId] = attempt
            attempt
        }
        return repository
    }

    private fun stateRepository(state: SpacecraftPlanningStateEntity): SpacecraftPlanningStateJpaRepository {
        val repository = mock(SpacecraftPlanningStateJpaRepository::class.java)
        `when`(repository.findById(state.spacecraftId)).thenReturn(Optional.of(state))
        `when`(repository.lockBySpacecraftId(state.spacecraftId)).thenReturn(state)
        `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] }
        return repository
    }

    private fun plannedPlan(
        spacecraftId: String,
        start: LocalDateTime,
        end: LocalDateTime,
        status: PlannedPlanStatus
    ): PlannedPlanEntity =
        PlannedPlanEntity(
            planId = UUID.randomUUID(),
            spacecraftId = spacecraftId,
            startTime = start,
            endTime = end,
            kppId = "KPP-1",
            status = status,
            issueNotBefore = LocalDateTime.now().minusMinutes(1)
        )

    private fun attempt(planId: UUID, status: PlanIssueAttemptStatus): PlanIssueAttemptEntity =
        PlanIssueAttemptEntity(
            attemptId = UUID.randomUUID(),
            planId = planId,
            attemptNo = 1,
            status = status
        )

    private fun decisionMessage(
        spacecraftId: String,
        planId: UUID,
        attemptId: UUID,
        decision: PlanDecision
    ): PlanDecisionMessage =
        PlanDecisionMessage(
            eventId = UUID.randomUUID(),
            spacecraftId = spacecraftId,
            planId = planId,
            attemptId = attemptId,
            decision = decision,
            decisionTime = LocalDateTime.now(),
            reason = null
        )

    private fun feedbackMessage(spacecraftId: String, planId: UUID): PlanExecutionFeedbackMessage =
        PlanExecutionFeedbackMessage(
            eventId = UUID.randomUUID(),
            spacecraftId = spacecraftId,
            planId = planId,
            occurredAt = LocalDateTime.now(),
            reason = null
        )

    private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())

    private fun planningProperties(): PlanningProperties =
        PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10)

    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
    }
}
