import Foundation

enum UpdateStatus {
    case upToDate
    case updatesAvailable(Int)
    case error(String)
}

enum WrapperUpdateStatus {
    case upToDate
    case updateAvailable(current: String, latest: String)
    case error(String)
}

enum InstallStage {
    case cloning
    case creatingVenv
    case installingDeps
    case done
}

enum ClaudeCLIStatus {
    case installed(version: String, loggedIn: Bool, outdated: Bool)
    case notInstalled
    case error(String)

    var isReady: Bool {
        if case .installed(_, let loggedIn, let outdated) = self { return loggedIn && !outdated }
        return false
    }
}

class ServerManager: ObservableObject {
    @Published var installStage: InstallStage?
    @Published var installError: String?

    static let repoURL = "https://github.com/epatel/claude-agents-dashboard.git"
    static let minimumPythonVersion = "3.10"
    static let minimumClaudeCLIVersion = "2.1.111"

    /// Find a suitable Python 3.10+ binary, preferring Homebrew over system Python.
    var pythonPath: String {
        let candidates = [
            "/opt/homebrew/bin/python3",
            "/usr/local/bin/python3",
            "/opt/homebrew/bin/python3.14",
            "/opt/homebrew/bin/python3.13",
            "/opt/homebrew/bin/python3.12",
            "/opt/homebrew/bin/python3.11",
            "/opt/homebrew/bin/python3.10",
            "/usr/local/bin/python3.14",
            "/usr/local/bin/python3.13",
            "/usr/local/bin/python3.12",
            "/usr/local/bin/python3.11",
            "/usr/local/bin/python3.10",
            "/usr/bin/python3",
        ]
        for path in candidates {
            if FileManager.default.isExecutableFile(atPath: path) {
                return path
            }
        }
        return "/usr/bin/python3"
    }

    /// Override in tests to use a temp directory instead of ~/.agents-dashboard
    var serverURL: URL {
        _serverURLOverride ?? FileManager.default.homeDirectoryForCurrentUser
            .appendingPathComponent(".agents-dashboard")
    }

    /// For testing only — set to redirect serverURL to a temp path
    var _serverURLOverride: URL?

    var serverPath: String { serverURL.path }

    /// Find the Claude CLI binary, searching the user's full PATH.
    func findClaudePath(userPATH: String? = nil) -> String? {
        // Check common locations first
        // Expand ~ since FileManager needs absolute paths
        let home = FileManager.default.homeDirectoryForCurrentUser.path
        let candidates = [
            "\(home)/.local/bin/claude",
            "\(home)/.claude/bin/claude",
            "/opt/homebrew/bin/claude",
            "/usr/local/bin/claude",
        ]
        for path in candidates {
            if FileManager.default.isExecutableFile(atPath: path) {
                return path
            }
        }

        // Search the user's PATH directories
        if let pathStr = userPATH ?? ProcessInfo.processInfo.environment["PATH"] {
            for dir in pathStr.split(separator: ":") {
                let candidate = "\(dir)/claude"
                if FileManager.default.isExecutableFile(atPath: candidate) {
                    return candidate
                }
            }
        }

        return nil
    }

    /// Check if Claude CLI is installed and the user is logged in.
    func checkClaudeCLI(userPATH: String? = nil) async -> ClaudeCLIStatus {
        guard let claudeBin = findClaudePath(userPATH: userPATH) else {
            return .notInstalled
        }

        // Get version
        let version: String
        do {
            let output = try await runProcessBackground(claudeBin, arguments: ["--version"])
            version = output.trimmingCharacters(in: .whitespacesAndNewlines)
        } catch {
            return .error("Claude CLI found at \(claudeBin) but failed to run: \(error.localizedDescription)")
        }

        let outdated = isClaudeVersionOutdated(version)

        // Check login status by running `claude auth status`
        // Exit code 0 = logged in, non-zero = not logged in
        do {
            _ = try await runProcessBackground(claudeBin, arguments: ["auth", "status"])
            return .installed(version: version, loggedIn: true, outdated: outdated)
        } catch {
            return .installed(version: version, loggedIn: false, outdated: outdated)
        }
    }

