//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import Contacts
import SignalCoreKit

public enum ExperienceUpgradeManifest: Dependencies {
    /// Prompts the user to create a PIN, if they did not create one during
    /// registration.
    ///
    /// Skipping a PIN is not user-selectable during registration, but is
    /// possible if KBS returned errors.
    case introducingPins

    /// Prompts the user to enable notifications permissions.
    case notificationPermissionReminder

    /// Prompts the user to create a username.
    case createUsernameReminder

    /// Prompts the user according to the contained ``RemoteMegaphoneModel``.
    ///
    /// Remote megaphones are fetched from the service, and expected to change
    /// over time.
    case remoteMegaphone(megaphone: RemoteMegaphoneModel)

    /// Prompts the user about any "inactive" linked devices that will expire
    /// soon.
    case inactiveLinkedDeviceReminder

    /// Prompts the user to enter their PIN, to help ensure they remember it.
    ///
    /// Note that this upgrade stores state in external components, rather than
    /// in an ``ExperienceUpgrade``.
    case pinReminder

    /// Prompts the user to enable contacts permissions.
    case contactPermissionReminder

    /// An unrecognized upgrade, which should generally be ignored/discarded.
    ///
    /// This may represent a persisted ``ExperienceUpgrade`` record which refers
    /// to an upgrade that has since been removed.
    case unrecognized(uniqueId: String)
}

// MARK: - Codable

extension ExperienceUpgradeManifest: Codable {

    public enum CodingKeys: String, CodingKey {
        /// Keys to the unique ID identifying a manifest.
        case uniqueId
        /// Keys to the remote megaphone for a manifest. Only present if the
        /// manifest represents a remote megaphone.
        case remoteMegaphone
    }

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        let persistedUniqueId = try container.decode(String.self, forKey: .uniqueId)
        let persistedRemoteMegaphone = try container.decodeIfPresent(RemoteMegaphoneModel.self, forKey: .remoteMegaphone)

        self.init(uniqueId: persistedUniqueId, remoteMegaphone: persistedRemoteMegaphone)

        owsAssertDebug(uniqueId == persistedUniqueId, "Persisted unique ID does not match deserialized model!")
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(uniqueId, forKey: .uniqueId)

        if case .remoteMegaphone(let megaphone) = self {
            try container.encode(megaphone, forKey: .remoteMegaphone)
        }
    }
}

// MARK: - From persisted unique IDs

extension ExperienceUpgradeManifest {
    /// Instantiate an ``ExperienceUpgradeManifest`` from the unique ID of a
    /// persisted ``ExperienceUpgrade`` which does not have a manifest.
    ///
    /// This is only relevant for ``ExperienceUpgrade``s that were persisted
    /// before the ``ExperienceUpgradeManifest`` was added.
    static func makeLegacy(fromPersistedExperienceUpgradeUniqueId uniqueId: String) -> ExperienceUpgradeManifest {
        ExperienceUpgradeManifest(uniqueId: uniqueId, remoteMegaphone: nil)
    }

    private init(uniqueId: String, remoteMegaphone: RemoteMegaphoneModel?) {
        self = {
            switch uniqueId {
            case Self.introducingPins.uniqueId:
                return .introducingPins
            case Self.notificationPermissionReminder.uniqueId:
                return .notificationPermissionReminder
            case Self.createUsernameReminder.uniqueId:
                return .createUsernameReminder
            case Self.inactiveLinkedDeviceReminder.uniqueId:
                return .inactiveLinkedDeviceReminder
            case Self.pinReminder.uniqueId:
                return .pinReminder
            case Self.contactPermissionReminder.uniqueId:
                return .contactPermissionReminder
            default:
                break
            }

            if let megaphone = remoteMegaphone {
                return .remoteMegaphone(megaphone: megaphone)
            }

            return .unrecognized(uniqueId: uniqueId)
        }()
    }
}

// MARK: - Well-known, local manifests

extension ExperienceUpgradeManifest {
    /// Contains upgrade manifests that are well-known to the app.
    ///
    /// Examples of manifests _not_ listed here include upgrades that were once
    /// well-known, but have since been removed.
    static let wellKnownLocalUpgradeManifests: Set<ExperienceUpgradeManifest> = [
        .introducingPins,
        .notificationPermissionReminder,
        .createUsernameReminder,
        .inactiveLinkedDeviceReminder,
        .pinReminder,
        .contactPermissionReminder
    ]
}

