package com.optix.android.tools.engine

import com.optix.android.domain.model.ToolEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Explicit, typed failures for shell execution so callers can react precisely
 * instead of catching generic [Exception].
 */
open class ShellException(message: String, cause: Throwable? = null) :
    Exception(message, cause)

/** Thrown when a command exceeds its configured timeout. The process is forcibly destroyed. */
class ShellTimeoutException(message: String, cause: Throwable? = null) :
    ShellException(message, cause)

/** Thrown when a command exits with a non-zero status code and the caller requested strict checks. */
class ShellNonZeroExitException(
    message: String,
    val exitCode: Int,
    val stdout: String,
    val stderr: String,
) : ShellException(message)

/** Thrown when a command or argument fails validation (denied, malformed, or unsafe). */
class ShellSecurityException(message: String, cause: Throwable? = null) :
    ShellException(message, cause)

/**
 * Result of a shell invocation. stdout and stderr are captured separately and
 * [timedOut] reports whether the process was killed for exceeding the timeout.
 */
data class ShellResult(
    val exitCode: Int,
    val stdout: String,
    val stderr: String,
    val timedOut: Boolean = false,
) {
    /** Combined output (stdout then stderr) for callers that don't care about the split. */
    val output: String
        get() = if (stderr.isBlank()) stdout else "$stdout\n$stderr".trimEnd()

    /** True when the process exited successfully (0 and not timed out). */
    val isSuccess: Boolean get() = exitCode == 0 && !timedOut
}

/**
 * Runs shell commands (typically via `su -c`) on the device. Hardened to be safe
 * and observable:
 *  - Explicit exception types ([ShellTimeoutException], [ShellNonZeroExitException],
 *    [ShellSecurityException]) instead of swallowing generic [Exception].
 *  - Configurable per-command timeout (default [DEFAULT_TIMEOUT_MS]) that forcibly
 *    destroys hung processes via [Process.destroyForcibly] so execution can never block forever.
 *  - Argument validation and a denylist to reject obviously dangerous or malformed
 *    commands, plus [sanitizeArg]/[quoteArg] helpers for safe construction.
 *  - Structured [ShellResult] capturing stdout and stderr separately.
 *
 * Existing callers using [execute] (returns String) and [executeTermuxAsRoot] keep
 * working unchanged; new code should prefer [exec] / [execTermux].
 */
