package com.correx.core.kernel.orchestration
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.PostFailureDiagnosedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.retry.FailureFingerprint
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionDecision
import java.util.concurrent.*
import java.util.concurrent.atomic.*

internal suspend fun DefaultSessionOrchestrator.retryStageOrFail(
    ctx: EnrichedExecutionContext,
    reason: String,
): WorkflowResult {
    val attempt = orchestrationRepository.getState(ctx.sessionId).retryCount
    val shouldRetry = retryCoordinator.shouldRetry(
        sessionId = ctx.sessionId,
        stageId = ctx.currentStageId,
        currentAttempt = attempt,
        policy = ctx.config.retryPolicy,
        failureReason = reason,
    )
    if (!shouldRetry) {
        return failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true)
    }
    return when (val r = enterStage(ctx, ctx.currentStageId)) {
        is StepResult.Continue -> step(r.ctx)
        is StepResult.Terminal -> r.result
    }
}


/**
 * Retry-agency guard (ticket + recovery model). When [failure]'s gate requires a capability the
 * failing stage does not hold (e.g. a build/contract gate needs `file_write` on a write-less
 * verifier), retrying the stage in place can never change the outcome. Instead open a
 * [FailureTicketOpenedEvent] and route to the graph's recovery stage — which does hold the
 * capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET].
 *
 * Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal),
 * or null to fall through to the normal per-gate retry path. A stage that already holds the
 * capability retries in place first; its unchanged-fingerprint retry exhaustion is separately
 * routed from the step loop, because capability possession does not prove the owner can apply it.
 */

internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
    ctx: EnrichedExecutionContext,
    stageId: StageId,
    failure: StageExecutionResult.Failure,
    state: OrchestrationState,
): StepResult? {
    val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null
    val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet()
    if (requiredCapability in stageTools) return null // retry in place before no-progress exhaustion
    return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state)
}

/**
 * Open a [FailureTicketOpenedEvent] and route [stageId]'s failure down a two-tier repair ladder.
 *
 * **Tier 1 (owner):** when the gate's evidence names a file a stage actually wrote, the ticket goes
 * to that author ([resolveTicketOwner]) — it owns the code and the contract it declared, so it
 * patches its own layer. Bounded by [RECOVERY_ROUTE_BUDGET].
 *
 * **Tier 2 (arbiter / intent-holder):** when the owner loop spends its budget without settling the
 * failure, the failure is a cross-file CONTRACT dispute no single owner can resolve (each side is
 * internally consistent; they disagree with each other). The ticket escalates to the synthesized
 * recovery stage ([findRecoveryStage]) recast as the intent-holder — given the initial intent as
 * authority (see buildRecoveryTicketEntry) and told to reconcile ALL the named files in one pass.
 * Bounded by its own independent [INTENT_ROUTE_BUDGET]. An orphan failure (no author) starts here.
 *
 * Both tiers charge progress-awarely: a route whose failure fingerprint changed from the previous
 * one moved the needle, so it is FREE; only a route reproducing the same failure charges the tier's
 * budget. Terminal only when every reachable tier is spent. Returns null when neither an owner nor a
 * recovery stage exists (caller falls back to the legacy per-gate retry path). Shared by the agency
 * guard ([maybeRouteToRecovery]) and the review-gate salvage judge ([decideGateExhaustion]).
 */

