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

import Foundation

@objcMembers
public class MessageBody: NSObject, NSCopying, NSSecureCoding {

    typealias Style = MessageBodyRanges.Style

    public static var supportsSecureCoding = true
    public static let mentionPlaceholder = "\u{FFFC}" // Object Replacement Character

    public let text: String
    public let ranges: MessageBodyRanges
    public var hasMentions: Bool { ranges.hasMentions }

    public init(text: String, ranges: MessageBodyRanges) {
        self.text = text
        self.ranges = ranges
    }

    public required init?(coder: NSCoder) {
        guard let text = coder.decodeObject(of: NSString.self, forKey: "text") as String? else {
            owsFailDebug("Missing text")
            return nil
        }

        guard let ranges = coder.decodeObject(of: MessageBodyRanges.self, forKey: "ranges") else {
            owsFailDebug("Missing ranges")
            return nil
        }

        self.text = text
        self.ranges = ranges
    }

    public func copy(with zone: NSZone? = nil) -> Any {
        return MessageBody(text: text, ranges: ranges)
    }

    public func encode(with coder: NSCoder) {
        coder.encode(text, forKey: "text")
        coder.encode(ranges, forKey: "ranges")
    }

    // TODO[TextFormatting]: "plaintext" here is misleading; depending on usage we may
    // want to apply some styles.
    public func plaintextBody(transaction: GRDBReadTransaction) -> String {
        let hydratedMessageBody = hydratingMentions(hydrator: { mentionUUID in
            return .hydrate(
                Self.contactsManager.displayName(
                    for: SignalServiceAddress(uuid: mentionUUID),
                    transaction: transaction.asAnyRead
                ),
                alreadyIncludesPrefix: false
            )
        })
        return (hydratedMessageBody.text as NSString).filterStringForDisplay()
    }

    public func forNewContext(
        _ context: TSThread,
        transaction: GRDBReadTransaction
    ) -> MessageBody {
        guard hasMentions else {
            return self
        }

        let isGroupThread: Bool
        let recipientAddresses: Set<SignalServiceAddress>
        if let groupThread = context as? TSGroupThread, groupThread.isGroupV2Thread {
            isGroupThread = true
            recipientAddresses = Set(groupThread.recipientAddresses(with: transaction.asAnyRead))
        } else {
            isGroupThread = false
            recipientAddresses = .init()
        }

        return hydratingMentions(hydrator: { mentionUuid in
            let address = SignalServiceAddress(uuid: mentionUuid)
            if isGroupThread, recipientAddresses.contains(address) {
                // We don't want to hydrate for mentions in the destination group;
                // these can be hydrated on the fly with group member information.
                // Those not in the group are hydrated with a snapshot of their
                // contact info that we have; the other group members might not
                // even know about them.
                return .preserveMention
            } else {
                let displayName = Self.contactsManager.displayName(
                    for: address,
                    transaction: transaction.asAnyRead
                )
                return .hydrate(displayName, alreadyIncludesPrefix: false)
            }
        })
    }

    internal func hydratingMentions(
        hydrator: (UUID) -> MessageBodyRanges.MentionHydrationOption,
        isRTL: Bool = CurrentAppContext().isRTL
    ) -> MessageBody {
        let text = NSMutableAttributedString(string: self.text)
        let ranges = ranges.hydratingMentions(
            in: text,
            hydrator: hydrator,
            isRTL: isRTL
        )
        return MessageBody(text: text.string, ranges: ranges)
    }

    override public func isEqual(_ object: Any?) -> Bool {
        guard let other = object as? MessageBody else {
            return false
        }
        guard text == other.text else {
            return false
        }
        guard ranges == other.ranges else {
            return false
        }
        return true
    }
}

@objcMembers
public class MessageBodyRanges: NSObject, NSCopying, NSSecureCoding {
    public static var supportsSecureCoding = true
    public static let mentionPrefix = "@"
    public static var empty: MessageBodyRanges { MessageBodyRanges(mentions: [:], styles: []) }

    // Styles are kept separate from mentions; mentions are not allowed to overlap,
    // which is partially enforced by its structure (it enforces they at least can't have
    // identical ranges) while styles can overlap with each other and
    // with mentions.

