package com.correx.core.kernel.orchestration

import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.ProjectIdentity
import com.correx.core.approvals.Tier
import com.correx.core.approvals.isAtMost
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.artifacts.kind.FileWrittenArtifact
import com.correx.core.artifacts.kind.ProcessResultArtifact
import com.correx.core.artifacts.kind.TypedArtifactSlot
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.events.WriteScopeGrantedEvent
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.risk.RiskSummary
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.toAssessedIssues
import com.correx.core.toolintent.toRiskSummary
import com.correx.core.sessions.projections.EgressAllowlistProjection
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.inference.ToolCallRequest
import com.correx.core.sessions.ApprovalMode
import com.correx.core.tools.compression.ToolOutputContext
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolResult
import com.correx.core.transitions.graph.StageConfig
import kotlinx.coroutines.CompletableDeferred
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.nio.file.Files
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.stripCodeFence(text: String): String {
    val trimmed = text.trim()
    if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return text
    val withoutClose = trimmed.removeSuffix("```").trimEnd()
    val firstNewline = withoutClose.indexOf('\n')
    if (firstNewline < 0) return text
    // Drop the opening fence line (``` optionally followed by a language tag).
    return withoutClose.substring(firstNewline + 1).trim()
}

// On a recoverable tool failure, append the tool's argument schema to the error fed back to the
// model so it can self-correct its next attempt (it already sees its own malformed call in the
// assistant entry above) rather than repeating the same mistake. Kept compact to bound context.
// NOTE: the "corrected arguments" phrasing was removed because it actively drives loops: a model
// that called list_dir frontend/ and got "directory not found" interprets "re-issue with corrected
// arguments" as "retry the same call" rather than "try a different path" — the arguments were
// valid, the path simply doesn't exist. The schema reference stays so malformed calls can still
// self-correct; the model must infer the tactic from the tool's error text.
internal fun SessionOrchestrator.toolArgsHint(tool: Tool?): String =
    tool?.let {
        "\nAccepted parameters for '${it.name}': ${it.parametersSchema}"
    }.orEmpty()

/**
 * Render a tool result into the consistently-framed, bounded text the model sees. Success output
 * is run through the tool's [ToolOutputCompressor], then framed with a uniform `[tool exit=N]`
 * header. If it still exceeds [TOOL_RESULT_MAX_CHARS] the FULL raw output is spilled to the
 * artifact store (CAS — durable, its hash recorded on the ToolReceipt in the event log) and only a
 * head+tail preview is shown, with a marker telling the model to call `tool_output(ref=…)` for the
 * rest. Failures keep their `ERROR:`/`FATAL:` sentinels unchanged — the all-rejected loop breaker
 * keys on those prefixes.
 */

internal suspend fun SessionOrchestrator.renderToolResult(toolName: String, tool: Tool?, result: ToolResult): RenderedToolResult =
    when (result) {
        is ToolResult.Failure -> RenderedToolResult(
            if (!result.recoverable) {
                "FATAL: ${result.reason}"
            } else {
                "ERROR: ${result.reason}${toolArgsHint(tool)}${packageNotFoundAdvisory(result.reason)}"
            },
            null,
        )
        is ToolResult.Success -> {
            val compressed = tool?.outputCompressor?.compress(result.output, ToolOutputContext(result.exitCode))
                ?: result.output
            val header = "[$toolName exit=${result.exitCode}]"
            // A package-install 404 usually surfaces as a nonzero-exit Success (the shell ran; the
            // installer failed). Parse it deterministically and name the offending manifest entry so
            // the model edits the manifest instead of thrashing the installer (Vikunja #192).
            val advisory = if (result.exitCode != 0) packageNotFoundAdvisory(result.output) else ""
            if (compressed.length + advisory.length <= TOOL_RESULT_MAX_CHARS) {
                RenderedToolResult("$header\n$compressed$advisory", null)
            } else {
                // Spill the full RAW output (most complete) so retrieval returns everything, not the
                // already-compressed preview the model saw.
                val ref = artifactStore.put(result.output.toByteArray()).value
                RenderedToolResult(frameTruncatedToolResult(header, compressed, ref) + advisory, ref)
            }
        }
    }

