import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.approvals.Tier
import com.correx.core.approvals.UserSteering
import com.correx.core.approvals.domain.DefaultApprovalEngine
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.sessions.ApprovalMode
import kotlinx.datetime.Clock
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
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.ToolRequest
import com.correx.core.events.events.WriteScopeGrantedEvent
import com.correx.core.toolintent.rules.ManifestContainmentRule
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.toolintent.rules.ReferenceExistsRule
import com.correx.core.tools.contract.ParamRole
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.InferenceState
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.Tokenizer
import com.correx.core.inference.ToolCallFunction
import com.correx.core.inference.ToolCallRequest
import com.correx.core.inference.TokenUsage
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.journal.DecisionJournalProjector
import com.correx.core.journal.DefaultDecisionJournalReducer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestrationTuning
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.toolintent.ToolCallAssessment
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallAssessor
import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
import com.correx.core.tools.compression.DeclarativeCompressor
import com.correx.core.tools.compression.OutputCompressionSpec
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
import com.correx.testing.fixtures.context.ContextFixtures
import com.correx.testing.fixtures.cyclePolicyMissingValidator
import com.correx.testing.fixtures.inference.MockTokenizer
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

@Suppress("LongMethod")
class ToolCallGateTest {

    private inner class FakeFileWriteTool(override val tier: Tier = Tier.T1) : Tool {
        override val name = "file_write"
        override val description = "fake file write"
        override val parametersSchema: JsonObject = buildJsonObject {}
        override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
        override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
    }

    /** A tool that declares an output compressor (strips blank lines) — the Tier-A seam under test. */
    private inner class FakeCompressingTool(override val tier: Tier = Tier.T1) : Tool {
        // Non-write tool: #289 elides successful write receipts before FORMAT_COMPRESS, so this
        // must be a generic tool to actually exercise the outputCompressor (blank-line stripping).
        override val name = "shell_run"
        override val description = "fake compressing tool"
        override val parametersSchema: JsonObject = buildJsonObject {}
        override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
        override val outputCompressor = DeclarativeCompressor(OutputCompressionSpec(listOf(StripBlankLines)))
        override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
    }

    private inner class FakeToolRegistry(private val tool: Tool) : ToolRegistry {
        override fun resolve(name: String): Tool? = if (name == tool.name) tool else null
        override fun all(): List<Tool> = listOf(tool)
    }

    private inner class RecordingExecutor : ToolExecutor {
        val executeCalled = AtomicBoolean(false)
        override suspend fun execute(request: ToolRequest): ToolResult {
            executeCalled.set(true)
            return ToolResult.Success(invocationId = request.invocationId, output = "ok", exitCode = 0)
        }
    }

    private inner class ToolCallingProvider(private val toolName: String) : InferenceProvider {
        override val id = ProviderId("tool-caller")
        override val name = "tool-caller"
        override val tokenizer: Tokenizer = MockTokenizer()
        val requests = mutableListOf<InferenceRequest>()
        private var callCount = 0

        override suspend fun infer(request: InferenceRequest): InferenceResponse {
            requests += request
            callCount++
            return if (callCount == 1) {
                InferenceResponse(
                    requestId = request.requestId,
                    text = "",
                    finishReason = FinishReason.ToolCall,
                    tokensUsed = TokenUsage(10, 5),
                    latencyMs = 0,
                    toolCalls = listOf(
                        ToolCallRequest(
                            id = "tc-1",
                            function = ToolCallFunction(name = toolName, arguments = "{}"),
                        ),
                    ),
                )
            } else {
                InferenceResponse(
                    requestId = request.requestId,
                    text = "done",
                    finishReason = FinishReason.Stop,
                    tokensUsed = TokenUsage(10, 5),
                    latencyMs = 0,
                )
            }
        }

        override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
        override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
    }

    private fun ruleReturning(action: RiskAction): ToolCallRule = object : ToolCallRule {
        override fun appliesTo(capabilities: Set<ToolCapability>) = true
        override fun assess(input: ToolCallAssessmentInput) = ToolCallAssessment(disposition = action)
    }

    private val workspace = Path.of("/work")

