package dev.ccpocket.daemon

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.main
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.int
import dev.ccpocket.daemon.agent.AgentBackendFactory
import dev.ccpocket.daemon.claude.ClaudeBackend
import dev.ccpocket.daemon.claude.ClaudeLauncher
import dev.ccpocket.daemon.codex.CodexBackend
import dev.ccpocket.daemon.codex.CodexLauncher
import dev.ccpocket.daemon.identity.Identity
import dev.ccpocket.protocol.AgentKind
import dev.ccpocket.daemon.relay.LoopbackPair
import dev.ccpocket.daemon.relay.LoopbackStatus
import dev.ccpocket.daemon.relay.PairLoopback
import dev.ccpocket.daemon.relay.RelayClient
import dev.ccpocket.daemon.server.DaemonServer
import dev.ccpocket.daemon.service.ServiceInstaller
import dev.ccpocket.daemon.util.QrTerminal
import dev.ccpocket.protocol.PocketJson
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import java.net.Inet4Address
import java.net.NetworkInterface

/** Production relay; users connect here by default so `run`/`service-install` need no URL. */
const val DEFAULT_RELAY = "wss://pocket.ark-nexus.cc"

/** Pick the most likely LAN IPv4 address: a physical, non-virtual site-local (RFC1918) address. */
fun lanIp(): String? {
    // Virtual / overlay / tunnel NICs look like ordinary interfaces to the JVM. On Linux/macOS the
    // hint is in the device name (zt*, utun*, docker0, veth*); on Windows the JVM hands out synthetic
    // names (eth7, net3) and the only hint is the friendly displayName — so we check both.
    val virtualHints = listOf("zt", "tun", "tap", "docker", "veth", "br-", "gif", "stf", "anpi",
        "awdl", "bridge", "llw", "utun", "vmnet", "vmware", "vbox", "virtualbox",
        "zerotier", "tailscale", "wireguard", "hyper-v", "loopback")
    fun looksVirtual(ni: NetworkInterface): Boolean {
        val name = ni.name.lowercase()
        val display = (ni.displayName ?: "").lowercase()
        return virtualHints.any { name.startsWith(it) || display.contains(it) }
    }
    return NetworkInterface.getNetworkInterfaces().asSequence()
        .filter { it.isUp && !it.isLoopback && !it.isVirtual }
        .sortedBy { if (looksVirtual(it)) 1 else 0 } // de-prioritize overlay/tunnel NICs
        .flatMap { it.interfaceAddresses.asSequence() }
        .mapNotNull { it.address as? Inet4Address }
        .filter { it.isSiteLocalAddress } // RFC1918 only: 10/8, 172.16/12, 192.168/16
        .map { it.hostAddress }
        .firstOrNull()
}

private class Root : CliktCommand(name = "cc-pocket-daemon") {
    override fun run() = Unit
}

private class RunCmd : CliktCommand(name = "run") {
    private val host by option().default("127.0.0.1")
    private val port by option().int().default(8765)
    private val claudeBin by option("--claude-bin", help = "claude executable (default: auto-detect the installed Claude Code)")
    private val codexBin by option("--codex-bin", help = "codex executable (default: auto-detect the installed Codex CLI)")
    private val relay by option("--relay", help = "relay wss base").default(DEFAULT_RELAY)
    private val local by option("--local", help = "run a LAN-only WebSocket server instead of dialing the relay").flag()
    private val pairPort by option("--pair-port", help = "loopback port for the `pair` command").int().default(8799)
    private val takeover by option("--takeover", help = "if another cc-pocket daemon is already running, stop it and run this one instead (default: exit and leave it running)").flag()
    private val autoUpdate by option("--auto-update", help = "apply daemon updates automatically (installer-managed macOS/Linux installs; others get a notification)").flag()

