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

import Foundation
import UIKit

public enum BackgroundTaskState {
    case success
    case couldNotStart
    case expired
}

/// This class makes it easier and safer to use background tasks.
///
/// * Uses RAII (Resource Acquisition Is Initialization) pattern.
/// * Ensures completion block is called exactly once and on main thread,
///   to facilitate handling "background task timed out" case, for example.
/// * Ensures we properly handle the "background task could not be created"
///   case.
///
/// Usage:
///
/// * Use init to start a background task.
/// * Retain a strong reference to the OWSBackgroundTask during the "work".
/// * Clear all references to the OWSBackgroundTask when the work is done,
///   if possible.
public class OWSBackgroundTask {
    private let label: String

    // TODO: Replace all of the below ivars with Mutex in Swift 6.
    private let lock = NSRecursiveLock()

    /// This property should only be accessed while holding `lock`.
    private var taskId: UInt64?

    /// This property should only be accessed while holding `lock`.
    private var completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?

    // This could be a default param but objc is in the way for now.
    public convenience init(label: String) {
        self.init(label: label, completionBlock: nil)
    }

    /// - Parameters:
    ///   - completionBlock: will be called exactly once on the main thread
    public init(label: String, completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?) {
        owsAssertDebug(!label.isEmpty)

        self.label = label
        self.completionBlock = completionBlock

        start()
    }

    deinit {
        end()
    }

    private func start() {
        taskId = OWSBackgroundTaskManager.shared.addTaskWithExpirationBlock { [weak self] in
            DispatchMainThreadSafe {
                guard let self else {
                    return
                }

                // Make a local copy of completionBlock to ensure that it is called
                // exactly once.
                var completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?
                self.lock.withLock {
                    guard self.taskId != nil else {
                        return
                    }
                    Logger.info("\(self.label) background task expired.")
                    self.taskId = nil

                    completionBlock = self.completionBlock
                    self.completionBlock = nil
                }
                if let completionBlock {
                    completionBlock(.expired)
                }
            }
        }

        // If a background task could not be begun, call the completion block.
        if taskId == nil {
            // Make a local copy of completionBlock to ensure that it is called
            // exactly once.
            var completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?
            lock.withLock {
                completionBlock = self.completionBlock
                self.completionBlock = nil
            }
            if let completionBlock {
                DispatchMainThreadSafe {
                    completionBlock(.couldNotStart)
                }
            }
        }
    }

    public func end() {
        // Make a local copy of this state, since this method is called by `dealloc`.
        var completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?

        lock.withLock {
            guard let taskId = self.taskId else {
                return
            }
            OWSBackgroundTaskManager.shared.removeTask(taskId)
            self.taskId = nil

            completionBlock = self.completionBlock
            self.completionBlock = nil
        }

        // endBackgroundTask must be called on the main thread.
        DispatchMainThreadSafe {
            if let completionBlock {
                completionBlock(.success)
            }
        }
    }
}

public class OWSBackgroundTaskManager {
    public static let shared = {
        if Thread.isMainThread {
            return OWSBackgroundTaskManager()
        } else {
            return DispatchQueue.main.sync {
                OWSBackgroundTaskManager()
            }
        }
    }()

    /// We use this timer to provide continuity and reduce churn,
    /// so that if one OWSBackgroundTask ends right before another
    /// begins, we use a single uninterrupted background task that
    /// spans their lifetimes.
    ///
    /// This property should only be accessed on the main thread.
    private var continuityTimer: Timer?

    // TODO: Replace all of the below ivars with Mutex in Swift 6.
    private let lock = NSRecursiveLock()

    /// This property should only be accessed while holding `lock`.
    private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid

    /// This property should only be accessed while holding `lock`.
    private var expirationMap: [UInt64: @Sendable () -> Void] = [:]

    /// This property should only be accessed while holding `lock`.
    private var idCounter: UInt64 = 0

    /// Note that this flag is set a little early in "will resign active".
    ///
    /// This property should only be accessed while holding `lock`.
    private var isAppActive: Bool = CurrentAppContext().isMainAppAndActive

    /// This property should only be accessed while holding `lock`.
    private var isMaintainingContinuity: Bool = false

    /// This property should only be accessed while holding `lock`.
    private var didBecomeActiveObserver: (any NSObjectProtocol)?

    /// This property should only be accessed while holding `lock`.
    private var willResignActiveObserver: (any NSObjectProtocol)?

    /// Due to `isAppActive` should only be executed from the main thread.
    private init() {
        AssertIsOnMainThread()
        SwiftSingletons.register(self)
    }

    deinit {
        lock.withLock {
            self.clearObservers()
        }
    }

    /// Do not call unless `lock` is held.
    private func clearObservers() {
        if let didBecomeActiveObserver = self.didBecomeActiveObserver {
            NotificationCenter.default.removeObserver(didBecomeActiveObserver, name: .OWSApplicationDidBecomeActive, object: nil)
            self.didBecomeActiveObserver = nil
        }
        if let willResignActiveObserver = self.willResignActiveObserver {
            NotificationCenter.default.removeObserver(willResignActiveObserver, name: .OWSApplicationWillResignActive, object: nil)
            self.willResignActiveObserver = nil
        }
    }

    public func observeNotifications() {
        guard CurrentAppContext().isMainApp else {
            return
        }

        lock.withLock {
            self.clearObservers()

            self.didBecomeActiveObserver = NotificationCenter.default.addObserver(forName: .OWSApplicationDidBecomeActive, object: nil, queue: nil) { [weak self] _ in
                AssertIsOnMainThread()

                guard let self else {
                    return
                }

                self.lock.withLock {
                    self.isAppActive = true
                    self.ensureBackgroundTaskState()
                }
            }
            self.willResignActiveObserver = NotificationCenter.default.addObserver(forName: .OWSApplicationWillResignActive, object: nil, queue: nil) { [weak self] _ in
                AssertIsOnMainThread()

                guard let self else {
                    return
                }

                self.lock.withLock {
                    self.isAppActive = false
                    self.ensureBackgroundTaskState()
                }
            }
        }
    }

