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

import Foundation
import LibSignalClient

// Stickers
//
// * Stickers are either "installed" or not.
// * We can only send installed stickers.
// * When we receive a sticker, we download it like any other attachment...
//   ...unless we have it installed in which case we skip the download as
//   an optimization.
//
// Sticker Packs
//
// * Some "default" sticker packs ship in the app. See DefaultStickerPack.
//   Some "default" packs auto-install, others don't.
// * Other packs can be installed from "sticker pack shares" and "sticker pack URLs".
// * There are also "known" packs, e.g. packs the client knows about because
//   we received a sticker from that pack.
// * All of the above (default packs, packs installed from shares, known packs)
//   show up in the sticker management view.  Those that are installed are
//   shown as such; the others are shown as "available".
// * We download pack manifests & covers for "default" but not "known" packs.
//   Once we've download the manifest the pack is "saved" but not "installed".
// * We discard sticker and pack info once it is no longer in use.
@objc
public class StickerManager: NSObject {

    // MARK: - Constants

    @objc
    public static let packKeyLength: UInt = 32

    // MARK: - Notifications

    @objc
    public static let packsDidChange = Notification.Name("packsDidChange")
    @objc
    public static let stickersOrPacksDidChange = Notification.Name("stickersOrPacksDidChange")
    @objc
    public static let recentStickersDidChange = Notification.Name("recentStickersDidChange")

    private static var packsDidChangeEvent: DebouncedEvent {
        DebouncedEvents.build(mode: .firstLast,
                              maxFrequencySeconds: 0.5,
                              onQueue: .asyncOnQueue(queue: .global())) {
            NotificationCenter.default.postNotificationNameAsync(packsDidChange, object: nil)
            NotificationCenter.default.postNotificationNameAsync(stickersOrPacksDidChange, object: nil)
        }
    }

    private static var stickersDidChangeEvent: DebouncedEvent {
        DebouncedEvents.build(mode: .firstLast,
                              maxFrequencySeconds: 0.5,
                              onQueue: .asyncOnQueue(queue: .global())) {
            NotificationCenter.default.postNotificationNameAsync(stickersOrPacksDidChange, object: nil)
        }
    }

    // MARK: - Properties

    public static let store = SDSKeyValueStore(collection: "recentStickers")
    public static let emojiMapStore = SDSKeyValueStore(collection: "emojiMap")

    @objc
    public enum InstallMode: Int {
        case doNotInstall
        case install
        // For default packs that should be auto-installed,
        // we only want to install the first time we save them.
        // If a user subsequently uninstalls the pack, we want
        // to honor that.
        case installIfUnsaved
    }

    // MARK: - Initializers

