package com.correx.apps.server.ws

import com.correx.apps.server.ServerModule
import com.correx.apps.server.config.ConfigUpdateResult
import com.correx.apps.server.health.HealthInspectionService
import com.correx.apps.server.metrics.MetricsInspectionService
import com.correx.apps.server.protocol.ArtifactSummaryDto
import com.correx.apps.server.protocol.ConfigFieldDto
import com.correx.apps.server.protocol.GrantDto
import com.correx.apps.server.protocol.IdeaDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.workspace.WorkspaceFiles
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.ProjectIdentity
import com.correx.core.events.types.ProjectId
import com.correx.core.talkie.IdeaReader
import com.correx.core.config.EditableConfig
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import java.nio.file.Paths
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import org.slf4j.LoggerFactory

private val log = LoggerFactory.getLogger("StreamQueries")

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

/**
 * Read-only / snapshot queries served over the global stream: artifact listings and config
 * get/update. Kept separate from [GlobalStreamHandler] (which owns the connection lifecycle and
 * session-mutating commands) so each stays focused. Every method here is replay-neutral — it
 * reads recorded state or edits TOML config, never appends events.
 */
class StreamQueries(private val module: ServerModule) {

    private val metricsService = MetricsInspectionService(module.eventStore)
    private val healthService = HealthInspectionService(module.eventStore)

    /**
     * A session's derived metrics as a snapshot frame. Replays the event log through
     * [MetricsInspectionService] (the same projection the REST metrics endpoint uses) — pure read,
     * no events appended, so it is replay-neutral (invariant #1/#8).
     */
    suspend fun sessionStats(sessionId: SessionId): ServerMessage.SessionStats {
        val report = withContext(Dispatchers.IO) { metricsService.inspect(sessionId) }
        return ServerMessage.SessionStats(sessionId = sessionId, stats = report)
    }

    /**
     * The system health-check report as a snapshot frame. Replays the system session through
     * [HealthInspectionService] (the same projection the REST /health/checks endpoint uses) — pure
     * read, no events appended, so it is replay-neutral (invariant #1/#8). When health monitoring is
     * disabled or no probes have fired yet, the report has empty subjects and overall=HEALTHY; the
     * client renders a visible "no health probes recorded" fallback rather than going blank.
     */
    suspend fun healthChecks(): ServerMessage.HealthChecks {
        val report = withContext(Dispatchers.IO) { healthService.inspect() }
        return ServerMessage.HealthChecks(health = report)
    }

    /**
     * The session workspace's file paths for the `@` picker. Resolves the bound workspace root from
     * the recorded [SessionWorkspaceBoundEvent] (invariant #9) and lists it by path. Pure snapshot
     * read — no events appended (replay-neutral). Empty when the session has no bound workspace.
     */
    suspend fun listFiles(sessionId: SessionId): ServerMessage.FileList {
        val paths = withContext(Dispatchers.IO) {
            workspaceRootOf(sessionId)?.let { WorkspaceFiles.list(Paths.get(it)) } ?: emptyList()
        }
        return ServerMessage.FileList(sessionId = sessionId, paths = paths)
    }

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

    /**
     * The stable [ProjectId] a session's bound workspace resolves to (the same derivation the
     * approval gate uses), or null when the session has no bound workspace. Used to scope a PROJECT
     * grant to the right repository without trusting a client-supplied id.
     */
    fun projectIdForSession(sessionId: SessionId): ProjectId? =
        workspaceRootOf(sessionId)?.let { ProjectIdentity.of(it) }

    /**
     * The active cross-session grants as a snapshot frame, folded from the grant ledger via the
     * approval reducer. Expired grants are dropped so the viewer only lists ones still in force.
     * Pure read — no events appended (replay-neutral).
     */
    fun listGrants(): ServerMessage.GrantList {
        val now = Clock.System.now()
        val grants = module.approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
            .filter { grant -> grant.expiresAt.let { it == null || it > now } }
            .map { grant ->
                GrantDto(
                    grantId = grant.id.value,
                    scope = grant.scope.kindName(),
                    toolName = grant.scope.toolNameOrNull(),
                    projectId = (grant.scope as? GrantScope.PROJECT)?.projectId?.value,
                    tiers = grant.permittedTiers.map { it.name }.sorted(),
                    reason = grant.reason,
                    expiresAtMs = grant.expiresAt?.toEpochMilliseconds(),
                )
            }
        return ServerMessage.GrantList(grants = grants)
    }

