package dev.jasonpearson.automobile.junit

import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.Closeable
import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.UnixDomainSocketAddress
import java.nio.channels.Channels
import java.nio.channels.SocketChannel
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import kotlin.concurrent.thread
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

internal object DaemonSocketClientManager {
  // Use ThreadLocal to give each thread its own socket connection
  // This enables true parallel execution since socket server queues requests per connection
  private val threadLocalClient = ThreadLocal<DaemonSocketClient>()
  private val clientLock = Any()
  @Volatile private var daemonEnsured = false
  @JvmStatic internal var testClient: DaemonToolClient? = null

  fun callTool(toolName: String, arguments: JsonObject, timeoutMs: Long): DaemonResponse {
    val overrideClient = testClient
    if (overrideClient != null) {
      return overrideClient.callTool(toolName, arguments, timeoutMs)
    }
    return getOrCreateClient().callTool(toolName, arguments, timeoutMs)
  }

  fun readResource(uri: String, timeoutMs: Long): DaemonResponse {
    val overrideClient = testClient
    if (overrideClient != null) {
      return overrideClient.readResource(uri, timeoutMs)
    }
    return getOrCreateClient().readResource(uri, timeoutMs)
  }

  fun sessionUuid(): String {
    return testClient?.sessionUuid ?: getOrCreateClient().sessionUuid
  }

  private fun getOrCreateClient(): DaemonSocketClient {
    // Each thread gets its own connection for parallel execution
    val existing = threadLocalClient.get()
    if (existing != null && existing.isConnected()) {
      return existing
    }

    val socketPath = DaemonSocketPaths.socketPath()

    // Ensure daemon is running (restart if socket disappeared after crash/exit)
    synchronized(clientLock) {
      // Reset daemonEnsured if socket doesn't exist (daemon crashed/exited)
      if (!File(socketPath).exists()) {
        daemonEnsured = false
      }

      if (!daemonEnsured) {
        ensureDaemonRunning()
        daemonEnsured = true
      }
    }

    // Create new client for this thread
    val newClient = DaemonSocketClient(socketPath)
    threadLocalClient.set(newClient)
    return newClient
  }

  private fun ensureDaemonRunning() {
    val socketPath = DaemonSocketPaths.socketPath()
    val forceRestart = SystemPropertyCache.getBoolean("automobile.daemon.force.restart", false)
    if (!forceRestart && DaemonSocketClient.isAvailable(socketPath)) {
      return
    }

    val startCommand =
        if (forceRestart) {
          DaemonSocketPaths.buildDaemonRestartCommand()
        } else {
          DaemonSocketPaths.buildDaemonStartCommand()
        }
    val debugMode = SystemPropertyCache.getBoolean("automobile.debug", false)
    if (debugMode) {
      println("Starting AutoMobile daemon with: ${startCommand.joinToString(" ")}")
    }

    val environmentOverrides = resolveDaemonEnvironmentOverrides()
    AutoMobileSharedUtils.executeCommand(
        startCommand,
        DaemonSocketPaths.daemonStartTimeoutMs(),
        environmentOverrides,
    )

    val started =
        DaemonSocketClient.waitForAvailability(
            socketPath,
            DaemonSocketPaths.daemonStartTimeoutMs(),
        )
    if (!started) {
      throw DaemonUnavailableException(
          "Daemon failed to start within ${DaemonSocketPaths.daemonStartTimeoutMs()}ms"
      )
    }

    // NOTE: Device pool initialization check removed to allow parallel test execution.
    // The daemon initializes its device pool at startup, and tests will wait for
    // devices as needed when they call executePlan.
  }

