package com.correx.core.kernel.orchestration

import com.correx.core.events.events.BuildPrerequisiteBootstrapAttemptedEvent
import com.correx.core.events.events.ClarificationQuestion
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.ClarificationRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.transitions.graph.StageConfig
import kotlinx.coroutines.CompletableDeferred
import java.nio.file.FileSystems
import java.nio.file.Path
import java.util.UUID

/**
 * Repeated attempts to read a missing build prerequisite are no longer mere ReAct noise. Once the
 * same build-critical path has been durably BLOCK'd three times *within the current bootstrap
 * window* (design 2026-07-15 §#163/#170), return a retryable gate failure so the orchestrator can
 * grant a bounded precondition-resolution turn (or route to a stage that can create the file).
 *
 * The window is scoped to events after the last [BuildPrerequisiteBootstrapAttemptedEvent] for this
 * stage: without that, the historical blocks that triggered the FIRST detection would re-trip the
 * gate on the very first round of the granted bootstrap turn, before the model ever gets to write
 * the file. Scoping resets the count so the bootstrap turn starts clean and only *new* blocks
 * (the model failed to create it) escalate.
 */
internal fun SessionOrchestrator.repeatedBuildCriticalReferenceBlock(
    sessionId: SessionId,
    stageId: StageId,
): String? {
    val events = eventStore.read(sessionId)
    val windowStart = events
        .filter { (it.payload as? BuildPrerequisiteBootstrapAttemptedEvent)?.stageId == stageId }
        .maxOfOrNull { it.sequence } ?: Long.MIN_VALUE
    val windowed = events.filter { it.sequence > windowStart }
    val requests = windowed
        .mapNotNull { it.payload as? ToolInvocationRequestedEvent }
        .filter { it.stageId == stageId }
        .associateBy { it.invocationId }
    val paths = windowed
        .mapNotNull { it.payload as? ToolCallAssessedEvent }
        .filter { it.stageId == stageId && it.disposition == RiskAction.BLOCK }
        .filter { event -> event.issues.any { it.code == REFERENCE_EXISTS_CODE } }
        .mapNotNull { event -> requests[event.invocationId]?.request?.parameters?.get("path") as? String }
        .filter(::isBuildCriticalPath)
    val repeated = paths.groupingBy { it }.eachCount().entries
        .firstOrNull { it.value >= REFERENCE_EXISTS_REPEAT_LIMIT }
        ?: return null
    return "stage ${stageId.value} repeatedly referenced missing build prerequisite '${repeated.key}' " +
        "(${repeated.value} blocked attempts). Create or repair the project setup before continuing."
}

/**
 * Global backstop for a stage stuck retrying the *same* failing action (Vikunja #78). The existing
 * read-loop and rejection-loop breakers key on CONSECUTIVE rounds, so a single interleaved success
 * resets their counters — exactly what let the 2026-07-16 audition run-3 thrash `npm install` against
 * a hallucinated package across 57 inferences. This counts CUMULATIVELY per normalized failure
 * signature within the stage: once one signature has failed [limit] times, retrying it is provably
 * pointless, so return a retryable gate failure that the step handler routes straight to recovery.
 *
 * Pure fold over recorded events (replay-safe, invariants #8/#9). [ToolExecutionFailedEvent] carries
 * no stageId, so failures are correlated to the stage via their invocationId → the emitting
 * [ToolInvocationRequestedEvent] (which does).
 *
 * ponytail: cumulative over the whole stage, not windowed per re-entry — a break escalates to
 * recovery under a bounded budget, so an unfixable loop terminates rather than re-tripping forever.
 *
 * #304: the window IS reset once the stage has been routed to recovery (a [FailureTicketOpenedEvent]
 * naming it), so a stage returning from a genuine repair attempt gets a clean count instead of
 * re-tripping this gate on its very first post-recovery execution off stale, pre-recovery failures —
 * which is exactly what turned recovery into an expensive predetermined dead end (session
 * 67ef4b3f-9ce9-4436-9870-543feb0ca450). The recovery ROUTE budget ([RECOVERY_ROUTE_BUDGET] /
 * `recoveryRoutes`) is untouched by this reset — it is charged by the reducer off the ticket event
 * itself, independent of this fold — so a stage that keeps failing after recovery still terminates
 * once that budget is spent.
 */
internal fun SessionOrchestrator.repeatedToolFailureLoop(
    sessionId: SessionId,
    stageId: StageId,
    limit: Int,
): String? = detectRepeatedToolFailure(eventStore.read(sessionId), stageId, limit)

