import Foundation

public struct RemoteSessionHostResult: Equatable, Sendable, Identifiable {
    public let host: String
    public let sessions: [AgentSession]
    public let error: String?

    public var id: String {
        self.host
    }

    public var isReachable: Bool {
        self.error == nil
    }

    public init(host: String, sessions: [AgentSession], error: String?) {
        self.host = host
        self.sessions = sessions
        self.error = error
    }
}

public enum TailscaleStatusParser {
    /// Parses hosts from `tailscale status --json` output.
    ///
    /// Returns `nil` when `data` is not recognizable Tailscale status JSON — a failed, wrong, or
    /// non-Tailscale `tailscale` binary — so callers can fall through to the next candidate. Returns a
    /// possibly-empty list for a valid status that simply has no eligible peers (a real answer, stop).
    package static func parseHosts(from data: Data, excludingLocalHost localHost: String? = nil) -> [String]? {
        guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
        if let rawBackendState = root["BackendState"] {
            guard let backendState = rawBackendState as? String,
                  backendState.caseInsensitiveCompare("Running") == .orderedSame
            else { return nil }
        }

        let selfStatus: [String: Any]?
        if let rawSelf = root["Self"] {
            guard let parsedSelf = rawSelf as? [String: Any] else { return nil }
            selfStatus = parsedSelf
        } else {
            selfStatus = nil
        }

        let peers: [[String: Any]]
        let hasPeerShape: Bool
        switch root["Peer"] {
        case let dictionary as [String: [String: Any]]:
            peers = Array(dictionary.values)
            hasPeerShape = true
        case let array as [[String: Any]]:
            peers = array
            hasPeerShape = true
        case is NSNull:
            peers = []
            hasPeerShape = true
        case nil:
            peers = []
            hasPeerShape = false
        default:
            return nil
        }
        guard selfStatus != nil || hasPeerShape else { return nil }
        let localLabels = Set([
            localHost,
            selfStatus?["DNSName"] as? String,
            selfStatus?["HostName"] as? String,
        ].compactMap(self.firstDNSLabel).map { $0.lowercased() })

        var seen = Set<String>()
        return peers.compactMap { peer in
            guard peer["Online"] as? Bool == true,
                  let operatingSystem = peer["OS"] as? String,
                  operatingSystem == "macOS" || operatingSystem == "linux",
                  let label = self.firstDNSLabel(peer["DNSName"] as? String)
            else { return nil }
            let normalized = label.lowercased()
            guard !localLabels.contains(normalized), seen.insert(normalized).inserted else { return nil }
            return label
        }.sorted()
    }

    /// Convenience returning `[]` for unparseable output. Prefer `parseHosts` when the caller needs to
    /// distinguish a failed probe from an empty tailnet.
    public static func hosts(from data: Data, excludingLocalHost localHost: String? = nil) -> [String] {
        self.parseHosts(from: data, excludingLocalHost: localHost) ?? []
    }

    private static func firstDNSLabel(_ value: String?) -> String? {
        guard let value else { return nil }
        let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "."))
        guard let label = trimmed.split(separator: ".").first, !label.isEmpty else { return nil }
        return String(label)
    }
}

public struct RemoteSessionFetcher: Sendable {
    public static let bundledCLIFallback = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI"

    public init() {}

    public func discoveredHosts(
        environment: [String: String] = ProcessInfo.processInfo.environment,
        localHost: String = ProcessInfo.processInfo.hostName) async -> [String]
    {
        let probeEnvironment = Self.tailscaleCLIEnvironment(from: environment)
        let candidates = Self.tailscaleBinaryCandidates(path: environment["PATH"])
            .filter { FileManager.default.isExecutableFile(atPath: $0) }
        return await Self.firstDiscoveredHosts(candidates: candidates, localHost: localHost) { binary in
            guard let result = try? await SubprocessRunner.run(
                binary: binary,
                arguments: ["status", "--json"],
                environment: probeEnvironment,
                timeout: 5,
                label: "Tailscale session host discovery")
            else { return nil }
            return Data(result.stdout.utf8)
        }
    }

    /// Runs `tailscale status --json` on each candidate in order, falling through to the next when a
    /// candidate fails (`run` returns nil), returns invalid status JSON, or reports an inactive backend.
    /// Returns the first candidate's parsed hosts (possibly empty), or `[]` if none succeed. This keeps
    /// the app-binary fallback working even when an earlier — but non-functional — `tailscale` variant
    /// is installed (e.g. an open-source/Homebrew CLI that isn't the active client).
    package static func firstDiscoveredHosts(
        candidates: [String],
        localHost: String?,
        run: (String) async -> Data?) async -> [String]
    {
        for binary in candidates {
            guard let data = await run(binary),
                  let hosts = TailscaleStatusParser.parseHosts(from: data, excludingLocalHost: localHost)
            else { continue }
            return hosts
        }
        return []
    }

    public func fetch(
        hosts: [String],
        environment: [String: String] = ProcessInfo.processInfo.environment) async -> [RemoteSessionHostResult]
    {
        let normalizedHosts = Self.sanitizedHosts(hosts)
        return await withTaskGroup(
            of: RemoteSessionHostResult.self,
            returning: [RemoteSessionHostResult].self)
        { group in
            for host in normalizedHosts {
                group.addTask {
                    await self.fetch(host: host, environment: environment)
                }
            }
            var results: [RemoteSessionHostResult] = []
            for await result in group {
                results.append(result)
            }
            return results
                .sorted { lhs, rhs in lhs.host.localizedCaseInsensitiveCompare(rhs.host) == .orderedAscending }
        }
    }

