package com.correx.infrastructure.tools.filesystem

import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.ParamRole
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import kotlin.io.path.name
import kotlin.io.path.readText

/**
 * `.gitignore`-aware directory listing — the affordance weak local models should reach for instead
 * of shell `ls -R`, which dumps `node_modules`/`build`/`dist` and drowns a small model in noise.
 * Shallow by default (`recursive=false`): a bare `list_dir .` returns the top-level entries, which
 * is what the common question ("what's at the root / does `frontend/` exist?") actually wants — a
 * recursive default buried that answer under an alphabetical 400-entry `docs/` subtree flood and
 * drove models to re-issue the same call 2-3x (observed: discovery looping on `list_dir .`). Pass
 * `recursive:true` to descend. Read-only (Tier T1, FILE_READ): it shares [FileReadTool]'s path jail
 * and workspace anchor. `.gitignore` is honoured for *enumeration only* — the mutation tools
 * deliberately do NOT respect it (you legitimately write to ignored paths like `.env`/`dist`).
 */
class ListDirTool(
    private val allowedPaths: Set<Path> = emptySet(),
    private val workingDir: Path? = null,
) : Tool, ToolExecutor {

    private fun resolvePath(pathString: String): Path {
        val raw = Paths.get(pathString)
        return when {
            raw.isAbsolute -> raw.normalize()
            workingDir != null -> workingDir.resolve(raw).normalize()
            else -> raw.toAbsolutePath().normalize()
        }
    }

    override val name: String = "list_dir"
    override val description: String =
        "List a directory tree, skipping anything matched by .gitignore (node_modules, build output, " +
            "logs, etc). Prefer this over shell 'ls -R' or 'find' for exploring project structure."
    override val parametersSchema: JsonObject = buildJsonObject {
        put("type", "object")
        putJsonObject("properties") {
            putJsonObject("path") {
                put("type", "string")
                put("description", "Relative directory to list. Omit or '.' for the workspace root.")
            }
            putJsonObject("recursive") {
                put("type", "boolean")
                put("description", "Descend into sub-directories (gitignored subtrees are pruned). Default false.")
            }
        }
        put("required", buildJsonArray {})
    }
    override val tier: Tier = Tier.T1
    override val requiredCapabilities: Set<ToolCapability> =
        setOf(ToolCapability.FILE_READ, ToolCapability.DIRECTORY_LIST)
    override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)

    override fun validateRequest(request: ToolRequest): ValidationResult {
        val pathString = (request.parameters["path"] as? String) ?: "."
        return runCatching {
            val path = resolvePath(pathString)
            // Operator-approved out-of-workspace paths (OutsidePathAccessGrantedEvent) widen the jail.
            val effectiveRoots = allowedPaths + request.grantedPaths.map { Paths.get(it) }
            if (!PathJail.isContained(path, effectiveRoots))
                ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
            else ValidationResult.Valid
        }.getOrElse {
            when (it) {
                is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
                else -> ValidationResult.Invalid(it.message ?: "Unknown error occurred")
            }
        }
    }

    override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
        (validateRequest(request) as? ValidationResult.Invalid)?.let {
            return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
        }
        val pathString = (request.parameters["path"] as? String) ?: "."
        val recursive = request.parameters["recursive"]?.toString()?.toBoolean() ?: false
        val root = resolvePath(pathString)
        when {
            !Files.exists(root) -> {
                val suggestions = similarEntries(root)
                val msg = buildString {
                    append("Directory not found: $pathString")
                    if (suggestions.isNotEmpty()) {
                        append(". Did you mean ")
                        append(suggestions.joinToString(", ", postfix = "?"))
                    }
                }
                ToolResult.Failure(request.invocationId, msg, recoverable = true)
            }
            !Files.isDirectory(root) ->
                // Explicit file-vs-dir mismatch: a bare "Not a directory" left models re-issuing the
                // identical list_dir until cancel (session a60c54b0). Name the remedy so the next
                // action is unambiguous — file_read it, or delete/convert it first.
                ToolResult.Failure(
                    request.invocationId,
                    "Not a directory: $pathString is a FILE, not a directory. " +
                        "Use file_read to read it, or delete/convert it before listing it as a directory.",
                    recoverable = true,
                )
            else -> runCatching { walk(root, recursive, request) }.getOrElse {
                ToolResult.Failure(request.invocationId, "Failed to list dir: ${it.message}", recoverable = false)
            }
        }
    }

    private fun walk(root: Path, recursive: Boolean, request: ToolRequest): ToolResult {
        var listedRoot = root
        var listing = collect(listedRoot, recursive)
        val descended = ArrayList<String>()
        // Do not follow symlinks: a symlink chain can point back to an ancestor outside the walk's
        // normal cycle protections. A real directory chain is capped as a second guard against a
        // pathological generated tree.
        while (!recursive && descended.size < MAX_AUTO_DESCENT_DEPTH) {
            val only = listing.entries.singleOrNull() ?: break
            val child = listedRoot.resolve(only.removeSuffix("/"))
            if (!only.endsWith('/') || Files.isSymbolicLink(child) || !Files.isDirectory(child)) break
            descended += only.removeSuffix("/")
            listedRoot = child
            listing = collect(listedRoot, recursive = false)
        }
        return ToolResult.Success(
            request.invocationId,
            output = render(listedRoot, listing.entries, listing.truncated, listing.ignoredCount, descended),
        )
    }

    private data class Listing(
        val entries: List<String>,
        val truncated: Boolean,
        val ignoredCount: Int,
    )

    private fun collect(root: Path, recursive: Boolean): Listing {
        val matcher = GitignoreMatcher(workingDir ?: root, root)
        // The caller explicitly targeted `root` — if a rule already covers it (e.g. an entire
        // subtree gitignored as scaffold output), don't let that same rule keep pruning everything
        // found underneath; other, unrelated rules (node_modules/ inside it, etc.) still apply.
        matcher.exemptRulesMatching(root)
        val out = ArrayList<String>()
        var truncated = false
        var ignoredCount = 0
        // Emit one entry; TERMINATE the walk once the cap is hit so a huge tree can't run away.
        fun emit(path: Path, isDir: Boolean): FileVisitResult {
            val slash = if (isDir) "/" else ""
            out += root.relativize(path).toString().replace('\\', '/') + slash
            if (out.size >= MAX_ENTRIES) truncated = true
            return if (truncated) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
        }
        val maxDepth = if (recursive) Int.MAX_VALUE else 1
        Files.walkFileTree(root, emptySet(), maxDepth, object : SimpleFileVisitor<Path>() {
            override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
                if (dir != root && dir.name == ".git") return FileVisitResult.SKIP_SUBTREE
                if (dir != root && matcher.ignored(dir, isDir = true)) {
                    ignoredCount++
                    return FileVisitResult.SKIP_SUBTREE
                }
                matcher.loadFrom(dir)
                return if (dir == root) FileVisitResult.CONTINUE else emit(dir, isDir = true)
            }
            override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
                // A directory sitting exactly at maxDepth is delivered here (walkFileTree won't
                // descend it), so honour its dir-ness for both the ignore check and the trailing '/'.
                val isDir = attrs.isDirectory
                if (matcher.ignored(file, isDir)) {
                    ignoredCount++
                    return FileVisitResult.CONTINUE
                }
                return emit(file, isDir)
            }
            override fun visitFileFailed(file: Path, exc: java.io.IOException) = FileVisitResult.CONTINUE
        })
        out.sort()
        return Listing(out, truncated, ignoredCount)
    }

    /**
     * `<path>/<type>/<entries>` wrapper (matching the shape other coding-agent tools use) instead of
     * a bare newline list — the count and explicit `directory` tag let a small model tell "empty
     * listing" from "malformed output" and know how many entries to expect. Notes on truncation/
     * gitignore-pruning are surfaced here too, so a shorter-than-expected listing isn't mistaken for
     * the real tree.
     */
    private fun render(
        root: Path,
        entries: List<String>,
        truncated: Boolean,
        ignoredCount: Int,
        descended: List<String>,
    ): String {
        val notes = buildList {
            if (descended.isNotEmpty()) {
                add("auto-descended ${descended.joinToString(" → ")} (single child at each level); listing shown is $root")
            }
            if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory")
            if (ignoredCount > 0) {
                add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
            }
        }
        return buildString {
            appendLine("<path>$root</path>")
            appendLine("<type>directory</type>")
            appendLine("<entries>")
            appendLine(entries.joinToString("\n").ifEmpty { "(empty)" })
            if (notes.isNotEmpty()) appendLine(notes.joinToString("; "))
            append("(${entries.size} ${if (entries.size == 1) "entry" else "entries"})")
            appendLine()
            append("</entries>")
        }
    }

    private fun similarEntries(target: Path): List<String> {
        val parent = target.parent ?: return emptyList()
        if (!Files.isDirectory(parent)) return emptyList()
        val targetName = target.fileName?.toString() ?: return emptyList()
        return try {
            Files.list(parent).use { stream ->
                stream.map { it.fileName.toString() }
                    .filter { it != targetName && isSimilarName(it, targetName) }
                    .toList()
                    .sortedWith(compareByDescending<String> { commonPrefixLen(it, targetName) }.thenBy { it })
                    .take(MAX_SIMILAR_SUGGESTIONS)
            }
        } catch (_: Exception) { emptyList() }
    }

    private fun isSimilarName(a: String, b: String): Boolean {
        val prefix = commonPrefixLen(a, b)
        return prefix >= SIMILAR_NAME_MIN_COMMON_PREFIX || levenshtein(a, b) <= SIMILAR_NAME_MAX_LEVENSHTEIN
    }

    private fun commonPrefixLen(a: String, b: String): Int {
        var i = 0
        val minLen = minOf(a.length, b.length)
        while (i < minLen && a[i].lowercaseChar() == b[i].lowercaseChar()) i++
        return i
    }

    private fun levenshtein(a: String, b: String): Int {
        val dp = Array(a.length + 1) { IntArray(b.length + 1) }
        for (i in 0..a.length) dp[i][0] = i
        for (j in 0..b.length) dp[0][j] = j
        for (i in 1..a.length) {
            for (j in 1..b.length) {
                dp[i][j] = minOf(
                    dp[i - 1][j] + 1,
                    dp[i][j - 1] + 1,
                    dp[i - 1][j - 1] + if (a[i - 1].lowercaseChar() == b[i - 1].lowercaseChar()) 0 else 1
                )
            }
        }
        return dp[a.length][b.length]
    }

    private companion object {
        const val MAX_ENTRIES = 400
        const val MAX_AUTO_DESCENT_DEPTH = 32
        const val MAX_SIMILAR_SUGGESTIONS = 3
        const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2
        const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3
    }
}

