package com.correx.core.kernel.orchestration

import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.WriteScopeGrantedEvent
import com.correx.core.events.risk.RiskAction
import com.correx.core.toolintent.SessionContextProjection
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.sessions.projections.EgressAllowlistProjection
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.transitions.graph.StageConfig
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*

internal fun SessionOrchestrator.resolveSessionEgressHosts(sessionId: SessionId): Set<String> {
    val projection = EgressAllowlistProjection(sessionId)
    return eventStore.read(sessionId)
        .fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
}

/**
 * Folds this session's tool events through [SessionContextProjection] into the read-model the
 * anti-hallucination gates consume (reads, writes, active task). Replay-safe (reads the log,
 * never live state); empty before anything has happened.
 */

internal fun SessionOrchestrator.resolveSessionContext(sessionId: SessionId): com.correx.core.toolintent.SessionContext {
    val projection = SessionContextProjection(sessionId)
    return eventStore.read(sessionId)
        .fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
}

/**
 * The out-of-workspace path a read tool targets, or null if the read stays inside the workspace
 * (or there's no workspace policy / path arg). Resolution is purely lexical (absolute-normalize,
 * or resolve-against-root for relatives) so the containment decision is deterministic and never
 * touches the filesystem. Symlink escapes and privileged locations are caught upstream by the
 * plane-2 PathContainmentRule (BLOCK before any prompt), and by the tool's own realize() jail.
 */

internal fun SessionOrchestrator.outsideWorkspaceTarget(parameters: Map<String, Any>, workspaceRoot: Path?): Path? {
    workspaceRoot ?: return null
    val raw = parameters["path"] as? String ?: return null
    val candidate = runCatching { Path.of(raw) }.getOrNull() ?: return null
    val root = workspaceRoot.toAbsolutePath().normalize()
    val resolved = (if (candidate.isAbsolute) candidate else root.resolve(candidate)).normalize()
    return resolved.takeUnless { it.startsWith(root) }
}

/**
 * Paths outside the workspace the operator has approved this session (folded from
 * [OutsidePathAccessGrantedEvent]). Replay-safe: reads the log, never re-prompts or re-probes.
 */

internal fun SessionOrchestrator.grantedOutsidePaths(sessionId: SessionId): Set<String> =
    eventStore.read(sessionId)
        .mapNotNull { it.payload as? OutsidePathAccessGrantedEvent }
        .filter { it.sessionId == sessionId }
        .map { it.path }
        .toSet()

/**
 * Write-scope/manifest paths the operator has approved widening into this session (folded from
 * [WriteScopeGrantedEvent] — see #301). Replay-safe: reads the log, never re-prompts a path once
 * granted.
 */

internal fun SessionOrchestrator.escalatedWriteScopePaths(sessionId: SessionId): Set<String> =
    eventStore.read(sessionId)
        .mapNotNull { it.payload as? WriteScopeGrantedEvent }
        .filter { it.sessionId == sessionId }
        .map { it.path }
        .toSet()

/** The rule codes whose BLOCK rationale (`"[CODE] message"`, see [toRiskSummary]) makes a
 * rejection eligible for the #301 same-path escalation — a write rejected purely for being
 * outside the claimed task's scope or the stage's manifest, not for any other reason (path
 * traversal, privileged location, etc. are never escalated). */
private val SCOPE_BLOCK_CODES = setOf("WRITE_SCOPE", "PATH_OUTSIDE_MANIFEST")

internal fun SessionOrchestrator.isScopeBlockRationale(rationale: String): Boolean =
    SCOPE_BLOCK_CODES.any { rationale.contains("[$it]") }

/**
 * Every (first-attempt-invocation, rejection) pair this session where [path] was rejected for a
 * WRITE_SCOPE/PATH_OUTSIDE_MANIFEST reason, oldest first. Reached back into purely by folding the
 * event log — no branching/replay-engine change needed (#301). The invocation carries the
 * pristine [ToolRequest] parameters from that attempt, which may differ from the model's current
 * (possibly degraded) call.
 */

internal fun SessionOrchestrator.priorScopeRejections(
    sessionId: SessionId,
    path: String,
): List<Pair<ToolInvocationRequestedEvent, ToolExecutionRejectedEvent>> {
    val events = eventStore.read(sessionId)
    val invocationsById = events
        .mapNotNull { it.payload as? ToolInvocationRequestedEvent }
        .filter { it.sessionId == sessionId }
        .associateBy { it.invocationId }
    return events
        .mapNotNull { it.payload as? ToolExecutionRejectedEvent }
        .filter { it.sessionId == sessionId && isScopeBlockRationale(it.reason) }
        .mapNotNull { rejected ->
            val invocation = invocationsById[rejected.invocationId] ?: return@mapNotNull null
            val invocationPath = invocation.request.parameters["path"] as? String
            if (invocationPath == path) invocation to rejected else null
        }
}

