package com.correx.apps.server.memory

import com.correx.core.events.events.RepoMapEntry
import com.correx.core.sourcedesc.describe
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.extension
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.name
import kotlin.io.path.readText
import kotlin.io.path.relativeTo

/** Minimal port for repo-map scanning — allows injection of test doubles. */
interface RepoMapIndexerPort {
    fun index(repoRoot: Path): List<RepoMapEntry>
}

/**
 * Walks a repo and produces a ranked file/symbol index. Reads the filesystem (a
 * nondeterministic environment observation) — the caller records the result as a
 * [com.correx.core.events.events.RepoMapComputedEvent] so replay reads recorded facts and
 * never re-scans (invariant #9). Entries retain paths, top-level symbols, and a bounded,
 * deterministic purpose descriptor; full file bodies are never stored in the repo map.
 *
 * Scoring is recency-based: the most-recently-modified source file scores 1.0, the oldest
 * ~0.0, linearly between. Recency is a cheap centrality proxy — files under active work rank
 * highest, which is what a fresh session most wants to see.
 */
class RepoMapIndexer(
    private val maxDepth: Int = 4,
    private val ignoreGlobs: List<String> = emptyList(),
) : RepoMapIndexerPort {
    override fun index(repoRoot: Path): List<RepoMapEntry> {
        if (!repoRoot.isDirectory()) return emptyList()
        val ignore = ignoreGlobs.toSet() + loadCorrexIgnore(repoRoot)

        val files = Files.walk(repoRoot, maxDepth).use { stream ->
            stream.filter { it.isRegularFile() }
                .filter { it.extension in SYMBOL_PATTERNS.keys }
                .filter { path -> !isIgnored(path.relativeTo(repoRoot), ignore) }
                .toList()
        }
        if (files.isEmpty()) return emptyList()

        val mtimes = files.associateWith { runCatching { it.getLastModifiedTime().toMillis() }.getOrDefault(0L) }
        val min = mtimes.values.min()
        val max = mtimes.values.max()
        val span = (max - min).coerceAtLeast(1L)

        return files
            .map { path ->
                RepoMapEntry(
                    path = path.relativeTo(repoRoot).toString(),
                    score = (mtimes.getValue(path) - min).toDouble() / span,
                    symbols = extractSymbols(path),
                    descriptor = sourceDescriptor(path),
                )
            }
            .sortedByDescending { it.score }
    }

    /**
     * A repo-local, gitignore-style opt-out for the repo map: patterns here apply on top of
     * `[project] ignore_globs` (CorrexConfig) without needing a config edit, so anyone working in
     * the repo can silence a noisy path for themselves. Same matching rules as [isIgnored] — one
     * pattern per line, blank lines and `#` comments skipped. Missing file is not an error (most
     * repos won't have one).
     */
    private fun loadCorrexIgnore(repoRoot: Path): List<String> {
        val file = repoRoot.resolve(".correxignore")
        if (!file.isRegularFile()) return emptyList()
        return runCatching { file.readText() }.getOrNull()
            ?.lines()
            ?.map { it.trim() }
            ?.filter { it.isNotEmpty() && !it.startsWith("#") }
            ?: emptyList()
    }

    /**
     * An ignore entry matches either a bare path-segment name (any depth, e.g. "node_modules") or a
     * relative path prefix (e.g. "examples/workflows/prompts", to exclude just that subtree).
     */
    private fun isIgnored(relativePath: Path, ignore: Set<String>): Boolean {
        val rel = relativePath.toString().replace('\\', '/')
        return ignore.any { pattern ->
            if ('/' in pattern) rel == pattern || rel.startsWith("$pattern/")
            else relativePath.any { it.name == pattern }
        }
    }

    private fun extractSymbols(path: Path): List<String> {
        // Docs are indexed as a single "what is this" descriptor, not a table-of-contents dump: a
        // heading flood (40 h1–h3 per doc across a large docs/ tree) buries real source and force-feeds
        // content the stage may never need. One descriptor lets the agent decide whether to file_read
        // the doc (2026-07-07 doc-injection rework). Non-doc source keeps top-level symbol extraction.
        if (path.extension.equals("md", ignoreCase = true)) return listOfNotNull(docDescriptor(path))
        val regex = SYMBOL_PATTERNS[path.extension]
        val text = regex?.let { runCatching { path.readText() }.getOrNull() }
        return text?.let {
            regex.findAll(it)
                .mapNotNull { m -> m.groups["name"]?.value }
                .distinct()
                .take(MAX_SYMBOLS_PER_FILE)
                .toList()
        } ?: emptyList()
    }

    /**
     * A small semantic bridge between an intent ("context packing") and code whose class name
     * alone lacks those words — module identity and imports, both stable structural facts.
     *
     * Deliberately non-prose: derived through the shared comment-free [describe] extractor, NOT by
     * scraping leading comments/docstrings. This string is embedded into L3 and surfaced verbatim to
     * successor stages via RepoKnowledgeHit.text, so any natural-language content read out of an
     * agent-writable file here would be a prompt-injection channel from one stage into the next.
     * Comments are gone by design; module+imports+symbols remain as the retrieval signal.
     */
    private fun sourceDescriptor(path: Path): String {
        if (path.extension.equals("md", ignoreCase = true)) return docDescriptor(path).orEmpty()
        val bytes = runCatching { Files.readAllBytes(path) }.getOrNull() ?: return ""
        val d = describe(path.name, bytes)
        return buildList {
            d.module?.let { add("module $it") }
            if (d.imports.isNotEmpty()) add("uses ${d.imports.take(MAX_DESCRIPTOR_IMPORTS).joinToString(", ")}")
        }.joinToString("; ").truncateDescriptor()
    }

    /**
     * One-line "what is this doc about" descriptor, preferred in order: YAML frontmatter
     * `description`/`summary`/`title`, then the first `# H1` heading, then the first prose line.
     * This is the read-on-demand index the doc catalog surfaces — never the doc body.
     */
    private fun docDescriptor(path: Path): String? {
        val lines = runCatching { path.readText() }.getOrNull()?.lines() ?: return null
        val raw = frontmatterDescriptor(lines)
            ?: lines.firstOrNull { it.trimStart().startsWith("#") }
                ?.trimStart()?.trimStart('#')?.trim()?.takeIf { it.isNotBlank() }
            ?: lines.firstOrNull { it.isNotBlank() && !it.startsWith("---") }?.trim()
        return raw?.truncateDescriptor()
    }

    private fun frontmatterDescriptor(lines: List<String>): String? {
        val hasOpen = lines.firstOrNull()?.trim() == "---"
        val end = if (hasOpen) lines.drop(1).indexOfFirst { it.trim() == "---" } else -1
        if (!hasOpen || end < 0) return null
        return FRONTMATTER_KEYS.firstNotNullOfOrNull { key ->
            lines.subList(1, end + 1)
                .firstOrNull { it.trimStart().startsWith("$key:", ignoreCase = true) }
                ?.substringAfter(':')?.trim()?.trim('"', '\'')
                ?.takeIf { it.isNotBlank() }
        }
    }

    private fun String.truncateDescriptor(): String =
        if (length <= MAX_DESCRIPTOR_CHARS) this else take(MAX_DESCRIPTOR_CHARS).trimEnd() + "…"

    companion object {
        /** File extensions considered for indexing — reused by [WorkspaceStateProbe] for fingerprinting. */
        val INDEXED_EXTENSIONS: Set<String> get() = SYMBOL_PATTERNS.keys

        private const val MAX_SYMBOLS_PER_FILE = 40
        private const val MAX_DESCRIPTOR_CHARS = 120
        private const val MAX_DESCRIPTOR_IMPORTS = 6
        private val FRONTMATTER_KEYS = listOf("description", "summary", "title")

        // Top-level declarations only — best-effort per language. Bodies/locals are never matched.
        val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
            "kt" to Regex(
                """(?m)^\s*(?:public |internal |open |abstract |sealed |data )*""" +
                    """(?:class|interface|object|fun)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
            ),
            "java" to Regex(
                """(?m)^\s*(?:public |private |protected |final |abstract |static )*""" +
                    """(?:class|interface|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
            ),
            "py" to Regex("""(?m)^(?:def|class)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
            "go" to Regex("""(?m)^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+)(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
            "js" to Regex(
                """(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+""" +
                    """(?<name>[A-Za-z_$][A-Za-z0-9_$]*)""",
            ),
            "ts" to Regex(
                """(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+""" +
                    """(?<name>[A-Za-z_$][A-Za-z0-9_$]*)""",
            ),
            // GDScript: column-0 declarations only, so indented inner-class members never match.
            "gd" to Regex(
                """(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
            ),
            // C# / Godot Mono: top-level type declarations with leading modifiers, kept conservative
            // (no method capture — return-type heuristics there are noisy and risk false positives).
            "cs" to Regex(
                """(?m)^\s*(?:public |private |protected |internal |static |sealed |abstract |""" +
                    """partial )*(?:class|interface|struct|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
            ),
            // Markdown: h1–h3 headings as "symbols" — deeper levels are noise relative to retrieval value.
            "md" to Regex("""(?m)^#{1,3}\s+(?<name>.+?)\s*$"""),
        )
    }
}