internal suspend fun SessionOrchestrator.dispatchToolCalls(
    sessionId: SessionId,
    stageId: StageId,
    toolCalls: List<ToolCallRequest>,
    stageConfig: StageConfig,
    effectives: RunEffectives,
    approvalMode: ApprovalMode,
    // The reasoning that preceded this whole batch of tool calls (they all came from one
    // inference response). Attached only to the first call's assistant entry — one thought
    // block per model turn, matching how the response actually arrived.
    reasoning: String? = null,
): List<ContextEntry> {
    val executor = effectives.executor ?: return emptyList()
    val processResultSlots = stageConfig.produces.filter { it.kind.id == "process_result" }
    val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
    return toolCalls.withIndex().flatMap { (toolCallIndex, toolCall) ->
        // Only the first call's assistant entry carries the reasoning — the whole batch came
        // from one model turn/one thought block, so later calls in the same batch get none.
        val toolCallReasoning = reasoning.takeIf { toolCallIndex == 0 }
        val invocationId = ToolInvocationId(UUID.randomUUID().toString())
        val parameters = parseToolArguments(toolCall.function.arguments)
        val request = ToolRequest(
            invocationId = invocationId,
            sessionId = sessionId,
            stageId = stageId,
            toolName = toolCall.function.name,
            parameters = parameters,
        )
        val tool = effectives.registry?.resolve(toolCall.function.name)
        val tier = tool?.tier ?: Tier.T2
        // Out-of-workspace read access: a read whose target lexically resolves outside the
        // workspace root. The intent plane raises PROMPT_USER for it; once the operator approves
        // (recorded as OutsidePathAccessGrantedEvent), the same path this session skips the prompt
        // and the tool jail is widened for it. Writes never take this path (isRead gate).
        val isRead = tool?.requiredCapabilities?.contains(ToolCapability.FILE_READ) == true &&
            tool.requiredCapabilities.contains(ToolCapability.FILE_WRITE) != true
        val outsideReadTarget = if (isRead) {
            outsideWorkspaceTarget(parameters, effectives.policy?.workspaceRoot)
        } else {
            null
        }
        val grantedOutside = grantedOutsidePaths(sessionId)
        val alreadyGranted = outsideReadTarget != null && outsideReadTarget.toString() in grantedOutside
        emit(
            sessionId,
            ToolInvocationRequestedEvent(
                invocationId = invocationId,
                sessionId = sessionId,
                stageId = stageId,
                toolName = toolCall.function.name,
                tier = tier,
                request = request,
                capabilities = tool?.requiredCapabilities ?: emptySet(),
            ),
        )
        val deniedByStage = toolCall.function.name != STAGE_COMPLETE_TOOL &&
            toolCall.function.name !in stageConfig.effectiveAllowedTools
        val deniedByReadOnly = !deniedByStage && isReadOnlyMode(sessionId) &&
            (tool?.requiredCapabilities?.contains(ToolCapability.FILE_WRITE) == true)
        if (deniedByStage || deniedByReadOnly) {
            val denyReason = if (deniedByReadOnly) {
                "read-before-write: '${toolCall.function.name}' requires reading the file first — use file_read"
            } else {
                "tool '${toolCall.function.name}' is not permitted in stage '${stageId.value}'"
            }
            emit(
                sessionId,
                ToolExecutionRejectedEvent(
                    invocationId = invocationId,
                    sessionId = sessionId,
                    toolName = toolCall.function.name,
                    tier = tier,
                    reason = denyReason,
                ),
            )
            val sourceId = toolCall.id ?: invocationId.value
            return@flatMap listOf(
                ContextEntry(
                    id = ContextEntryId(UUID.randomUUID().toString()),
                    layer = ContextLayer.L2,
                    sourceType = "assistantToolCall",
                    sourceId = sourceId,
                    content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
                    tokenEstimate = estimateTokens(toolCall.function.arguments),
                    role = EntryRole.ASSISTANT,
                    reasoning = toolCallReasoning,
                ),
                ContextEntry(
                    id = ContextEntryId(UUID.randomUUID().toString()),
                    layer = ContextLayer.L2,
                    sourceType = "toolResult",
                    sourceId = sourceId,
                    content = "ERROR: $denyReason",
                    tokenEstimate = estimateTokens(denyReason),
                    role = EntryRole.TOOL,
                ),
            )
        }
        // Per-task write scope: while a task is claimed, narrow the manifest to its affected
        // paths (recorded on the task) so the implementer can't write outside its unit of work.
        // Falls back to the stage's static manifest when nothing is claimed.
        val escalatedScope = escalatedWriteScopePaths(sessionId)
        val effectiveManifest = (
            taskClaimCoordinator?.activeScope(sessionId)?.takeIf { it.isNotEmpty() }
                ?: stageConfig.writeManifest
            ) + escalatedScope
        val plane2Risk: RiskSummary? = runPlane2Assessment(
            sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives,
            effectiveManifest,
        )?.let { assessment ->
            if (assessment.recommendedAction == RiskAction.BLOCK) {
                val rationale = assessment.rationale.joinToString("; ")
                // #301: a write repeatedly rejected for being outside the claimed task's scope or
                // the stage's manifest — never for anything else — escalates to user approval
                // after N same-path rejections instead of hard-blocking forever. Small models
                // routinely fail to comply with the "add its path via task_update" remediation and
                // thrash the same call rather than widen scope themselves.
                val escalationPath = (parameters["path"] as? String)
                    ?.takeIf { isScopeBlockRationale(rationale) }
                val escalateAfterN = tuning.escalateScopeAfterN
                if (escalationPath != null && escalateAfterN > 0) {
                    val priorRejections = priorScopeRejections(sessionId, escalationPath)
                    if (priorRejections.size >= escalateAfterN) {
                        val firstAttempt = priorRejections.first().first
                        return@flatMap escalateWriteScopeBlock(
                            sessionId, stageId, invocationId, toolCall, tier, escalationPath,
                            firstAttempt, effectives, toolCallReasoning, approvalMode, assessment,
                            fileWrittenSlots,
                        )
                    }
                }
                // On a bad-path block, point the model at the closest real file so it fixes the
                // path instead of retrying the same wrong guess (deep package paths are easy to
                // misremember). Only fires when we can name a concrete match from the repo map.
                val suggestion = if (assessment.rationale.any { it.contains(REFERENCE_EXISTS_CODE) }) {
                    (parameters["path"] as? String)
                        ?.let { suggestClosestPath(sessionId, it) }
                        ?.let { " Did you mean: $it ?" }
                        .orEmpty()
                } else {
                    ""
                }
                emit(
                    sessionId,
                    ToolExecutionRejectedEvent(
                        invocationId = invocationId,
                        sessionId = sessionId,
                        toolName = toolCall.function.name,
                        tier = tier,
                        // Record the specific rule rationale, not a generic string, so the audit
                        // trail (and task history) shows why a call was blocked.
                        reason = rationale.ifBlank { "blocked by tool-call policy" },
                    ),
                )
                val sourceId = toolCall.id ?: invocationId.value
                return@flatMap listOf(
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L2,
                        sourceType = "assistantToolCall",
                        sourceId = sourceId,
                        content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
                        tokenEstimate = estimateTokens(toolCall.function.arguments),
                        role = EntryRole.ASSISTANT,
                        reasoning = toolCallReasoning,
                    ),
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L2,
                        sourceType = "toolResult",
                        sourceId = sourceId,
                        content = "BLOCKED: $rationale$suggestion",
                        tokenEstimate = estimateTokens(rationale + suggestion),
                        role = EntryRole.TOOL,
                    ),
                )
            }
            assessment
        }
        val plane2Prompts = plane2Risk?.recommendedAction == RiskAction.PROMPT_USER
        // A steering note attached to a human approval is captured here and injected after the
        // tool result, so the same-stage loop re-infers with it and the model acts on the note.
        var approvalNote: String? = null
        if ((tier.isAtMost(Tier.T1) && !plane2Prompts) || alreadyGranted) {
            // no approval needed — either within the auto-approve tier, or this out-of-workspace
            // read path was already approved earlier this session (this-path-this-session).
        } else {
            // Grants in effect = this session's own (SESSION/STAGE) unioned with the
            // cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound
            // workspace root so PROJECT grants match later sessions on the same repo.
            val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values
            val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
            val activeGrants = (sessionGrants + ledgerGrants).toList()
            val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
            val approvalCtx = ApprovalContext(
                identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
                mode = approvalMode,
            )
            val requestId = ApprovalRequestId(UUID.randomUUID().toString())
            val toolPreview = computeToolPreview(
                toolCall.function.name, parameters, effectives.policy?.workspaceRoot,
            )
            val domainRequest = DomainApprovalRequest(
                id = requestId,
                tier = tier,
                validationReportId = ValidationReportId(UUID.randomUUID().toString()),
                riskSummaryId = null,
                timestamp = Clock.System.now(),
                toolName = toolCall.function.name,
                preview = toolPreview ?: toolCall.function.arguments.take(200),
            )
            val engineDecision = approvalEngine.evaluate(
                domainRequest, approvalCtx, activeGrants, Clock.System.now(),
            )
            if (engineDecision.state == ApprovalStatus.COMPLETED) {
                emitDecisionResolved(sessionId, domainRequest, engineDecision)
                if (!engineDecision.isApproved) {
                    val rejectReason = engineDecision.reason ?: "denied"
                    blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
                    emit(sessionId, ToolExecutionRejectedEvent(
                        invocationId = invocationId,
                        sessionId = sessionId,
                        toolName = toolCall.function.name,
                        tier = tier,
                        reason = rejectReason,
                    ))
                    val sourceId = toolCall.id ?: invocationId.value
                    return@flatMap listOf(
                        ContextEntry(
                            id = ContextEntryId(UUID.randomUUID().toString()),
                            layer = ContextLayer.L2,
                            sourceType = "assistantToolCall",
                            sourceId = sourceId,
                            content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
                            tokenEstimate = estimateTokens(toolCall.function.arguments),
                            role = EntryRole.ASSISTANT,
                            reasoning = toolCallReasoning,
                        ),
                        ContextEntry(
                            id = ContextEntryId(UUID.randomUUID().toString()),
                            layer = ContextLayer.L2,
                            sourceType = "toolResult",
                            sourceId = sourceId,
                            content = "ERROR: $rejectReason",
                            tokenEstimate = estimateTokens(rejectReason),
                            role = EntryRole.TOOL,
                        ),
                    )
                }
                // grant auto-approved — fall through to execute
            } else {
                val deferred = CompletableDeferred<ApprovalDecision>()
                pendingApprovals[requestId] = deferred
                emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
                emit(
                    sessionId,
                    ApprovalRequestedEvent(
                        requestId = requestId,
                        tier = tier,
                        validationReportId = domainRequest.validationReportId,
                        riskSummaryId = null,
                        riskSummary = plane2Risk,
                        sessionId = sessionId,
                        stageId = stageId,
                        projectId = null,
                        toolName = toolCall.function.name,
                        preview = toolPreview ?: toolCall.function.arguments.take(200),
                    ),
                )
                val userDecision = try {
                    deferred.await()
                } finally {
                    pendingApprovals.remove(requestId)
                }
                emitDecisionResolved(sessionId, domainRequest, userDecision)
                if (!userDecision.isApproved) {
                    val rejectReason = userDecision.reason ?: "approval denied"
                    blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
                    emit(sessionId, ToolExecutionRejectedEvent(
                        invocationId = invocationId,
                        sessionId = sessionId,
                        toolName = toolCall.function.name,
                        tier = tier,
                        reason = rejectReason,
                    ))
                    emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
                    val sourceId = toolCall.id ?: invocationId.value
                    return@flatMap listOf(
                        ContextEntry(
                            id = ContextEntryId(UUID.randomUUID().toString()),
                            layer = ContextLayer.L2,
                            sourceType = "assistantToolCall",
                            sourceId = sourceId,
                            content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
                            tokenEstimate = estimateTokens(toolCall.function.arguments),
                            role = EntryRole.ASSISTANT,
                            reasoning = toolCallReasoning,
                        ),
                        ContextEntry(
                            id = ContextEntryId(UUID.randomUUID().toString()),
                            layer = ContextLayer.L2,
                            sourceType = "toolResult",
                            sourceId = sourceId,
                            content = "ERROR: $rejectReason",
                            tokenEstimate = estimateTokens(rejectReason),
                            role = EntryRole.TOOL,
                        ),
                    )
                }
                approvalNote = userDecision.userSteering?.text
                emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
            }
        }
        // Reaching here means the call is authorized (all deny/block/pending paths returned early).
        // For a first-time out-of-workspace read, record the grant so future reads of the same path
        // this session skip the prompt (invariants #8/#9 — replay reads the recorded fact). Then
        // widen the executed request's jail with every path approved outside the workspace.
        if (outsideReadTarget != null && !alreadyGranted) {
            emit(
                sessionId,
                OutsidePathAccessGrantedEvent(
                    sessionId = sessionId,
                    stageId = stageId,
                    path = outsideReadTarget.toString(),
                ),
            )
        }
        val executedRequest = outsideReadTarget
            ?.let { request.copy(grantedPaths = grantedOutside + it.toString()) }
            ?: request
        val result = executor.execute(executedRequest)
        // Frame + bound the result once; on truncation this spills the full output to CAS and
        // returns its hash, which we record on the receipt so the event log points at the full text.
        val rendered = renderToolResult(toolCall.function.name, tool, result)

        // Store ProcessResult artifact for every shell execution outcome
        for (slot in processResultSlots) {
            val processResult = when (result) {
                is ToolResult.Success -> ProcessResultArtifact(
                    status = "success",
                    command = toolCall.function.arguments,
                    exitCode = result.exitCode,
                    stdout = result.output,
                    stderr = result.metadata["stderr"] ?: "",
                    durationMs = 0,
                )
                is ToolResult.Failure -> ProcessResultArtifact(
                    status = "failed",
                    command = toolCall.function.arguments,
                    exitCode = -1,
                    stdout = "",
                    stderr = result.reason,
                    durationMs = 0,
                )
            }
            val jsonContent = Json.encodeToString(ProcessResultArtifact.serializer(), processResult)
            val storedHash = artifactStore.put(jsonContent.toByteArray())
            artifactContentCache["${sessionId.value}:${slot.name.value}"] = jsonContent
            emit(
                sessionId,
                ArtifactContentStoredEvent(
                    artifactId = slot.name,
                    contentHash = storedHash,
                    sessionId = sessionId,
                    stageId = stageId,
                ),
            )
        }

        // Record the tool execution outcome as events (invariant #5: every side effect is
        // captured; silent execution is not allowed). For file-mutating tools, materialise a
        // real file_written artifact from the actual on-disk result — so a stage that did NOT
        // write cannot have its file_written slot rubber-stamped as validated (F-015), and the
        // artifact carries the diff as evidence of the change.
        recordToolExecution(
            sessionId, stageId, toolCall, invocationId, tier, result,
            tool as? FileAffectingTool, request, fileWrittenSlots, rendered.fullOutputHash,
        )

        val sourceId = toolCall.id ?: invocationId.value
        val assistantEntry = ContextEntry(
            id = ContextEntryId(UUID.randomUUID().toString()),
            layer = ContextLayer.L2,
            sourceType = "assistantToolCall",
            sourceId = sourceId,
            content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
            tokenEstimate = estimateTokens(toolCall.function.arguments),
            role = EntryRole.ASSISTANT,
            reasoning = toolCallReasoning,
        )
        val resultEntry = ContextEntry(
            id = ContextEntryId(UUID.randomUUID().toString()),
            layer = ContextLayer.L2,
            sourceType = "toolResult",
            sourceId = sourceId,
            content = rendered.content,
            tokenEstimate = estimateTokens(rendered.content),
            role = EntryRole.TOOL,
        )
        val steeringEntry = approvalNote?.takeIf { it.isNotBlank() }?.let {
            ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L2,
                sourceType = "steeringNote",
                sourceId = sourceId,
                content = "User steering on the approved '${toolCall.function.name}' call: $it",
                tokenEstimate = estimateTokens(it),
                role = EntryRole.USER,
            )
        }
        listOfNotNull(assistantEntry, resultEntry, steeringEntry)
    }
}

