//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

/// Set of methods to be able to create an account on a homeserver.
///
/// Common scenario to register an account successfully:
///  - Call `registrationFlow` to check that you application supports all the mandatory registration stages
///  - Call `createAccount` to start the account creation
///  - Fulfil all mandatory stages using the methods `performReCaptcha` `acceptTerms` `dummy`, etc.
///
/// More documentation can be found in the file https://github.com/vector-im/element-android/blob/main/docs/signup.md
/// and https://matrix.org/docs/spec/client_server/latest#account-registration-and-management
class RegistrationWizard {
    struct State {
        var currentSession: String?
        var isRegistrationStarted = false
        var currentThreePIDData: ThreePIDData?
        
        var clientSecret = UUID().uuidString
        var sendAttempt: UInt = 0
    }
    
    let client: AuthenticationRestClient
    let sessionCreator: SessionCreatorProtocol
    
    private(set) var state: State
    
    /// This is the current ThreePID, waiting for validation. The SDK will store it in database, so it can be
    /// restored even if the app has been killed during the registration
    var currentThreePID: String? {
        guard let threePid = state.currentThreePIDData?.threePID else { return nil }
        
        switch threePid {
        case .email(let string):
            return string
        case .msisdn(let msisdn, _):
            return state.currentThreePIDData?.registrationResponse.formattedMSISDN ?? msisdn
        }
    }

    /// True when login and password have been sent with success to the homeserver,
    /// i.e. `createAccount` has been called successfully.
    var isRegistrationStarted: Bool {
        state.isRegistrationStarted
    }
    
    init(client: AuthenticationRestClient, sessionCreator: SessionCreatorProtocol) {
        self.client = client
        self.sessionCreator = sessionCreator
        
        state = State()
    }
    
    /// Call this method to get the possible registration flow of the current homeserver.
    /// It can be useful to ensure that your application implementation supports all the stages
    /// required to create an account. If it is not the case, you will have to use the web fallback
    /// to let the user create an account with your application.
    /// See `AuthenticationService.getFallbackUrl`
    func registrationFlow() async throws -> RegistrationResult {
        let parameters = RegistrationParameters()
        
        do {
            let result = try await performRegistrationRequest(parameters: parameters)
            return result
        } catch {
            // Map M_FORBIDDEN into a registration error.
            guard let mxError = MXError(nsError: error), mxError.errcode == kMXErrCodeStringForbidden else { throw error }
            MXLog.warning("[RegistrationWizard] Registration is disabled for the selected server.")
            throw RegistrationError.registrationDisabled
        }
    }

    /// Can be call to check is the desired username is available for registration on the current homeserver.
    /// It may also fails if the desired username is not correctly formatted or does not follow any restriction on
    /// the homeserver. Ex: username with only digits may be rejected.
    /// - Parameter username: The desired username. Ex: "alice"
    func registrationAvailable(username: String) async throws -> Bool {
        try await client.isUsernameAvailable(username)
    }

    /// This is the first method to call in order to create an account and start the registration process.
    ///
    /// - Parameters:
    ///   - username: The desired username. Ex: "alice"
    ///   - password: The desired password
    ///   - initialDeviceDisplayName: The device display name
    func createAccount(username: String?,
                       password: String?,
                       initialDeviceDisplayName: String?) async throws -> RegistrationResult {
        let parameters = RegistrationParameters(username: username, password: password, initialDeviceDisplayName: initialDeviceDisplayName)
        let result = try await performRegistrationRequest(parameters: parameters, isCreatingAccount: true)
        state.isRegistrationStarted = true
        return result
    }

    /// Perform the "m.login.recaptcha" stage.
    ///
    /// - Parameter response: The response from ReCaptcha
    func performReCaptcha(response: String) async throws -> RegistrationResult {
        guard let session = state.currentSession else {
            MXLog.error("[RegistrationWizard] performReCaptcha: Missing authentication session, createAccount hasn't been called.")
            throw RegistrationError.createAccountNotCalled
        }
        
        let parameters = RegistrationParameters(auth: AuthenticationParameters.captchaParameters(session: session, captchaResponse: response))
        return try await performRegistrationRequest(parameters: parameters)
    }