internal suspend fun DefaultSessionOrchestrator.routeToRecovery(
    ctx: EnrichedExecutionContext,
    stageId: StageId,
    gate: String,
    requiredCapability: String,
    reason: String,
    state: OrchestrationState,
): StepResult? {
    val events = repositories.eventStore.read(ctx.sessionId)
    val owner = resolveTicketOwner(events, reason)
        ?.takeIf { it != stageId && requiredCapability in (ctx.graph.stages[it]?.allowedTools ?: emptySet()) }
    val arbiter = findRecoveryStage(ctx.graph, stageId)
    val fingerprint = FailureFingerprint.of(reason)
    val ownerKey = stageId.value
    val intentKey = stageId.value + INTENT_BUDGET_SUFFIX

    // (target, budgetKey, budget, escalated). Prefer the owner tier while it has budget; escalate to
    // the arbiter tier once the owner loop is spent; null = no tier available now.
    val route = when {
        owner != null && !budgetExhausted(state, ownerKey, fingerprint, tuning.recoveryRouteBudget) ->
            RouteTier(owner, ownerKey, tuning.recoveryRouteBudget, escalated = false)
        arbiter != null && !budgetExhausted(state, intentKey, fingerprint, tuning.intentRouteBudget) ->
            RouteTier(arbiter, intentKey, tuning.intentRouteBudget, escalated = true)
        else -> null
    }
    if (route == null) {
        if (owner == null && arbiter == null) return null // nothing to route to: legacy retry path
        // Terminal boundary: the repair ladder is spent. Give the run one bounded post-failure
        // diagnostic (#294) before FAILED — it may find a materially-new route the budget accounting lacked.
        return terminalOrDiagnose(
            ctx,
            stageId,
            gate,
            "repair ladder exhausted for stage ${stageId.value} (gate=$gate): $reason",
            state,
        )
    }

    val prev = state.recoveryFailureFingerprints[route.budgetKey]
    val progressed = prev != null && prev != fingerprint
    val used = state.recoveryRoutes[route.budgetKey] ?: 0
    val routeAttempt = if (progressed) used else used + 1
    emit(
        ctx.sessionId,
        FailureTicketOpenedEvent(
            sessionId = ctx.sessionId,
            stageId = stageId,
            gate = gate,
            category = ticketCategory(gate),
            requiredCapability = requiredCapability,
            routeTo = route.target,
            evidence = reason,
            routeAttempt = routeAttempt,
            fingerprint = fingerprint,
            escalated = route.escalated,
        ),
    )
    log.info(
        "[Orchestrator] failure ticket session={} stage={} gate={} lacks={} -> {}={} " +
            "(charged={}/{}, progressed={})",
        ctx.sessionId.value, stageId.value, gate, requiredCapability,
        if (route.escalated) "arbiter" else "owner", route.target.value,
        routeAttempt, route.budget, progressed,
    )
    val advancedTo = advanceStage(
        ctx.sessionId,
        stageId,
        TransitionDecision.Move(transitionId = TICKET_ROUTE, to = route.target),
    )
    return enterStage(ctx.copy(currentStageId = advancedTo), route.target)
}

// How many recent tool actions to hand the diagnostic as "what was already tried".
private const val DIAGNOSIS_ACTION_TAIL = 10

/**
 * One-shot post-failure diagnostic (design task #294). Called at the terminal boundary — when a run
 * is about to become terminal FAILED. Runs exactly one tool-free diagnostic inference per terminal
 * failure [fingerprint] (deduped on the recorded [PostFailureDiagnosedEvent], so no loop is possible)
 * over recorded facts only. If the untrusted proposal is validated as materially new, confident, and
 * a recovery stage exists, routes once into that stage via the existing ticket machinery — bypassing
 * the already-spent route budget, since the fresh proposal is evidence the budget accounting lacked.
 * Otherwise, or when no diagnoser is wired, returns the terminal failure unchanged (safe degrade).
 * Every observation, proposal, validation decision and route is recorded (invariants #7/#9), so
 * replay reproduces the decision without re-invoking the diagnoser.
 */
