package space.nstart.pcp_tgu_service.service

import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.server.ResponseStatusException
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.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.UUID
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes

/**
 * Выдаёт дозревшие планы. В on-demand-модели голова цепочки считается из снапшота ЗРВ
 * ([PlanProjectionService]) и материализуется в planned_plan ровно в момент выдачи (статус ISSUING).
 */
@Service
class IssueDuePlanWorker(
    private val planningProperties: PlanningProperties,
    private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository,
    private val plannedPlanRepository: PlannedPlanRepository,
    private val planIssueAttemptRepository: PlanIssueAttemptRepository,
    private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
    private val planProjectionService: PlanProjectionService,
    private val bpmnPlanIssueProcessStarter: BpmnPlanIssueProcessStarter,
    private val transactionTemplate: TransactionTemplate
) {
    private val log = LoggerFactory.getLogger(javaClass)

    /** Периодически по активным КА считает голову проекции и выдаёт дозревший план. */
    @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}")
    fun issueDuePlans() {
        spacecraftSnapshotRepository.findAllByActiveTrue().forEach { snapshot ->
            val prepared = transactionTemplate.execute { prepareDueForSpacecraft(snapshot.spacecraftId) }
            prepared?.let { preparedAttempt -> startPreparedAttempt(preparedAttempt) }
        }
    }

    /**
     * Ручная выдача конкретного persisted plan: создаёт новую attempt и стартует её.
     * Гард «точки невозврата» (см. [assertReissuableInSlot]) выполняется под lock'ом состояния КА.
     * Бросает 404, если плана нет; 409/422 — если выдать его нельзя.
     */
    fun issuePlanNow(planId: UUID): ProcessStartResponse {
        val prepared = transactionTemplate.execute { prepareSpecificPlan(planId) }
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
        return startPreparedAttempt(prepared)
    }

    /**
     * «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и сразу выдаёт его.
     *
     * Под lock'ом состояния КА проверяет (в порядке):
     * 1. план существует и действительно DRAFT (иначе 404 / 422);
     * 2. **оптимистичный лок базы** — версия цепочки не изменилась с момента создания черновика
     *    ([assertDraftBaseUnchanged]); иначе 409 «база уехала, посмотрите дифф»;
     * 3. **слот ещё валиден** — окно черновика по-прежнему соответствует реальной будущей ЗРВ
     *    (дрейф снапшота / перекрытие слота при replace, [assertDraftSlotStillValid]); иначе 422;
     * 4. гард точки невозврата ([assertReissuableInSlot]) — не заморожено и нет активной выдачи
     *    другого плана; иначе 422 / 409.
     *
     * Затем DRAFT→ISSUING, новая attempt, и старт BPMN. Цепочка продолжается штатно: на внешний
     * ACCEPT существующий acceptPlan сделает супершединг (chainVersion++). Прежние версии остаются
     * в БД (откат = новый черновик из старой версии). basePlanId черновика не трогаем — это его
     * провенанс (какой план он замещает/продолжает).
     */
    fun promoteDraftNow(draftPlanId: UUID): ProcessStartResponse {
        val prepared = transactionTemplate.execute { prepareDraftPromotion(draftPlanId) }
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Draft plan with planId=$draftPlanId was not found")
        return startPreparedAttempt(prepared)
    }

    /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
    private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
        val now = UtcDateTimes.now()
        val state = getOrCreateLockedState(spacecraftId)
        // Форвард-цепочку не выдаём, пока по КА есть выдача «в работе»: ISSUING или WAITING_DECISION
        // в пределах grace после старта. Прошедший grace больше не блокирует — молчаливо продолжаем
        // (базу/версию это НЕ двигает, их двигает только LAID_IN).
        if (hasBlockingInFlight(spacecraftId, now)) {
            return null
        }

        val projection = planProjectionService.project(spacecraftId)
        if (projection.isEmpty()) {
            return null
        }

        val urgentFrom = state.urgentReissueFrom
        val head = if (urgentFrom != null) {
            projection.firstOrNull { plan -> !plan.startTime.isBefore(urgentFrom) }
        } else {
            projection.first()
        } ?: return null

        if (!head.startTime.isAfter(now)) {
            return null
        }

        // Пауза после REJECTED централизована в PlanProjectionService.project(): для отклонённой
        // цепочки проекция приходит пустой (выше уже сработал early-return), поэтому отдельного
        // гарда «не выдавать отклонённое окно» здесь не нужно.
        val issueNotBefore = head.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes)
        val due = urgentFrom != null || !now.isBefore(issueNotBefore)
        if (!due) {
            return null
        }

        val basePlanId = latestCommittedPlanId(spacecraftId, state.currentBasePlanId)
        val plan = materializeIssuingPlan(head, basePlanId, state.chainVersion, issueNotBefore, now)
        val attempt = createStartingAttempt(plan.planId, now)

        state.activePlanId = plan.planId
        state.activeAttemptId = attempt.attemptId
        state.urgentReissueFrom = null
        state.updatedAt = now
        spacecraftPlanningStateJpaRepository.save(state)

        return PreparedAttempt(plan.planId, attempt.attemptId)
    }

    /**
     * По КА есть выдача «в работе», блокирующая авто-выдачу следующего окна форвард-цепочки:
     * ISSUING (только что выдан, решения ещё нет) или WAITING_DECISION в пределах
     * [PlanningProperties.waitingDecisionGraceMinutes] после старта. WAITING_DECISION за grace
     * больше не блокирует — молчаливо продолжаем цепочку (статус плана не меняем; базу/версию
     * двигает только LAID_IN). Несколько выдач по КА (исходный план + коррекция) допустимы —
     * блокирует наличие хотя бы одной «в работе».
     */
    private fun hasBlockingInFlight(spacecraftId: String, now: LocalDateTime): Boolean =
        plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, BLOCKING_INFLIGHT_STATUSES).any { plan ->
            plan.status == PlannedPlanStatus.ISSUING ||
                now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes))
        }

    /**
     * planId самого дальнего (по endTime) зафиксированного окна КА — провенанс (basePlanId) для
     * следующего выданного плана форвард-цепочки. Включает ещё не заложенные выдачи, чтобы цепочка
     * связывалась с непосредственно предыдущим окном, а не с последней заложенной базой.
     */
    private fun latestCommittedPlanId(spacecraftId: String, fallback: UUID?): UUID? =
        plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, COMMITTED_STATUSES)
            .maxByOrNull { plan -> plan.endTime }?.planId ?: fallback

    private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
        val now = UtcDateTimes.now()
        val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
        val state = getOrCreateLockedState(plan.spacecraftId)
        assertReissuableInSlot(plan, now)

        plan.status = PlannedPlanStatus.ISSUING
        plan.updatedAt = now
        plannedPlanRepository.save(plan)

        val attempt = createStartingAttempt(plan.planId, now)
        state.activePlanId = plan.planId
        state.activeAttemptId = attempt.attemptId
        state.urgentReissueFrom = null
        // Оператор вручную выдал план — снимаем паузу после отказа (если была).
        state.pausedAfterReject = false
        state.updatedAt = now
        spacecraftPlanningStateJpaRepository.save(state)

        return PreparedAttempt(plan.planId, attempt.attemptId)
    }

    private fun prepareDraftPromotion(draftPlanId: UUID): PreparedAttempt? {
        val now = UtcDateTimes.now()
        val plan = plannedPlanRepository.findById(draftPlanId).orElse(null) ?: return null
        if (plan.status != PlannedPlanStatus.DRAFT) {
            throw ResponseStatusException(
                HttpStatus.UNPROCESSABLE_ENTITY,
                "План $draftPlanId не является черновиком (status=${plan.status}) — «Применить» доступно только для DRAFT"
            )
        }

        val state = getOrCreateLockedState(plan.spacecraftId)
        assertDraftBaseUnchanged(plan, state)
        assertDraftSlotStillValid(plan)
        assertReissuableInSlot(plan, now)

        plan.status = PlannedPlanStatus.ISSUING
        plan.updatedAt = now
        plannedPlanRepository.save(plan)

        val attempt = createStartingAttempt(plan.planId, now)
        state.activePlanId = plan.planId
        state.activeAttemptId = attempt.attemptId
        state.urgentReissueFrom = null
        // Оператор отправил исправленный черновик — снимаем паузу после отказа (если была).
        state.pausedAfterReject = false
        state.updatedAt = now
        spacecraftPlanningStateJpaRepository.save(state)

        log.info(
            "Черновик {} применён (promote): spacecraft={}, окно [{}–{}], chainVersion={}",
            plan.planId, plan.spacecraftId, plan.startTime, plan.endTime, plan.chainVersion
        )
        return PreparedAttempt(plan.planId, attempt.attemptId)
    }

    /**
     * Оптимистичный лок базы для promote: версия цепочки, снятая при создании черновика
     * ([PlannedPlanEntity.chainVersion] у DRAFT), должна совпадать с текущей версией цепочки КА.
     * Любой ACCEPT/grace-сдвиг базы между созданием черновика и «Применить» инкрементит версию —
     * тогда черновик строился от устаревшей базы и его нужно пересоздать (посмотрев дифф).
     */
    private fun assertDraftBaseUnchanged(plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity) {
        if (plan.chainVersion != state.chainVersion) {
            throw ResponseStatusException(
                HttpStatus.CONFLICT,
                "База КА ${plan.spacecraftId} изменилась с момента создания черновика ${plan.planId} " +
                    "(цепочка v${plan.chainVersion} → v${state.chainVersion}) — посмотрите дифф и пересоздайте черновик"
            )
        }
    }

    /**
     * Контроль перекрытия слота / дрейфа снапшота ЗРВ на «Применить»: окно черновика должно
     * по-прежнему набираться из реальных ЗРВ снапшота. Сверка против «сырых» окон ЗРВ единым
     * правилом [ZrvWindowMatcher] (начало+КПП одной зоны, конец — другой) — оно покрывает и окно
     * из одной ЗРВ (replace/fork-forward), и окно из двух ЗРВ (план новой цепочки). Не набирается →
     * снапшот сместился → 422.
     */
    private fun assertDraftSlotStillValid(plan: PlannedPlanEntity) {
        val rawWindows = planProjectionService.rawZrvWindows(plan.spacecraftId)
        if (!ZrvWindowMatcher.matches(rawWindows, plan.startTime, plan.endTime, plan.kppId)) {
            throw ResponseStatusException(
                HttpStatus.UNPROCESSABLE_ENTITY,
                "Окно черновика ${plan.planId} [${plan.startTime}–${plan.endTime}, kpp=${plan.kppId}] " +
                    "больше не набирается из реальных ЗРВ — снапшот сместился, пересоздайте черновик"
            )
        }
    }

    /**
     * Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
     * если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
     * - план не подтверждён как заложенный (`status != LAID_IN` — иначе 422, доступен только fork-forward);
     * - до старта плана больше [PlanningProperties.layinFreezeMinutes] минут (иначе 422 — окно заморожено).
     *
     * Гарда «по КА уже идёт выдача другого плана» больше НЕТ: несколько выдач по КА одновременно —
     * норма (исходный план + коррекция того же окна). Конкуренция разрешается на закладке: ушедший
     * в LAID_IN план гасит остальные в SUPERSEDED ([HandlePlanDecisionUseCase.handleLayinConfirmed]).
     *
     * Узкого списка «перевыдаваемых» статусов нет: правим в т.ч. ACCEPTED/AWAITING_LAYIN, пока план
     * в будущем за порогом заморозки и не заложен.
     */
    private fun assertReissuableInSlot(plan: PlannedPlanEntity, now: LocalDateTime) {
        if (plan.status == PlannedPlanStatus.LAID_IN) {
            throw ResponseStatusException(
                HttpStatus.UNPROCESSABLE_ENTITY,
                "План ${plan.planId} уже заложен (LAID_IN) — перевыдача в свой слот невозможна, используйте fork-forward"
            )
        }
        val freezeAt = plan.startTime.minusMinutes(planningProperties.layinFreezeMinutes)
        if (!now.isBefore(freezeAt)) {
            throw ResponseStatusException(
                HttpStatus.UNPROCESSABLE_ENTITY,
                "До старта плана ${plan.planId} осталось меньше ${planningProperties.layinFreezeMinutes} мин — " +
                    "окно выдачи заморожено (точка невозврата), используйте fork-forward"
            )
        }
    }

    private fun materializeIssuingPlan(
        head: CalculatedPlan,
        basePlanId: UUID?,
        chainVersion: Long,
        issueNotBefore: LocalDateTime,
        now: LocalDateTime
    ): PlannedPlanEntity =
        plannedPlanRepository.insertIssuingPlan(
            planId = UUID.randomUUID(),
            spacecraftId = head.spacecraftId,
            startTime = head.startTime,
            endTime = head.endTime,
            kppId = head.kppId,
            issueNotBefore = issueNotBefore,
            basePlanId = basePlanId,
            chainVersion = chainVersion,
            now = now
        )

    private fun createStartingAttempt(planId: UUID, now: LocalDateTime): PlanIssueAttemptEntity {
        val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(planId)?.attemptNo ?: 0) + 1
        return planIssueAttemptRepository.save(
            PlanIssueAttemptEntity(
                attemptId = UUID.randomUUID(),
                planId = planId,
                attemptNo = attemptNo,
                status = PlanIssueAttemptStatus.STARTING,
                createdAt = now,
                updatedAt = now
            )
        )
    }

    private fun startPreparedAttempt(preparedAttempt: PreparedAttempt): ProcessStartResponse {
        val plan = plannedPlanRepository.findById(preparedAttempt.planId)
            .orElseThrow { IllegalStateException("Prepared plan ${preparedAttempt.planId} disappeared") }
        val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId)
            .orElseThrow { IllegalStateException("Prepared attempt ${preparedAttempt.attemptId} disappeared") }

        return try {
            val response = bpmnPlanIssueProcessStarter.startProcess(plan, attempt)
            markStarted(response)
            response
        } catch (ex: Exception) {
            log.warn("BPMN start failed after attempt was prepared. planId={}, attemptId={}", plan.planId, attempt.attemptId, ex)
            markAmbiguous(preparedAttempt)
            throw ex
        }
    }

    private fun markStarted(response: ProcessStartResponse) {
        transactionTemplate.executeWithoutResult {
            val now = UtcDateTimes.now()
            val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult
            val plan = plannedPlanRepository.findById(response.planId).orElse(null) ?: return@executeWithoutResult

            if (attempt.processInstanceKey == null) {
                attempt.processInstanceKey = response.processInstanceKey
            }
            if (attempt.startedAt == null) {
                attempt.startedAt = now
            }
            if (attempt.status == PlanIssueAttemptStatus.STARTING && plan.status == PlannedPlanStatus.ISSUING) {
                attempt.status = PlanIssueAttemptStatus.STARTED
                plan.status = PlannedPlanStatus.WAITING_DECISION
                plan.updatedAt = now
            }
            attempt.updatedAt = now

            planIssueAttemptRepository.save(attempt)
            plannedPlanRepository.save(plan)
        }
    }

    private fun markAmbiguous(preparedAttempt: PreparedAttempt) {
        transactionTemplate.executeWithoutResult {
            val now = UtcDateTimes.now()
            val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult
            val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult
            attempt.status = PlanIssueAttemptStatus.AMBIGUOUS
            attempt.updatedAt = now
            plan.status = PlannedPlanStatus.START_AMBIGUOUS
            plan.updatedAt = now
            planIssueAttemptRepository.save(attempt)
            plannedPlanRepository.save(plan)
        }
    }

    private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity {
        spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
            ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId))
        return spacecraftPlanningStateJpaRepository.lockBySpacecraftId(spacecraftId)
            ?: throw IllegalStateException("Planning state for spacecraftId=$spacecraftId was not found after create")
    }

    private data class PreparedAttempt(
        val planId: UUID,
        val attemptId: UUID
    )

    private companion object {
        /** Выдачи «в работе», по которым решение ещё ожидается (для гейта авто-выдачи). */
        val BLOCKING_INFLIGHT_STATUSES = setOf(
            PlannedPlanStatus.ISSUING,
            PlannedPlanStatus.WAITING_DECISION
        )

        /** Зафиксированные окна КА (выданы/приняты/заложены) — источник провенанса форвард-цепочки. */
        val COMMITTED_STATUSES = setOf(
            PlannedPlanStatus.ISSUING,
            PlannedPlanStatus.WAITING_DECISION,
            PlannedPlanStatus.AWAITING_LAYIN,
            PlannedPlanStatus.LAID_IN
        )
    }
}
