package com.correx.core.kernel.orchestration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*

/**
 * Compute a unified-diff preview for file-affecting tool calls.
 * For [file_write] with operation "write": reads the existing file (if any) and diffs it
 * against the proposed content from the LLM arguments.
 * For all other tools: returns null (caller falls back to raw JSON args).
 *
 * This function performs blocking I/O (file reads) and must be called from a suspend context
 * that will dispatch it on [Dispatchers.IO].
 */
internal suspend fun computeToolPreview(
    toolName: String,
    parameters: Map<String, Any>,
    workspaceRoot: java.nio.file.Path?,
): String? {
    if (toolName == "shell") return shellCommandPreview(parameters)
    if (toolName == "task_decompose") return renderDecomposePreview(parameters)
    if (toolName == "file_edit") return computeFileEditPreview(parameters, workspaceRoot)
    if (toolName != "file_write") return null
    val path = parameters["path"] as? String ?: return null
    // file_write no longer carries an `operation` param (delete was split into file_delete), so the
    // preview is unconditional: path + content is always a write. The old `operation == "write"` gate
    // silently bailed to the raw-JSON args fallback for every file_write.
    val proposedContent = parameters["content"] as? String ?: return null

    val existingContent = readFileIfExists(path, workspaceRoot)
    return buildDiffString(path, existingContent, proposedContent)
}

internal suspend fun readFileIfExists(path: String, workspaceRoot: java.nio.file.Path?): String? =
    withContext(Dispatchers.IO) {
        runCatching {
            // Resolve relative paths against the session's workspace root, same as the tools do —
            // resolving against the daemon CWD showed the operator the wrong file (or nothing) when
            // server CWD ≠ workspace_root.
            val raw = java.nio.file.Paths.get(path)
            val filePath = if (raw.isAbsolute || workspaceRoot == null) raw else workspaceRoot.resolve(raw)
            if (java.nio.file.Files.exists(filePath)) {
                java.nio.file.Files.readString(filePath)
            } else null
        }.getOrNull()
    }

/**
 * Preview for `file_edit`'s "replace" and "append" operations: reads the existing file and
 * simulates the edit locally to produce a diff, mirroring `file_write`'s preview. "patch" is left
 * to the raw-JSON-args fallback — simulating `patch -p1` application here isn't worth the risk of
 * the preview silently disagreeing with what the tool actually does.
 */
internal suspend fun computeFileEditPreview(
    parameters: Map<String, Any>,
    workspaceRoot: java.nio.file.Path?,
): String? {
    val path = parameters["path"] as? String ?: return null
    val operation = parameters["operation"] as? String ?: return null
    val existingContent = readFileIfExists(path, workspaceRoot) ?: return null

    val proposedContent = when (operation) {
        "append" -> existingContent + (parameters["content"] as? String ?: return null)
        "replace" -> {
            val target = parameters["target"] as? String ?: return null
            val replacement = parameters["replacement"] as? String ?: return null
            if (!existingContent.contains(target)) return null
            existingContent.replaceFirst(target, replacement)
        }
        else -> return null
    }

    return buildDiffString(path, existingContent, proposedContent)
}

/**
 * Render a shell tool's argv as a full, shell-quoted command line for the approval
 * card (tui-requirements §5 / §3 "full untruncated command"). Returning the rendered
 * line — rather than the raw `arguments.take(200)` fallback — keeps the command both
 * untruncated and human-readable; the TUI's deterministic parser re-tokenizes it.
 */
internal fun shellCommandPreview(parameters: Map<String, Any>): String? {
    val argv = when (val raw = parameters["argv"]) {
        is List<*> -> raw.filterIsInstance<String>()
        is String -> runCatching { Json.decodeFromString<List<String>>(raw) }.getOrElse { emptyList() }
        else -> emptyList()
    }
    if (argv.isEmpty()) return null
    return argv.joinToString(" ") { shellQuoteToken(it) }
}