    /// Mentions can overlap with styles but not with each other.
    public let mentions: [NSRange: UUID]
    public var hasMentions: Bool { !mentions.isEmpty }

    /// Sorted from lowest location to highest location
    public let orderedMentions: [(NSRange, UUID)]

    public struct Style: OptionSet, Equatable, Hashable {
        public let rawValue: Int

        public init(rawValue: Int) {
            self.rawValue = rawValue
        }

        public static let bold = Style(rawValue: 1 << 0)
        public static let italic = Style(rawValue: 1 << 1)
        public static let spoiler = Style(rawValue: 1 << 2)
        public static let strikethrough = Style(rawValue: 1 << 3)
        public static let monospace = Style(rawValue: 1 << 4)

        static let attributedStringKey = NSAttributedString.Key("OWSStyle")
    }

    /// Sorted from lowest location to highest location.
    /// Styles can overlap with mentions but not with each other.
    /// If a style overlaps with _any_ part of a mention, it applies
    /// to the entire length of the mention.
    public let styles: [(NSRange, Style)]

    public var hasRanges: Bool {
        return mentions.isEmpty.negated || styles.isEmpty.negated
    }

    public init(mentions: [NSRange: UUID], styles: [(NSRange, Style)]) {
        self.mentions = mentions
        let orderedMentions = mentions.sorted(by: { $0.key.location < $1.key.location })
        self.orderedMentions = orderedMentions
        self.styles = Self.processStylesForInitialization(styles, orderedMentions: orderedMentions)

        super.init()
    }

    public convenience init(protos: [SSKProtoBodyRange]) {
        var mentions = [NSRange: UUID]()
        var styles = [(NSRange, Style)]()
        for proto in protos {
            let range = NSRange(location: Int(proto.start), length: Int(proto.length))
            if
                let mentionUuidString = proto.mentionUuid,
                let mentionUuid = UUID(uuidString: mentionUuidString)
            {
                mentions[range] = mentionUuid
            } else if let protoStyle = proto.style {
                let style: Style
                switch protoStyle {
                case .none:
                    continue
                case .bold:
                    style = .bold
                case .italic:
                    style = .italic
                case .spoiler:
                    style = .spoiler
                case .strikethrough:
                    style = .strikethrough
                case .monospace:
                    style = .monospace
                }
                styles.append((range, style))
            }
        }
        self.init(mentions: mentions, styles: styles)
    }

    public required init?(coder: NSCoder) {
        let mentionsCount = coder.decodeInteger(forKey: "mentionsCount")

        var mentions = [NSRange: UUID]()
        for idx in 0..<mentionsCount {
            guard let range = coder.decodeObject(of: NSValue.self, forKey: "mentions.range.\(idx)")?.rangeValue else {
                owsFailDebug("Failed to decode mention range key of MessageBody")
                return nil
            }
            guard let uuid = coder.decodeObject(of: NSUUID.self, forKey: "mentions.uuid.\(idx)") as UUID? else {
                owsFailDebug("Failed to decode mention range value of MessageBody")
                return nil
            }
            mentions[range] = uuid
        }

        self.mentions = mentions
        let orderedMentions = mentions.sorted(by: { $0.key.location < $1.key.location })
        self.orderedMentions = orderedMentions

        let stylesCount: Int = {
            let key = "stylesCount"
            guard coder.containsValue(forKey: key) else {
                // encoded values from before styles were added
                // have no styles; that's fine.
                return 0
            }
            return coder.decodeInteger(forKey: key)
        }()

        var styles = [(NSRange, Style)]()
        for idx in 0..<stylesCount {
            guard let range = coder.decodeObject(of: NSValue.self, forKey: "styles.range.\(idx)")?.rangeValue else {
                owsFailDebug("Failed to decode style range key of MessageBody")
                return nil
            }
            let style = Style(rawValue: coder.decodeInteger(forKey: "styles.style.\(idx)"))
            styles.append((range, style))
        }

        self.styles = Self.processStylesForInitialization(styles, orderedMentions: orderedMentions)
    }

