package com.correx.apps.server.memory

import com.correx.core.config.ProjectConfig
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.RepoMapEntry
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.WorkspaceStateObservedEvent
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.inference.Embedder
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.journal.ProjectMemoryDistiller
import com.correx.core.talkie.l3.L3MemoryEntry
import com.correx.core.talkie.l3.L3MemoryStore
import com.correx.core.talkie.l3.L3Query
import kotlinx.coroutines.CancellationException
import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory
import java.util.UUID

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

// Over-fetch factor: project lines share the L3 store with chat turns, so query wider
// than memoryK and post-filter by the repo-scoped turnId tag.
private const val OVERFETCH = 4

/**
 * Cross-session, repo-scoped memory. Project lines are tagged in the L3 store via
 * `turnId = "project:<repoRoot>"` (no schema change). On session end the decision journal
 * is distilled and persisted; on session start prior-session project lines are retrieved and
 * seeded into the new session as a recorded steering note (invariant #9: the L3 read is
 * recorded as an event, never re-observed at replay), so the decision-journal pinning surfaces
 * them to every stage. All operations are gated on [ProjectConfig.enabled].
 */
class ProjectMemoryService(
    private val config: ProjectConfig,
    private val embedder: Embedder,
    private val l3MemoryStore: L3MemoryStore,
    private val journalRepository: DefaultDecisionJournalRepository,
    private val eventStore: EventStore,
    private val distiller: ProjectMemoryDistiller = ProjectMemoryDistiller(),
    private val indexer: RepoMapIndexerPort = RepoMapIndexer(),
    private val workspaceStateProbe: WorkspaceStateProbe = RealWorkspaceStateProbe(),
) {
    // repoRoot+stateKey -> scanned entries, so a second session on the same workspace state reuses
    // the walk instead of re-scanning the FS (preserves the one-walk-per-state guarantee while still
    // emitting a per-session RepoMapComputedEvent). Per-process; cold after a restart (one walk then).
    private val entryCache = java.util.concurrent.ConcurrentHashMap<String, List<RepoMapEntry>>()

    private fun tag(repoRoot: String) = "project:$repoRoot"

    /**
     * Walk [repoRoot] and record the ranked file/symbol map as a [RepoMapComputedEvent] once
     * per session. The full map is recorded (the log); only a top-K slice is injected into
     * context downstream. Replay reads the recorded event, never re-scanning (invariant #9).
     */
    suspend fun indexAndRecord(sessionId: SessionId, repoRoot: String) {
        if (!config.enabled) return
        val stateKey = eventStore.read(sessionId)
            .mapNotNull { it.payload as? WorkspaceStateObservedEvent }
            .lastOrNull()?.stateKey
        runCatching {
            // Record the map into THIS session's log unconditionally. buildRepoMapEntries and replay
            // are session-scoped (invariant #9), so the old walk-once-and-skip left every session after
            // the first with no deterministic repo grounding. To keep the walk-once guarantee we reuse
            // the scanned entries from a per-stateKey in-memory cache (no re-walk), and only the
            // expensive L3 embed/store below stays guarded by the persistent L3 presence check.
            val cacheKey = stateKey?.let { "$repoRoot::$it" }
            val entries = cacheKey?.let { entryCache[it] }
                ?: indexer.index(java.nio.file.Path.of(repoRoot)).also {
                    if (cacheKey != null && it.isNotEmpty()) entryCache[cacheKey] = it
                }
            if (entries.isEmpty()) return
            eventStore.append(
                NewEvent(
                    metadata = EventMetadata(
                        eventId = EventId(UUID.randomUUID().toString()),
                        sessionId = sessionId,
                        timestamp = Clock.System.now(),
                        schemaVersion = 1,
                        causationId = null,
                        correlationId = null,
                    ),
                    payload = RepoMapComputedEvent(
                        sessionId = sessionId,
                        repoRoot = repoRoot,
                        entries = entries,
                        stateKey = stateKey,
                        computedAt = Clock.System.now(),
                    ),
                ),
            )
            embedRepoMapIntoL3(sessionId, repoRoot, stateKey, entries)
        }.exceptionOrNull()?.let { e ->
            if (e is CancellationException) throw e
            log.warn("repo-map indexing failed for {}: {}", repoRoot, e.message)
        }
    }

    /** Embed the scanned repo-map entries into L3 once per workspace state (skips if already stored). */
    private suspend fun embedRepoMapIntoL3(
        sessionId: SessionId,
        repoRoot: String,
        stateKey: String?,
        entries: List<RepoMapEntry>,
    ) {
        val tag = repoMapEmbeddingTag(repoRoot, stateKey)
        if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix(tag)) {
            log.debug("repo-map already embedded for {}@{} — skipping L3 store", repoRoot, stateKey)
            return
        }
        // Trailing ':' delimiter so a repoRoot prefix can't collide with a longer sibling
        // (/repo vs /repo2) under the retriever's startsWith filter.
        // Docs are already surfaced via the "Docs available" catalog built straight from
        // RepoMapComputedEvent (SessionOrchestrator, 2026-07-07 doc-injection rework) — embedding
        // them into the same L3 namespace as code let generic textual similarity (e.g. "analyst",
        // "compression pipeline") outrank the actual code files for a code-focused query, silently
        // crowding the semantic "Relevant files" feed with irrelevant markdown (2026-07-08 recurrence
        // of the 2026-07-05 context-poisoning root cause, this time via L3RepoKnowledgeRetriever
        // rather than the repo-map floor that was fixed then).
        entries.filterNot { it.path.endsWith(".md", ignoreCase = true) }.forEach { entry ->
            runCatching {
                val text = buildString {
                    append(entry.path)
                    if (entry.descriptor.isNotBlank()) append(": ").append(entry.descriptor)
                    if (entry.symbols.isNotEmpty()) append("; symbols: ").append(entry.symbols.joinToString(", "))
                }
                l3MemoryStore.store(
                    L3MemoryEntry(
                        id = UUID.randomUUID().toString(),
                        sessionId = sessionId,
                        turnId = tag,
                        text = text,
                        vector = embedder.embed(text),
                        timestampMs = Clock.System.now().toEpochMilliseconds(),
                    ),
                )
            }.exceptionOrNull()?.let { e ->
                if (e is CancellationException) throw e
                log.warn("repo-map embedding failed for {}: {}", entry.path, e.message)
            }
        }
    }

    /**
     * Observe the workspace content state and record it as a [WorkspaceStateObservedEvent]
     * (invariant #9: environment observations are recorded as events, never re-observed).
     */
    suspend fun observeAndRecord(sessionId: SessionId, repoRoot: String) {
        if (!config.enabled) return
        val state = workspaceStateProbe.observe(repoRoot) ?: run {
            log.debug("workspace state unavailable for {} — skipping observation", repoRoot)
            return
        }
        runCatching {
            eventStore.append(
                NewEvent(
                    metadata = EventMetadata(
                        eventId = EventId(UUID.randomUUID().toString()),
                        sessionId = sessionId,
                        timestamp = Clock.System.now(),
                        schemaVersion = 1,
                        causationId = null,
                        correlationId = null,
                    ),
                    payload = WorkspaceStateObservedEvent(
                        sessionId = sessionId,
                        workspaceRoot = repoRoot,
                        stateKey = state.stateKey,
                        source = state.source,
                        branch = state.branch,
                        dirty = state.dirty,
                    ),
                ),
            )
        }.exceptionOrNull()?.let { e ->
            if (e is CancellationException) throw e
            log.warn("workspace state observation failed for {}: {}", repoRoot, e.message)
        }
    }

    /** Distil the session's decision journal into durable, repo-tagged L3 entries. */
    suspend fun persist(sessionId: SessionId, repoRoot: String) {
        if (!config.enabled) return
        val lines = distiller.distill(journalRepository.getJournal(sessionId), repoRoot)
        lines.forEach { line ->
            runCatching {
                val vector = embedder.embed(line)
                l3MemoryStore.store(
                    L3MemoryEntry(
                        id = UUID.randomUUID().toString(),
                        sessionId = sessionId,
                        turnId = tag(repoRoot),
                        text = line,
                        vector = vector,
                        timestampMs = Clock.System.now().toEpochMilliseconds(),
                    ),
                )
            }.exceptionOrNull()?.let { e ->
                if (e is CancellationException) throw e
                log.warn("project memory persist failed for {}: {}", repoRoot, e.message)
            }
        }
    }

    /**
     * Retrieve prior-session project lines for [repoRoot] and seed them into [sessionId] as a
     * steering note. Returns the lines seeded (empty if disabled or none found).
     */
    suspend fun retrieveAndSeed(sessionId: SessionId, repoRoot: String): List<String> {
        if (!config.enabled) return emptyList()
        val lines = runCatching {
            val vector = embedder.embed(repoRoot)
            l3MemoryStore.query(L3Query(vector = vector, k = config.memoryK * OVERFETCH))
                .map { it.entry }
                .filter { it.turnId == tag(repoRoot) && it.sessionId != sessionId }
                .map { it.text }
                .distinct()
                .take(config.memoryK)
        }.getOrElse { e ->
            if (e is CancellationException) throw e
            log.warn("project memory retrieval failed for {}: {}", repoRoot, e.message)
            emptyList()
        }
        if (lines.isEmpty()) return emptyList()

        eventStore.append(
            NewEvent(
                metadata = EventMetadata(
                    eventId = EventId(UUID.randomUUID().toString()),
                    sessionId = sessionId,
                    timestamp = Clock.System.now(),
                    schemaVersion = 1,
                    causationId = null,
                    correlationId = null,
                ),
                payload = SteeringNoteAddedEvent(
                    sessionId = sessionId,
                    content = "Project memory (prior sessions):\n" + lines.joinToString("\n"),
                ),
            ),
        )
        return lines
    }
}
