package com.correx.apps.server.ws

import com.correx.apps.server.ServerModule
import com.correx.apps.server.bridge.DomainEventMapper
import com.correx.apps.server.bridge.SessionEventBridge
import com.correx.apps.server.logging.withSessionContext
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.GrantScopeDto
import com.correx.apps.server.protocol.ProtocolSerializer
import com.correx.apps.server.protocol.ProviderHealthDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.WorkflowDto
import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.GrantScope
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ApprovalGrantExpiredEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.GrantId
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.config.OperatorProfile
import com.correx.core.config.OperatorProfileWriter
import com.correx.core.config.ProfileLoader
import com.correx.core.config.ProfilePreferences
import com.correx.core.config.ProjectProfile
import com.correx.core.config.ProjectProfileLoader
import com.correx.core.config.ProjectProfileWriter
import com.correx.core.config.withConvention
import com.correx.core.talkie.IdeaReader
import com.correx.core.utils.TypeId
import com.correx.infrastructure.inference.commons.ResourceProbe
import com.correx.infrastructure.inference.commons.ResourceSnapshot
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.util.UUID

private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java)
private const val BUFFER_CAPACITY = 1024

/** Raised to tear down a WS connection whose forward buffer overflowed, so the client reconnects. */
private class SlowClientException : RuntimeException("global WS stream buffer overflow")
private const val RESOURCE_PUSH_INTERVAL_MS = 2500L
private const val BYTES_PER_MB = 1024L * 1024L

private fun ResourceSnapshot.toResourceStatus(): ServerMessage.ResourceStatus =
    ServerMessage.ResourceStatus(
        gpuMemoryUsedMb = gpu?.memoryUsedMb,
        gpuMemoryTotalMb = gpu?.memoryTotalMb,
        gpuUtilizationPct = gpu?.utilizationPct,
        processRssMb = processRssBytes?.let { it / BYTES_PER_MB },
        systemRamUsedMb = systemRamUsedBytes?.let { it / BYTES_PER_MB },
        systemRamTotalMb = systemRamTotalBytes?.let { it / BYTES_PER_MB },
    )

class GlobalStreamHandler(private val module: ServerModule) {

    private val queries = StreamQueries(module)

