import Foundation

public struct AntigravityOAuthCredentials: Codable, Sendable, Equatable {
    public var accessToken: String?
    public var refreshToken: String?
    public var expiryDateMilliseconds: Double?
    public var idToken: String?
    public var email: String?
    public var projectID: String?
    public var clientID: String?
    public var clientSecret: String?

    public init(
        accessToken: String?,
        refreshToken: String?,
        expiryDate: Date?,
        idToken: String? = nil,
        email: String? = nil,
        projectID: String? = nil,
        clientID: String? = nil,
        clientSecret: String? = nil)
    {
        self.accessToken = accessToken
        self.refreshToken = refreshToken
        self.expiryDateMilliseconds = expiryDate.map { $0.timeIntervalSince1970 * 1000 }
        self.idToken = idToken
        self.email = email
        self.projectID = projectID
        self.clientID = clientID
        self.clientSecret = clientSecret
    }

    public var expiryDate: Date? {
        guard let expiryDateMilliseconds else { return nil }
        return Date(timeIntervalSince1970: expiryDateMilliseconds / 1000)
    }

    /// Email of the Google account these credentials authenticate, preferring the
    /// signed `id_token` claim (what the remote OAuth fetcher reports) and falling
    /// back to the stored `email` field. Used to verify that an ambient local/CLI
    /// Antigravity snapshot belongs to the account the user explicitly selected.
    public var resolvedAccountEmail: String? {
        Self.email(fromIDToken: self.idToken) ?? self.email?.trimmedNonEmptyEmail
    }

    static func email(fromIDToken idToken: String?) -> String? {
        guard let idToken else { return nil }
        let parts = idToken.components(separatedBy: ".")
        guard parts.count >= 2 else { return nil }
        var payload = parts[1]
            .replacingOccurrences(of: "-", with: "+")
            .replacingOccurrences(of: "_", with: "/")
        let remainder = payload.count % 4
        if remainder > 0 {
            payload += String(repeating: "=", count: 4 - remainder)
        }
        guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else {
            return nil
        }
        return (json["email"] as? String)?.trimmedNonEmptyEmail
    }

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.accessToken =
            try container.decodeIfPresent(String.self, forKey: .accessTokenSnake)
            ?? container.decodeIfPresent(String.self, forKey: .accessTokenCamel)
        self.refreshToken =
            try container.decodeIfPresent(String.self, forKey: .refreshTokenSnake)
            ?? container.decodeIfPresent(String.self, forKey: .refreshTokenCamel)
        self.idToken =
            try container.decodeIfPresent(String.self, forKey: .idTokenSnake)
            ?? container.decodeIfPresent(String.self, forKey: .idTokenCamel)
        self.email = try container.decodeIfPresent(String.self, forKey: .email)
        self.projectID =
            try container.decodeIfPresent(String.self, forKey: .projectIDSnake)
            ?? container.decodeIfPresent(String.self, forKey: .projectIDCamel)
        self.clientID =
            try container.decodeIfPresent(String.self, forKey: .clientIDSnake)
            ?? container.decodeIfPresent(String.self, forKey: .clientIDCamel)
        self.clientSecret =
            try container.decodeIfPresent(String.self, forKey: .clientSecretSnake)
            ?? container.decodeIfPresent(String.self, forKey: .clientSecretCamel)

        if let expiryDateMilliseconds = try container.decodeIfPresent(Double.self, forKey: .expiryDateSnake)
            ?? container.decodeIfPresent(Double.self, forKey: .expiresAtCamel)
        {
            self.expiryDateMilliseconds = expiryDateMilliseconds
        } else if let expiryDateMilliseconds = try container.decodeIfPresent(Int.self, forKey: .expiryDateSnake)
            ?? container.decodeIfPresent(Int.self, forKey: .expiresAtCamel)
        {
            self.expiryDateMilliseconds = Double(expiryDateMilliseconds)
        } else {
            self.expiryDateMilliseconds = nil
        }
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(self.accessToken, forKey: .accessTokenSnake)
        try container.encodeIfPresent(self.refreshToken, forKey: .refreshTokenSnake)
        try container.encodeIfPresent(self.expiryDateMilliseconds, forKey: .expiryDateSnake)
        try container.encodeIfPresent(self.idToken, forKey: .idTokenSnake)
        try container.encodeIfPresent(self.email, forKey: .email)
        try container.encodeIfPresent(self.projectID, forKey: .projectIDSnake)
        try container.encodeIfPresent(self.clientID, forKey: .clientIDSnake)
        try container.encodeIfPresent(self.clientSecret, forKey: .clientSecretSnake)
    }

    enum CodingKeys: String, CodingKey {
        case accessTokenSnake = "access_token"
        case accessTokenCamel = "accessToken"
        case refreshTokenSnake = "refresh_token"
        case refreshTokenCamel = "refreshToken"
        case expiryDateSnake = "expiry_date"
        case expiresAtCamel = "expiresAt"
        case idTokenSnake = "id_token"
        case idTokenCamel = "idToken"
        case email
        case projectIDSnake = "project_id"
        case projectIDCamel = "projectId"
        case clientIDSnake = "client_id"
        case clientIDCamel = "clientId"
        case clientSecretSnake = "client_secret"
        case clientSecretCamel = "clientSecret"
    }
}

