package com.correx.infrastructure.tools.shell

import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.compression.ToolOutputContext
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.*

class ShellToolTest {

    private val sessionId = SessionId(UUID.randomUUID().toString())
    private val stageId = StageId(UUID.randomUUID().toString())
    private val invocationId = ToolInvocationId(UUID.randomUUID().toString())

    private fun createRequest(argv: List<String>): ToolRequest {
        return ToolRequest(
            invocationId = invocationId,
            sessionId = sessionId,
            stageId = stageId,
            toolName = "shell",
            parameters = mapOf("argv" to argv),
        )
    }

    @Test
    fun `validateRequest returns Valid for allowed executable`(): Unit = runBlocking {
        val tool = ShellTool(allowedExecutables = setOf("echo"))
        val request = createRequest(listOf("echo", "hello"))
        assertEquals(ValidationResult.Valid, tool.validateRequest(request))
    }

    @Test
    fun `validateRequest returns Invalid for disallowed executable`(): Unit = runBlocking {
        val tool = ShellTool(allowedExecutables = setOf("echo"))
        val request = createRequest(listOf("ls"))
        val result = tool.validateRequest(request)
        assertTrue(result is ValidationResult.Invalid)
        assertEquals("Executable 'ls' is not in the allowed list.", (result as ValidationResult.Invalid).reason)
    }

    @Test
    fun `allowlist rejects a disallowed command hidden after a shell operator`(): Unit = runBlocking {
        // Bypass (#61): ["echo","&&","rm"] joins to `sh -c "echo && rm"`; an argv[0]-only allowlist
        // check would wave it through with {echo} and then run rm. Every command position is checked.
        val tool = ShellTool(allowedExecutables = setOf("echo"))
        val result = tool.validateRequest(createRequest(listOf("echo", "hi", "&&", "rm", "-rf", "x")))
        assertTrue(result is ValidationResult.Invalid)
        assertEquals("Executable 'rm' is not in the allowed list.", (result as ValidationResult.Invalid).reason)
    }

    @Test
    fun `allowlist allows a chain where every command is allowed, builtins implicit`(): Unit = runBlocking {
        // `cd dir && echo x` — cd is a builtin (implicitly allowed), echo is allowlisted, `dir`/`x`
        // are arguments (not command positions), so the chain is valid.
        val tool = ShellTool(allowedExecutables = setOf("echo"))
        val result = tool.validateRequest(createRequest(listOf("cd", "dir", "&&", "echo", "x")))
        assertEquals(ValidationResult.Valid, result)
    }

    @Test
    fun `allowlist treats a redirect target as a filename, not a command`(): Unit = runBlocking {
        // `echo x > rm` — the token after `>` is a redirect FILE named "rm", not a command to run.
        val tool = ShellTool(allowedExecutables = setOf("echo"))
        assertEquals(ValidationResult.Valid, tool.validateRequest(createRequest(listOf("echo", "x", ">", "rm"))))
    }

    @Test
    fun `validateRequest returns Invalid for empty argv`(): Unit = runBlocking {
        val tool = ShellTool(allowedExecutables = setOf("echo"))
        val request = createRequest(emptyList())
        val result = tool.validateRequest(request)
        assertTrue(result is ValidationResult.Invalid)
        assertEquals(
            "Missing or empty 'argv' parameter. Expected a JSON array of strings.",
            (result as ValidationResult.Invalid).reason,
        )
    }

    @Test
    fun `validateRequest returns Invalid for null argv`(): Unit = runBlocking {
        val tool = ShellTool(allowedExecutables = setOf("echo"))
        val request = ToolRequest(
            invocationId = invocationId,
            sessionId = sessionId,
            stageId = stageId,
            toolName = "shell",
            parameters = emptyMap(),
        )
        val result = tool.validateRequest(request)
        assertTrue(result is ValidationResult.Invalid)
        assertEquals(
            "Missing or empty 'argv' parameter. Expected a JSON array of strings.",
            (result as ValidationResult.Invalid).reason,
        )
    }

    @Test
    fun `execute returns Success for exit code 0`(): Unit = runBlocking {
        val tool = ShellTool(allowedExecutables = setOf("echo"))
        val request = createRequest(listOf("echo", "hello"))
        val result = tool.execute(request)

        assertTrue(result is ToolResult.Success)
        val success = result as ToolResult.Success
        assertTrue(success.output.startsWith("hello"))
        assertEquals(0, success.exitCode)
        assertEquals(invocationId, success.invocationId)
    }