private fun parametersToArgumentsJson(parameters: Map<String, Any>): String =
    kotlinx.serialization.json.buildJsonObject {
        parameters.forEach { (k, v) ->
            when (v) {
                is List<*> -> put(
                    k,
                    kotlinx.serialization.json.buildJsonArray {
                        v.forEach { add(kotlinx.serialization.json.JsonPrimitive(it.toString())) }
                    },
                )
                else -> put(k, kotlinx.serialization.json.JsonPrimitive(v.toString()))
            }
        }
    }.toString()

/**
 * #301: the Nth same-path WRITE_SCOPE/PATH_OUTSIDE_MANIFEST rejection routes into the existing
 * approval/pause flow instead of hard-blocking again. [firstAttempt] is the FIRST invocation that
 * was rejected for this path/reason (reached back into via [priorScopeRejections]) — its pristine
 * arguments are what gets previewed and, on approval, executed; later attempts this session may
 * have degraded (e.g. the model giving up and calling `task_update(action="block")` instead), so
 * replaying those would not fulfil the original intent.
 */
internal suspend fun SessionOrchestrator.escalateWriteScopeBlock(
    sessionId: SessionId,
    stageId: StageId,
    invocationId: ToolInvocationId,
    toolCall: ToolCallRequest,
    tier: Tier,
    path: String,
    firstAttempt: ToolInvocationRequestedEvent,
    effectives: RunEffectives,
    toolCallReasoning: String?,
    approvalMode: ApprovalMode,
    plane2Risk: RiskSummary?,
    fileWrittenSlots: List<TypedArtifactSlot>,
): List<ContextEntry> {
    val sourceId = toolCall.id ?: invocationId.value
    val assistantEntry = ContextEntry(
        id = ContextEntryId(UUID.randomUUID().toString()),
        layer = ContextLayer.L2,
        sourceType = "assistantToolCall",
        sourceId = sourceId,
        content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
        tokenEstimate = estimateTokens(toolCall.function.arguments),
        role = EntryRole.ASSISTANT,
        reasoning = toolCallReasoning,
    )
    suspend fun rejected(reason: String): List<ContextEntry> {
        blockTaskOnScopeRejection(sessionId, toolCall.function.name, reason)
        emit(
            sessionId,
            ToolExecutionRejectedEvent(
                invocationId = invocationId,
                sessionId = sessionId,
                toolName = toolCall.function.name,
                tier = tier,
                reason = reason,
            ),
        )
        return listOf(
            assistantEntry,
            ContextEntry(
                id = ContextEntryId(UUID.randomUUID().toString()),
                layer = ContextLayer.L2,
                sourceType = "toolResult",
                sourceId = sourceId,
                content = "BLOCKED: $reason",
                tokenEstimate = estimateTokens(reason),
                role = EntryRole.TOOL,
            ),
        )
    }

    val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
    val approvalCtx = ApprovalContext(
        identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
        mode = approvalMode,
    )
    val requestId = ApprovalRequestId(UUID.randomUUID().toString())
    val toolPreview = computeToolPreview(
        firstAttempt.toolName, firstAttempt.request.parameters, effectives.policy?.workspaceRoot,
    )
    val previewArguments = parametersToArgumentsJson(firstAttempt.request.parameters)
    val domainRequest = DomainApprovalRequest(
        id = requestId,
        tier = firstAttempt.tier,
        validationReportId = ValidationReportId(UUID.randomUUID().toString()),
        riskSummaryId = null,
        timestamp = Clock.System.now(),
        toolName = firstAttempt.toolName,
        preview = toolPreview ?: previewArguments.take(200),
    )
    val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values
    val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
    val activeGrants = (sessionGrants + ledgerGrants).toList()
    val engineDecision = approvalEngine.evaluate(domainRequest, approvalCtx, activeGrants, Clock.System.now())
    val approved: Boolean
    val denyReason: String?
    if (engineDecision.state == ApprovalStatus.COMPLETED) {
        // Headless/no-approver sessions resolve here (e.g. approvalMode DENY auto-completes to a
        // denial) — the escalation never hangs waiting on a human who isn't connected.
        emitDecisionResolved(sessionId, domainRequest, engineDecision)
        approved = engineDecision.isApproved
        denyReason = engineDecision.reason
    } else {
        val deferred = CompletableDeferred<ApprovalDecision>()
        pendingApprovals[requestId] = deferred
        emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
        emit(
            sessionId,
            ApprovalRequestedEvent(
                requestId = requestId,
                tier = firstAttempt.tier,
                validationReportId = domainRequest.validationReportId,
                riskSummaryId = null,
                riskSummary = plane2Risk,
                sessionId = sessionId,
                stageId = stageId,
                projectId = null,
                toolName = firstAttempt.toolName,
                preview = toolPreview ?: previewArguments.take(200),
            ),
        )
        val userDecision = try {
            deferred.await()
        } finally {
            pendingApprovals.remove(requestId)
        }
        emitDecisionResolved(sessionId, domainRequest, userDecision)
        emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
        approved = userDecision.isApproved
        denyReason = userDecision.reason
    }
    if (!approved) {
        return rejected(denyReason ?: "scope-widening request denied")
    }

    // Approved: widen the write scope for this path for the rest of the session (future writes to
    // it skip straight past the risk plane, mirroring OutsidePathAccessGrantedEvent), then execute
    // the FIRST rejected attempt's pristine write rather than the model's current — possibly
    // degraded — call.
    emit(sessionId, WriteScopeGrantedEvent(sessionId, stageId, path))
    val executor = effectives.executor ?: return rejected("no executor available to apply the approved write")
    val tool = effectives.registry?.resolve(firstAttempt.toolName)
    val result = executor.execute(firstAttempt.request)
    val rendered = renderToolResult(firstAttempt.toolName, tool, result)
    recordToolExecution(
        sessionId,
        stageId,
        toolCall.copy(function = toolCall.function.copy(name = firstAttempt.toolName)),
        invocationId,
        tier,
        result,
        tool as? FileAffectingTool,
        firstAttempt.request,
        fileWrittenSlots,
        rendered.fullOutputHash,
    )
    return listOf(
        assistantEntry,
        ContextEntry(
            id = ContextEntryId(UUID.randomUUID().toString()),
            layer = ContextLayer.L2,
            sourceType = "toolResult",
            sourceId = sourceId,
            content = "APPROVED: the user widened write scope to admit '$path' after repeated rejection; " +
                "the original write is applied. ${rendered.content}",
            tokenEstimate = estimateTokens(rendered.content),
            role = EntryRole.TOOL,
        ),
    )
}