    private static func processStylesForInitialization(
        _ styles: [(NSRange, Style)],
        orderedMentions: [(NSRange, UUID)]
    ) -> [(NSRange, Style)] {
        guard !styles.isEmpty else {
            return []
        }
        var maxUpperBound = orderedMentions.last?.0.upperBound ?? 0
        var sortedStyles = styles
            .lazy
            .filter { (range, _) in
                guard range.location >= 0 else {
                    return false
                }
                maxUpperBound = max(maxUpperBound, range.upperBound)
                return true
            }
            .sorted(by: { $0.0.location < $1.0.location })
        var orderedMentions = orderedMentions

        // Collapse all overlaps.
        var finalStyles = [(NSRange, Style)]()
        var collapsedStyleAtIndex: (start: Int, Style) = (start: 0, [])
        var endIndexToStyle = [Int: Style]()
        var styleToEndIndex = [Style: Int]()
        for i in 0..<maxUpperBound {
            var newStylesToApply: Style = []

            func startApplyingStyles(at index: Int) {
                while let (newRange, newStyle) = sortedStyles.first, newRange.location == index {
                    sortedStyles.removeFirst()
                    newStylesToApply.insert(newStyle)

                    // A new style starts here. But we might overlap with
                    // a style of the same type, in which case we should
                    // join them by taking the further of the two endpoints
                    let oldUpperBound = styleToEndIndex[newStyle]
                    if newRange.upperBound > (oldUpperBound ?? -1) {
                        styleToEndIndex[newStyle] = newRange.upperBound
                        var stylesAtEnd = endIndexToStyle[newRange.upperBound] ?? []
                        stylesAtEnd.insert(newStyle)
                        endIndexToStyle[newRange.upperBound] = stylesAtEnd
                        if let oldUpperBound {
                            var stylesAtExistingEnd = endIndexToStyle[oldUpperBound] ?? []
                            stylesAtExistingEnd.remove(newStyle)
                            endIndexToStyle[oldUpperBound] = stylesAtExistingEnd
                        }
                    }
                }
            }

            startApplyingStyles(at: i)
            let stylesToRemove = endIndexToStyle.removeValue(forKey: i) ?? []
            if stylesToRemove.isEmpty.negated {
                styleToEndIndex[stylesToRemove] = nil
            }

            if let mention = orderedMentions.first, mention.0.location == i {
                orderedMentions.removeFirst()
                if mention.0.length > 0 {
                    // Styles always apply to an entire mention. This means when we find
                    // a mention we have to do two things:
                    // 1) any styles that start later in the mention are treated as if they start now.
                    for j in i+1..<mention.0.upperBound {
                        startApplyingStyles(at: j)
                    }
                    // 2) make sure any active styles are extended to the end of the mention
                    for j in i..<mention.0.upperBound {
                        if let stylesEndingMidMention = endIndexToStyle.removeValue(forKey: j) {
                            var stylesAtNewEnd = endIndexToStyle[mention.0.upperBound] ?? []
                            stylesAtNewEnd.insert(stylesEndingMidMention)
                            endIndexToStyle[mention.0.upperBound] = stylesAtNewEnd
                        }
                    }
                }
            }

            if newStylesToApply.isEmpty.negated || stylesToRemove.isEmpty.negated {
                // We have changes. End the previous style if any, and start a new one.
                var (startIndex, currentCollapsedStyle) = collapsedStyleAtIndex
                if currentCollapsedStyle.isEmpty.negated {
                    finalStyles.append((NSRange(location: startIndex, length: i - startIndex), currentCollapsedStyle))
                }

                currentCollapsedStyle.remove(stylesToRemove)
                currentCollapsedStyle.insert(newStylesToApply)
                collapsedStyleAtIndex = (start: i, currentCollapsedStyle)
            }
        }

        if collapsedStyleAtIndex.1.isEmpty.negated {
            finalStyles.append((
                NSRange(
                    location: collapsedStyleAtIndex.start,
                    length: maxUpperBound - collapsedStyleAtIndex.start
                ),
                collapsedStyleAtIndex.1
            ))
        }

        return finalStyles
    }

    public func copy(with zone: NSZone? = nil) -> Any {
        return MessageBodyRanges(mentions: mentions, styles: styles)
    }