  /**
   * Wait for daemon device pool to have at least one device This is a blocking health check that
   * prevents tests from running until devices are available.
   */
  private fun waitForDevicePoolReady(timeoutMs: Long) {
    val debugMode = SystemPropertyCache.getBoolean("automobile.debug", false)
    val json = Json { ignoreUnknownKeys = true }
    if (debugMode) {
      println("Waiting for daemon device pool to initialize...")
    }

    val startTime = System.currentTimeMillis()
    var lastDeviceCount = -1
    var nextRefreshTime = startTime + 2000 // First refresh after 2 seconds

    while (System.currentTimeMillis() - startTime < timeoutMs) {
      try {
        val socketClient = DaemonSocketClient(DaemonSocketPaths.socketPath())

        // Trigger device refresh every 2 seconds if pool is still empty
        val now = System.currentTimeMillis()
        if (now >= nextRefreshTime && lastDeviceCount == 0) {
          if (debugMode) {
            println("Triggering device pool refresh...")
          }
          try {
            val refreshResponse = socketClient.callDaemonMethod("daemon/refreshDevices", 5000)
            if (debugMode && refreshResponse.success) {
              val addedDevices =
                  refreshResponse.result?.jsonObject?.get("addedDevices")?.jsonPrimitive?.intOrNull
                      ?: 0
              println("Device pool refresh complete: added $addedDevices devices")
            }
          } catch (e: Exception) {
            if (debugMode) {
              println("Device refresh error: ${e.message}")
            }
          }
          nextRefreshTime = now + 2000 // Schedule next refresh in 2 seconds
        }

        val response =
            socketClient.callTool(
                "listDevices",
                JsonObject(mapOf("platform" to JsonPrimitive("android"))),
                5000,
            )
        socketClient.close()

        if (response.success) {
          val payloadText =
              response.result
                  ?.jsonObject
                  ?.get("content")
                  ?.jsonArray
                  ?.firstOrNull()
                  ?.jsonObject
                  ?.get("text")
                  ?.jsonPrimitive
                  ?.content
          val parsedResult = payloadText?.let { json.parseToJsonElement(it).jsonObject }
          val poolStatus = parsedResult?.get("poolStatus")?.jsonObject
          val totalDevices =
              poolStatus?.get("total")?.jsonPrimitive?.intOrNull
                  ?: parsedResult?.get("totalCount")?.jsonPrimitive?.intOrNull
                  ?: 0

          if (totalDevices > 0) {
            if (debugMode) {
              println("Device pool ready with $totalDevices device(s)")
            }
            return
          }

          // Log only if device count changed
          if (totalDevices != lastDeviceCount) {
            if (debugMode) {
              println("Device pool still empty, waiting...")
            }
            lastDeviceCount = totalDevices
          }
        }
      } catch (e: Exception) {
        // Ignore errors during health check, will retry
        if (debugMode) {
          println("Device pool check error: ${e.message}")
        }
      }

      Thread.sleep(500)
    }

    // Device pool is still empty after timeout - throw error
    throw DaemonUnavailableException(
        "Daemon device pool is empty after ${timeoutMs}ms. " +
            "Start an emulator or connect a physical device before running tests."
    )
  }

  private fun resolveDaemonEnvironmentOverrides(): Map<String, String> {
    val resolvedOverrides = mutableMapOf<String, String>()
    val ctrlProxyApkProperty =
        SystemPropertyCache.get("automobile.ctrl.proxy.apk.path", "").trim()
    val ctrlProxyApkEnv = System.getenv("AUTOMOBILE_CTRL_PROXY_APK_PATH")?.trim().orEmpty()
    val ctrlProxyApkPath =
        when {
          ctrlProxyApkProperty.isNotEmpty() -> ctrlProxyApkProperty
          ctrlProxyApkEnv.isNotEmpty() -> ctrlProxyApkEnv
          else -> findLocalCtrlProxyApkPath().orEmpty()
        }
    if (ctrlProxyApkPath.isNotEmpty()) {
      resolvedOverrides["AUTOMOBILE_CTRL_PROXY_APK_PATH"] = ctrlProxyApkPath
    }
    return resolvedOverrides
  }

  private fun findLocalCtrlProxyApkPath(): String? {
    val candidates =
        listOf(
            File("control-proxy/build/outputs/apk/debug/control-proxy-debug.apk"),
            File(
                "../control-proxy/build/outputs/apk/debug/control-proxy-debug.apk"
            ),
            File(
                "../../control-proxy/build/outputs/apk/debug/control-proxy-debug.apk"
            ),
        )
    return candidates.firstOrNull { it.exists() }?.absolutePath
  }
}

