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

import SignalCoreKit

/// Responsible for cleaning up expired ``DeletedCallRecord``s.
///
/// ``DeletedCallRecord``s are only intended to exist on-disk for as long as is
/// necessary to silently swallow events related to a call the user deleted.
/// Once that period has concluded – i.e., the ``DeletedCallRecord`` has
/// "expired" – this manager is responsible for deleting the
/// ``DeletedCallRecord``.
///
/// This manager works by finding all expired ``DeletedCallRecord``s and
/// deleting them immediately, then scheduling another cleanup (deletion) pass
/// for the expiration time of the next-expiring record, if there is one.
///
/// The manager will add a minimum delay between deletion passes to accommodate
/// multiple ``DeletedCallRecord``s with very close deletion times. For example,
/// if we have records with deletion times 10ms, 20ms, 30ms, and 40ms from now,
/// we don't want to schedule a pass for each one. Rather, if our minimum delay
/// is 1s, we'll schedule a single pass for `max(10ms, 1s)` from now, which will
/// then delete all the records.
///
/// - Note
/// "Expiration time" for a ``DeletedCallRecord`` is a function of its
/// ``DeletedCallRecord/deletedAtTimestamp`` property. Consequently, the phrase
/// "multiple records with the same expiration time" is equivalent to "multiple
/// records with the same `deletedAtTimestamp`.
public protocol DeletedCallRecordCleanupManager {
    /// Start cleaning up deleted call records, as necessary.
    ///
    /// - Important
    /// This method must be safe to call anytime, including while asynchronous
    /// cleanup is already scheduled.
    func startCleanupIfNecessary()
}

// MARK: -

final class DeletedCallRecordCleanupManagerImpl: DeletedCallRecordCleanupManager {
    typealias TimeIntervalProvider = () -> TimeInterval

    private struct CleanupLock {
        private let lock = AtomicBool(false, lock: .init())

        func get() -> Bool {
            return lock.get()
        }

        func tryTake() -> Bool {
            return lock.tryToSetFlag()
        }

        func release() {
            let isUnlocked = lock.tryToClearFlag()
            owsAssert(isUnlocked)
        }
    }

    private let minimumSecondsBetweenCleanupPasses: TimeIntervalProvider
    private let dateProvider: DateProvider
    private let db: DB
    private let deletedCallRecordStore: DeletedCallRecordStore
    private let schedulers: Schedulers

    private let cleanupLock = CleanupLock()

    /// Creates a cleanup manager.
    ///
    /// - Parameter minimumSecondsBetweenCleanupPasses
    /// Returns the minimum time interval between cleanup passes, in seconds.
    init(
        minimumSecondsBetweenCleanupPasses: @escaping TimeIntervalProvider = { 1 },
        dateProvider: @escaping DateProvider,
        db: DB,
        deletedCallRecordStore: DeletedCallRecordStore,
        schedulers: Schedulers
    ) {
        self.minimumSecondsBetweenCleanupPasses = minimumSecondsBetweenCleanupPasses
        self.dateProvider = dateProvider
        self.db = db
        self.deletedCallRecordStore = deletedCallRecordStore
        self.schedulers = schedulers
    }

    func startCleanupIfNecessary() {
        guard cleanupLock.tryTake() else {
            return
        }

        schedulers.global().async {
            guard let notYetExpiredRecord = self.cleanUpAlreadyExpiredRecords() else {
                self.cleanupLock.release()
                return
            }

            self.scheduleCleanup(beginningWith: notYetExpiredRecord)
        }
    }

    /// Cleans up any deleted call records that have already expired.
    ///
    /// - Returns
    /// The not-yet-expired deleted call record that will next expire.
    private func cleanUpAlreadyExpiredRecords() -> DeletedCallRecord? {
       var firstRecordNotDeleted: DeletedCallRecord?

        _ = TimeGatedBatch.processAll(db: db) { tx in
            if
                let nextExpiringRecord = deletedCallRecordStore
                    .nextDeletedRecord(tx: tx)
            {
                if nextExpiringRecord.isExpired(dateProvider: dateProvider) {
                    /// If the next-expiring record should be deleted in this
                    /// batch, delete it and report to ``TimeGatedBatch`` that
                    /// we did. This method will be called by ``TimeGatedBatch``
                    /// repeatedly in the same transaction (time allowing), so
                    /// only deleting a single element per call is fine.

                    deletedCallRecordStore.delete(
                        expiredDeletedCallRecord: nextExpiringRecord,
                        tx: tx
                    )

                    return 1
                }

                /// If the next expiring record shouldn't be deleted in this
                /// batch, cache it and bail out so we can return it to the
                /// caller.
                firstRecordNotDeleted = nextExpiringRecord
            }

            return 0
        }

        return firstRecordNotDeleted
    }

    /// Schedules a cleanup pass for the expiration time of the given record.
    ///
    /// If there are any not-yet-expired records after this cleanup pass
    /// finishes, this this method recursively calls itself with the *next*
    /// next-expiring record.
    ///
    /// - Important
    /// The caller must have taken `cleanupLock` before calling this method.
    /// method. This method releases `cleanupLock` when there are no more
    /// records scheduled for cleanup.
    private func scheduleCleanup(
        beginningWith recordToScheduleExpiration: DeletedCallRecord
    ) {
        owsAssert(cleanupLock.get())

        let secondsUntilNextCleanupPass: TimeInterval = max(
            recordToScheduleExpiration.secondsUntilExpiration(
                dateProvider: dateProvider
            ),
            minimumSecondsBetweenCleanupPasses()
        )

        schedulers.global().asyncAfter(deadline: .now() + secondsUntilNextCleanupPass) {
            let nextRecordToSchedule = self.cleanUpAlreadyExpiredRecords()

            if let nextRecordToSchedule {
                self.scheduleCleanup(beginningWith: nextRecordToSchedule)
            } else {
                self.cleanupLock.release()
            }
        }
    }
}

private extension DeletedCallRecord {
    private enum Constants {
        static let deletedRecordLifetime: TimeInterval = 8 * kHourInterval
    }

    private var expirationDate: Date {
        return Date(millisecondsSince1970: deletedAtTimestamp)
            .addingTimeInterval(Constants.deletedRecordLifetime)
    }

    /// The number of seconds until this record expires.
    func secondsUntilExpiration(dateProvider: DateProvider) -> TimeInterval {
        return max(0, dateProvider().distance(to: expirationDate))
    }

    /// Whether this record is already expired.
    func isExpired(dateProvider: DateProvider) -> Bool {
        return secondsUntilExpiration(dateProvider: dateProvider) == 0
    }
}