    /// Perform the "m.login.terms" stage.
    func acceptTerms() async throws -> RegistrationResult {
        guard let session = state.currentSession else {
            MXLog.error("[RegistrationWizard] acceptTerms: Missing authentication session, createAccount hasn't been called.")
            throw RegistrationError.createAccountNotCalled
        }
        
        let parameters = RegistrationParameters(auth: AuthenticationParameters(type: kMXLoginFlowTypeTerms, session: session))
        return try await performRegistrationRequest(parameters: parameters)
    }

    /// Perform the "m.login.dummy" stage.
    func dummy() async throws -> RegistrationResult {
        guard let session = state.currentSession else {
            MXLog.error("[RegistrationWizard] dummy: Missing authentication session, createAccount hasn't been called.")
            throw RegistrationError.createAccountNotCalled
        }
        
        let parameters = RegistrationParameters(auth: AuthenticationParameters(type: kMXLoginFlowTypeDummy, session: session))
        return try await performRegistrationRequest(parameters: parameters)
    }

    /// Perform the "m.login.email.identity" or "m.login.msisdn" stage.
    ///
    /// - Parameter threePID: the threePID to add to the account. If this is an email, the homeserver will send an email
    /// to validate it. For a msisdn a SMS will be sent.
    func addThreePID(threePID: RegisterThreePID) async throws -> RegistrationResult {
        state.currentThreePIDData = nil
        return try await sendThreePID(threePID: threePID)
    }

    /// Ask the homeserver to send again the current threePID (email or msisdn).
    func sendAgainThreePID() async throws -> RegistrationResult {
        guard let threePID = state.currentThreePIDData?.threePID else {
            MXLog.error("[RegistrationWizard] sendAgainThreePID: Missing authentication session, createAccount hasn't been called.")
            throw RegistrationError.createAccountNotCalled
        }
        return try await sendThreePID(threePID: threePID)
    }

    /// Send the code received by SMS to validate a msisdn.
    /// If the code is correct, the registration request will be executed to validate the msisdn.
    func handleValidateThreePID(code: String) async throws -> RegistrationResult {
        try await validateThreePid(code: code)
    }

    /// Useful to poll the homeserver when waiting for the email to be validated by the user.
    /// Once the email is validated, this method will return successfully.
    func checkIfEmailHasBeenValidated() async throws -> RegistrationResult {
        guard let parameters = state.currentThreePIDData?.registrationParameters else {
            MXLog.error("[RegistrationWizard] checkIfEmailHasBeenValidated: The current 3pid data hasn't been stored in the state.")
            throw RegistrationError.missingThreePIDData
        }

        do {
            return try await performRegistrationRequest(parameters: parameters)
        } catch {
            // An unauthorized error indicates that the user hasn't tapped the link yet.
            guard isUnauthorized(error) else { throw error }
            throw RegistrationError.waitingForThreePIDValidation
        }
    }
    
    // MARK: - Private
    
