package com.correx.core.talkie

import com.correx.core.context.model.CompressionMetadata
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.ChatTurnRole
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.L3MemoryRetrievedEvent
import com.correx.core.events.events.L3RetrievedHit
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.TalkieNarrationEvent
import com.correx.core.events.events.SessionNamedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Embedder
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ResponseFormat
import com.correx.core.inference.TokenUsage
import com.correx.core.talkie.l3.L3MemoryEntry
import com.correx.core.talkie.l3.L3MemoryStore
import com.correx.core.talkie.l3.L3Query
import com.correx.core.talkie.model.NarrationTrigger
import com.correx.core.talkie.model.TalkieConfig
import com.correx.core.talkie.model.TalkieResponse
import com.correx.core.talkie.model.WorkflowSummary
import kotlinx.coroutines.CancellationException
import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory
import java.util.UUID

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

// Narration activity caps: keep the "what happened" line short and cheap.
private const val MAX_NARRATED_ACTIONS = 6
private const val ACTION_ARG_MAX = 60

// Session-naming caps.
private const val NAME_INTENT_MAX = 2000
private const val NAME_MAX_CHARS = 64
private const val TOKEN_CHARS = 4

interface TalkieFacade {
    suspend fun onUserInput(
        sessionId: SessionId,
        input: String,
        mode: ChatMode = ChatMode.CHAT,
    ): TalkieResponse

    suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger)

    /**
     * Derives a short, human-readable title for the session from its initial [intent] and records
     * it once as a [SessionNamedEvent]. Nondeterministic (an LLM call) so the result is captured as
     * an event, never re-derived on replay (invariant #9). No-op on blank intent or empty output.
     */
    suspend fun nameSession(sessionId: SessionId, intent: String)
}