@Suppress("ReturnCount") // guard-clause ladder over the validation decision — flattest form
internal suspend fun DefaultSessionOrchestrator.terminalOrDiagnose(
    ctx: EnrichedExecutionContext,
    stageId: StageId,
    gate: String,
    reason: String,
    state: OrchestrationState,
): StepResult {
    val terminal: suspend () -> StepResult =
        { StepResult.Terminal(failWorkflow(ctx.sessionId, stageId, reason, retryExhausted = true)) }
    val diagnoser = postFailureDiagnoser ?: return terminal()
    val fingerprint = FailureFingerprint.of(reason)
    val events = repositories.eventStore.read(ctx.sessionId)
    // Acceptance #1/#6: at most one diagnosis per terminal fingerprint — this is what bounds the loop.
    if (events.any { (it.payload as? PostFailureDiagnosedEvent)?.fingerprint == fingerprint }) return terminal()

    val recoveryStage = findRecoveryStage(ctx.graph, stageId)
    // Acceptance #2: recorded facts only, no fresh workspace observation.
    val input = DiagnosisInput(
        intent = events.mapNotNull { it.payload as? InitialIntentEvent }.lastOrNull()?.intent.orEmpty(),
        gate = gate,
        reason = reason,
        fingerprint = fingerprint,
        retryHistory = events.mapNotNull { it.payload as? RetryAttemptedEvent }
            .map { "${it.gate} attempt=${it.attemptNumber} fp=${it.fingerprint}" },
        attemptedActions = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
            .takeLast(DIAGNOSIS_ACTION_TAIL).map { it.toolName },
        recoveryAvailable = recoveryStage != null,
    )
    val proposal = runCatching { diagnoser.diagnose(input) }.getOrNull()
    val decision = when {
        proposal == null -> "TERMINAL_NO_PROPOSAL"
        proposal.noRecovery -> "TERMINAL_NO_RECOVERY"
        proposal.confidence < tuning.diagnosisMinConfidence -> "TERMINAL_LOW_CONFIDENCE"
        proposal.expectedFingerprint.isBlank() || proposal.expectedFingerprint == fingerprint -> "TERMINAL_NOT_MATERIAL"
        recoveryStage == null -> "TERMINAL_NO_ROUTE"
        else -> "ROUTE"
    }
    val routed = decision == "ROUTE"
    emit(
        ctx.sessionId,
        PostFailureDiagnosedEvent(ctx.sessionId, stageId, gate, fingerprint, proposal, decision, routed),
    )
    log.info(
        "[Orchestrator] post-failure diagnosis session={} stage={} gate={} decision={} routed={}",
        ctx.sessionId.value, stageId.value, gate, decision, routed,
    )
    if (!routed || recoveryStage == null) return terminal()

    // One validated, materially-new route into the existing recovery stage. Reuses the ticket
    // machinery so buildRecoveryTicketEntry feeds the narrow repair bundle and ticketReturnMove
    // re-runs the origin gate. Bounded by the per-fingerprint dedupe above, not the spent budget.
    val used = state.recoveryRoutes[stageId.value + INTENT_BUDGET_SUFFIX] ?: 0
    emit(
        ctx.sessionId,
        FailureTicketOpenedEvent(
            sessionId = ctx.sessionId,
            stageId = stageId,
            gate = gate,
            category = ticketCategory(gate),
            requiredCapability = GATE_REQUIRED_CAPABILITY[gate] ?: "file_write",
            routeTo = recoveryStage,
            evidence = reason,
            routeAttempt = used + 1,
            fingerprint = fingerprint,
            escalated = true,
        ),
    )
    val advancedTo = advanceStage(ctx.sessionId, stageId, TransitionDecision.Move(TICKET_ROUTE, recoveryStage))
    return enterStage(ctx.copy(currentStageId = advancedTo), recoveryStage)
}

/** A chosen rung of the repair ladder: where to route, which budget it charges, and its tier. */
private data class RouteTier(
    val target: StageId,
    val budgetKey: String,
    val budget: Int,
    val escalated: Boolean,
)

/**
 * Progress-aware budget check for one ladder tier. A route whose failure fingerprint changed from
 * the previous route on the same [key] made progress (fixed one cause, surfaced another) → NOT
 * exhausted (progress is free). Only a no-progress streak that reaches [budget] is exhausted.
 */