    private fun GrantScope.kindName(): String = when (this) {
        is GrantScope.SESSION -> "SESSION"
        is GrantScope.STAGE -> "STAGE"
        is GrantScope.PROJECT -> "PROJECT"
        is GrantScope.GLOBAL -> "GLOBAL"
    }

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

    /**
     * Reads a session's events to assemble its artifact listing (creation order preserved),
     * resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —
     * no events appended, so it is replay-neutral (invariant #1/#8).
     */
    suspend fun listArtifacts(sessionId: SessionId): ServerMessage.ArtifactList {
        val events = withContext(Dispatchers.IO) { module.eventStore.read(sessionId) }
        val artifacts = planArtifacts(events).map { plan ->
            ArtifactSummaryDto(
                artifactId = plan.artifactId,
                stageId = plan.stageId,
                schemaVersion = plan.schemaVersion,
                phase = plan.phase,
                content = resolveContent(plan.contentHash),
            )
        }
        return ServerMessage.ArtifactList(sessionId = sessionId, artifacts = artifacts)
    }

    /**
     * Resolves an artifact's UTF-8 content from CAS, degrading to null instead of throwing. A
     * referenced hash that resolves to null is logged WARN — most likely the bytes were evicted by
     * the CAS [com.correx.infrastructure.artifactscas.evict.Evictor] while the event log still
     * references them, which would otherwise blank a single artifact with no signal.
     */
    private suspend fun resolveContent(contentHash: ArtifactId?): String? {
        val hash = contentHash ?: return null
        val bytes = runCatching { withContext(Dispatchers.IO) { module.artifactStore.get(hash) } }
            .getOrElse {
                log.warn("artifact content read failed for hash={}: {}", hash.value, it.message)
                null
            }
        if (bytes == null) {
            log.warn("artifact content missing for referenced hash={} (evicted/compacted?)", hash.value)
            return null
        }
        val rawString = bytes.toString(Charsets.UTF_8).takeIf { it.isNotBlank() } ?: return null
        return dereferenceEnvelope(lenientJson, rawString) { innerHash ->
            runCatching { withContext(Dispatchers.IO) { module.artifactStore.get(innerHash) } }
                .getOrNull()
                ?.toString(Charsets.UTF_8)
                ?.takeIf { it.isNotBlank() }
        } ?: rawString
    }

    /**
     * The cross-session idea board as a snapshot frame, folded from the whole event log via
     * [IdeaReader]. Pure read — no events appended, so it is replay-neutral (invariant #1/#8).
     */
    suspend fun listIdeas(): ServerMessage.IdeaList {
        val ideas = withContext(Dispatchers.IO) { IdeaReader(module.eventStore).activeIdeas() }
        return ServerMessage.IdeaList(
            ideas = ideas.map { IdeaDto(ideaId = it.id, text = it.text, capturedAtMs = it.capturedAtMs) },
        )
    }

    /** Current editable config as a snapshot frame. [error] / [restartRequired] are set by updates. */
    fun configSnapshot(error: String?, restartRequired: List<String>): ServerMessage.ConfigSnapshot {
        val fields = module.configService.snapshot().map { (field, value) ->
            ConfigFieldDto(
                key = field.key,
                type = field.type.name,
                value = value,
                options = field.options,
                restartRequired = field.key in EditableConfig.restartRequiredKeys,
            )
        }
        return ServerMessage.ConfigSnapshot(fields = fields, restartRequired = restartRequired, error = error)
    }