/** Pure core of [repeatedToolFailureLoop] — a fold over recorded events (unit-tested in isolation). */
internal fun detectRepeatedToolFailure(
    events: List<StoredEvent>,
    stageId: StageId,
    limit: Int,
): String? {
    val windowStart = events
        .filter { (it.payload as? FailureTicketOpenedEvent)?.stageId == stageId }
        .maxOfOrNull { it.sequence } ?: Long.MIN_VALUE
    val windowed = events.filter { it.sequence > windowStart }
    val stageInvocations = windowed
        .mapNotNull { it.payload as? ToolInvocationRequestedEvent }
        .filter { it.stageId == stageId }
        .map { it.invocationId }
        .toSet()
    val repeated = windowed
        .mapNotNull { it.payload as? ToolExecutionFailedEvent }
        .filter { it.invocationId in stageInvocations }
        // Collapse to a stable signature so equivalent retries group together: drop digits (package
        // versions, ports, counts) and punctuation/paths, keep the tool name and alphabetic core.
        // `npm install @types/vite@4.0.0` 404 and `@types/vite@5` 404 map to the same signature.
        .groupingBy { failure ->
            failure.toolName + "|" + failure.reason.lowercase()
                .replace(Regex("[0-9]+"), " ")
                .replace(Regex("[^a-z ]+"), " ")
                .replace(Regex("\\s+"), " ")
                .trim()
                .take(FAILURE_SIGNATURE_PREFIX)
        }
        .eachCount().entries
        .firstOrNull { it.value >= limit }
        ?: return null
    return "stage ${stageId.value} is stuck: the same tool failure recurred ${repeated.value} times " +
        "(${repeated.key}). Retrying it keeps failing — a materially different action is required."
}

/**
 * Which bounded precondition-resolution path a repeated build-prerequisite block takes (design #170).
 * Pure over the three observable facts — deterministic/replay-safe, unit-tested in isolation.
 */
internal enum class PrerequisiteAction { BOOTSTRAP, ROUTE_TO_RECOVERY, CLARIFY, ESCALATE }

/**
 * - not writable → the stage can't create the file; route to a stage that can ([ROUTE_TO_RECOVERY]).
 * - writable but the path is outside the stage's declared scope → don't guess who owns project
 *   setup, ask ([CLARIFY]).
 * - writable, in-scope, bootstrap budget left → grant the one dedicated turn ([BOOTSTRAP]).
 * - writable, in-scope, budget spent → the turn didn't clear it; escalate to recovery ([ESCALATE]).
 */
internal fun decidePrerequisiteAction(
    writable: Boolean,
    inScope: Boolean,
    bootstrapBudgetLeft: Boolean,
): PrerequisiteAction = when {
    !writable -> PrerequisiteAction.ROUTE_TO_RECOVERY
    !inScope -> PrerequisiteAction.CLARIFY
    bootstrapBudgetLeft -> PrerequisiteAction.BOOTSTRAP
    else -> PrerequisiteAction.ESCALATE
}

/** Extract the quoted build-prerequisite path from a [repeatedBuildCriticalReferenceBlock] reason. */
internal fun buildPrerequisitePath(reason: String): String? =
    PREREQUISITE_PATH_RE.find(reason)?.groupValues?.get(1)

/**
 * Is [path] within this stage's declared territory? A stage with no declared scope (empty [touches]
 * and [writeManifest]) is unconstrained → owns everything → in-scope. Otherwise the path must match a
 * declared glob or be a concretely-declared file. Reused glob semantics from ManifestContainmentRule.
 */
internal fun pathInDeclaredScope(stage: StageConfig, path: String): Boolean {
    val globs = stage.touches + stage.writeManifest
    return globs.isEmpty() || // unconstrained stage owns everything
        path in stage.expectedFiles ||
        globs.any { FileSystems.getDefault().getPathMatcher("glob:$it").matches(Path.of(path)) }
}

/** How many bootstrap turns this stage has already been granted (the separate one-shot budget). */
internal fun bootstrapAttemptCount(events: List<StoredEvent>, stageId: StageId): Int =
    events.count { (it.payload as? BuildPrerequisiteBootstrapAttemptedEvent)?.stageId == stageId }

/**
 * What the step loop should do after a repeated build-prerequisite block on a writable-or-not stage.
 * [FallThrough] = not our special case (non-writable) — let the normal recovery routing handle it;
 * [Bootstrap] = re-run the stage this once (loop), the separate budget already charged;
 * [Done] = a terminal/routed [StepResult] to return.
 */
internal sealed interface PrerequisiteOutcome {
    data object Bootstrap : PrerequisiteOutcome
    data object FallThrough : PrerequisiteOutcome
    data class Done(val result: StepResult) : PrerequisiteOutcome
}

/**
 * Decide and enact the bounded precondition-resolution path for a `workspace_precondition` failure,
 * WITHOUT touching the normal stage retry budget (design #170). Side effects (recording the bootstrap
 * grant, requesting clarification, routing to recovery) are performed here; the returned outcome tells
 * the step loop whether to loop, fall through, or return.
 */
