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

import GRDB
import LibSignalClient
import SignalCoreKit

public enum EditMessageQueryMode {
    case includeAllEdits
    case excludeReadEdits
    case excludeAllEdits
}

// MARK: -

@objc
public class InteractionFinder: NSObject {
    let threadUniqueId: String

    @objc
    public init(threadUniqueId: String) {
        self.threadUniqueId = threadUniqueId
    }

    // MARK: - static methods

    public class func fetch(
        rowId: Int64,
        transaction: SDSAnyReadTransaction
    ) -> TSInteraction? {
        guard let interaction = TSInteraction.grdbFetchOne(
            sql: """
                SELECT *
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .id) = ?
            """,
            arguments: [ rowId ],
            transaction: transaction.unwrapGrdbRead
        ) else {
            owsFailDebug("Missing interaction with row ID - how did we get this row ID?")
            return nil
        }

        return interaction
    }

    public class func existsIncomingMessage(
        timestamp: UInt64,
        sourceAci: Aci,
        transaction: SDSAnyReadTransaction
    ) -> Bool {
        let sql = """
            SELECT EXISTS(
                SELECT 1
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .timestamp) = ?
                AND (
                    \(interactionColumn: .authorUUID) = ?
                    OR (
                        \(interactionColumn: .authorUUID) IS NULL
                        AND \(interactionColumn: .authorPhoneNumber) = ?
                    )
                )
            )
        """
        let arguments: StatementArguments = [
            timestamp,
            sourceAci.serviceIdUppercaseString,
            SignalServiceAddress(sourceAci).phoneNumber
        ]
        do {
            return try Bool.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: arguments
            ) ?? false
        } catch {
            DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
                userDefaults: CurrentAppContext().appUserDefaults(),
                error: error
            )
            owsFail("Failed to find incoming message")
        }
    }

    @objc
    public class func interactions(
        withTimestamp timestamp: UInt64,
        filter: (TSInteraction) -> Bool,
        transaction: SDSAnyReadTransaction
    ) throws -> [TSInteraction] {
        let sql = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .timestamp) = ?
        """
        let arguments: StatementArguments = [timestamp]

        let unfiltered = try TSInteraction.grdbFetchCursor(
            sql: sql,
            arguments: arguments,
            transaction: transaction.unwrapGrdbRead
        ).all()
        return unfiltered.filter(filter)
    }

    public class func incompleteCallIds(transaction: SDSAnyReadTransaction) -> [String] {
        let sql: String = """
            SELECT \(interactionColumn: .uniqueId)
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .recordType) = ?
            AND (
                \(interactionColumn: .callType) = ?
                OR \(interactionColumn: .callType) = ?
            )
        """
        let statementArguments: StatementArguments = [
            SDSRecordType.call.rawValue,
            RPRecentCallType.outgoingIncomplete.rawValue,
            RPRecentCallType.incomingIncomplete.rawValue
        ]
        var result = [String]()
        do {
            result = try String.fetchAll(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: statementArguments
            )
        } catch {
            owsFailDebug("error: \(error)")
        }
        return result
    }

    public class func attemptingOutInteractionIds(
        transaction: SDSAnyReadTransaction
    ) -> [String] {
        let sql: String = """
            SELECT \(interactionColumn: .uniqueId)
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .storedMessageState) = ?
        """
        var result = [String]()
        do {
            result = try String.fetchAll(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: [TSOutgoingMessageState.sending.rawValue]
            )
        } catch {
            owsFailDebug("error: \(error)")
        }
        return result
    }

    public class func pendingInteractionIds(
        transaction: SDSAnyReadTransaction
    ) -> [String] {
        let sql: String = """
            SELECT \(interactionColumn: .uniqueId)
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .storedMessageState) = ?
        """
        var result = [String]()
        do {
            result = try String.fetchAll(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: [TSOutgoingMessageState.pending.rawValue]
            )
        } catch {
            owsFailDebug("error: \(error)")
        }
        return result
    }

    public class func unreadCountInAllThreads(transaction: SDSAnyReadTransaction) -> UInt {
        do {
            let includeMutedThreads = SSKPreferences.includeMutedThreadsInBadgeCount(transaction: transaction)

            var unreadInteractionQuery = """
                SELECT COUNT(interaction.\(interactionColumn: .id))
                FROM \(InteractionRecord.databaseTableName) AS interaction
                INNER JOIN \(ThreadAssociatedData.databaseTableName) AS associatedData
                    ON associatedData.threadUniqueId = \(interactionColumn: .threadUniqueId)
                WHERE associatedData.isArchived = "0"
            """

            if !includeMutedThreads {
                unreadInteractionQuery += " \(sqlClauseForIgnoringInteractionsWithMutedThread(threadAssociatedDataAlias: "associatedData")) "
            }

            unreadInteractionQuery += " AND \(sqlClauseForUnreadInteractionCounts(interactionsAlias: "interaction")) "

            let unreadInteractionCount = try UInt.fetchOne(transaction.unwrapGrdbRead.database, sql: unreadInteractionQuery)
            owsAssertDebug(unreadInteractionCount != nil, "unreadInteractionCount was unexpectedly nil")

            var markedUnreadThreadQuery = """
                SELECT COUNT(*)
                FROM \(ThreadRecord.databaseTableName)
                INNER JOIN \(ThreadAssociatedData.databaseTableName) AS associatedData
                    ON associatedData.threadUniqueId = \(threadColumn: .uniqueId)
                WHERE associatedData.isMarkedUnread = 1
                AND associatedData.isArchived = "0"
                AND \(threadColumn: .shouldThreadBeVisible) = 1
            """

            if !includeMutedThreads {
                markedUnreadThreadQuery += " \(sqlClauseForIgnoringInteractionsWithMutedThread(threadAssociatedDataAlias: "associatedData")) "
            }

            let markedUnreadCount = try UInt.fetchOne(transaction.unwrapGrdbRead.database, sql: markedUnreadThreadQuery)
            owsAssertDebug(markedUnreadCount != nil, "markedUnreadCount was unexpectedly nil")

            return (unreadInteractionCount ?? 0) + (markedUnreadCount ?? 0)
        } catch {
            owsFailDebug("error: \(error.grdbErrorForLogging)")
            return 0
        }
    }

    // The interactions should be enumerated in order from "next to expire" to "last to expire".
    public class func nextMessageWithStartedPerConversationExpirationToExpire(
        transaction: SDSAnyReadTransaction
    ) -> TSMessage? {
        // NOTE: We DO NOT consult storedShouldStartExpireTimer here;
        //       once expiration has begun we want to see it through.
        let sql = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .expiresAt) > 0
            ORDER BY \(interactionColumn: .expiresAt)
        """
        let cursor = TSInteraction.grdbFetchCursor(
            sql: sql,
            transaction: transaction.unwrapGrdbRead
        )
        do {
            while let interaction = try cursor.next() {
                if let message = interaction as? TSMessage {
                    return message
                } else {
                    owsFailDebug("Unexpected object: \(type(of: interaction))")
                }
            }
        } catch {
            owsFail("error: \(error)")
        }
        return nil
    }

    public class func fetchSomeExpiredMessageRowIds(now: UInt64, limit: Int, tx: SDSAnyReadTransaction) throws -> [Int64] {
        // NOTE: We DO NOT consult storedShouldStartExpireTimer here;
        //       once expiration has begun we want to see it through.
        let sql = """
            SELECT \(interactionColumn: .id)
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .expiresAt) > 0
            AND \(interactionColumn: .expiresAt) <= ?
            LIMIT \(limit)
        """
        do {
            return try Int64.fetchAll(tx.unwrapGrdbRead.database, sql: sql, arguments: [now])
        } catch {
            throw error.grdbErrorForLogging
        }
    }

    public class func fetchAllMessageUniqueIdsWhichFailedToStartExpiring(
        transaction: SDSAnyReadTransaction
    ) -> [String] {
        // NOTE: We DO consult storedShouldStartExpireTimer here.
        //       We don't want to start expiration until it is true.
        let sql = """
            SELECT \(interactionColumn: .uniqueId)
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .storedShouldStartExpireTimer) IS TRUE
            AND (
                \(interactionColumn: .expiresAt) IS 0 OR
                \(interactionColumn: .expireStartedAt) IS 0
            )
        """
        do {
            return try String.fetchAll(
                transaction.unwrapGrdbRead.database,
                sql: sql
            )
        } catch {
            owsFailDebug("error: \(error)")
            return []
        }
    }

    public class func interactions(
        withInteractionIds interactionIds: Set<String>,
        transaction: SDSAnyReadTransaction
    ) -> Set<TSInteraction> {
        guard !interactionIds.isEmpty else {
            return []
        }

        let sql = """
            SELECT * FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .uniqueId) IN (\(interactionIds.map { "\'\($0)'" }.joined(separator: ",")))
        """
        let arguments: StatementArguments = []
        let cursor = TSInteraction.grdbFetchCursor(
            sql: sql,
            arguments: arguments,
            transaction: transaction.unwrapGrdbRead
        )
        var interactions = Set<TSInteraction>()
        do {
            while let interaction = try cursor.next() {
                interactions.insert(interaction)
            }
        } catch {
            owsFailDebug("unexpected error \(error)")
        }
        return interactions
    }

    public static func enumerateGroupReplies(
        for storyMessage: StoryMessage,
        transaction: SDSAnyReadTransaction,
        block: @escaping (TSMessage, inout Bool) -> Void
    ) {
        let sql = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .storyTimestamp) = ?
            AND \(interactionColumn: .storyAuthorUuidString) = ?
            AND \(interactionColumn: .isGroupStoryReply) = 1
        """
        let cursor = TSInteraction.grdbFetchCursor(
            sql: sql,
            arguments: [storyMessage.timestamp, storyMessage.authorAci.serviceIdUppercaseString],
            transaction: transaction.unwrapGrdbRead
        )
        do {
            while let interaction = try cursor.next() {
                guard let message = interaction as? TSMessage else {
                    owsFailDebug("Unexpected object: \(type(of: interaction))")
                    return
                }
                var stop: Bool = false
                block(message, &stop)
                if stop {
                    return
                }
            }
        } catch {
            owsFail("error: \(error)")
        }
    }

    public static func hasLocalUserReplied(
        storyTimestamp: UInt64,
        storyAuthorAci: Aci,
        transaction: SDSAnyReadTransaction
    ) -> Bool {
        let sql = """
            SELECT EXISTS(
                SELECT 1
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .storyTimestamp) = ?
                AND \(interactionColumn: .storyAuthorUuidString) = ?
                AND \(interactionColumn: .recordType) = \(SDSRecordType.outgoingMessage.rawValue)
                AND \(interactionColumn: .isGroupStoryReply) = 1
                LIMIT 1
            )
        """
        do {
            return try Bool.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: [
                    storyTimestamp,
                    storyAuthorAci.serviceIdUppercaseString
                ]
            ) ?? false
        } catch {
            owsFail("error: \(error)")
        }
    }

    public static func groupReplyUniqueIdsAndRowIds(
        storyAuthor: Aci,
        storyTimestamp: UInt64,
        transaction: SDSAnyReadTransaction
    ) -> [(String, Int64)] {
        guard storyAuthor != StoryMessage.systemStoryAuthor else {
            // No replies on system stories.
            return []
        }
        do {
            let sql: String = """
                SELECT \(interactionColumn: .uniqueId), \(interactionColumn: .id)
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .storyTimestamp) = ?
                AND \(interactionColumn: .storyAuthorUuidString) = ?
                AND \(interactionColumn: .isGroupStoryReply) = 1
                ORDER BY \(interactionColumn: .id) ASC
            """
            return try Row.fetchAll(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: [storyTimestamp, storyAuthor.serviceIdUppercaseString]
            ).map { ($0[0], $0[1]) }
        } catch {
            owsFail("error: \(error)")
        }
    }

    static func enumeratePlaceholders(
        transaction: SDSAnyReadTransaction,
        block: (OWSRecoverableDecryptionPlaceholder) -> Void
    ) {
        let sql = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .recordType) IS \(SDSRecordType.recoverableDecryptionPlaceholder.rawValue)
        """
        do {
            let cursor = TSInteraction.grdbFetchCursor(
                sql: sql,
                transaction: transaction.unwrapGrdbRead
            )
            while let result = try cursor.next() {
                if let placeholder = result as? OWSRecoverableDecryptionPlaceholder {
                    block(placeholder)
                } else {
                    owsFailDebug("Unexpected type: \(type(of: result))")
                }
            }
        } catch {
            owsFailDebug("unexpected error \(error)")
        }
    }

    @objc
    public class func findMessage(
        withTimestamp timestamp: UInt64,
        threadId: String,
        author: SignalServiceAddress,
        transaction: SDSAnyReadTransaction
    ) -> TSMessage? {
        guard timestamp > 0 else {
            owsFailDebug("invalid timestamp: \(timestamp)")
            return nil
        }

        guard !threadId.isEmpty else {
            owsFailDebug("invalid thread")
            return nil
        }

        guard author.isValid else {
            owsFailDebug("Invalid author \(author)")
            return nil
        }

        let interactions: [TSInteraction]

        do {
            interactions = try InteractionFinder.interactions(
                withTimestamp: timestamp,
                filter: { $0 is TSMessage },
                transaction: transaction
            )
        } catch {
            owsFailDebug("Error loading interactions \(error.userErrorDescription)")
            return nil
        }

        for interaction in interactions {
            guard let message = interaction as? TSMessage else {
                owsFailDebug("received unexpected non-message interaction")
                continue
            }

            guard message.uniqueThreadId == threadId else { continue }

            if let incomingMessage = message as? TSIncomingMessage,
                incomingMessage.authorAddress.isEqualToAddress(author) {
                return incomingMessage
            }

            if let outgoingMessage = message as? TSOutgoingMessage,
                author.isLocalAddress {
                return outgoingMessage
            }
        }

        return nil
    }

    /// Gets the most recently inserted Interaction of type `incomingMessage`.
    public static func lastInsertedIncomingMessage(
        transaction: SDSAnyReadTransaction
    ) -> TSIncomingMessage? {
        let sql: String = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .recordType) = ?
            AND \(interactionColumn: .callType) IS NULL
            ORDER BY \(interactionColumn: .id) DESC
            LIMIT 1
        """
        let arguments: StatementArguments = [
            SDSRecordType.incomingMessage.rawValue
        ]
        let result = TSInteraction.grdbFetchOne(
            sql: sql,
            arguments: arguments,
            transaction: transaction.unwrapGrdbRead
        )
        if let result = result as? TSIncomingMessage {
            return result
        } else if let result {
            owsFailDebug("Unexpected type: \(type(of: result))")
            return nil
        } else {
            return nil
        }
    }

    // MARK: - instance methods

    public func profileUpdateInteractions(
        afterSortId sortId: UInt64,
        transaction: SDSAnyReadTransaction
    ) -> [TSInfoMessage] {
        let cursor = TSInteraction.grdbFetchCursor(
            sql: """
                SELECT *
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                AND \(interactionColumn: .messageType) = ?
                AND \(interactionColumn: .id) > ?
            """,
            arguments: [threadUniqueId, TSInfoMessageType.profileUpdate.rawValue, sortId],
            transaction: transaction.unwrapGrdbRead
        )

        let allResults: [TSInteraction]
        do {
            // Every result should be an info message with associated profile changes
            allResults = try cursor.all()
            owsAssertDebug(allResults.allSatisfy({ ($0 as? TSInfoMessage)?.profileChangeAddress != nil }))
        } catch {
            owsFailDebug("Unexpected error \(error)")
            allResults = []
        }

        return allResults.compactMap { $0 as? TSInfoMessage }
    }

    func latestInteraction(
        from address: SignalServiceAddress,
        transaction: SDSAnyReadTransaction
    ) -> TSInteraction? {
        let sql = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .threadUniqueId) = ?
            AND (
                \(interactionColumn: .authorUUID) = ?
                OR (\(interactionColumn: .authorUUID) IS NULL AND \(interactionColumn: .authorPhoneNumber) = ?)
            )
            ORDER BY \(interactionColumn: .id) DESC
            LIMIT 1
        """
        let arguments: StatementArguments = [threadUniqueId, address.serviceIdUppercaseString, address.phoneNumber]
        return TSInteraction.grdbFetchOne(
            sql: sql,
            arguments: arguments,
            transaction: transaction.unwrapGrdbRead
        )
    }

    private var mostRecentInteractionSqlAndArgs: (String, StatementArguments) {
        return (
            """
                SELECT *
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                \(Self.filterGroupStoryRepliesClause())
                \(Self.filterEditHistoryClause())
                AND \(interactionColumn: .errorType) IS NOT ?
                AND \(interactionColumn: .messageType) IS NOT ?
                AND \(interactionColumn: .messageType) IS NOT ?
                ORDER BY \(interactionColumn: .id) DESC
            """,
            [
                threadUniqueId,
                TSErrorMessageType.nonBlockingIdentityChange.rawValue,
                TSInfoMessageType.verificationStateChange.rawValue,
                TSInfoMessageType.profileUpdate.rawValue
            ]
        )
    }

    func mostRecentInteraction(
        transaction: SDSAnyReadTransaction
    ) -> TSInteraction? {
        let (sql, args) = mostRecentInteractionSqlAndArgs
        let firstInteractionSql = sql + " LIMIT 1"
        return TSInteraction.grdbFetchOne(
            sql: firstInteractionSql,
            arguments: args,
            transaction: transaction.unwrapGrdbRead
        )
    }

    @objc
    public func mostRecentInteractionForInbox(
        transaction: SDSAnyReadTransaction
    ) -> TSInteraction? {
        guard let firstInteraction = mostRecentInteraction(transaction: transaction) else {
            return nil
        }

        // We can't exclude specific group updates in the query.
        // In the (mildly) rare case that the most recent message
        // is a group update that shouldn't be shown,
        // we iterate backward until we find a good interaction.
        if firstInteraction.shouldAppearInInbox(transaction: transaction) {
            return firstInteraction
        }
        do {
            let (sql, args) = mostRecentInteractionSqlAndArgs
            let cursor = TSInteraction.grdbFetchCursor(
                sql: sql,
                arguments: args,
                transaction: transaction.unwrapGrdbRead
            )
            while let interaction = try cursor.next() {
                if interaction.shouldAppearInInbox(transaction: transaction) {
                    return interaction
                }
            }
            return nil
        } catch {
            owsFailDebug("Error: \(error)")
            return nil
        }
    }

    public func unreadCount(transaction: SDSAnyReadTransaction) -> UInt {
        do {
            let sql = """
                SELECT COUNT(*)
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                AND \(InteractionFinder.sqlClauseForUnreadInteractionCounts())
            """
            let arguments: StatementArguments = [threadUniqueId]

            guard let count = try UInt.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: arguments
            ) else {
                owsFailDebug("count was unexpectedly nil")
                return 0
            }
            return count
        } catch {
            owsFailDebug("error: \(error)")
            return 0
        }
    }

    @objc
    public func enumerateInteractionIds(
        transaction: SDSAnyReadTransaction,
        block: @escaping (String, UnsafeMutablePointer<ObjCBool>) -> Void
    ) throws {
        let cursor = try String.fetchCursor(
            transaction.unwrapGrdbRead.database,
            sql: """
                SELECT \(interactionColumn: .uniqueId)
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                ORDER BY \(interactionColumn: .id) DESC
            """,
            arguments: [threadUniqueId]
        )

        while let uniqueId = try cursor.next() {
            var stop: ObjCBool = false
            block(uniqueId, &stop)
            if stop.boolValue {
                return
            }
        }
    }

    /// Enumerates all the unread interactions in this thread, sorted by sort id.
    public func fetchAllUnreadMessages(
        transaction: SDSAnyReadTransaction
    ) -> SDSMappedCursor<TSInteractionCursor, OWSReadTracking> {
        let sql = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .threadUniqueId) = ?
            AND \(Self.sqlClauseForAllUnreadInteractions(excludeReadEdits: true))
            ORDER BY \(interactionColumn: .id)
        """

        let cursor = TSInteraction.grdbFetchCursor(
            sql: sql,
            arguments: [threadUniqueId],
            transaction: transaction.unwrapGrdbRead
        )
        return cursor.compactMap { interaction -> OWSReadTracking? in
            guard let readTracking = interaction as? OWSReadTracking else {
                owsFailDebug("Interaction has unexpected type: \(type(of: interaction))")
                return nil
            }
            guard !readTracking.wasRead else {
                owsFailDebug("Unexpectedly found read interaction: \(interaction.timestamp)")
                return nil
            }
            return readTracking
        }
    }

    /// Do we have any messages to mark read in this thread before a given sort ID?
    ///
    /// See also: ``fetchUnreadMessages`` and ``fetchMessagesWithUnreadReactions``.
    public func hasMessagesToMarkRead(
        beforeSortId: UInt64,
        transaction: SDSAnyReadTransaction
    ) -> Bool {
        let hasUnreadMessages = (try? Bool.fetchOne(
            transaction.unwrapGrdbRead.database,
            sql: """
            SELECT EXISTS (
                SELECT 1
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                AND \(interactionColumn: .id) <= ?
                AND \(Self.sqlClauseForAllUnreadInteractions())
                LIMIT 1
            )
            """,
            arguments: [threadUniqueId, beforeSortId]
        )) ?? false

        lazy var hasOutgoingMessagesWithUnreadReactions = (try? Bool.fetchOne(
            transaction.unwrapGrdbRead.database,
            sql: """
            SELECT EXISTS (
                SELECT 1
                FROM \(InteractionRecord.databaseTableName) AS interaction
                INNER JOIN \(OWSReaction.databaseTableName) AS reaction
                    ON interaction.\(interactionColumn: .uniqueId) = reaction.\(OWSReaction.columnName(.uniqueMessageId))
                    AND reaction.\(OWSReaction.columnName(.read)) IS 0
                WHERE interaction.\(interactionColumn: .recordType) IS \(SDSRecordType.outgoingMessage.rawValue)
                AND interaction.\(interactionColumn: .threadUniqueId) = ?
                AND interaction.\(interactionColumn: .id) <= ?
                LIMIT 1
            )
            """,
            arguments: [threadUniqueId, beforeSortId]
        )) ?? false

        return hasUnreadMessages || hasOutgoingMessagesWithUnreadReactions
    }

    /// Enumerates all the unread interactions in this thread before a given sort id,
    /// sorted by sort id.
    ///
    /// See also: ``hasMessagesToMarkRead``.
    public func fetchUnreadMessages(
        beforeSortId: UInt64,
        transaction: SDSAnyReadTransaction
    ) -> SDSMappedCursor<TSInteractionCursor, OWSReadTracking> {
        let sql = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .threadUniqueId) = ?
            AND \(interactionColumn: .id) <= ?
            AND \(Self.sqlClauseForAllUnreadInteractions())
            ORDER BY \(interactionColumn: .id)
        """

        let cursor = TSInteraction.grdbFetchCursor(
            sql: sql,
            arguments: [threadUniqueId, beforeSortId],
            transaction: transaction.unwrapGrdbRead
        )
        return cursor.compactMap { interaction -> OWSReadTracking? in
            guard let readTracking = interaction as? OWSReadTracking else {
                owsFailDebug("Interaction has unexpected type: \(type(of: interaction))")
                return nil
            }
            guard !readTracking.wasRead else {
                owsFailDebug("Unexpectedly found read interaction: \(interaction.timestamp)")
                return nil
            }
            return readTracking
        }
    }

    /// Returns all the messages with unread reactions in this thread before a given sort id,
    /// sorted by sort id.
    ///
    /// See also: ``hasMessagesToMarkRead``.
    public func fetchMessagesWithUnreadReactions(
        beforeSortId: UInt64,
        transaction: SDSAnyReadTransaction
    ) -> SDSMappedCursor<TSInteractionCursor, TSOutgoingMessage> {
        let sql = """
            SELECT interaction.*
            FROM \(InteractionRecord.databaseTableName) AS interaction
            INNER JOIN \(OWSReaction.databaseTableName) AS reaction
                ON interaction.\(interactionColumn: .uniqueId) = reaction.\(OWSReaction.columnName(.uniqueMessageId))
                AND reaction.\(OWSReaction.columnName(.read)) IS 0
            WHERE interaction.\(interactionColumn: .recordType) IS \(SDSRecordType.outgoingMessage.rawValue)
            AND interaction.\(interactionColumn: .threadUniqueId) = ?
            AND interaction.\(interactionColumn: .id) <= ?
            GROUP BY interaction.\(interactionColumn: .id)
            ORDER BY interaction.\(interactionColumn: .id)
        """

        let cursor = TSOutgoingMessage.grdbFetchCursor(
            sql: sql,
            arguments: [threadUniqueId, beforeSortId],
            transaction: transaction.unwrapGrdbRead
        )
        return cursor.compactMap { $0 as? TSOutgoingMessage }
    }

    public func oldestUnreadInteraction(transaction: SDSAnyReadTransaction) throws -> TSInteraction? {
        let sql = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .threadUniqueId) = ?
            AND \(Self.sqlClauseForAllUnreadInteractions(excludeReadEdits: true))
            ORDER BY \(interactionColumn: .id)
        """
        let cursor = TSInteraction.grdbFetchCursor(
            sql: sql,
            arguments: [threadUniqueId],
            transaction: transaction.unwrapGrdbRead
        )
        return try cursor.next()
    }

    @objc
    public func firstInteraction(
        atOrAroundSortId sortId: UInt64,
        transaction: SDSAnyReadTransaction
    ) -> TSInteraction? {
        guard sortId > 0 else { return nil }

        // First, see if there's an interaction at or before this sortId.

        let atOrBeforeQuery = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .threadUniqueId) = ?
            AND \(interactionColumn: .id) <= ?
            \(Self.filterEditHistoryClause())
            ORDER BY \(interactionColumn: .id) DESC
            LIMIT 1
        """
        let arguments: StatementArguments = [threadUniqueId, sortId]

        if let interactionAtOrBeforeSortId = TSInteraction.grdbFetchOne(
            sql: atOrBeforeQuery,
            arguments: arguments,
            transaction: transaction.unwrapGrdbRead
        ) {
            return interactionAtOrBeforeSortId
        }

        // If there wasn't an interaction at or before this sortId,
        // look for the first interaction *after* this sort id.

        let afterQuery = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .threadUniqueId) = ?
            AND \(interactionColumn: .id) > ?
            \(Self.filterEditHistoryClause())
            ORDER BY \(interactionColumn: .id) ASC
            LIMIT 1
        """

        return TSInteraction.grdbFetchOne(
            sql: afterQuery,
            arguments: arguments,
            transaction: transaction.unwrapGrdbRead
        )
    }

    public func existsOutgoingMessage(transaction: SDSAnyReadTransaction) -> Bool {
        let sql = """
            SELECT EXISTS(
                SELECT 1
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                AND \(interactionColumn: .recordType) = ?
                LIMIT 1
            )
        """
        let arguments: StatementArguments = [
            threadUniqueId,
            SDSRecordType.outgoingMessage.rawValue
        ]
        do {
            return try Bool.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: arguments
            ) ?? false
        } catch {
            DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
                userDefaults: CurrentAppContext().appUserDefaults(),
                error: error
            )
            owsFail("Failed to find outgoing message")
        }
    }

    func hasGroupUpdateInfoMessage(transaction: SDSAnyReadTransaction) -> Bool {
        let sql = """
            SELECT EXISTS(
                SELECT 1
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                AND \(interactionColumn: .recordType) = \(SDSRecordType.infoMessage.rawValue)
                AND \(interactionColumn: .messageType) = \(TSInfoMessageType.typeGroupUpdate.rawValue)
                LIMIT 1
            )
        """

        let arguments: StatementArguments = [threadUniqueId]
        do {
            return try Bool.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: arguments
            )!
        } catch {
            DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
                userDefaults: CurrentAppContext().appUserDefaults(),
                error: error
            )
            owsFail("Failed to find info message")
        }
    }

    public func enumerateRecentGroupUpdateMessages(
        transaction: SDSAnyReadTransaction,
        block: (TSInfoMessage, UnsafeMutablePointer<ObjCBool>) -> Void
    ) throws {
        // In DEBUG builds, confirm that we use the expected index.
        let indexedBy: String
        #if DEBUG
        indexedBy = "INDEXED BY index_model_TSInteraction_on_uniqueThreadId_recordType_messageType"
        #else
        indexedBy = ""
        #endif

        let sql = """
            SELECT *
            FROM \(InteractionRecord.databaseTableName)
            \(indexedBy)
            WHERE \(interactionColumn: .threadUniqueId) = ?
            AND \(interactionColumn: .recordType) = \(SDSRecordType.infoMessage.rawValue)
            AND \(interactionColumn: .messageType) = \(TSInfoMessageType.typeGroupUpdate.rawValue)
            ORDER BY \(interactionColumn: .id) DESC
        """

        let cursor = TSInfoMessage.grdbFetchCursor(
            sql: sql,
            arguments: [threadUniqueId],
            transaction: transaction.unwrapGrdbRead
        )

        while let interaction = try cursor.next() {
            guard let infoMessage = interaction as? TSInfoMessage else { return }
            var stop: ObjCBool = false
            block(infoMessage, &stop)
            if stop.boolValue {
                return
            }
        }
    }

    public func hasUserReportedSpam(transaction: SDSAnyReadTransaction) -> Bool {
        // In DEBUG builds, confirm that we use the expected index.
        let indexedBy: String
        #if DEBUG
        indexedBy = "INDEXED BY index_model_TSInteraction_on_uniqueThreadId_recordType_messageType"
        #else
        indexedBy = ""
        #endif

        let sql = """
            SELECT EXISTS(
                SELECT 1
                FROM \(InteractionRecord.databaseTableName)
                \(indexedBy)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                AND \(interactionColumn: .recordType) = \(SDSRecordType.infoMessage.rawValue)
                AND \(interactionColumn: .messageType) = \(TSInfoMessageType.reportedSpam.rawValue)
                LIMIT 1
            )
        """

        let arguments: StatementArguments = [threadUniqueId]
        do {
            return try Bool.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: arguments
            )!
        } catch {
            DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
                userDefaults: CurrentAppContext().appUserDefaults(),
                error: error
            )
            owsFail("Failed to find info message")
        }
    }

    func hasUserInitiatedInteraction(transaction: SDSAnyReadTransaction) -> Bool {
        let infoMessageTypes: [TSInfoMessageType] = [
            .typeGroupQuit,
            .typeGroupUpdate,
            .typeSessionDidEnd,
            .typeDisappearingMessagesUpdate,
            .unknownProtocolVersion
        ]

        let errorMessageInteractions: [SDSRecordType] = [
            .errorMessage,
            .recoverableDecryptionPlaceholder
        ]
        let errorMessageTypes: [TSErrorMessageType] = [
            .noSession,
            .wrongTrustedIdentityKey,
            .invalidKeyException,
            .missingKeyId,
            .invalidMessage,
            .duplicateMessage,
            .groupCreationFailed,
            .sessionRefresh,
            .decryptionFailure
        ]

        let interactionTypes: [SDSRecordType] = [
            .incomingMessage,
            .outgoingMessage,
            .disappearingConfigurationUpdateInfoMessage,
            .unknownProtocolVersionMessage,
            .call,
            .groupCallMessage,
            .verificationStateChangeMessage
        ]

        let sql = """
            SELECT EXISTS(
                SELECT 1
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                AND (
                    (
                        \(interactionColumn: .recordType) = \(SDSRecordType.infoMessage.rawValue)
                        AND \(interactionColumn: .messageType) IN (\(infoMessageTypes.map { "\($0.rawValue)" }.joined(separator: ",")))
                    ) OR (
                        \(interactionColumn: .recordType) IN (\(errorMessageInteractions.map { "\($0.rawValue)" }.joined(separator: ",")))
                        AND \(interactionColumn: .errorType) IN (\(errorMessageTypes.map { "\($0.rawValue)" }.joined(separator: ",")))
                    ) OR \(interactionColumn: .recordType) IN (\(interactionTypes.map { "\($0.rawValue)" }.joined(separator: ",")))
                )
                \(Self.filterGroupStoryRepliesClause())
                \(Self.filterEditHistoryClause())
                LIMIT 1
            )
        """
        let arguments: StatementArguments = [threadUniqueId]

        do {
            return try Bool.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: arguments
            )!
        } catch {
            DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
                userDefaults: CurrentAppContext().appUserDefaults(),
                error: error
            )
            owsFail("Failed to determine interaction")
        }
    }

    func possiblyHasIncomingMessages(transaction: SDSAnyReadTransaction) -> Bool {
        // All of these message types could have been triggered by anyone in
        // the conversation. So, if one of them exists we have to assume the conversation
        // *might* have received messages. At some point it'd be nice to refactor this to
        // be more explicit, but not all our interaction types allow for that level of
        // granularity presently.

        let interactionTypes: [SDSRecordType] = [
            .incomingMessage,
            .disappearingConfigurationUpdateInfoMessage,
            .unknownProtocolVersionMessage,
            .verificationStateChangeMessage,
            .call,
            .errorMessage,
            .recoverableDecryptionPlaceholder,
            .invalidIdentityKeyErrorMessage,
            .invalidIdentityKeyReceivingErrorMessage,
            .invalidIdentityKeySendingErrorMessage
        ]

        let sqlInteractionTypes = interactionTypes.map { "\($0.rawValue)" }.joined(separator: ",")

        let sql = """
            SELECT EXISTS(
                SELECT 1
                FROM \(InteractionRecord.databaseTableName)
                WHERE \(interactionColumn: .threadUniqueId) = ?
                AND \(interactionColumn: .recordType) IN (\(sqlInteractionTypes))
                LIMIT 1
            )
        """
        let arguments: StatementArguments = [threadUniqueId]

        do {
            return try Bool.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: arguments
            )!
        } catch {
            DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
                userDefaults: CurrentAppContext().appUserDefaults(),
                error: error
            )
            owsFail("Failed to find Incoming message")
        }
    }

    public func outgoingMessageCount(transaction: SDSAnyReadTransaction) -> UInt {
        let sql = """
            SELECT COUNT(*)
            FROM \(InteractionRecord.databaseTableName)
            WHERE \(interactionColumn: .threadUniqueId) = ?
            AND \(interactionColumn: .recordType) = ?
        """
        let arguments: StatementArguments = [
            threadUniqueId,
            SDSRecordType.outgoingMessage.rawValue
        ]

        do {
            return try UInt.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: sql,
                arguments: arguments
            ) ?? 0
        } catch {
            DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
                userDefaults: CurrentAppContext().appUserDefaults(),
                error: error
            )
            owsFail("Failed to determine message count")
        }
    }

    // MARK: - Fetch by Row ID

    public enum RowIdFilter {
        case newest
        case atOrBefore(Int64)
        case before(Int64)
        case after(Int64)
        case range(ClosedRange<Int64>)
    }

    /// Fetch interaction unique IDs covered by this finder, filtered and
    /// ordered as they should appear in the conversation view.
    public func fetchUniqueIdsForConversationView(
        rowIdFilter: RowIdFilter,
        limit: Int,
        tx: SDSAnyReadTransaction
    ) throws -> [String] {
        let (rowIdClause, arguments, isAscending) = sqlClauseForInteractionsByRowId(
            rowIdFilter: rowIdFilter,
            additionalFiltering: .filterForConversationView,
            limit: limit
        )

        let uniqueIds = try String.fetchAll(
            tx.unwrapGrdbRead.database,
            sql: """
                SELECT "uniqueId" FROM \(InteractionRecord.databaseTableName)
                \(rowIdClause)
            """,
            arguments: arguments
        )

        return isAscending ? uniqueIds : Array(uniqueIds.reversed())
    }

    @objc
    @available(swift, obsoleted: 1.0)
    public func enumerateRecentInteractionsForConversationView(
        transaction tx: SDSAnyReadTransaction,
        block: (TSInteraction) -> Bool
    ) throws {
        try enumerateInteractionsForConversationView(
            rowIdFilter: .newest,
            tx: tx,
            block: block
        )
    }

    /// Enumerate interactions covered by this finder, filtered and ordered as
    /// they should appear in the conversation view.
    ///
    /// - Parameter block
    /// A block executed for each enumerated interaction. Returns `true` if
    /// enumeration should continue, and `false` otherwise.
    public func enumerateInteractionsForConversationView(
        rowIdFilter: RowIdFilter,
        tx: SDSAnyReadTransaction,
        block: (TSInteraction) -> Bool
    ) throws {
        try buildInteractionCursor(
            rowIdFilter: rowIdFilter,
            additionalFiltering: .filterForConversationView,
            limit: nil,
            tx: tx
        ).enumerate(block: block)
    }

    /// Fetch all interactions covered by this finder.
    func fetchAllInteractions(
        rowIdFilter: RowIdFilter,
        limit: Int,
        tx: SDSAnyReadTransaction
    ) throws -> [TSInteraction] {
        var interactions: [TSInteraction] = []

        try buildInteractionCursor(
            rowIdFilter: rowIdFilter,
            additionalFiltering: .noFiltering,
            limit: limit,
            tx: tx
        ).enumerate { interaction -> Bool in
            interactions.append(interaction)
            return true
        }

        return interactions
    }

    /// Returns a cursor over all ``TSIncomingMessage``s covered by this finder
    /// that returns its next element in O(1) time.
    ///
    /// - Important
    /// This cursor may not outlive the given transaction!
    func buildIncomingMessagesCursor(
        rowIdFilter: RowIdFilter,
        tx: SDSAnyReadTransaction
    ) -> TSInteractionCursor {
        return buildInteractionCursor(
            rowIdFilter: rowIdFilter,
            additionalFiltering: .filterForIncomingMessages,
            limit: nil,
            tx: tx
        )
    }

    /// Returns a cursor over all ``TSOutgoingMessage``s covered by this finder
    /// that returns its next element in O(1) time.
    ///
    /// - Important
    /// This cursor may not outlive the given transaction!
    func buildOutgoingMessagesCursor(
        rowIdFilter: RowIdFilter,
        tx: SDSAnyReadTransaction
    ) -> TSInteractionCursor {
        return buildInteractionCursor(
            rowIdFilter: rowIdFilter,
            additionalFiltering: .filterForIncomingMessages,
            limit: nil,
            tx: tx
        )
    }

    /// Options for configuring the SQL clause to fetch interactions by row ID.
    ///
    /// - Important
    /// At the time of writing, all cases included here result in a SQL clause
    /// that is supported by a database index and is therefore fast. Take care
    /// when updating these options that the resulting SQL clause does not
    /// result in queries that will *not* be supported by an index.
    private enum InteractionsByRowIdAdditionalFiltering {
        /// Filter the fetched interactions as appropriate for the conversation
        /// view. This includes filtering out decryption placeholders, group
        /// story replies, and edit history.
        ///
        /// Relies on `index_model_TSInteraction_UnreadMessages`.
        case filterForConversationView

        /// Filter the fetched interactions to ``TSIncomingMessage``s.
        ///
        /// Relies on `index_interactions_on_recordType_and_threadUniqueId_and_errorType`,
        /// by passing a `NULL` error type since no incoming message will
        /// have that column populated.
        case filterForIncomingMessages

        /// Filter the fetched interactions to ``TSOutgoingMessage``s.
        ///
        /// Relies on `index_interactions_on_recordType_and_threadUniqueId_and_errorType`,
        /// by passing a `NULL` error type since no outgoing message will
        /// have that column populated.
        case filterForOutgoingMessages

        /// Do no additional filtering. This will return all interactions.
        ///
        /// Relies on `index_interactions_on_threadUniqueId_and_id`.
        case noFiltering
    }

    private func buildInteractionCursor(
        rowIdFilter: RowIdFilter,
        additionalFiltering: InteractionsByRowIdAdditionalFiltering,
        limit: Int?,
        tx: SDSAnyReadTransaction
    ) -> TSInteractionCursor {
        let (rowIdClause, arguments, _) = sqlClauseForInteractionsByRowId(
            rowIdFilter: rowIdFilter,
            additionalFiltering: additionalFiltering,
            limit: limit
        )

        return TSInteraction.grdbFetchCursor(
            sql: """
                SELECT * FROM \(InteractionRecord.databaseTableName)
                \(rowIdClause)
            """,
            arguments: arguments,
            transaction: tx.unwrapGrdbRead
        )
    }

    private func sqlClauseForInteractionsByRowId(
        rowIdFilter: RowIdFilter,
        additionalFiltering: InteractionsByRowIdAdditionalFiltering,
        limit: Int?
    ) -> (String, StatementArguments, isAscending: Bool) {
        let rowIdFilterClause: String
        let rowIdArguments: StatementArguments
        let isAscending: Bool
        switch rowIdFilter {
        case .newest:
            rowIdFilterClause = ""
            rowIdArguments = []
            isAscending = false
        case .atOrBefore(let rowId):
            rowIdFilterClause = "AND \(interactionColumn: .id) <= ?"
            rowIdArguments = [rowId]
            isAscending = false
        case .before(let rowId):
            rowIdFilterClause = "AND \(interactionColumn: .id) < ?"
            rowIdArguments = [rowId]
            isAscending = false
        case .after(let rowId):
            rowIdFilterClause = "AND \(interactionColumn: .id) > ?"
            rowIdArguments = [rowId]
            isAscending = true
        case .range(let rowIds):
            rowIdFilterClause = "AND \(interactionColumn: .id) >= ? AND \(interactionColumn: .id) <= ?"
            rowIdArguments = [rowIds.lowerBound, rowIds.upperBound]
            isAscending = true
        }

        let additionalFilterClause: String = switch additionalFiltering {
        case .filterForConversationView:
            """
            \(Self.filterGroupStoryRepliesClause())
            \(Self.filterEditHistoryClause())
            \(Self.filterPlaceholdersClause)
            """
        case .filterForIncomingMessages:
            "AND recordType = \(SDSRecordType.incomingMessage.rawValue) AND errorType is NULL"
        case .filterForOutgoingMessages:
            "AND recordType = \(SDSRecordType.outgoingMessage.rawValue) AND errorType is NULL"
        case .noFiltering:
            ""
        }

        var sql = """
            WHERE
                \(interactionColumn: .threadUniqueId) = ?
                \(rowIdFilterClause)
                \(additionalFilterClause)
            ORDER BY \(interactionColumn: .id) \(isAscending ? "ASC" : "DESC")
        """
        if let limit {
            sql += " LIMIT \(limit)"
        }

        let arguments: StatementArguments = [threadUniqueId] + rowIdArguments

        return (sql, arguments, isAscending)
    }

    // MARK: -

    /// The SQLite row ID of the most-recently inserted interaction covered by
    /// this finder.
    func mostRecentRowId(tx: SDSAnyReadTransaction) -> Int64 {
        var mostRecentRowId: Int64 = 0

        try? buildInteractionCursor(
            rowIdFilter: .newest,
            additionalFiltering: .noFiltering,
            limit: 1,
            tx: tx
        ).enumerate { mostRecentInteraction -> Bool in
            mostRecentRowId = mostRecentInteraction.sqliteRowId!
            return false
        }

        return mostRecentRowId
    }

    public static func maxRowId(transaction: SDSAnyReadTransaction) -> Int {
        do {
            return try Int.fetchOne(
                transaction.unwrapGrdbRead.database,
                sql: "SELECT MAX(id) FROM model_TSInteraction"
            ) ?? 0
        } catch {
            DatabaseCorruptionState.flagDatabaseReadCorruptionIfNecessary(
                userDefaults: CurrentAppContext().appUserDefaults(),
                error: error
            )
            owsFail("Failed to find max row id")
        }
    }
}

private extension TSInteractionCursor {
    func enumerate(block: (TSInteraction) -> Bool) throws {
        while
            let interaction = try next(),
            block(interaction)
        {}
    }
}

// MARK: - Clauses

private extension InteractionFinder {

    private static func sqlClauseForAllUnreadInteractions(
        excludeReadEdits: Bool = false
    ) -> String {
        let recordTypes: [SDSRecordType] = [
            .disappearingConfigurationUpdateInfoMessage,
            .unknownProtocolVersionMessage,
            .verificationStateChangeMessage,
            .call,
            .groupCallMessage,
            .errorMessage,
            .recoverableDecryptionPlaceholder,
            .incomingMessage,
            .incomingPaymentMessage,
            .infoMessage,
            .invalidIdentityKeyErrorMessage,
            .invalidIdentityKeyReceivingErrorMessage,
            .invalidIdentityKeySendingErrorMessage
        ]

        let recordTypesSql = recordTypes.map { "\($0.rawValue)" }.joined(separator: ",")
        let editQueryMode: EditMessageQueryMode = excludeReadEdits ? .excludeReadEdits : .includeAllEdits

        return """
        (
            \(interactionColumn: .read) IS 0
            \(Self.filterGroupStoryRepliesClause())
            \(self.filterEditHistoryClause(mode: editQueryMode))
            AND \(interactionColumn: .recordType) IN (\(recordTypesSql))
        )
        """
    }

    private static func sqlClauseForUnreadInteractionCounts(
        interactionsAlias: String? = nil
    ) -> String {
        let columnPrefix: String
        if let interactionsAlias = interactionsAlias {
            columnPrefix = interactionsAlias + "."
        } else {
            columnPrefix = ""
        }

        return """
        \(columnPrefix)\(interactionColumn: .read) IS 0
        \(Self.filterGroupStoryRepliesClause(interactionsAlias: interactionsAlias))
        \(Self.filterEditHistoryClause(mode: .excludeReadEdits, interactionsAlias: interactionsAlias))
        AND (
            \(columnPrefix)\(interactionColumn: .recordType) IS \(SDSRecordType.incomingMessage.rawValue)
            OR (
                \(columnPrefix)\(interactionColumn: .recordType) IS \(SDSRecordType.infoMessage.rawValue)
                AND \(columnPrefix)\(interactionColumn: .messageType) IS \(TSInfoMessageType.userJoinedSignal.rawValue)
            )
        )
        """
    }

    private static func sqlClauseForIgnoringInteractionsWithMutedThread(threadAssociatedDataAlias: String) -> String {
        """
            AND (
                \(threadAssociatedDataAlias).mutedUntilTimestamp <= strftime('%s','now') * 1000
                OR \(threadAssociatedDataAlias).mutedUntilTimestamp = 0
            )
        """
    }

    // From: https://www.sqlite.org/optoverview.html
    // This clause has been tuned hand-in-hand with the index_model_TSInteraction_on_nonPlaceholders_uniqueThreadId_id index
    // If you need to adjust this clause, you should probably update the index as well. This is a perf sensitive code path.
    static let filterPlaceholdersClause = "AND \(interactionColumn: .recordType) IS NOT \(SDSRecordType.recoverableDecryptionPlaceholder.rawValue)"

    static func filterGroupStoryRepliesClause(interactionsAlias: String? = nil) -> String {
        let columnPrefix: String
        if let interactionsAlias = interactionsAlias {
            columnPrefix = interactionsAlias + "."
        } else {
            columnPrefix = ""
        }

        // Treat NULL and 0 as equivalent.
        return "AND \(columnPrefix)\(interactionColumn: .isGroupStoryReply) IS NOT 1"
    }

    static func filterEditHistoryClause(
        mode: EditMessageQueryMode = .includeAllEdits,
        interactionsAlias: String? = nil
    ) -> String {
        let columnPrefix: String
        if let interactionsAlias = interactionsAlias {
            columnPrefix = interactionsAlias + "."
        } else {
            columnPrefix = ""
        }

        /// We need to ensure that whatever clauses we return here appropriately
        /// handle `NULL` values for `editState.
        ///
        /// Specifically, only ``TSMessage`` descendants will have a non-`NULL`
        /// `editState`, since it refers to the ``TSMessage/editState`` column.
        /// However, we don't want this clause to necessarily exclude those
        /// (non-``TSMessage``) interactions with `editState = NULL`.
        switch mode {
        case .includeAllEdits:
            /// Using `IS NOT` includes `NULL`.
            return "AND \(columnPrefix)\(interactionColumn: .editState) IS NOT \(TSEditState.pastRevision.rawValue)"
        case .excludeReadEdits:
            return """
            AND (
                \(columnPrefix)\(interactionColumn: .editState) IN (\(TSEditState.none.rawValue), \(TSEditState.latestRevisionUnread.rawValue))
                OR \(columnPrefix)\(interactionColumn: .editState) IS NULL
            )
            """
        case .excludeAllEdits:
            return "AND \(columnPrefix)\(interactionColumn: .editState) IS \(TSEditState.none.rawValue)"
        }
    }
}
