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

import SignalCoreKit

/// Represents an "inactive" linked device.
/// - SeeAlso ``InactiveLinkedDeviceFinder``
public struct InactiveLinkedDevice: Equatable {
    public let displayName: String
    public let expirationDate: Date

    init(displayName: String, expirationDate: Date) {
        self.displayName = displayName
        self.expirationDate = expirationDate
    }
}

/// Responsible for finding "inactive" linked devices, or those who have not
/// come online in a long time and are at risk of expiring (and being unlinked)
/// soon.
public protocol InactiveLinkedDeviceFinder {
    /// At most once per day, re-fetch our linked device state.
    ///
    /// - Note
    /// This method does nothing if invoked from a linked device.
    func refreshLinkedDeviceStateIfNecessary() async

    /// Find the user's "least active" linked device, i.e. their linked device
    /// that was last seen longest ago.
    ///
    /// - Note
    /// A linked device's expiration time (when it is unlinked) is a function of
    /// its "last seen" time. Consequently, the least-active linked device
    /// returned by this method will also be the next-expiring device.
    ///
    /// - Note
    /// This method returns `nil` if the current device is a linked device.
    func findLeastActiveLinkedDevice(tx: DBReadTransaction) -> InactiveLinkedDevice?

    /// Permanently disables this and any future inactive linked device finders.
    ///
    /// - Important
    /// This is irreversible for the life of this app install. Use with care.
    func permanentlyDisableFinders(tx: DBWriteTransaction)

    #if TESTABLE_BUILD
    func reenablePermanentlyDisabledFinders(tx: DBWriteTransaction)
    #endif
}

public extension InactiveLinkedDeviceFinder {
    /// Whether the user has an "inactive" linked device.
    func hasInactiveLinkedDevice(tx: DBReadTransaction) -> Bool {
        return findLeastActiveLinkedDevice(tx: tx) != nil
    }
}

class InactiveLinkedDeviceFinderImpl: InactiveLinkedDeviceFinder {
    private enum Constants {
        /// How long we should wait between device state refreshes.
        static let intervalForDeviceRefresh: TimeInterval = kDayInterval

        /// How long before a device expires it is considered "inactive".
        static let intervalBeforeExpirationConsideredInactive = kWeekInterval
    }

    private enum StoreKeys {
        static let lastRefreshedDate: String = "lastRefreshedDate"
        static let isPermanentlyDisabled: String = "isPermanentlyDisabled"
    }

    private let dateProvider: DateProvider
    private let db: DB
    private let deviceNameDecrypter: Shims.OWSDeviceNameDecrypter
    private let deviceStore: OWSDeviceStore
    private let devicesService: Shims.OWSDevicesService
    private let kvStore: KeyValueStore
    private let remoteConfig: Shims.RemoteConfig
    private let tsAccountManager: TSAccountManager

    private var intervalForDeviceExpiration: TimeInterval {
        return remoteConfig.linkedDeviceLifespan()
    }

    private var intervalForDeviceInactivity: TimeInterval {
        return max(0, remoteConfig.linkedDeviceLifespan() - Constants.intervalBeforeExpirationConsideredInactive)
    }

    private let logger = PrefixedLogger(prefix: "InactiveLinkedDeviceFinder")

    init(
        dateProvider: @escaping DateProvider,
        db: DB,
        deviceNameDecrypter: Shims.OWSDeviceNameDecrypter,
        deviceStore: OWSDeviceStore,
        devicesService: Shims.OWSDevicesService,
        kvStoreFactory: KeyValueStoreFactory,
        remoteConfig: Shims.RemoteConfig,
        tsAccountManager: TSAccountManager
    ) {
        self.dateProvider = dateProvider
        self.db = db
        self.deviceNameDecrypter = deviceNameDecrypter
        self.deviceStore = deviceStore
        self.devicesService = devicesService
        self.kvStore = kvStoreFactory.keyValueStore(collection: "InactiveLinkedDeviceFinderImpl")
        self.remoteConfig = remoteConfig
        self.tsAccountManager = tsAccountManager
    }

    func refreshLinkedDeviceStateIfNecessary() async {
        struct SkipRefreshError: Error {}

        let shouldSkip = db.read { tx -> Bool in
            if kvStore.hasValue(StoreKeys.isPermanentlyDisabled, transaction: tx) {
                // Finder is permanently disabled, no need to refresh.
                return true
            }

            if !tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice {
                // Only refresh state on primaries.
                return true
            }

            if
                let lastRefreshedDate = kvStore.getDate(StoreKeys.lastRefreshedDate, transaction: tx),
                lastRefreshedDate.addingTimeInterval(Constants.intervalForDeviceRefresh) > dateProvider()
            {
                // Checked less than a day ago, skip.
                return true
            }

            return false
        }

        if shouldSkip {
            return
        }

        do {
            try await devicesService.refreshDevices()

            await db.awaitableWrite { tx in
                self.kvStore.setDate(
                    self.dateProvider(),
                    key: StoreKeys.lastRefreshedDate,
                    transaction: tx
                )
            }
        } catch {
            logger.warn("Failed to refresh devices!")
        }
    }