class DefaultTalkieFacade(
    private val routerRepository: TalkieRepository,
    private val routerContextBuilder: TalkieContextBuilder,
    private val inferenceRouter: InferenceRouter,
    private val eventStore: EventStore,
    private val config: TalkieConfig,
    private val validateSteering: (suspend (String) -> String?)? = null,
    private val embedder: Embedder,
    private val l3MemoryStore: L3MemoryStore,
    private val workflowSummaryProvider: () -> List<WorkflowSummary> = { emptyList() },
    private val sessionProfileProvider: suspend (SessionId) -> String? = { null },
) : TalkieFacade {

    override suspend fun onUserInput(
        sessionId: SessionId,
        input: String,
        mode: ChatMode,
    ): TalkieResponse {
        // Emit USER turn event and get the turnId for retrieval dedup
        val userTurnId = emitChatTurn(sessionId, input, ChatTurnRole.USER)

        // Rebuild state with user turn appended
        var state = routerRepository.getTalkieState(sessionId)
        val effectiveStageId = state.currentStageId ?: StageId.NONE

        // L3 retrieval — non-fatal; results fed back into state via event.
        // Always emit L3MemoryRetrievedEvent for every CHAT turn where retrieval was attempted,
        // including with empty hits — so lastRetrievedMemory is always current-turn-scoped.
        val retrieved = runCatching {
            val queryVector = embedder.embed(input)
            l3MemoryStore.query(L3Query(vector = queryVector, k = config.retrievalK))
        }.also { result ->
            result.exceptionOrNull()?.let { e ->
                if (e is CancellationException) throw e
                log.warn("L3 retrieval failed for session {}: {}", sessionId, e.message)
            }
        }.getOrElse { emptyList() }

        val inSessionTurnIds = state.conversationHistory.map { it.turnId }.toSet()
        val deduped = retrieved.filter { it.entry.turnId !in inSessionTurnIds }

        // Always emit L3MemoryRetrievedEvent (even with empty hits) so that
        // lastRetrievedMemory always reflects this turn and a prior turn's hits
        // are never injected into the next turn's context.
        val retrievalNow = Clock.System.now()
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = retrievalNow,
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = L3MemoryRetrievedEvent(
                    sessionId = sessionId,
                    queryTurnId = userTurnId,
                    hits = deduped.map { hit ->
                        L3RetrievedHit(
                            entryId = hit.entry.id,
                            sourceSessionId = hit.entry.sessionId,
                            sourceTurnId = hit.entry.turnId,
                            text = hit.entry.text,
                            score = hit.score,
                        )
                    },
                    timestampMs = retrievalNow.toEpochMilliseconds(),
                ),
            ),
        )
        // Rebuild state so lastRetrievedMemory reflects this turn
        state = routerRepository.getTalkieState(sessionId)

        val contextPack = routerContextBuilder.build(
            state,
            config.tokenBudget,
            workflowSummaryProvider(),
            sessionProfileProvider(sessionId),
        )

        if (contextPack.compressionMetadata.entriesDropped > 0) {
            val truncNow = Clock.System.now()
            eventStore.append(
                NewEvent(
                    metadata = EventMetadata(
                        eventId = EventId(UUID.randomUUID().toString()),
                        sessionId = sessionId,
                        timestamp = truncNow,
                        schemaVersion = 1,
                        causationId = null,
                        correlationId = null,
                    ),
                    payload = ContextTruncatedEvent(
                        sessionId = sessionId,
                        turnId = userTurnId,
                        entriesDropped = contextPack.compressionMetadata.entriesDropped,
                        truncatedLayers = contextPack.compressionMetadata.truncatedLayers.map { it.name },
                        timestampMs = truncNow.toEpochMilliseconds(),
                    ),
                ),
            )
        }

        val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
        val inferenceRequest = InferenceRequest(
            requestId = InferenceRequestId(UUID.randomUUID().toString()),
            sessionId = sessionId,
            stageId = effectiveStageId,
            contextPack = contextPack,
            generationConfig = config.generationConfig,
            responseFormat = ResponseFormat.Text,
        )
        val inferenceResponse = provider.infer(inferenceRequest)
        val rawContent = inferenceResponse.text
        // The triage path may append a structured workflow proposal, and the rubber-duck path an
        // `ideas` block; strip both from the visible turn so the operator reads only prose, then
        // record them as events below.
        val proposal = WorkflowProposals.parse(rawContent)
        val ideas = Ideas.parse(proposal.cleanText)
        // A length-finish with no visible text means the model burned the whole completion
        // budget on hidden reasoning — surface that instead of rendering a silent blank turn.
        val content = ideas.cleanText.ifBlank {
            if (inferenceResponse.finishReason is FinishReason.Length) {
                "The model used its entire ${config.generationConfig.maxTokens}-token response budget " +
                    "without producing an answer (likely spent on hidden reasoning). " +
                    "Raise [router.generation] max_tokens or lower the model's reasoning budget, then ask again."
            } else {
                "The model returned an empty response " +
                    "(finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again."
            }
        }

        // Emit ROUTER turn event
        emitChatTurn(
            sessionId, content, ChatTurnRole.ROUTER,
            latencyMs = inferenceResponse.latencyMs,
            tokensUsed = inferenceResponse.tokensUsed,
        )

        if (mode == ChatMode.CHAT) {
            emitWorkflowProposalIfAny(sessionId, input, proposal)
            emitIdeasCaptured(sessionId, ideas.ideas)
        }

        // ponytail: STEERING launders the user's text through the router LLM (the `content` above)
        // before injecting it as a note. For a clear instruction that's an extra inference that can
        // distort intent; the reformulation only earns its cost when the input is terse/context-
        // dependent. Upgrade path: inject the raw (validated) input directly and skip the rewrite
        // unless a heuristic flags the message as too short/ambiguous to stand alone.
        val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
        if (steeringEmitted) {
            val validationError = validateSteering?.invoke(content)
            if (validationError == null) {
                emitSteeringNote(sessionId, content, effectiveStageId)
            }
        }

        return TalkieResponse(content = content, steeringEmitted = steeringEmitted)
    }

    override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
        val state = routerRepository.getTalkieState(sessionId)
        val effectiveStageId = trigger.stageId?.let { StageId(it) } ?: state.currentStageId ?: StageId.NONE

        val recentActivity = recentActivity(sessionId)
        val contextPack = routerContextBuilder.buildNarrationContext(state, trigger, config.tokenBudget, recentActivity)

        val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General), config.narrationModelId)
        val inferenceRequest = InferenceRequest(
            requestId = InferenceRequestId(UUID.randomUUID().toString()),
            sessionId = sessionId,
            stageId = effectiveStageId,
            contextPack = contextPack,
            generationConfig = config.narrationGenerationConfig,
            responseFormat = ResponseFormat.Text,
        )
        val inferenceResponse = provider.infer(inferenceRequest)
        val content = inferenceResponse.text

        if (content.isBlank()) return

        val narrationId = UUID.randomUUID().toString()
        val now = Clock.System.now()
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = now,
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = TalkieNarrationEvent(
                    sessionId = sessionId,
                    narrationId = narrationId,
                    trigger = trigger.kind,
                    stageId = effectiveStageId.takeIf { it != StageId.NONE }?.value,
                    content = content,
                    latencyMs = inferenceResponse.latencyMs,
                    tokensUsed = inferenceResponse.tokensUsed,
                    timestampMs = now.toEpochMilliseconds(),
                ),
            ),
        )
    }

    override suspend fun nameSession(sessionId: SessionId, intent: String) {
        if (intent.isBlank()) return

        val instruction = buildString {
            append("Produce a short, specific title (3-6 words, Title Case, no quotes, no trailing ")
            append("punctuation) that names the task described below. Reply with the title only.\n\n")
            append("Task: ")
            append(intent.trim().take(NAME_INTENT_MAX))
        }
        val contextPack = ContextPack(
            id = ContextPackId("${sessionId.value}-naming-pack"),
            sessionId = sessionId,
            stageId = StageId.NONE,
            layers = mapOf(
                ContextLayer.L0 to listOf(
                    ContextEntry(
                        id = ContextEntryId(UUID.randomUUID().toString()),
                        layer = ContextLayer.L0,
                        content = instruction,
                        sourceType = "sessionNaming",
                        sourceId = sessionId.value,
                        tokenEstimate = (instruction.length / TOKEN_CHARS).coerceAtLeast(1),
                        role = EntryRole.USER,
                    ),
                ),
            ),
            budgetUsed = (instruction.length / TOKEN_CHARS).coerceAtLeast(1),
            budgetLimit = config.tokenBudget.limit,
            compressionMetadata = CompressionMetadata(appliedStrategies = listOf("SessionNaming")),
        )

        val provider = inferenceRouter.route(StageId.NONE, setOf(ModelCapability.General), config.narrationModelId)
        val inferenceRequest = InferenceRequest(
            requestId = InferenceRequestId(UUID.randomUUID().toString()),
            sessionId = sessionId,
            stageId = StageId.NONE,
            contextPack = contextPack,
            generationConfig = config.narrationGenerationConfig,
            responseFormat = ResponseFormat.Text,
        )
        val raw = provider.infer(inferenceRequest).text
        val name = sanitizeSessionName(raw)
        if (name.isBlank()) return

        val now = Clock.System.now()
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = now,
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = SessionNamedEvent(
                    sessionId = sessionId,
                    name = name,
                    timestampMs = now.toEpochMilliseconds(),
                ),
            ),
        )
    }

    /** Strips quotes/markdown/trailing punctuation and clamps a model-produced title to one line. */
    private fun sanitizeSessionName(raw: String): String =
        raw.lineSequence()
            .map { it.trim() }
            .firstOrNull { it.isNotBlank() }
            .orEmpty()
            .trim('"', '\'', '`', '*', '#', ' ', '.', ':')
            .take(NAME_MAX_CHARS)
            .trim()

    /**
     * The most recent concrete tool actions in the session, read straight from the event log so
     * the narrator can say what actually happened (files read/written, tools run) instead of a
     * generic "stage completed". Not stage-scoped: a narration commonly fires from a later stage
     * (e.g. an approval pause) than the one that did the work, so the recent actions across the
     * whole run are what "just happened". Returns null when nothing ran; capped at the last few.
     */
    private suspend fun recentActivity(sessionId: SessionId): String? {
        val actions = eventStore.read(sessionId)
            .mapNotNull { it.payload as? ToolInvocationRequestedEvent }
            .takeLast(MAX_NARRATED_ACTIONS)
            .map { req ->
                val params = req.request.parameters
                val arg = (params["path"] ?: params["file"] ?: params["command"] ?: params["query"]
                    ?: params.values.firstOrNull())?.toString()?.take(ACTION_ARG_MAX)
                if (arg.isNullOrBlank()) req.toolName else "${req.toolName}($arg)"
            }
        if (actions.isEmpty()) return null
        return "Recent actions taken in this run: " + actions.joinToString(", ")
    }

    private suspend fun emitChatTurn(
        sessionId: SessionId,
        content: String,
        role: ChatTurnRole,
        latencyMs: Long? = null,
        tokensUsed: TokenUsage? = null,
    ): String {
        val turnId = UUID.randomUUID().toString()
        val now = Clock.System.now()
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = now,
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = ChatTurnEvent(
                    sessionId = sessionId,
                    turnId = turnId,
                    role = role,
                    content = content,
                    timestampMs = now.toEpochMilliseconds(),
                    latencyMs = latencyMs,
                    tokensUsed = tokensUsed,
                ),
            ),
        )

        runCatching {
            val vector = embedder.embed(content)
            l3MemoryStore.store(
                L3MemoryEntry(
                    id = turnId,
                    sessionId = sessionId,
                    turnId = turnId,
                    text = content,
                    vector = vector,
                    timestampMs = now.toEpochMilliseconds(),
                )
            )
        }.also { result ->
            result.exceptionOrNull()?.let { e ->
                if (e is CancellationException) throw e
                log.warn("L3 write failed for turn {}: {}", turnId, e.message)
            }
        }

        return turnId
    }

    /**
     * Records a [WorkflowProposedEvent] when the triage reply proposed at least one *known* workflow.
     * Candidate ids are validated against the registered workflows (invariant #7 — LLM output is
     * untrusted), so an invented id never reaches the operator's picker.
     */
    private suspend fun emitWorkflowProposalIfAny(
        sessionId: SessionId,
        originalRequest: String,
        proposal: WorkflowProposals.Parsed,
    ) {
        if (proposal.candidates.isEmpty()) return
        val known = workflowSummaryProvider().mapTo(mutableSetOf()) { it.id }
        val valid = proposal.candidates.filter { it.workflowId in known }
        if (valid.isEmpty()) return

        val now = Clock.System.now()
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = now,
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = WorkflowProposedEvent(
                    sessionId = sessionId,
                    proposalId = UUID.randomUUID().toString(),
                    prompt = proposal.prompt.ifBlank { "Start one of these workflows?" },
                    candidates = valid,
                    originalRequest = originalRequest,
                ),
            ),
        )
    }

    /** Records one [IdeaCapturedEvent] per idea the operator surfaced while rubber-ducking. */
    private suspend fun emitIdeasCaptured(sessionId: SessionId, ideas: List<String>) {
        ideas.forEach { text ->
            val now = Clock.System.now()
            eventStore.append(
                NewEvent(
                    metadata = EventMetadata(
                        eventId = EventId(UUID.randomUUID().toString()),
                        sessionId = sessionId,
                        timestamp = now,
                        schemaVersion = 1,
                        causationId = null,
                        correlationId = null,
                    ),
                    payload = IdeaCapturedEvent(
                        ideaId = UUID.randomUUID().toString(),
                        sessionId = sessionId,
                        text = text,
                        timestampMs = now.toEpochMilliseconds(),
                    ),
                ),
            )
        }
    }

    private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
        val now = Clock.System.now()
        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = now,
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = SteeringNoteAddedEvent(
                    sessionId = sessionId,
                    content = content,
                    stageId = effectiveStageId.takeIf { it != StageId.NONE },
                ),
            ),
        )
    }
}

enum class ChatMode {
    CHAT,
    STEERING,
}