// MARK: - Unique IDs

extension ExperienceUpgradeManifest {
    /// The "unique ID" of this upgrade. Stable, and may be used for persistence.
    var uniqueId: String {
        switch self {
        case .introducingPins:
            // For historical compatibility, this experience has a unique ID
            // that does not match the enum case.
            return "009"
        case .notificationPermissionReminder:
            return "notificationPermissionReminder"
        case .createUsernameReminder:
            return "createUsernameReminder"
        case .remoteMegaphone(let megaphone):
            return megaphone.id
        case .inactiveLinkedDeviceReminder:
            return "inactiveLinkedDeviceReminder"
        case .pinReminder:
            return "pinReminder"
        case .contactPermissionReminder:
            return "contactPermissionReminder"
        case .unrecognized(let uniqueId):
            return uniqueId
        }
    }
}

extension ExperienceUpgradeManifest: Equatable {
    public static func == (lhs: ExperienceUpgradeManifest, rhs: ExperienceUpgradeManifest) -> Bool {
        lhs.uniqueId == rhs.uniqueId
    }
}

extension ExperienceUpgradeManifest: Hashable {
    public func hash(into hasher: inout Hasher) {
        uniqueId.hash(into: &hasher)
    }
}

// MARK: - Importance order

protocol ExperienceUpgradeSortable {
    /// The relative "importance" of this upgrade - i.e., whether this or
    /// another which of multiple upgrade should be preferred for presentation.
    ///
    /// Lower values indicate higher importance. When comparing, ties in the
    /// primary index are broken by the secondary index. Equal primary and
    /// secondary indicies indicates equal importance.
    ///
    /// These values are not expected to remain stable.
    var importanceIndex: (primary: Int, secondary: Int) { get }
}

extension Sequence where Element: ExperienceUpgradeSortable {
    /// Returns the elements sorted by importance order - i.e., each
    /// element in the returned array should be preferred for presention over
    /// its subsequent elements.
    func sortedByImportance() -> [Element] {
        sorted { lhs, rhs in
            if lhs.importanceIndex.primary == rhs.importanceIndex.primary {
                return lhs.importanceIndex.secondary < rhs.importanceIndex.secondary
            }

            return lhs.importanceIndex.primary < rhs.importanceIndex.primary
        }
    }
}

extension ExperienceUpgradeManifest: ExperienceUpgradeSortable {
    var importanceIndex: (primary: Int, secondary: Int) {
        switch self {
        case .introducingPins:
            return (0, 0)
        case .notificationPermissionReminder:
            return (1, 0)
        case .createUsernameReminder:
            return (2, 0)
        case .remoteMegaphone(let megaphone):
            // Remote megaphone manifests use higher numbers to indicate higher
            // priority, so we should invert their priority here.
            return (3, -1 * megaphone.manifest.priority)
        case .inactiveLinkedDeviceReminder:
            return (4, 0)
        case .pinReminder:
            return (5, 0)
        case .contactPermissionReminder:
            return (6, 0)
        case .unrecognized:
            return (Int.max, Int.max)
        }
    }
}

extension ExperienceUpgrade: ExperienceUpgradeSortable {
    var importanceIndex: (primary: Int, secondary: Int) {
        manifest.importanceIndex
    }
}

// MARK: - Metadata

extension ExperienceUpgradeManifest {
    /// Whether this upgrade should not be shown to brand-new users.
    var skipForNewUsers: Bool {
        switch self {
        case
                .introducingPins,
                .createUsernameReminder,
                .remoteMegaphone,
                .inactiveLinkedDeviceReminder:
            return false
        case
                .notificationPermissionReminder,
                .pinReminder,
                .contactPermissionReminder,
                .unrecognized:
            return true
        }
    }

    /// Whether we should save state for this upgrade in an ``ExperienceUpgrade``
    /// record. If we track state for this upgrade using other components, we
    /// may not need to persist ``ExperienceUpgrade`` state.
    var shouldSave: Bool {
        switch self {
        case
                .introducingPins,
                .pinReminder,
                .unrecognized:
            return false
        case
                .notificationPermissionReminder,
                .createUsernameReminder,
                .inactiveLinkedDeviceReminder,
                .remoteMegaphone,
                .contactPermissionReminder:
            return true
        }
    }

