package space.nstart.pcp_tgu_service.service

import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
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.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 java.time.Duration
import java.time.LocalDateTime
import space.nstart.pcp.pcp_types_lib.utils.UtcDateTimes

/**
 * Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
 *
 * - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается.
 * - REJECTED — план отклонён как неправильный: помечается терминально, авто-перевыдача НЕ
 *   запускается. Цепочка встаёт на паузу — оператор должен вручную создать исправленный план
 *   («Создать план»). Бессмысленно автоматически перевыдавать тот же план на то же окно.
 * - «не заложен» / «не принят» — технический сбой доставки (содержимое не отклонялось): план
 *   помечается терминально, и на ближайшую ЗРВ запускается срочная перевыдача ([reissueOnNearestWindow]).
 */
@Service
class HandlePlanDecisionUseCase(
    private val processedPlanDecisionEventRepository: ProcessedPlanDecisionEventRepository,
    private val planIssueAttemptRepository: PlanIssueAttemptRepository,
    private val plannedPlanRepository: PlannedPlanRepository,
    private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
    private val acceptedPlanSlotsUpdateJobService: AcceptedPlanSlotsUpdateJobService,
    private val planningProperties: PlanningProperties
) {
    private val log = LoggerFactory.getLogger(javaClass)

    /** Применяет решение по attempt; повтор того же eventId — успешный no-op. */
    @Transactional
    fun handle(message: PlanDecisionMessage) {
        if (!claimEvent(message.eventId)) {
            return
        }

        val attempt = planIssueAttemptRepository.lockByAttemptId(message.attemptId)
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Attempt ${message.attemptId} was not found")
        if (attempt.planId != message.planId) {
            throw ResponseStatusException(
                HttpStatus.CONFLICT,
                "Decision attemptId=${message.attemptId} does not belong to planId=${message.planId}"
            )
        }

        val plan = plannedPlanRepository.lockByPlanId(message.planId)
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found")
        if (plan.spacecraftId != message.spacecraftId) {
            throw ResponseStatusException(
                HttpStatus.CONFLICT,
                "Decision spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}"
            )
        }

        val state = getOrCreateLockedState(message.spacecraftId)
        if (attempt.status.isTerminal()) {
            return
        }
        // Несколько выдач по КА могут лететь одновременно (исходный план + коррекция того же окна),
        // поэтому решение применяем к своему плану по его статусу, а не сверяя с единственным
        // active-слотом. Если план уже терминальный (вытеснен заложенной коррекцией / отклонён) —
        // запоздавшее решение просто пишем на attempt, цепочку им не двигаем.
        if (plan.status !in IN_FLIGHT_STATUSES) {
            recordDecisionOnAttempt(message, attempt)
            planIssueAttemptRepository.save(attempt)
            return
        }

        when (message.decision) {
            PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan)
            PlanDecision.REJECTED -> rejectPlan(message, attempt, plan, state)
        }
    }

    /**
     * Событие «план заложен» (позитивный фидбек закладки): принятый план переходит в LAID_IN и
     * становится базой цепочки. Именно закладка — а не ACCEPT — двигает якорь и chainVersion,
     * поэтому до неё по одному окну могут одновременно «лететь» исходный план и его коррекция.
     * Все ещё не заложенные конкуренты на пересекающееся окно гасятся в SUPERSEDED.
     */
    @Transactional
    fun handleLayinConfirmed(message: PlanExecutionFeedbackMessage) {
        if (!claimEvent(message.eventId)) {
            return
        }

        val plan = plannedPlanRepository.lockByPlanId(message.planId)
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found")
        if (plan.spacecraftId != message.spacecraftId) {
            throw ResponseStatusException(
                HttpStatus.CONFLICT,
                "Layin spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}"
            )
        }

        val state = getOrCreateLockedState(message.spacecraftId)
        if (plan.status != PlannedPlanStatus.AWAITING_LAYIN) {
            // Закладка по плану не в статусе «ждёт закладки» (устаревшее/дубль/уже вытеснен) — игнор.
            log.info("Layin-confirmed ignored: plan {} is not AWAITING_LAYIN (status={}).", plan.planId, plan.status)
            return
        }

        // Нельзя заложить план, если по КА уже есть заложенный (LAID_IN) со стартом в пределах
        // ±[LAYIN_MIN_SEPARATION_MINUTES] минут — две закладки на практически одно и то же время
        // недопустимы (один заложенный слот на окно).
        val nearbyLaidIn = plannedPlanRepository
            .findAllBySpacecraftIdAndStatusIn(plan.spacecraftId, setOf(PlannedPlanStatus.LAID_IN))
            .firstOrNull { laid ->
                laid.planId != plan.planId &&
                    Duration.between(laid.startTime, plan.startTime).abs().toMinutes() < LAYIN_MIN_SEPARATION_MINUTES
            }
        if (nearbyLaidIn != null) {
            throw ResponseStatusException(
                HttpStatus.CONFLICT,
                "По КА ${plan.spacecraftId} уже заложен план ${nearbyLaidIn.planId} со стартом в пределах " +
                    "$LAYIN_MIN_SEPARATION_MINUTES мин — закладка на то же время недопустима"
            )
        }

        val now = UtcDateTimes.now()
        plan.status = PlannedPlanStatus.LAID_IN
        plan.updatedAt = now

        // База/версия двигаются только здесь. Снимаем паузу и срочную перевыдачу — окно закрыто.
        state.currentBasePlanId = plan.planId
        state.chainVersion += 1
        state.urgentReissueFrom = null
        state.pausedAfterReject = false
        state.activePlanId = null
        state.activeAttemptId = null
        state.updatedAt = now

        supersedeCompetingPlans(plan, now)

        plannedPlanRepository.save(plan)
        spacecraftPlanningStateJpaRepository.save(state)

        // Статусы слотов проставляем только по факту закладки на КА (LAID_IN), а не на ACCEPT:
        // до закладки план ещё может быть вытеснен коррекцией, и занимать слоты рано.
        acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)
    }

    /** Гасит в SUPERSEDED ещё не заложенные планы КА, чьё окно пересекается с заложенным. */
    private fun supersedeCompetingPlans(laidIn: PlannedPlanEntity, now: LocalDateTime) {
        val competitors = plannedPlanRepository
            .findAllBySpacecraftIdAndStatusIn(laidIn.spacecraftId, LIVE_UNLAID_STATUSES)
            .filter { it.planId != laidIn.planId && overlaps(it, laidIn) }
        if (competitors.isEmpty()) {
            return
        }
        for (competitor in competitors) {
            competitor.status = PlannedPlanStatus.SUPERSEDED
            competitor.updatedAt = now
        }
        plannedPlanRepository.saveAll(competitors)
        log.info(
            "Plan {} laid in; superseded {} competing plan(s) on overlapping window.",
            laidIn.planId, competitors.size
        )
    }

    private fun overlaps(a: PlannedPlanEntity, b: PlannedPlanEntity): Boolean =
        a.startTime.isBefore(b.endTime) && b.startTime.isBefore(a.endTime)

    /** Событие «не заложен» по базовому плану — срочная перевыдача на ближайшую ЗРВ. */
    @Transactional
    fun handleLayinFailed(message: PlanExecutionFeedbackMessage) {
        applyNegativeFeedback(message, PlannedPlanStatus.LAYIN_FAILED)
    }

    /** Событие «не принят» по базовому плану — пока поведение идентично «не заложен». */
    @Transactional
    fun handleNotAccepted(message: PlanExecutionFeedbackMessage) {
        applyNegativeFeedback(message, PlannedPlanStatus.NOT_ACCEPTED)
    }

    private fun applyNegativeFeedback(message: PlanExecutionFeedbackMessage, terminalStatus: PlannedPlanStatus) {
        if (!claimEvent(message.eventId)) {
            return
        }

        val plan = plannedPlanRepository.lockByPlanId(message.planId)
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found")
        if (plan.spacecraftId != message.spacecraftId) {
            throw ResponseStatusException(
                HttpStatus.CONFLICT,
                "Feedback spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}"
            )
        }

        val state = getOrCreateLockedState(message.spacecraftId)
        if (state.currentBasePlanId != plan.planId || plan.status != PlannedPlanStatus.AWAITING_LAYIN) {
            // Событие пришло не по текущему базовому плану (устарело/дубль логики) — ничего не делаем.
            log.info(
                "Negative feedback ignored: plan {} is not the current AWAITING_LAYIN base (status={}, base={}).",
                plan.planId, plan.status, state.currentBasePlanId
            )
            return
        }

        val now = UtcDateTimes.now()
        plan.status = terminalStatus
        plan.updatedAt = now
        plannedPlanRepository.save(plan)

        reissueOnNearestWindow(state, plan, now)
    }

    private fun recordDecisionOnAttempt(
        message: PlanDecisionMessage,
        attempt: PlanIssueAttemptEntity
    ) {
        val now = UtcDateTimes.now()
        attempt.status = when (message.decision) {
            PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED
            PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED
        }
        attempt.finishedAt = message.decisionTime
        attempt.decisionEventId = message.eventId
        attempt.decisionReason = message.reason
        attempt.updatedAt = now
    }

    private fun acceptPlan(
        message: PlanDecisionMessage,
        attempt: PlanIssueAttemptEntity,
        plan: PlannedPlanEntity
    ) {
        val now = UtcDateTimes.now()
        recordDecisionOnAttempt(message, attempt)

        plan.status = PlannedPlanStatus.AWAITING_LAYIN
        plan.acceptedAt = message.decisionTime
        plan.updatedAt = now

        // ACCEPT больше НЕ запирает цепочку и не двигает базу/версию: принятый план лишь ждёт
        // закладки. Базой (currentBasePlanId) и chainVersion управляет только LAID_IN
        // ([handleLayinConfirmed]) — до закладки можно отправить и принять коррекцию того же окна.
        // Статусы слотов тоже проставляются не здесь, а на закладке ([handleLayinConfirmed]).
        planIssueAttemptRepository.save(attempt)
        plannedPlanRepository.save(plan)
    }

    private fun rejectPlan(
        message: PlanDecisionMessage,
        attempt: PlanIssueAttemptEntity,
        plan: PlannedPlanEntity,
        state: SpacecraftPlanningStateEntity
    ) {
        val now = UtcDateTimes.now()
        recordDecisionOnAttempt(message, attempt)

        plan.status = PlannedPlanStatus.REJECTED
        plan.updatedAt = now

        // Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный». Но при нескольких выдачах
        // по КА отказ одной не должен глушить цепочку, если живёт другой кандидат (в полёте/принят,
        // но не заложен) — он ещё может закрыть окно. Паузу (pausedAfterReject) ставим, только если
        // других живых кандидатов не осталось: тогда проекция пуста, и оператор создаёт план вручную.
        val hasOtherLiveCandidate = plannedPlanRepository
            .findAllBySpacecraftIdAndStatusIn(plan.spacecraftId, LIVE_UNLAID_STATUSES)
            .any { it.planId != plan.planId }
        state.activePlanId = null
        state.activeAttemptId = null
        state.urgentReissueFrom = null
        state.pausedAfterReject = !hasOtherLiveCandidate
        state.updatedAt = now

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

    /**
     * Общий маршрут после негатива (REJECTED/«не заложен»/«не принят»): снимает активную выдачу
     * и взводит срочную перевыдачу на ближайшую ЗРВ. Базовый указатель не трогаем — проекция
     * сама подберёт ближайшее окно (для принятого-и-провалившегося плана он же служит якорем цепочки).
     */
    private fun reissueOnNearestWindow(
        state: SpacecraftPlanningStateEntity,
        failedPlan: PlannedPlanEntity,
        now: LocalDateTime
    ) {
        state.activePlanId = null
        state.activeAttemptId = null
        state.urgentReissueFrom = maxOf(
            now.plusMinutes(planningProperties.reissuePrepMinutes),
            failedPlan.startTime
        )
        state.updatedAt = now
        spacecraftPlanningStateJpaRepository.save(state)
    }

    private fun claimEvent(eventId: java.util.UUID): Boolean =
        processedPlanDecisionEventRepository.insertIfAbsent(eventId, UtcDateTimes.now()) != 0

    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 fun PlanIssueAttemptStatus.isTerminal(): Boolean =
        this != PlanIssueAttemptStatus.STARTING && this != PlanIssueAttemptStatus.STARTED

    private companion object {
        /** Минимальный разнос по старту между двумя заложенными планами КА (минуты). */
        const val LAYIN_MIN_SEPARATION_MINUTES = 10L

        /** Выданный, но ещё не решённый план — его решение двигает цепочку. */
        val IN_FLIGHT_STATUSES = setOf(PlannedPlanStatus.ISSUING, PlannedPlanStatus.WAITING_DECISION)

        /** Живые, но ещё не заложенные планы КА: кандидаты на supersede и на «есть ли ещё кандидат». */
        val LIVE_UNLAID_STATUSES = setOf(
            PlannedPlanStatus.ISSUING,
            PlannedPlanStatus.WAITING_DECISION,
            PlannedPlanStatus.AWAITING_LAYIN
        )
    }
}