internal fun DefaultSessionOrchestrator.budgetExhausted(
    state: OrchestrationState,
    key: String,
    fingerprint: String,
    budget: Int,
): Boolean {
    val prev = state.recoveryFailureFingerprints[key]
    val progressed = prev != null && prev != fingerprint
    val used = state.recoveryRoutes[key] ?: 0
    return !progressed && used >= budget
}

/**
 * The graph's recovery stage: a stage marked `metadata["role"] == "recovery"` (or, as a
 * convenience, one whose id is literally "recovery"), other than [failingStageId] itself.
 * Null when the graph declares none — recovery routing is opt-in per workflow.
 */

internal fun DefaultSessionOrchestrator.findRecoveryStage(graph: WorkflowGraph, failingStageId: StageId): StageId? =
    graph.stages.entries.firstOrNull { (id, cfg) ->
        id != failingStageId && (cfg.metadata["role"] == "recovery" || id.value == "recovery")
    }?.key

// isRecoveryStage / recoveryFileLoopBreak / escalateRecoveryLoop moved to RecoveryFileLoopBreak.kt
// (Vikunja #309) — split out to keep this file under detekt's function-count cap.

/**
 * Route-to-owner resolution: map the failing gate's [evidence] (build/tsc output that names
 * files, e.g. "src/App.tsx: TS2322") to the stage that most recently WROTE one of those files,
 * via the recorded manifest chain (ToolInvocationRequestedEvent.stageId ← FileWrittenEvent by
 * invocationId → path). The last write of a named file wins — its author is who to hand the
 * ticket to. Pure over recorded events (replay-deterministic). Null when the evidence names no
 * written file (an orphan failure — caller falls back to the synthesized recovery stage).
 */

internal fun DefaultSessionOrchestrator.resolveTicketOwner(events: List<StoredEvent>, evidence: String): StageId? {
    val tokens = EVIDENCE_PATH_RE.findAll(evidence)
        .map { it.value.substringBefore(':').replace('\\', '/') }
        .filter { it.length >= MIN_EVIDENCE_TOKEN }
        .toSet()
    if (tokens.isEmpty()) return null
    val invToStage = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
        .associate { it.invocationId to it.stageId }
    return events.mapNotNull { it.payload as? FileWrittenEvent }
        .filter { it.postImageHash != null }
        .lastOrNull { fw ->
            val norm = fw.path.replace('\\', '/')
            invToStage.containsKey(fw.invocationId) && tokens.any { norm == it || norm.endsWith("/$it") }
        }
        ?.let { invToStage[it.invocationId] }
}

/**
 * Return-to-sender for a routed repair stage. When the current stage was entered by a failure
 * ticket ([TICKET_ROUTE]) rather than a plan edge, its exit goes BACK to the gate that opened the
 * ticket (origin of the latest [FailureTicketOpenedEvent] routed here) so that gate re-runs. This
 * holds for any repair destination — the file's author OR the fallback recovery stage — and for
 * arbitrary topology, instead of following the plan's forward edge (which would walk a
 * regenerating implementer, or skip intervening stages). Returns null when the stage was NOT
 * entered via a ticket (a normal forward visit — the resolver's edge is used).
 */

internal fun DefaultSessionOrchestrator.ticketReturnMove(ctx: EnrichedExecutionContext): TransitionDecision.Move? {
    val events = repositories.eventStore.read(ctx.sessionId)
    val enteredViaTicket = events.mapNotNull { it.payload as? TransitionExecutedEvent }
        .lastOrNull { it.to == ctx.currentStageId }
        ?.transitionId == TICKET_ROUTE
    if (!enteredViaTicket) return null
    val origin = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
        .lastOrNull { it.routeTo == ctx.currentStageId }
        ?.stageId ?: return null
    return TransitionDecision.Move(transitionId = TICKET_RETURN, to = origin)
}