    /// Whether we should mark this upgrade's corresponding ``ExperienceUpgrade``
    /// record as complete, if it exists. If we track state for this upgrade
    /// using other components, we may not need to mark the ``ExperienceUpgrade``
    /// as complete.
    var shouldComplete: Bool {
        switch self {
        case
                .introducingPins,
                .notificationPermissionReminder,
                .createUsernameReminder,
                .inactiveLinkedDeviceReminder,
                .pinReminder,
                .contactPermissionReminder,
                .unrecognized:
            return false
        case .remoteMegaphone:
            return true
        }
    }

    /// The interval after snoozing during which we should not show the upgrade.
    func snoozeDuration(forSnoozeCount snoozeCount: UInt) -> TimeInterval {
        guard snoozeCount > 0 else {
            owsFailDebug("Asking for snooze duration, but snooze count is zero!")
            return 0
        }

        switch self {
        case
                .introducingPins,
                .pinReminder:
            return 2 * kDayInterval
        case
                .notificationPermissionReminder,
                .inactiveLinkedDeviceReminder:
            return 3 * kDayInterval
        case .createUsernameReminder:
            // On snooze, never show again.
            return .infinity
        case .remoteMegaphone(let megaphone):
            let daysToSnooze: UInt = {
                // If we have snooze duration days as action data, get the
                // appropriate number of days from there based on our snooze
                // count. Otherwise, return a default value.

                let snoozeDurationDays: [UInt]? = {
                    if
                        let primaryActionData = megaphone.manifest.primaryActionData,
                        case .snoozeDurationDays(let days) = primaryActionData
                    {
                        return days
                    } else if
                        let secondaryActionData = megaphone.manifest.secondaryActionData,
                        case .snoozeDurationDays(let days) = secondaryActionData
                    {
                        return days
                    }

                    return nil
                }()

                if
                    let snoozeDurationDays = snoozeDurationDays,
                    let lastDurationDays = snoozeDurationDays.last
                {
                    // Safe to subtract from `snoozeCount`, since we checked for 0 above.
                    let snoozeDurationDaysIndex = snoozeCount - 1
                    return snoozeDurationDays[safe: Int(snoozeDurationDaysIndex)] ?? lastDurationDays
                }

                return 3
            }()

            return Double(daysToSnooze) * kDayInterval
        case .contactPermissionReminder:
            return 30 * kDayInterval
        case .unrecognized:
            return .infinity
        }
    }

    /// The number of days this upgrade should be shown, starting from the
    /// first time it is shown.
    var numberOfDaysToShowFor: Int {
        switch self {
        case
                .introducingPins,
                .notificationPermissionReminder,
                .createUsernameReminder,
                .inactiveLinkedDeviceReminder,
                .pinReminder,
                .contactPermissionReminder:
            return Int.max
        case .remoteMegaphone(let megaphone):
            return megaphone.manifest.showForNumberOfDays
        case .unrecognized:
            return 0
        }
    }

    /// The interval immediately after registration during which we should not
    /// show the upgrade.
    private var delayAfterRegistration: TimeInterval {
        switch self {
        case
                .notificationPermissionReminder,
                .createUsernameReminder,
                .inactiveLinkedDeviceReminder,
                .contactPermissionReminder:
            return kDayInterval
        case .introducingPins:
            return 2 * kHourInterval
        case .remoteMegaphone(let megaphone):
            guard let conditionalCheck = megaphone.manifest.conditionalCheck else {
                return 0
            }

            switch conditionalCheck {
            case .standardDonate:
                return 7 * kDayInterval
            case .internalUser:
                return 0
            case .unrecognized:
                return .infinity
            }
        case .pinReminder:
            return 8 * kHourInterval
        case .unrecognized:
            return .infinity
        }
    }

    /// The date after which the upgrade should no longer be shown.
    private var expirationDate: Date {
        switch self {
        case
                .introducingPins,
                .notificationPermissionReminder,
                .createUsernameReminder,
                .inactiveLinkedDeviceReminder,
                .pinReminder,
                .contactPermissionReminder:
            return Date.distantFuture
        case .remoteMegaphone(let megaphone):
            return Date(timeIntervalSince1970: TimeInterval(megaphone.manifest.dontShowAfter))
        case .unrecognized:
            return Date.distantPast
        }
    }

