//
//  CoinosDeterministicClient.swift
//  damus
//
//  Created by Daniel D’Aquino on 2025-04-14.
//

import Foundation
import Sentry

/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
///
/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
class CoinosDeterministicAccountClient {
    // MARK: - State
    
    /// The user's normal keypair for using Nostr
    private let userKeypair: FullKeypair
    /// The JWT authentication token with Coinos
    private var jwtAuthToken: String? = nil
    
    
    // MARK: - Computed properties for a deterministic wallet
    
    /// A deterministic keypair for the NWC connection derived from the user's private key
    private var nwcKeypair: FullKeypair? {
        let nwcPrivateKey: Privkey = Privkey(sha256(self.userKeypair.privkey.id))   // SHA256 is an irreversible operation, user's nsec should not be deriveable from this new private key
        return FullKeypair(privkey: nwcPrivateKey)
    }
    
    /// A deterministic username for a Coinos account
    private var username: String? {
        // Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
        // Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
        guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
        // There is very little risk of a birthday attack on getting only the first 16 characters, because:
        // 1. before this user creates an account, no one else knows the private key in order to know the expected username and create an account before them
        // 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
        //
        // In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
        // According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
        // even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
        return String(fullText.prefix(16))
    }
    
    var expectedLud16: String? {
        guard let username else { return nil }
        return username + "@coinos.io"
    }
    
    /// A deterministic password for a Coinos account
    private var password: String? {
        // Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
        return sha256Hex(text: "coinos_password:" + self.userKeypair.privkey.hex())
    }
    
    /// A deterministic NWC app connection name
    private var nwcConnectionName: String { return "Damus" }
    
    
    // MARK: - Initialization
    
    /// Initializes the client with the user's keypair
    init(userKeypair: FullKeypair) {
        self.userKeypair = userKeypair
    }
    
    
    // MARK: - Authentication and registration
    
    /// Tries to login to the user's deterministic account. If it cannot be found, it will register for one and log into that.
    func loginOrRegister() async throws {
        do {
            // Check if client has an account
            try await self.login()
        }
        catch {
            guard let error = error as? CoinosDeterministicAccountClient.ClientError, error == .unauthorized else { throw error }
            // Client does not seem to have an account, create one
            try await self.register()
            try await self.login()
        }
    }
    