    public func focus(
        sessionID: String,
        host: String,
        environment: [String: String] = ProcessInfo.processInfo.environment) async
    {
        guard let host = Self.sanitizedHosts([host]).first else { return }
        guard let ssh = self.findExecutable("ssh", environment: environment) ??
            (["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) })
        else { return }
        let command = "codexbar sessions focus \(Self.shellQuote(sessionID)) || " +
            "\(Self.shellQuote(Self.bundledCLIFallback)) sessions focus \(Self.shellQuote(sessionID))"
        _ = try? await SubprocessRunner.run(
            binary: ssh,
            arguments: ["-o", "BatchMode=yes", "-o", "ConnectTimeout=3", host, "sh", "-lc", Self.shellQuote(command)],
            environment: environment,
            timeout: 5,
            acceptsNonZeroExit: true,
            label: "focus remote agent session")
    }

    private func fetch(host: String, environment: [String: String]) async -> RemoteSessionHostResult {
        guard let ssh = self.findExecutable("ssh", environment: environment) ??
            (["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) })
        else {
            return RemoteSessionHostResult(host: host, sessions: [], error: "ssh not found")
        }
        let command = Self.remoteSessionsCommand()
        do {
            let result = try await SubprocessRunner.run(
                binary: ssh,
                arguments: [
                    "-o", "BatchMode=yes",
                    "-o", "ConnectTimeout=3",
                    host,
                    "sh", "-lc", Self.shellQuote(command),
                ],
                environment: environment,
                timeout: 5,
                label: "fetch remote agent sessions")
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            var sessions = try decoder.decode([AgentSession].self, from: Data(result.stdout.utf8))
            for index in sessions.indices {
                sessions[index].host = host
            }
            return RemoteSessionHostResult(host: host, sessions: sessions, error: nil)
        } catch {
            return RemoteSessionHostResult(host: host, sessions: [], error: error.localizedDescription)
        }
    }

    /// Tries the v2 session JSON protocol first, then the legacy v1 form, for both PATH and the
    /// bundled app CLI. Each fallback is reached only when the preceding command exits non-zero.
    package static func remoteSessionsCommand() -> String {
        let bundledCLI = Self.shellQuote(Self.bundledCLIFallback)
        return [
            "codexbar sessions --json-v2",
            "codexbar sessions --json",
            "\(bundledCLI) sessions --json-v2",
            "\(bundledCLI) sessions --json",
        ].joined(separator: " || ")
    }

    /// Ordered candidate paths for the `tailscale` CLI, most-preferred first.
    ///
    /// The macOS app ships its CLI as a thin `/bin/sh` wrapper (usually
    /// `/usr/local/bin/tailscale`) around the app's dual-mode binary. We prefer the
    /// wrapper, but a GUI-launched CodexBar inherits a minimal `PATH` (`/usr/bin:/bin`)
    /// that omits the standard CLI locations, so we also probe them explicitly before
    /// falling back to the app binary itself.
    package static func tailscaleBinaryCandidates(path: String?) -> [String] {
        let pathDirs = path?.split(separator: ":").map(String.init) ?? []
        var seen = Set<String>()
        var candidates = (pathDirs + ["/usr/local/bin", "/opt/homebrew/bin"])
            .filter { seen.insert($0).inserted }
            .map { $0 + "/tailscale" }
        // Last resort: the dual-mode app binary. Must be run via
        // `tailscaleCLIEnvironment(from:)` so it stays in CLI mode.
        candidates.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
        return candidates
    }

    /// Environment that keeps the dual-mode Tailscale app binary in CLI mode.
    ///
    /// With no shell/terminal marker present the binary boots the full menu-bar GUI
    /// (SkyLight/WindowServer, status icon) instead of running the CLI: it never emits
    /// JSON, the probe times out, and the Tailscale icon flickers on every refresh. A
    /// set `TERM` or `SHLVL` forces CLI mode (argv[0] casing and `XPC_SERVICE_NAME` do
    /// not). `SHLVL` is what the app's own `/bin/sh` CLI wrapper injects, so we mirror it here.
    ///
    /// Applied to every probe, not just the app-binary fallback: it is redundant but harmless for the
    /// CLI wrapper (itself a `/bin/sh` script that already exports `SHLVL`), and injecting it
    /// unconditionally keeps CLI mode guaranteed regardless of which binary `tailscaleBinary` resolves.
    /// An existing `TERM`/`SHLVL` (real terminal context) is left untouched.
    package static func tailscaleCLIEnvironment(from environment: [String: String]) -> [String: String] {
        guard environment["TERM"] == nil, environment["SHLVL"] == nil else { return environment }
        var environment = environment
        environment["SHLVL"] = "1"
        return environment
    }

    private func findExecutable(_ name: String, environment: [String: String]) -> String? {
        let path = environment["PATH"] ?? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
        return path.split(separator: ":")
            .map { String($0) + "/" + name }
            .first { FileManager.default.isExecutableFile(atPath: $0) }
    }

    public static func sanitizedHosts(_ hosts: [String]) -> [String] {
        var seen = Set<String>()
        return hosts.compactMap { rawHost in
            let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines)
            let hasUnsafeScalar = host.unicodeScalars.contains { scalar in
                CharacterSet.controlCharacters.contains(scalar) ||
                    CharacterSet.whitespacesAndNewlines.contains(scalar)
            }
            guard !host.isEmpty,
                  !host.hasPrefix("-"),
                  !hasUnsafeScalar,
                  seen.insert(host.lowercased()).inserted
            else { return nil }
            return host
        }
    }

    private static func shellQuote(_ value: String) -> String {
        "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
    }
}
