package com.correx.core.kernel.orchestration
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.journal.DecisionJournalRenderer
import com.correx.core.artifacts.ArtifactState
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.SalvageDecision
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ArtifactLifecyclePhase
import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.subagent.SubagentRunRequest
import com.correx.core.kernel.retry.RetryDecision
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.resolution.TransitionDecision
import kotlinx.datetime.Clock
import java.util.concurrent.*
import java.util.concurrent.atomic.*

@Suppress("LongMethod")
internal tailrec suspend fun DefaultSessionOrchestrator.step(ctx: EnrichedExecutionContext): WorkflowResult {
    log.debug(
        "[Orchestrator] step session={} stage={} stageCount={}",
        ctx.sessionId.value, ctx.currentStageId.value, ctx.stageCount,
    )
    if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId)

    val enriched = EnrichedExecutionContext(
        graph = ctx.graph,
        sessionId = ctx.sessionId,
        stageCount = ctx.stageCount,
        currentStageId = ctx.currentStageId,
        config = ctx.config,
        state = orchestrationRepository.getState(ctx.sessionId),
        session = repositories.sessionRepository.getSession(ctx.sessionId),
    )

    val stageConfig = enriched.graph.stages[enriched.currentStageId]
    val targetIds: Set<ArtifactId> = stageConfig?.produces?.map { it.name }?.toSet() ?: emptySet()
    val stageArtifacts: Map<ArtifactId, ArtifactState> = if (targetIds.isEmpty()) {
        emptyMap()
    } else {
        val validated = repositories.eventStore.read(enriched.sessionId)
            .mapNotNull { (it.payload as? ArtifactValidatedEvent)?.artifactId }
            .filter { it in targetIds }
            .toSet()
        validated.associateWith { ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED) }
    }

    val artifactContent: Map<ArtifactId, String> = targetIds.mapNotNull { id ->
        val cacheKey = "${enriched.sessionId.value}:${id.value}"
        artifactContentCache[cacheKey]?.let { content -> id to content }
    }.toMap()

    val resolved = resolveTransition(
        enriched.graph, enriched.sessionId, enriched.currentStageId, stageArtifacts, artifactContent,
    )
    // Ticket return-to-sender: a repair stage (the file's author, or the fallback recovery stage)
    // is entered by kernel routing from whichever stage failed a gate, so its exit must go BACK to
    // that exact gate (re-run it) — not follow a plan edge that would walk a regenerating
    // implementer or skip intervening stages. Derived from the latest failure ticket routed here
    // (replay-deterministic). Overrides the resolved edge; an operator preempt below still wins.
    val baseDecision = ticketReturnMove(enriched) ?: resolved
    // Preemptive redirect override (freestyle graph re-routing): if the operator confirmed a
    // jump (a recorded, unconsumed PreemptRedirectEvent), it overrides the resolver's edge —
    // unless the target is unknown or its needs aren't satisfied, in which case it is blocked
    // and the normal edge is taken. The decision is pure over the event log (replay-deterministic);
    // only the block-event emission is a side effect here.
    val decision = when (
        val outcome = PreemptRedirect.decide(
            events = repositories.eventStore.read(enriched.sessionId),
            graph = enriched.graph,
            sessionId = enriched.sessionId,
            artifactAvailable = { id ->
                !artifactContentCache["${enriched.sessionId.value}:${id.value}"].isNullOrBlank()
            },
            nowMs = Clock.System.now().toEpochMilliseconds(),
        )
    ) {
        is PreemptRedirect.Outcome.Override -> outcome.move
        is PreemptRedirect.Outcome.Block -> {
            emit(enriched.sessionId, outcome.event)
            log.info(
                "[Orchestrator] redirect blocked session={} to={} reason={}",
                enriched.sessionId.value, outcome.event.toStageId.value, outcome.event.reason,
            )
            baseDecision
        }
        PreemptRedirect.Outcome.None -> baseDecision
    }
    log.debug(
        "[Orchestrator] transition session={} stage={} decision={}",
        enriched.sessionId.value, enriched.currentStageId.value, decision::class.simpleName,
    )
    return when (decision) {
        is TransitionDecision.Move -> when (val r = executeMove(enriched, decision)) {
            is StepResult.Continue -> step(r.ctx)
            is StepResult.Terminal -> r.result
        }

        is TransitionDecision.Stay -> if (isTerminal(enriched.graph, enriched.currentStageId)) {
            completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount, enriched.graph.id)
        } else {
            // No outgoing edge matched: the stage finished but produced nothing that satisfies a
            // transition (e.g. the analyst never emitted a validated `analysis`). Treat it as a
            // retryable stage failure — a fresh attempt lets the model gather more before writing —
            // rather than killing the run. Only terminal once the retry budget is spent.
            retryStageOrFail(
                enriched,
                "no transition condition matched from stage ${enriched.currentStageId.value}",
            )
        }

        is TransitionDecision.Blocked -> failWorkflow(
            sessionId = enriched.sessionId,
            stageId = enriched.currentStageId,
            reason = decision.reason,
            retryExhausted = false,
        )

        is TransitionDecision.NoMatch -> retryStageOrFail(
            enriched,
            "no matching transition from stage ${enriched.currentStageId.value}",
        )
    }
}