    /// Registers for a Coinos account using deterministic account details.
    ///
    /// It succeeds if it returns without throwing errors.
    func register() async throws {
        guard let username, let password else { throw ClientError.errorFormingRequest }
        let registerPayload = RegisterRequest(user: UserCredentials(username: username, password: password))
        let jsonData = try JSONEncoder().encode(registerPayload)
        
        let url = URL(string: "https://coinos.io/api/register")!
        let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
        
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
            return
        } else {
            let error = ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
            DamusSentry.captureSentryError(error) { scope in
                scope.setContext(value: [
                    "operation": "coinos_register",
                    "status_code": (response as? HTTPURLResponse)?.statusCode ?? -1,
                    "response_length": data.count
                ], key: "wallet")
            }
            throw error
        }
    }
    
    /// Logs into the deterministic account, if an auth token is not present
    func loginIfNeeded() async throws {
        if self.jwtAuthToken == nil { try await self.login() }
    }
    
    /// Logs into to our deterministic account.
    ///
    /// Succeeds if it returns without returning errors.
    ///
    /// Mutating function, will update the client's internal state.
    func login() async throws {
        self.jwtAuthToken = try await sendLoginRequest().token
    }
    
    /// Sends the login request and return the response
    ///
    /// Does NOT update the internal login state.
    private func sendLoginRequest() async throws -> AuthResponse {
        guard let url = URL(string: "https://coinos.io/api/login") else { throw ClientError.errorFormingRequest }
        guard let username, let password else { throw ClientError.errorFormingRequest }
        let credentials = UserCredentials(username: username, password: password)
        let jsonData = try JSONEncoder().encode(credentials)
        
        let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
        
        if let httpResponse = response as? HTTPURLResponse {
            switch httpResponse.statusCode {
            case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
            case 401: throw ClientError.unauthorized
            default:
                let error = ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
                DamusSentry.captureSentryError(error) { scope in
                    scope.setContext(value: [
                        "operation": "coinos_login",
                        "status_code": httpResponse.statusCode,
                        "response_length": data.count
                    ], key: "wallet")
                }
                throw error
            }
        }
        throw ClientError.errorProcessingResponse
    }
    
    
    // MARK: - Managing NWC connections
    
    /// Creates a new NWC connection
    ///
    /// Note: Account must exist before calling this endpoint
    func createNWCConnection() async throws -> WalletConnectURL {
        guard let nwcKeypair else { throw ClientError.errorFormingRequest }
        guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
        
        try await self.loginIfNeeded()
        
        let config = try defaultWalletConnectionConfig()
        let configData = try encode_json_data(config)
        
        let (data, response) = try await self.makeAuthenticatedRequest(
            method: .post,
            url: urlEndpoint,
            payload: configData,
            payload_type: .json
        )
        
        if let httpResponse = response as? HTTPURLResponse {
            switch httpResponse.statusCode {
            case 200:
                guard let nwc = try await self.getNWCUrl() else {
                    let error = ClientError.errorProcessingResponse
                    DamusSentry.captureSentryError(error) { scope in
                        scope.setContext(value: [
                            "operation": "create_nwc_connection_get_url"
                        ], key: "wallet")
                    }
                    throw error
                }
                return nwc
            case 401: throw ClientError.unauthorized
            default:
                let error = ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
                DamusSentry.captureSentryError(error) { scope in
                    scope.setContext(value: [
                        "operation": "create_nwc_connection",
                        "status_code": httpResponse.statusCode,
                        "response_length": data.count
                    ], key: "wallet")
                }
                throw error
            }
        }
        throw ClientError.errorProcessingResponse
    }
    
    /// Updates an existing NWC connection with a new maximum budget
    ///
    /// Note: Account and NWC connection must exist before calling this endpoint
    func updateNWCConnection(maxAmount: UInt64) async throws -> WalletConnectURL {
        guard let nwcKeypair else { throw ClientError.errorFormingRequest }
        guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
        
        try await self.loginIfNeeded()
        
        // Get existing config first
        guard let existingConfig = try await self.getNWCAppConnectionConfig() else {
            throw ClientError.errorProcessingResponse
        }
        
        // Create updated config with new max amount
        let updatedConfig = NewWalletConnectionConfig(
            name: existingConfig.name ?? self.nwcConnectionName,
            secret: existingConfig.secret ?? nwcKeypair.privkey.hex(),
            pubkey: existingConfig.pubkey ?? nwcKeypair.pubkey.hex(),
            max_amount: maxAmount,
            budget_renewal: .weekly
        )
        
        let configData = try encode_json_data(updatedConfig)
        
        let (data, response) = try await self.makeAuthenticatedRequest(
            method: .post,
            url: urlEndpoint,
            payload: configData,
            payload_type: .json
        )
        
        if let httpResponse = response as? HTTPURLResponse {
            switch httpResponse.statusCode {
            case 200:
                guard let nwc = try await self.getNWCUrl() else {
                    let error = ClientError.errorProcessingResponse
                    DamusSentry.captureSentryError(error) { scope in
                        scope.setContext(value: [
                            "operation": "update_nwc_connection_get_url"
                        ], key: "wallet")
                    }
                    throw error
                }
                return nwc
            case 401: throw ClientError.unauthorized
            default:
                let error = ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
                DamusSentry.captureSentryError(error) { scope in
                    scope.setContext(value: [
                        "operation": "update_nwc_connection",
                        "status_code": httpResponse.statusCode,
                        "max_amount": maxAmount,
                        "response_length": data.count
                    ], key: "wallet")
                }
                throw error
            }
        }
        throw ClientError.errorProcessingResponse
    }
    
    /// Returns the default wallet connection config
    private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
        guard let nwcKeypair else { throw ClientError.errorFormingRequest }
        return NewWalletConnectionConfig(
            name: self.nwcConnectionName,
            secret: nwcKeypair.privkey.hex(),
            pubkey: nwcKeypair.pubkey.hex(),
            max_amount: 30000,  // 30K sats per week maximum
            budget_renewal: .weekly
        )
    }
    
    /// Gets the NWC URL for the deterministic NWC app connection
    ///
    /// Account must already exist before calling this
    ///
    /// Returns `nil` if no NWC url is found, (e.g. if app connection has not been configured yet)
    func getNWCUrl() async throws -> WalletConnectURL? {
        guard let connectionConfig = try await self.getNWCAppConnectionConfig(), let nwc = connectionConfig.nwc else { return nil }
        return WalletConnectURL(str: nwc)
    }
    
    /// Gets the deterministic NWC app connection configuration details, if it exists
    ///
    /// Account must already exist before calling this
    ///
    /// Returns `nil` if no connection is found, (e.g. if app connection has not been configured yet)
    func getNWCAppConnectionConfig() async throws -> WalletConnectionConfig? {
        guard let nwcKeypair else { throw ClientError.errorFormingRequest }
        guard let url = URL(string: "https://coinos.io/api/app/" + nwcKeypair.pubkey.hex()) else { throw ClientError.errorFormingRequest }
        
        try await self.loginIfNeeded()
        
        let (data, response) = try await self.makeAuthenticatedRequest(
            method: .get,
            url: url,
            payload: nil,
            payload_type: nil
        )
        
        if let httpResponse = response as? HTTPURLResponse {
            switch httpResponse.statusCode {
            case 200: return try JSONDecoder().decode(WalletConnectionConfig.self, from: data)
            case 401: throw ClientError.unauthorized
            case 404: return nil
            default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
            }
        }
        throw ClientError.errorProcessingResponse
    }
    
    
    // MARK: - Lower level request convenience functions
    
    /// Makes a request without any authorization
    func makeRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.httpBody = payload

        if let payload_type {
            request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
        }
        return try await URLSession.shared.data(for: request)
    }
    
    /// Makes an authenticated request with our JWT auth token.
    ///
    /// Client must be logged-in before calling this, otherwise an error will be thrown.
    func makeAuthenticatedRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
        guard let jwtAuthToken else { throw ClientError.errorFormingRequest }
        
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.httpBody = payload

        request.setValue("Bearer " + jwtAuthToken, forHTTPHeaderField: "Authorization")
        if let payload_type {
            request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
        }
        return try await URLSession.shared.data(for: request)
    }
    
    
    // MARK: - Helper structures
    
    /// Payload for registering for a new Coinos account
    struct RegisterRequest: Codable {
        /// New user credentials
        let user: UserCredentials
    }
    
    /// Payload for user credentials (sign-up and login)
    struct UserCredentials: Codable {
        /// The username
        let username: String
        /// The user password
        let password: String
    }
    
    /// A successful response to a login auth endpoint
    struct AuthResponse: Codable {
        /// The JWT token to be applied to any authenticated API calls
        let token: String
    }
    
    /// Used by the client to define new NWC configurations
    struct NewWalletConnectionConfig: Codable {
        /// The name of the connection
        let name: String
        /// 32 Hex-encoded bytes containing a shared private key secret
        let secret: String
        /// 32 Hex-encoded bytes containing the pubkey for the secret
        let pubkey: String
        /// Max amount that can be spent in each renewal period (measured in sats)
        let max_amount: UInt64
        /// The period of time it takes for the budget limits to reset
        let budget_renewal: BudgetRenewalPeriod
    }
    
    /// The NWC connection configuration details
    ///
    /// ## Implementation notes
    ///
    /// - All items defined as optionals because the Coinos API may change in the future, so this may help increase future compatibility.
    struct WalletConnectionConfig: Codable {
        /// The name of the connection
        let name: String?
        /// 32 Hex-encoded bytes containing a shared private key secret
        let secret: String?
        /// 32 Hex-encoded bytes containing the pubkey for the secret
        let pubkey: String?
        /// Max amount that can be spent in every renewal period (measured in sats)
        let max_amount: UInt64?
        /// The NWC url generated by the server
        let nwc: String?
        /// Budget renewal information
        let budget_renewal: BudgetRenewalPeriod?
    }
    
    /// A period of time it takes for budget limits to be reset
    enum BudgetRenewalPeriod: String, Codable {
        /// Resets once a week
        case weekly
    }
    
    /// A client error occured
    enum ClientError: Error, Equatable {
        /// Received an unexpected HTTP response
        ///
        /// Could be for a variety of reasons.
        case unexpectedHTTPResponse(status_code: Int, response: Data)
        /// Error forming the request, generally due to missing or inconsistent internal data
        ///
        /// Probably caused by a programming error.
        case errorFormingRequest
        /// The client could not process the response from the server
        ///
        /// Might be a sign of an incompatibility bug
        case errorProcessingResponse
        /// The action performed is not authorized
        /// Generally thrown if user does not exist, credentials do not match what Coinos has on file, or programming error
        case unauthorized
        /// Client not logged in on a call that expected login
        case notLoggedIn
    }
}

/// Computes a SHA256 hash digest from a piece of UTF-8 text, and returns the result as a "hex" string
///
/// When working only with strings, this can be more convenient than transforming text to data, and data back to text
fileprivate func sha256Hex(text: String) -> String? {
    guard let data = text.data(using: .utf8) else { return nil }
    return sha256(data).toHexString()
}