    suspend fun handle(session: DefaultWebSocketServerSession) = coroutineScope {
        log.info("client connected")

        val sendFrame: suspend (ServerMessage) -> Unit = {
            session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(it)))
        }

        // Stash the working directory received in the Hello frame (first frame on connect).
        // Null means no Hello was received; handleStartSession falls back to boot default.
        var pendingWorkingDir: String? = null
        // Guard: once StartSession has been handled the Hello window is closed. A late Hello
        // has no session to bind to and its workingDir is silently stale — ignore it.
        var sessionStarted = false

        val bridge = SessionEventBridge(
            eventStore = module.eventStore,
            artifactStore = module.artifactStore,
            orchestrationRepository = module.orchestrationRepository,
            workflowRegistry = module.workflowRegistry,
            toolRegistry = module.toolRegistry,
            send = sendFrame,
            approvalCoordinator = module.approvalCoordinator,
            livePendingClarificationIds = { module.orchestrator.liveClarificationRequestIds() },
        )
        val mapper = DomainEventMapper(module.artifactStore)

        module.approvalCoordinator.registerGlobalClient(session)

        sendInitialSnapshot(session)

        // Launch global stream (snapshot + live-forward) concurrently with incoming frame loop
        launch {
            streamGlobal(module.eventStore, bridge, mapper, sendFrame)
        }
        // Live resource gauge (GPU/RAM) pushed periodically. Cancelled with the session scope.
        launch {
            streamResources(module.resourceProbe, sendFrame)
        }

        try {
            for (frame in session.incoming) {
                if (frame is Frame.Text) {
                    val text = frame.readText()
                    runCatching { ProtocolSerializer.decodeClientMessage(text) }
                        .onSuccess { msg ->
                            log.debug("recv: {}", msg::class.simpleName)
                            runCatching {
                                handleClientMessage(
                                    session, msg, sendFrame, pendingWorkingDir, sessionStarted,
                                    onHello = { pendingWorkingDir = it },
                                    onSessionStarted = { sessionStarted = true },
                                )
                            }.onFailure {
                                log.error("handle failed", it)
                            }
                        }
                        .onFailure {
                            log.warn("decode error: {}", it.message)
                            val error = ServerMessage.ProtocolError("Unknown message: ${it.message}")
                            session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
                        }
                }
            }
        } catch (_: ClosedReceiveChannelException) {
            log.info("client disconnected")
        } finally {
            module.approvalCoordinator.unregisterGlobalClient(session)
        }
    }

    private suspend fun sendInitialSnapshot(session: DefaultWebSocketServerSession) {
        val providerHealth = runCatching { module.providerRegistry.healthCheckAll() }
            .onFailure { log.warn("healthCheckAll failed: {}", it.message) }
            .getOrDefault(emptyMap())
        log.info("snapshot: {} provider(s)", providerHealth.size)
        providerHealth.forEach { (providerId, health) ->
            val status = when (health) {
                is ProviderHealth.Healthy -> "healthy"
                is ProviderHealth.Degraded -> "degraded"
                is ProviderHealth.Unavailable -> "unavailable"
            }
            log.debug("provider={} status={}", providerId.value, status)
            val msg = ServerMessage.ProviderStatusChanged(
                providerId = providerId.value,
                status = ProviderHealthDto(providerId.value, status, null),
                sequence = null,
                sessionSequence = null,
            )
            session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(msg)))
        }

        // Send available workflows so the TUI can show a workflow picker
        val workflows = module.workflowRegistry.listAll().map { summary ->
            WorkflowDto(workflowId = summary.workflowId, description = summary.description)
        }
        log.debug("workflow list: {} workflow(s)", workflows.size)
        session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(
            ServerMessage.WorkflowList(workflows = workflows),
        )))

        // Configured models + current, so the client can offer a swap picker (managed path only).
        module.modelSwapper?.let { swapper ->
            session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(
                ServerMessage.ModelList(
                    models = swapper.availableModelIds(),
                    current = swapper.currentModelId(),
                ),
            )))
        }

        // Initial resource gauge so a freshly-connected client renders VRAM/RAM immediately.
        val snapshot = withContext(Dispatchers.IO) { module.resourceProbe.probe() }
        session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(snapshot.toResourceStatus())))
    }

    /** Pushes a [ServerMessage.ResourceStatus] every [RESOURCE_PUSH_INTERVAL_MS]. */
    private suspend fun streamResources(
        probe: ResourceProbe,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        while (true) {
            delay(RESOURCE_PUSH_INTERVAL_MS)
            val snapshot = withContext(Dispatchers.IO) { probe.probe() }
            sendFrame(snapshot.toResourceStatus())
        }
    }

    // Flat dispatch table: an exhaustive `when` over the sealed ClientMessage. Its cyclomatic
    // complexity scales with the number of message types, which is the false-positive case the
    // metric flags — each branch is a one-line delegation, not real branching logic.
    @Suppress("CyclomaticComplexMethod")
    private suspend fun handleClientMessage(
        session: DefaultWebSocketServerSession,
        msg: ClientMessage,
        sendFrame: suspend (ServerMessage) -> Unit,
        pendingWorkingDir: String?,
        sessionStarted: Boolean,
        onHello: (String) -> Unit,
        onSessionStarted: () -> Unit,
    ) {
        when (msg) {
            is ClientMessage.Ping -> Unit
            is ClientMessage.Hello -> {
                if (sessionStarted) {
                    log.warn("recv Hello after session already started (workingDir={}); ignoring", msg.workingDir)
                } else {
                    log.info("recv Hello: workingDir={}", msg.workingDir)
                    onHello(msg.workingDir)
                }
            }
            is ClientMessage.StartSession -> {
                handleStartSession(session, msg, pendingWorkingDir)
                onSessionStarted()
            }
            is ClientMessage.StartChatSession -> handleStartChatSession(session, msg, sendFrame)
            is ClientMessage.CancelSession -> {
                runCatching { module.orchestrator.cancel(msg.sessionId) }
                    .onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
            }
            is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
            is ClientMessage.ListFiles -> sendFrame(queries.listFiles(msg.sessionId))
            is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
            is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
            is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame)
            is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
            is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks())
            is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
            is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
            is ClientMessage.GetProjectProfile -> handleGetProjectProfile(msg, sendFrame)
            is ClientMessage.UpdateProjectProfile -> handleUpdateProjectProfile(msg, sendFrame)
            is ClientMessage.GetOperatorProfile -> handleGetOperatorProfile(sendFrame)
            is ClientMessage.UpdateOperatorProfile -> handleUpdateOperatorProfile(msg, sendFrame)
            is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
            is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
            is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
            is ClientMessage.SteerSession -> handleSteerSession(msg)
            is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
            is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame)
            is ClientMessage.ListGrants -> sendFrame(queries.listGrants())
            is ClientMessage.ChatInput -> {
                // The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
                // and reach the client as chat.turn frames via streamGlobal — no direct
                // TalkieResponseMessage (the conversation is event-derived).
                withSessionContext(msg.sessionId) {
                    runCatching {
                        module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode)
                    }.onFailure {
                        log.error("routerFacade.onUserInput failed: {}", it.message)
                        sendFrame(ServerMessage.ProtocolError("Router error: ${it.message}"))
                    }
                }
            }
            is ClientMessage.SwapModel -> handleSwapModel(msg, sendFrame)
            is ClientMessage.ClearModelPin -> {
                val swapper = module.modelSwapper
                if (swapper == null) {
                    sendFrame(errorResponse("Model management is not enabled"))
                } else {
                    swapper.clearPin()
                    log.info("model pin cleared")
                }
            }
        }
    }

    private suspend fun handleSwapModel(
        msg: ClientMessage.SwapModel,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        val swapper = module.modelSwapper
        if (swapper == null) {
            sendFrame(errorResponse("Model management is not enabled"))
            return
        }
        // swap() makes the model resident; the resulting ModelLoadedEvent surfaces the change to
        // clients via streamGlobal, so we don't push ModelChanged here.
        runCatching { swapper.swap(msg.modelId) }
            .onSuccess { log.info("swapped to model={}", msg.modelId) }
            .onFailure {
                log.error("model swap failed for '{}': {}", msg.modelId, it.message)
                sendFrame(errorResponse("Model swap failed: ${it.message}"))
            }
    }

    private suspend fun handleApprovalResponse(
        msg: ClientMessage.ApprovalResponse,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        val scopeSessionId = module.approvalCoordinator.lookupSession(msg.requestId)
        if (scopeSessionId == null) {
            log.warn("handleApprovalResponse: no session found for requestId={}", msg.requestId.value)
            sendFrame(ServerMessage.ProtocolError("Unknown approval request"))
            return
        }
        module.approvalCoordinator.handleResponse(msg, scopeSessionId)?.let { sendFrame(it) }
    }

    // The message carries its own sessionId/stageId/requestId, so no lookup is needed — the parked
    // stage completes and re-runs with the answers in context (DefaultSessionOrchestrator).
    private suspend fun handleClarificationResponse(msg: ClientMessage.ClarificationResponse) {
        runCatching {
            module.orchestrator.submitClarification(msg.sessionId, msg.stageId, msg.requestId, msg.answers)
        }.onFailure {
            log.error("submitClarification failed for session={}: {}", msg.sessionId.value, it.message, it)
        }
    }

    // Free-form mid-stage steering, independent of any approval gate — see
    // DefaultSessionOrchestrator.submitSteering and ClientMessage.SteerSession.
    private suspend fun handleSteerSession(msg: ClientMessage.SteerSession) {
        runCatching {
            module.orchestrator.submitSteering(msg.sessionId, msg.text)
        }.onFailure {
            log.error("submitSteering failed for session={}: {}", msg.sessionId.value, it.message, it)
        }
    }

    // Tombstone an idea, landing the discard in the session that captured it (so all of one idea's
    // events stay co-located), then reply with the refreshed board. A missing idea is a no-op.
    private suspend fun handleDiscardIdea(
        msg: ClientMessage.DiscardIdea,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        runCatching {
            IdeaReader(module.eventStore).sessionOf(msg.ideaId)?.let { sessionId ->
                module.eventStore.append(
                    NewEvent(
                        metadata = EventMetadata(
                            eventId = EventId(UUID.randomUUID().toString()),
                            sessionId = sessionId,
                            timestamp = Clock.System.now(),
                            schemaVersion = 1,
                            causationId = null,
                            correlationId = null,
                        ),
                        payload = IdeaDiscardedEvent(ideaId = msg.ideaId, sessionId = sessionId),
                    ),
                )
            }
        }.onFailure {
            log.error("discardIdea failed for ideaId={}: {}", msg.ideaId, it.message, it)
        }
        sendFrame(queries.listIdeas())
    }

    // Promote an idea into the bound session's curated project profile (a `conventions` entry in
    // <workspaceRoot>/.correx/project.toml), then tombstone it like a discard so it leaves the board.
    // No-ops (just refresh the board) when the idea is missing or its capturing session has no bound
    // workspace — promotion needs a workspace to write the profile into.
    private suspend fun handlePromoteIdea(
        msg: ClientMessage.PromoteIdea,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        runCatching {
            val reader = IdeaReader(module.eventStore)
            val sessionId = reader.sessionOf(msg.ideaId) ?: return@runCatching
            val text = reader.textOf(msg.ideaId) ?: return@runCatching
            val workspaceRoot = workspaceRootOf(sessionId) ?: return@runCatching

            withContext(Dispatchers.IO) {
                val updated = ProjectProfileLoader.load(workspaceRoot).withConvention(text)
                ProjectProfileWriter.write(workspaceRoot, updated)
            }

            module.eventStore.append(
                NewEvent(
                    metadata = EventMetadata(
                        eventId = EventId(UUID.randomUUID().toString()),
                        sessionId = sessionId,
                        timestamp = Clock.System.now(),
                        schemaVersion = 1,
                        causationId = null,
                        correlationId = null,
                    ),
                    payload = IdeaPromotedEvent(
                        ideaId = msg.ideaId,
                        sessionId = sessionId,
                        text = text,
                        timestampMs = System.currentTimeMillis(),
                    ),
                ),
            )
        }.onFailure {
            log.error("promoteIdea failed for ideaId={}: {}", msg.ideaId, it.message, it)
        }
        sendFrame(queries.listIdeas())
    }

    // The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent (invariant #9),
    // or null if the session never bound one.
    private fun workspaceRootOf(sessionId: SessionId): String? =
        module.eventStore.read(sessionId)
            .mapNotNull { it.payload as? SessionWorkspaceBoundEvent }
            .lastOrNull()
            ?.workspaceRoot

    private suspend fun handleGetProjectProfile(
        msg: ClientMessage.GetProjectProfile,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        val workspaceRoot = workspaceRootOf(msg.sessionId)
        if (workspaceRoot == null) {
            sendFrame(
                ServerMessage.ProjectProfileSnapshot(
                    sessionId = msg.sessionId,
                    about = "",
                    conventions = emptyList(),
                    commands = emptyMap(),
                    error = "session has no bound workspace",
                ),
            )
            return
        }
        val profile = withContext(Dispatchers.IO) { ProjectProfileLoader.load(workspaceRoot) }
        sendFrame(
            ServerMessage.ProjectProfileSnapshot(
                sessionId = msg.sessionId,
                about = profile.about,
                conventions = profile.conventions,
                commands = profile.commands,
            ),
        )
    }

    private suspend fun handleUpdateProjectProfile(
        msg: ClientMessage.UpdateProjectProfile,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        val workspaceRoot = workspaceRootOf(msg.sessionId)
        if (workspaceRoot == null) {
            sendFrame(
                ServerMessage.ProjectProfileSnapshot(
                    sessionId = msg.sessionId,
                    about = "",
                    conventions = emptyList(),
                    commands = emptyMap(),
                    error = "session has no bound workspace",
                ),
            )
            return
        }
        val profile = ProjectProfile(about = msg.about, conventions = msg.conventions, commands = msg.commands)
        runCatching {
            withContext(Dispatchers.IO) { ProjectProfileWriter.write(workspaceRoot, profile) }
        }.onFailure {
            log.error("updateProjectProfile failed for session={}: {}", msg.sessionId.value, it.message, it)
            sendFrame(
                ServerMessage.ProjectProfileSnapshot(
                    sessionId = msg.sessionId,
                    about = profile.about,
                    conventions = profile.conventions,
                    commands = profile.commands,
                    error = "failed to save: ${it.message}",
                ),
            )
            return
        }
        sendFrame(
            ServerMessage.ProjectProfileSnapshot(
                sessionId = msg.sessionId,
                about = profile.about,
                conventions = profile.conventions,
                commands = profile.commands,
            ),
        )
    }

    private suspend fun handleGetOperatorProfile(sendFrame: suspend (ServerMessage) -> Unit) {
        val profile = withContext(Dispatchers.IO) { ProfileLoader.load() }
        val proposedPath = ProfileLoader.profilePath().resolveSibling("profile.proposed.toml")
        val proposed = withContext(Dispatchers.IO) {
            runCatching { if (Files.exists(proposedPath)) Files.readString(proposedPath) else null }.getOrNull()
        }
        sendFrame(
            ServerMessage.OperatorProfileSnapshot(
                about = profile.about,
                approvalMode = profile.preferences.approvalMode,
                preferredModels = profile.preferences.preferredModels,
                conventions = profile.preferences.conventions,
                proposedAdaptation = proposed,
            ),
        )
    }

    private suspend fun handleUpdateOperatorProfile(
        msg: ClientMessage.UpdateOperatorProfile,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        val profile = OperatorProfile(
            about = msg.about,
            preferences = ProfilePreferences(
                approvalMode = msg.approvalMode,
                preferredModels = msg.preferredModels,
                conventions = msg.conventions,
            ),
        )
        runCatching {
            withContext(Dispatchers.IO) { OperatorProfileWriter.write(profile) }
        }.onFailure {
            log.error("updateOperatorProfile failed: {}", it.message, it)
            sendFrame(
                ServerMessage.OperatorProfileSnapshot(
                    about = profile.about,
                    approvalMode = profile.preferences.approvalMode,
                    preferredModels = profile.preferences.preferredModels,
                    conventions = profile.preferences.conventions,
                    error = "failed to save: ${it.message}",
                ),
            )
            return
        }
        sendFrame(
            ServerMessage.OperatorProfileSnapshot(
                about = profile.about,
                approvalMode = profile.preferences.approvalMode,
                preferredModels = profile.preferences.preferredModels,
                conventions = profile.preferences.conventions,
            ),
        )
    }

    private fun errorResponse(message: String) = ServerMessage.ProtocolError(message)

    private fun encodeError(message: String): Frame.Text =
        Frame.Text(ProtocolSerializer.encodeServerMessage(ServerMessage.ProtocolError(message)))

    private suspend fun handleCreateGrant(
        msg: ClientMessage.CreateGrant,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        // No tier ceiling: the operator explicitly opted out of the prior T2 cap, so a grant may
        // authorize any tier (including the destructive T3/T4). Every operator-creatable scope is
        // still tool-bound (below) so a grant never becomes a blanket "approve everything".
        if (msg.permittedTiers.isEmpty()) {
            sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
            return
        }

        suspend fun requireToolName(scopeLabel: String): String? =
            msg.toolName?.takeIf { it.isNotBlank() }
                ?: run {
                    val errMsg = "CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"
                    sendFrame(errorResponse(errMsg))
                    null
                }

        val scope = when (msg.scope) {
            GrantScopeDto.SESSION -> {
                if (msg.stageId != null) {
                    sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
                    return
                }
                GrantScope.SESSION(requireToolName("SESSION") ?: return)
            }
            GrantScopeDto.STAGE -> {
                val sid = msg.stageId ?: run {
                    sendFrame(errorResponse("CreateGrant: STAGE scope requires stageId"))
                    return
                }
                GrantScope.STAGE(sid)
            }
            GrantScopeDto.PROJECT -> {
                // projectId is derived from the session's bound workspace, never trusted from the client.
                val projectId = queries.projectIdForSession(msg.sessionId) ?: run {
                    sendFrame(errorResponse("CreateGrant: PROJECT scope requires a session with a bound workspace"))
                    return
                }
                GrantScope.PROJECT(projectId, requireToolName("PROJECT") ?: return)
            }
            GrantScopeDto.GLOBAL -> GrantScope.GLOBAL(requireToolName("GLOBAL") ?: return)
        }

        // SESSION/STAGE grants live in the originating session's stream (they die with it); PROJECT
        // and GLOBAL grants outlive any session, so they go to the shared cross-session grant ledger.
        val ledgered = scope is GrantScope.PROJECT || scope is GrantScope.GLOBAL
        val streamId = if (ledgered) GRANT_LEDGER_SESSION_ID else msg.sessionId
        val event = NewEvent(
            metadata = EventMetadata(
                eventId = EventId(UUID.randomUUID().toString()),
                sessionId = streamId,
                timestamp = Clock.System.now(),
                schemaVersion = 1,
                causationId = null,
                correlationId = null,
            ),
            payload = ApprovalGrantCreatedEvent(
                grantId = GrantId(UUID.randomUUID().toString()),
                scope = scope,
                permittedTiers = msg.permittedTiers.toSet(),
                reason = msg.reason,
                expiresAt = msg.expiresAt,
                sessionId = msg.sessionId,
                stageId = (scope as? GrantScope.STAGE)?.stageId,
                projectId = (scope as? GrantScope.PROJECT)?.projectId,
                toolName = scope.toolNameOrNull(),
            ),
        )
        module.eventStore.append(event)
        // Echo the refreshed standing-grant list so the TUI can confirm a PROJECT/GLOBAL grant landed.
        if (ledgered) sendFrame(queries.listGrants())
    }

    /**
     * Revokes a standing (PROJECT/GLOBAL) grant by appending an [ApprovalGrantExpiredEvent] to the
     * grant ledger — the reducer drops it, so the gate stops honouring it across all sessions while
     * the create/revoke pair stays in the audit log. Replies with the refreshed grant list.
     */
    private suspend fun handleRevokeGrant(
        msg: ClientMessage.RevokeGrant,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        val event = NewEvent(
            metadata = EventMetadata(
                eventId = EventId(UUID.randomUUID().toString()),
                sessionId = GRANT_LEDGER_SESSION_ID,
                timestamp = Clock.System.now(),
                schemaVersion = 1,
                causationId = null,
                correlationId = null,
            ),
            payload = ApprovalGrantExpiredEvent(grantId = msg.grantId),
        )
        module.eventStore.append(event)
        sendFrame(queries.listGrants())
    }

    private fun GrantScope.toolNameOrNull(): String? = when (this) {
        is GrantScope.SESSION -> toolName
        is GrantScope.PROJECT -> toolName
        is GrantScope.GLOBAL -> toolName
        is GrantScope.STAGE -> null
    }

    private suspend fun handleStartChatSession(
        session: DefaultWebSocketServerSession,
        msg: ClientMessage.StartChatSession,
        sendFrame: suspend (ServerMessage) -> Unit,
    ) {
        val sessionId = msg.sessionId
        log.info("starting chat session={}", sessionId.value)

        // Emit ChatSessionStartedEvent for the new session (no graph, no orchestrator).
        // streamGlobal maps it to a session.announced frame with the real sequence, and the
        // USER/ROUTER turns from onUserInput stream as chat.turn frames — all event-derived.
        module.eventStore.append(NewEvent(
            metadata = EventMetadata(
                eventId = EventId(UUID.randomUUID().toString()),
                sessionId = sessionId,
                timestamp = Clock.System.now(),
                schemaVersion = 1,
                causationId = null,
                correlationId = null,
            ),
            payload = ChatSessionStartedEvent(sessionId = sessionId),
        ))

        // Bind the per-repo project profile so router triage can cite conventions/commands.
        // Parity with launchSessionRun — chat sessions previously never bound a profile.
        runCatching { module.bindProjectProfile(sessionId) }
            .onFailure {
                log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message)
            }

        withSessionContext(sessionId) {
            runCatching {
                module.routerFacade.onUserInput(sessionId, msg.text)
            }.onFailure {
                log.error("routerFacade.onUserInput failed: {}", it.message)
                sendFrame(ServerMessage.ProtocolError(
                    message = "Router error: ${it.message}",
                    sequence = null,
                    sessionSequence = null,
                ))
            }
        }
    }

    private suspend fun handleStartSession(
        session: DefaultWebSocketServerSession,
        msg: ClientMessage.StartSession,
        workingDir: String?,
    ) {
        val graph = module.workflowRegistry.find(msg.workflowId)
        log.info("find returned: {}", graph)
        if (graph == null) {
            val error = ServerMessage.ProtocolError(
                message = "Unknown workflowId: ${msg.workflowId}",
                sequence = null,
                sessionSequence = null,
            )
            session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
            return
        }
        val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
        log.info("starting session={} workflow={}", sessionId.value, msg.workflowId)

        // Resolve + record the workspace from the Hello-frame working directory (invariant #9:
        // record the resolver's decision as an event; replay reads the recorded fact). Shared with
        // the REST launcher via module.bindWorkspace.
        val resolvedWorkspace: WorkspaceContext? = module.bindWorkspace(sessionId, workingDir)

        // Send StageToolManifest FIRST. If the WS is already closed, this throws and the
        // orchestrator never launches — no silent data loss. The exception propagates to the
        // runCatching in handleClientMessage (caller) and is logged there.
        // No session announce is sent here — it is derived from WorkflowStartedEvent by
        // streamGlobal via DomainEventMapper (one session.announced per session with the
        // correct sequence number).
        val stages = graph.stages.map { (stageId, stageConfig) ->
            val tools = stageConfig.allowedTools.mapNotNull { toolName ->
                module.toolRegistry.resolve(toolName)?.let { tool ->
                    ToolDecl(name = tool.name, tier = tool.tier.level)
                }
            }
            StageToolDecl(stageId = stageId.value, tools = tools)
        }
        session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(
            ServerMessage.StageToolManifest(
                sessionId = sessionId,
                workflowId = msg.workflowId,
                stages = stages,
            ),
        )))

        // Thread the resolved workspace into the OrchestrationConfig so the live run uses
        // the bound workspace root for tool path containment and per-session tool registry.
        val sessionConfig = module.orchestrationConfig().copy(workspace = resolvedWorkspace)

        // Seed the freeform request (if any) before the run so it lands in the decision
        // journal and is pinned into every stage's context (the intent input channel).
        msg.input?.takeIf { it.isNotBlank() }?.let { intent ->
            module.eventStore.append(NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = Clock.System.now(),
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = InitialIntentEvent(sessionId = sessionId, intent = intent),
            ))
        }

        // Only launch orchestrator after protocol messages are delivered successfully.
        // Uses launchSessionRun() so the job is tracked in activeSessionJobs — this prevents
        // the OrchestrationResumedEvent subscription from spuriously launching a duplicate resume.
        module.launchSessionRun(sessionId, graph, sessionConfig)
    }
}