/**
 * Returns true when the agent must be offered only read-only tools on the next inference turn.
 * Active from the moment a READ_BEFORE_WRITE block lands until a FILE_READ completion follows it.
 * Derived purely from the event log — no separate flag stored.
 */

internal fun SessionOrchestrator.isReadOnlyMode(sessionId: SessionId): Boolean {
    var blocked = false
    val pendingReadIds = mutableSetOf<String>()
    for (event in eventStore.read(sessionId)) {
        when (val p = event.payload) {
            is ToolInvocationRequestedEvent ->
                if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities)
                    pendingReadIds += p.invocationId.value
            is ToolCallAssessedEvent ->
                if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK) {
                    when {
                        p.issues.any { it.code == READ_BEFORE_WRITE_CODE } -> {
                            blocked = true
                            pendingReadIds.clear()
                        }
                        // A read BLOCKED because its target doesn't exist (REFERENCE_EXISTS) can never
                        // produce a completion, so it can't be what lifts read-only mode. Treat the
                        // attempt as satisfying read-before-write: the file is absent, so there is
                        // nothing to read before creating it. Without this, a stage that writes a NEW
                        // file deadlocks — writes are filtered out and every read of the not-yet-created
                        // target is blocked, burning MAX_TOOL_ROUNDS with no artifact.
                        p.issues.any { it.code == REFERENCE_EXISTS_CODE } -> {
                            blocked = false
                            pendingReadIds.clear()
                        }
                    }
                }
            is ToolExecutionCompletedEvent ->
                if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) {
                    blocked = false
                    pendingReadIds.clear()
                }
            else -> {}
        }
    }
    return blocked
}

/** True once any tool result in the session has spilled its full output to CAS (recorded as a
 *  non-null [ToolReceipt.fullOutputHash]). Until then the retrieval tool `tool_output` is withheld
 *  from stage tool lists — it can't retrieve anything before a spill, and an unusable hash-eating tool
 *  in every request just nudges models into reasoning about opaque hashes. */
internal fun SessionOrchestrator.sessionHasSpilledOutput(sessionId: SessionId): Boolean =
    eventStore.read(sessionId).any {
        (it.payload as? ToolExecutionCompletedEvent)?.receipt?.fullOutputHash != null
    }

internal fun SessionOrchestrator.mandateSuppressedByTicket(
    sessionId: SessionId,
    stageId: StageId,
    stageConfig: StageConfig,
): Boolean {
    if (stageConfig.metadata["role"] == "recovery") return false
    return eventStore.read(sessionId)
        .mapNotNull { it.payload as? TransitionExecutedEvent }
        .lastOrNull { it.to == stageId }
        ?.transitionId == TICKET_ROUTE
}

/**
 * Best real path in the repo map that shares [guessed]'s filename, ranked by shared trailing
 * path segments — so a wrong package prefix (e.g. `com/correx/server/...` for the real
 * `com/correx/apps/server/...`) still resolves. Null when nothing shares the filename, so we
 * only ever suggest a concrete, existing file. Reads the recorded map (replay-safe).
 */

internal fun SessionOrchestrator.suggestClosestPath(sessionId: SessionId, guessed: String): String? {
    val map = eventStore.read(sessionId)
        .mapNotNull { it.payload as? RepoMapComputedEvent }
        .lastOrNull() ?: return null
    return closestPathByFilename(guessed, map.entries.map { it.path })
}

/**
 * Resolves the actual prompt text a stage will see, for use as the repo-knowledge query.
 * `metadata["prompt"]` holds a *path* to the prompt file (resolved into context separately,
 * around line 431) — using that path string itself as the embedding query means the query the
 * retriever compares against is a filesystem path, not the stage's real instructions, so
 * similarity scores never reflect genuine relevance (2026-07-08 finding: no file scored a
 * clear match, everything clustered in the embedder's baseline noise band for short strings).
 */

internal suspend fun SessionOrchestrator.handleCancellation(
    sessionId: SessionId,
    stageId: StageId,
): WorkflowResult.Cancelled {
    log.warn("[Orchestrator] CANCELLED session={} stage={}", sessionId.value, stageId.value)
    emit(sessionId, WorkflowFailedEvent(sessionId, stageId, "CANCELLED", retryExhausted = false))
    cancellations.remove(sessionId)
    return WorkflowResult.Cancelled(sessionId)
}

// --- cancellation ---

internal fun SessionOrchestrator.isCancelled(sessionId: SessionId): Boolean =
    cancellations[sessionId]?.get() == true