public struct AntigravityOAuthClient: Sendable, Equatable {
    public let clientID: String
    public let clientSecret: String

    public init(clientID: String, clientSecret: String) {
        self.clientID = clientID
        self.clientSecret = clientSecret
    }
}

public enum AntigravityOAuthConfig {
    public static var configuredClientID: String? {
        let value = ProcessInfo.processInfo.environment["ANTIGRAVITY_OAUTH_CLIENT_ID"]
        return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
    }

    public static var configuredClientSecret: String? {
        let value = ProcessInfo.processInfo.environment["ANTIGRAVITY_OAUTH_CLIENT_SECRET"]
        return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
    }

    public static let authURL = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!
    public static let tokenURL = URL(string: "https://oauth2.googleapis.com/token")!
    public static let userInfoURL = URL(string: "https://www.googleapis.com/oauth2/v2/userinfo")!
    public static let scopes = [
        "https://www.googleapis.com/auth/cloud-platform",
        "https://www.googleapis.com/auth/userinfo.email",
    ]

    public static let missingCredentialsMessage =
        """
        Antigravity OAuth client is not configured. Install Antigravity.app or set \
        ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET before logging in.
        """

    public static func resolvedClient() -> AntigravityOAuthClient? {
        if let client = environmentClient() {
            return client
        }
        return Self.discoverClientFromInstalledApp()
    }

    private static func environmentClient() -> AntigravityOAuthClient? {
        guard let clientID = configuredClientID,
              let clientSecret = configuredClientSecret
        else {
            return nil
        }
        return AntigravityOAuthClient(clientID: clientID, clientSecret: clientSecret)
    }