    /// Whether we should show this upgrade on linked devices.
    private var showOnLinkedDevices: Bool {
        switch self {
        case
                .introducingPins,
                .pinReminder,
                .inactiveLinkedDeviceReminder,
                .contactPermissionReminder,
                .unrecognized:
            return false
        case
                .notificationPermissionReminder,
                .createUsernameReminder:
            return true
        case
                .remoteMegaphone:
            // Controlled by conditional check
            return true
        }
    }
}

// MARK: - Should we show this upgrade

extension ExperienceUpgradeManifest {
    func shouldBeShown(transaction: SDSAnyReadTransaction) -> Bool {
        if
            let registrationDate = DependenciesBridge.shared.tsAccountManager.registrationDate(tx: transaction.asV2Read),
            Date().timeIntervalSince(registrationDate) < delayAfterRegistration
        {
            // We have not waited long enough after registration to show this
            // upgrade.
            return false
        }

        guard Date() < expirationDate else {
            // We should not show an expired upgrade.
            return false
        }

        guard showOnLinkedDevices || DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isRegisteredPrimaryDevice else {
            // We are a linked device, which should not show this upgrade.
            return false
        }

        return Self.checkPreconditions(specificTo: self, transaction: transaction)
    }

    private static func checkPreconditions(specificTo enumCase: ExperienceUpgradeManifest, transaction: SDSAnyReadTransaction) -> Bool {
        switch enumCase {
        case .introducingPins:
            return checkPreconditionsForIntroducingPins(transaction: transaction)
        case .notificationPermissionReminder:
            return checkPreconditionsForNotificationsPermissionsReminder()
        case .createUsernameReminder:
            return checkPreconditionsForCreateUsernameReminder(transaction: transaction)
        case .remoteMegaphone(let megaphone):
            return checkPreconditionsForRemoteMegaphone(megaphone, tx: transaction)
        case .inactiveLinkedDeviceReminder:
            return checkPreconditionsForInactiveLinkedDeviceReminder(tx: transaction)
        case .pinReminder:
            return checkPreconditionsForPinReminder(transaction: transaction)
        case .contactPermissionReminder:
            return checkPreconditionsForContactsPermissionReminder()
        case .unrecognized:
            return false
        }
    }

    // MARK: Local megaphone preconditions

    private static func checkPreconditionsForIntroducingPins(transaction: SDSAnyReadTransaction) -> Bool {
        // The PIN setup flow requires an internet connection and you to not already have a PIN
        if
            reachabilityManager.isReachable,
            DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isRegisteredPrimaryDevice,
            !DependenciesBridge.shared.svr.hasMasterKey(transaction: transaction.asV2Read)
        {
            return true
        }

        return false
    }

    private static func checkPreconditionsForNotificationsPermissionsReminder() -> Bool {
        let (promise, future) = Promise<Bool>.pending()

        DispatchQueue.global(qos: .userInitiated).async {
            UNUserNotificationCenter.current().getNotificationSettings { settings in
                future.resolve(settings.authorizationStatus == .authorized)
            }
        }

        DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
            guard promise.result == nil else { return }
            future.reject(OWSGenericError("timeout fetching notification permissions"))
        }