    /// This method registers a new task with this manager.  We only bother
    /// requesting a background task from iOS if the app is inactive (or about
    /// to become inactive), so this will often not start a background task.
    ///
    /// - Returns nil if adding this task _should have_ started a
    /// background task, but the background task couldn't be begun.
    /// In that case expirationBlock will not be called.
    fileprivate func addTaskWithExpirationBlock(_ expirationBlock: @escaping @Sendable () -> Void) -> UInt64? {
        lock.withLock {
            self.idCounter += 1
            let taskId = self.idCounter
            self.expirationMap[taskId] = expirationBlock

            guard self.ensureBackgroundTaskState() else {
                self.expirationMap.removeValue(forKey: taskId)
                return nil
            }
            return taskId
        }
    }

    fileprivate func removeTask(_ taskId: UInt64) {
        var shouldMaintainContinuity = false

        lock.withLock {
            let removedBlock = self.expirationMap.removeValue(forKey: taskId)
            owsAssertDebug(removedBlock != nil)

            // If expirationMap has just been emptied, try to maintain continuity.
            // See: scheduleCleanupOfContinuity().
            shouldMaintainContinuity = self.expirationMap.isEmpty
            if shouldMaintainContinuity {
                self.isMaintainingContinuity = true
            }

            self.ensureBackgroundTaskState()
        }

        if shouldMaintainContinuity {
            self.scheduleCleanupOfContinuity()
        }
    }

    /// Begins or end a background task if necessary.
    @discardableResult
    private func ensureBackgroundTaskState() -> Bool {
        guard CurrentAppContext().isMainApp else {
            // We can't create background tasks in the SAE, but pretend that we succeeded.
            return true
        }

        return lock.withLock {
            // We only want to have a background task if we are:
            // a) "not active" AND
            // b1) there is one or more active instance of OWSBackgroundTask OR...
            // b2) ...there _was_ an active instance recently.
            let shouldHaveBackgroundTask = (!self.isAppActive && (!self.expirationMap.isEmpty || self.isMaintainingContinuity))
            let hasBackgroundTask = self.backgroundTaskId != .invalid

            if shouldHaveBackgroundTask == hasBackgroundTask {
                // Current state is correct.
                return true
            } else if shouldHaveBackgroundTask {
                Logger.info("Starting background task.")
                return self.startBackgroundTask()
            } else {
                // Need to end background task.
                Logger.info("Ending background task.")
                CurrentAppContext().endBackgroundTask(self.backgroundTaskId)
                self.backgroundTaskId = .invalid
                return true
            }
        }
    }

    /// - Returns false if the background task cannot be begun.
    private func startBackgroundTask() -> Bool {
        owsAssertDebug(CurrentAppContext().isMainApp)

        return lock.withLock {
            owsAssertDebug(self.backgroundTaskId == .invalid)

            self.backgroundTaskId = CurrentAppContext().beginBackgroundTask {
                // Supposedly [UIApplication beginBackgroundTaskWithExpirationHandler]'s handler
                // will always be called on the main thread, but in practice we've observed
                // otherwise.
                //
                // See:
                // https://developer.apple.com/documentation/uikit/uiapplication/1623031-beginbackgroundtaskwithexpiratio)
                AssertIsOnMainThread()

                self.backgroundTaskExpired()
            }

            // If the background task could not begin, return NO to indicate that.
            guard self.backgroundTaskId != .invalid else {
                Logger.warn("background task could not be started.")
                return false
            }
            return true
        }
    }

    private func backgroundTaskExpired() {
        let (backgroundTaskId, expirationMap) = lock.withLock {
            let backgroundTaskId = self.backgroundTaskId
            self.backgroundTaskId = .invalid

            let expirationMap = self.expirationMap
            self.expirationMap.removeAll()

            return (backgroundTaskId, expirationMap)
        }

        // Supposedly [UIApplication beginBackgroundTaskWithExpirationHandler]'s handler
        // will always be called on the main thread, but in practice we've observed
        // otherwise.  OWSBackgroundTask's API guarantees that completionBlock will
        // always be called on the main thread, so we use DispatchSyncMainThreadSafe()
        // to ensure that.  We thereby ensure that we don't end the background task
        // until all of the completion blocks have completed.
        DispatchSyncMainThreadSafe {
            for expirationBlock in expirationMap.values {
                expirationBlock()
            }
            if backgroundTaskId != .invalid {
                // Apparently we need to "end" even expired background tasks.
                CurrentAppContext().endBackgroundTask(backgroundTaskId)
            }
        }
    }

    private func scheduleCleanupOfContinuity() {
        // This timer will ensure that we keep the background task active (if necessary)
        // for an extra fraction of a second to provide continuity between tasks.
        // This makes it easier and safer to use background tasks, since most code
        // should be able to ensure background tasks by "narrowly" wrapping
        // their core logic with a OWSBackgroundTask and not worrying about "hand off"
        // between OWSBackgroundTasks.
        DispatchQueue.main.async {
            self.continuityTimer?.invalidate()
            self.continuityTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false, block: { [weak self] timer in
                AssertIsOnMainThread()

                guard let self else {
                    timer.invalidate()
                    return
                }

                self.continuityTimer?.invalidate()
                self.continuityTimer = nil

                self.lock.withLock {
                    self.isMaintainingContinuity = false
                    self.ensureBackgroundTaskState()
                }
            })
        }
    }
}