internal object DaemonSocketPaths {
  private const val DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 10000L

  fun socketPath(): String {
    val userId = getUserId()
    return "/tmp/auto-mobile-daemon-$userId.sock"
  }

  fun daemonStartTimeoutMs(): Long {
    // Check system property first, then environment variable
    val sysProp = SystemPropertyCache.get("automobile.daemon.startup.timeout.ms", "")
    val envVar = System.getenv("AUTOMOBILE_DAEMON_STARTUP_TIMEOUT_MS")?.trim().orEmpty()
    val configured =
        sysProp.ifEmpty { envVar }.ifEmpty { DEFAULT_DAEMON_STARTUP_TIMEOUT_MS.toString() }
    return configured.toLongOrNull() ?: DEFAULT_DAEMON_STARTUP_TIMEOUT_MS
  }

  fun buildDaemonStartCommand(): List<String> {
    return buildDaemonCommand("start")
  }

  fun buildDaemonRestartCommand(): List<String> {
    return buildDaemonCommand("restart")
  }

  private fun buildDaemonCommand(subCommand: String): List<String> {
    val command = resolveLocalCommand(subCommand)
        ?: resolvePackageCommand(subCommand)
        ?: listOf("auto-mobile", "--daemon", subCommand)

    return command
        .withDismissKeyboardAfterInput()
        .withNoUiPerfMode()
        .withNoNavigationScreenshots()
        .withNoWaitForPollingOverhead()
        .withNoIncludeNotImportantViews()
        .withNoReportViewIds()
        .withNoRetrieveInteractiveWindows()
  }

  private fun resolveLocalCommand(subCommand: String): List<String>? {
    val localPath = resolveLocalProjectPath() ?: return null
    val entryPoint = File(localPath, "dist/src/index.js")
    if (!entryPoint.exists()) return null
    val runtime = resolveRuntimePath()
    return listOf(runtime, entryPoint.absolutePath, "--daemon", subCommand)
  }

  private fun resolvePackageCommand(subCommand: String): List<String>? {
    val runner = resolvePackageRunner() ?: return null
    return if (runner.endsWith("npx")) {
      listOf(runner, "-y", "@kaeawc/auto-mobile@latest", "--daemon", subCommand)
    } else {
      listOf(runner, "@kaeawc/auto-mobile@latest", "--daemon", subCommand)
    }
  }

  /**
   * When true, the spawned daemon passes `--dismiss-keyboard-after-input` so every `inputText` call
   * hides the soft keyboard after injection (Android emulator CI often leaves it open otherwise).
   *
   * Configured via JVM system property `automobile.daemon.dismiss.keyboard.after.input` (e.g.
   * `./gradlew -Dautomobile.daemon.dismiss.keyboard.after.input=true`) or environment variable
   * `AUTOMOBILE_DAEMON_DISMISS_KEYBOARD_AFTER_INPUT`.
   */
  private fun dismissKeyboardAfterInputRequested(): Boolean {
    if (SystemPropertyCache.getBoolean("automobile.daemon.dismiss.keyboard.after.input", false)) {
      return true
    }
    val env = System.getenv("AUTOMOBILE_DAEMON_DISMISS_KEYBOARD_AFTER_INPUT")?.trim()?.lowercase().orEmpty()
    return env == "1" || env == "true" || env == "yes"
  }

  private fun List<String>.withDismissKeyboardAfterInput(): List<String> {
    if (!dismissKeyboardAfterInputRequested()) return this
    return this + "--dismiss-keyboard-after-input"
  }