@Singleton
class ShellExecutor @Inject constructor(
    private val envManager: EnvironmentManager
) {
    /** Default timeout per command: 60 seconds. */
    var defaultTimeoutMs: Long = DEFAULT_TIMEOUT_MS

    /**
     * When true, commands that contain denylisted shell metacharacters (e.g. `;`, `||`,
     * `&`, backtick substitution) are rejected. Defaults to false because legitimate
     * tooling uses these operators (e.g. `which aws || echo NOT_FOUND`). Callers that
     * build commands from untrusted user/tool parameters should enable this and/or pass
     * each parameter through [sanitizeArg]/[quoteArg] so injection is impossible.
     */
    var enforceDenylist: Boolean = false

    /**
     * Tokens that indicate a likely dangerous or malformed command. These are matched
     * against the raw command string and against each individual argument.
     */
    private val DENYLIST: Set<Regex> = setOf(
        // Shell metacharacters that enable chaining/redirection/injection when not expected.
        Regex(";\\s*\\w"),          // command separator followed by another command
        Regex("\\|\\|?\\s*\\w"),    // pipes / logical OR
        Regex("&\\s*\\w"),          // background / logical AND
        Regex("`[^`]*`"),           // backtick command substitution
        Regex("\\$\\("),            // $(...) command substitution
        Regex(">\\s*/"),            // redirection to a path
        Regex(">>\\s*/"),
        Regex("\\$\\{"),            // parameter expansion
        Regex("\\\$\\w+\\("),       // function/command-style expansion
    )

    /**
     * Substrings that are never allowed anywhere in a command or argument because they
     * are almost always indicative of injection or destructive intent.
     */
    private val HARD_DENY_SUBSTRINGS: Set<String> = setOf(
        "rm -rf /",
        "mkfs",
        ":(){:|:&};:",   // fork bomb
        "dd if=/dev/zero",
        "chmod -R 000",
        "curl | sh",
        "wget | sh",
    )

    /**
     * Escapes a single argument for safe inclusion in a shell command by wrapping it in
     * single quotes and escaping any embedded single quotes. Use this on every untrusted
     * value that is concatenated into a command line.
     */
    fun quoteArg(arg: String): String = "'" + arg.replace("'", "'\\''") + "'"

    /**
     * Alias of [quoteArg] kept for call-site readability when sanitizing user/tool params.
     */
    fun sanitizeArg(arg: String): String = quoteArg(arg)

    /**
     * Extension form retained for backwards compatibility with existing callers that
     * used `"x".shellEscape()`.
     */
    fun String.shellEscape(): String = quoteArg(this)

    /**
     * Validates a command/argument. Throws [ShellSecurityException] when the input is
     * empty, absurdly long, or matches the denylist. Returns normally otherwise.
     */
    fun validateCommand(command: String) {
        if (command.isBlank()) {
            throw ShellSecurityException("Refusing to run a blank command")
        }
        if (command.length > MAX_COMMAND_LENGTH) {
            throw ShellSecurityException(
                "Command too long (${command.length} chars, max $MAX_COMMAND_LENGTH)"
            )
        }
        for (bad in HARD_DENY_SUBSTRINGS) {
            if (command.contains(bad, ignoreCase = true)) {
                throw ShellSecurityException("Command contains denied token: '$bad'")
            }
        }
        if (enforceDenylist) {
            for (pattern in DENYLIST) {
                if (pattern.containsMatchIn(command)) {
                    throw ShellSecurityException(
                        "Command rejected by denylist pattern: ${pattern.pattern}"
                    )
                }
            }
        }
    }

    /**
     * Executes a command via `su -c` and returns the output as a String (backwards
     * compatible). On failure, returns an error string rather than throwing so existing
     * callers keep compiling. Prefer [exec] for structured results and typed errors.
     */
    suspend fun execute(command: String): String {
        return try {
            exec(command).output
        } catch (e: ShellException) {
            "ERROR: ${e.message}"
        }
    }

    /**
     * Executes a command via `su -c` with a configurable timeout and separate stream
     * capture. Throws [ShellSecurityException] if validation fails,
     * [ShellTimeoutException] if the command exceeds [timeoutMs], or wraps IO failures
     * in [ShellException].
     */
    suspend fun exec(
        command: String,
        timeoutMs: Long = defaultTimeoutMs,
        root: Boolean = true,
    ): ShellResult = withContext(Dispatchers.IO) {
        validateCommand(command)

        val builder = if (root) {
            ProcessBuilder("su", "-c", command)
        } else {
            ProcessBuilder("sh", "-c", command)
        }
        builder.redirectErrorStream(false)

        val process = try {
            builder.start()
        } catch (e: IOException) {
            throw ShellException("Failed to start process for command: ${e.message}", e)
        }

        val stdout = StringBuilder()
        val stderr = StringBuilder()
        val outThread = readInto(process.inputStream, stdout)
        val errThread = readInto(process.errorStream, stderr)

        val finished = try {
            process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
        } catch (e: InterruptedException) {
            process.destroyForcibly()
            Thread.currentThread().interrupt()
            false
        }

        if (!finished) {
            process.destroyForcibly()
            // Drain remaining output so we don't lose diagnostics.
            outThread.join(500)
            errThread.join(500)
            throw ShellTimeoutException(
                "Command timed out after ${timeoutMs}ms and was forcibly destroyed: $command"
            )
        }

        outThread.join()
        errThread.join()
        val exitCode = runCatching { process.exitValue() }.getOrDefault(-1)
        ShellResult(
            exitCode = exitCode,
            stdout = stdout.toString(),
            stderr = stderr.toString(),
            timedOut = false,
        )
    }

    /**
     * Executes a Termux tool via root with properly escaped arguments. Streams each line
     * as a [ToolEvent.Log] and surfaces a missing binary via [ToolEvent.InstallRequired]
     * instead of silently running `pkg install`.
     */
    suspend fun executeTermuxAsRoot(
        toolName: String,
        args: List<String>,
        aptPackage: String = toolName,
        emit: suspend (ToolEvent) -> Unit
    ) {
        execTermux(toolName, args, aptPackage, emit, autoInstall = false)
    }

    /**
     * Same as [executeTermuxAsRoot] but auto-installs the package if missing. Only call
     * after the user has explicitly consented to the installation.
     */
    suspend fun executeTermuxAsRootWithInstall(
        toolName: String,
        args: List<String>,
        aptPackage: String = toolName,
        emit: suspend (ToolEvent) -> Unit
    ) {
        execTermux(toolName, args, aptPackage, emit, autoInstall = true)
    }

    /**
     * Structured variant of [executeTermuxAsRoot]. Runs the tool via `su -c`, captures
     * combined output, and returns a [ShellResult]. Emits the same [ToolEvent]s as the
     * legacy methods for UI parity. Throws [ShellException] on execution failure.
     */
    suspend fun execTermux(
        toolName: String,
        args: List<String>,
        aptPackage: String = toolName,
        emit: (suspend (ToolEvent) -> Unit)? = null,
        autoInstall: Boolean = false,
        timeoutMs: Long = defaultTimeoutMs,
    ): ShellResult = withContext(Dispatchers.IO) {
        if (!envManager.isRootAvailable()) {
            emit?.invoke(ToolEvent.Error("Root is required to run $toolName."))
            emit?.invoke(ToolEvent.Done)
            throw ShellException("Root unavailable for $toolName")
        }
        if (!envManager.isTermuxInstalled()) {
            emit?.invoke(ToolEvent.Error("Termux is required to run $toolName. Install from F-Droid."))
            emit?.invoke(ToolEvent.Done)
            throw ShellException("Termux unavailable for $toolName")
        }

        val escapedArgs = args.joinToString(" ") { sanitizeArg(it) }
        val toolPath = "${envManager.getTermuxPath()}/$toolName"
        val shellCommand = "$toolPath $escapedArgs"

        try {
            val result = runSuCaptured(shellCommand, toolName, emit, timeoutMs)
            var missing = result.stdout.contains("command not found", true) ||
                result.stdout.contains("No such file", true)

            if (missing) {
                if (!autoInstall) {
                    emit?.invoke(ToolEvent.InstallRequired(toolName, aptPackage))
                    emit?.invoke(ToolEvent.Done)
                    return@withContext result
                }
                emit?.invoke(ToolEvent.Log("⚠️ $toolName not found. Installing '$aptPackage'..."))
                val installResult = runSuCaptured(
                    "${envManager.getTermuxPath()}/pkg install -y $aptPackage",
                    toolName,
                    emit,
                    timeoutMs,
                )
                emit?.invoke(ToolEvent.Log("✅ Installation complete. Retrying $toolName..."))
                val retry = runSuCaptured(shellCommand, toolName, emit, timeoutMs)
                missing = retry.stdout.contains("command not found", true) ||
                    retry.stdout.contains("No such file", true)
                if (missing) {
                    emit?.invoke(ToolEvent.Error("❌ Failed to install $toolName. Open Termux and run 'pkg install $aptPackage'."))
                }
                retry
            } else {
                result
            }
        } catch (e: ShellTimeoutException) {
            emit?.invoke(ToolEvent.Error("❌ $toolName timed out after ${timeoutMs}ms"))
            emit?.invoke(ToolEvent.Done)
            throw e
        } catch (e: ShellException) {
            emit?.invoke(ToolEvent.Error("Execution failed: ${e.message}"))
            emit?.invoke(ToolEvent.Done)
            throw e
        }
    }

    /**
     * Captures combined output of a `su -c` command with timeout enforcement. Returns a
     * [ShellResult] with stdout holding the combined stream. On timeout the process is
     * forcibly destroyed and [ShellTimeoutException] is thrown.
     */
    private suspend fun runSuCaptured(
        shellCommand: String,
        toolName: String,
        emit: (suspend (ToolEvent) -> Unit)?,
        timeoutMs: Long,
    ): ShellResult = withContext(Dispatchers.IO) {
        validateCommand(shellCommand)
        val process = try {
            ProcessBuilder("su", "-c", shellCommand)
                .redirectErrorStream(true)
                .start()
        } catch (e: IOException) {
            throw ShellException("Failed to start $toolName: ${e.message}", e)
        }

        val combined = StringBuilder()
        val pendingLogs = java.util.concurrent.ConcurrentLinkedQueue<String>()
        val thread = readInto(process.inputStream, combined) { line ->
            if (line.isNotBlank()) pendingLogs.add(line)
        }

        val finished = try {
            process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
        } catch (e: InterruptedException) {
            process.destroyForcibly()
            Thread.currentThread().interrupt()
            false
        }

        if (!finished) {
            process.destroyForcibly()
            thread.join(500)
            throw ShellTimeoutException(
                "$toolName timed out after ${timeoutMs}ms and was forcibly destroyed"
            )
        }
        thread.join()

        // Emit accumulated log lines in a suspend context now that the thread is done
        if (emit != null) {
            pendingLogs.forEach { line -> emit(ToolEvent.Log(line)) }
        }

        val exitCode = runCatching { process.exitValue() }.getOrDefault(-1)
        ShellResult(
            exitCode = exitCode,
            stdout = combined.toString(),
            stderr = "",
            timedOut = false,
        )
    }

    /**
     * Drains [stream] into [sink] on a dedicated thread so reads never block the
     * coroutine. Optionally invokes [onLine] for each non-null line.
     */
    private fun readInto(
        stream: java.io.InputStream,
        sink: StringBuilder,
        onLine: ((String) -> Unit)? = null,
    ): Thread {
        val thread = Thread {
            try {
                stream.bufferedReader().use { reader ->
                    var line: String? = reader.readLine()
                    while (line != null) {
                        sink.appendLine(line)
                        onLine?.invoke(line)
                        line = reader.readLine()
                    }
                }
            } catch (_: IOException) {
                // Stream closed by process destruction — ignore.
            }
        }
        thread.isDaemon = true
        thread.start()
        return thread
    }

    companion object {
        const val DEFAULT_TIMEOUT_MS: Long = 60_000L
        const val MAX_COMMAND_LENGTH: Int = 32_768
    }
}