    override fun run() {
        val exe = ClaudeLauncher.resolveExecutable(claudeBin)
        // codex is optional: probe for the echo, but resolve lazily per session so a missing codex never
        // blocks startup — a Codex open then fails with a clear PocketError instead.
        val codexExe = runCatching { CodexLauncher.resolveExecutable(codexBin) }.getOrNull()
        val core = DaemonCore(
            mapOf(
                AgentKind.CLAUDE to AgentBackendFactory { ClaudeBackend(exe) },
                AgentKind.CODEX to AgentBackendFactory { CodexBackend(codexBin) }, // resolves the binary lazily on first launch
            ),
        )
        if (!local) {
            val identity = Identity.loadOrCreate()
            val relayClient = RelayClient(relay, identity, core)
            echo("cc-pocket daemon — claude=$exe — codex=${codexExe ?: "(not found)"} — relay=$relay")
            echo("account id: ${identity.accountId}")
            echo("(run `cc-pocket-daemon pair` in another terminal to add a phone)")
            // Windows: if we're not yet registered as a logon background service, self-install so closing this
            // window no longer takes the daemon offline (issue #16). No-op on macOS/Linux and when already set up.
            ServiceInstaller.selfInstallIfMissingWindows(
                ProcessHandle.current().info().command().orElse(""),
                buildList {
                    add("run"); add("--relay"); add(relay)
                    claudeBin?.let { add("--claude-bin"); add(it) }
                    codexBin?.let { add("--codex-bin"); add(it) }
                },
            )?.let { echo(it) }
            // A daemon is a singleton (owns the pair port + one relay identity). If another instance is
            // already up — the cask's KeepAlive LaunchAgent, or a stray dev run — don't bind/attach a
            // duplicate that fights it on the relay; exit cleanly (or --takeover to replace it).
            SingleInstance.ensureSolo(pairPort, takeover) { echo(it) }
            PairLoopback(relayClient, relay, identity.e2ePubB64, pairPort).start()
            // daily new-version check: log + one phone push per version; --auto-update (or the env
            // toggle, so the flag survives service reinstalls) hot-swaps installer-managed installs
            val auto = autoUpdate || System.getenv("CC_POCKET_AUTO_UPDATE") == "1"
            dev.ccpocket.daemon.update.UpdateChecker.start(relayClient, auto)
            Runtime.getRuntime().addShutdownHook(Thread { runBlocking { core.shutdown() } })
            runBlocking { relayClient.run() }
        } else {
            // Advertise a LAN URL + QR for phone pairing, but NEVER silently widen the bind:
            // we listen on `host` as-is. The direct-LAN path has no handshake / auth / E2E, so a
            // phone can only reach us once the user explicitly binds beyond loopback — show the
            // pairing URL/QR only then, never for a loopback bind the phone can't connect to.
            val lan = lanIp()
            echo("cc-pocket daemon — claude=$exe — codex=${codexExe ?: "(not found)"}")
            echo("")
            if (host == "127.0.0.1") {
                echo("  Bound to 127.0.0.1 (loopback only) — not reachable from your phone.")
                if (lan != null) {
                    echo("  To pair over your LAN, re-run with:  --host 0.0.0.0")
                    echo("  It would then be reachable at ws://$lan:$port/v1/ws")
                }
                echo("")
            } else {
                val advertiseIp = if (host == "0.0.0.0") (lan ?: host) else host
                val url = "ws://$advertiseIp:$port/v1/ws"
                if (host == "0.0.0.0") {
                    echo("  !! UNGUARDED — bound to 0.0.0.0 (all interfaces) !!")
                    echo("  Anyone on your network can open sessions, browse files and approve tools.")
                    echo("")
                }
                echo("  LAN server on $url")
                echo("")
                echo("  On your phone, open CC Pocket and tap:")
                echo("    Advanced: Direct LAN")
                echo("  Then enter: $url")
                echo("")
                if (advertiseIp != "0.0.0.0") {
                    echo(QrTerminal.render(url))
                    echo("")
                }
            }
            DaemonServer(core, host, port).run()
        }
    }
}

private class TestClientCmd : CliktCommand(name = "test-client") {
    private val host by option().default("127.0.0.1")
    private val port by option().int().default(8765)
    private val relay by option("--relay", help = "relay ws base for device mode, e.g. ws://host:9000")
    private val daemonPub by option("--daemon-pub", help = "daemon E2E public key from `pair` (relay mode)")
    private val ticket by option("--ticket", help = "pairing ticket from `pair` (relay mode)")

    override fun run() {
        val r = relay
        if (r != null) {
            TestClient.relay(
                r,
                daemonPub ?: error("--daemon-pub is required with --relay"),
                ticket ?: error("--ticket is required with --relay"),
            ).run()
        } else {
            TestClient.direct("ws://$host:$port/v1/ws").run()
        }
    }
}