        do {
            return !(try promise.wait())
        } catch {
            Logger.warn("failed to query notification permission")
            return false
        }
    }

    private static func checkPreconditionsForCreateUsernameReminder(transaction: SDSAnyReadTransaction) -> Bool {
        guard
            DependenciesBridge.shared.localUsernameManager.usernameState(
                tx: transaction.asV2Read
            ).isExplicitlyUnset
        else {
            // If we have a username, do not show the reminder.
            return false
        }
        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        if tsAccountManager.phoneNumberDiscoverability(tx: transaction.asV2Read).orDefault.isDiscoverable {
            // If phone number discovery is enabled, do not prompt to create a
            // username.
            return false
        }

        /// The elapsed interval since the user disabled phone number
        /// discovery. Note that we need to invert the sign as this date will
        /// be in the past.
        let timeIntervalSinceDisabledDiscovery = DependenciesBridge.shared.tsAccountManager
            .lastSetIsDiscoverableByPhoneNumber(tx: transaction.asV2Read)
            .timeIntervalSinceNow * -1

        let requiredDelayAfterDisablingDiscovery: TimeInterval = 3 * kDayInterval

        return timeIntervalSinceDisabledDiscovery > requiredDelayAfterDisablingDiscovery
    }

    private static func checkPreconditionsForInactiveLinkedDeviceReminder(tx: SDSAnyReadTransaction) -> Bool {
        return DependenciesBridge.shared.inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: tx.asV2Read)
    }

    private static func checkPreconditionsForPinReminder(transaction: SDSAnyReadTransaction) -> Bool {
        return OWS2FAManager.shared.isDueForV2Reminder(transaction: transaction)
    }

    private static func checkPreconditionsForContactsPermissionReminder() -> Bool {
        return CNContactStore.authorizationStatus(for: .contacts) != .authorized
    }

    // MARK: Remote megaphone preconditions

    private static func checkPreconditionsForRemoteMegaphone(_ megaphone: RemoteMegaphoneModel, tx: SDSAnyReadTransaction) -> Bool {
        let minimumVersion = AppVersionNumber(megaphone.manifest.minAppVersion)
        let currentVersion = AppVersionNumber(AppVersionImpl.shared.currentAppVersion)
        guard currentVersion >= minimumVersion else {
            return false
        }

        guard Date().timeIntervalSince1970 > TimeInterval(megaphone.manifest.dontShowBefore) else {
            return false
        }

        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx.asV2Read) else {
            return false
        }

        guard RemoteConfig.isCountryCodeBucketEnabled(
            csvString: megaphone.manifest.countries,
            key: megaphone.manifest.id,
            csvDescription: "remoteMegaphoneCountries_\(megaphone.manifest.id)",
            localIdentifiers: localIdentifiers
        ) else {
            return false
        }

        guard validateRemoteMegaphone(
            conditionalCheck: megaphone.manifest.conditionalCheck, tx: tx
        ) else {
            return false
        }

        guard validateRemoteMegaphone(
            action: megaphone.manifest.primaryAction,
            withText: megaphone.translation.primaryActionText
        ) else {
            return false
        }

        guard validateRemoteMegaphone(
            action: megaphone.manifest.secondaryAction,
            withText: megaphone.translation.secondaryActionText
        ) else {
            return false
        }

        return true
    }

    private static func validateRemoteMegaphone(
        conditionalCheck: RemoteMegaphoneModel.Manifest.ConditionalCheck?,
        tx: SDSAnyReadTransaction
    ) -> Bool {
        guard let conditionalCheck else {
            // Having no conditional check is valid.
            return true
        }

        switch conditionalCheck {
        case .standardDonate:
            if
                let localProfileBadgeInfo = profileManager.localProfileBadgeInfo(),
                !localProfileBadgeInfo.isEmpty
            {
                // Fail the check if we currently have a badge.
                return false
            } else if
                DependenciesBridge.shared.receiptCredentialResultStore
                    .hasAnyPaymentsStillProcessing(tx: tx.asV2Read)
            {
                // Fail the check if we have any in-progress payments.
                return false
            }

            return true
        case .internalUser:
            // Show this megaphone to all internal users, even if they already
            // have a badge.
            return DebugFlags.internalMegaphoneEligible
        case .unrecognized(let conditionalId):
            Logger.warn("Found unrecognized conditional check with ID \(conditionalId), bailing.")
            return false
        }
    }

    private static func validateRemoteMegaphone(
        action: RemoteMegaphoneModel.Manifest.Action?,
        withText text: String?
    ) -> Bool {
        guard let action = action else {
            // Having no action is valid...
            return true
        }

        guard action.isRecognized else {
            // ...but we need to recognize it...
            Logger.warn("Found unrecognized action with ID \(action.actionId), bailing.")
            return false
        }

        guard text != nil else {
            // ...and have text for it.
            Logger.warn("Missing action text for action \(action.actionId)")
            return false
        }

        return true
    }
}

private extension RemoteMegaphoneModel.Manifest.Action {
    var isRecognized: Bool {
        if case .unrecognized = self {
            return false
        }

        return true
    }
}

private extension ReceiptCredentialResultStore {
    /// Do we have any payments that have been initiated, but are still
    /// in-progress?
    func hasAnyPaymentsStillProcessing(tx: DBReadTransaction) -> Bool {
        for requestErrorMode in Mode.allCases {
            if
                let requestError = getRequestError(errorMode: requestErrorMode, tx: tx),
                case .paymentStillProcessing = requestError.errorCode
            {
                return true
            }
        }

        return false
    }
}
