package com.correx.infrastructure.tools.filesystem

import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.FileAffectingTool
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.CancellationException
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.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths

/**
 * Writes content to a file. Write-only by design: deleting is the separate, explicitly-named
 * [FileDeleteTool] so a model can never delete a file by getting an `operation` mode wrong — a
 * destructive action must name itself. (Previously this tool carried an `operation: write|delete`
 * mode; splitting it removes the most-forgotten parameter and makes delete a distinct capability.)
 */
class FileWriteTool(
    allowedPaths: Set<Path> = emptySet(),
    private val workingDir: Path? = null,
) : Tool, FileAffectingTool, ToolExecutor {

    private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()

    override val name: String = "file_write"
    override val description: String =
        "Write content to a file at the specified relative path (creates or overwrites). " +
            "Writing to a nested path auto-creates any missing parent directories — there is no separate " +
            "mkdir step. Do not write shell commands into a file's content."
    override val parametersSchema: JsonObject = buildJsonObject {
        put("type", "object")
        putJsonObject("properties") {
            putJsonObject("path") {
                put("type", "string")
                put("description", "Relative path to write to")
            }
            putJsonObject("content") {
                put("type", "string")
                put("description", "The full file content to write")
            }
        }
        put(
            "required",
            buildJsonArray {
                add(JsonPrimitive("path"))
                add(JsonPrimitive("content"))
            },
        )
    }
    override val tier: Tier = Tier.T2
    override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
    override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)

    override fun affectedPaths(request: ToolRequest): Set<Path> {
        val pathString = request.parameters["path"] as? String ?: return emptySet()
        return setOf(resolvePath(pathString))
    }

    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 fun validateRequest(request: ToolRequest): ValidationResult {
        val pathString = request.parameters["path"] as? String
        val hasContent = request.parameters.containsKey("content")

        return when {
            pathString == null ->
                ValidationResult.Invalid(
                    "Missing 'path' parameter (string). Call file_write with " +
                        """{"path": "<relative path>", "content": "<full file content>"}.""",
                )

            !hasContent ->
                ValidationResult.Invalid(
                    "Missing 'content' parameter (string). Call file_write with " +
                        """{"path": "$pathString", "content": "<full file content>"}.""",
                )

            else -> runCatching {
                val path = resolvePath(pathString)
                checkPathAllowed(path, pathString)
            }.getOrElse { e ->
                mapExceptionToValidationResult(e)
            }
        }
    }

    private fun checkPathAllowed(path: Path, pathString: String): ValidationResult =
        when {
            normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
            !isPathAllowed(path) -> ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
            else -> ValidationResult.Valid
        }

    private fun isPathAllowed(path: Path): Boolean =
        PathJail.isContained(path, normalizedAllowedPaths)

    private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
        when (e) {
            is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
            is IOException -> ValidationResult.Invalid("IO error: ${e.message}")
            is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}")
            else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred")
        }

    override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
        val validation = validateRequest(request)
        if (validation is ValidationResult.Invalid) {
            return@withContext ToolResult.Failure(
                invocationId = request.invocationId,
                reason = validation.reason,
                recoverable = false,
            )
        }

        val pathString = request.parameters["path"] as String
        val content = request.parameters["content"] as String
        val path = resolvePath(pathString)

        // A path that names an existing directory is a correctable model mistake (it meant a file
        // inside it), not a fatal IO error. Caught here it flows back as a recoverable ERROR the
        // model can fix on the next round, instead of the atomic move throwing a bare-path
        // IOException that aborts the whole stage non-retryably.
        if (Files.isDirectory(path)) {
            return@withContext ToolResult.Failure(
                invocationId = request.invocationId,
                reason = "Path '$pathString' is a directory — write to a file inside it instead " +
                    "(e.g. '$pathString/<filename>').",
                recoverable = true,
            )
        }

        val originalContent = runCatching { Files.readString(path) }.getOrDefault("")

        // Clobber guard (#245): a full overwrite that shrinks an existing non-trivial file to an
        // elided stub (literal `...` placeholders) is almost always the model writing a file from
        // memory instead of editing it — the incident that silently broke SessionRoutes.kt. Block it
        // (recoverable) and force a targeted file_edit. Independent of approval tier because the
        // auto-driver approves T2 writes without a human looking.
        clobberReason(originalContent, content)?.let { reason ->
            return@withContext ToolResult.Failure(request.invocationId, reason, recoverable = true)
        }

        val result = runCatching {
            Files.createDirectories(path.parent)
            AtomicFileWriter.write(path, content.toByteArray(Charsets.UTF_8))
            ToolResult.Success(
                invocationId = request.invocationId,
                output = "File written successfully to $pathString",
            )
        }.getOrElse { e ->
            handleExecutionException(e, request.invocationId, pathString)
        }

        // Attach a unified diff of the change so the side effect is auditable downstream — the
        // reviewer stage reads the file_written artifact and needs to see WHAT changed, not just
        // that a write happened (F-019).
        if (result is ToolResult.Success) {
            val updatedContent = runCatching { Files.readString(path) }.getOrDefault("")
            val diff = unifiedDiff(pathString, originalContent, updatedContent)
            result.copy(
                output = if (diff.isBlank()) result.output else "${result.output}\n\n$diff",
                metadata = result.metadata + ("diff" to diff),
            )
        } else {
            result
        }
    }

    /**
     * Returns a rejection reason when [newContent] would overwrite a non-trivial existing file
     * ([oldContent]) with a drastically-shrunk elided stub, else null. Requires BOTH a big shrink
     * and elision markers so legitimate refactors that only shrink (dead-code removal) and new-file
     * scaffolds that only elide (target didn't exist → oldContent is "") both pass untouched.
     */
    private fun clobberReason(oldContent: String, newContent: String): String? {
        val oldLines = oldContent.lines().count { it.isNotBlank() }
        if (oldLines < MIN_GUARDED_LINES) return null
        val newLines = newContent.lines().count { it.isNotBlank() }
        val shrankHard = newLines < oldLines * MAX_SHRINK_RATIO
        if (!shrankHard || !hasElisionMarker(newContent)) return null
        return "Refusing full overwrite: this replaces $oldLines lines of existing code with a " +
            "$newLines-line stub containing '...' placeholders. Use file_edit (operation 'replace') " +
            "for a targeted change instead of rewriting the whole file from memory."
    }

    /** A line that is just an elision placeholder (`...`, `// ...`, `# ... existing code ...`). */
    private fun hasElisionMarker(content: String): Boolean =
        content.lineSequence().any { line ->
            val t = line.trim().trim('/', '#', '*', '<', '!', '-', ' ')
            t == "..." || t == "…" || t.endsWith(" ...") || t.endsWith(" …")
        }

    private fun handleExecutionException(
        e: Throwable,
        invocationId: ToolInvocationId,
        pathString: String,
    ): ToolResult = when (e) {
        is CancellationException -> throw e
        // Write IO errors are recoverable: the model can correct the path/content and retry within
        // the stage's tool loop (bounded by MAX_TOOL_ROUNDS). Marking them fatal turned a fixable
        // mistake — e.g. writing to a directory — into a terminal workflow failure.
        is IOException -> ToolResult.Failure(
            invocationId = invocationId,
            reason = "IO error writing '$pathString': ${e.message ?: e.javaClass.simpleName}",
            recoverable = true,
        )

        is SecurityException -> ToolResult.Failure(
            invocationId = invocationId,
            reason = "Access denied: $pathString, ${e.message}",
            recoverable = false,
        )

        else -> ToolResult.Failure(
            invocationId = invocationId,
            reason = e.message ?: "Unknown error occurred",
            recoverable = false,
        )
    }

    private companion object {
        const val MIN_GUARDED_LINES = 30
        const val MAX_SHRINK_RATIO = 0.4
    }
}
