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

import Foundation
import GRDB
import SignalCoreKit

// TODO: Support stubbing out OWSFileSystem more generally. This is a temporary
// SystemStoryManager-scoped wrapper to avoid refactoring all usages of OWSFileSystem.
@objc
public class OnboardingStoryManagerFilesystem: NSObject {

    public class func fileOrFolderExists(url: URL) -> Bool {
        return OWSFileSystem.fileOrFolderExists(url: url)
    }

    public class func fileSize(of url: URL) -> NSNumber? {
        return OWSFileSystem.fileSize(of: url)
    }

    public class func deleteFile(url: URL) throws {
        try OWSFileSystem.deleteFile(url: url)
    }

    public class func moveFile(from fromUrl: URL, to toUrl: URL) throws {
        try OWSFileSystem.moveFile(from: fromUrl, to: toUrl)
    }

    public class func isValidImage(at url: URL, mimeType: String?) -> Bool {
        return Data.ows_isValidImage(at: url, mimeType: mimeType)
    }
}

// TODO: Support stubbing out StoryMessage init more generally.
@objc
public class OnboardingStoryManagerStoryMessageFactory: NSObject {

    public class func createFromSystemAuthor(
        attachmentSource: TSResourceDataSource,
        timestamp: UInt64,
        transaction: SDSAnyWriteTransaction
    ) throws -> StoryMessage {
        return try StoryMessage.createFromSystemAuthor(
            attachmentSource: attachmentSource,
            timestamp: timestamp,
            transaction: transaction
        )
    }

    public class func validateAttachmentContents(
        dataSource: DataSource,
        mimeType: String
    ) throws -> TSResourceDataSource {
        return try DependenciesBridge.shared.tsResourceContentValidator.validateContents(
            dataSource: dataSource,
            shouldConsume: true,
            mimeType: mimeType,
            sourceFilename: nil,
            caption: nil,
            renderingFlag: .default
        )
    }
}

@objc
public class SystemStoryManager: NSObject, Dependencies, SystemStoryManagerProtocol {

    private let fileSystem: OnboardingStoryManagerFilesystem.Type
    private let schedulers: Schedulers
    private let storyMessageFactory: OnboardingStoryManagerStoryMessageFactory.Type

    private let kvStore = SDSKeyValueStore(collection: "OnboardingStory")
    private let overlayKvStore = SDSKeyValueStore(collection: "StoryViewerOnboardingOverlay")

    private lazy var queue = schedulers.queue(label: "org.signal.story.onboarding", qos: .utility)

    private lazy var chainedPromise = ChainedPromise<Void>(scheduler: queue)

    @objc
    public override convenience init() {
        self.init(
            fileSystem: OnboardingStoryManagerFilesystem.self,
            schedulers: DispatchQueueSchedulers(),
            storyMessageFactory: OnboardingStoryManagerStoryMessageFactory.self
        )
    }