/** The per-OS way to (re)start the background daemon — shown whenever the loopback port is unreachable. */
private fun daemonStartHint(): String {
    val os = System.getProperty("os.name").lowercase()
    return when {
        os.contains("win") -> "start it:  schtasks /Run /TN ${ServiceInstaller.WINDOWS_TASK}    (or run it by hand: cc-pocket-daemon run)"
        os.contains("mac") -> "start it:  launchctl kickstart -k gui/$(id -u)/dev.ccpocket.daemon    (or run it by hand: cc-pocket-daemon run)"
        else -> "start it:  systemctl --user start cc-pocket-daemon    (or run it by hand: cc-pocket-daemon run)"
    }
}

private class PairCmd : CliktCommand(name = "pair") {
    private val pairPort by option("--pair-port", help = "loopback port of the running daemon").int().default(8799)

    override fun run() = runBlocking {
        val client = HttpClient(CIO)
        try {
            // The daemon's relay link may legitimately be mid-reconnect (backoff reaches 30s) while the mint
            // window is only 10s — a one-shot pair failed spuriously on a HEALTHY daemon. Ride it out: retry
            // until the link comes back or the window closes, and only then diagnose.
            val deadline = System.currentTimeMillis() + PAIR_RETRY_WINDOW_MS
            var lastBody = ""
            var waiting = false
            while (true) {
                val body = runCatching { client.post("http://127.0.0.1:$pairPort/pair").bodyAsText() }.getOrElse {
                    echo("✗ no daemon on 127.0.0.1:$pairPort — ${daemonStartHint()}")
                    return@runBlocking
                }
                val info = runCatching { PocketJson.decodeFromString<LoopbackPair>(body) }.getOrNull()
                if (info != null) {
                    echo("")
                    echo("  Open CC Pocket on your phone and scan this — or type the code (valid ${info.ttlSec}s):")
                    echo("")
                    echo(QrTerminal.render("ccpocket://pair?code=${info.code}"))
                    echo("        code:  ${info.code.chunked(3).joinToString(" ")}")
                    echo("")
                    return@runBlocking
                }
                lastBody = body
                if (System.currentTimeMillis() > deadline) break
                if (!waiting) {
                    waiting = true
                    echo("daemon is up but its relay link is down — waiting for it to reconnect (up to ${PAIR_RETRY_WINDOW_MS / 1000}s)…")
                }
                kotlinx.coroutines.delay(3_000)
            }
            echo("✗ pairing failed — the daemon can't reach the relay ($lastBody)")
            echo("  likely: no internet, or a proxy/firewall blocking $DEFAULT_RELAY, or the relay is down.")
            echo("  inspect: cc-pocket-daemon status")
        } finally {
            client.close()
        }
    }

    private companion object { const val PAIR_RETRY_WINDOW_MS = 60_000L }
}

private class UpdateCmd : CliktCommand(name = "update") {
    private val check by option("--check", help = "only report whether a newer release exists").flag()

    override fun run() {
        val current = dev.ccpocket.daemon.update.UpdateService.currentVersion()
        echo("current: $current")
        val latest = dev.ccpocket.daemon.update.UpdateService.latestRelease()
            ?: throw com.github.ajalt.clikt.core.CliktError("could not reach GitHub releases — check network/proxy")
        if (!dev.ccpocket.daemon.update.UpdateService.isNewer(latest.version, current)) {
            echo("already up to date (latest: ${latest.version})")
            return
        }
        echo("newer release available: ${latest.version}")
        if (check) return
        val exe = dev.ccpocket.daemon.update.UpdateService.selfExe()
        val install = dev.ccpocket.daemon.update.UpdateService.managedInstallOf(exe)
            ?: throw com.github.ajalt.clikt.core.CliktError(dev.ccpocket.daemon.update.UpdateService.ownerHint(exe))
        val newLauncher = dev.ccpocket.daemon.update.UpdateService.apply(latest, install)
        echo("installed ${latest.version} → ${install.versionsDir.resolve(latest.version)}")
        echo("restarting the background service onto it…")
        dev.ccpocket.daemon.update.UpdateService.restartService(newLauncher)
        echo("✅ updated to ${latest.version}")
    }
}

private class StatusCmd : CliktCommand(name = "status") {
    private val pairPort by option("--pair-port", help = "loopback port of the running daemon").int().default(8799)