    private fun buildOrchestrator(
        executor: ToolExecutor,
        tool: Tool,
        assessorRule: ToolCallRule?,
    ): Triple<DefaultSessionOrchestrator, InMemoryEventStore, ToolCallingProvider> {
        val eventStore = InMemoryEventStore()
        val artifactStore = NoopArtifactStore()
        val toolRegistry = FakeToolRegistry(tool)

        val assessor = assessorRule?.let { ToolCallAssessor(listOf(it)) }
        val policy = if (assessor != null) WorkspacePolicy(workspace) else null

        val provider = ToolCallingProvider(tool.name)
        val inferenceRouter = object : InferenceRouter {
            override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider =
                provider
        }

        val approvalRepository = DefaultApprovalRepository(
            DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
        )

        val repositories = OrchestratorRepositories(
            eventStore = eventStore,
            inferenceRepository = InferenceRepository(object : EventReplayer<InferenceState> {
                override fun rebuild(sessionId: SessionId) = InferenceState()
            }),
            orchestrationRepository = OrchestrationRepository(
                DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
            ),
            sessionRepository = DefaultSessionRepository(
                DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
            ),
            artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
            approvalRepository = approvalRepository,
        )

        val engines = OrchestratorEngines(
            transitionResolver = DefaultTransitionResolver { _, _ -> true },
            contextPackBuilder = ContextFixtures.simpleBuilder(),
            inferenceRouter = inferenceRouter,
            validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
            approvalEngine = DefaultApprovalEngine(),
            riskAssessor = DefaultRiskAssessor(),
            toolExecutor = executor,
            toolRegistry = toolRegistry,
            toolCallAssessor = assessor,
            workspacePolicy = policy,
        )

        val decisionJournalRepository = DefaultDecisionJournalRepository(
            DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
        )
        val orchestrator = DefaultSessionOrchestrator(
            repositories = repositories,
            engines = engines,
            retryCoordinator = DefaultRetryCoordinator(eventStore),
            artifactStore = artifactStore,
            decisionJournalRepository = decisionJournalRepository,
        )

        return Triple(orchestrator, eventStore, provider)
    }

    private fun singleStageGraph(allowedTools: Set<String> = setOf("file_write")): WorkflowGraph =
        WorkflowGraph(
            id = "gate-test",
            stages = mapOf(
                StageId("A") to StageConfig(allowedTools = allowedTools),
            ),
            transitions = setOf(
                TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
            ),
            start = StageId("A"),
        )