internal suspend fun DefaultSessionOrchestrator.buildPrerequisiteDecision(
    ctx: EnrichedExecutionContext,
    stageId: StageId,
    failure: com.correx.core.transitions.execution.StageExecutionResult.Failure,
    state: OrchestrationState,
): PrerequisiteOutcome {
    val stage = ctx.graph.stages[stageId]
    val path = buildPrerequisitePath(failure.reason)
    if (stage == null || path == null) return PrerequisiteOutcome.FallThrough
    val events = repositories.eventStore.read(ctx.sessionId)
    val action = decidePrerequisiteAction(
        writable = "file_write" in stage.allowedTools,
        inScope = pathInDeclaredScope(stage, path),
        bootstrapBudgetLeft = bootstrapAttemptCount(events, stageId) < BOOTSTRAP_BUDGET,
    )
    return when (action) {
        PrerequisiteAction.ROUTE_TO_RECOVERY -> PrerequisiteOutcome.FallThrough
        PrerequisiteAction.BOOTSTRAP -> {
            emit(
                ctx.sessionId,
                BuildPrerequisiteBootstrapAttemptedEvent(ctx.sessionId, stageId, path, failure.reason),
            )
            log.info(
                "[Orchestrator] granting build-prerequisite bootstrap turn session={} stage={} path={}",
                ctx.sessionId.value, stageId.value, path,
            )
            PrerequisiteOutcome.Bootstrap
        }
        PrerequisiteAction.CLARIFY -> requestBuildPrerequisiteClarification(ctx, stageId, path)
        PrerequisiteAction.ESCALATE -> PrerequisiteOutcome.Done(
            routeToRecovery(ctx, stageId, failure.gate, "file_write", failure.reason, state)
                ?: StepResult.Terminal(
                    failWorkflow(
                        ctx.sessionId,
                        stageId,
                        "build prerequisite '$path' unresolved after bootstrap: ${failure.reason}",
                        retryExhausted = true,
                    ),
                ),
        )
    }
}

/**
 * The missing prerequisite lies outside the writable stage's declared scope: ownership of project
 * setup is ambiguous, so record a clarification request and pause rather than guess (design #170
 * point 5). After the operator answers, loop to re-run the stage with the answer in context. Bounded
 * by [tuning].maxClarificationRounds; a spent budget fails the workflow rather than looping.
 */
private suspend fun DefaultSessionOrchestrator.requestBuildPrerequisiteClarification(
    ctx: EnrichedExecutionContext,
    stageId: StageId,
    path: String,
): PrerequisiteOutcome {
    val priorRounds = repositories.eventStore.read(ctx.sessionId)
        .count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
    if (priorRounds >= tuning.maxClarificationRounds) {
        return PrerequisiteOutcome.Done(
            StepResult.Terminal(
                failWorkflow(
                    ctx.sessionId,
                    stageId,
                    "build prerequisite '$path' is outside stage ${stageId.value}'s declared scope " +
                        "and clarification budget is spent",
                    retryExhausted = true,
                ),
            ),
        )
    }
    val requestId = ClarificationRequestId(UUID.randomUUID().toString())
    val deferred = CompletableDeferred<List<ClarificationAnswer>>()
    pendingClarifications[requestId] = deferred
    emit(ctx.sessionId, OrchestrationPausedEvent(ctx.sessionId, stageId, "CLARIFICATION_PENDING"))
    emit(
        ctx.sessionId,
        ClarificationRequestedEvent(
            requestId = requestId,
            sessionId = ctx.sessionId,
            stageId = stageId,
            questions = listOf(
                ClarificationQuestion(
                    id = "build_prerequisite",
                    prompt = "Stage ${stageId.value} is blocked on a missing build prerequisite " +
                        "'$path' that is outside its declared scope. Should this stage create it, or " +
                        "does another stage own project setup?",
                    header = "Missing setup",
                ),
            ),
        ),
    )
    return try {
        val answers = deferred.await()
        emit(ctx.sessionId, ClarificationAnsweredEvent(requestId, ctx.sessionId, stageId, answers))
        emit(ctx.sessionId, OrchestrationResumedEvent(ctx.sessionId, stageId))
        PrerequisiteOutcome.Bootstrap
    } finally {
        pendingClarifications.remove(requestId)
    }
}

private fun isBuildCriticalPath(path: String): Boolean {
    val name = path.substringAfterLast('/').lowercase()
    return name in BUILD_PREREQUISITE_FILENAMES
}

// At most one dedicated bootstrap turn per stage (design #170 acceptance: "at most one").
private const val BOOTSTRAP_BUDGET = 1
private val PREREQUISITE_PATH_RE = Regex("""missing build prerequisite '([^']+)'""")
private const val REFERENCE_EXISTS_REPEAT_LIMIT = 3
private const val FAILURE_SIGNATURE_PREFIX = 80
private val BUILD_PREREQUISITE_FILENAMES = setOf(
    "package.json", "tsconfig.json", "vite.config.ts", "build.gradle", "build.gradle.kts", "pom.xml",
)