    public func encode(with coder: NSCoder) {
        coder.encode(mentions.count, forKey: "mentionsCount")
        for (idx, (range, uuid)) in mentions.enumerated() {
            coder.encode(NSValue(range: range), forKey: "mentions.range.\(idx)")
            coder.encode(uuid, forKey: "mentions.uuid.\(idx)")
        }
        coder.encode(styles.count, forKey: "stylesCount")
        for (idx, (range, style)) in styles.enumerated() {
            coder.encode(NSValue(range: range), forKey: "styles.range.\(idx)")
            coder.encode(style.rawValue, forKey: "styles.style.\(idx)")
        }
    }

    // TODO[TextFormatting]: "plaintext" here is misleading; depending on usage we may
    // want to apply some styles.
    public func plaintextBody(text: String, transaction: GRDBReadTransaction) -> String {
        return MessageBody(text: text, ranges: self).plaintextBody(transaction: transaction)
    }

    public enum MentionHydrationOption {
        /// Do not hydrate the mention; this leaves the string as it was in the original,
        /// which we want to do e.g. when forwarding a message with mentions from one
        /// thread context to another, where we hydrate the mentions of members not in
        /// the destination, but preserve mentions of shared members fully intact.
        case preserveMention
        /// Replace the mention range with the populated display name.
        case hydrate(String, alreadyIncludesPrefix: Bool)
        /// Replace the mention range with the populated display name and attributes.
        case hydrateAttributed(NSAttributedString, alreadyIncludesPrefix: Bool)
    }

    /// Hydrates mentions (as determined by the hydrator) and sets the `.owsStyle` attribute
    /// on the string for any styles but *does not* actually apply the styles. (e.g. no font attribute)
    /// `.owsStyle` attributes can be converted to attributes just before display, when the font
    /// and text color are available.
    public func hydrateMentionsAndSetStyleAttributes(
        on string: NSMutableAttributedString,
        hydrator: (UUID) -> MentionHydrationOption,
        isRTL: Bool = CurrentAppContext().isRTL
    ) {
        let newStyles = hydratingMentions(
            in: string,
            hydrator: hydrator,
            isRTL: isRTL
        )
        newStyles.setStyleAttributesWithoutApplying(on: string)
    }