/**
 * A non-terminal stage whose outcome matched no outgoing transition (Stay/NoMatch) produced no
 * usable artifact. Route it back through the stage's retry budget instead of failing the run:
 * re-execute the same stage if attempts remain, else fail terminally with retryExhausted=true.
 */

@Suppress("LongMethod", "ReturnCount", "NestedBlockDepth")
internal suspend fun DefaultSessionOrchestrator.executeMove(
    ctx: EnrichedExecutionContext,
    decision: TransitionDecision.Move,
): StepResult {
    val nextStageId = decision.to

    if (isTerminal(ctx.graph, nextStageId)) {
        // Terminal stage is a sentinel — not executed, so don't count it.
        return StepResult.Terminal(
            completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount, ctx.graph.id),
        )
    }

    // Runtime refinement guard: a back-edge (re-entering a stage already visited this
    // run, e.g. reviewer→implementer) increments a per-cycle counter recorded as an
    // event, so the guard is replay-deterministic. Exceeding the cap escalates to a
    // terminal failure instead of looping forever.
    if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
        val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
        val reviewerRole = ctx.graph.stages[ctx.currentStageId]?.metadata?.get("role")?.lowercase()
        val reviewLoop = reviewerRole in setOf("review", "reviewer")
        val maxIterations = if (reviewLoop) {
            tuning.reviewLoopMaxCycles
        } else {
            ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
        }
        val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
        emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
        if (iteration > maxIterations) {
            if (reviewLoop) {
                val events = repositories.eventStore.read(ctx.sessionId)
                val alreadyRecovered = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
                    .any { it.gate == REVIEW_LOOP_GATE && it.stageId == ctx.currentStageId }
                val notes = ctx.graph.stages[ctx.currentStageId]?.produces
                    ?.mapNotNull { artifactContentCache["${ctx.sessionId.value}:${it.name.value}"] }
                    ?.joinToString("\n\n")
                    .orEmpty()
                val reason = "review loop exhausted after exactly $maxIterations cycles. " +
                    "The fixed DoD was not approved. Accumulated review notes:\n" +
                    notes.ifBlank { "(review stage emitted no retained notes)" }
                if (!alreadyRecovered) {
                    return routeToRecovery(
                        ctx,
                        ctx.currentStageId,
                        REVIEW_LOOP_GATE,
                        "review_convergence",
                        reason,
                        orchestrationRepository.getState(ctx.sessionId),
                    ) ?: StepResult.Terminal(
                        failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true),
                    )
                }
                return StepResult.Terminal(
                    failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true),
                )
            }
            return StepResult.Terminal(
                failWorkflow(
                    ctx.sessionId,
                    nextStageId,
                    "refinement loop '$cycleKey' exceeded $maxIterations iterations — escalating",
                    retryExhausted = true,
                ),
            )
        }
    }

    // Emit the transition before executing the next stage. Otherwise the next stage's
    // events (InferenceStarted, artifacts, …) are recorded ahead of the TransitionExecuted
    // that marks entering it, so the log shows the stage running before it was entered.
    val advancedTo = advanceStage(ctx.sessionId, ctx.currentStageId, decision)
    return when (val result = enterStage(ctx.copy(currentStageId = advancedTo), nextStageId)) {
        is StepResult.Continue -> StepResult.Continue(result.ctx)
        is StepResult.Terminal -> result
    }
}

private const val REVIEW_LOOP_GATE = "review_loop"