    @Test
    fun `BLOCK rule prevents executor from being called and emits rejected event`(): Unit = runBlocking {
        val executor = RecordingExecutor()
        val (orchestrator, eventStore, _) = buildOrchestrator(executor, FakeFileWriteTool(), ruleReturning(RiskAction.BLOCK))
        val sessionId = SessionId("gate-block")
        val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))

        orchestrator.run(sessionId, singleStageGraph(), config)

        val events = eventStore.read(sessionId)
        assertTrue(!executor.executeCalled.get(), "executor must NOT be called when BLOCK")
        assertNotNull(events.find { it.payload is ToolCallAssessedEvent }, "ToolCallAssessedEvent must be emitted")
        assertNotNull(events.find { it.payload is ToolExecutionRejectedEvent }, "ToolExecutionRejectedEvent must be emitted")
    }

    @Test
    fun `PROMPT_USER rule on T2 tool triggers approval with plane2 risk summary`(): Unit = runBlocking {
        val executor = RecordingExecutor()
        // Use T2 so the engine does not auto-approve (PROMPT mode auto-approves up to T1 only)
        val (orchestrator, eventStore, _) = buildOrchestrator(
            executor, FakeFileWriteTool(Tier.T2), ruleReturning(RiskAction.PROMPT_USER),
        )
        val sessionId = SessionId("gate-prompt")
        val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))

        // Run in background; orchestrator will pause waiting for approval
        val job = launch { orchestrator.run(sessionId, singleStageGraph(), config) }

        // Wait for ApprovalRequestedEvent
        withTimeout(5_000) {
            while (eventStore.read(sessionId).none { it.payload is ApprovalRequestedEvent }) {
                yield()
            }
        }

        val approval = eventStore.read(sessionId).firstNotNullOfOrNull { it.payload as? ApprovalRequestedEvent }
        assertNotNull(approval, "ApprovalRequestedEvent must be emitted for PROMPT_USER plane-2 rule on T1 tool")
        assertEquals(RiskAction.PROMPT_USER, approval?.riskSummary?.recommendedAction)

        job.cancel()
        job.join()
    }

    @Test
    fun `null assessor means executor is called normally (regression guard)`(): Unit = runBlocking {
        val executor = RecordingExecutor()
        val (orchestrator, eventStore, _) = buildOrchestrator(executor, FakeFileWriteTool(), assessorRule = null)
        val sessionId = SessionId("gate-null")
        val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))

        orchestrator.run(sessionId, singleStageGraph(), config)

        val events = eventStore.read(sessionId)
        assertTrue(executor.executeCalled.get(), "executor must be called when assessor is null")
        assertNull(events.find { it.payload is ToolCallAssessedEvent }, "ToolCallAssessedEvent must NOT be emitted when assessor is null")
    }

    @Test
    fun `approving with a steering note injects it into the next inference context`(): Unit = runBlocking {
        val executor = RecordingExecutor()
        // T2 so PROMPT mode does not auto-approve — the orchestrator pauses for a human decision.
        val (orchestrator, eventStore, provider) = buildOrchestrator(
            executor, FakeFileWriteTool(Tier.T2), ruleReturning(RiskAction.PROMPT_USER),
        )
        val sessionId = SessionId("gate-steer")
        val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))

        val job = launch { orchestrator.run(sessionId, singleStageGraph(), config) }

        val request = withTimeout(5_000) {
            var req: ApprovalRequestedEvent? = null
            while (req == null) {
                req = eventStore.read(sessionId).firstNotNullOfOrNull { it.payload as? ApprovalRequestedEvent }
                if (req == null) yield()
            }
            req
        }

        val note = "add the current distro to the output"
        orchestrator.submitApprovalDecision(
            request.requestId,
            ApprovalDecision(
                id = null,
                requestId = request.requestId,
                outcome = ApprovalOutcome.APPROVED,
                state = ApprovalStatus.COMPLETED,
                tier = Tier.T2,
                contextSnapshot = ApprovalContext(
                    identity = ApprovalScopeIdentity(sessionId, StageId("A"), projectId = null),
                    mode = ApprovalMode.PROMPT,
                ),
                resolutionTimestamp = Clock.System.now(),
                reason = note,
                userSteering = UserSteering(text = note, sessionId = sessionId, timestamp = Clock.System.now()),
            ),
        )

        // The approved call executes, then the loop re-infers — that second request must carry the note.
        withTimeout(5_000) {
            while (provider.requests.size < 2) yield()
        }
        job.cancel()
        job.join()

        assertTrue(executor.executeCalled.get(), "the approved tool must still execute")
        val steeringEntry = provider.requests[1].contextPack.layers.values.flatten().firstOrNull {
            it.sourceType == "steeringNote" && it.content.contains(note)
        }
        assertNotNull(steeringEntry, "approved steering note must be injected into the next inference context")
    }

    @Test
    fun `tool output is compressed on the path into context`(): Unit = runBlocking {
        val rawOutput = "line1\n\nline2\n\n\nline3"
        val executor = object : ToolExecutor {
            override suspend fun execute(request: ToolRequest): ToolResult =
                ToolResult.Success(invocationId = request.invocationId, output = rawOutput, exitCode = 0)
        }
        val (orchestrator, _, provider) = buildOrchestrator(executor, FakeCompressingTool(), assessorRule = null)
        val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))

        orchestrator.run(SessionId("compress"), singleStageGraph(setOf("shell_run")), config)

        // The second inference carries round-1's tool result in its context pack — compressed
        // (blank lines stripped), not raw, under the uniform `[tool exit=N]` frame the model sees.
        // Raw output stays authoritative on the executor's event path (invariant #6); the compressor
        // only shapes the derived context entry.
        val secondPack = provider.requests[1].contextPack
        val toolEntry = secondPack.layers.values.flatten().first { it.sourceType == "toolResult" }
        assertEquals("[shell_run exit=0]\nline1\nline2\nline3", toolEntry.content)
    }

    @Test
    fun `tool not in allowedTools is not executed and emits ToolExecutionRejectedEvent`(): Unit = runBlocking {
        val executor = RecordingExecutor()
        // Stage only allows "allowed_tool"; the LLM returns a call to "file_write" (not in the set).
        val (orchestrator, eventStore, _) = buildOrchestrator(executor, FakeFileWriteTool(), assessorRule = null)
        val sessionId = SessionId("gate-stage-deny")
        val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
        val graph = singleStageGraph(allowedTools = setOf("allowed_tool"))

        orchestrator.run(sessionId, graph, config)

        val events = eventStore.read(sessionId)
        assertTrue(!executor.executeCalled.get(), "executor must NOT be called for a tool outside allowedTools")

        // Denial is recorded in the event log (invariant #5).
        val rejected = events.firstNotNullOfOrNull { it.payload as? ToolExecutionRejectedEvent }
        assertNotNull(rejected, "ToolExecutionRejectedEvent must be emitted when tool is not in allowedTools")
        assertEquals("file_write", rejected!!.toolName)
        assertTrue(
            rejected.reason.contains("not permitted"),
            "rejection reason should mention 'not permitted', got: ${rejected.reason}",
        )

        // ToolInvocationRequestedEvent is still emitted so the request itself is in the log.
        assertTrue(
            events.any { (it.payload as? ToolInvocationRequestedEvent)?.toolName == "file_write" },
            "ToolInvocationRequestedEvent must be emitted to record the denied request",
        )
    }

    /**
     * Read-before-write narrowing: after the READ_BEFORE_WRITE rule blocks a write, the next
     * inference turn must only offer read tools; once a file_read completes, write tools return.
     */
    @Test
    fun `read-before-write block narrows tools to read-only then restores writes after a read`(): Unit = runBlocking {
        val targetPath = "/work/Foo.kt"

        // Registry with both a file_write and a file_read tool, paramRoles declare the path param.
        val fileWriteTool = object : Tool {
            override val name = "file_write"
            override val description = "write"
            override val parametersSchema: JsonObject = buildJsonObject {}
            override val tier = Tier.T1
            override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
            override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
            override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
        }
        val fileReadTool = object : Tool {
            override val name = "file_read"
            override val description = "read"
            override val parametersSchema: JsonObject = buildJsonObject {}
            override val tier = Tier.T1
            override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
            override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
            override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
        }
        val twoToolRegistry = object : ToolRegistry {
            override fun resolve(name: String): Tool? = when (name) {
                "file_write" -> fileWriteTool
                "file_read" -> fileReadTool
                else -> null
            }
            override fun all(): List<Tool> = listOf(fileWriteTool, fileReadTool)
        }

        // Provider: turn 1 → write, turn 2 → read, turn 3 → write, turn 4 → stop.
        // Records the tool names offered on each inference request.
        val offeredToolsPerTurn = mutableListOf<Set<String>>()
        var turnCount = 0
        val provider = object : InferenceProvider {
            override val id = ProviderId("rbw-test")
            override val name = "rbw-test"
            override val tokenizer: Tokenizer = MockTokenizer()
            override suspend fun infer(request: InferenceRequest): InferenceResponse {
                offeredToolsPerTurn += request.tools.map { it.function.name }.toSet()
                turnCount++
                val toolCall = when (turnCount) {
                    1 -> ToolCallRequest(id = "tc-write-1", function = ToolCallFunction("file_write", """{"path":"$targetPath"}"""))
                    2 -> ToolCallRequest(id = "tc-read-1", function = ToolCallFunction("file_read", """{"path":"$targetPath"}"""))
                    3 -> ToolCallRequest(id = "tc-write-2", function = ToolCallFunction("file_write", """{"path":"$targetPath"}"""))
                    else -> null
                }
                return if (toolCall != null) {
                    InferenceResponse(request.requestId, "", FinishReason.ToolCall, TokenUsage(1, 1), 0, listOf(toolCall))
                } else {
                    InferenceResponse(request.requestId, "done", FinishReason.Stop, TokenUsage(1, 1), 0)
                }
            }
            override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
            override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
        }

        // WorldProbe: the target file always exists (triggers the rule) and resolves to itself.
        val fakeProbe = object : com.correx.core.toolintent.WorldProbe {
            override fun exists(path: java.nio.file.Path): Boolean = true
            override fun resolveReal(path: java.nio.file.Path): java.nio.file.Path = path.toAbsolutePath().normalize()
        }

        val eventStore = InMemoryEventStore()
        val artifactStore = NoopArtifactStore()
        val assessor = ToolCallAssessor(listOf(ReadBeforeWriteRule()))
        val policy = WorkspacePolicy(workspace)

        val repositories = OrchestratorRepositories(
            eventStore = eventStore,
            inferenceRepository = InferenceRepository(object : EventReplayer<InferenceState> {
                override fun rebuild(sessionId: SessionId) = InferenceState()
            }),
            orchestrationRepository = OrchestrationRepository(
                DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
            ),
            sessionRepository = DefaultSessionRepository(
                DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
            ),
            artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
            approvalRepository = DefaultApprovalRepository(
                DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
            ),
        )
        val engines = OrchestratorEngines(
            transitionResolver = DefaultTransitionResolver { _, _ -> true },
            contextPackBuilder = ContextFixtures.simpleBuilder(),
            inferenceRouter = object : InferenceRouter {
                override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider = provider
            },
            validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
            approvalEngine = DefaultApprovalEngine(),
            riskAssessor = DefaultRiskAssessor(),
            toolExecutor = object : ToolExecutor {
                override suspend fun execute(request: ToolRequest): ToolResult =
                    ToolResult.Success(request.invocationId, "ok", 0)
            },
            toolRegistry = twoToolRegistry,
            toolCallAssessor = assessor,
            workspacePolicy = policy,
            worldProbe = fakeProbe,
        )
        val orchestrator = DefaultSessionOrchestrator(
            repositories = repositories,
            engines = engines,
            retryCoordinator = DefaultRetryCoordinator(eventStore),
            artifactStore = artifactStore,
            decisionJournalRepository = DefaultDecisionJournalRepository(
                DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
            ),
        )

        val sessionId = SessionId("rbw-narrow")
        val graph = WorkflowGraph(
            id = "rbw-test",
            stages = mapOf(
                StageId("A") to StageConfig(allowedTools = setOf("file_write", "file_read")),
            ),
            transitions = setOf(
                TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
            ),
            start = StageId("A"),
        )
        orchestrator.run(sessionId, graph, OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)))

        // Turn 1: model asked for file_write → blocked by READ_BEFORE_WRITE.
        // Turn 2: offered tools must NOT include file_write (read-only mode active).
        assertTrue(offeredToolsPerTurn.size >= 2, "expected at least 2 inference turns, got ${offeredToolsPerTurn.size}")
        val turn2Tools = offeredToolsPerTurn[1]
        assertTrue("file_write" !in turn2Tools, "turn 2 must not offer file_write (read-only mode), got: $turn2Tools")
        assertTrue("file_read" in turn2Tools, "turn 2 must offer file_read, got: $turn2Tools")

        // Turn 3: after the file_read completes, write tools are restored.
        assertTrue(offeredToolsPerTurn.size >= 3, "expected at least 3 inference turns, got ${offeredToolsPerTurn.size}")
        val turn3Tools = offeredToolsPerTurn[2]
        assertTrue("file_write" in turn3Tools, "turn 3 must offer file_write (read completed), got: $turn3Tools")

        // Verify the READ_BEFORE_WRITE block event was recorded (turn 1 block).
        val events = eventStore.read(sessionId)
        assertTrue(
            events.any { e ->
                val p = e.payload as? ToolCallAssessedEvent
                p != null && p.issues.any { it.code == "READ_BEFORE_WRITE" }
            },
            "expected a READ_BEFORE_WRITE ToolCallAssessedEvent",
        )
        // Turn 3's file_write must succeed (executor runs, ToolExecutionCompletedEvent for file_write).
        assertTrue(
            events.any { e ->
                val p = e.payload as? ToolExecutionCompletedEvent
                p != null && p.toolName == "file_write"
            },
            "expected file_write to complete successfully on turn 3",
        )
    }

    @Test
    fun `a read blocked by REFERENCE_EXISTS lifts read-only mode so a new-file write is not deadlocked`(): Unit = runBlocking {
        val writeTarget = "/work/Existing.kt" // exists → READ_BEFORE_WRITE blocks the unread write
        val ghostRead = "/work/Ghost.kt"      // absent → REFERENCE_EXISTS blocks the read

        val fileWriteTool = object : Tool {
            override val name = "file_write"
            override val description = "write"
            override val parametersSchema: JsonObject = buildJsonObject {}
            override val tier = Tier.T1
            override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
            override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
            override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
        }
        val fileReadTool = object : Tool {
            override val name = "file_read"
            override val description = "read"
            override val parametersSchema: JsonObject = buildJsonObject {}
            override val tier = Tier.T1
            override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
            override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
            override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
        }
        val twoToolRegistry = object : ToolRegistry {
            override fun resolve(name: String): Tool? = when (name) {
                "file_write" -> fileWriteTool
                "file_read" -> fileReadTool
                else -> null
            }
            override fun all(): List<Tool> = listOf(fileWriteTool, fileReadTool)
        }

        // turn 1 → write existing-but-unread (→ READ_BEFORE_WRITE block → read-only),
        // turn 2 → read a file that does not exist (→ REFERENCE_EXISTS block),
        // turn 3 → write again; file_write must be offered again (read-only lifted, no deadlock).
        val offeredToolsPerTurn = mutableListOf<Set<String>>()
        var turnCount = 0
        val provider = object : InferenceProvider {
            override val id = ProviderId("rbw-ghost")
            override val name = "rbw-ghost"
            override val tokenizer: Tokenizer = MockTokenizer()
            override suspend fun infer(request: InferenceRequest): InferenceResponse {
                offeredToolsPerTurn += request.tools.map { it.function.name }.toSet()
                turnCount++
                val toolCall = when (turnCount) {
                    1 -> ToolCallRequest(id = "tc-w1", function = ToolCallFunction("file_write", """{"path":"$writeTarget"}"""))
                    2 -> ToolCallRequest(id = "tc-r1", function = ToolCallFunction("file_read", """{"path":"$ghostRead"}"""))
                    3 -> ToolCallRequest(id = "tc-w2", function = ToolCallFunction("file_write", """{"path":"$writeTarget"}"""))
                    else -> null
                }
                return if (toolCall != null) {
                    InferenceResponse(request.requestId, "", FinishReason.ToolCall, TokenUsage(1, 1), 0, listOf(toolCall))
                } else {
                    InferenceResponse(request.requestId, "done", FinishReason.Stop, TokenUsage(1, 1), 0)
                }
            }
            override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
            override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
        }

        // Path-discriminating probe: the write target exists, the ghost read does not.
        val fakeProbe = object : com.correx.core.toolintent.WorldProbe {
            override fun exists(path: java.nio.file.Path): Boolean = path.toString().endsWith("Existing.kt")
            override fun resolveReal(path: java.nio.file.Path): java.nio.file.Path = path.toAbsolutePath().normalize()
        }

        val eventStore = InMemoryEventStore()
        val artifactStore = NoopArtifactStore()
        val assessor = ToolCallAssessor(listOf(ReadBeforeWriteRule(), ReferenceExistsRule()))
        val policy = WorkspacePolicy(workspace)

        val repositories = OrchestratorRepositories(
            eventStore = eventStore,
            inferenceRepository = InferenceRepository(object : EventReplayer<InferenceState> {
                override fun rebuild(sessionId: SessionId) = InferenceState()
            }),
            orchestrationRepository = OrchestrationRepository(
                DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
            ),
            sessionRepository = DefaultSessionRepository(
                DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
            ),
            artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
            approvalRepository = DefaultApprovalRepository(
                DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
            ),
        )
        val engines = OrchestratorEngines(
            transitionResolver = DefaultTransitionResolver { _, _ -> true },
            contextPackBuilder = ContextFixtures.simpleBuilder(),
            inferenceRouter = object : InferenceRouter {
                override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider = provider
            },
            validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
            approvalEngine = DefaultApprovalEngine(),
            riskAssessor = DefaultRiskAssessor(),
            toolExecutor = object : ToolExecutor {
                override suspend fun execute(request: ToolRequest): ToolResult =
                    ToolResult.Success(request.invocationId, "ok", 0)
            },
            toolRegistry = twoToolRegistry,
            toolCallAssessor = assessor,
            workspacePolicy = policy,
            worldProbe = fakeProbe,
        )
        val orchestrator = DefaultSessionOrchestrator(
            repositories = repositories,
            engines = engines,
            retryCoordinator = DefaultRetryCoordinator(eventStore),
            artifactStore = artifactStore,
            decisionJournalRepository = DefaultDecisionJournalRepository(
                DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
            ),
        )

        val sessionId = SessionId("rbw-ghost-deadlock")
        val graph = WorkflowGraph(
            id = "rbw-ghost-test",
            stages = mapOf(
                StageId("A") to StageConfig(allowedTools = setOf("file_write", "file_read")),
            ),
            transitions = setOf(
                TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
            ),
            start = StageId("A"),
        )
        orchestrator.run(sessionId, graph, OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)))

        // Turn 2 is read-only (the turn-1 write was READ_BEFORE_WRITE-blocked).
        assertTrue(offeredToolsPerTurn.size >= 3, "expected at least 3 inference turns, got ${offeredToolsPerTurn.size}")
        assertTrue("file_write" !in offeredToolsPerTurn[1], "turn 2 must be read-only after the write block")
        // Turn 3: the ghost read could never complete, so without the fix read-only would never lift
        // and file_write would stay filtered forever. The fix lifts it on the REFERENCE_EXISTS block.
        assertTrue(
            "file_write" in offeredToolsPerTurn[2],
            "turn 3 must offer file_write again — a REFERENCE_EXISTS-blocked read must lift read-only, " +
                "got: ${offeredToolsPerTurn[2]}",
        )
    }

    /**
     * #301: a write repeatedly rejected for being outside the stage's declared manifest escalates
     * to user approval after N same-path rejections instead of hard-blocking forever. On approval
     * the FIRST attempt's pristine write is applied (widening the manifest for the session), and
     * later attempts to the same path skip the prompt entirely.
     */
    @Test
    fun `Nth same-path PATH_OUTSIDE_MANIFEST rejection escalates to approval, which then executes the first attempt (#301)`(): Unit =
        runBlocking {
            val blockedPath = "/work/scratch/blocked.kt"
            // T2 so the approval engine cannot auto-approve under PROMPT mode (T1 would auto-approve
            // and the escalation would never actually pause) — mirrors the other PROMPT_USER tests.
            val fileWriteTool = object : Tool {
                override val name = "file_write"
                override val description = "write"
                override val parametersSchema: JsonObject = buildJsonObject {}
                override val tier = Tier.T2
                override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
                override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
                override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
            }
            val toolRegistry = object : ToolRegistry {
                override fun resolve(name: String): Tool? = if (name == "file_write") fileWriteTool else null
                override fun all(): List<Tool> = listOf(fileWriteTool)
            }

            var turnCount = 0
            val provider = object : InferenceProvider {
                override val id = ProviderId("scope-escalate")
                override val name = "scope-escalate"
                override val tokenizer: Tokenizer = MockTokenizer()
                val requests = mutableListOf<InferenceRequest>()
                override suspend fun infer(request: InferenceRequest): InferenceResponse {
                    requests += request
                    turnCount++
                    // Turns 1-3 keep retrying the exact same out-of-manifest write; turn 4 (after
                    // the escalated approval resolves) stops so the run completes.
                    return if (turnCount <= 3) {
                        InferenceResponse(
                            request.requestId, "", FinishReason.ToolCall, TokenUsage(1, 1), 0,
                            listOf(
                                ToolCallRequest(
                                    id = "tc-$turnCount",
                                    function = ToolCallFunction("file_write", """{"path":"$blockedPath"}"""),
                                ),
                            ),
                        )
                    } else {
                        InferenceResponse(request.requestId, "done", FinishReason.Stop, TokenUsage(1, 1), 0)
                    }
                }
                override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
                override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
            }

            val fakeProbe = object : com.correx.core.toolintent.WorldProbe {
                override fun exists(path: Path): Boolean = true
                override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
            }

            val eventStore = InMemoryEventStore()
            val artifactStore = NoopArtifactStore()
            val assessor = ToolCallAssessor(listOf(ManifestContainmentRule()))
            val policy = WorkspacePolicy(workspace)
            val executor = RecordingExecutor()

            val repositories = OrchestratorRepositories(
                eventStore = eventStore,
                inferenceRepository = InferenceRepository(object : EventReplayer<InferenceState> {
                    override fun rebuild(sessionId: SessionId) = InferenceState()
                }),
                orchestrationRepository = OrchestrationRepository(
                    DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
                ),
                sessionRepository = DefaultSessionRepository(
                    DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
                ),
                artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
                approvalRepository = DefaultApprovalRepository(
                    DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
                ),
            )
            val engines = OrchestratorEngines(
                transitionResolver = DefaultTransitionResolver { _, _ -> true },
                contextPackBuilder = ContextFixtures.simpleBuilder(),
                inferenceRouter = object : InferenceRouter {
                    override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider = provider
                },
                validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
                approvalEngine = DefaultApprovalEngine(),
                riskAssessor = DefaultRiskAssessor(),
                toolExecutor = executor,
                toolRegistry = toolRegistry,
                toolCallAssessor = assessor,
                workspacePolicy = policy,
                worldProbe = fakeProbe,
            )
            val orchestrator = DefaultSessionOrchestrator(
                repositories = repositories,
                engines = engines,
                retryCoordinator = DefaultRetryCoordinator(eventStore),
                artifactStore = artifactStore,
                decisionJournalRepository = DefaultDecisionJournalRepository(
                    DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
                ),
                // Escalate after 2 same-path rejections instead of the default 3, so the test's
                // 3rd attempt is the one that triggers the approval prompt.
                tuning = OrchestrationTuning(escalateScopeAfterN = 2),
            )

            val sessionId = SessionId("scope-escalate")
            val graph = WorkflowGraph(
                id = "scope-escalate-test",
                stages = mapOf(
                    StageId("A") to StageConfig(
                        allowedTools = setOf("file_write"),
                        writeManifest = listOf("allowed/**"),
                    ),
                ),
                transitions = setOf(
                    TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
                ),
                start = StageId("A"),
            )
            val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))

            val job = launch { orchestrator.run(sessionId, graph, config) }

            // Wait for the 3rd attempt's escalation to raise an ApprovalRequestedEvent.
            val approval = withTimeout(5_000) {
                var req: ApprovalRequestedEvent? = null
                while (req == null) {
                    req = eventStore.read(sessionId).firstNotNullOfOrNull { it.payload as? ApprovalRequestedEvent }
                    if (req == null) yield()
                }
                req
            }
            assertEquals("file_write", approval.toolName)

            // Exactly 2 hard rejections happened before the escalation (turns 1 and 2); the 3rd
            // attempt paused for approval instead of rejecting again.
            val rejectionsBeforeApproval = eventStore.read(sessionId).count { it.payload is ToolExecutionRejectedEvent }
            assertEquals(2, rejectionsBeforeApproval, "expected exactly 2 hard rejections before escalation")
            assertTrue(!executor.executeCalled.get(), "executor must not run before the escalated approval resolves")

            orchestrator.submitApprovalDecision(
                approval.requestId,
                ApprovalDecision(
                    id = null,
                    requestId = approval.requestId,
                    outcome = ApprovalOutcome.APPROVED,
                    state = ApprovalStatus.COMPLETED,
                    tier = Tier.T2,
                    contextSnapshot = ApprovalContext(
                        identity = ApprovalScopeIdentity(sessionId, StageId("A"), projectId = null),
                        mode = ApprovalMode.PROMPT,
                    ),
                    resolutionTimestamp = Clock.System.now(),
                    reason = "approved widening",
                ),
            )

            withTimeout(5_000) {
                while (eventStore.read(sessionId).none { it.payload is WriteScopeGrantedEvent }) yield()
            }
            job.join()

            assertTrue(executor.executeCalled.get(), "the approved write must execute")
            val grant = eventStore.read(sessionId).firstNotNullOfOrNull { it.payload as? WriteScopeGrantedEvent }
            assertEquals(blockedPath, grant?.path)
        }

    @Test
    fun `empty allowedTools denies all domain tools and emits ToolExecutionRejectedEvent`(): Unit = runBlocking {
        val executor = RecordingExecutor()
        // Stage has allowedTools = emptySet() — only stage_complete is permitted.
        val (orchestrator, eventStore, _) = buildOrchestrator(executor, FakeFileWriteTool(), assessorRule = null)
        val sessionId = SessionId("gate-empty-allowed")
        val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
        val graph = singleStageGraph(allowedTools = emptySet())

        orchestrator.run(sessionId, graph, config)

        val events = eventStore.read(sessionId)
        assertTrue(!executor.executeCalled.get(), "executor must NOT be called when allowedTools is empty")

        val rejected = events.firstNotNullOfOrNull { it.payload as? ToolExecutionRejectedEvent }
        assertNotNull(rejected, "ToolExecutionRejectedEvent must be emitted when allowedTools is empty")
        assertEquals("file_write", rejected!!.toolName)
        assertTrue(
            rejected.reason.contains("not permitted"),
            "rejection reason should mention 'not permitted', got: ${rejected.reason}",
        )
    }
}