    /// Applies hydrations to the provided `NSMutableAttributedString`, and returns any
    /// ranges left over (styles and preserved mentions) with ranges updated to reflect the new string.
    public func hydratingMentions(
        in text: NSMutableAttributedString,
        hydrator: (UUID) -> MentionHydrationOption,
        isRTL: Bool = CurrentAppContext().isRTL
    ) -> MessageBodyRanges {
        guard hasMentions else {
            return self
        }

        let finalText = text
        var finalMentions = [NSRange: UUID]()
        var finalStyles = [(NSRange, Style)]()

        var mentionsInOriginal = orderedMentions
        var stylesInOriginal = styles

        var rangeOffset = 0

        struct ProcessingStyle {
            let originalRange: NSRange
            let newRange: NSRange
            let style: Style
        }
        var styleAtCurrentIndex: ProcessingStyle?

        let startLength = text.length
        for currentIndex in 0..<startLength {
            // If we are past the end, apply the active style to the final result
            // and drop.
            if
                let style = styleAtCurrentIndex,
                currentIndex >= style.originalRange.upperBound
            {
                finalStyles.append((style.newRange, style.style))
                styleAtCurrentIndex = nil
            }
            // Check for any new styles starting at the current index.
            if stylesInOriginal.first?.0.contains(currentIndex) == true {
                let (originalRange, style) = stylesInOriginal.removeFirst()
                styleAtCurrentIndex = .init(
                    originalRange: originalRange,
                    newRange: NSRange(
                        location: originalRange.location + rangeOffset,
                        length: originalRange.length
                    ),
                    style: style
                )
            }

            // Check for any mentions at the current index.
            // Mentions can't overlap, so we don't need a while loop to check for multiple.
            guard
                let (originalMentionRange, mentionUuid) = mentionsInOriginal.first,
                (
                    originalMentionRange.contains(currentIndex)
                    || originalMentionRange.location == currentIndex
                )
            else {
                // No mentions, so no additional logic needed, just go to the next index.
                continue
            }
            mentionsInOriginal.removeFirst()

            let newMentionRange = NSRange(
                location: originalMentionRange.location + rangeOffset,
                length: originalMentionRange.length
            )

            let finalMentionLength: Int
            let mentionOffsetDelta: Int
            switch hydrator(mentionUuid) {
            case .preserveMention:
                // Preserve the mention without replacement and proceed.
                finalMentions[newMentionRange] = mentionUuid
                continue
            case let .hydrate(displayName, alreadyIncludesPrefix):
                let mentionPlaintext: String
                if alreadyIncludesPrefix {
                    mentionPlaintext = displayName
                } else {
                    if isRTL {
                        mentionPlaintext = displayName + MessageBodyRanges.mentionPrefix
                    } else {
                        mentionPlaintext = MessageBodyRanges.mentionPrefix + displayName
                    }
                }
                finalMentionLength = (mentionPlaintext as NSString).length
                mentionOffsetDelta = finalMentionLength - originalMentionRange.length
                finalText.replaceCharacters(in: newMentionRange, with: mentionPlaintext)
            case let .hydrateAttributed(displayName, alreadyIncludesPrefix):
                let mentionString: NSAttributedString
                if alreadyIncludesPrefix {
                    mentionString = displayName
                } else {
                    let base = NSMutableAttributedString(attributedString: displayName)
                    let replacement = NSAttributedString(string: MessageBodyRanges.mentionPrefix)
                    if isRTL {
                        base.replaceCharacters(in: NSRange(location: 0, length: 0), with: replacement)
                    } else {
                        base.replaceCharacters(in: NSRange(location: base.length, length: 0), with: replacement)
                    }
                    mentionString = base
                }
                finalMentionLength = mentionString.length
                mentionOffsetDelta = finalMentionLength - originalMentionRange.length
                finalText.replaceCharacters(in: newMentionRange, with: mentionString)
            }
            rangeOffset += mentionOffsetDelta

            // We have to adjust style ranges for the active style
            if let style = styleAtCurrentIndex {
                if style.originalRange.upperBound <= originalMentionRange.upperBound {
                    // If the style ended inside (or right at the end of) the mention,
                    // it should now end at the end of the replacement text.

                    let finalLength = (newMentionRange.location + finalMentionLength) - style.newRange.location
                    let finalStyle = (
                        NSRange(
                            location: style.newRange.location,
                            length: finalLength
                        ),
                        style.style
                    )
                    finalStyles.append(finalStyle)

                    // We are done with it, now.
                    styleAtCurrentIndex = nil
                } else {
                    // The original style ends past the mention; extend its
                    // length by the right amount, but keep it in
                    // the current styles being walked through.
                    styleAtCurrentIndex = .init(
                        originalRange: style.originalRange,
                        newRange: NSRange(
                            location: style.newRange.location,
                            length: style.newRange.length + mentionOffsetDelta
                        ),
                        style: style.style
                    )
                }
            }
        }

        if let style = styleAtCurrentIndex {
            // Styles that ran right to the end (or overran) should be finalized.
            let finalStyle = (
                NSRange(
                    location: style.newRange.location,
                    length: finalText.length - style.newRange.location
                ),
                style.style
            )
            finalStyles.append(finalStyle)
        }

        return MessageBodyRanges(
            mentions: finalMentions,
            styles: finalStyles
        )
    }

    /// Sets the `.owsStyle` attribute on the string for any styles but
    /// *does not* actually apply the styles. (e.g. no font attribute)
    /// `.owsStyle` attributes can be converted to attributes just before display,
    /// when the font and text color are available.
    public func setStyleAttributesWithoutApplying(on string: NSMutableAttributedString) {
        for (range, style) in styles {
            var attrs: [NSAttributedString.Key: Any] = [
                .owsStyle: style
            ]
            if style.contains(.spoiler) {
                SpoilerAttribute.fromOriginalRange(range).addToAttributes(&attrs)
            }
            // Desktop could send a range that extends past the end of the string;
            // long term this should be corrected elsewhere but for now catch it here.
            // TODO[TextFormatting]: correct for out of bounds style ranges in received protos.
            guard let overlapRange = range.intersection(string.entireRange) else {
                continue
            }
            string.addAttributes(attrs, range: overlapRange)
        }
    }