  /**
   * When true, the spawned daemon passes `--no-ui-perf-mode` so CI / emulator runs skip UI
   * performance audit paths. Matches manual `bun … --daemon start --no-ui-perf-mode` when the
   * runner performs `--daemon restart`.
   */
  private fun noUiPerfModeRequested(): Boolean {
    if (SystemPropertyCache.getBoolean("automobile.daemon.no.ui.perf.mode", false)) {
      return true
    }
    val env = System.getenv("AUTOMOBILE_DAEMON_NO_UI_PERF")?.trim()?.lowercase().orEmpty()
    return env == "1" || env == "true" || env == "yes"
  }

  private fun List<String>.withNoUiPerfMode(): List<String> {
    if (!noUiPerfModeRequested()) return this
    return this + "--no-ui-perf-mode"
  }

  /**
   * When true, the spawned daemon passes `--no-navigation-screenshots` to disable screenshot
   * capture on navigation events. Reduces emulator resource usage in CI where navigation graph
   * thumbnails are not needed.
   */
  private fun noNavigationScreenshotsRequested(): Boolean {
    if (SystemPropertyCache.getBoolean("automobile.daemon.no.navigation.screenshots", false)) {
      return true
    }
    val env = System.getenv("AUTOMOBILE_DAEMON_NO_NAVIGATION_SCREENSHOTS")?.trim()?.lowercase().orEmpty()
    return env == "1" || env == "true" || env == "yes"
  }

  private fun List<String>.withNoNavigationScreenshots(): List<String> {
    if (!noNavigationScreenshotsRequested()) return this
    return this + "--no-navigation-screenshots"
  }

  /**
   * When true, the spawned daemon passes `--no-include-not-important-views` to disable
   * `FLAG_INCLUDE_NOT_IMPORTANT_VIEWS` on the CtrlProxy accessibility service.
   *
   * Configured via JVM system property `automobile.daemon.no.include.not.important.views`
   * or environment variable `AUTOMOBILE_DAEMON_NO_INCLUDE_NOT_IMPORTANT_VIEWS`.
   */
  private fun noIncludeNotImportantViewsRequested(): Boolean {
    if (SystemPropertyCache.getBoolean(
        "automobile.daemon.no.include.not.important.views",
        false
      )
    ) {
      return true
    }
    val env = System.getenv("AUTOMOBILE_DAEMON_NO_INCLUDE_NOT_IMPORTANT_VIEWS")
        ?.trim()?.lowercase().orEmpty()
    return env == "1" || env == "true" || env == "yes"
  }

  private fun List<String>.withNoIncludeNotImportantViews(): List<String> {
    if (!noIncludeNotImportantViewsRequested()) return this
    return this + "--no-include-not-important-views"
  }

  /**
   * When true, the spawned daemon passes `--no-report-view-ids` to disable
   * `FLAG_REPORT_VIEW_IDS` on the CtrlProxy accessibility service.
   *
   * Configured via JVM system property `automobile.daemon.no.report.view.ids`
   * or environment variable `AUTOMOBILE_DAEMON_NO_REPORT_VIEW_IDS`.
   */
  private fun noReportViewIdsRequested(): Boolean {
    if (SystemPropertyCache.getBoolean(
        "automobile.daemon.no.report.view.ids",
        false
      )
    ) {
      return true
    }
    val env = System.getenv("AUTOMOBILE_DAEMON_NO_REPORT_VIEW_IDS")
        ?.trim()?.lowercase().orEmpty()
    return env == "1" || env == "true" || env == "yes"
  }

  private fun List<String>.withNoReportViewIds(): List<String> {
    if (!noReportViewIdsRequested()) return this
    return this + "--no-report-view-ids"
  }

  /**
   * When true, the spawned daemon passes `--no-retrieve-interactive-windows` to disable
   * `FLAG_RETRIEVE_INTERACTIVE_WINDOWS` on the CtrlProxy accessibility service.
   *
   * Configured via JVM system property `automobile.daemon.no.retrieve.interactive.windows`
   * or environment variable `AUTOMOBILE_DAEMON_NO_RETRIEVE_INTERACTIVE_WINDOWS`.
   */
  private fun noRetrieveInteractiveWindowsRequested(): Boolean {
    if (SystemPropertyCache.getBoolean(
        "automobile.daemon.no.retrieve.interactive.windows",
        false
      )
    ) {
      return true
    }
    val env = System.getenv("AUTOMOBILE_DAEMON_NO_RETRIEVE_INTERACTIVE_WINDOWS")
        ?.trim()?.lowercase().orEmpty()
    return env == "1" || env == "true" || env == "yes"
  }