/**
 * Minimal `.gitignore` matcher: enough to prune the common noise (node_modules, dist, build, logs,
 * editor dirs). Loads a `.gitignore` per directory as the walk descends, plus the target's ancestors
 * once at construction, so nested ignore files scope to their own subtree.
 *
 * ponytail: no negation (`!`) and `**` is treated as a loose `.*`. If a real repo needs full
 * gitignore semantics, back this with `git check-ignore` / a spec-complete matcher.
 */
internal class GitignoreMatcher(anchor: Path, target: Path) {
    private data class Rule(val base: Path, val regex: Regex, val dirOnly: Boolean)

    private val rules = ArrayList<Rule>()

    init {
        // Ancestor .gitignore files (target up to and including anchor) apply to the whole listing;
        // load them outermost-first so the target's own rules are added last.
        generateSequence(target) { if (it == anchor) null else it.parent }
            .toList().asReversed().forEach { loadFrom(it) }
    }

    fun loadFrom(dir: Path) {
        val gi = dir.resolve(".gitignore")
        if (!Files.isRegularFile(gi)) return
        runCatching { gi.readText() }.getOrNull()?.lineSequence()?.forEach { line ->
            val pat = line.trim()
            if (pat.isEmpty() || pat.startsWith("#") || pat.startsWith("!")) return@forEach
            rules += compile(dir, pat)
        }
    }