    public struct SpoilerAttribute {
        /// Externally: identifies a single spoiler range, even if the actual attribute has been
        /// split when applied, as happens when a parallel attribute is applied to the middle
        /// of a spoiler range.
        ///
        /// Really this is just the original fill range of the spoiler, hashed. But that detail is
        /// irrelevant to everthing outside of this class.
        public let id: Int
        public let effectiveRange: NSRange

        private static let idKey = NSAttributedString.Key("OWSStyle.spoilerId")

        fileprivate static func extractFromAttributes(
            _ attrs: [NSAttributedString.Key: Any],
            range: NSRange
        ) -> Self {
            guard let id = attrs[Self.idKey] as? Int else {
                return .fromOriginalRange(range)
            }
            return .init(id: id, effectiveRange: range)
        }

        fileprivate static func fromOriginalRange(_ range: NSRange) -> Self {
            var hasher = Hasher()
            hasher.combine(range)
            let id = hasher.finalize()
            return .init(id: id, effectiveRange: range)
        }

        private init(id: Int, effectiveRange: NSRange) {
            self.id = id
            self.effectiveRange = effectiveRange
        }

        fileprivate func addToAttributes(_ attrs: inout [NSAttributedString.Key: Any]) {
            attrs[Self.idKey] = id
        }
    }

    public enum SpoilerStyle {
        case revealed
        // TODO[TextFormatting]: instead of highlight, we should use
        // a fancy animation which won't be represented in attributes.
        case concealedWithHighlight(HighlightColors)
        // TODO[TextFormatting]: add concealed with characters option

        public struct HighlightColors {
            public let baseColor: UIColor
            // Subranges within the spoiler range to apply other
            // colors to.
            public let otherColors: [(NSRange, UIColor)]

            public init(baseColor: UIColor, otherColors: [(NSRange, UIColor)]) {
                self.baseColor = baseColor
                self.otherColors = otherColors
            }
        }
    }

    /// Applies styles to the provided string (sets attributes for font, strikethrough, etc).
    /// Font and colors for styles are based on the provided base font and color.
    public func applyStyles(
        to string: NSMutableAttributedString,
        baseFont: UIFont,
        textColor: UIColor,
        spoilerStyler: (SpoilerAttribute) -> SpoilerStyle
    ) {
        var spoilerCount = 0
        for (range, style) in styles {
            Self.applyStyle(
                style: style,
                to: string,
                attributes: [:],
                range: range,
                baseFont: baseFont,
                textColor: textColor,
                spoilerStyler: spoilerStyler,
                spoilerCount: &spoilerCount
            )
        }
    }

    /// Applies any `.owsStyle` attributes on the string (sets attributes for font, strikethrough, etc).
    /// Font and colors for styles are based on the provided base font and color.
    public static func applyStyleAttributes(
        on string: NSMutableAttributedString,
        baseFont: UIFont,
        textColor: UIColor,
        spoilerStyler: (SpoilerAttribute) -> SpoilerStyle
    ) {
        let copy = NSAttributedString(attributedString: string)
        var spoilerCount = 0
        copy.enumerateAttributes(
            in: string.entireRange,
            using: { attrs, range, stop in
                guard let style = attrs[.owsStyle] as? Style else {
                    return
                }
                applyStyle(
                    style: style,
                    to: string,
                    attributes: attrs,
                    range: range,
                    baseFont: baseFont,
                    textColor: textColor,
                    spoilerStyler: spoilerStyler,
                    spoilerCount: &spoilerCount
                )
            }
        )
    }

