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.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
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.domain.CalculatedPlan
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.entity.SpacecraftSnapshotEntity
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
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 IssueDuePlanWorkerOnDemandTest {

    private val spacecraftId = "25544"

    @Test
    fun `materializes due projection head as ISSUING and starts one attempt`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        // issueNotBefore = start - 30m; start=now+10m → issueNotBefore in the past → due now.
        val head = plan(now().plusMinutes(10), now().plusHours(3))
        val worker = worker(planStore, attemptStore, state, projection = listOf(head))

        worker.issueDuePlans()

        assertEquals(1, planStore.size)
        val materialized = planStore.values.single()
        assertEquals(head.startTime, materialized.startTime)
        // markStarted перевёл выданный план в WAITING_DECISION.
        assertEquals(PlannedPlanStatus.WAITING_DECISION, materialized.status)
        assertNotNull(state.activeAttemptId)
        assertEquals(1, attemptStore.size)
    }

    @Test
    fun `does not issue when projection head is not due yet`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        // start far in the future → issueNotBefore well after now → not due.
        val head = plan(now().plusHours(10), now().plusHours(13))
        val worker = worker(planStore, attemptStore, state, projection = listOf(head))

        worker.issueDuePlans()

        assertTrue(planStore.isEmpty())
        assertNull(state.activeAttemptId)
    }

    @Test
    fun `urgent reissue issues nearest window past the floor immediately and clears the flag`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        val floor = now().plusMinutes(30)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, urgentReissueFrom = floor)
        val beforeFloor = plan(now().plusMinutes(10), now().plusHours(3))
        val afterFloor = plan(now().plusMinutes(40), now().plusHours(4))
        val worker = worker(planStore, attemptStore, state, projection = listOf(beforeFloor, afterFloor))

        worker.issueDuePlans()

        val materialized = planStore.values.single()
        assertEquals(afterFloor.startTime, materialized.startTime)
        assertNull(state.urgentReissueFrom)
        assertNotNull(state.activeAttemptId)
    }

    @Test
    fun `skips spacecraft that has an in-flight plan being issued`() {
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // По КА есть план «в работе» (ISSUING) → авто-выдача следующего окна блокируется (до решения).
        val inFlight = persistedPlan(PlannedPlanStatus.ISSUING, now().plusMinutes(10), now().plusHours(1))
        val planStore = linkedMapOf(inFlight.planId to inFlight)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val head = plan(now().plusMinutes(10), now().plusHours(3))
        val projection = mock(PlanProjectionService::class.java)
        `when`(projection.project(spacecraftId)).thenReturn(listOf(head))
        val worker = worker(planStore, attemptStore, state, projectionService = projection)

        worker.issueDuePlans()

        // Новый план не материализован, проекция даже не запрашивалась (блокировка раньше).
        assertEquals(1, planStore.size)
        verify(projection, never()).project(spacecraftId)
    }

    @Test
    fun `advances chain past WAITING_DECISION plan once grace elapsed and issues next without accepting it`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // Активный план висит в WAITING_DECISION; старт был 40 минут назад (grace=30 → истёк).
        val unconfirmed = persistedPlan(
            status = PlannedPlanStatus.WAITING_DECISION,
            start = now().minusMinutes(40),
            end = now().plusHours(1)
        )
        planStore[unconfirmed.planId] = unconfirmed
        val baseId = UUID.randomUUID()
        val state = SpacecraftPlanningStateEntity(
            spacecraftId = spacecraftId,
            currentBasePlanId = baseId,
            activePlanId = unconfirmed.planId,
            activeAttemptId = UUID.randomUUID()
        )
        val next = plan(now().plusMinutes(10), now().plusHours(3))
        val worker = worker(planStore, attemptStore, state, projection = listOf(next))

        worker.issueDuePlans()

        // Незакрытый план НЕ помечается ACCEPTED — остаётся WAITING_DECISION.
        assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[unconfirmed.planId]!!.status)
        // Базу/версию grace НЕ двигает (это делает только LAID_IN) — currentBasePlanId не меняется.
        assertEquals(baseId, state.currentBasePlanId)
        // Следующий план всё равно выдан с новым attempt (grace перестал блокировать).
        assertEquals(2, planStore.size)
        assertNotNull(state.activeAttemptId)
        assertEquals(next.startTime, planStore.values.single { it.planId != unconfirmed.planId }.startTime)
    }

    @Test
    fun `keeps blocking on WAITING_DECISION plan before grace elapses`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // Старт был всего 10 минут назад (grace=30 → ещё не истёк) → блокировка держится.
        val unconfirmed = persistedPlan(
            status = PlannedPlanStatus.WAITING_DECISION,
            start = now().minusMinutes(10),
            end = now().plusHours(1)
        )
        planStore[unconfirmed.planId] = unconfirmed
        val attemptId = UUID.randomUUID()
        val state = SpacecraftPlanningStateEntity(
            spacecraftId = spacecraftId,
            activePlanId = unconfirmed.planId,
            activeAttemptId = attemptId
        )
        val worker = worker(planStore, attemptStore, state, projection = listOf(plan(now().plusMinutes(10), now().plusHours(3))))

        worker.issueDuePlans()

        assertEquals(1, planStore.size)
        assertEquals(attemptId, state.activeAttemptId)
    }

    // --- Ручная выдача issuePlanNow + гард «точки невозврата» (layinFreezeMinutes = 10) ---

    @Test
    fun `issuePlanNow issues a future not-laid-in plan past the freeze threshold`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // Принятый план в будущем (старт через 30 мин > порога 10 мин), активной выдачи нет.
        val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
        planStore[target.planId] = target
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val worker = worker(planStore, attemptStore, state)

        worker.issuePlanNow(target.planId)

        // Выдача стартовала: новая attempt, план ушёл в WAITING_DECISION (markStarted), КА залочен.
        assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
        assertEquals(1, attemptStore.size)
        assertEquals(target.planId, state.activePlanId)
        assertNotNull(state.activeAttemptId)
    }

    @Test
    fun `issuePlanNow clears the paused-after-reject flag (operator intervened)`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
        planStore[target.planId] = target
        // Цепочка стояла на паузе после отказа; ручная выдача = осознанное вмешательство → пауза снимается.
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, pausedAfterReject = true)
        val worker = worker(planStore, attemptStore, state)

        worker.issuePlanNow(target.planId)

        assertEquals(false, state.pausedAfterReject)
        assertEquals(target.planId, state.activePlanId)
    }

    @Test
    fun `issuePlanNow rejects a plan inside the freeze window`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // Старт через 5 мин (< порога 10 мин) → точка невозврата пройдена.
        val target = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3))
        planStore[target.planId] = target
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val worker = worker(planStore, attemptStore, state)

        val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }

        assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
        assertEquals(PlannedPlanStatus.ACCEPTED, planStore[target.planId]!!.status)
        assertTrue(attemptStore.isEmpty())
        assertNull(state.activeAttemptId)
    }

    @Test
    fun `issuePlanNow rejects a laid-in plan`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // В будущем за порогом, но уже подтверждён как заложенный → только fork-forward.
        val target = persistedPlan(PlannedPlanStatus.LAID_IN, now().plusMinutes(30), now().plusHours(3))
        planStore[target.planId] = target
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val worker = worker(planStore, attemptStore, state)

        val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }

        assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
        assertTrue(attemptStore.isEmpty())
        assertNull(state.activeAttemptId)
    }

    @Test
    fun `issuePlanNow allows a parallel issue while another plan is in flight`() {
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // По КА уже летит другой план (WAITING_DECISION) — это больше НЕ блокирует: несколько выдач
        // по КА разом допустимы (исходный + коррекция), конкуренция разрешается на закладке.
        val other = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(2))
        val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
        val planStore = linkedMapOf(other.planId to other, target.planId to target)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val worker = worker(planStore, attemptStore, state)

        worker.issuePlanNow(target.planId)

        // Выдача target стартовала параллельно: новый attempt, target в WAITING_DECISION.
        assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
        assertEquals(1, attemptStore.size)
        assertEquals(target.planId, state.activePlanId)
        assertNotNull(state.activeAttemptId)
    }

    @Test
    fun `issuePlanNow re-issues the same plan over its own in-flight attempt`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // План уже ушёл в авто-выдачу (attempt #1 в полёте), но до старта далеко (> порога).
        val target = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(3))
        planStore[target.planId] = target
        val inFlight = PlanIssueAttemptEntity(
            attemptId = UUID.randomUUID(),
            planId = target.planId,
            attemptNo = 1,
            status = PlanIssueAttemptStatus.STARTED,
            createdAt = now(),
            updatedAt = now()
        )
        attemptStore[inFlight.attemptId] = inFlight
        val state = SpacecraftPlanningStateEntity(
            spacecraftId = spacecraftId,
            activePlanId = target.planId,
            activeAttemptId = inFlight.attemptId
        )
        val worker = worker(planStore, attemptStore, state)

        worker.issuePlanNow(target.planId)

        // Новая attempt #2 замещает летящую #1; план снова WAITING_DECISION, КА указывает на новую attempt.
        assertEquals(2, attemptStore.size)
        assertEquals(target.planId, state.activePlanId)
        assertNotEquals(inFlight.attemptId, state.activeAttemptId)
        assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
    }

    @Test
    fun `issuePlanNow returns 404 for unknown plan`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val worker = worker(planStore, attemptStore, state)

        val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(UUID.randomUUID()) }

        assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
    }

    // --- Применить черновик: promoteDraftNow (Ф5, оптимистичный лок базы) ---

    @Test
    fun `promoteDraftNow promotes a fresh draft into the chain and issues it`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // Заменяемый базовый план (даёт окно для проверки валидности слота при replace).
        val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
        planStore[base.planId] = base
        val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
        planStore[draft.planId] = draft
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
        val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft)))

        worker.promoteDraftNow(draft.planId)

        // DRAFT ушёл в выдачу: markStarted перевёл его в WAITING_DECISION, КА указывает на новую attempt.
        assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status)
        assertEquals(1, attemptStore.size)
        assertEquals(draft.planId, state.activePlanId)
        assertNotNull(state.activeAttemptId)
    }

    @Test
    fun `promoteDraftNow rejects with 409 when the chain moved since the draft was created`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
        planStore[base.planId] = base
        // Черновик снят на версии цепочки 0, а база уже уехала вперёд (accept/grace) → состояние v1.
        val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
        planStore[draft.planId] = draft
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 1)
        val worker = worker(planStore, attemptStore, state)

        val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }

        assertEquals(HttpStatus.CONFLICT, ex.statusCode)
        assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status)
        assertTrue(attemptStore.isEmpty())
        assertNull(state.activeAttemptId)
    }

    @Test
    fun `promoteDraftNow rejects a non-draft plan with 422`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        val live = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
        planStore[live.planId] = live
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val worker = worker(planStore, attemptStore, state)

        val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(live.planId) }

        assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
        assertTrue(attemptStore.isEmpty())
    }

    @Test
    fun `promoteDraftNow returns 404 for unknown draft`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
        val worker = worker(planStore, attemptStore, state)

        val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(UUID.randomUUID()) }

        assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
    }

    @Test
    fun `promoteDraftNow rejects a draft inside the freeze window with 422`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // Старт через 5 мин (< порога 10 мин): слот валиден (база даёт окно), но точка невозврата пройдена.
        val base = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3))
        planStore[base.planId] = base
        val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
        planStore[draft.planId] = draft
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
        // Слот валиден (окно совпадает с ЗРВ) — отказ именно из-за порога заморозки, а не слота.
        val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft)))

        val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }

        assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
        assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status)
        assertTrue(attemptStore.isEmpty())
    }

    @Test
    fun `promoteDraftNow promotes a correction while the base is still in flight`() {
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // База того же окна ещё «в полёте» (WAITING_DECISION) — это больше НЕ блокирует promote
        // коррекции: оба плана летят параллельно, конкуренция разрешается на закладке (LAID_IN).
        val base = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(3))
        val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
        val planStore = linkedMapOf(base.planId to base, draft.planId to draft)
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
        val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft)))

        worker.promoteDraftNow(draft.planId)

        assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status)
        assertEquals(1, attemptStore.size)
        assertEquals(draft.planId, state.activePlanId)
        assertNotNull(state.activeAttemptId)
    }

    @Test
    fun `promoteDraftNow rejects with 422 when the draft window no longer matches any future ZRV`() {
        val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
        val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
        // Нет базового плана и пустая проекция → окно слота не подтверждается (снапшот сместился).
        val draft = draftPlan(now().plusMinutes(30), now().plusHours(3), chainVersion = 0, basePlanId = null)
        planStore[draft.planId] = draft
        val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
        val worker = worker(planStore, attemptStore, state)

        val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }

        assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
        assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status)
        assertTrue(attemptStore.isEmpty())
    }

    private fun draftPlan(
        start: LocalDateTime,
        end: LocalDateTime,
        chainVersion: Long,
        basePlanId: UUID?
    ): PlannedPlanEntity =
        persistedPlan(PlannedPlanStatus.DRAFT, start, end).apply {
            this.chainVersion = chainVersion
            this.basePlanId = basePlanId
        }

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

    private fun worker(
        planStore: MutableMap<UUID, PlannedPlanEntity>,
        attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
        state: SpacecraftPlanningStateEntity,
        projection: List<CalculatedPlan> = emptyList(),
        rawWindows: List<CalculatedPlan> = emptyList(),
        projectionService: PlanProjectionService = projectionReturning(projection, rawWindows)
    ): IssueDuePlanWorker {
        val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
        `when`(snapshotRepository.findAllByActiveTrue()).thenReturn(
            listOf(SpacecraftSnapshotEntity(spacecraftId = spacecraftId, noradId = 25544L))
        )
        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 = snapshotRepository,
            plannedPlanRepository = plannedRepository(planStore),
            planIssueAttemptRepository = attemptRepository(attemptStore),
            spacecraftPlanningStateJpaRepository = stateRepository(state),
            planProjectionService = projectionService,
            bpmnPlanIssueProcessStarter = starter,
            transactionTemplate = transactionTemplate()
        )
    }

    private fun projectionReturning(
        projection: List<CalculatedPlan>,
        rawWindows: List<CalculatedPlan> = emptyList()
    ): PlanProjectionService {
        val service = mock(PlanProjectionService::class.java)
        `when`(service.project(spacecraftId)).thenReturn(projection)
        `when`(service.rawZrvWindows(spacecraftId)).thenReturn(rawWindows)
        return service
    }

    /** Сырое окно ЗРВ, совпадающее со слотом черновика (старт+КПП и конец) — слот проходит валидацию. */
    private fun rawWindowFor(plan: PlannedPlanEntity): CalculatedPlan =
        CalculatedPlan(spacecraftId = spacecraftId, startTime = plan.startTime, endTime = plan.endTime, kppId = plan.kppId)

    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.save(anyNonNull())).thenAnswer { invocation ->
            val plan = invocation.arguments[0] as PlannedPlanEntity
            store[plan.planId] = plan
            plan
        }
        `when`(
            repository.insertIssuingPlan(
                anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull(),
                anyNonNull(), anyOrNull(), org.mockito.ArgumentMatchers.anyLong(), anyNonNull()
            )
        ).thenAnswer { invocation ->
            val entity = PlannedPlanEntity(
                planId = invocation.arguments[0] as UUID,
                spacecraftId = invocation.arguments[1] as String,
                startTime = invocation.arguments[2] as LocalDateTime,
                endTime = invocation.arguments[3] as LocalDateTime,
                kppId = invocation.arguments[4] as String,
                status = PlannedPlanStatus.ISSUING,
                issueNotBefore = invocation.arguments[5] as LocalDateTime
            )
            store[entity.planId] = entity
            entity
        }
        `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 }
        }
        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.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 plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan =
        CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1")

    // Тот же UTC-эталон, что и у воркера (IssueDuePlanWorker берёт LocalDateTime.now()),
    // иначе на не-UTC JVM подготовленные относительно "сейчас" окна расходятся с часами воркера на офсет.
    private fun now(): LocalDateTime = LocalDateTime.now()

    private fun planningProperties(): PlanningProperties =
        PlanningProperties(30, 180, 60, false, 10, 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
    }

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