    /// Extract the "2.1.111" part from output like "2.1.111 (Claude Code)".
    /// Returns nil if no version-like token is found.
    func parseClaudeVersion(_ raw: String) -> String? {
        // Grab the first whitespace-delimited token that looks like digits.dots
        for token in raw.split(whereSeparator: { $0.isWhitespace }) {
            let s = String(token)
            let allowed = CharacterSet(charactersIn: "0123456789.")
            if s.unicodeScalars.allSatisfy({ allowed.contains($0) }) && s.contains(".") {
                return s
            }
        }
        return nil
    }

    /// True when the parsed CLI version is below `minimumClaudeCLIVersion`.
    /// On parse failure, returns false (don't block on an unreadable version).
    func isClaudeVersionOutdated(_ raw: String) -> Bool {
        guard let parsed = parseClaudeVersion(raw) else { return false }
        return compareVersions(parsed, Self.minimumClaudeCLIVersion) == .orderedAscending
    }

    func installationExists() -> Bool {
        let runSh = serverURL.appendingPathComponent("run.sh").path
        return FileManager.default.fileExists(atPath: runSh)
    }

    func clone() async throws {
        await MainActor.run { installStage = .cloning; installError = nil }

        var cloned = false
        do {
            // Clone repo
            try await runProcessBackground("/usr/bin/git", arguments: ["clone", Self.repoURL, serverPath])
            cloned = true

            // Create venv
            await MainActor.run { installStage = .creatingVenv }
            let venvPath = serverURL.appendingPathComponent("venv").path
            try await runProcessBackground(pythonPath, arguments: ["-m", "venv", venvPath])

            // Upgrade pip, then install dependencies
            await MainActor.run { installStage = .installingDeps }
            let pipPath = serverURL.appendingPathComponent("venv/bin/pip").path
            try await runProcessBackground(pipPath, arguments: ["install", "--upgrade", "-q", "pip"])
            let reqPath = serverURL.appendingPathComponent("requirements.txt").path
            try await runProcessBackground(pipPath, arguments: ["install", "-q", "-r", reqPath])

            await MainActor.run { installStage = .done }
        } catch {
            // Clean up partial clone so a retry can start fresh
            if cloned {
                try? FileManager.default.removeItem(at: serverURL)
            }
            await MainActor.run { installError = error.localizedDescription }
            throw error
        }
    }

