package com.correx.infrastructure.tools.filesystem

import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
import com.correx.core.tools.compression.CompressionRule.StripLeadingWhitespace
import com.correx.core.tools.compression.DeclarativeCompressor
import com.correx.core.tools.compression.OutputCompressionSpec
import com.correx.core.tools.compression.ToolOutputCompressor
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.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name

class FileReadTool(
    private val allowedPaths: Set<Path> = emptySet(),
    private val workingDir: Path? = null,
) : Tool, ToolExecutor {

    // Resolve relative paths against the bound workspace's working dir, NOT the JVM
    // process cwd. Mirrors FileWriteTool/FileEditTool so all three jail against the
    // same anchor and agree with the Plane-2 tool-call-intent containment check.
    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 = "file_read"
    override val description: String =
        "Read a file's contents, or list a directory's entries, at the specified path"
    override val parametersSchema: JsonObject = buildJsonObject {
        put("type", "object")
        putJsonObject("properties") {
            putJsonObject("path") {
                put("type", "string")
                put(
                    "description",
                    "Relative path. A file path returns the file's contents; a directory path " +
                        "returns its entries, one per line, with sub-directories suffixed '/'.",
                )
            }
            putJsonObject("start_line") {
                put("type", "integer")
                put("description", "First line to read, 1-indexed inclusive. Omit to read from beginning.")
            }
            putJsonObject("end_line") {
                put("type", "integer")
                put("description", "Last line to read, 1-indexed inclusive. Omit to read to end of file.")
            }
        }
        put("required", buildJsonArray { add(JsonPrimitive("path")) })
    }
    override val tier: Tier = Tier.T1
    override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
    override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
    override val outputCompressor: ToolOutputCompressor = DeclarativeCompressor(
        OutputCompressionSpec(listOf(StripBlankLines, StripLeadingWhitespace)),
    )

    override fun validateRequest(request: ToolRequest): ValidationResult {
        val pathString = request.parameters["path"] as? String
            ?: return ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.")
        val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
        val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()

        if (startLine != null && startLine < 1)
            return ValidationResult.Invalid("'start_line' must be >= 1.")
        if (endLine != null && endLine < 1)
            return ValidationResult.Invalid("'end_line' must be >= 1.")
        if (startLine != null && endLine != null && endLine < startLine)
            return ValidationResult.Invalid("'end_line' must be >= 'start_line'.")

        return runCatching {
            val path = resolvePath(pathString)
            // Widen the jail with any operator-approved out-of-workspace paths carried on the request
            // (OutsidePathAccessGrantedEvent). Reads may escape once approved; writes never see this.
            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}")
                is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
                is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
                else -> ValidationResult.Invalid(it.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 path = resolvePath(pathString)
        val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
        val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()

        if (!Files.exists(path))
        // Recoverable: the model often probes for files that may not exist (e.g. a
        // speculative __init__.py). Feed the miss back so it can adapt, don't kill the stage.
            return@withContext ToolResult.Failure(
                invocationId = request.invocationId,
                reason = "File not found: $pathString",
                recoverable = true,
            )

        if (Files.isDirectory(path)) {
            listDir(path, request)
        } else {
            readFile(path, startLine, endLine, request)
        }
    }

    private fun readFile(path: Path, startLine: Int?, endLine: Int?, request: ToolRequest): ToolResult = runCatching {
        // Read the file once. Lines and the (whole-read) content hash both derive from these bytes,
        // so a full read no longer hits the disk twice (readAllLines + readAllBytes for the hash).
        val bytes = Files.readAllBytes(path)
        val lines = bytes.inputStream().bufferedReader().readLines()
        val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
        val end = (endLine ?: lines.size).coerceAtMost(lines.size)
        val selected = lines.subList(start, end)
        // Bound the read's context footprint. Only auto-truncate a read-to-EOF (no explicit end_line):
        // an explicit range is deliberate paging and is honoured in full. This stops a single file_read
        // of a large file (e.g. a doc) from flooding L2 and evicting the files the stage actually needs.
        val lineCapped = endLine == null && selected.size > MAX_READ_LINES
        var content = (if (lineCapped) selected.take(MAX_READ_LINES) else selected).joinToString("\n")
        var note = if (lineCapped) {
            "\n… truncated at $MAX_READ_LINES lines (file has ${lines.size}); " +
                "pass start_line/end_line to read a specific range."
        } else {
            ""
        }
        // Hard char backstop (very long lines can blow the budget under the line cap).
        val charCapped = content.length > MAX_READ_CHARS
        if (charCapped) {
            content = content.take(MAX_READ_CHARS)
            note = "\n… truncated at $MAX_READ_CHARS chars; pass start_line/end_line to page through the file."
        }
        // Record a content hash only for a WHOLE, UNTRUNCATED read, so the stale-write gate baselines
        // against what the agent actually saw. A partial or truncated view establishes no baseline —
        // the agent must read the relevant range before editing.
        val sawWholeFile = startLine == null && endLine == null && !lineCapped && !charCapped
        val metadata = if (sawWholeFile) mapOf("contentHash" to sha256(bytes)) else emptyMap()
        ToolResult.Success(
            invocationId = request.invocationId,
            output = content + note,
            metadata = metadata,
        )
    }.getOrElse {
        ToolResult.Failure(
            invocationId = request.invocationId,
            reason = "Failed to read file: ${it.message}",
            recoverable = false,
        )
    }

    private fun sha256(bytes: ByteArray): String =
        java.security.MessageDigest.getInstance("SHA-256").digest(bytes)
            .joinToString("") { "%02x".format(it) }

    private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching {
        val entries = path.listDirectoryEntries().sortedBy { it.name }
        val shown = entries.take(LIST_MAX_ENTRIES).joinToString("\n") { entry ->
            if (Files.isDirectory(entry)) "${entry.name}/" else entry.name
        }
        val note =
            if (entries.size > LIST_MAX_ENTRIES) {
                "\n… truncated at $LIST_MAX_ENTRIES of ${entries.size} entries; list a sub-directory to narrow."
            } else {
                ""
            }
        ToolResult.Success(
            invocationId = request.invocationId,
            output = shown + note,
        )
    }.getOrElse {
        ToolResult.Failure(
            invocationId = request.invocationId,
            reason = "Failed to list dir: ${it.message}",
            recoverable = false,
        )
    }

    private companion object {
        // Bound a single read's context footprint so one file_read of a large file can't flood L2
        // and evict the files the stage actually needs. A read-to-EOF past the line cap, or any read
        // past the char backstop, is truncated with a note pointing at start_line/end_line paging.
        const val MAX_READ_LINES = 400
        const val MAX_READ_CHARS = 20_000
        const val LIST_MAX_ENTRIES = 400
    }
}