    fun ignored(path: Path, isDir: Boolean): Boolean = rules.any { matches(it, path, isDir) }

    /** Drop whichever rules currently cover [path] — used so an explicitly-targeted (already
     * gitignored) listing root doesn't have that same rule keep pruning its own children. */
    fun exemptRulesMatching(path: Path) {
        rules.removeAll { matches(it, path, isDir = true) }
    }

    private fun matches(r: Rule, path: Path, isDir: Boolean): Boolean =
        (!r.dirOnly || isDir) && path.startsWith(r.base) &&
            r.regex.matches(r.base.relativize(path).toString().replace('\\', '/'))

    private fun compile(base: Path, raw: String): Rule {
        var pat = raw
        val dirOnly = pat.endsWith("/").also { if (it) pat = pat.dropLast(1) }
        val anchored = pat.startsWith("/").also { if (it) pat = pat.drop(1) }
        val body = StringBuilder()
        var i = 0
        while (i < pat.length) {
            val c = pat[i]
            when (c) {
                '*' -> if (i + 1 < pat.length && pat[i + 1] == '*') { body.append(".*"); i++ } else body.append("[^/]*")
                '?' -> body.append("[^/]")
                '.', '(', ')', '+', '|', '^', '$', '{', '}', '[', ']', '\\' -> body.append('\\').append(c)
                else -> body.append(c)
            }
            i++
        }
        // A slash-less, unanchored pattern matches the entry at any depth (git semantics); otherwise
        // it is anchored to the .gitignore's directory. Trailing "(/.*)?" so a matched dir also
        // covers everything under it.
        val prefix = if (anchored || pat.contains('/')) "" else "(.*/)?"
        return Rule(base, Regex("$prefix$body(/.*)?"), dirOnly)
    }
}