    override fun run() = runBlocking {
        val client = HttpClient(CIO)
        var healthy = true
        echo("  version:  ${dev.ccpocket.daemon.update.UpdateService.currentVersion()}")
        try {
            // 1. daemon process + relay link (via the loopback /status the running daemon serves)
            val body = runCatching { client.get("http://127.0.0.1:$pairPort/status").bodyAsText() }.getOrNull()
            val st = body?.let { runCatching { PocketJson.decodeFromString<LoopbackStatus>(it) }.getOrNull() }
            if (st == null) {
                healthy = false
                echo("  daemon:   ✗ not reachable on 127.0.0.1:$pairPort — ${daemonStartHint()}")
            } else {
                echo("  daemon:   ✓ running (account ${st.accountId.take(8)}…, relay ${st.relay})")
                if (st.attached) {
                    echo("  relay:    ✓ attached${st.lastPongAgeMs?.let { " — liveness ${it / 1000}s ago" } ?: ""}")
                } else {
                    healthy = false
                    echo("  relay:    ✗ link down (reconnecting — backoff reaches 30s; check network/proxy to $DEFAULT_RELAY)")
                }
            }
            // 2. background service registered?
            val os = System.getProperty("os.name").lowercase()
            val service = when {
                os.contains("win") -> if (ServiceInstaller.isWindowsTaskInstalled()) "✓ logon Scheduled Task '${ServiceInstaller.WINDOWS_TASK}'" else "✗ no Scheduled Task — cc-pocket-daemon service-install --apply"
                os.contains("mac") -> {
                    val plist = java.io.File(System.getProperty("user.home"), "Library/LaunchAgents/dev.ccpocket.daemon.plist")
                    if (plist.exists()) "✓ launchd agent (${plist.path})" else "✗ no launchd agent — cc-pocket-daemon service-install --apply"
                }
                else -> {
                    val unit = java.io.File(System.getProperty("user.home"), ".config/systemd/user/cc-pocket-daemon.service")
                    if (unit.exists()) "✓ systemd user unit (${unit.path})" else "✗ no systemd unit — cc-pocket-daemon service-install --apply"
                }
            }
            echo("  service:  $service")
            // 3. agent CLIs resolvable?
            val claude = runCatching { ClaudeLauncher.resolveExecutable(null) }.getOrNull()
            echo("  claude:   ${claude?.let { "✓ $it" } ?: "✗ not found — install Claude Code first"}")
            if (claude == null) healthy = false
            val codex = runCatching { CodexLauncher.resolveExecutable(null) }.getOrNull()
            echo("  codex:    ${codex?.let { "✓ $it" } ?: "– not found (optional; Codex sessions unavailable)"}")
        } finally {
            client.close()
        }
        if (!healthy) throw com.github.ajalt.clikt.core.ProgramResult(1)
    }
}

private class ServiceInstallCmd : CliktCommand(name = "service-install") {
    private val exec by option("--exec", help = "path to the cc-pocket-daemon launcher (default: auto-detect)")
    private val relay by option("--relay").default(DEFAULT_RELAY)
    private val claudeBin by option("--claude-bin")
    private val codexBin by option("--codex-bin")
    private val apply by option("--apply", help = "actually write + load the service (default: print only)").flag()

    override fun run() {
        val launcher = exec ?: resolveLauncher()
        val runArgs = buildList {
            add("run")
            add("--relay"); add(relay)
            claudeBin?.let { add("--claude-bin"); add(it) }
            codexBin?.let { add("--codex-bin"); add(it) }
        }
        echo(ServiceInstaller.install(launcher, runArgs, apply))
    }

    /**
     * Find the launcher to put in the service's ExecStart. Prefer a stable on-PATH symlink (survives
     * `brew upgrade`); the Homebrew prefix differs by arch (/opt/homebrew on arm64, /usr/local on Intel).
     * Fall back to THIS process's own launcher (the jpackage native binary inside the .app).
     */
    private fun resolveLauncher(): String {
        listOf(
            "/opt/homebrew/bin/cc-pocket-daemon",            // Homebrew on Apple Silicon
            "/home/linuxbrew/.linuxbrew/bin/cc-pocket-daemon", // Homebrew on Linux
            "/usr/local/bin/cc-pocket-daemon",               // Homebrew on Intel / manual installs
            "/usr/bin/cc-pocket-daemon",                     // distro package / manual install on Linux
        ).firstOrNull { java.io.File(it).canExecute() }?.let { return it }
        return ProcessHandle.current().info().command().orElse("/usr/local/bin/cc-pocket-daemon")
    }
}

fun main(args: Array<String>) =
    Root().subcommands(RunCmd(), TestClientCmd(), PairCmd(), StatusCmd(), UpdateCmd(), ServiceInstallCmd()).main(args)