    @objc
    public override init() {
        super.init()

        // Resume sticker and sticker pack downloads when app is ready.
        AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
            if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered {
                StickerManager.refreshContents()
            }
        }
    }

    // The sticker manager is responsible for downloading more than one kind
    // of content; those downloads can fail.  Therefore the sticker manager
    // retries those downloads, sometimes in response to user activity.
    @objc
    public class func refreshContents() {
        // Try to download the manifests for "default" sticker packs.
        tryToDownloadDefaultStickerPacks()

        // Try to download the stickers for "installed" sticker packs.
        ensureAllStickerDownloadsAsync()
    }

    // MARK: - Paths

    private class func ensureCacheDirUrl() -> URL {
        var url = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath())
        url.appendPathComponent("StickerManager")
        OWSFileSystem.ensureDirectoryExists(url.path)
        return url
    }
    private static let _cacheDirUrl = {
        return ensureCacheDirUrl()
    }()
    @objc
    public class func cacheDirUrl() -> URL {
        // In test we need to compute the sticker cache dir every time we use it,
        // since it will change from test to test.
        //
        // In production, we should only ensure it once because ensuring:
        //
        // * Makes sure it exists on disk which is expensive.
        // * Makes sure it is protected on disk which is expensive.
        // * Does some logging which really clutters up the logs once you have
        //   a bunch of stickers installed.
        if CurrentAppContext().isRunningTests {
            return ensureCacheDirUrl()
        } else {
            return _cacheDirUrl
        }
    }

    // MARK: - Sticker Packs

    @objc
    public class func allStickerPacks() -> [StickerPack] {
        var result = [StickerPack]()
        databaseStorage.read { (transaction) in
            result += allStickerPacks(transaction: transaction)
        }
        return result
    }

    @objc
    public class func allStickerPacks(transaction: SDSAnyReadTransaction) -> [StickerPack] {
        return StickerPack.anyFetchAll(transaction: transaction)
    }

    @objc
    public class func installedStickerPacks(transaction: SDSAnyReadTransaction) -> [StickerPack] {
        return allStickerPacks(transaction: transaction).filter {
            $0.isInstalled
        }
    }

    @objc
    public class func availableStickerPacks(transaction: SDSAnyReadTransaction) -> [StickerPack] {
        return allStickerPacks(transaction: transaction).filter {
            !$0.isInstalled
        }
    }

    @objc
    public class func isStickerPackSaved(stickerPackInfo: StickerPackInfo) -> Bool {
        var result = false
        databaseStorage.read { (transaction) in
            result = isStickerPackSaved(stickerPackInfo: stickerPackInfo,
                                        transaction: transaction)
        }
        return result
    }

    @objc
    public class func isStickerPackSaved(stickerPackInfo: StickerPackInfo,
                                         transaction: SDSAnyReadTransaction) -> Bool {
        return nil != fetchStickerPack(stickerPackInfo: stickerPackInfo, transaction: transaction)
    }

    @objc
    public class func uninstallStickerPack(stickerPackInfo: StickerPackInfo,
                                           wasLocallyInitiated: Bool,
                                           transaction: SDSAnyWriteTransaction) {
        uninstallStickerPack(stickerPackInfo: stickerPackInfo,
                             uninstallEverything: false,
                             wasLocallyInitiated: wasLocallyInitiated,
                             transaction: transaction)
    }

    private class func uninstallStickerPack(stickerPackInfo: StickerPackInfo,
                                            uninstallEverything: Bool,
                                            wasLocallyInitiated: Bool,
                                            transaction: SDSAnyWriteTransaction) {

        guard let stickerPack = fetchStickerPack(stickerPackInfo: stickerPackInfo, transaction: transaction) else {
            Logger.info("Skipping uninstall; not saved or installed.")
            return
        }

        let isDefaultStickerPack = DefaultStickerPack.isDefaultStickerPack(packId: stickerPackInfo.packId)
        let shouldRemove = uninstallEverything || !isDefaultStickerPack

        if shouldRemove {
            uninstallSticker(stickerInfo: stickerPack.coverInfo,
                             transaction: transaction)

            for stickerInfo in stickerPack.stickerInfos {
                if stickerInfo == stickerPack.coverInfo {
                    // Don't uninstall the cover for saved packs.
                    continue
                }
                uninstallSticker(stickerInfo: stickerInfo,
                                 transaction: transaction)
            }

            stickerPack.anyRemove(transaction: transaction)
        } else {
            stickerPack.update(withIsInstalled: false, transaction: transaction)
        }

        if wasLocallyInitiated {
            enqueueStickerSyncMessage(operationType: .remove,
                                      packs: [stickerPackInfo],
                                      transaction: transaction)
        }

        transaction.addAsyncCompletionOffMain {
            packsDidChangeEvent.requestNotify()
        }
    }

    @objc
    public class func installStickerPack(stickerPack: StickerPack,
                                         wasLocallyInitiated: Bool,
                                         transaction: SDSAnyWriteTransaction) {
        upsertStickerPack(stickerPack: stickerPack,
                          installMode: .install,
                          wasLocallyInitiated: wasLocallyInitiated,
                          transaction: transaction)
    }

    @objc
    public class func fetchStickerPack(stickerPackInfo: StickerPackInfo) -> StickerPack? {
        var result: StickerPack?
        databaseStorage.read { (transaction) in
            result = fetchStickerPack(stickerPackInfo: stickerPackInfo,
                                      transaction: transaction)
        }
        return result
    }

    @objc
    public class func fetchStickerPack(stickerPackInfo: StickerPackInfo,
                                       transaction: SDSAnyReadTransaction) -> StickerPack? {
        let uniqueId = StickerPack.uniqueId(for: stickerPackInfo)
        return StickerPack.anyFetch(uniqueId: uniqueId, transaction: transaction)
    }

    private class func tryToDownloadAndSaveStickerPack(stickerPackInfo: StickerPackInfo,
                                                       installMode: InstallMode,
                                                       wasLocallyInitiated: Bool) {
        tryToDownloadStickerPack(stickerPackInfo: stickerPackInfo)
            .done(on: DispatchQueue.global()) { (stickerPack) in
                self.upsertStickerPack(stickerPack: stickerPack,
                                       installMode: installMode,
                                       wasLocallyInitiated: wasLocallyInitiated)
        }.cauterize()
    }

    private let packOperationQueue: OperationQueue = {
        let operationQueue = OperationQueue()
        operationQueue.name = "StickerManager-Pack"
        operationQueue.maxConcurrentOperationCount = 3
        return operationQueue
    }()

    private func tryToDownloadStickerPack(stickerPackInfo: StickerPackInfo) -> Promise<StickerPack> {
        let (promise, future) = Promise<StickerPack>.pending()
        let operation = DownloadStickerPackOperation(stickerPackInfo: stickerPackInfo,
                                                     success: future.resolve,
                                                     failure: future.reject)
        packOperationQueue.addOperation(operation)
        return promise
    }

    // This method is public so that we can download "transient" (uninstalled) sticker packs.
    public class func tryToDownloadStickerPack(stickerPackInfo: StickerPackInfo) -> Promise<StickerPack> {
        return shared.tryToDownloadStickerPack(stickerPackInfo: stickerPackInfo)
    }

    private class func upsertStickerPack(stickerPack: StickerPack,
                                         installMode: InstallMode,
                                         wasLocallyInitiated: Bool) {

        databaseStorage.write { (transaction) in
            upsertStickerPack(stickerPack: stickerPack,
                              installMode: installMode,
                              wasLocallyInitiated: wasLocallyInitiated,
                              transaction: transaction)
        }
    }

    private class func upsertStickerPack(stickerPack stickerPackParam: StickerPack,
                                         installMode: InstallMode,
                                         wasLocallyInitiated: Bool,
                                         transaction: SDSAnyWriteTransaction) {

        // If we re-insert a sticker pack, make sure that it
        // has a new row id.
        let stickerPack = stickerPackParam.copy() as! StickerPack
        stickerPack.clearRowId()

        let oldCopy = fetchStickerPack(stickerPackInfo: stickerPack.info, transaction: transaction)
        let wasSaved = oldCopy != nil

        // Preserve old mutable state.
        if let oldCopy = oldCopy {
            stickerPack.update(withIsInstalled: oldCopy.isInstalled, transaction: transaction)
        } else {
            stickerPack.anyInsert(transaction: transaction)
        }

        if stickerPack.isInstalled, wasLocallyInitiated {
            enqueueStickerSyncMessage(operationType: .install,
                                      packs: [stickerPack.info],
                                      transaction: transaction)
        }

        // If the pack is already installed, make sure all stickers are installed.
        let promise: Promise<Void>
        if stickerPack.isInstalled {
            promise = installStickerPackContents(stickerPack: stickerPack, transaction: transaction)
        } else {
            switch installMode {
            case .doNotInstall:
                promise = .value(())
            case .install:
                promise = self.markSavedStickerPackAsInstalled(stickerPack: stickerPack,
                                                               wasLocallyInitiated: wasLocallyInitiated,
                                                               transaction: transaction)
            case .installIfUnsaved:
                if !wasSaved {
                    promise = self.markSavedStickerPackAsInstalled(stickerPack: stickerPack,
                                                                   wasLocallyInitiated: wasLocallyInitiated,
                                                                   transaction: transaction)
                } else {
                    promise = .value(())
                }
            }
        }

        transaction.addAsyncCompletionOffMain {
            _ = promise.ensure {
                packsDidChangeEvent.requestNotify()
            }
        }
    }

    private class func markSavedStickerPackAsInstalled(stickerPack: StickerPack,
                                                       wasLocallyInitiated: Bool,
                                                       transaction: SDSAnyWriteTransaction) -> Promise<Void> {

        if stickerPack.isInstalled {
            return .value(())
        }

        stickerPack.update(withIsInstalled: true, transaction: transaction)

        let promise = installStickerPackContents(stickerPack: stickerPack, transaction: transaction)

        if wasLocallyInitiated {
            enqueueStickerSyncMessage(operationType: .install,
                                      packs: [stickerPack.info],
                                      transaction: transaction)
        }
        return promise
    }

    private class func installStickerPackContents(stickerPack: StickerPack,
                                                  transaction: SDSAnyReadTransaction,
                                                  onlyInstallCover: Bool = false) -> Promise<Void> {
        // Note: It's safe to kick off downloads of stickers that are already installed.
        var fetches = [Promise<Void>]()
        var needsNotify = false

        // The cover.
        let coverFetch = firstly {
            tryToDownloadAndInstallSticker(
                stickerPack: stickerPack,
                item: stickerPack.cover,
                transaction: transaction)
        }.done { (shouldNotify: Bool) in
            if shouldNotify {
                stickersDidChangeEvent.requestNotify()
                needsNotify = false
            }
        }
        fetches.append(coverFetch)

        guard !onlyInstallCover else {
            return Promise.when(fulfilled: fetches)
        }

        // The stickers.
        for item in stickerPack.items {
            fetches.append(
                firstly {
                    tryToDownloadAndInstallSticker(
                        stickerPack: stickerPack,
                        item: item,
                        transaction: transaction)
                }.done { shouldNotify in
                    if shouldNotify, coverFetch.isSealed {
                        // We should only notify for changes once we've fetched the cover
                        // Some views will assume that an installed pack always has a cover
                        // and faildebug otherwise
                        stickersDidChangeEvent.requestNotify()
                        needsNotify = false
                    } else if shouldNotify {
                        needsNotify = true
                    }
                }
            )
        }
        return Promise.when(fulfilled: fetches).ensure {
            if needsNotify {
                stickersDidChangeEvent.requestNotify()
            }
        }
    }

    private class func tryToDownloadDefaultStickerPacks() {
        DispatchQueue.global().async {
            self.tryToDownloadStickerPacks(stickerPacks: DefaultStickerPack.packsToAutoInstall,
                                           installMode: .installIfUnsaved)
            self.tryToDownloadStickerPacks(stickerPacks: DefaultStickerPack.packsToNotAutoInstall,
                                           installMode: .doNotInstall)
        }
    }

    @objc
    public class func installedStickers(forStickerPack stickerPack: StickerPack,
                                        verifyExists: Bool) -> [StickerInfo] {
        var result = [StickerInfo]()
        databaseStorage.read { (transaction) in
            result = self.installedStickers(forStickerPack: stickerPack,
                                            verifyExists: verifyExists,
                                            transaction: transaction)
        }
        return result
    }

    @objc
    public class func installedStickers(forStickerPack stickerPack: StickerPack,
                                        verifyExists: Bool,
                                        transaction: SDSAnyReadTransaction) -> [StickerInfo] {

        var result = [StickerInfo]()
        for stickerInfo in stickerPack.stickerInfos {
            let uniqueId = InstalledSticker.uniqueId(for: stickerInfo)
            guard let installedSticker = InstalledSticker.anyFetch(uniqueId: uniqueId, transaction: transaction) else {
                    continue
            }
            if verifyExists,
                nil == self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: verifyExists) {
                continue
            }
            result.append(stickerInfo)
        }
        return result
    }

    @objc
    public class func isDefaultStickerPack(packId: Data) -> Bool {
        return DefaultStickerPack.isDefaultStickerPack(packId: packId)
    }

    @objc
    public class func isStickerPackInstalled(stickerPackInfo: StickerPackInfo) -> Bool {
        var result = false
        databaseStorage.read { (transaction) in
            result = isStickerPackInstalled(stickerPackInfo: stickerPackInfo,
                                            transaction: transaction)
        }
        return result
    }

    @objc
    public class func isStickerPackInstalled(stickerPackInfo: StickerPackInfo,
                                             transaction: SDSAnyReadTransaction) -> Bool {
        guard let pack = fetchStickerPack(stickerPackInfo: stickerPackInfo, transaction: transaction) else {
            return false
        }
        return pack.isInstalled
    }

    // MARK: - Stickers

    @objc
    public static func stickerType(forContentType contentType: String?) -> StickerType {
        StickerType.stickerType(forContentType: contentType)
    }

    public class func installedStickerMetadataWithSneakyTransaction(stickerInfo: StickerInfo) -> (any StickerMetadata)? {
        databaseStorage.read { transaction in
            self.installedStickerMetadata(stickerInfo: stickerInfo,
                                          transaction: transaction)
        }
    }

    public class func installedStickerMetadata(
        stickerInfo: StickerInfo,
        transaction: SDSAnyReadTransaction
    ) -> (any StickerMetadata)? {

        let uniqueId = InstalledSticker.uniqueId(for: stickerInfo)
        guard let installedSticker = InstalledSticker.anyFetch(uniqueId: uniqueId,
                                                               transaction: transaction) else {
                                                                return nil
        }

        return installedStickerMetadata(installedSticker: installedSticker,
                                        transaction: transaction)
    }

    public class func installedStickerMetadata(
        installedSticker: InstalledSticker,
        transaction: SDSAnyReadTransaction
    ) -> (any StickerMetadata)? {

        let stickerInfo = installedSticker.info
        let stickerType = StickerType.stickerType(forContentType: installedSticker.contentType)
        guard let stickerDataUrl = self.stickerDataUrl(stickerInfo: stickerInfo, stickerType: stickerType, verifyExists: true) else {
            return nil
        }
        return DecryptedStickerMetadata(
            stickerInfo: stickerInfo,
            stickerType: stickerType,
            stickerDataUrl: stickerDataUrl,
            emojiString: installedSticker.emojiString
        )
    }

    @objc
    public class func stickerDataUrlWithSneakyTransaction(stickerInfo: StickerInfo, verifyExists: Bool) -> URL? {
        guard let installedSticker = fetchInstalledStickerWithSneakyTransaction(stickerInfo: stickerInfo) else {
            return nil
        }
        return self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: verifyExists)
    }

    private class func stickerDataUrl(stickerInfo: StickerInfo,
                                      contentType: String?,
                                      verifyExists: Bool) -> URL? {
        let stickerType = StickerType.stickerType(forContentType: contentType)
        return stickerDataUrl(stickerInfo: stickerInfo, stickerType: stickerType, verifyExists: verifyExists)
    }

    public class func stickerDataUrl(forInstalledSticker installedSticker: InstalledSticker, verifyExists: Bool) -> URL? {
        let stickerInfo = installedSticker.info
        let stickerType = StickerType.stickerType(forContentType: installedSticker.contentType)
        return stickerDataUrl(stickerInfo: stickerInfo, stickerType: stickerType, verifyExists: verifyExists)
    }

    private class func stickerDataUrl(stickerInfo: StickerInfo, stickerType: StickerType, verifyExists: Bool) -> URL? {
        let uniqueId = InstalledSticker.uniqueId(for: stickerInfo)
        var url = cacheDirUrl()
        // Not all stickers are .webp.
        url.appendPathComponent("\(uniqueId).\(stickerType.fileExtension)")
        if verifyExists && !OWSFileSystem.fileOrFolderExists(url: url) {
            return nil
        }
        return url
    }

    @objc
    public class func filePathsForAllInstalledStickers(transaction: SDSAnyReadTransaction) -> [String] {
        var filePaths = [String]()
        InstalledSticker.anyEnumerate(transaction: transaction) { (installedSticker, _) in
            if let stickerDataUrl = stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: false) {
                filePaths.append(stickerDataUrl.path)
            }
        }
        return filePaths
    }

    @objc
    public class func isStickerInstalled(stickerInfo: StickerInfo) -> Bool {
        var result = false
        databaseStorage.read { (transaction) in
            result = isStickerInstalled(stickerInfo: stickerInfo,
                                        transaction: transaction)
        }
        return result
    }

    @objc
    public class func isStickerInstalled(stickerInfo: StickerInfo,
                                         transaction: SDSAnyReadTransaction) -> Bool {
        let uniqueId = InstalledSticker.uniqueId(for: stickerInfo)
        // We use anyFetch(...) instead of anyExists(...) to
        // leverage the model cache.
        return InstalledSticker.anyFetch(uniqueId: uniqueId, transaction: transaction) != nil
    }

    internal typealias CleanupCompletion = () -> Void

    internal class func uninstallSticker(stickerInfo: StickerInfo,
                                         transaction: SDSAnyWriteTransaction) {

        guard let installedSticker = fetchInstalledSticker(stickerInfo: stickerInfo, transaction: transaction) else {
            Logger.info("Skipping uninstall; not installed.")
            return
        }

        installedSticker.anyRemove(transaction: transaction)

        removeFromRecentStickers(stickerInfo, transaction: transaction)

        removeStickerFromEmojiMap(installedSticker, tx: transaction)

        guard let stickerDataUrl = self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: false) else {
            owsFailDebug("Could not generate sticker data URL.")
            return
        }

        // Cleans up the sticker data on disk. We want to do these deletions
        // after the transaction is complete so that other transactions aren't
        // blocked.
        transaction.addSyncCompletion {
            DispatchQueue.sharedUtility.async {
                do {
                    try OWSFileSystem.deleteFileIfExists(url: stickerDataUrl)
                } catch {
                    owsFailDebug("Error: \(error)")
                }
            }
        }

        // No need to post stickersOrPacksDidChange; caller will do that.
    }

    @objc
    public class func fetchInstalledStickerWithSneakyTransaction(stickerInfo: StickerInfo) -> InstalledSticker? {
        databaseStorage.read { transaction in
            self.fetchInstalledSticker(stickerInfo: stickerInfo, transaction: transaction)
        }
    }

    @objc
    public class func fetchInstalledSticker(stickerInfo: StickerInfo,
                                            transaction: SDSAnyReadTransaction) -> InstalledSticker? {

        let uniqueId = InstalledSticker.uniqueId(for: stickerInfo)

        return InstalledSticker.anyFetch(uniqueId: uniqueId, transaction: transaction)
    }

    @objc
    public class func installSticker(stickerInfo: StickerInfo,
                                     stickerUrl stickerTemporaryUrl: URL,
                                     contentType: String?,
                                     emojiString: String?) -> Bool {
        guard nil == fetchInstalledStickerWithSneakyTransaction(stickerInfo: stickerInfo) else {
            // Sticker already installed, skip.
            return false
        }

        guard OWSFileSystem.fileOrFolderExists(url: stickerTemporaryUrl) else {
            owsFailDebug("Missing sticker file.")
            return false
        }

        let installedSticker = InstalledSticker(info: stickerInfo,
                                                contentType: contentType,
                                                emojiString: emojiString)

        guard let stickerDataUrl = self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: false) else {
            owsFailDebug("Could not generate sticker data URL.")
            return false
        }

        do {
            // We copy the file rather than move it as some transient data sources
            // could be trying to access the temp file at this point. Stickers are
            // generally very small so this shouldn't be a big perf hit.
            try OWSFileSystem.deleteFileIfExists(url: stickerDataUrl)
            try FileManager.default.copyItem(at: stickerTemporaryUrl, to: stickerDataUrl)
        } catch let error as NSError {
            if OWSFileSystem.fileOrFolderExists(url: stickerDataUrl) {
                // Races can occur; ignore and proceed.
                Logger.warn("File already exists: \(error)")
            } else {
                owsFailDebug("File write failed: \(error)")
                return false
            }
        }

        return databaseStorage.write { (transaction) -> Bool in
            guard nil == fetchInstalledSticker(stickerInfo: stickerInfo, transaction: transaction) else {
                // RACE: sticker has already been installed between now and when we last checked.
                //
                // Initially we check for a stickers presence with a read transaction, to avoid opening
                // an unnecessary write transaction. However, it's possible a race has occurred and the
                // sticker has since been installed, in which case there's nothing more for us to do.
                return false
            }

            installedSticker.anyInsert(transaction: transaction)

            #if DEBUG
            guard self.isStickerInstalled(stickerInfo: stickerInfo, transaction: transaction) else {
                owsFailDebug("Skipping redundant sticker install.")
                return false
            }
            if !OWSFileSystem.fileOrFolderExists(url: stickerDataUrl) {
                owsFailDebug("Missing sticker data for installed sticker.")
                return false
            }
            #endif

            self.addStickerToEmojiMap(installedSticker, tx: transaction)
            return true
        }
    }

    private class func tryToDownloadAndInstallSticker(stickerPack: StickerPack,
                                                      item: StickerPackItem,
                                                      transaction: SDSAnyReadTransaction) -> Promise<Bool> {
        let stickerInfo: StickerInfo = item.stickerInfo(with: stickerPack)
        let emojiString = item.emojiString

        guard !self.isStickerInstalled(stickerInfo: stickerInfo, transaction: transaction) else {
            // Skipping redundant sticker install.
            return .value(false)
        }

        return firstly {
            tryToDownloadSticker(stickerPack: stickerPack, stickerInfo: stickerInfo)
        }.map(on: DispatchQueue.global()) { stickerUrl in
            self.installSticker(stickerInfo: stickerInfo,
                                stickerUrl: stickerUrl,
                                contentType: item.contentType,
                                emojiString: emojiString)
        }
    }

    private struct StickerDownload {
        let promise: Promise<URL>
        let future: Future<URL>

        init() {
            let (promise, future) = Promise<URL>.pending()
            self.promise = promise
            self.future = future
        }
    }
    private let stickerDownloadQueue = DispatchQueue(label: "org.signal.sticker-manager.download")
    // This property should only be accessed on stickerDownloadQueue.
    private var stickerDownloadMap = [String: StickerDownload]()
    private let stickerOperationQueue: OperationQueue = {
        let operationQueue = OperationQueue()
        operationQueue.name = "StickerManager"
        operationQueue.maxConcurrentOperationCount = 4
        return operationQueue
    }()

    private func tryToDownloadSticker(stickerPack: StickerPack,
                                      stickerInfo: StickerInfo) -> Promise<URL> {
        if let stickerUrl = DownloadStickerOperation.cachedUrl(for: stickerInfo) {
            return Promise.value(stickerUrl)
        }
        return stickerDownloadQueue.sync { () -> Promise<URL> in
            if let stickerDownload = stickerDownloadMap[stickerInfo.asKey()] {
                return stickerDownload.promise
            }

            let stickerDownload = StickerDownload()
            stickerDownloadMap[stickerInfo.asKey()] = stickerDownload

            let operation = DownloadStickerOperation(stickerInfo: stickerInfo,
                                                     success: { [weak self] data in
                                                        guard let self = self else {
                                                            return
                                                        }
                                                        _ = self.stickerDownloadQueue.sync {
                                                            self.stickerDownloadMap.removeValue(forKey: stickerInfo.asKey())
                                                        }
                                                        stickerDownload.future.resolve(data)
                },
                                                     failure: { [weak self] error in
                                                        guard let self = self else {
                                                            return
                                                        }
                                                        _ = self.stickerDownloadQueue.sync {
                                                            self.stickerDownloadMap.removeValue(forKey: stickerInfo.asKey())
                                                        }
                                                        stickerDownload.future.reject(error)
            })
            self.stickerOperationQueue.addOperation(operation)
            return stickerDownload.promise
        }
    }

    // This method is public so that we can download "transient" (uninstalled) stickers.
    public class func tryToDownloadSticker(stickerPack: StickerPack,
                                           stickerInfo: StickerInfo) -> Promise<URL> {
        shared.tryToDownloadSticker(stickerPack: stickerPack, stickerInfo: stickerInfo)
    }

    // MARK: - Emoji

    @objc
    public class func allEmoji(inEmojiString emojiString: String?) -> [String] {
        guard let emojiString = emojiString else {
            return []
        }

        return emojiString.map(String.init).filter {
            $0.containsOnlyEmoji
        }
    }

    @objc
    public class func firstEmoji(inEmojiString emojiString: String?) -> String? {
        return allEmoji(inEmojiString: emojiString).first
    }

    private class func addStickerToEmojiMap(_ installedSticker: InstalledSticker, tx: SDSAnyWriteTransaction) {
        guard let emojiString = installedSticker.emojiString else {
            return
        }
        let stickerId = installedSticker.uniqueId
        for emoji in allEmoji(inEmojiString: emojiString) {
            emojiMapStore.prependToOrderedUniqueArray(key: emoji, value: stickerId, tx: tx)
        }
        shared.clearSuggestedStickersCache()
    }

    private class func removeStickerFromEmojiMap(_ installedSticker: InstalledSticker, tx: SDSAnyWriteTransaction) {
        guard let emojiString = installedSticker.emojiString else {
            return
        }
        let stickerId = installedSticker.uniqueId
        for emoji in allEmoji(inEmojiString: emojiString) {
            emojiMapStore.removeFromOrderedUniqueArray(key: emoji, value: stickerId, tx: tx)
        }
        shared.clearSuggestedStickersCache()
    }

    private static let cacheQueue = DispatchQueue(label: "org.signal.sticker-manager.cache")
    // This cache should only be accessed on cacheQueue.
    private var suggestedStickersCache = LRUCache<String, [InstalledSticker]>(maxSize: 5)

    // We clear the cache every time we install or uninstall a sticker.
    private func clearSuggestedStickersCache() {
        StickerManager.cacheQueue.sync {
            self.suggestedStickersCache.removeAllObjects()
        }
    }

    @objc
    public func suggestedStickers(forTextInput textInput: String) -> [InstalledSticker] {
        return StickerManager.cacheQueue.sync {
            if let suggestions = suggestedStickersCache.object(forKey: textInput) {
                return suggestions
            }

            let suggestions = StickerManager.suggestedStickers(forTextInput: textInput)
            suggestedStickersCache.setObject(suggestions, forKey: textInput)
            return suggestions
        }
    }

    internal class func suggestedStickers(forTextInput textInput: String) -> [InstalledSticker] {
        var result = [InstalledSticker]()
        databaseStorage.read { (transaction) in
            result = self.suggestedStickers(forTextInput: textInput, transaction: transaction)
        }
        return result
    }

    internal class func suggestedStickers(forTextInput textInput: String,
                                          transaction: SDSAnyReadTransaction) -> [InstalledSticker] {
        guard let emoji = firstEmoji(inEmojiString: textInput) else {
            // Text input contains no emoji.
            return []
        }
        guard emoji == textInput else {
            // Text input contains more than just a single emoji.
            return []
        }
        let stickerIds = emojiMapStore.orderedUniqueArray(forKey: emoji, tx: transaction)
        return stickerIds.compactMap { (stickerId) in
            guard let installedSticker = InstalledSticker.anyFetch(uniqueId: stickerId, transaction: transaction) else {
                owsFailDebug("Missing installed sticker.")
                return nil
            }
            return installedSticker
        }
    }

    // MARK: - Known Sticker Packs

    @objc
    public class func addKnownStickerInfo(_ stickerInfo: StickerInfo,
                                          transaction: SDSAnyWriteTransaction) {
        let packInfo = stickerInfo.packInfo
        let uniqueId = KnownStickerPack.uniqueId(for: packInfo)
        if let existing = KnownStickerPack.anyFetch(uniqueId: uniqueId, transaction: transaction) {
            let pack = existing
            pack.anyUpdate(transaction: transaction) { pack in
                pack.referenceCount += 1
            }
        } else {
            let pack = KnownStickerPack(info: packInfo)
            pack.referenceCount += 1
            pack.anyInsert(transaction: transaction)
        }
    }

    @objc
    public class func removeKnownStickerInfo(_ stickerInfo: StickerInfo,
                                             transaction: SDSAnyWriteTransaction) {
        let packInfo = stickerInfo.packInfo
        let uniqueId = KnownStickerPack.uniqueId(for: packInfo)
        guard let pack = KnownStickerPack.anyFetch(uniqueId: uniqueId, transaction: transaction) else {
            owsFailDebug("Missing known sticker pack.")
            return
        }
        if pack.referenceCount <= 1 {
            pack.anyRemove(transaction: transaction)
        } else {
            pack.anyUpdate(transaction: transaction) { (pack) in
                pack.referenceCount -= 1
            }
        }
    }

    @objc
    public class func allKnownStickerPackInfos(transaction: SDSAnyReadTransaction) -> [StickerPackInfo] {
        return allKnownStickerPacks(transaction: transaction).map { $0.info }
    }

    @objc
    public class func allKnownStickerPacks(transaction: SDSAnyReadTransaction) -> [KnownStickerPack] {
        var result = [KnownStickerPack]()
        KnownStickerPack.anyEnumerate(transaction: transaction) { (knownStickerPack, _) in
            result.append(knownStickerPack)
        }
        return result
    }

    private class func tryToDownloadStickerPacks(stickerPacks: [StickerPackInfo],
                                                 installMode: InstallMode) {

        var stickerPacksToDownload = [StickerPackInfo]()
        StickerManager.databaseStorage.read { (transaction) in
            for stickerPackInfo in stickerPacks {
                if !StickerManager.isStickerPackSaved(stickerPackInfo: stickerPackInfo, transaction: transaction) {
                    stickerPacksToDownload.append(stickerPackInfo)
                }
            }
        }

        for stickerPackInfo in stickerPacksToDownload {
            StickerManager.tryToDownloadAndSaveStickerPack(stickerPackInfo: stickerPackInfo,
                                                           installMode: installMode,
                                                           wasLocallyInitiated: true)
        }
    }

    // MARK: - Missing Packs

    // Track which sticker packs downloads have failed permanently.
    private static var missingStickerPacks = Set<String>()

    @objc
    public class func markStickerPackAsMissing(stickerPackInfo: StickerPackInfo) {
        DispatchQueue.main.async {
            self.missingStickerPacks.insert(stickerPackInfo.asKey)
        }
    }

    @objc
    public class func isStickerPackMissing(stickerPackInfo: StickerPackInfo) -> Bool {
        AssertIsOnMainThread()

        return missingStickerPacks.contains(stickerPackInfo.asKey)
    }

    // MARK: - Recents

    private static var kRecentStickersKey: String { "recentStickers" }
    private static let kRecentStickersMaxCount: Int = 25

    public class func stickerWasSent(_ stickerInfo: StickerInfo, transaction: SDSAnyWriteTransaction) {
        guard isStickerInstalled(stickerInfo: stickerInfo, transaction: transaction) else {
            return
        }
        store.prependToOrderedUniqueArray(
            key: kRecentStickersKey,
            value: stickerInfo.asKey(),
            maxCount: kRecentStickersMaxCount,
            tx: transaction
        )
        NotificationCenter.default.postNotificationNameAsync(recentStickersDidChange, object: nil)
    }

    private class func removeFromRecentStickers(
        _ stickerInfo: StickerInfo,
        transaction: SDSAnyWriteTransaction
    ) {
        store.removeFromOrderedUniqueArray(key: kRecentStickersKey, value: stickerInfo.asKey(), tx: transaction)
        NotificationCenter.default.postNotificationNameAsync(recentStickersDidChange, object: nil)
    }

    // Returned in descending order of recency.
    //
    // Only returns installed stickers.
    @objc
    public class func recentStickers() -> [StickerInfo] {
        var result = [StickerInfo]()
        databaseStorage.read { (transaction) in
            result = self.recentStickers(transaction: transaction)
        }
        return result
    }

    // Returned in descending order of recency.
    //
    // Only returns installed stickers.
    private class func recentStickers(transaction: SDSAnyReadTransaction) -> [StickerInfo] {
        let keys = store.orderedUniqueArray(forKey: kRecentStickersKey, tx: transaction)
        var result = [StickerInfo]()
        for key in keys {
            guard let installedSticker = InstalledSticker.anyFetch(uniqueId: key, transaction: transaction) else {
                owsFailDebug("Couldn't fetch sticker")
                continue
            }
            guard nil != self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: true) else {
                owsFailDebug("Missing sticker data for installed sticker.")
                continue
            }
            result.append(installedSticker.info)
        }
        return result
    }

    // MARK: - Misc.

    // URL might be a sticker or a sticker pack manifest.
    public class func decrypt(at url: URL, packKey: Data) throws -> URL {
        guard packKey.count == packKeyLength else {
            owsFailDebug("Invalid pack key length: \(packKey.count).")
            throw StickerError.invalidInput
        }
        let stickerKeyInfo = "Sticker Pack"
        let stickerKeyLength = 64
        let stickerKey = try stickerKeyInfo.utf8.withContiguousStorageIfAvailable {
            try hkdf(outputLength: stickerKeyLength, inputKeyMaterial: packKey, salt: [], info: $0)
        }!

        let temporaryDecryptedFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
        try Cryptography.decryptFile(at: url, metadata: .init(key: Data(stickerKey)), output: temporaryDecryptedFile)
        return temporaryDecryptedFile
    }

    private class func ensureAllStickerDownloadsAsync() {
        DispatchQueue.global().async {
            databaseStorage.read { (transaction) in
                for stickerPack in self.allStickerPacks(transaction: transaction) {
                    ensureDownloads(forStickerPack: stickerPack, transaction: transaction)
                }
            }
        }
    }

    public class func ensureDownloadsAsync(forStickerPack stickerPack: StickerPack) -> Promise<Void> {
        let (promise, future) = Promise<Void>.pending()
        DispatchQueue.global().async {
            databaseStorage.read { (transaction) in
                firstly {
                    ensureDownloads(forStickerPack: stickerPack, transaction: transaction)
                }.done {
                    future.resolve()
                }.catch { (error) in
                    future.reject(error)
                }
            }
        }
        return promise
    }

    @discardableResult
    private class func ensureDownloads(forStickerPack stickerPack: StickerPack, transaction: SDSAnyReadTransaction) -> Promise<Void> {
        // TODO: As an optimization, we could flag packs as "complete" if we know all
        // of their stickers are installed.

        // Install the covers for available sticker packs.
        let onlyInstallCover = !stickerPack.isInstalled
        return installStickerPackContents(stickerPack: stickerPack, transaction: transaction, onlyInstallCover: onlyInstallCover)
    }

    @objc
    public static func hasOrphanedData(tx: SDSAnyReadTransaction) -> Bool {
        let (packsToRemove, stickersToRemove) = fetchOrphanedPacksAndStickers(tx: tx)
        return !packsToRemove.isEmpty || !stickersToRemove.isEmpty
    }

    @objc
    public static func cleanUpOrphanedData(tx: SDSAnyWriteTransaction) {
        // We re-compute the orphaned packs within the write transaction. It's
        // possible that a new pack was being saved during the read transaction,
        // and it's possible that an orphaned pack is no longer orphaned. Most of
        // the time we don't expect to have any orphans, so the 2x performance
        // overhead shouldn't matter. If we don't have any orphans by the time we
        // reach this point, the code will do the correct thing (which is nothing).
        let (packsToRemove, stickersToRemove) = fetchOrphanedPacksAndStickers(tx: tx)

        for stickerPack in packsToRemove {
            owsFailDebug("Removing orphan pack")
            stickerPack.anyRemove(transaction: tx)
        }

        if !stickersToRemove.isEmpty {
            Logger.warn("Removing \(stickersToRemove.count) orphan stickers.")
        }
        for sticker in stickersToRemove {
            self.uninstallSticker(stickerInfo: sticker.info, transaction: tx)
        }
    }

    private static func fetchOrphanedPacksAndStickers(tx: SDSAnyReadTransaction) -> ([StickerPack], [InstalledSticker]) {
        var stickerPacks = [String: StickerPack]()
        var packsToRemove = [StickerPack]()

        for stickerPack in StickerPack.anyFetchAll(transaction: tx) {
            if stickerPack.isInstalled || self.isDefaultStickerPack(packId: stickerPack.info.packId) {
                stickerPacks[stickerPack.info.asKey] = stickerPack
            } else {
                packsToRemove.append(stickerPack)
            }
        }

        var stickersToRemove = [InstalledSticker]()
        InstalledSticker.anyEnumerate(transaction: tx) { (sticker, _) in
            let shouldKeepSticker: Bool = {
                guard let stickerPack = stickerPacks[sticker.info.packInfo.asKey] else {
                    return false
                }
                return stickerPack.isInstalled || stickerPack.coverInfo == sticker.info
            }()
            if !shouldKeepSticker {
                stickersToRemove.append(sticker)
            }
        }

        return (packsToRemove, stickersToRemove)
    }

    // MARK: - Sync Messages

    private class func enqueueStickerSyncMessage(operationType: StickerPackOperationType,
                                                 packs: [StickerPackInfo],
                                                 transaction: SDSAnyWriteTransaction) {
        guard DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isRegistered else {
            return
        }
        guard let thread = TSContactThread.getOrCreateLocalThread(transaction: transaction) else {
            owsFailDebug("Missing thread.")
            return
        }
        let message = OWSStickerPackSyncMessage(
            thread: thread,
            packs: packs,
            operationType: operationType,
            transaction: transaction
        )
        // The sync message doesn't include the actual stickers on it as attachments.
        let preparedMessage = PreparedOutgoingMessage.preprepared(
            transientMessageWithoutAttachments: message
        )
        SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction)
    }

    @objc
    public class func syncAllInstalledPacks(transaction: SDSAnyWriteTransaction) {
        guard DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isRegistered else {
            return
        }

        let stickerPackInfos = installedStickerPacks(transaction: transaction).map { $0.info }
        guard stickerPackInfos.count > 0 else {
            return
        }

        enqueueStickerSyncMessage(operationType: .install,
                                  packs: stickerPackInfos,
                                  transaction: transaction)
    }

    public class func processIncomingStickerPackOperation(_ proto: SSKProtoSyncMessageStickerPackOperation,
                                                          transaction: SDSAnyWriteTransaction) {
        let packID: Data = proto.packID
        let packKey: Data = proto.packKey
        guard let stickerPackInfo = StickerPackInfo.parse(packId: packID, packKey: packKey) else {
            owsFailDebug("Invalid pack info.")
            return
        }

        guard let type = proto.type else {
            owsFailDebug("Pack operation missing type.")
            return
        }
        switch type {
        case .install:
            tryToDownloadAndSaveStickerPack(stickerPackInfo: stickerPackInfo,
                                            installMode: .install,
                                            wasLocallyInitiated: false)
        case .remove:
            uninstallStickerPack(stickerPackInfo: stickerPackInfo, wasLocallyInitiated: false, transaction: transaction)
        @unknown default:
            owsFailDebug("Unknown type.")
            return
        }
    }

    // MARK: - Debug

    // This is only intended for use while debugging.
    #if DEBUG
    @objc
    public class func uninstallAllStickerPacks() {
        databaseStorage.write { (transaction) in
            let stickerPacks = installedStickerPacks(transaction: transaction)
            for stickerPack in stickerPacks {
                uninstallStickerPack(stickerPackInfo: stickerPack.info,
                                     wasLocallyInitiated: true,
                                     transaction: transaction)
            }
        }
    }

    @objc
    public class func removeAllStickerPacks() {
        databaseStorage.write { (transaction) in
            let stickerPacks = allStickerPacks(transaction: transaction)
            for stickerPack in stickerPacks {
                uninstallStickerPack(stickerPackInfo: stickerPack.info,
                                     uninstallEverything: true,
                                     wasLocallyInitiated: true,
                                     transaction: transaction)
                stickerPack.anyRemove(transaction: transaction)
            }
        }
    }

    @objc
    public class func tryToInstallAllAvailableStickerPacks() {
        databaseStorage.write { (transaction) in
            for stickerPack in self.availableStickerPacks(transaction: transaction) {
                self.installStickerPack(stickerPack: stickerPack, wasLocallyInitiated: true, transaction: transaction)
            }
        }

        tryToDownloadDefaultStickerPacks()
    }
    #endif
}