    @Test
    fun `execute returns Failure with non-zero exit code for failed process`(): Unit = runBlocking {
        val tool = ShellTool(allowedExecutables = setOf("false"))
        val request = createRequest(listOf("false"))
        val result = tool.execute(request)

        assertTrue(result is ToolResult.Failure)
        val failure = result as ToolResult.Failure
        assertTrue(failure.reason.contains("exited with code 1"))
        assertEquals(invocationId, failure.invocationId)
        assertTrue(failure.recoverable, "non-zero exit must be recoverable so the model can adapt")
    }

    @Test
    fun `execute surfaces stdout in the failure reason for a non-zero exit`(): Unit = runBlocking {
        // Compilers/test runners (tsc, vite, jest) print diagnostics to STDOUT and exit non-zero.
        // The failure reason must carry that output, else the model sees only "exited with code 2"
        // and fixes blind. Regression for session 6f9a9f08 recovery loop.
        val tool = ShellTool()
        val request = createRequest(listOf("sh", "-c", "echo 'hooks.ts(57,3): error TS1005'; exit 2"))
        val result = tool.execute(request)

        assertTrue(result is ToolResult.Failure)
        val failure = result as ToolResult.Failure
        assertTrue(failure.reason.contains("exited with code 2"))
        assertTrue(
            failure.reason.contains("TS1005"),
            "stdout diagnostics must reach the model's failure feedback, got: ${failure.reason}",
        )
    }

    @Test
    fun `execute returns Failure on timeout`(): Unit = runBlocking {
        // Using sleep command to simulate timeout
        val tool = ShellTool(allowedExecutables = setOf("sleep"), timeoutMs = 100L)
        val request = createRequest(listOf("sleep", "1"))
        val result = tool.execute(request)

        assertTrue(result is ToolResult.Failure)
        val failure = result as ToolResult.Failure
        assertTrue(failure.reason.contains("did not exit within 100ms"), failure.reason)
        assertTrue(failure.reason.contains("detached"), "must coach the model to background it")
        assertTrue(failure.recoverable, "a timeout is a long-running command, not a fatal error")
    }

    @Test
    fun `outputCompressor strips blanks and head-tail elides long output`() {
        val tool = ShellTool()
        val body = (1..100).joinToString("\n") { "line$it" }
        val raw = "\n$body\n\n"
        val compressed = tool.outputCompressor.compress(raw, ToolOutputContext(exitCode = 0))
        val lines = compressed.split("\n")
        assertEquals("line1", lines.first())
        assertEquals("line100", lines.last())
        assertEquals(81, lines.size) // 40 head + 1 marker + 40 tail
        assertTrue(compressed.contains("lines elided"))
    }

    @Test
    fun `outputCompressor passes raw through on non-zero exit`() {
        val tool = ShellTool()
        val raw = "a\n\nb\n\nc"
        val compressed = tool.outputCompressor.compress(raw, ToolOutputContext(exitCode = 1))
        assertEquals(raw, compressed)
    }

    private fun rawArgvRequest(argv: List<Any>) = ToolRequest(
        invocationId = invocationId, sessionId = sessionId, stageId = stageId,
        toolName = "shell", parameters = mapOf("argv" to argv),
    )

    @Test
    fun `validateRequest rejects non-string argv element with actionable message`() {
        // Repro: gemma emitted ["mkdir", -1, "dir"] — AnyMapSerializer decodes -1 to a Long, which
        // used to be silently dropped and stringified to "-1" at exec ("invalid option -- '1'").
        val result = ShellTool().validateRequest(rawArgvRequest(listOf("mkdir", -1L, "dir")))
        assertTrue(result is ValidationResult.Invalid)
        val reason = (result as ValidationResult.Invalid).reason
        assertTrue(reason.contains("index 1"), reason)
        assertTrue(reason.contains("string"), reason)
    }

    @Test
    fun `validateRequest rejects collapsed-array argv0`() {
        // Repro: ["npm\",\"-v\","] — the whole array collapsed into argv[0].
        val result = ShellTool().validateRequest(rawArgvRequest(listOf("npm\",\"-v\",")))
        assertTrue(result is ValidationResult.Invalid)
        assertTrue((result as ValidationResult.Invalid).reason.contains("collapsed array"), result.reason)
    }