    init(
        fileSystem: OnboardingStoryManagerFilesystem.Type,
        schedulers: Schedulers,
        storyMessageFactory: OnboardingStoryManagerStoryMessageFactory.Type
    ) {
        self.fileSystem = fileSystem
        self.schedulers = schedulers
        self.storyMessageFactory = storyMessageFactory
        super.init()

        if CurrentAppContext().isMainApp {
            AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { [weak self] in
                guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
                    // Observe when the account is ready before we try and download.
                    self?.observeRegistrationChanges()
                    return
                }
                self?.enqueueOnboardingStoryDownload()
            }
        }
    }

    deinit {
        NotificationCenter.default.removeObserver(self)
    }

    // MARK: - API

    @discardableResult
    public func enqueueOnboardingStoryDownload() -> Promise<Void> {
        return chainedPromise.enqueue { [weak self] in
            return self?.downloadOnboardingStoryIfNeeded() ?? .init(error: OWSAssertionError("SystemStoryManager unretained"))
        }
    }

    @discardableResult
    public func cleanUpOnboardingStoryIfNeeded() -> Promise<Void> {
        return chainedPromise.enqueue { [weak self] () -> Promise<OnboardingStoryDownloadStatus> in
            return self?.checkOnboardingStoryDownloadStatus() ?? .init(error: OWSAssertionError("SystemStoryManager unretained"))
        }.asVoid()
    }

    public func isOnboardingStoryRead(transaction: SDSAnyReadTransaction) -> Bool {
        if onboardingStoryReadStatus(transaction: transaction) {
            return true
        }
        // If its viewed, that also counts as being read.
        return isOnboardingStoryViewed(transaction: transaction)
    }

    public func isOnboardingStoryViewed(transaction: SDSAnyReadTransaction) -> Bool {
        let status = onboardingStoryViewStatus(transaction: transaction)
        switch status.status {
        case .notViewed:
            return false
        case .viewedOnThisDevice, .viewedOnAnotherDevice:
            return true
        }
    }

    public func setHasReadOnboardingStory(transaction: SDSAnyWriteTransaction, updateStorageService: Bool) {
        try? setOnboardingStoryRead(transaction: transaction, updateStorageService: updateStorageService)
    }

    public func setHasViewedOnboardingStory(
        source: OnboardingStoryViewSource,
        transaction: SDSAnyWriteTransaction
    ) throws {
        switch source {
        case .local(let timestamp, let updateStorageService):
            try setOnboardingStoryViewedOnThisDevice(
                atTimestamp: timestamp,
                shouldUpdateStorageService: updateStorageService,
                transaction: transaction
            )
        case .otherDevice:
            setHasViewedOnboardingStoryOnAnotherDevice(transaction: transaction)
        }
    }

    private func setHasViewedOnboardingStoryOnAnotherDevice(transaction: SDSAnyWriteTransaction) {
        try? setOnboardingStoryViewedOnAnotherDevice(transaction: transaction)
        self.cleanUpOnboardingStoryIfNeeded()
    }

    // MARK: OnboardingOverlay state

    public func isOnboardingOverlayViewed(transaction: SDSAnyReadTransaction) -> Bool {
        if overlayKvStore.getBool(Constants.kvStoreOnboardingOverlayViewedKey, defaultValue: false, transaction: transaction) {
            return true
        }

        if isOnboardingStoryViewed(transaction: transaction) {
            // We don't sync view state for the onboarding overlay. But we can use
            // viewing of the onboarding story as an imperfect proxy; if they viewed it
            // that means they also definitely saw the viewer overlay.
            return true
        }
        return false
    }

    public func setOnboardingOverlayViewed(value: Bool, transaction: SDSAnyWriteTransaction) {
        overlayKvStore.setBool(value, key: Constants.kvStoreOnboardingOverlayViewedKey, transaction: transaction)
    }

    // MARK: Hidden State

    private var stateChangeObservers = [SystemStoryStateChangeObserver]()

    public func addStateChangedObserver(_ observer: SystemStoryStateChangeObserver) {
        stateChangeObservers.append(observer)
    }

    public func removeStateChangedObserver(_ observer: SystemStoryStateChangeObserver) {
        stateChangeObservers.removeAll(where: { $0 == observer })
    }

    public func setSystemStoriesHidden(_ hidden: Bool, transaction: SDSAnyWriteTransaction) {
        var changedRowIds = [Int64]()
        defer {
            schedulers.main.async {
                self.stateChangeObservers.forEach { $0.systemStoryHiddenStateDidChange(rowIds: changedRowIds) }
            }
        }

        // No need to make this serial with the other calls, db transactions cover us.
        self.setSystemStoryHidden(hidden, transaction: transaction)

        let onboardingStatus = onboardingStoryDownloadStatus(transaction: transaction)
        guard
            let messageUniqueIds = onboardingStatus.messageUniqueIds,
            !messageUniqueIds.isEmpty
        else {
            return
        }
        let stories = StoryFinder.listStoriesWithUniqueIds(messageUniqueIds, transaction: transaction)
        stories.forEach {
            if hidden {
                $0.markAsViewed(at: Date().ows_millisecondsSince1970, circumstance: .onThisDevice, transaction: transaction)
            }
            if let rowId = $0.id {
                changedRowIds.append(rowId)
            }
        }
    }

    // MARK: - Internal Event Observation

    private func observeRegistrationChanges() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(registrationStateDidChange),
            name: .registrationStateDidChange,
            object: nil
        )
    }

    @objc
    private func registrationStateDidChange() {
        guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
            return
        }
        NotificationCenter.default.removeObserver(self, name: .registrationStateDidChange, object: nil)
        _ = self.enqueueOnboardingStoryDownload()
    }

    private var isObservingOnboardingStoryEvents = false
    private var storyMessagesObservation: DatabaseCancellable?

    private func beginObservingOnboardingStoryEventsIfNeeded(downloadStatus: OnboardingStoryDownloadStatus) {
        guard
            CurrentAppContext().isMainApp,
            !isObservingOnboardingStoryEvents,
            downloadStatus.isDownloaded,
            let messageUniqueIds = downloadStatus.messageUniqueIds
        else {
            return
        }

        let viewStatus = Self.databaseStorage.read {
            self.onboardingStoryViewStatus(transaction: $0)
        }
        switch viewStatus.status {
        case .viewedOnThisDevice, .viewedOnAnotherDevice:
            // No need to observe if we've already viewed.
            return
        case .notViewed:
            break
        }

        isObservingOnboardingStoryEvents = true

        // Observe app background to opportunistically delete timed out stories.
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didEnterBackground),
            name: .OWSApplicationDidEnterBackground,
            object: nil
        )

        // Observe view state changes for the stories.
        let observation = ValueObservation.tracking { db throws in
            try StoryMessage
                .filter(messageUniqueIds.contains(Column(StoryMessage.columnName(.uniqueId))))
                .fetchAll(db)
        }
        // Ignore the first emission that fires right away, we
        // want subsequent updates only.
        var hasEmitted = false
        storyMessagesObservation?.cancel()
        storyMessagesObservation = observation.start(
            in: databaseStorage.grdbStorage.pool,
            onError: { error in
                owsFailDebug("Failed to observe story view state: \(error))")
            }, onChange: { [weak self] changedModels in
                guard hasEmitted else {
                    hasEmitted = true
                    return
                }
                guard
                    let viewedTimstamp = changedModels
                        .lazy
                        .compactMap(\.localUserViewedTimestamp)
                        .min()
                else {
                    return
                }
                do {
                    try Self.databaseStorage.write {
                        try self?.setOnboardingStoryViewedOnThisDevice(
                            atTimestamp: viewedTimstamp,
                            shouldUpdateStorageService: true,
                            transaction: $0
                        )
                    }
                    self?.cleanUpOnboardingStoryIfNeeded()
                    self?.stopObservingOnboardingStoryEvents()
                } catch {
                    return
                }
            }
        )
    }

    private func stopObservingOnboardingStoryEvents() {
        isObservingOnboardingStoryEvents = false
        storyMessagesObservation?.cancel()
        storyMessagesObservation = nil
        NotificationCenter.default.removeObserver(self, name: .OWSApplicationDidEnterBackground, object: nil)
    }

    @objc
    private func didEnterBackground() {
        self.cleanUpOnboardingStoryIfNeeded()
    }

    // MARK: - Implementation

    private func downloadOnboardingStoryIfNeeded() -> Promise<Void> {
        let knownViewStatus = Self.databaseStorage.read {
            self.onboardingStoryViewStatus(transaction: $0)
        }
        switch knownViewStatus.status {
        case .viewedOnAnotherDevice:
            // We already know things are viewed, we can stop right away.
            return .value(())
        case .viewedOnThisDevice:
            // Already viewed, take the opportunity to clean up if we have to, but don't force it.
            return self.checkOnboardingStoryDownloadStatus().asVoid()
        case .notViewed:
            // Sync to check if we viewed on another device since last time we synced.
            return self.syncOnboardingStoryViewStatus()
                .then(on: queue) { [weak self] (viewStatus: OnboardingStoryViewStatus) -> Promise<Void> in
                    guard let strongSelf = self else {
                        return .init(error: OWSAssertionError("SystemStoryManager unretained"))
                    }
                    switch viewStatus.status {
                    case .viewedOnAnotherDevice:
                        // Already viewed, immediately delete anything we already downloaded.
                        return strongSelf.checkOnboardingStoryDownloadStatus(forceDeletingIfDownloaded: true).asVoid()
                    case .viewedOnThisDevice:
                        // Already viewed, take the opportunity to clean up if we have to, but don't force it.
                        return strongSelf.checkOnboardingStoryDownloadStatus().asVoid()
                    case .notViewed:
                        return strongSelf.downloadOnboardingStoryIfUndownloaded()
                    }
                }
        }
    }

    private func syncOnboardingStoryViewStatus() -> Promise<OnboardingStoryViewStatus> {
        Self.storageServiceManager.restoreOrCreateManifestIfNecessary(authedDevice: .implicit)
            .then(on: queue) { [weak self] _ -> Promise<OnboardingStoryViewStatus> in
                guard let strongSelf = self else {
                    return .init(error: OWSAssertionError("SystemStoryManager unretained"))
                }
                // At this point, we will have synced the AccountRecord, which would call
                // `SystemStoryManager.setHasViewedOnboardingStoryOnAnotherDevice()` and write
                // to the database. Read from the database to get whatever the latest value is.
                return .value(Self.databaseStorage.read { transaction in
                    return strongSelf.onboardingStoryViewStatus(transaction: transaction)
                })
            }
    }

    private func downloadOnboardingStoryIfUndownloaded() -> Promise<Void> {
        let queue = self.queue
        return checkOnboardingStoryDownloadStatus()
            .then(on: queue) { [weak self] (downloadStatus: OnboardingStoryDownloadStatus) -> Promise<Void> in
                guard !downloadStatus.isDownloaded else {
                    // Already done.
                    return .value(())
                }
                guard let strongSelf = self else {
                    return .init(error: OWSAssertionError("SystemStoryManager unretained"))
                }
                let urlSession = Self.signalService.urlSessionForUpdates2()
                return strongSelf.fetchFilenames(urlSession: urlSession)
                    .then(on: queue) { [weak self] (fileNames: [String]) -> Promise<[TSResourceDataSource]> in
                        let promises = fileNames.compactMap {
                            self?.downloadOnboardingAsset(urlSession: urlSession, url: $0)
                        }
                        return Promise.when(on: SyncScheduler(), fulfilled: promises)
                    }
                    .then(on: queue) { [weak self] (attachmentSources: [TSResourceDataSource]) -> Promise<Void> in
                        guard let strongSelf = self else {
                            return .init(error: OWSAssertionError("SystemStoryManager unretained"))
                        }
                        do {
                            return .value(try strongSelf.databaseStorage.write { transaction in
                                let uniqueIds = try strongSelf.createStoryMessages(
                                    attachmentSources: attachmentSources,
                                    transaction: transaction
                                )
                                try strongSelf.markOnboardingStoryDownloaded(
                                    messageUniqueIds: uniqueIds,
                                    transaction: transaction
                                )
                            })
                        } catch {
                            return .init(error: error)
                        }
                    }
                }
    }

    private func checkOnboardingStoryDownloadStatus(forceDeletingIfDownloaded: Bool = false) -> Promise<OnboardingStoryDownloadStatus> {
        let status = databaseStorage.write { transaction -> OnboardingStoryDownloadStatus in
            let status = self.onboardingStoryDownloadStatus(transaction: transaction)
            if status.isDownloaded {
                // clean up opportunistically.
                try? self.cleanUpOnboardingStoriesIfNeeded(
                    messageUniqueIds: status.messageUniqueIds,
                    forceDeleteIfDownloaded: forceDeletingIfDownloaded,
                    transaction: transaction
                )
            }
            return status
        }

        schedulers.main.async {
            self.beginObservingOnboardingStoryEventsIfNeeded(downloadStatus: status)
        }
        return .value(status)
    }

    // MARK: Story Deletion

    private func cleanUpOnboardingStoriesIfNeeded(
        messageUniqueIds: [String]?,
        forceDeleteIfDownloaded: Bool,
        transaction: SDSAnyWriteTransaction
    ) throws {
        var forceDelete = forceDeleteIfDownloaded

        let viewStatus = self.onboardingStoryViewStatus(transaction: transaction)

        let viewedTimestamp: UInt64?
        var markViewedIfNotFound = false
        switch viewStatus.status {
        case .notViewed:
            // Legacy clients might have viewed stories from before we recorded viewed status.
            viewedTimestamp = nil
            markViewedIfNotFound = true
        case .viewedOnAnotherDevice:
            // Delete right away.
            forceDelete = true
            viewedTimestamp = nil
        case .viewedOnThisDevice:
            guard let timestamp = viewStatus.viewedTimestamp else {
                throw OWSAssertionError("Invalid view status")
            }
            viewedTimestamp = timestamp
        }

        let isExpired = viewedTimestamp.map {
            Date().timeIntervalSince(Date(millisecondsSince1970: $0)) >= Constants.postViewingTimeout
        } ?? false

        guard isExpired || forceDelete || markViewedIfNotFound else {
            return
        }

        guard let messageUniqueIds = messageUniqueIds, !messageUniqueIds.isEmpty else {
            throw OWSAssertionError("No messages")
        }
        let stories = StoryFinder.listStoriesWithUniqueIds(messageUniqueIds, transaction: transaction)
        guard !stories.isEmpty else {
            if markViewedIfNotFound {
                // this is a legacy client with stories that were viewed before
                // we kept track of viewed state independently.
                try self.setOnboardingStoryViewedOnThisDevice(
                    atTimestamp: Date.distantPast.ows_millisecondsSince1970,
                    shouldUpdateStorageService: true,
                    transaction: transaction
                )
            }
            return
        }

        guard isExpired || forceDelete else {
            return
        }

        stories.forEach {
            $0.anyRemove(transaction: transaction)
        }
    }

    // MARK: Downloading

    private func fetchFilenames(
        urlSession: OWSURLSessionProtocol
    ) -> Promise<[String]> {
        return urlSession.dataTaskPromise(
            on: schedulers.global(),
            Constants.manifestPath,
            method: .get
        ).map(on: queue) { (response: HTTPResponse) throws -> [String] in
            guard
                let json = response.responseBodyJson,
                let responseDictionary = json as? [String: AnyObject],
                let version = responseDictionary[Constants.manifestVersionKey] as? String,
                let languages = responseDictionary[Constants.manifestLanguagesKey] as? [String: AnyObject]
            else {
                throw OWSAssertionError("Missing or invalid JSON")
            }
            guard
                let assetFilenames = Locale.current.languageCode.map({ languageCode in
                    languages[languageCode] as? [String]
                }) ?? (languages[Constants.fallbackLanguageCode] as? [String])
            else {
                throw OWSAssertionError("Unable to locate onboarding image set")
            }
            return assetFilenames.map {
                return Constants.imagePath(version: version, filename: $0)
            }
        }
    }

    private func downloadOnboardingAsset(
        urlSession: OWSURLSessionProtocol,
        url: String
    ) -> Promise<TSResourceDataSource> {
        return urlSession.downloadTaskPromise(
            on: schedulers.global(),
            url,
            method: .get
        ).map(on: self.queue) { [fileSystem, storyMessageFactory] result in
            let resultUrl = result.downloadUrl

            guard fileSystem.fileOrFolderExists(url: resultUrl) else {
                throw OWSAssertionError("Onboarding story url missing")
            }
            guard
                fileSystem.isValidImage(at: resultUrl, mimeType: Constants.imageExtension)
            else {
                throw OWSAssertionError("Invalid onboarding asset")
            }
            let dataSource = try DataSourcePath.dataSource(
                with: resultUrl,
                shouldDeleteOnDeallocation: CurrentAppContext().isRunningTests.negated
            )
            return try storyMessageFactory.validateAttachmentContents(
                dataSource: dataSource,
                mimeType: Constants.imageMimeType
            )
        }
    }

    /// Returns unique Ids for the created messages. Fails if any one message creation fails.
    private func createStoryMessages(
        attachmentSources: [TSResourceDataSource],
        transaction: SDSAnyWriteTransaction
    ) throws -> [String] {
        let baseTimestamp = Date().ows_millisecondsSince1970
        let ids = try attachmentSources.lazy.enumerated().map { (i, attachmentSource) throws -> String in
            let message = try storyMessageFactory.createFromSystemAuthor(
                attachmentSource: attachmentSource,
                // Ensure timestamps are unique since they are sometimes used for uniquing.
                timestamp: baseTimestamp + UInt64(i),
                transaction: transaction
            )
            return message.uniqueId
        }
        return ids
    }

    // MARK: - KV Store

    // MARK: Onboarding Story Read Status

    private func onboardingStoryReadStatus(transaction: SDSAnyReadTransaction) -> Bool {
        return kvStore.getBool(Constants.kvStoreOnboardingStoryIsReadKey, defaultValue: false, transaction: transaction)
    }

    private func setOnboardingStoryRead(transaction: SDSAnyWriteTransaction, updateStorageService: Bool) throws {
        guard !onboardingStoryReadStatus(transaction: transaction) else {
            return
        }
        kvStore.setBool(true, key: Constants.kvStoreOnboardingStoryIsReadKey, transaction: transaction)
        if updateStorageService {
            Self.storageServiceManager.recordPendingLocalAccountUpdates()
        }
        NotificationCenter.default.postNotificationNameAsync(.onboardingStoryStateDidChange, object: nil)
    }

    // MARK: Onboarding Story View Status

    private struct OnboardingStoryViewStatus: Codable {
        enum Status: Int, Codable {
            case notViewed
            case viewedOnThisDevice
            case viewedOnAnotherDevice
        }

        let status: Status
        // only set for viewedOnThisDevice
        let viewedTimestamp: UInt64?
    }

    private func onboardingStoryViewStatus(transaction: SDSAnyReadTransaction) -> OnboardingStoryViewStatus {
        guard
            let rawStatus = kvStore.getData(Constants.kvStoreOnboardingStoryViewStatusKey, transaction: transaction),
            let status = try? JSONDecoder().decode(OnboardingStoryViewStatus.self, from: rawStatus)
        else {
            return OnboardingStoryViewStatus(status: .notViewed, viewedTimestamp: nil)
        }
        return status
    }

    private func setOnboardingStoryViewedOnAnotherDevice(transaction: SDSAnyWriteTransaction) throws {
        try kvStore.setData(
            JSONEncoder().encode(OnboardingStoryViewStatus(status: .viewedOnAnotherDevice, viewedTimestamp: nil)),
            key: Constants.kvStoreOnboardingStoryViewStatusKey,
            transaction: transaction
        )
        NotificationCenter.default.postNotificationNameAsync(.onboardingStoryStateDidChange, object: nil)
    }

    private func setOnboardingStoryViewedOnThisDevice(
        atTimestamp timestamp: UInt64,
        shouldUpdateStorageService: Bool,
        transaction: SDSAnyWriteTransaction
    ) throws {
        let oldStatus = onboardingStoryViewStatus(transaction: transaction)
        guard oldStatus.status == .notViewed else {
            return
        }
        try kvStore.setData(
            JSONEncoder().encode(OnboardingStoryViewStatus(status: .viewedOnThisDevice, viewedTimestamp: timestamp)),
            key: Constants.kvStoreOnboardingStoryViewStatusKey,
            transaction: transaction
        )
        if shouldUpdateStorageService {
            Self.storageServiceManager.recordPendingLocalAccountUpdates()
        }
        NotificationCenter.default.postNotificationNameAsync(.onboardingStoryStateDidChange, object: nil)
    }

    // MARK: Onboarding Story Download Status

    private struct OnboardingStoryDownloadStatus: Codable {
        var messageUniqueIds: [String]?

        var isDownloaded: Bool { return messageUniqueIds?.isEmpty == false }

        static var requiresDownload: Self { return .init(messageUniqueIds: nil) }
    }

    private func onboardingStoryDownloadStatus(transaction: SDSAnyReadTransaction) -> OnboardingStoryDownloadStatus {
        guard
            let rawStatus = kvStore.getData(Constants.kvStoreOnboardingStoryDownloadStatusKey, transaction: transaction),
            let status = try? JSONDecoder().decode(OnboardingStoryDownloadStatus.self, from: rawStatus)
        else {
            return .requiresDownload
        }
        return status
    }

    internal func markOnboardingStoryDownloaded(
        messageUniqueIds: [String],
        transaction: SDSAnyWriteTransaction
    ) throws {
        let status = OnboardingStoryDownloadStatus(messageUniqueIds: messageUniqueIds)
        try kvStore.setData(
            JSONEncoder().encode(status),
            key: Constants.kvStoreOnboardingStoryDownloadStatusKey,
            transaction: transaction
        )
        DispatchQueue.main.async {
            self.beginObservingOnboardingStoryEventsIfNeeded(downloadStatus: status)
            NotificationCenter.default.post(name: .onboardingStoryStateDidChange, object: nil)
        }
    }

    // MARK: System Story Hidden Status

    public func areSystemStoriesHidden(transaction: SDSAnyReadTransaction) -> Bool {
        // No need to make this serial with the other calls, db transactions cover us.
        kvStore.getBool(Constants.kvStoreHiddenStateKey, defaultValue: false, transaction: transaction)
    }

    private func setSystemStoryHidden(_ hidden: Bool, transaction: SDSAnyWriteTransaction) {
        kvStore.setBool(hidden, key: Constants.kvStoreHiddenStateKey, transaction: transaction)
        NotificationCenter.default.postNotificationNameAsync(.onboardingStoryStateDidChange, object: nil)
    }

    internal enum Constants {
        static let kvStoreOnboardingStoryIsReadKey = "OnboardingStoryIsRead"
        static let kvStoreOnboardingStoryViewStatusKey = "OnboardingStoryViewStatus"
        static let kvStoreOnboardingStoryDownloadStatusKey = "OnboardingStoryStatus"
        static let kvStoreHiddenStateKey = "SystemStoriesAreHidden"
        static let kvStoreOnboardingOverlayViewedKey = "hasSeenStoryViewerOnboardingOverlay" // leading 'h' lowercase for legacy reasons

        static let manifestPath = "dynamic/ios/stories/onboarding/manifest.json"
        static let manifestVersionKey = "version"
        static let manifestLanguagesKey = "languages"
        static let fallbackLanguageCode = "en"

        static func imagePath(version: String, filename: String) -> String {
            return "static/ios/stories/onboarding"
                .appendingPathComponent(version)
                .appendingPathComponent(filename)
                + Constants.imageExtension
        }
        static let imageExtension = ".jpg"
        static let imageMimeType = MimeType.imageJpeg.rawValue
        static let imageWidth = 1125
        static let imageHeight = 1998

        static let postViewingTimeout: TimeInterval = 24 /* hrs */ * 60 * 60
    }
}