    func checkForUpdates() async -> UpdateStatus {
        guard installationExists() else { return .error("Not installed") }

        // Fetch
        do {
            try await runProcessBackground("/usr/bin/git", arguments: ["-C", serverPath, "fetch", "--quiet"])
        } catch {
            return .error("Fetch failed: \(error.localizedDescription)")
        }

        // Compare HEAD vs @{u}
        do {
            let local = try await runProcessBackground("/usr/bin/git", arguments: ["-C", serverPath, "rev-parse", "HEAD"])
            let remote = try await runProcessBackground("/usr/bin/git", arguments: ["-C", serverPath, "rev-parse", "@{u}"])

            if local.trimmingCharacters(in: .whitespacesAndNewlines)
                == remote.trimmingCharacters(in: .whitespacesAndNewlines) {
                return .upToDate
            }

            let behindStr = try await runProcessBackground(
                "/usr/bin/git",
                arguments: ["-C", serverPath, "rev-list", "--count", "HEAD..@{u}"]
            )
            let behind = Int(behindStr.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
            return behind > 0 ? .updatesAvailable(behind) : .upToDate
        } catch {
            return .error(error.localizedDescription)
        }
    }

    func pullUpdates() async throws {
        try await runProcessBackground("/usr/bin/git", arguments: ["-C", serverPath, "pull", "--quiet"])
    }

    /// Check if a newer wrapper release exists on GitHub.
    /// Follows the /releases/latest redirect to extract the tag version.
    static let releasesLatestURL = URL(string: "https://github.com/epatel/claude-agents-dashboard/releases/latest")!

    func checkForWrapperUpdate() async -> WrapperUpdateStatus {
        let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"

        // Use a session that doesn't follow redirects so we can read the Location header
        let config = URLSessionConfiguration.ephemeral
        let session = URLSession(configuration: config, delegate: RedirectStopper(), delegateQueue: nil)
        defer { session.invalidateAndCancel() }

        do {
            let (_, response) = try await session.data(from: Self.releasesLatestURL)

            guard let httpResponse = response as? HTTPURLResponse,
                  (300...399).contains(httpResponse.statusCode),
                  let location = httpResponse.value(forHTTPHeaderField: "Location") else {
                return .error("Unexpected response from GitHub")
            }

            // Location looks like: https://github.com/.../releases/tag/v1.2.0
            guard let tagRange = location.range(of: "/tag/v", options: .backwards),
                  !location[tagRange.upperBound...].isEmpty else {
                return .error("Could not parse version from redirect")
            }
            let latestVersion = String(location[tagRange.upperBound...])

            if compareVersions(currentVersion, latestVersion) == .orderedAscending {
                return .updateAvailable(current: currentVersion, latest: latestVersion)
            }
            return .upToDate
        } catch {
            return .error(error.localizedDescription)
        }
    }

    /// Compare two semver strings (e.g. "1.1.0" vs "1.2.0").
    func compareVersions(_ a: String, _ b: String) -> ComparisonResult {
        let partsA = a.split(separator: ".").compactMap { Int($0) }
        let partsB = b.split(separator: ".").compactMap { Int($0) }
        let count = max(partsA.count, partsB.count)
        for i in 0..<count {
            let va = i < partsA.count ? partsA[i] : 0
            let vb = i < partsB.count ? partsB[i] : 0
            if va < vb { return .orderedAscending }
            if va > vb { return .orderedDescending }
        }
        return .orderedSame
    }

    /// Runs a process on a background thread and returns its stdout output.
    /// Throws if the process exits with a non-zero status.
    @discardableResult
    private func runProcessBackground(_ executablePath: String, arguments: [String]) async throws -> String {
        let runner = self.runProcess
        return try await withCheckedThrowingContinuation { continuation in
            DispatchQueue.global(qos: .userInitiated).async {
                do {
                    let output = try runner(executablePath, arguments)
                    continuation.resume(returning: output)
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }

    /// Synchronous process runner — always captures stdout and returns it.
    /// Must only be called from a background thread (never from MainActor).
    @discardableResult
    private func runProcess(_ executablePath: String, arguments: [String]) throws -> String {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: executablePath)
        process.arguments = arguments

        let outputPipe = Pipe()
        let errorPipe = Pipe()
        process.standardOutput = outputPipe
        process.standardError = errorPipe

        try process.run()
        process.waitUntilExit()

        if process.terminationStatus != 0 {
            let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
            let errorMsg = String(data: errorData, encoding: .utf8) ?? "Unknown error"
            throw NSError(
                domain: "ServerManager",
                code: Int(process.terminationStatus),
                userInfo: [NSLocalizedDescriptionKey: errorMsg]
            )
        }

        let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
        return String(data: data, encoding: .utf8) ?? ""
    }
}

/// URLSession delegate that stops redirect following so we can read the Location header.
private class RedirectStopper: NSObject, URLSessionTaskDelegate {
    func urlSession(_ session: URLSession, task: URLSessionTask,
                    willPerformHTTPRedirection response: HTTPURLResponse,
                    newRequest request: URLRequest,
                    completionHandler: @escaping (URLRequest?) -> Void) {
        completionHandler(nil)  // Don't follow the redirect
    }
}