  private fun List<String>.withNoRetrieveInteractiveWindows(): List<String> {
    if (!noRetrieveInteractiveWindowsRequested()) return this
    return this + "--no-retrieve-interactive-windows"
  }

  /**
   * When true, the spawned daemon passes `--no-waitfor-polling-overhead` to skip screenshots
   * and back stack collection during observe waitFor polling loops. Reduces ADB contention
   * that can cause ctrl-proxy WebSocket instability on resource-constrained CI emulators.
   *
   * Configured via JVM system property `automobile.daemon.no.waitfor.polling.overhead`
   * or environment variable `AUTOMOBILE_DAEMON_NO_WAITFOR_POLLING_OVERHEAD`.
   */
  private fun noWaitForPollingOverheadRequested(): Boolean {
    if (SystemPropertyCache.getBoolean(
        "automobile.daemon.no.waitfor.polling.overhead",
        false
      )
    ) {
      return true
    }
    val env = System.getenv("AUTOMOBILE_DAEMON_NO_WAITFOR_POLLING_OVERHEAD")
        ?.trim()?.lowercase().orEmpty()
    return env == "1" || env == "true" || env == "yes"
  }

  private fun List<String>.withNoWaitForPollingOverhead(): List<String> {
    if (!noWaitForPollingOverheadRequested()) return this
    return this + "--no-waitfor-polling-overhead"
  }

  private fun resolveLocalProjectPath(): String? {
    val sysProp = SystemPropertyCache.get("automobile.daemon.local.project.path", "")
    val envVar = System.getenv("AUTOMOBILE_DAEMON_LOCAL_PROJECT_PATH")?.trim().orEmpty()
    val path = sysProp.ifEmpty { envVar }
    if (path.isEmpty()) return null
    val dir = File(path)
    return if (dir.isDirectory) dir.absolutePath else null
  }

  private fun resolveRuntimePath(): String {
    // Gradle test workers often have a stripped PATH, so resolve full paths.
    // Prefer bun since the project runs on bun.
    val home = System.getProperty("user.home") ?: System.getenv("HOME") ?: ""
    val candidates = listOfNotNull(
        if (home.isNotEmpty()) "$home/.bun/bin/bun" else null,
        "/usr/local/bin/bun",
        "/opt/homebrew/bin/bun",
        "/usr/local/bin/node",
        "/opt/homebrew/bin/node",
        if (home.isNotEmpty()) "$home/.nvm/current/bin/node" else null,
    )

    for (path in candidates) {
      if (File(path).exists()) return path
    }

    return resolveCommandPath("bun") ?: resolveCommandPath("node") ?: "node"
  }

  private fun resolvePackageRunner(): String? {
    for (cmd in listOf("bunx", "npx")) {
      val resolved = resolveCommandPath(cmd)
      if (resolved != null) return resolved
    }
    return null
  }

  private fun resolveCommandPath(cmd: String): String? {
    try {
      val process = ProcessBuilder("which", cmd)
          .redirectErrorStream(true)
          .start()
      val exited = process.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)
      if (exited && process.exitValue() == 0) {
        val path = process.inputStream.bufferedReader().readText().trim()
        if (path.isNotEmpty()) return path
      }
    } catch (_: Exception) {
      // ignore
    }
    return null
  }

  private fun getUserId(): String {
    val userName = System.getProperty("user.name", "default").ifBlank { "default" }
    val osName = System.getProperty("os.name").lowercase()
    if (osName.contains("win")) {
      return userName
    }

    return try {
      val process = ProcessBuilder("id", "-u").start()
      val exitCode = process.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)
      if (!exitCode) {
        process.destroy()
        return userName
      }
      val uid = process.inputStream.bufferedReader().readText().trim()
      if (uid.isNotEmpty()) uid else userName
    } catch (e: Exception) {
      userName
    }
  }
}