    static func discoverClientFromInstalledApp(
        applicationRoots: [URL]? = nil,
        fileManager: FileManager = .default) -> AntigravityOAuthClient?
    {
        for url in self.candidateOAuthClientArtifactURLs(
            applicationRoots: applicationRoots,
            fileManager: fileManager)
            where fileManager.fileExists(atPath: url.path)
        {
            guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]),
                  let client = Self.parseClient(fromInstalledArtifactData: data)
            else {
                continue
            }
            return client
        }
        return nil
    }

    static func candidateOAuthClientArtifactURLs(
        applicationRoots: [URL]? = nil,
        fileManager: FileManager = .default) -> [URL]
    {
        let roots = [
            URL(fileURLWithPath: "/Applications", isDirectory: true),
            fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true),
        ]
        let applicationRoots = applicationRoots ?? roots
        let appBundleURLs = self.candidateAntigravityAppBundleURLs(
            applicationRoots: applicationRoots,
            fileManager: fileManager)
        let relativePaths = [
            "Contents/Resources/app/extensions/antigravity/bin/language_server_macos_arm",
            "Contents/Resources/app/extensions/antigravity/bin/language_server_macos_x64",
            "Contents/Resources/app/extensions/antigravity/bin/language_server_macos",
            "Contents/Resources/app/out/main.js",
            "Contents/Resources/bin/language_server",
            "Contents/Resources/bin/language_server_macos",
            // Gemini.app is a single native binary with no Electron artifacts.
            "Contents/MacOS/Gemini",
        ]
        return appBundleURLs.flatMap { bundleURL in
            relativePaths.map { bundleURL.appendingPathComponent($0) }
        }
    }

    private static func candidateAntigravityAppBundleURLs(
        applicationRoots: [URL],
        fileManager: FileManager) -> [URL]
    {
        var urls: [URL] = []

        for root in applicationRoots {
            urls.append(root.appendingPathComponent("Antigravity.app", isDirectory: true))

            // "Gemini.app" is a generic name, so unlike "Antigravity.app" it is
            // only accepted when the bundle identifier confirms the renamed app.
            let geminiURL = root.appendingPathComponent("Gemini.app", isDirectory: true)
            if self.isAntigravityAppBundle(geminiURL) {
                urls.append(geminiURL)
            }

            let appURLs = (try? fileManager.contentsOfDirectory(
                at: root,
                includingPropertiesForKeys: [.isDirectoryKey],
                options: [.skipsHiddenFiles])) ?? []
            for appURL in appURLs where appURL.pathExtension == "app" {
                guard self.isAntigravityAppBundle(appURL) else { continue }
                urls.append(appURL)
            }
        }

        var seen = Set<String>()
        return urls.filter { url in
            let key = url.standardizedFileURL.path
            guard !seen.contains(key) else { return false }
            seen.insert(key)
            return true
        }
    }

    private static func isAntigravityAppBundle(_ url: URL) -> Bool {
        switch Bundle(url: url)?.bundleIdentifier {
        case "com.google.antigravity", "com.google.antigravity-ide", "com.google.GeminiMacOS":
            true
        default:
            false
        }
    }

    static func parseClient(fromInstalledArtifactData data: Data) -> AntigravityOAuthClient? {
        if let content = String(data: data, encoding: .utf8),
           let client = parseClient(fromInstalledArtifactText: content)
        {
            return client
        }

        let clientIDs = Self.clientIDs(in: data)
        let clientSecrets = Self.clientSecrets(in: data)
        guard let client = Self.preferredBinaryClient(
            clientIDs: clientIDs,
            clientSecrets: clientSecrets)
        else {
            return nil
        }

        return client
    }

    static func parseClient(fromInstalledArtifactText content: String) -> AntigravityOAuthClient? {
        let marker = "vs/platform/cloudCode/common/oauthClient.js"
        let searchStart = content.range(of: marker)?.lowerBound ?? content.startIndex
        let searchEnd = content.index(searchStart, offsetBy: 4000, limitedBy: content.endIndex) ?? content.endIndex
        let haystack = String(content[searchStart..<searchEnd])

        guard let clientID = Self.firstMatch(
            pattern: #"[0-9]+-[A-Za-z0-9_-]+\.apps\.googleusercontent\.com"#,
            in: haystack),
            let clientSecret = Self.firstMatch(
                pattern: #"GOCSPX-[A-Za-z0-9_-]{28}"#,
                in: haystack)
        else {
            return nil
        }

        return AntigravityOAuthClient(clientID: clientID, clientSecret: clientSecret)
    }

    private static func clientIDs(in data: Data) -> [String] {
        let suffix = Data(".apps.googleusercontent.com".utf8)
        var searchRange = data.startIndex..<data.endIndex
        var values: [String] = []

        while let range = data.range(of: suffix, options: [], in: searchRange) {
            var start = range.lowerBound
            while start > data.startIndex {
                let previous = data.index(before: start)
                guard Self.isOAuthClientIDPrefixByte(data[previous]) else { break }
                start = previous
            }

            let candidateData = Data(data[start..<range.upperBound])
            if let candidate = String(data: candidateData, encoding: .ascii),
               let clientID = Self.firstMatch(
                   pattern: #"[0-9]+-[A-Za-z0-9_-]+\.apps\.googleusercontent\.com"#,
                   in: candidate)
            {
                values.append(clientID)
            }

            searchRange = range.upperBound..<data.endIndex
        }

        return self.unique(values)
    }

    private static func clientSecrets(in data: Data) -> [String] {
        let prefix = Data("GOCSPX-".utf8)
        let secretLength = 35
        var searchRange = data.startIndex..<data.endIndex
        var values: [String] = []

        while let range = data.range(of: prefix, options: [], in: searchRange) {
            let end = range.lowerBound + secretLength
            if end <= data.endIndex {
                let candidateData = Data(data[range.lowerBound..<end])
                if candidateData.dropFirst(prefix.count).allSatisfy(Self.isOAuthClientSecretByte),
                   let candidate = String(data: candidateData, encoding: .ascii)
                {
                    values.append(candidate)
                }
            }

            searchRange = range.upperBound..<data.endIndex
        }

        return self.unique(values)
    }

    private static func preferredBinaryClient(
        clientIDs: [String],
        clientSecrets: [String]) -> AntigravityOAuthClient?
    {
        guard !clientIDs.isEmpty,
              !clientSecrets.isEmpty
        else {
            return nil
        }

        if clientSecrets.count == 1, clientIDs.count > 1 {
            return AntigravityOAuthClient(clientID: clientIDs[clientIDs.count - 1], clientSecret: clientSecrets[0])
        }

        let clientSecret: String = if clientSecrets.count == clientIDs.count, clientSecrets.count > 1 {
            // Antigravity 2's language_server binary stores the secret table before the client id table.
            clientSecrets[clientSecrets.count - 1]
        } else {
            clientSecrets[0]
        }

        return AntigravityOAuthClient(clientID: clientIDs[0], clientSecret: clientSecret)
    }

    private static func isOAuthClientIDPrefixByte(_ byte: UInt8) -> Bool {
        (byte >= 48 && byte <= 57)
            || (byte >= 65 && byte <= 90)
            || (byte >= 97 && byte <= 122)
            || byte == 45
            || byte == 95
    }

    private static func isOAuthClientSecretByte(_ byte: UInt8) -> Bool {
        self.isOAuthClientIDPrefixByte(byte)
    }

    private static func firstMatch(pattern: String, in text: String) -> String? {
        guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
        let range = NSRange(text.startIndex..<text.endIndex, in: text)
        guard let match = regex.firstMatch(in: text, range: range),
              let swiftRange = Range(match.range, in: text)
        else {
            return nil
        }
        return String(text[swiftRange])
    }

    private static func unique(_ values: [String]) -> [String] {
        var seen = Set<String>()
        return values.filter { value in
            guard !seen.contains(value) else { return false }
            seen.insert(value)
            return true
        }
    }
}