    @Test
    fun `validateRequest recovers per-token separator leak into argv`() {
        // Repro: gemma re-emitted `npm -v` as ["npm,","-v"] (trailing comma) and ["npm\",","\"-v\""]
        // (wrapping escaped-quotes) six times → stage_loop_break → FAILED. Strip the stray quote/comma
        // per token so the call runs instead of looping the stage to death.
        listOf(listOf("npm,", "-v"), listOf("npm\",", "\"-v\"")).forEach { argv ->
            val result = ShellTool().validateRequest(rawArgvRequest(argv))
            assertTrue(result is ValidationResult.Valid, "$argv: ${(result as? ValidationResult.Invalid)?.reason}")
        }
    }

    @Test
    fun `validateRequest splits a collapsed single-string command line into tokens`() {
        // Repro: weak model emitted ["npm create vite@latest frontend"] — the whole command line as
        // one whitespace-carrying element (no quotes/commas). Now split into tokens instead of
        // rejected as a collapsed array, so the stage doesn't loop on a call the model keeps re-emitting.
        val result = ShellTool().validateRequest(rawArgvRequest(listOf("npm create vite@latest frontend")))
        assertTrue(result is ValidationResult.Valid, (result as? ValidationResult.Invalid)?.reason ?: "")
    }

    @Test
    fun `execute runs a shell command line via sh -c`(): Unit = runBlocking {
        // Repro: gemma called shell with a builtin/command-line ("cd apps && ..."). Now honoured
        // through sh -c instead of the IOException escaping uncaught and killing the workflow.
        val result = ShellTool().execute(createRequest(listOf("cd", "/tmp", "&&", "echo", "ok")))
        assertTrue(result is ToolResult.Success, "shell command line should run: $result")
        assertTrue((result as ToolResult.Success).output.trim() == "ok", result.output)
    }

    @Test
    fun `execute returns recoverable Failure when program is genuinely not runnable`(): Unit = runBlocking {
        val result = ShellTool().execute(createRequest(listOf("definitely-not-a-real-binary-xyz")))
        assertTrue(result is ToolResult.Failure)
        assertTrue((result as ToolResult.Failure).recoverable, "unrunnable program must be recoverable")
    }

    @Test
    fun `validateRequest accepts a clean string argv`() {
        assertEquals(ValidationResult.Valid, ShellTool().validateRequest(createRequest(listOf("mkdir", "-p", "dir"))))
    }

    @Test
    fun `denylist allows npm create — hang closed at the process factory`() {
        // `npm create vite@latest` used to be blocked as an interactive scaffolder. The stdin-prompt
        // hang is now closed at ChildProcess (stdin=/dev/null + non-interactive env), so the idiomatic
        // scaffold path is allowed again. Empty allowedExecutables = allow-all.
        val tool = ShellTool(allowedExecutables = emptySet())
        assertEquals(ValidationResult.Valid, tool.validateRequest(createRequest(listOf("npm", "create", "vite"))))
    }

    @Test
    fun `denylist blocks npx remote runner`() {
        val result = ShellTool().validateRequest(createRequest(listOf("npx", "create-react-app", "app")))
        assertTrue(result is ValidationResult.Invalid)
        assertTrue((result as ValidationResult.Invalid).reason.contains("remote code"), result.reason)
    }

    @Test
    fun `denylist catches remote runner hidden in a shell command line`() {
        // argv[0] is `cd`, but `npx` rides along and would run via sh -c.
        val result = ShellTool().validateRequest(createRequest(listOf("cd", "web", "&&", "npx", "create-foo")))
        assertTrue(result is ValidationResult.Invalid)
        assertTrue((result as ValidationResult.Invalid).reason.contains("remote code"), result.reason)
    }

    @Test
    fun `denylist allows npm install`() {
        assertEquals(ValidationResult.Valid, ShellTool().validateRequest(createRequest(listOf("npm", "install"))))
    }

    @Test
    fun `denylist takes precedence over the allowlist for remote runners`() {
        // Even if npx is explicitly allowed, remote-exec stays blocked.
        val tool = ShellTool(allowedExecutables = setOf("npx"))
        val result = tool.validateRequest(createRequest(listOf("npx", "create-react-app", "app")))
        assertTrue(result is ValidationResult.Invalid)
        assertTrue((result as ValidationResult.Invalid).reason.contains("remote code"), result.reason)
    }
}