    private func validateThreePid(code: String) async throws -> RegistrationResult {
        guard let threePIDData = state.currentThreePIDData else {
            MXLog.error("[RegistrationWizard] validateThreePid: There is no third party ID data stored in the state.")
            throw RegistrationError.missingThreePIDData
        }
        
        guard let submitURL = threePIDData.registrationResponse.submitURL else {
            MXLog.error("[RegistrationWizard] validateThreePid: The third party ID data doesn't contain a submitURL.")
            throw RegistrationError.missingThreePIDURL
        }
        
        let validationBody = ThreePIDValidationCodeBody(clientSecret: state.clientSecret,
                                                        sessionID: threePIDData.registrationResponse.sessionID,
                                                        code: code)
        
        //  Seems odd to pass a nil baseURL and then the url as the path, yet this is how MXK3PID works"
        guard let httpClient = MXHTTPClient(baseURL: nil, andOnUnrecognizedCertificateBlock: nil) else {
            MXLog.error("[RegistrationWizard] validateThreePid: Failed to create an MXHTTPClient.")
            throw RegistrationError.threePIDClientFailure
        }
        guard try await httpClient.validateThreePIDCode(submitURL: submitURL, validationBody: validationBody) else {
            MXLog.error("[RegistrationWizard] validateThreePid: Third party ID validation failed.")
            throw RegistrationError.threePIDValidationFailure
        }
        
        let parameters = threePIDData.registrationParameters
        return try await performRegistrationRequest(parameters: parameters)
    }
    
    private func sendThreePID(threePID: RegisterThreePID) async throws -> RegistrationResult {
        guard let session = state.currentSession else {
            MXLog.error("[RegistrationWizard] sendThreePID: Missing authentication session, createAccount hasn't been called.")
            throw RegistrationError.createAccountNotCalled
        }
        
        let response = try await client.requestTokenDuringRegistration(for: threePID,
                                                                       clientSecret: state.clientSecret,
                                                                       sendAttempt: state.sendAttempt)
        
        state.sendAttempt += 1
        
        let threePIDCredentials = ThreePIDCredentials(clientSecret: state.clientSecret, sessionID: response.sessionID)
        let authenticationParameters: AuthenticationParameters
        switch threePID {
        case .email:
            authenticationParameters = AuthenticationParameters.emailIdentityParameters(session: session, threePIDCredentials: threePIDCredentials)
        case .msisdn:
            authenticationParameters = AuthenticationParameters.msisdnIdentityParameters(session: session, threePIDCredentials: threePIDCredentials)
        }
        
        let parameters = RegistrationParameters(auth: authenticationParameters)
        
        state.currentThreePIDData = ThreePIDData(threePID: threePID, registrationResponse: response, registrationParameters: parameters)

        do {
            // Send the session id for the first time
            return try await performRegistrationRequest(parameters: parameters)
        } catch {
            // An unauthorized error means that it was accepted and is awaiting validation.
            guard isUnauthorized(error) else { throw error }
            throw RegistrationError.waitingForThreePIDValidation
        }
    }
    
    private func performRegistrationRequest(parameters: RegistrationParameters, isCreatingAccount: Bool = false) async throws -> RegistrationResult {
        do {
            let response = try await client.register(parameters: parameters)
            let credentials = MXCredentials(loginResponse: response, andDefaultCredentials: client.credentials)
            return await .success(sessionCreator.createSession(credentials: credentials, client: client, removeOtherAccounts: false))
        } catch {
            let nsError = error as NSError
            
            guard
                let jsonResponse = nsError.userInfo[MXHTTPClientErrorResponseDataKey] as? [String: Any],
                let authenticationSession = MXAuthenticationSession(fromJSON: jsonResponse)
            else { throw error }
            
            state.currentSession = authenticationSession.session
            let flowResult = authenticationSession.flowResult
            
            if isCreatingAccount || isRegistrationStarted {
                return try await handleDummyStage(flowResult: flowResult)
            }
            
            return .flowResponse(flowResult)
        }
    }
    
    /// Checks for a dummy stage and handles it automatically when possible.
    private func handleDummyStage(flowResult: FlowResult) async throws -> RegistrationResult {
        // If the dummy stage is mandatory, do the dummy stage now
        guard flowResult.missingStages.contains(where: \.isDummy) else { return .flowResponse(flowResult) }
        return try await dummy()
    }
    
    /// Checks whether an error is an `M_UNAUTHORIZED` for handling third party ID responses.
    private func isUnauthorized(_ error: Error) -> Bool {
        guard let mxError = MXError(nsError: error) else { return false }
        return mxError.errcode == kMXErrCodeStringUnauthorized
    }
}