public struct AntigravityOAuthCredentialsStore: @unchecked Sendable {
    public static let environmentCredentialsKey = "ANTIGRAVITY_OAUTH_CREDENTIALS_JSON"
    private static let fileLock = NSLock()

    public let fileURL: URL
    private let fileManager: FileManager

    public init(fileURL: URL = Self.defaultURL(), fileManager: FileManager = .default) {
        self.fileURL = fileURL
        self.fileManager = fileManager
    }

    public func load() throws -> AntigravityOAuthCredentials? {
        try Self.fileLock.withLock {
            try self.loadUnlocked()
        }
    }

    private func loadUnlocked() throws -> AntigravityOAuthCredentials? {
        guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return nil }
        let data = try Data(contentsOf: self.fileURL)
        return try JSONDecoder().decode(AntigravityOAuthCredentials.self, from: data)
    }

    public func save(_ credentials: AntigravityOAuthCredentials) throws {
        try Self.fileLock.withLock {
            let data = try JSONEncoder.antigravityCredentials.encode(credentials)
            let directory = self.fileURL.deletingLastPathComponent()
            if !self.fileManager.fileExists(atPath: directory.path) {
                try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
            }
            try data.write(to: self.fileURL, options: [.atomic])
            try self.applySecurePermissionsIfNeeded()
        }
    }

    public func deleteIfPresent() throws {
        try Self.fileLock.withLock {
            try self.deleteIfPresentUnlocked()
        }
    }

    public func deleteIfPresent(
        matching predicate: (AntigravityOAuthCredentials) -> Bool) throws
    {
        try Self.fileLock.withLock {
            guard let credentials = try self.loadUnlocked(), predicate(credentials) else { return }
            try self.deleteIfPresentUnlocked()
        }
    }

    private func deleteIfPresentUnlocked() throws {
        guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return }
        try self.fileManager.removeItem(at: self.fileURL)
    }

    public static func defaultDirectoryURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
        home
            .appendingPathComponent(".codexbar", isDirectory: true)
            .appendingPathComponent("antigravity", isDirectory: true)
    }

    public static func defaultURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
        self.defaultDirectoryURL(home: home)
            .appendingPathComponent("oauth_creds.json")
    }

    public static func tokenAccountValue(for credentials: AntigravityOAuthCredentials) throws -> String {
        let data = try JSONEncoder.antigravityCredentials.encode(credentials)
        guard let value = String(data: data, encoding: .utf8) else {
            throw CocoaError(.coderInvalidValue)
        }
        return value
    }

    public static func credentials(fromTokenAccountValue value: String) -> AntigravityOAuthCredentials? {
        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil }
        return try? JSONDecoder().decode(AntigravityOAuthCredentials.self, from: data)
    }

    private func applySecurePermissionsIfNeeded() throws {
        #if os(macOS) || os(Linux)
        try self.fileManager.setAttributes([
            .posixPermissions: NSNumber(value: Int16(0o600)),
        ], ofItemAtPath: self.fileURL.path)
        #endif
    }
}

extension JSONEncoder {
    fileprivate static let antigravityCredentials: JSONEncoder = {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        return encoder
    }()
}

extension String {
    fileprivate var nilIfEmpty: String? {
        self.isEmpty ? nil : self
    }

    fileprivate var trimmedNonEmptyEmail: String? {
        let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }
}