    func findLeastActiveLinkedDevice(tx: DBReadTransaction) -> InactiveLinkedDevice? {
        if !kvStore.hasValue(StoreKeys.lastRefreshedDate, transaction: tx) {
            /// Short-circuit if we've never refreshed device state. Otherwise,
            /// we'll be querying stale data from who knows when.
            return nil
        }

        if kvStore.hasValue(StoreKeys.isPermanentlyDisabled, transaction: tx) {
            // Short-circuit if we've been disabled.
            return nil
        }

        if !tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice {
            // Only report linked devices if we are a primary.
            return nil
        }

        let allInactiveLinkedDevices = deviceStore.fetchAll(tx: tx)
            .filter { !$0.isPrimaryDevice }
            .filter { device in
                // Only keep devices whose inactivity date has passed.
                let inactivityDate = device.lastSeenAt.addingTimeInterval(intervalForDeviceInactivity)
                return inactivityDate < dateProvider()
            }

        return allInactiveLinkedDevices
            .min { lhs, rhs in
                return lhs.lastSeenAt < rhs.lastSeenAt
            }
            .map { device -> InactiveLinkedDevice in
                return InactiveLinkedDevice(
                    displayName: deviceNameDecrypter.decryptName(
                        device: device, tx: tx
                    ),
                    expirationDate: device.lastSeenAt.addingTimeInterval(
                        intervalForDeviceExpiration
                    )
                )
            }
    }

    func permanentlyDisableFinders(tx: DBWriteTransaction) {
        kvStore.setBool(true, key: StoreKeys.isPermanentlyDisabled, transaction: tx)
    }

    #if TESTABLE_BUILD
    func reenablePermanentlyDisabledFinders(tx: DBWriteTransaction) {
        kvStore.removeValue(forKey: StoreKeys.isPermanentlyDisabled, transaction: tx)
    }
    #endif
}

// MARK: - Shims

extension InactiveLinkedDeviceFinderImpl {
    enum Shims {
        typealias OWSDeviceNameDecrypter = InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Shim
        typealias OWSDevicesService = InactiveLinkedDeviceFinderImpl_OWSDevicesService_Shim
        typealias RemoteConfig = InactiveLinkedDeviceFinderImpl_RemoteConfig_Shim
    }

    enum Wrappers {
        typealias OWSDeviceNameDecrypter = InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Wrapper
        typealias OWSDevicesService = InactiveLinkedDeviceFinderImpl_OWSDevicesService_Wrapper
        typealias RemoteConfig = InactiveLinkedDeviceFinderImpl_RemoteConfig_Wrapper
    }
}

// MARK: OWSDeviceNameDecrypter

protocol InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Shim {
    func decryptName(device: OWSDevice, tx: DBReadTransaction) -> String
}

class InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Wrapper: InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Shim {
    private let identityManager: OWSIdentityManager

    init(identityManager: OWSIdentityManager) {
        self.identityManager = identityManager
    }

    func decryptName(device: OWSDevice, tx: DBReadTransaction) -> String {
        return device.displayName(
            identityManager: identityManager, tx: tx
        )
    }
}

// MARK: OWSDevicesService

protocol InactiveLinkedDeviceFinderImpl_OWSDevicesService_Shim {
    func refreshDevices() async throws
}

class InactiveLinkedDeviceFinderImpl_OWSDevicesService_Wrapper: InactiveLinkedDeviceFinderImpl_OWSDevicesService_Shim {
    init() {}

    func refreshDevices() async throws {
        try await OWSDevicesService.refreshDevices().awaitable()
    }
}

// MARK: RemoteConfig

/// We need to shim around ``RemoteConfig`` because accessing it transitively
/// accesses the global ``RemoteConfigManager`` in ``Dependencies``, which is
/// not set up when ``InactiveLinkedDeviceFinderImpl`` is initialized.
protocol InactiveLinkedDeviceFinderImpl_RemoteConfig_Shim {
    func linkedDeviceLifespan() -> TimeInterval
}

class InactiveLinkedDeviceFinderImpl_RemoteConfig_Wrapper: InactiveLinkedDeviceFinderImpl_RemoteConfig_Shim {
    func linkedDeviceLifespan() -> TimeInterval {
        return RemoteConfig.linkedDeviceLifespan
    }
}