    /** Applies a config patch and returns the resulting snapshot (with an error set on failure). */
    fun updateConfig(patch: Map<String, String>): ServerMessage.ConfigSnapshot =
        when (val result = module.configService.apply(patch)) {
            is ConfigUpdateResult.Ok -> configSnapshot(error = null, restartRequired = result.restartRequired.toList())
            is ConfigUpdateResult.Error -> configSnapshot(error = result.message, restartRequired = emptyList())
        }

    companion object {
        /**
         * If [rawContent] is a `FileWrittenArtifact` envelope (JSON object with both "path" and
         * "contentHash" string fields), resolves the real content via [lookup] on the inner hash.
         * Returns null when [rawContent] is not an envelope or [lookup] returns null (caller falls
         * back to [rawContent]). Non-throwing — JSON parse failure silently returns null.
         */
        internal suspend fun dereferenceEnvelope(
            json: Json,
            rawContent: String,
            lookup: suspend (ArtifactId) -> String?,
        ): String? {
            val obj = runCatching {
                json.parseToJsonElement(rawContent).jsonObject
            }.getOrNull() ?: return null

            val innerHash = (obj["contentHash"] as? JsonPrimitive)?.takeIf { it.isString }?.content
                ?.takeIf { it.isNotBlank() } ?: return null
            val path = (obj["path"] as? JsonPrimitive)?.takeIf { it.isString }?.content
            if (path == null) return null

            val resolved = lookup(ArtifactId(innerHash))
            if (resolved == null) {
                log.warn(
                    "FileWrittenArtifact envelope references missing inner hash={} (evicted?)",
                    innerHash,
                )
            }
            return resolved
        }

        /**
         * Folds a session's events into one plan per artifact (creation order preserved). Pure — no
         * CAS read — so it is unit-testable. [ArtifactContentStoredEvent] is emitted at inference
         * time, BEFORE the [ArtifactCreatedEvent] that fires at validation, so BOTH seed the
         * accumulator (`getOrPut`); folding only on creation silently dropped the content hash and
         * blanked every artifact. As a fallback, an artifact with no stored hash uses its stage's
         * last inference output so the operator still sees what the stage produced.
         */
        internal fun planArtifacts(events: List<StoredEvent>): List<ArtifactPlan> {
            val accs = LinkedHashMap<String, ArtifactAcc>()
            val lastInferenceByStage = HashMap<String, ArtifactId>()
            for (event in events) {
                when (val p = event.payload) {
                    is ArtifactCreatedEvent -> accs.getOrPut(p.artifactId.value) { ArtifactAcc() }.apply {
                        stageId = p.stageId.value
                        schemaVersion = p.schemaVersion
                    }
                    is ArtifactValidatingEvent -> accs[p.artifactId.value]?.let { it.phase = "VALIDATING" }
                    is ArtifactValidatedEvent -> accs[p.artifactId.value]?.let { it.phase = "VALIDATED" }
                    is ArtifactContentStoredEvent ->
                        accs.getOrPut(p.artifactId.value) { ArtifactAcc() }.contentHash = p.contentHash
                    is InferenceCompletedEvent -> lastInferenceByStage[p.stageId.value] = p.responseArtifactId
                    else -> Unit
                }
            }
            return accs.map { (id, acc) ->
                ArtifactPlan(
                    artifactId = id,
                    stageId = acc.stageId,
                    schemaVersion = acc.schemaVersion,
                    phase = acc.phase,
                    contentHash = acc.contentHash ?: lastInferenceByStage[acc.stageId],
                )
            }
        }
    }

    /** A folded artifact: identity/metadata plus the CAS hash to resolve its content from (if any). */
    internal data class ArtifactPlan(
        val artifactId: String,
        val stageId: String,
        val schemaVersion: Int,
        val phase: String,
        val contentHash: ArtifactId?,
    )

    /** Mutable accumulator for folding a session's artifact events into one plan per artifact. */
    private class ArtifactAcc {
        var stageId: String = ""
        var schemaVersion: Int = 0
        var phase: String = "CREATED"
        var contentHash: ArtifactId? = null
    }
}