internal class DaemonSocketClient(private val socketPath: String) : Closeable, DaemonToolClient {
  private val json = Json { ignoreUnknownKeys = true }
  private val pending = ConcurrentHashMap<String, CompletableFuture<DaemonResponse>>()
  private val writeLock = Any()
  @Volatile private var closed = false

  // Session UUID for per-thread plan execution locking.
  // Defaults to a random UUID but can be overridden by autolock session ID from startDevice.
  override var sessionUuid: String = UUID.randomUUID().toString()

  private val channel: SocketChannel = connect()
  private val reader: BufferedReader
  private val writer: BufferedWriter
  private val readThread: Thread

  init {
    val inputStream = Channels.newInputStream(channel)
    val outputStream = Channels.newOutputStream(channel)
    reader = BufferedReader(InputStreamReader(inputStream, StandardCharsets.UTF_8))
    writer = BufferedWriter(OutputStreamWriter(outputStream, StandardCharsets.UTF_8))

    readThread =
        thread(start = true, isDaemon = true, name = "auto-mobile-daemon-reader") { readLoop() }
  }

  fun isConnected(): Boolean {
    return !closed && channel.isOpen
  }

  override fun callTool(toolName: String, arguments: JsonObject, timeoutMs: Long): DaemonResponse {
    if (!isConnected()) {
      throw DaemonUnavailableException("Daemon socket connection is not available")
    }

    val requestId = UUID.randomUUID().toString()
    val request =
        DaemonRequest(
            id = requestId,
            type = "mcp_request",
            method = "tools/call",
            params = buildJsonParams(toolName, arguments),
            timeoutMs = timeoutMs,
        )

    val responseFuture = CompletableFuture<DaemonResponse>()
    pending[requestId] = responseFuture

    sendRequest(request)

    return try {
      responseFuture.get(timeoutMs, TimeUnit.MILLISECONDS)
    } catch (e: TimeoutException) {
      pending.remove(requestId)
      throw DaemonUnavailableException("Daemon request timeout after ${timeoutMs}ms")
    }
  }

  override fun readResource(uri: String, timeoutMs: Long): DaemonResponse {
    if (!isConnected()) {
      throw DaemonUnavailableException("Daemon socket connection is not available")
    }

    val requestId = UUID.randomUUID().toString()
    val request =
        DaemonRequest(
            id = requestId,
            type = "mcp_request",
            method = "resources/read",
            params = JsonObject(mapOf("uri" to JsonPrimitive(uri))),
            timeoutMs = timeoutMs,
        )

    val responseFuture = CompletableFuture<DaemonResponse>()
    pending[requestId] = responseFuture

    sendRequest(request)

    return try {
      responseFuture.get(timeoutMs, TimeUnit.MILLISECONDS)
    } catch (e: TimeoutException) {
      pending.remove(requestId)
      throw DaemonUnavailableException("Daemon request timeout after ${timeoutMs}ms")
    }
  }

  fun callDaemonMethod(
      method: String,
      timeoutMs: Long,
      params: JsonObject = JsonObject(emptyMap()),
  ): DaemonResponse {
    if (!isConnected()) {
      throw DaemonUnavailableException("Daemon socket connection is not available")
    }

    val requestId = UUID.randomUUID().toString()
    val request =
        DaemonRequest(
            id = requestId,
            type = "daemon_request",
            method = method,
            params = params,
        )

    val responseFuture = CompletableFuture<DaemonResponse>()
    pending[requestId] = responseFuture

    sendRequest(request)

    return try {
      responseFuture.get(timeoutMs, TimeUnit.MILLISECONDS)
    } catch (e: TimeoutException) {
      pending.remove(requestId)
      throw DaemonUnavailableException("Daemon request timeout after ${timeoutMs}ms")
    }
  }

