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

import Foundation

// Consider a DebouncedEvent with maxFrequencySeconds of 5.
//
// The event receives requests in the following order:
//
// Second 1:  A
// Second 2:  B
// Second 4:  C
// Second 8:  D
// Second 9:  E
// Second 20: F
public enum DebouncedEventMode {
    // "Ignore requests if the event has fired recently."
    //
    // .firstOnly will fire immediately if it has not fired in the last
    // maxFrequencySeconds; otherwise it will ignore the request.
    //
    // Second 1:  A -> Event Fires Immediately
    // Second 2:  B    (de-bounced)
    // Second 4:  C    (de-bounced)
    // Second 8:  D -> Event Fires Immediately
    // Second 9:  E    (de-bounced)
    // Second 20: F -> Event Fires Immediately
    //
    // .firstOnly is appropriate when we want to throttle frequent events and:
    //
    // * Timeliness of "first" request matters.
    // * "Last" requests can be safely discarded.
    // * We need to ensure the event never fires more often than once 1/maxFrequencySeconds.
    // * .firstOnly and .lastOnly will fire less often than .firstLast.
    case firstOnly

    // "Wait before firing in case requests come in quick succession."
    //
    // .lastOnly fires delay events and ignores "redundant" requests
    // that occur while a delayed event is already enqueued.
    //
    // Second  1: A -> Enqueues Delayed Fire
    // Second  2: B    (de-bounced)
    // Second  4: C    (de-bounced)
    // Second  6:      Event Fires for A, B, C (maxFrequencySeconds after first delayed request A)
    // Second  8: D -> Enqueues Delayed Fire
    // Second  9: E    (de-bounced)
    // Second 13:      Event Fires for D, E (maxFrequencySeconds after first delayed request D)
    // Second 20: F -> Enqueues Delayed Fire
    // Second 25:      Event Fires for F (maxFrequencySeconds after first delayed request F)
    //
    // .lastOnly is appropriate when we want to throttle frequent events and:
    //
    // * Timeliness of "first" request does _not_ matter.
    // * "Last" requests cannot be safely discarded (e.g. B does work that A would not do).
    // * We need to ensure the event never fires more often than once 1/maxFrequencySeconds.
    // * .firstOnly and .lastOnly will fire less often than .firstLast.
    case lastOnly

    // "Delay firing if we've fired recently, but always fire eventually."
    //
    // .firstLast will fire immediately if has not fired in the last
    // maxFrequencySeconds; otherwise it will delay until maxFrequencySeconds
    // has passed.
    //
    // Second  1: A -> Event Fires Immediately
    // Second  2: B -> Enqueues Delayed Fire
    // Second  4: C    (de-bounced)
    // Second  7:      Event Fires for A, B, C (maxFrequencySeconds after first delayed request B)
    // Second  8: D -> Enqueues Delayed Fire
    // Second  9: E    (de-bounced)
    // Second 13:      Event Fires for D, E (maxFrequencySeconds after first delayed request D)
    // Second 20: F -> Event Fires Immediately
    //
    // .firstLast is appropriate when we want to throttle frequent events and:
    //
    // * Timeliness of "first" request matters.
    // * "Last" requests cannot be safely discarded (e.g. B does work that A would not do).
    // * We need to ensure the event never fires more often than once 1/maxFrequencySeconds.
    // * firstLast will fire more often than .firstOnly and .lastOnly.
    case firstLast
}

// MARK: -

// Invokes a "notification" block in response to
// "notification requests", but no more often than every N
// seconds.
public protocol DebouncedEvent {
    func requestNotify()
}

// MARK: -

public class DebouncedEvents {

    @available(*, unavailable, message: "Do not instantiate this class.")
    private init() {}

    // A very small interval.
    public static let thetaInterval: Double = 0.001

    public static func build(mode: DebouncedEventMode,
                             maxFrequencySeconds: TimeInterval,
                             onQueue queueBehavior: DebouncedEventQueueBehavior,
                             notifyBlock: @escaping () -> Void) -> DebouncedEvent {
        switch mode {
        case .firstOnly:
            return DebouncedEventFirstOnly(maxFrequencySeconds: maxFrequencySeconds,
                                           onQueue: queueBehavior,
                                           notifyBlock: notifyBlock)
        case .lastOnly:
            return DebouncedEventLastOnly(maxFrequencySeconds: maxFrequencySeconds,
                                          onQueue: queueBehavior,
                                           notifyBlock: notifyBlock)
        case .firstLast:
            return DebouncedEventFirstLast(maxFrequencySeconds: maxFrequencySeconds,
                                           onQueue: queueBehavior,
                                           notifyBlock: notifyBlock)
        }
    }
}

// MARK: -

// See comments on DebouncedEventMode.
private class DebouncedEventFirstOnly: NSObject, DebouncedEvent {
    private var lastNotificationDate: Date?
    private let maxFrequencySeconds: TimeInterval
    private let notifyBlock: () -> Void
    private let queueBehavior: DebouncedEventQueueBehavior
    private let unfairLock = UnfairLock()