    private static func applyStyle(
        style: Style,
        to string: NSMutableAttributedString,
        attributes originalAttributes: [NSAttributedString.Key: Any],
        range: NSRange,
        baseFont: UIFont,
        textColor: UIColor,
        spoilerStyler: (SpoilerAttribute) -> SpoilerStyle,
        spoilerCount: inout Int
    ) {
        var fontTraits: UIFontDescriptor.SymbolicTraits = []
        var attributes: [NSAttributedString.Key: Any] = [
            .owsStyle: style
        ]
        if style.contains(.bold) {
            fontTraits.insert(.traitBold)
        }
        if style.contains(.italic) {
            fontTraits.insert(.traitItalic)
        }
        if style.contains(.monospace) {
            fontTraits.insert(.traitMonoSpace)
        }
        if style.contains(.strikethrough) {
            attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
            attributes[.strikethroughColor] = textColor
        }
        var otherHighlightColors: [(NSRange, UIColor)]?
        if style.contains(.spoiler) {
            let spoilerAttribute = SpoilerAttribute.extractFromAttributes(
                originalAttributes,
                range: range
            )
            switch spoilerStyler(spoilerAttribute) {
            case .revealed:
                attributes[.foregroundColor] = textColor
                spoilerAttribute.addToAttributes(&attributes)
            case .concealedWithHighlight(let highlightColors):
                attributes[.foregroundColor] = highlightColors.baseColor
                attributes[.backgroundColor] = highlightColors.baseColor
                spoilerAttribute.addToAttributes(&attributes)
                otherHighlightColors = highlightColors.otherColors
            }
            spoilerCount += 1
        }
        if !fontTraits.isEmpty {
            attributes[.font] = baseFont.withTraits(fontTraits)
        }
        string.addAttributes(attributes, range: range)

        if let otherHighlightColors {
            for (otherRange, otherColor) in otherHighlightColors {
                let lowInside = otherRange.lowerBound >= range.lowerBound
                    && otherRange.lowerBound < range.upperBound
                let highInside = otherRange.upperBound <= range.upperBound
                    && otherRange.upperBound > range.lowerBound
                var otherAttributes: [NSAttributedString.Key: Any] = [
                    .foregroundColor: otherColor,
                    .backgroundColor: otherColor,
                    .owsStyle: style
                ]
                SpoilerAttribute.extractFromAttributes(
                    originalAttributes,
                    range: range
                ).addToAttributes(&otherAttributes)
                if lowInside && highInside {
                    string.addAttributes(
                        otherAttributes,
                        range: otherRange
                    )
                } else if lowInside {
                    string.addAttributes(
                        otherAttributes,
                        range: NSRange(
                            location: otherRange.location,
                            length: range.upperBound - otherRange.lowerBound
                        )
                    )
                } else if highInside {
                    string.addAttributes(
                        otherAttributes,
                        range: NSRange(
                            location: range.location,
                            length: otherRange.upperBound - range.lowerBound
                        )
                    )
                }
            }
        }
    }

    public static func spoilerAttributes(in string: NSAttributedString) -> [SpoilerAttribute] {
        var spoilerAttributes = [SpoilerAttribute]()
        string.enumerateAttributes(
            in: string.entireRange,
            using: { attrs, range, _ in
                guard
                    let style = attrs[.owsStyle] as? Style,
                    style.contains(.spoiler)
                else {
                    return
                }
                spoilerAttributes.append(SpoilerAttribute.extractFromAttributes(attrs, range: range))
            }
        )
        return spoilerAttributes
    }

    override public func isEqual(_ object: Any?) -> Bool {
        guard let other = object as? MessageBodyRanges else {
            return false
        }
        guard mentions == other.mentions else {
            return false
        }
        guard styles.count == other.styles.count else {
            return false
        }
        for i in 0..<styles.count {
            let style = styles[i]
            let otherStyle = other.styles[i]
            guard style.0 == otherStyle.0 else {
                return false
            }
            guard style.1 == otherStyle.1 else {
                return false
            }
        }
        return true
    }
}

extension UIFont {

    func withTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont {

        // create a new font descriptor with the given traits
        guard let fd = fontDescriptor.withSymbolicTraits(traits) else {
            // the given traits couldn't be applied, return self
            return self
        }

        // return a new font with the created font descriptor
        return UIFont(descriptor: fd, size: pointSize)
    }
}

extension NSAttributedString.Key {
    public static let owsStyle = MessageBodyRanges.Style.attributedStringKey
}