@Suppress("ReturnCount")
internal suspend fun DefaultSessionOrchestrator.enterStage(
    ctx: EnrichedExecutionContext,
    stageId: StageId,
): StepResult {
    log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}")

    if (ctx.graph.stages[stageId]?.metadata?.get("requiresApproval") == "true") {
        val alreadyApproved = repositories.eventStore.read(ctx.sessionId).let { events ->
            val stageRequestIds = events
                .mapNotNull { it.payload as? ApprovalRequestedEvent }
                .filter { it.stageId == stageId && it.toolName == null }
                .map { it.requestId }
                .toSet()
            stageRequestIds.isNotEmpty() && events.any {
                val decision = it.payload as? ApprovalDecisionResolvedEvent
                // A REJECTED decision must NOT satisfy the gate on retry/resume, else the stage
                // runs unapproved. Only APPROVED/AUTO_APPROVED count as a prior approval.
                decision?.requestId in stageRequestIds &&
                    decision?.outcome != ApprovalOutcome.REJECTED
            }
        }
        if (!alreadyApproved) {
            val gateArtifact = ctx.graph.stages[stageId]?.needs?.firstOrNull()?.value
            val preview = gateArtifact
                ?.let { artifactContentCache["${ctx.sessionId.value}:$it"] }
                ?: "Upstream stage has completed. Review and approve to continue to stage ${stageId.value}."
            val approved = requestStageApproval(ctx.sessionId, stageId, preview)
            if (!approved) {
                return StepResult.Terminal(
                    failWorkflow(ctx.sessionId, stageId, "approval rejected for stage ${stageId.value}", retryExhausted = false)
                )
            }
        }
    }

    while (true) {
        if (isCancelled(ctx.sessionId)) {
            return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId))
        }
        val result = subagentRunner.run(
            SubagentRunRequest(ctx.sessionId, stageId, ctx.graph, ctx.session, ctx.config),
        ).outcome
        when (result) {
            is StageExecutionResult.Success -> {
                // The stage may emit open questions in its artifact (discovery does). Park, let the
                // operator answer, and record the answers — then ADVANCE, do not re-run the stage.
                // A re-run restarts inference with no prior CoT, so the stage re-explores instead of
                // converging (2026-07-18). The answers are recorded as ClarificationAnsweredEvents
                // and injected into every later stage's L0 context (buildClarificationAnswerEntries),
                // so the next stage (e.g. analyst) sees them without discovery running again.
                requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)
                compactionService?.let { svc ->
                    val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
                    val journalText = DecisionJournalRenderer().render(journalState)
                    val tokenEstimate = estimateTokens(journalText)
                    svc.compactIfNeeded(
                        sessionId = ctx.sessionId,
                        state = journalState,
                        renderedTokenEstimate = tokenEstimate,
                        emit = { payload -> emit(ctx.sessionId, payload) },
                    )
                }
                return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
            }

            is StageExecutionResult.Failure -> {
                log.debug(
                    "[Orchestrator] stage failed session=${ctx.sessionId.value} " +
                            "stage=${stageId.value} reason=${result.reason} retryable=${result.retryable}",
                )
                if (!result.retryable) {
                    // Non-retryable: let the transition resolver handle the outcome
                    // (e.g., back-edge via artifact_field_equals on failure)
                    return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
                }
                val refreshedState = orchestrationRepository.getState(ctx.sessionId)
                // #309: inside the recovery stage itself, a same-file/same-diagnostic recurrence is
                // never left to loop — recovery has no further tier to route to, so this checks BEFORE
                // any of the normal gate-retry machinery (which the whole-reason-string progress
                // fingerprint can be fooled into treating as "still progressing" indefinitely).
                if (isRecoveryStage(ctx.graph, stageId)) {
                    recoveryFileLoopBreak(ctx.sessionId, stageId, tuning.recoveryFileRewriteLimit)?.let { reason ->
                        return StepResult.Terminal(escalateRecoveryLoop(ctx, stageId, result.gate, reason))
                    }
                }
                // A repeated missing-build-prerequisite block is not an ordinary retry: it takes a
                // bounded, separately-budgeted precondition-resolution path (design #170) that never
                // charges the stage retry counter. FallThrough = defer to the normal recovery routing.
                if (result.gate == WORKSPACE_PRECONDITION_GATE) {
                    when (val outcome = buildPrerequisiteDecision(ctx, stageId, result, refreshedState)) {
                        PrerequisiteOutcome.Bootstrap -> continue // re-run stage, no retry charged
                        PrerequisiteOutcome.FallThrough -> Unit // non-writable → recovery routing below
                        is PrerequisiteOutcome.Done -> return outcome.result
                    }
                }
                // A proven same-signature failure loop (Vikunja #78) must never be retried in place —
                // retrying is what got us here. Route straight to recovery regardless of capability;
                // terminal if the graph offers no recovery route.
                if (result.gate == STAGE_LOOP_BREAK_GATE) {
                    return routeToRecovery(ctx, stageId, result.gate, "file_write", result.reason, refreshedState)
                        ?: StepResult.Terminal(
                            failWorkflow(ctx.sessionId, stageId, result.reason, retryExhausted = true),
                        )
                }
                // Retry-agency invariant: a gate this stage lacks the capability to fix must not
                // be retried in place (futile). Route to a recovery stage that holds the
                // capability, if one exists and the route budget remains.
                maybeRouteToRecovery(ctx, stageId, result, refreshedState)?.let { return it }
                val gateDecision = retryCoordinator.decide(
                    sessionId = ctx.sessionId,
                    stageId = stageId,
                    gate = result.gate,
                    failureReason = result.reason,
                    state = refreshedState,
                    policy = ctx.config.retryPolicy,
                )
                when (gateDecision) {
                    RetryDecision.Retry -> Unit // retry — loop and re-execute
                    RetryDecision.Exhausted -> {
                        // A stage may hold the nominal capability yet repeatedly fail to apply it
                        // (scope paralysis / frozen ReAct loop). Exhaustion is only returned for an
                        // unchanged fingerprint, so hand that no-progress dead-end to the
                        // intent-holder rather than declaring the workflow failed in place.
                        GATE_REQUIRED_CAPABILITY[result.gate]?.let { capability ->
                            routeToRecovery(
                                ctx,
                                stageId,
                                result.gate,
                                capability,
                                result.reason,
                                refreshedState,
                            )?.let { return it }
                        }
                        decideGateExhaustion(
                            ctx, stageId, result.gate, result.reason, refreshedState,
                        )?.let { return it }
                        // review-gate salvage said CONTINUE — budget was reset by the reducer;
                        // loop and re-execute the stage with a fresh budget.
                    }
                }
            }
        }
    }
}

