//
//  Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//

import Foundation
import LocalAuthentication

@objc public class OWSScreenLock: NSObject {

    public enum OWSScreenLockOutcome {
        case success
        case cancel
        case failure(error:String)
        case unexpectedFailure(error:String)
    }

    @objc public let screenLockTimeoutDefault = 15 * kMinuteInterval
    @objc public let screenLockTimeouts = [
        1 * kMinuteInterval,
        5 * kMinuteInterval,
        15 * kMinuteInterval,
        30 * kMinuteInterval,
        1 * kHourInterval,
        0
    ]

    @objc public static let ScreenLockDidChange = Notification.Name("ScreenLockDidChange")

    let primaryStorage: OWSPrimaryStorage
    let dbConnection: YapDatabaseConnection

    private let OWSScreenLock_Collection = "OWSScreenLock_Collection"
    private let OWSScreenLock_Key_IsScreenLockEnabled = "OWSScreenLock_Key_IsScreenLockEnabled"
    private let OWSScreenLock_Key_ScreenLockTimeoutSeconds = "OWSScreenLock_Key_ScreenLockTimeoutSeconds"

    // MARK: - Singleton class

    @objc(sharedManager)
    public static let shared = OWSScreenLock()

    private override init() {
        self.primaryStorage = OWSPrimaryStorage.shared()
        self.dbConnection = self.primaryStorage.newDatabaseConnection()

        super.init()

        SwiftSingletons.register(self)
    }

    // MARK: - Properties

    @objc public func isScreenLockEnabled() -> Bool {
        AssertIsOnMainThread()

        if !OWSStorage.isStorageReady() {
            owsFailDebug("accessed screen lock state before storage is ready.")
            return false
        }

        return self.dbConnection.bool(forKey: OWSScreenLock_Key_IsScreenLockEnabled, inCollection: OWSScreenLock_Collection, defaultValue: false)
    }