/**
 * A rejected [SCOPE_PROPOSAL_TOOL] call means the operator denied widening the claimed task's
 * scope — block the task so the loop advances instead of the implementer retrying the same
 * out-of-scope write. Only fires for the scope-proposal tool; other denied tools are unaffected.
 */

internal suspend fun SessionOrchestrator.blockTaskOnScopeRejection(sessionId: SessionId, toolName: String, reason: String) {
    if (toolName == SCOPE_PROPOSAL_TOOL) {
        taskClaimCoordinator?.blockActiveTask(sessionId, "scope amendment rejected: $reason")
    }
}

internal suspend fun SessionOrchestrator.runPlane2Assessment(
    sessionId: SessionId,
    stageId: StageId,
    invocationId: ToolInvocationId,
    toolName: String,
    request: ToolRequest,
    tool: Tool?,
    effectives: RunEffectives,
    writeManifest: List<String>,
): RiskSummary? {
    val assessor = toolCallAssessor ?: return null
    val policy = effectives.policy ?: return null
    // Resolve the egress hosts granted to this session by folding its EgressHostsGrantedEvents
    // through the projection. Unioned with the static allow-list in NetworkHostRule; empty when
    // nothing has been granted, which never narrows the static allow-list.
    val sessionEgressHosts = resolveSessionEgressHosts(sessionId)
    val assessment = assessor.assess(
        ToolCallAssessmentInput(
            request = request,
            capabilities = tool?.requiredCapabilities ?: emptySet(),
            workspace = policy,
            probe = worldProbe,
            paramRoles = tool?.paramRoles ?: emptyMap(),
            writeManifest = writeManifest,
            sessionEgressHosts = sessionEgressHosts,
            session = resolveSessionContext(sessionId),
        ),
    )
    emit(
        sessionId,
        ToolCallAssessedEvent(
            invocationId = invocationId,
            sessionId = sessionId,
            stageId = stageId,
            toolName = toolName,
            issues = assessment.toAssessedIssues(),
            observations = assessment.observations,
            disposition = assessment.disposition,
            timestampMs = Clock.System.now().toEpochMilliseconds(),
        ),
    )
    return assessment.toRiskSummary()
}