    public init(maxFrequencySeconds: TimeInterval,
                onQueue queueBehavior: DebouncedEventQueueBehavior,
                notifyBlock: @escaping () -> Void) {
        self.maxFrequencySeconds = maxFrequencySeconds
        self.queueBehavior = queueBehavior
        self.notifyBlock = notifyBlock
    }

    public func requestNotify() {
        let shouldFire: Bool = unfairLock.withLock {
            let shouldFire: Bool = {
                guard let lastNotificationDate = self.lastNotificationDate else {
                    return true
                }
                return abs(lastNotificationDate.timeIntervalSinceNow) >= self.maxFrequencySeconds
            }()
            guard shouldFire else {
                // We've notified recently; wait before notifying again.
                // We can ignore this request (de-bounce).
                return false
            }
            self.lastNotificationDate = Date()
            return true
        }

        if shouldFire {
            queueBehavior.fire(block: notifyBlock)
        }
    }
}

// MARK: -

// See comments on DebouncedEventMode.
private class DebouncedEventLastOnly: NSObject, DebouncedEvent {
    private var hasEnqueuedNotification = false
    private let maxFrequencySeconds: TimeInterval
    private let notifyBlock: () -> Void
    private let queueBehavior: DebouncedEventQueueBehavior
    private let unfairLock = UnfairLock()

    public init(maxFrequencySeconds: TimeInterval,
                onQueue queueBehavior: DebouncedEventQueueBehavior,
                notifyBlock: @escaping () -> Void) {
        self.maxFrequencySeconds = maxFrequencySeconds
        self.queueBehavior = queueBehavior
        self.notifyBlock = notifyBlock
    }

    public func requestNotify() {
        unfairLock.withLock {
            if hasEnqueuedNotification {
                // Delayed notification is already enqueued.
                // We can ignore this request (de-bounce).
                return
            }
            // We've notified recently; wait before notifying again.
            self.hasEnqueuedNotification = true
            if self.maxFrequencySeconds > DebouncedEvents.thetaInterval {
                DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + self.maxFrequencySeconds) { [weak self] in
                    self?.fireDelayedNotification()
                }
            } else {
                // For sufficiently small frequencies, dispatch without asyncAfter();
                // DispatchQueue.async() is much less vulnerable to delays.
                DispatchQueue.global().async { [weak self] in
                    self?.fireDelayedNotification()
                }
            }
        }
    }

    private func fireDelayedNotification() {
        unfairLock.withLock {
            owsAssertDebug(self.hasEnqueuedNotification)
            self.hasEnqueuedNotification = false
        }

        queueBehavior.fire(block: notifyBlock)
    }
}

// MARK: -

// See comments on DebouncedEventMode.
private class DebouncedEventFirstLast: NSObject, DebouncedEvent {
    private var hasEnqueuedNotification = false
    private var lastNotificationDate: Date?
    private let maxFrequencySeconds: TimeInterval
    private let notifyBlock: () -> Void
    private let queueBehavior: DebouncedEventQueueBehavior
    private let unfairLock = UnfairLock()

    public init(maxFrequencySeconds: TimeInterval,
                onQueue queueBehavior: DebouncedEventQueueBehavior,
                notifyBlock: @escaping () -> Void) {
        self.maxFrequencySeconds = maxFrequencySeconds
        self.queueBehavior = queueBehavior
        self.notifyBlock = notifyBlock
    }

    public func requestNotify() {
        let shouldFire: Bool = unfairLock.withLock {
            if hasEnqueuedNotification {
                // Delayed notification is already enqueued.
                // We can ignore this request (de-bounce).
                return false
            }
            if let lastNotificationDate = self.lastNotificationDate {
                let elapsed = abs(lastNotificationDate.timeIntervalSinceNow)
                let timerDelay = self.maxFrequencySeconds - elapsed
                if timerDelay > 0 {
                    // We've notified recently; wait before notifying again.
                    self.hasEnqueuedNotification = true
                    DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + timerDelay) { [weak self] in
                        self?.fireDelayedNotification()
                    }
                    return false
                }
            }

            // Notify immediately.
            self.lastNotificationDate = Date()
            return true
        }

        if shouldFire {
            queueBehavior.fire(block: notifyBlock)
        }
    }

    private func fireDelayedNotification() {
        unfairLock.withLock {
            owsAssertDebug(self.hasEnqueuedNotification)
            self.hasEnqueuedNotification = false
            self.lastNotificationDate = Date()
        }

        queueBehavior.fire(block: notifyBlock)
    }
}

// MARK: -

public enum DebouncedEventQueueBehavior {
    case syncOnRequestingQueue
    case asyncOnQueue(queue: DispatchQueue)

    func fire(block: @escaping () -> Void) {
        switch self {
        case .syncOnRequestingQueue:
            block()
        case .asyncOnQueue(let queue):
            queue.async {
                block()
            }
        }
    }
}