/** Single-quote a token when it carries whitespace or shell metacharacters. */
internal fun shellQuoteToken(token: String): String {
    if (token.isEmpty()) return "''"
    val needsQuote = token.any { it.isWhitespace() || it in "\"'\\|&;<>(){}\$`*?[]~#!" }
    return if (needsQuote) "'" + token.replace("'", "'\\''") + "'" else token
}

internal enum class DiffOpKind { EQUAL, DELETE, INSERT }
internal data class DiffOp(val kind: DiffOpKind, val line: String)

/**
 * Build a unified-diff string for a full file replacement, using a line-level LCS diff
 * so a small edit to a large file shows as a tight hunk rather than a full-file replacement.
 * When the file doesn't exist yet (new file), only `+` lines are shown.
 */
internal fun buildDiffString(path: String, existingContent: String?, proposedContent: String): String {
    val existingLines = existingContent?.lines() ?: emptyList()
    val proposedLines = proposedContent.lines()
    if (existingLines == proposedLines) return "  (no change)"

    val ops = diffLines(existingLines, proposedLines)
    return buildString {
        appendLine("--- a/$path")
        appendLine("+++ b/$path")
        append(renderHunks(ops))
    }.trimEnd()
}

/** Line-level LCS diff producing an edit script of EQUAL/DELETE/INSERT ops. */
internal fun diffLines(oldLines: List<String>, newLines: List<String>): List<DiffOp> {
    val n = oldLines.size
    val m = newLines.size
    val lcs = Array(n + 1) { IntArray(m + 1) }
    for (i in n - 1 downTo 0) {
        for (j in m - 1 downTo 0) {
            lcs[i][j] = if (oldLines[i] == newLines[j]) {
                lcs[i + 1][j + 1] + 1
            } else {
                maxOf(lcs[i + 1][j], lcs[i][j + 1])
            }
        }
    }
    val ops = mutableListOf<DiffOp>()
    var i = 0
    var j = 0
    while (i < n && j < m) {
        when {
            oldLines[i] == newLines[j] -> {
                ops += DiffOp(DiffOpKind.EQUAL, oldLines[i])
                i++
                j++
            }
            lcs[i + 1][j] >= lcs[i][j + 1] -> {
                ops += DiffOp(DiffOpKind.DELETE, oldLines[i])
                i++
            }
            else -> {
                ops += DiffOp(DiffOpKind.INSERT, newLines[j])
                j++
            }
        }
    }
    while (i < n) {
        ops += DiffOp(DiffOpKind.DELETE, oldLines[i])
        i++
    }
    while (j < m) {
        ops += DiffOp(DiffOpKind.INSERT, newLines[j])
        j++
    }
    return ops
}

/** Groups an edit script into unified-diff hunks with [context] lines of surrounding EQUAL context. */
internal fun renderHunks(ops: List<DiffOp>, context: Int = 3): String {
    val changedIndices = ops.indices.filter { ops[it].kind != DiffOpKind.EQUAL }
    if (changedIndices.isEmpty()) return ""

    data class Hunk(val start: Int, val end: Int)
    val hunks = mutableListOf<Hunk>()
    var hunkStart = changedIndices.first()
    var hunkEnd = changedIndices.first()
    for (idx in changedIndices.drop(1)) {
        if (idx - hunkEnd <= context * 2) {
            hunkEnd = idx
        } else {
            hunks += Hunk(hunkStart, hunkEnd)
            hunkStart = idx
            hunkEnd = idx
        }
    }
    hunks += Hunk(hunkStart, hunkEnd)

    var oldLine = 1
    var newLine = 1
    var opIdx = 0
    return buildString {
        for (hunk in hunks) {
            val from = maxOf(0, hunk.start - context)
            val to = minOf(ops.size - 1, hunk.end + context)
            while (opIdx < from) {
                if (ops[opIdx].kind != DiffOpKind.INSERT) oldLine++
                if (ops[opIdx].kind != DiffOpKind.DELETE) newLine++
                opIdx++
            }
            val oldStart = oldLine
            val newStart = newLine
            var oldCount = 0
            var newCount = 0
            val lines = StringBuilder()
            for (k in from..to) {
                val op = ops[k]
                when (op.kind) {
                    DiffOpKind.EQUAL -> {
                        lines.append(' ').append(op.line).append('\n')
                        oldCount++
                        newCount++
                    }
                    DiffOpKind.DELETE -> {
                        lines.append('-').append(op.line).append('\n')
                        oldCount++
                    }
                    DiffOpKind.INSERT -> {
                        lines.append('+').append(op.line).append('\n')
                        newCount++
                    }
                }
            }
            appendLine("@@ -$oldStart,${oldCount.coerceAtLeast(1)} +$newStart,${newCount.coerceAtLeast(1)} @@")
            append(lines)
            oldLine = oldStart + oldCount
            newLine = newStart + newCount
            opIdx = to + 1
        }
    }
}