  override fun close() {
    if (closed) {
      return
    }
    closed = true
    try {
      channel.close()
    } catch (e: Exception) {
      // Ignore close errors
    }
    failPendingRequests("Daemon socket closed")
  }

  private fun connect(): SocketChannel {
    if (!Files.exists(File(socketPath).toPath())) {
      throw DaemonUnavailableException("Daemon socket not found: $socketPath")
    }

    return SocketChannel.open(UnixDomainSocketAddress.of(socketPath))
  }

  private fun sendRequest(request: DaemonRequest) {
    val payload = json.encodeToString(request)
    synchronized(writeLock) {
      writer.write(payload)
      writer.newLine()
      writer.flush()
    }
  }

  private fun readLoop() {
    try {
      while (!closed) {
        val line = reader.readLine() ?: break
        if (line.isBlank()) {
          continue
        }
        try {
          val response = json.decodeFromString(DaemonResponse.serializer(), line)
          handleResponse(response)
        } catch (e: Exception) {
          println("Failed to parse daemon response: ${e.message}")
        }
      }
    } catch (e: Exception) {
      if (!closed) {
        println("Daemon socket read error: ${e.message}")
      }
    } finally {
      close()
    }
  }

  private fun handleResponse(response: DaemonResponse) {
    val future = pending.remove(response.id)
    if (future != null) {
      future.complete(response)
    }
  }

  private fun buildJsonParams(toolName: String, arguments: JsonObject): JsonObject {
    // Include sessionUuid in tool arguments to enable per-thread plan execution locking
    val argumentsWithSession =
        JsonObject(
            arguments.toMutableMap().apply {
              if (!containsKey("sessionUuid")) {
                put("sessionUuid", JsonPrimitive(sessionUuid))
              }
            }
        )
    return JsonObject(mapOf("name" to JsonPrimitive(toolName), "arguments" to argumentsWithSession))
  }

  private fun failPendingRequests(message: String) {
    val exception = DaemonUnavailableException(message)
    pending.values.forEach { future -> future.completeExceptionally(exception) }
    pending.clear()
  }

  companion object {
    fun isAvailable(socketPath: String): Boolean {
      return try {
        val client = DaemonSocketClient(socketPath)
        client.close()
        true
      } catch (e: Exception) {
        false
      }
    }

    fun waitForAvailability(socketPath: String, timeoutMs: Long): Boolean {
      val start = System.currentTimeMillis()
      while (System.currentTimeMillis() - start < timeoutMs) {
        if (isAvailable(socketPath)) {
          return true
        }
        Thread.sleep(100)
      }
      return false
    }
  }
}

@Serializable
internal data class DaemonRequest(
    val id: String,
    val type: String,
    val method: String,
    val params: JsonObject,
    val timeoutMs: Long? = null,
)

@Serializable
internal data class DaemonResponse(
    val id: String,
    val type: String,
    val success: Boolean,
    val result: JsonElement? = null,
    val error: String? = null,
)

/** Interface for daemon tool calls to enable testing with fakes. */
internal interface DaemonToolClient {
  fun callTool(toolName: String, arguments: JsonObject, timeoutMs: Long): DaemonResponse

  fun readResource(uri: String, timeoutMs: Long): DaemonResponse

  var sessionUuid: String
}

internal class DaemonUnavailableException(message: String) : Exception(message)

/** Interface for checking daemon connectivity. Allows for easy testing with fakes. */
internal interface DaemonConnectivityChecker {
  fun isDaemonAlive(): Boolean

  fun waitForDaemon(timeoutMs: Long): Boolean
}

/** Default implementation that checks actual daemon socket connectivity. */
internal class DefaultDaemonConnectivityChecker : DaemonConnectivityChecker {
  override fun isDaemonAlive(): Boolean {
    return DaemonSocketClient.isAvailable(DaemonSocketPaths.socketPath())
  }

  override fun waitForDaemon(timeoutMs: Long): Boolean {
    return DaemonSocketClient.waitForAvailability(DaemonSocketPaths.socketPath(), timeoutMs)
  }
}