internal suspend fun streamGlobal(
    eventStore: EventStore,
    bridge: SessionEventBridge,
    mapper: DomainEventMapper,
    sendFrame: suspend (ServerMessage) -> Unit,
) = coroutineScope {
    val buffer = Channel<StoredEvent>(
        capacity = BUFFER_CAPACITY,
        onBufferOverflow = BufferOverflow.SUSPEND,
    )

    // Subscription must be active BEFORE replaySnapshot reads lastGlobalSequence,
    // otherwise events appended in that window are lost (replay=0 SharedFlow).
    val subscribed = CompletableDeferred<Unit>()
    val source = eventStore.subscribeAll()
    val signaled = when (source) {
        is SharedFlow<StoredEvent> -> source.onSubscription { subscribed.complete(Unit) }
        else -> source.onStart { subscribed.complete(Unit) }
    }
    // trySend, never send: a blocking send here would back-pressure globalFlow (SUSPEND overflow),
    // suspending append() and stalling the whole kernel on one wedged client. On overflow we instead
    // drop the connection (close the buffer); the client reconnects and re-syncs via replaySnapshot.
    val subscription = launch {
        signaled.collect {
            val result = buffer.trySend(it)
            if (result.isFailure && !result.isClosed) {
                log.warn("global WS stream buffer overflow (slow client); dropping connection to re-sync")
                buffer.close(SlowClientException())
            }
        }
    }

    try {
        subscribed.await()
        bridge.replaySnapshot()
        for (event in buffer) {
            // Use the event's own persisted sessionSequence, not a per-connection counter. The
            // snapshot advertises lastSessionSequence = MAX(session_sequence); a counter restarting
            // at 1 per connection would collide with that after reconnect and the TUI's dedup/
            // ordering filter (keyed on sessionSequence) would silently drop or misorder frames.
            val msg = mapper.map(event, sessionSequence = event.sessionSequence) ?: continue
            sendFrame(msg)
        }
    } finally {
        subscription.cancel()
        buffer.close()
    }
}