/**
 * The candidate in [paths] that shares [guessed]'s filename and the most trailing path segments,
 * or null when nothing shares the filename. Lets a wrong package/dir prefix
 * (`com/correx/server/routes/SessionRoutes.kt`) still resolve to the real file
 * (`.../com/correx/apps/server/routes/SessionRoutes.kt`) without ever inventing a non-existent path.
 */
internal fun closestPathByFilename(guessed: String, paths: List<String>): String? {
    val guessedName = guessed.substringAfterLast('/')
    if (guessedName.isBlank()) return null
    val guessedSegs = guessed.split('/').filter { it.isNotBlank() }
    return paths.asSequence()
        .filter { it.substringAfterLast('/') == guessedName }
        .maxByOrNull { candidate ->
            val segs = candidate.split('/').filter { it.isNotBlank() }
            var shared = 0
            var i = segs.size - 1
            var j = guessedSegs.size - 1
            while (i >= 0 && j >= 0 && segs[i] == guessedSegs[j]) {
                shared++; i--; j--
            }
            shared
        }
}

/**
 * Human-readable approval-card preview of a `task_decompose` call: the proposed epic and the numbered
 * child tasks with their "after" (dependency) labels — instead of the truncated raw JSON the generic
 * fallback would show. The nested `tasks`/`parent` args arrive as JSON text (flattened), so they are
 * re-parsed here. Null on any parse miss, so the caller falls back to the raw arguments.
 */
internal fun renderDecomposePreview(parameters: Map<String, Any>): String? {
    val project = parameters["project"] as? String ?: return null
    val tasks = (parameters["tasks"] as? String)
        ?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() } as? JsonArray ?: return null
    if (tasks.isEmpty()) return null
    val titleAt = { i: Int -> ((tasks.getOrNull(i) as? JsonObject)?.get("title") as? JsonPrimitive)?.content }
    val refToIndex = tasks.mapIndexedNotNull { i, e ->
        ((e as? JsonObject)?.get("ref") as? JsonPrimitive)?.content?.let { it to i }
    }.toMap()
    val parentTitle = (parameters["parent"] as? String)
        ?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() as? JsonObject }
        ?.let { (it["title"] as? JsonPrimitive)?.content }

    return buildString {
        append("Decompose '").append(project).append("' into:")
        parentTitle?.let { append("\n  epic: ").append(it) }
        tasks.forEachIndexed { i, e ->
            val o = e as? JsonObject
            append("\n  ").append(i + 1).append(". ")
                .append((o?.get("title") as? JsonPrimitive)?.content ?: "(untitled)")
            val afters = ((o?.get("depends_on") as? JsonArray)
                ?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList())
                .map { d -> (refToIndex[d] ?: d.toIntOrNull())?.let { titleAt(it) } ?: d }
            if (afters.isNotEmpty()) append("  (after: ").append(afters.joinToString(", ")).append(")")
        }
    }
}
