package com.correx.apps.server

import com.correx.apps.server.approval.ApprovalCoordinator
import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.apps.server.logging.withSessionContext
import com.correx.apps.server.narration.NarrationSubscriber
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.apps.server.workspace.WorkspaceResolver
import com.correx.apps.server.workspace.WorkspaceResolution
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.config.AgentInstructionsLoader
import com.correx.core.config.OperatorProfile
import com.correx.core.config.ProjectProfileLoader
import com.correx.apps.server.memory.ArchitectContradictionChecker
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.WorkflowFailedEvent
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 kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.talkie.TalkieFacade
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionSummary
import com.correx.core.sessions.SessionSummaryProjector
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.events.orchestration.OrchestrationStatus
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.minus
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory

private val log = LoggerFactory.getLogger(ServerModule::class.java)

class ServerModule(
    val orchestrator: DefaultSessionOrchestrator,
    val eventStore: EventStore,
    val artifactStore: ArtifactStore,
    val sessionRepository: DefaultSessionRepository,
    val workflowRegistry: WorkflowRegistry,
    val providerRegistry: ProviderRegistry,
    val defaultOrchestrationConfig: OrchestrationConfig,
    // Rebuilt by ConfigService on a live config edit (router knobs); read at use-time by
    // GlobalStreamHandler.onUserInput so the next chat turn picks up the new facade.
    routerFacade: TalkieFacade,
    val orchestrationRepository: OrchestrationRepository,
    val approvalRepository: DefaultApprovalRepository,
    val toolRegistry: ToolRegistry,
    val sessionUndoService: com.correx.apps.server.undo.SessionUndoService,
    // The managed-model router when correx owns the local model process ([[models]] configured);
    // null on the static-provider path. Backs the manual SwapModel / ClearModelPin operations.
    val modelSwapper: com.correx.infrastructure.inference.commons.ManagedInferenceRouter? = null,
    // Live GPU/RAM gauge pushed to clients on the global stream. Defaults to the always-unavailable
    // probe (static path / non-GPU host); the managed path wires an NVIDIA-backed probe.
    val resourceProbe: com.correx.infrastructure.inference.commons.ResourceProbe =
        com.correx.infrastructure.inference.commons.UnavailableProbe,
    // Resolves a client-supplied cwd into a trusted WorkspaceContext using the trust pipeline
    // (canonicalize → privileged check → allow-list clamp → boot fallback).
    val workspaceResolver: WorkspaceResolver? = null,
    // Long-lived scope owned by the module — backs the event-store subscription.
    // SupervisorJob so one failure doesn't kill the whole module;
    // Dispatchers.Default since the work is non-blocking and CPU-light.
    val moduleScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
    approvalCoordinator: ApprovalCoordinator? = null,
    val narrationMaxPerRun: Int = 100,
    // Cross-session, repo-scoped memory. Null disables the hooks entirely (tests / static path).
    // Rebuilt by ConfigService when project.enabled is toggled live.
    projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = null,
    // Display-only architect contradiction surfacing (BACKLOG §B-§4). Null disables the hook
    // (tests / project.enabled=false). Unlike projectMemory there is no live config-rebuild hook:
    // a server restart re-reads project.enabled, which is enough for this informational flag.
    private val architectContradictionChecker: ArchitectContradictionChecker? = null,
    // Heuristic concept compiler write-side (design 2026-07-12-acr-concept-compiler.md). Null disables
    // the promotion hook (tests). Live-only like the subscriptions below — never runs under replay.
    private val conceptCompilerService: com.correx.apps.server.concept.ConceptCompilerService? = null,
    // Live, swappable config. Null only in tests that don't exercise config editing; defaults to a
    // holder seeded from defaults so callers always have a value to read.
    val configHolder: com.correx.core.config.ConfigHolder =
        com.correx.core.config.ConfigHolder(com.correx.core.config.CorrexConfig()),
    // Driver for the two-phase freestyle workflow: locks the plan and runs phase 2.
    // Null on non-freestyle paths; injected by Main when freestyle is configured.
    private val freestyleDriver: FreestyleDriver? = null,
    // Operator profile snapshot loaded at server start. Null when personalization is disabled.
    private val operatorProfile: OperatorProfile? = null,
    // Propose learned profile updates at session end. Null when learn=false or no profile.
    // Rebuilt by ConfigService when personalization.enabled/learn is toggled live.
    profileAdaptationService: com.correx.apps.server.personalization.ProfileAdaptationService? = null,
    // Continuous health watch (observability-spec §4). Null disables it (tests / health.enabled=false);
    // records degraded/restored edge events on the system session, started in [start].
    private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null,
    // Semantic enrichment for the task context bundle (GET /tasks/{id}/context). Null disables it.
    val taskKnowledgeRetriever: com.correx.core.tasks.TaskKnowledgeRetriever? = null,
    // Resolves linked ADRs/docs inline in the task context bundle. Null leaves them as raw links.
    val taskDocumentResolver: com.correx.core.tasks.TaskDocumentResolver? = null,
    // Resolves linked artifacts inline in the task context bundle. Null leaves them as raw links.
    val taskArtifactResolver: com.correx.core.tasks.TaskArtifactResolver? = null,
    // Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links.
    val taskSessionResolver: com.correx.core.tasks.TaskSessionResolver? = null,
    // Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read
    // (the endpoint then only acts on commits supplied in the request body).
    val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null,
    // Optional plain-Git transport for a server-owned checkout. It creates and pushes a per-run
    // branch; null preserves the ordinary local-workspace lifecycle.
    private val gitRunBranchTransport: com.correx.apps.server.git.GitRunBranchTransport? = null,
) {
    val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
        orchestrator = orchestrator,
    )

    // Config-derived services that ConfigService swaps on a live edit. Reads happen at use-time
    // (next session / next chat turn), so in-flight sessions keep the config they started with.
    @Volatile
    var routerFacade: TalkieFacade = routerFacade
        private set

    @Volatile
    var projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = projectMemory
        private set

    @Volatile
    var profileAdaptationService: com.correx.apps.server.personalization.ProfileAdaptationService? =
        profileAdaptationService
        private set

    // Set by Main after construction (it needs a reference to the module to build the rebuild hook).
    lateinit var configService: com.correx.apps.server.config.ConfigService

    /**
     * Swaps the config-derived services after a live config edit. Called by [ConfigService].
     * In-flight sessions are untouched; the next session/turn reads the new instances.
     */
    fun applyRebuiltServices(
        newTalkieFacade: TalkieFacade,
        newProjectMemory: com.correx.apps.server.memory.ProjectMemoryService?,
        newProfileAdaptationService: com.correx.apps.server.personalization.ProfileAdaptationService?,
    ) {
        this.routerFacade = newTalkieFacade
        this.projectMemory = newProjectMemory
        this.profileAdaptationService = newProfileAdaptationService
    }

    /** Orchestration config for a new session, with live-tunable knobs read from [configHolder]. */
    fun orchestrationConfig(): OrchestrationConfig = defaultOrchestrationConfig.copy(
        stageTimeoutMs = configHolder.get().orchestration.stageTimeoutMs,
    )

    private var subscriptionJob: Job? = null
    private val activeSessionJobs = ConcurrentHashMap<SessionId, Job>()

    /**
     * Subscribe to the event store and forward [ApprovalRequestedEvent] payloads to
     * the [ApprovalCoordinator]. Idempotent: calling twice is a no-op for the second call.
     *
     * Also pre-populates the coordinator's session lookup map from any approvals that were
     * pending when the server last stopped. This prevents a race where a reconnecting TUI
     * sends an ApprovalResponse before the per-connection replaySnapshot() has registered
     * the session mapping.
     */
    fun start() {
        if (subscriptionJob != null) return
        preRegisterPendingApprovals()
        repairStuckApprovalPauses()
        resumeAbandonedSessions()
        subscriptionJob = eventStore.subscribeAll()
            .filter { it.payload is ApprovalRequestedEvent }
            .onEach {
                approvalCoordinator.onApprovalRequested(it.payload as ApprovalRequestedEvent, it.sessionSequence)
            }
            .launchIn(moduleScope)

        // When a PAUSED session's approval is resolved without a live orchestrator coroutine
        // (server was restarted while approval was pending), emit OrchestrationResumedEvent
        // triggers this subscription to continue the session.
        eventStore.subscribeAll()
            .filter { it.payload is OrchestrationResumedEvent }
            .onEach { event ->
                val sessionId = (event.payload as OrchestrationResumedEvent).sessionId
                if (activeSessionJobs.containsKey(sessionId)) return@onEach
                val orchState = orchestrationRepository.getState(sessionId)
                if (orchState.status != OrchestrationStatus.RUNNING) return@onEach
                val graph = workflowRegistry.find(orchState.workflowId) ?: return@onEach
                launchSessionResumeWithRehydrate(sessionId, graph)
            }
            .launchIn(moduleScope)

        // Live-only: subscribeAll() replays nothing and ServerModule is never built under
        // ReplayOrchestrator, so narration never re-fires on restart/replay (invariant #8).
        NarrationSubscriber(
            eventStore = eventStore,
            routerFacade = routerFacade,
            scope = moduleScope,
            maxPerRun = narrationMaxPerRun,
        ).start()

        // Continuous, edge-triggered health watch (observability-spec §4). Live-only like narration:
        // probes read the environment and record degraded/restored events; replay reads those facts.
        healthMonitor?.start(moduleScope)

        // Display-only architect contradiction surfacing (BACKLOG §B-§4). Live-only like the hooks
        // above: subscribeAll() replays nothing and ServerModule is never built under replay, so a
        // restart never re-fires the flag (invariant #8). Never halts a stage — failures are logged
        // and swallowed (runCatching) and the flag is emitted purely for the operator to eyeball.
        architectContradictionChecker?.let { checker ->
            eventStore.subscribeAll()
                .filter {
                    it.payload is ArtifactCreatedEvent &&
                        (it.payload as ArtifactCreatedEvent).stageId.value == ARCHITECT_STAGE_ID
                }
                .onEach { stored ->
                    runCatching {
                        handleArchitectArtifact(checker, stored.payload as ArtifactCreatedEvent)
                    }.onFailure { log.warn("architect contradiction check failed: {}", it.message) }
                }
                .launchIn(moduleScope)
        }

        // Heuristic concept compiler (design 2026-07-12-acr-concept-compiler.md). Live-only like the
        // hooks above: subscribeAll() replays nothing and ServerModule is never built under replay, so
        // promotion never re-fires on restart/replay (invariant #8). A StageCompleted may resolve a
        // validated failure→fix; re-fold the log and promote any newly-eligible fingerprint. Failures
        // are logged and swallowed — promotion is best-effort enrichment, never on the stage's path.
        conceptCompilerService?.let { compiler ->
            eventStore.subscribeAll()
                .filter { it.payload is StageCompletedEvent }
                .onEach {
                    runCatching { compiler.runOnce() }
                        .onFailure { e -> log.warn("concept compiler run failed: {}", e.message) }
                }
                .launchIn(moduleScope)
        }
    }

    /**
     * Runs the architect contradiction check for a freshly-created `design` artifact and, when the
     * checker surfaces a non-null flag, appends the [PossibleContradictionFlaggedEvent]. Display-only:
     * the flag is informational and never halts the stage. Factored out of the subscription so the
     * extraction/dispatch path is unit-testable without a live event flow.
     *
     * @return the appended flag, or null when the artifact text could not be resolved or the checker
     * found nothing near the architect's decision.
     */
    internal suspend fun handleArchitectArtifact(
        checker: ArchitectContradictionChecker,
        event: ArtifactCreatedEvent,
    ): PossibleContradictionFlaggedEvent? {
        val decisionText = resolveArchitectDecisionText(event) ?: return null
        val workspaceRoot = sessionWorkspaceRoot(event.sessionId) ?: return null
        val flag = checker.check(event.sessionId, event.stageId, decisionText, workspaceRoot) ?: return null
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(java.util.UUID.randomUUID().toString()),
                    sessionId = event.sessionId,
                    timestamp = Clock.System.now(),
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = flag,
            ),
        )
        return flag
    }

    /**
     * Resolves the architect's decision text for [event] from the CAS-stored `design` artifact.
     * Finds the content hash by scanning the session for the [ArtifactContentStoredEvent] matching
     * the artifact id (emitted at inference time, before [ArtifactCreatedEvent]), fetches the bytes,
     * and prefers the design schema's `approach` field ("the chosen approach and why") as the
     * decision summary; falls back to the whole artifact text, capped at [DECISION_TEXT_CAP] chars.
     *
     * @return the decision text, or null when no content hash, bytes, or text could be resolved.
     */
    private suspend fun resolveArchitectDecisionText(event: ArtifactCreatedEvent): String? {
        val contentHash = eventStore.read(event.sessionId)
            .asSequence()
            .map { it.payload }
            .filterIsInstance<ArtifactContentStoredEvent>()
            .firstOrNull { it.artifactId == event.artifactId }
            ?.contentHash ?: return null
        val text = artifactStore.get(contentHash)?.toString(Charsets.UTF_8)?.takeIf { it.isNotBlank() }
            ?: return null
        val approach = runCatching {
            (lenientJson.parseToJsonElement(text).jsonObject["approach"] as? JsonPrimitive)
                ?.takeIf { it.isString }?.content
        }.getOrNull()?.takeIf { it.isNotBlank() }
        return (approach ?: text).take(DECISION_TEXT_CAP)
    }

    /**
     * Launch the orchestrator's [run] for a new session and register the job in [activeSessionJobs].
     * This ensures the [OrchestrationResumedEvent] subscription guard
     * (`activeSessionJobs.containsKey(sessionId)`) works correctly — without it, the original
     * run job is invisible to the guard and a second `resume()` is spuriously launched,
     * duplicating inference calls (the root cause of the healthcheck script corruption).
     */
    /**
     * Resolves [workingDir] into a trusted workspace and records the decision as a
     * [SessionWorkspaceBoundEvent] (invariant #9), returning the bound context or null when no
     * resolver is configured. Shared by the WS StartSession path and the REST POST /sessions
     * launcher so both anchor a session's workspace identically — the REST route previously skipped
     * this entirely, leaving sessions with no bound workspace.
     */
    suspend fun bindWorkspace(sessionId: SessionId, workingDir: String?): WorkspaceContext? {
        val resolver = workspaceResolver ?: return null
        val workspace = when (val resolution = withContext(Dispatchers.IO) { resolver.resolve(workingDir) }) {
            is WorkspaceResolution.Bound -> {
                log.info("workspace bound: session={} root={}", sessionId.value, resolution.workspace.workspaceRoot)
                resolution.workspace
            }
            is WorkspaceResolution.Rejected -> {
                log.warn(
                    "workspace rejected: session={} reason={} fallback={}",
                    sessionId.value, resolution.reason, resolution.fallback.workspaceRoot,
                )
                resolution.fallback
            }
        }
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(java.util.UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = Clock.System.now(),
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = SessionWorkspaceBoundEvent(
                    sessionId = sessionId,
                    workspaceRoot = workspace.workspaceRoot.toString(),
                    allowedPaths = workspace.allowedPaths.map { it.toString() },
                ),
            ),
        )
        return workspace
    }

    fun launchSessionRun(
        sessionId: SessionId,
        graph: com.correx.core.transitions.graph.WorkflowGraph,
        sessionConfig: OrchestrationConfig = orchestrationConfig(),
    ) {
        activeSessionJobs.computeIfAbsent(sessionId) {
            moduleScope.launch {
                withSessionContext(sessionId) {
                    // Record the repo map + seed prior-session memory before the run so stages
                    // see both in context.
                    sessionWorkspaceRoot(sessionId)?.let { root ->
                        projectMemory?.let { pm ->
                            runCatching {
                                pm.observeAndRecord(sessionId, root)
                                pm.indexAndRecord(sessionId, root)
                                pm.retrieveAndSeed(sessionId, root)
                            }
                        }
                    }
                    // Bind operator profile snapshot as an event so replay reads the recorded
                    // fact rather than re-reading the live file (invariants #8/#9).
                    operatorProfile?.let { profile ->
                        eventStore.append(
                            NewEvent(
                                metadata = EventMetadata(
                                    eventId = EventId(java.util.UUID.randomUUID().toString()),
                                    sessionId = sessionId,
                                    timestamp = Clock.System.now(),
                                    schemaVersion = 1,
                                    causationId = null,
                                    correlationId = null,
                                ),
                                payload = OperatorProfileBoundEvent(
                                    sessionId = sessionId,
                                    about = profile.about,
                                    approvalMode = profile.preferences.approvalMode,
                                    preferredModels = profile.preferences.preferredModels,
                                    conventions = profile.preferences.conventions,
                                ),
                            ),
                        )
                    }
                    bindProjectProfile(sessionId)
                    bindAgentInstructions(sessionId)
                    runCatching {
                        suspend fun runAndFinalize() {
                            val result = orchestrator.run(sessionId, graph, sessionConfig)
                            freestyleHandoff(sessionId, graph, result)
                            // Distil this run's decisions into durable project memory on completion.
                            sessionWorkspaceRoot(sessionId)?.let { root ->
                                projectMemory?.let { pm -> pm.persist(sessionId, root) }
                            }
                            // Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
                            operatorProfile?.let { profile ->
                                profileAdaptationService?.let { svc ->
                                    runCatching { svc.proposeAdaptation(sessionId, profile) }
                                        .onFailure { log.warn("Profile adaptation failed: {}", it.message) }
                                }
                            }
                        }
                        val workspaceRoot = sessionWorkspaceRoot(sessionId)?.let(java.nio.file.Path::of)
                        if (gitRunBranchTransport != null && workspaceRoot != null) {
                            gitRunBranchTransport.onRunBranch(sessionId, workspaceRoot) { runAndFinalize() }
                        } else {
                            runAndFinalize()
                        }
                    }.onFailure { ex -> recordUnhandledFailure(sessionId, graph, sessionConfig, ex) }
                    activeSessionJobs.remove(sessionId)
                }
            }
        }
    }

    // An exception escaped the orchestrator (the run never reached its own terminal WorkflowFailed).
    // Record a truthful terminal event: the real failing stage (not graph.start), the real message,
    // and a computed retryExhausted — so a headless client sees an honest failure, not a placeholder.
    private suspend fun recordUnhandledFailure(
        sessionId: SessionId,
        graph: com.correx.core.transitions.graph.WorkflowGraph,
        sessionConfig: OrchestrationConfig,
        ex: Throwable,
    ) {
        log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
        val orchState = orchestrationRepository.getState(sessionId)
        val failingStageId = orchState.currentStageId ?: graph.start
        val reason = ex.message
            ?.trim()
            ?.takeIf { it.isNotEmpty() }
            ?: "unexpected orchestrator failure: unhandled exception with no message " +
            "during stage '${failingStageId.value}'"
        val maxAttempts = orchState.retryPolicy?.maxAttempts
            ?: sessionConfig.retryPolicy.maxAttempts
        val retryExhausted = orchState.retryCount >= maxAttempts
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(java.util.UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = Clock.System.now(),
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = WorkflowFailedEvent(
                    sessionId = sessionId,
                    stageId = failingStageId,
                    reason = reason,
                    retryExhausted = retryExhausted,
                ),
            ),
        )
    }

    /**
     * Resolves the workflow graph for resuming a freestyle phase-2 session. Phase-2 graphs
     * (`freestyle-<sessionId>`) are compiled from the locked plan, never registered in the
     * TOML registry, so the resume route cannot find them there. Rehydrates the artifact
     * cache first because the plan content lives in it after a restart.
     */
    suspend fun freestyleResumeGraph(
        sessionId: SessionId,
        workflowId: String,
    ): com.correx.core.transitions.graph.WorkflowGraph? {
        if (!workflowId.startsWith("freestyle-")) return null
        orchestrator.rehydrate(sessionId)
        return freestyleDriver?.compiledGraph(sessionId)
    }

    /**
     * Bind the per-repo project profile (curated .correx/project.toml) as an event so stages,
     * router chat triage, and replay read the recorded snapshot, never the live file
     * (invariants #8/#9). Shared by the workflow path and the chat-session path.
     */
    /**
     * The session's bound workspace root — the same one the tool jail and [SessionWorkspaceBoundEvent]
     * use. Repo-scoped work must skip unbound sessions: a server cwd is not a session fact and
     * cannot safely stand in for the recorded workspace binding.
     */
    private fun sessionWorkspaceRoot(sessionId: SessionId): String? =
        runCatching { sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot }
            .getOrNull()

    suspend fun bindProjectProfile(sessionId: SessionId) {
        val workspaceRoot = sessionWorkspaceRoot(sessionId) ?: return
        val projectProfile = withContext(Dispatchers.IO) { ProjectProfileLoader.load(workspaceRoot) }
        if (projectProfile.isEmpty()) return
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(java.util.UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = Clock.System.now(),
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = ProjectProfileBoundEvent(
                    sessionId = sessionId,
                    workspaceRoot = workspaceRoot,
                    about = projectProfile.about,
                    conventions = projectProfile.conventions,
                    commands = projectProfile.commands,
                ),
            ),
        )
    }

    /**
     * Bind standing agent instructions (CLAUDE.md / AGENTS.md at the workspace root) as an
     * event so stages, router chat triage, and replay read the recorded snapshot, never the
     * live file (invariants #8/#9). Mirrors [bindProjectProfile].
     */
    suspend fun bindAgentInstructions(sessionId: SessionId) {
        val workspaceRoot = sessionWorkspaceRoot(sessionId) ?: return
        val instructions = withContext(Dispatchers.IO) { AgentInstructionsLoader.load(workspaceRoot) }
        if (instructions.isEmpty()) return
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(java.util.UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = Clock.System.now(),
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = AgentInstructionsBoundEvent(
                    sessionId = sessionId,
                    workspaceRoot = workspaceRoot,
                    sources = instructions.sources,
                    content = instructions.content,
                ),
            ),
        )
    }

    /**
     * Phase-2 handoff for freestyle sessions, shared by every launcher that can finish a
     * planning graph (fresh run and all resume paths). Locks the plan and executes it,
     * guarded so a failed planning run never emits a spurious ExecutionPlanRejected and
     * a session whose plan is already locked never double-locks.
     */
    private suspend fun freestyleHandoff(
        sessionId: SessionId,
        graph: com.correx.core.transitions.graph.WorkflowGraph,
        result: WorkflowResult,
    ) {
        if (graph.id != "freestyle_planning") return
        if (result !is WorkflowResult.Completed) return
        val planAlreadyLocked = eventStore.read(sessionId)
            .any { it.payload is ExecutionPlanLockedEvent }
        if (planAlreadyLocked) return
        // completeWorkflow evicts artifactContentCache on the planning graph's completion (#54),
        // so the execution_plan slot lockAndRun reads is gone by now. Rebuild it from the durable
        // ArtifactContentStoredEvent before handing off. Rehydrate-safe and idempotent.
        orchestrator.rehydrate(sessionId)
        freestyleDriver?.lockAndRun(sessionId)
    }

    /**
     * Rehydrates [artifactContentCache] from durable events and then resumes the session.
     * Used by [POST /sessions/{id}/resume], boot-time abandoned-session pickup, and the
     * restart-while-approval-pending subscription — every resume needs the rehydrate
     * (pre-restart `needs`-artifacts live only in events/CAS) and the freestyle handoff
     * (a planning graph that completes on resume must still lock and run phase 2).
     */
    fun launchSessionResumeWithRehydrate(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) {
        activeSessionJobs.computeIfAbsent(sessionId) {
            moduleScope.launch {
                runCatching {
                    orchestrator.rehydrate(sessionId)
                    val result = orchestrator.resume(sessionId, graph, orchestrationConfig())
                    freestyleHandoff(sessionId, graph, result)
                }.onFailure { e ->
                    log.error("resumeSession (rehydrate): session={} failed", sessionId.value, e)
                }
                activeSessionJobs.remove(sessionId)
            }
        }
    }

    private fun resumeAbandonedSessions() {
        val maxAgeMinutes = configHolder.get().orchestration.resumeAbandonedMaxAgeMinutes
        if (maxAgeMinutes <= 0) {
            log.info("resumeAbandoned: disabled (resume_abandoned_max_age_minutes={})", maxAgeMinutes)
            return
        }
        val cutoff = Clock.System.now().minus(maxAgeMinutes, DateTimeUnit.MINUTE)
        eventStore.allSessionIds().forEach { sessionId ->
            val orchState = orchestrationRepository.getState(sessionId)
            // Skip approval-PAUSED sessions — they wait for user approval, not for computation.
            // preRegisterPendingApprovals() already routes their responses. When the user approves,
            // submitApprovalDecision emits OrchestrationResumedEvent which triggers the subscription
            // above to continue the session.
            //
            // Clarification-PAUSED sessions are the exception: there is no lazy trigger that
            // re-surfaces the modal after a restart (the parked stage's coroutine died with the
            // deferred), so we eagerly relaunch them. rehydrate+resume re-runs the parked stage,
            // which re-emits a fresh, live ClarificationRequestedEvent (round budget MAX=3, only 1
            // prior round recorded, so it re-parks rather than silently proceeding). The re-emitted
            // event live-pushes the modal to connected clients, and SessionEventBridge.replaySnapshot
            // re-pushes it to clients that connect afterwards.
            val clarificationParked = orchState.status == OrchestrationStatus.PAUSED &&
                orchState.pauseReason == CLARIFICATION_PENDING_REASON
            if (orchState.status != OrchestrationStatus.RUNNING && !clarificationParked) return@forEach
            val lastEventAt = eventStore.read(sessionId).lastOrNull()?.metadata?.timestamp ?: return@forEach
            if (lastEventAt < cutoff) {
                log.info(
                    "resumeAbandoned: parking stale session={} (last event {}, cutoff {}m) — POST /resume revives it",
                    sessionId.value, lastEventAt, maxAgeMinutes,
                )
                // Record the parking decision as an event (invariant #9): the projection
                // otherwise reports RUNNING forever for a session no process is executing,
                // and the next boot would re-log the same skip.
                moduleScope.launch {
                    eventStore.append(
                        NewEvent(
                            metadata = EventMetadata(
                                eventId = EventId(java.util.UUID.randomUUID().toString()),
                                sessionId = sessionId,
                                timestamp = Clock.System.now(),
                                schemaVersion = 1,
                                causationId = null,
                                correlationId = null,
                            ),
                            payload = OrchestrationPausedEvent(
                                sessionId = sessionId,
                                stageId = orchState.currentStageId ?: StageId.NONE,
                                reason = "ABANDONED_STALE",
                            ),
                        ),
                    )
                }
                return@forEach
            }
            val graph = workflowRegistry.find(orchState.workflowId) ?: run {
                log.warn(
                    "resumeAbandoned: no graph for session={} workflowId={}",
                    sessionId.value,
                    orchState.workflowId,
                )
                return@forEach
            }
            log.info("resumeAbandoned: launching session={} workflow={}", sessionId.value, orchState.workflowId)
            launchSessionResumeWithRehydrate(sessionId, graph)
        }
    }

    private val sessionSummaryProjector = SessionSummaryProjector()

    // ponytail: memoized on lastGlobalSequence — GET /sessions was re-reading + re-projecting every
    // session's full log on every call (S5). Any new event anywhere bumps the global sequence, so a
    // stale cache is impossible: a mismatch always forces a full recompute. Volatile pair-swap is
    // enough here (no lock) — worst case under a race is one redundant recompute, never a stale read.
    @Volatile
    private var sessionSummaryCache: Pair<Long, List<SessionSummary>>? = null

    suspend fun listSessionSummaries(): List<SessionSummary> {
        val currentSeq = eventStore.lastGlobalSequence()
        sessionSummaryCache?.let { (seq, summaries) -> if (seq == currentSeq) return summaries }
        val summaries = eventStore.allSessionIds()
            // The system session carries global health events, not a user workflow — hide it.
            .filter { it != com.correx.apps.server.health.SYSTEM_SESSION }
            .map { sessionId ->
                sessionSummaryProjector.project(sessionId, eventStore.readFrom(sessionId, fromSequence = 0L))
            }
        sessionSummaryCache = currentSeq to summaries
        return summaries
    }

    private fun preRegisterPendingApprovals() {
        val projector = ApprovalProjector(DefaultApprovalReducer())
        eventStore.allSessionIds().forEach { sessionId ->
            val orchState = orchestrationRepository.getState(sessionId)
            if (!orchState.pendingApproval) return@forEach
            val events = eventStore.read(sessionId)
            val approvalState = events.fold(projector.initial()) { state, event ->
                projector.apply(state, event)
            }
            approvalState.requests.values
                .filter { req -> approvalState.decisions.values.none { it.requestId == req.id } }
                .forEach { req -> approvalCoordinator.registerPendingRequest(req.id, sessionId, req.tier) }
        }
    }

    /**
     * One-shot boot repair for sessions stuck PAUSED + pendingApproval with no actual unresolved
     * approval request — the symptom of a pre-Feb-13 orchestrator bug where approval was resolved
     * but OrchestrationResumedEvent was never emitted. Appends the missing resume so the projection
     * corrects permanently. Previously this ran per WS connection inside SessionEventBridge.
     * replaySnapshot, which put a racy write on a pure read path (two concurrent clients could
     * double-append). Running it once at boot removes that hazard; the bug that creates the state is
     * fixed, so no session newly enters it at runtime.
     *
     * Guard: skip when there are more OrchestrationPausedEvents than ApprovalRequestedEvents — the
     * session just entered the gate and its ApprovalRequestedEvent isn't stored yet; a spurious
     * resume would hide the pending approval.
     */
    private fun repairStuckApprovalPauses() {
        val projector = ApprovalProjector(DefaultApprovalReducer())
        eventStore.allSessionIds().forEach { sessionId ->
            val orchState = orchestrationRepository.getState(sessionId)
            if (!orchState.pendingApproval) return@forEach
            val events = eventStore.read(sessionId)
            val approvalState = events.fold(projector.initial()) { state, event -> projector.apply(state, event) }
            val hasUnresolved = approvalState.requests.values
                .any { req -> approvalState.decisions.values.none { it.requestId == req.id } }
            val pauseCount = events.count { it.payload is OrchestrationPausedEvent }
            val approvalRequestCount = events.count { it.payload is ApprovalRequestedEvent }
            val hasUnpairedPause = pauseCount > approvalRequestCount
            val alreadyResumed = events.any { it.payload is OrchestrationResumedEvent }
            val stageId = orchState.currentStageId
            if (hasUnresolved || hasUnpairedPause || alreadyResumed || stageId == null) return@forEach
            log.info("repairStuckApprovalPauses: emitting missing resume for stuck session={}", sessionId.value)
            moduleScope.launch {
                eventStore.append(
                    NewEvent(
                        metadata = EventMetadata(
                            eventId = EventId(java.util.UUID.randomUUID().toString()),
                            sessionId = sessionId,
                            timestamp = Clock.System.now(),
                            schemaVersion = 1,
                            causationId = null,
                            correlationId = null,
                        ),
                        payload = OrchestrationResumedEvent(sessionId = sessionId, stageId = stageId),
                    ),
                )
            }
        }
    }

    companion object {
        /** Workflow stage id that produces the `design` artifact (examples/workflows/role_pipeline.toml). */
        const val ARCHITECT_STAGE_ID = "architect"

        /** Pause reason emitted when a stage parks on operator clarification (see SessionOrchestrator). */
        private const val CLARIFICATION_PENDING_REASON = "CLARIFICATION_PENDING"

        /** Cap on the architect decision text embedded when no `approach` field is present. */
        private const val DECISION_TEXT_CAP = 4000

        private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true }
    }
}