/**
 * Folds this session's [com.correx.core.events.events.EgressHostsGrantedEvent]s through
 * [EgressAllowlistProjection] into the running union of hosts granted to it. Replay-safe (reads
 * the log, never live state); empty when no grants have been emitted.
 */

internal suspend fun SessionOrchestrator.emitToolArtifacts(
    sessionId: SessionId,
    stageId: StageId,
    stageConfig: StageConfig,
) {
    stageConfig.produces
        .filter { !it.kind.llmEmitted }
        .forEach { slot ->
            // A file_written slot must be backed by real content materialised from an actual
            // successful write (see recordToolExecution). If none happened, do NOT fabricate a
            // validated artifact (F-015) — leave the slot unproduced so verifyProduces fails the
            // stage and it retries, rather than reporting a green run that changed nothing.
            if (slot.kind.id == "file_written" &&
                artifactContentCache["${sessionId.value}:${slot.name.value}"] == null
            ) {
                return@forEach
            }
            emitAll(
                sessionId,
                listOf(
                    ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
                    ArtifactValidatingEvent(slot.name, sessionId, stageId),
                    ArtifactValidatedEvent(slot.name, sessionId, stageId),
                ),
            )
        }
}

internal suspend fun SessionOrchestrator.recordToolExecution(
    sessionId: SessionId,
    stageId: StageId,
    toolCall: ToolCallRequest,
    invocationId: ToolInvocationId,
    tier: Tier,
    result: ToolResult,
    fileTool: FileAffectingTool?,
    request: ToolRequest,
    fileWrittenSlots: List<TypedArtifactSlot>,
    fullOutputHash: String? = null,
) {
    // Invariant #5: every tool side effect is captured. This is the single authoritative
    // ToolExecutionCompleted/Failed record and the source the read-before-write gate replays.
    // FileWrittenEvent (with pre/post-image hashes for reversibility) and ToolExecutionStarted are
    // emitted once, by SandboxedToolExecutor — NOT here — so completions aren't duplicated in the log.
    when (result) {
        is ToolResult.Failure -> emit(
            sessionId,
            ToolExecutionFailedEvent(invocationId, sessionId, toolCall.function.name, result.reason),
        )

        is ToolResult.Success -> {
            val diff = result.metadata["diff"]?.takeIf { it.isNotBlank() }
            val affected = fileTool?.affectedPaths(request).orEmpty()
            // Read-only tools (file_read, list_dir) have no FileAffectingTool, so `affected`
            // is empty — fall back to the raw `path` argument so the receipt still names
            // the file/dir the tool looked at (surfaced in the TUI's output row).
            val affectedNames = affected.map { it.toString() }
                .ifEmpty { listOfNotNull(request.parameters["path"] as? String) }
            emit(
                sessionId,
                ToolExecutionCompletedEvent(
                    invocationId = invocationId,
                    sessionId = sessionId,
                    toolName = toolCall.function.name,
                    receipt = ToolReceipt(
                        invocationId = invocationId,
                        toolName = toolCall.function.name,
                        exitCode = result.exitCode,
                        outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
                        // Carry the tool's structured metadata (e.g. file_read's contentHash) so
                        // session projections can read it.
                        structuredOutput = result.metadata,
                        affectedEntities = affectedNames,
                        durationMs = 0,
                        tier = tier,
                        timestamp = Clock.System.now(),
                        diff = diff,
                        fullOutputHash = fullOutputHash,
                    ),
                ),
            )
            if (affected.isNotEmpty()) {
                materializeFileWritten(sessionId, stageId, request, affected, fileWrittenSlots, diff)
            }
        }
    }
}