    @objc
    public func setIsScreenLockEnabled(_ value: Bool) {
        AssertIsOnMainThread()
        assert(OWSStorage.isStorageReady())

        self.dbConnection.setBool(value, forKey: OWSScreenLock_Key_IsScreenLockEnabled, inCollection: OWSScreenLock_Collection)

        NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil)
    }

    @objc public func screenLockTimeout() -> TimeInterval {
        AssertIsOnMainThread()

        if !OWSStorage.isStorageReady() {
            owsFailDebug("accessed screen lock state before storage is ready.")
            return 0
        }

        return self.dbConnection.double(forKey: OWSScreenLock_Key_ScreenLockTimeoutSeconds, inCollection: OWSScreenLock_Collection, defaultValue: screenLockTimeoutDefault)
    }

    @objc public func setScreenLockTimeout(_ value: TimeInterval) {
        AssertIsOnMainThread()
        assert(OWSStorage.isStorageReady())

        self.dbConnection.setDouble(value, forKey: OWSScreenLock_Key_ScreenLockTimeoutSeconds, inCollection: OWSScreenLock_Collection)

        NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil)
    }

    // MARK: - Methods

    // This method should only be called:
    //
    // * On the main thread.
    //
    // Exactly one of these completions will be performed:
    //
    // * Asynchronously.
    // * On the main thread.
    @objc public func tryToUnlockScreenLock(success: @escaping (() -> Void),
                                            failure: @escaping ((Error) -> Void),
                                            unexpectedFailure: @escaping ((Error) -> Void),
                                            cancel: @escaping (() -> Void)) {
        AssertIsOnMainThread()

        tryToVerifyLocalAuthentication(localizedReason: NSLocalizedString("SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK",
                                                                          comment: "Description of how and why Signal iOS uses Touch ID/Face ID/Phone Passcode to unlock 'screen lock'."),
                                       completion: { (outcome: OWSScreenLockOutcome) in
                                        AssertIsOnMainThread()

                                        switch outcome {
                                        case .failure(let error):
                                            Logger.error("local authentication failed with error: \(error)")
                                            failure(self.authenticationError(errorDescription: error))
                                        case .unexpectedFailure(let error):
                                            Logger.error("local authentication failed with unexpected error: \(error)")
                                            unexpectedFailure(self.authenticationError(errorDescription: error))
                                        case .success:
                                            Logger.verbose("local authentication succeeded.")
                                            success()
                                        case .cancel:
                                            Logger.verbose("local authentication cancelled.")
                                            cancel()
                                        }
        })
    }

    // This method should only be called:
    //
    // * On the main thread.
    //
    // completionParam will be performed:
    //
    // * Asynchronously.
    // * On the main thread.
    private func tryToVerifyLocalAuthentication(localizedReason: String,
                                                completion completionParam: @escaping ((OWSScreenLockOutcome) -> Void)) {
        AssertIsOnMainThread()

        let defaultErrorDescription = NSLocalizedString("SCREEN_LOCK_ENABLE_UNKNOWN_ERROR",
                                                        comment: "Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode.")

        // Ensure completion is always called on the main thread.
        let completion = { (outcome: OWSScreenLockOutcome) in
            DispatchQueue.main.async {
                completionParam(outcome)
            }
        }

        let context = screenLockContext()

        var authError: NSError?
        let canEvaluatePolicy = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authError)
        if !canEvaluatePolicy || authError != nil {
            Logger.error("could not determine if local authentication is supported: \(String(describing: authError))")

            let outcome = self.outcomeForLAError(errorParam: authError,
                                                 defaultErrorDescription: defaultErrorDescription)
            switch outcome {
            case .success:
                owsFailDebug("local authentication unexpected success")
                completion(.failure(error:defaultErrorDescription))
            case .cancel, .failure, .unexpectedFailure:
                completion(outcome)
            }
            return
        }

        context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: localizedReason) { success, evaluateError in

            if success {
                Logger.info("local authentication succeeded.")
                completion(.success)
            } else {
                let outcome = self.outcomeForLAError(errorParam: evaluateError,
                                                     defaultErrorDescription: defaultErrorDescription)
                switch outcome {
                case .success:
                    owsFailDebug("local authentication unexpected success")
                    completion(.failure(error:defaultErrorDescription))
                case .cancel, .failure, .unexpectedFailure:
                    completion(outcome)
                }
            }
        }
    }

    // MARK: - Outcome

    private func outcomeForLAError(errorParam: Error?, defaultErrorDescription: String) -> OWSScreenLockOutcome {
        if let error = errorParam {
            guard let laError = error as? LAError else {
                return .failure(error:defaultErrorDescription)
            }

            if #available(iOS 11.0, *) {
                switch laError.code {
                case .biometryNotAvailable:
                    Logger.error("local authentication error: biometryNotAvailable.")
                    return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE",
                                                             comment: "Indicates that Touch ID/Face ID/Phone Passcode are not available on this device."))
                case .biometryNotEnrolled:
                    Logger.error("local authentication error: biometryNotEnrolled.")
                    return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED",
                                                             comment: "Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device."))
                case .biometryLockout:
                    Logger.error("local authentication error: biometryLockout.")
                    return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT",
                                                             comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures."))
                default:
                    // Fall through to second switch
                    break
                }
            }

            switch laError.code {
            case .authenticationFailed:
                Logger.error("local authentication error: authenticationFailed.")
                return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED",
                                                         comment: "Indicates that Touch ID/Face ID/Phone Passcode authentication failed."))
            case .userCancel, .userFallback, .systemCancel, .appCancel:
                Logger.info("local authentication cancelled.")
                return .cancel
            case .passcodeNotSet:
                Logger.error("local authentication error: passcodeNotSet.")
                return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET",
                                                         comment: "Indicates that Touch ID/Face ID/Phone Passcode passcode is not set."))
            case .touchIDNotAvailable:
                Logger.error("local authentication error: touchIDNotAvailable.")
                return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE",
                                                         comment: "Indicates that Touch ID/Face ID/Phone Passcode are not available on this device."))
            case .touchIDNotEnrolled:
                Logger.error("local authentication error: touchIDNotEnrolled.")
                return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED",
                                                         comment: "Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device."))
            case .touchIDLockout:
                Logger.error("local authentication error: touchIDLockout.")
                return .failure(error: NSLocalizedString("SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT",
                                                         comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures."))
            case .invalidContext:
                owsFailDebug("context not valid.")
                return .unexpectedFailure(error:defaultErrorDescription)
            case .notInteractive:
                owsFailDebug("context not interactive.")
                return .unexpectedFailure(error:defaultErrorDescription)
            }
        }
        return .failure(error:defaultErrorDescription)
    }

    private func authenticationError(errorDescription: String) -> Error {
        return OWSErrorWithCodeDescription(.localAuthenticationError,
                                           errorDescription)
    }

    // MARK: - Context

    private func screenLockContext() -> LAContext {
        let context = LAContext()

        // Never recycle biometric auth.
        context.touchIDAuthenticationAllowableReuseDuration = TimeInterval(0)

        if #available(iOS 11.0, *) {
            assert(!context.interactionNotAllowed)
        }

        return context
    }
}