// MARK: -

// These methods are used to maintain a "string set":
// A set (no duplicates) of strings stored as a list.
// As a bonus, the set is stored in order of descending recency.
private extension SDSKeyValueStore {
    func prependToOrderedUniqueArray(
        key: String,
        value: String,
        maxCount: Int? = nil,
        tx: SDSAnyWriteTransaction
    ) {
        // Prepend value to ensure descending order of recency.
        var orderedArray = [value]
        if let storedValue = getObject(forKey: key, transaction: tx) as? [String] {
            orderedArray += storedValue.filter { $0 != value }
        }
        if let maxCount {
            orderedArray = Array(orderedArray.prefix(maxCount))
        }
        setObject(orderedArray, key: key, transaction: tx)
    }

    func removeFromOrderedUniqueArray(key: String, value: String, tx: SDSAnyWriteTransaction) {
        var orderedArray = [String]()
        if let storedValue = getObject(forKey: key, transaction: tx) as? [String] {
            guard storedValue.contains(value) else {
                // No work to do.
                return
            }
            orderedArray += storedValue.filter { $0 != value }
        }
        setObject(orderedArray, key: key, transaction: tx)
    }

    func orderedUniqueArray(forKey key: String, tx: SDSAnyReadTransaction) -> [String] {
        guard let object = getObject(forKey: key, transaction: tx) else {
            return []
        }
        guard let orderedArray = object as? [String] else {
            owsFailDebug("Value has unexpected type \(type(of: object)).")
            return []
        }
        return orderedArray
    }
}