/**
 * Materialise a [FileWrittenArtifact] from the actual on-disk file after a successful mutation,
 * store it as the stage's file_written slot content, and record [ArtifactContentStoredEvent].
 * This ties the file_written artifact to a real write so validation can no longer be faked.
 */

internal suspend fun SessionOrchestrator.materializeFileWritten(
    sessionId: SessionId,
    stageId: StageId,
    request: ToolRequest,
    affected: Set<java.nio.file.Path>,
    fileWrittenSlots: List<TypedArtifactSlot>,
    diff: String?,
) {
    if (fileWrittenSlots.isEmpty()) return
    val relPath = request.parameters["path"] as? String ?: return
    val onDisk = affected.firstOrNull() ?: Paths.get(relPath)
    val bytes = runCatching { Files.readAllBytes(onDisk) }.getOrNull() ?: return
    val contentHash = artifactStore.put(bytes)
    val artifact = FileWrittenArtifact(
        path = relPath,
        contentHash = contentHash.value,
        size = bytes.size.toLong(),
        mode = "0644",
        diff = diff,
    )
    val json = Json.encodeToString(FileWrittenArtifact.serializer(), artifact)
    val storedHash = artifactStore.put(json.toByteArray())
    fileWrittenSlots.forEach { slot ->
        artifactContentCache["${sessionId.value}:${slot.name.value}"] = json
        emit(
            sessionId,
            ArtifactContentStoredEvent(
                artifactId = slot.name,
                contentHash = storedHash,
                sessionId = sessionId,
                stageId = stageId,
            ),
        )
    }
}

internal suspend fun SessionOrchestrator.emitLlmArtifacts(
    sessionId: SessionId,
    stageId: StageId,
    stageConfig: StageConfig,
) {
    stageConfig.produces
        .filter { it.kind.llmEmitted }
        .forEach { slot ->
            // Same rule as the file_written gate (F-015): no stored content means the
            // model never produced this artifact — do not fabricate a validated one.
            val cached = artifactContentCache["${sessionId.value}:${slot.name.value}"]
            if (cached.isNullOrBlank()) {
                log.warn(
                    "[Orchestrator] stage={} slot={}: no content stored — skipping artifact emission",
                    stageId.value,
                    slot.name.value,
                )
                return@forEach
            }
            emitAll(
                sessionId,
                listOf(
                    ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
                    ArtifactValidatingEvent(slot.name, sessionId, stageId),
                    ArtifactValidatedEvent(slot.name, sessionId, stageId),
                ),
            )
        }
}