/**
 * Hybrid exhaustion (design 2026-07-06-per-gate-retry-budgets.md §3/T6): a deterministic gate
 * exhausting its budget fails the stage terminally, unchanged from today's behaviour (just
 * per-gate now). The "review" gate instead gets one salvageability judgement — CONTINUE resets
 * its budget (via the reducer folding [RetrySalvageDecidedEvent]) and the caller loops to retry;
 * FAIL is terminal. A gate that already spent its one salvage reset (state.gateSalvageUsed) fails
 * immediately on a second exhaustion, regardless of what the judge would say.
 *
 * Returns a terminal [StepResult] to fail/route with, the recovery stage's result on RECOVER,
 * or null to retry in place (loop and re-execute).
 */

internal suspend fun DefaultSessionOrchestrator.decideGateExhaustion(
    ctx: EnrichedExecutionContext,
    stageId: StageId,
    gate: String,
    reason: String,
    state: OrchestrationState,
): StepResult? {
    val sessionId = ctx.sessionId
    if (gate != "review" || state.gateSalvageUsed.contains(gate)) {
        // Terminal boundary: give the run one bounded post-failure diagnostic (#294) before FAILED.
        return terminalOrDiagnose(ctx, stageId, gate, reason, state)
    }
    // No judge wired: degrade safely with a deterministic allow-one-reset-then-fail policy —
    // the gateSalvageUsed check above already ensures this fires at most once per gate.
    val judgment = salvageJudge?.judge(sessionId, stageId, reason)
        ?: SalvageJudgment(
            SalvageDecision.CONTINUE,
            "no salvage judge wired — deterministic one-time reset",
        )
    emit(sessionId, RetrySalvageDecidedEvent(sessionId, stageId, gate, judgment.decision, judgment.rationale))
    return when (judgment.decision) {
        SalvageDecision.CONTINUE -> null
        SalvageDecision.FAIL -> terminalOrDiagnose(ctx, stageId, gate, reason, state)
        // The judge chose recovery: route to the recovery stage (file_write is the capability it
        // provides). Degrade to terminal if the graph declares no recovery stage.
        SalvageDecision.RECOVER ->
            routeToRecovery(ctx, stageId, gate, "file_write", reason, state)
                ?: StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
    }
}
