//
//  VideoFeed.swift
//  react-native-firework-sdk
//
//  Created by Jeff Zheng on 2021/12/22.
//

// swiftlint:disable file_length

import FireworkVideo
import UIKit

@objc
public enum VideoFeedMode: Int {
    case row, column, grid
}

@objc
public enum VideFeedSourceType: Int {
    case discover, channel, playlist, playlistGroup, dynamicContent, hashtagPlaylist, sku, singleContent
}

public protocol VideoFeedDelegate: AnyObject {
    /// Called once after a video feed has been successfully loaded
    func videoFeedDidLoadFeed(_ view: VideoFeed)
    /// Called if the video feed failed to load
    func videoFeed(
        _ view: VideoFeed,
        didFailToLoadFeed error: VideoFeedError)
}

@objc
public class VideoFeed: UIView, VideoFeedViewControllerDelegate, PictureInPictureControllerDelegate {
    @objc public var sourceType: VideFeedSourceType = .discover
    @objc public var channel: String = ""
    @objc public var playlist: String = ""
    @objc public var playlistGroup: String = ""
    @objc public var dynamicContentParameters: NSDictionary = NSDictionary()
    @objc public var hashtagFilterExpression: String = ""
    @objc public var productIds: NSArray = NSArray()
    @objc public var contentId: String = ""
    @objc public var mode: VideoFeedMode = .row
    /// Whether the native feed sizes its own height to its content
    /// (`FWSVideoFeedView(autosizingEnabled:)`). Fixed when the feed view is created;
    /// JS re-creates this native view whenever the prop changes.
    @objc public var autosizing: Bool = false
    /// Caps the total number of videos the feed loads (`VideoFetchConfiguration.maxVideos`).
    /// `nil` or a non-positive value means no cap. Fixed at creation, like `autosizing`.
    @objc public var maxVideos: NSNumber?
    @objc public var feedViewConfig: VideoFeedConfiguration? {
        didSet {
            guard let feedView = feedView else {
                return
            }
            feedView.viewConfiguration = convertToVideoFeedContentConfiguration()

            let newLayout = videoFeedLayout
            guard !feedView.layout.isEqualToFeedLayout(newLayout) else {
                return
            }
            if autosizing, feedView.layout.isRowLayout != newLayout.isRowLayout {
                // An autosized feed latches its layout kind at creation: FWSVideoFeedView
                // refuses (and asserts on) a row <-> grid swap, so rebuild instead.
                rebuildFeedView()
            } else {
                feedView.layout = newLayout
            }
        }
    }
    @objc public var playerViewConfig: VideoPlayerConfiguration? {
        didSet {
            guard let feedView = feedView else {
                return
            }

            if playerViewConfig?.videoPlayerLogoConfiguration != oldValue?.videoPlayerLogoConfiguration {
                rebuildFeedView()
            } else {
                feedView.viewConfiguration = convertToVideoFeedContentConfiguration()

                if let enableSystemPiP = playerViewConfig?.enableSystemPictureInPicture {
                    var pipConfig = feedView.pictureInPictureConfiguration
                    pipConfig.isSystemPictureInPictureEnabled = enableSystemPiP
                    feedView.pictureInPictureConfiguration = pipConfig
                }
            }
        }
    }
    @objc public var adConfiguration: AdConfiguration?

    @objc var onVideoFeedLoadFinished: RCTBubblingEventBlock?

    @objc var onVideoFeedEmpty: RCTBubblingEventBlock?

    @objc var onVideoFeedDidStartPictureInPicture: RCTBubblingEventBlock?

    @objc var onVideoFeedDidStopPictureInPicture: RCTBubblingEventBlock?

    @objc var onVideoFeedGetFeedId: RCTBubblingEventBlock?

    /// Fired with `{ height }` whenever the autosized feed's intrinsic height changes, so
    /// JS can feed it back into the Yoga layout — `intrinsicContentSize` alone is only
    /// consumed by Auto Layout and is invisible to React Native. Never fired when
    /// `autosizing` is `false`.
    @objc var onVideoFeedContentSizeChange: RCTDirectEventBlock?
    @objc var onVideoFeedVideosLoaded: RCTBubblingEventBlock?

    public weak var delegate: VideoFeedDelegate?

    /// The SDK's UIView wrapper around `VideoFeedViewController`. The wrapper embeds the
    /// view controller into `fws_parentViewController` from its own `layoutSubviews` and
    /// detaches it again when it leaves the hierarchy or is released, so this view only
    /// has to manage the wrapper as a plain subview. It is also the only public way to
    /// enable autosizing — the view controller's autosizing init is internal to the SDK.
    private var feedView: FWSVideoFeedView?
    /// Last height handed to `onVideoFeedContentSizeChange`, so JS only hears about real changes.
    private var lastReportedContentHeight: CGFloat?
    private var source: VideoFeedContentSource {
        switch sourceType {
        case .discover:
            return .discover
        case .channel:
            return .channel(channelID: channel)
        case .playlist:
            return .channelPlaylist(channelID: channel, playlistID: playlist)
        case .playlistGroup:
            return .playlistGroup(groupID: playlistGroup)
        case .dynamicContent:
            let parameters = dynamicContentParameters as? [String: [String]]
            return .dynamicContent(channelID: channel, parameters: parameters ?? [:])
        case .hashtagPlaylist:
            return .hashtagPlaylist(channelID: channel, filterExpression: hashtagFilterExpression)
        case .sku:
            return .skuPlaylist(channelID: channel, productIDs: (productIds as? [String]) ?? [])
        case .singleContent:
            return .singleContent(contentID: contentId)
        }
    }

    private var videoFeedLayout: VideoFeedLayout {
        let resultLayout: VideoFeedLayout
        switch mode {
        case .row:
            let layout = VideoFeedHorizontalLayout()
            resultLayout = layout
        case .column:
            let layout = VideoFeedGridLayout()
            layout.numberOfColumns = 1
            resultLayout = layout
        case .grid:
            let layout = VideoFeedGridLayout()
            if let gridColumns = feedViewConfig?.gridColumns,
                gridColumns > 0 {
                layout.numberOfColumns = gridColumns
            } else {
                layout.numberOfColumns = 2
            }
            resultLayout = layout
        }
        if let config = feedViewConfig {
            if let aspectRatio = config.aspectRatio {
                if let horizontalLayout = resultLayout as? VideoFeedHorizontalLayout {
                    horizontalLayout.itemWidthRatio = aspectRatio
                } else if let gridLayout = resultLayout as? VideoFeedGridLayout {
                    gridLayout.itemWidthRatio = aspectRatio
                }
            }
            if let contentPadding = config.contentPadding {
                if let top = contentPadding.top {
                    resultLayout.contentInsets.top = top
                }
                if let right = contentPadding.right {
                    resultLayout.contentInsets.right = right
                }
                if let bottom = contentPadding.bottom {
                    resultLayout.contentInsets.bottom = bottom
                }
                if let left = contentPadding.left {
                    resultLayout.contentInsets.left = left
                }
            }

            if let itemSpacing = config.itemSpacing {
                resultLayout.itemSpacing = itemSpacing
            }
        }

        resultLayout.ignoresSafeAreaLayoutGuide = true

        return resultLayout
    }

    private var isInPip: Bool = false

    public override func layoutSubviews() {
        super.layoutSubviews()

        embed()
    }

    private func embed() {
        guard fws_parentViewController != nil else {
            return
        }
        guard self.feedView == nil else {
            return
        }

        let fireworkVideoAdConfiguration =
            RCTConvert.getFireworkVideoAdConfiguration(adConfiguration) ?? FireworkVideo.AdConfiguration()
        var fetchConfiguration = VideoFetchConfiguration()
        if let maxVideos = maxVideos?.intValue, maxVideos > 0 {
            fetchConfiguration.maxVideos = maxVideos
        }
        let feedView = FWSVideoFeedView(
            layout: videoFeedLayout,
            source: source,
            adConfiguration: fireworkVideoAdConfiguration,
            fetchConfiguration: fetchConfiguration,
            autosizingEnabled: autosizing
        )

        onVideoFeedGetFeedId?(["feedId": feedView.feedID])

        feedView.viewConfiguration = convertToVideoFeedContentConfiguration()

        feedView.isPictureInPictureEnabled = playerViewConfig?.enablePictureInPicture ?? false

        if let enableSystemPiP = playerViewConfig?.enableSystemPictureInPicture {
            var pipConfig = feedView.pictureInPictureConfiguration
            pipConfig.isSystemPictureInPictureEnabled = enableSystemPiP
            feedView.pictureInPictureConfiguration = pipConfig
        }

        feedView.delegate = self
        feedView.pictureInPictureDelegate = self
        if autosizing {
            feedView.onIntrinsicSizeChange = { [weak self] size in
                // `size.width` is `UIView.noIntrinsicMetric`; only the height is meaningful.
                self?.reportContentHeight(size.height)
            }
        }
        // Set before the feed is attached (and starts loading) so the initial
        // page is delivered too. The SDK calls this for every loaded batch,
        // initial page and each load-more page, with only that batch's videos.
        feedView.onVideosLoaded = { [weak self] info in
            self?.onVideoFeedVideosLoaded?(["videos": info.videos.map { $0.jsObject }])
        }
        self.feedView = feedView

        // The wrapper is pinned to this view's edges: React Native owns this view's frame
        // (Yoga), and the wrapper embeds the feed view controller into the host view
        // controller from its own `layoutSubviews`. Leave `viewportContainer` nil — with
        // autosizing the SDK derives the autoplay viewport from the clipping ancestors, which
        // covers React Native's ScrollView / FlatList (both UIScrollView subclasses); it only
        // needs to be pointed at the host scroll view if that derivation turns out not to work.
        feedView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(feedView)
        NSLayoutConstraint.activate([
            feedView.topAnchor.constraint(equalTo: topAnchor),
            feedView.leadingAnchor.constraint(equalTo: leadingAnchor),
            feedView.trailingAnchor.constraint(equalTo: trailingAnchor),
            feedView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])

        if autosizing {
            // The wrapper only calls back once its content size changes, so hand JS the
            // pre-content height (the loading footer for a grid, the fixed row height for
            // a row) right away: the feed is then laid out with a real height while the
            // first page loads instead of staying at whatever placeholder JS started with.
            reportContentHeight(feedView.intrinsicContentSize.height)
        }
    }

    /// Drops the current feed view and creates a fresh one on the next runloop turn, for
    /// changes the SDK cannot apply in place (logo configuration, row <-> grid swap under
    /// autosizing). Releasing the wrapper detaches its embedded view controller.
    private func rebuildFeedView() {
        guard let feedView = feedView else {
            return
        }
        feedView.isPictureInPictureEnabled = false
        feedView.removeFromSuperview()
        self.feedView = nil
        lastReportedContentHeight = nil
        DispatchQueue.main.async {
            self.embed()
        }
    }

    private func reportContentHeight(_ height: CGFloat) {
        guard autosizing else {
            return
        }
        // Yoga lays the view out on the point grid; rounding up avoids clipping a
        // fractional content height by a sub-point sliver.
        let roundedHeight = ceil(height)
        guard roundedHeight != lastReportedContentHeight else {
            return
        }
        lastReportedContentHeight = roundedHeight
        onVideoFeedContentSizeChange?(["height": roundedHeight])
    }

    public override func removeFromSuperview() {
        super.removeFromSuperview()

        guard let feedView = self.feedView else {
            return
        }

        if !isInPip {
            feedView.isPictureInPictureEnabled = false
        }

        feedView.removeFromSuperview()
        self.feedView = nil
    }

    @objc
    public func refresh() {
        guard let feedView = self.feedView else {
            return
        }
        feedView.refresh()
    }

    // MARK: VideoFeedViewControllerDelegate
    public func videoFeedDidLoadFeed(_ viewController: VideoFeedViewController) {
        guard let delegate = self.delegate else {
            return
        }
        delegate.videoFeedDidLoadFeed(self)
    }

    public func videoFeed(
        _ viewController: VideoFeedViewController, didFailToLoadFeed error: VideoFeedError
    ) {
        guard let delegate = self.delegate else {
            return
        }
        delegate.videoFeed(self, didFailToLoadFeed: error)
    }

    // MARK: PictureInPictureDelegate

    public func pictureInPictureControllerWillStartPictureInPicture(
        _ pictureInPictureController: FireworkVideo.PictureInPictureController
    ) {
    }

    public func pictureInPictureControllerDidStartPictureInPicture(
        _ pictureInPictureController: FireworkVideo.PictureInPictureController
    ) {
        self.onVideoFeedDidStartPictureInPicture?(nil)
        isInPip = true
    }

    public func pictureInPictureController(
        _ pictureInPictureController: FireworkVideo.PictureInPictureController,
        failedToStartPictureInPictureWithError error: Error?
    ) {
    }

    public func pictureInPictureControllerWillStopPictureInPicture(
        _ pictureInPictureController: FireworkVideo.PictureInPictureController
    ) {
    }

    public func pictureInPictureControllerDidStopPictureInPicture(
        _ pictureInPictureController: FireworkVideo.PictureInPictureController
    ) {
        self.onVideoFeedDidStopPictureInPicture?(nil)
        isInPip = false
    }

    public func pictureInPictureController(
        _ pictureInPictureController: FireworkVideo.PictureInPictureController,
        restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
    ) {
        completionHandler(true)
    }
}

extension VideoFeed {
    // swiftlint:disable:next cyclomatic_complexity function_body_length
    private static func convertToVideoFeedItemContentConfiguration(
        _ config: VideoFeedConfiguration?
    ) -> VideoFeedItemContentConfiguration? {
        guard let config = config else {
            return nil
        }

        var vfcConfig = VideoFeedItemContentConfiguration()
        // set default value so that behavior will the same with Android
        vfcConfig.title.isHidden = false
        vfcConfig.titleLayoutConfiguration.titlePosition = .nested

        if let cornerRadius = config.cornerRadius {
            vfcConfig.cornerRadius = cornerRadius
        }
        if let title = config.title {
            if let hidden = title.hidden {
                vfcConfig.title.isHidden = hidden
            }
            if let textcolor = title.textColor, let color = textcolor.uicolor() {
                vfcConfig.title.textColor = color
            }
            if let numberOfLines = title.numberOfLines {
                vfcConfig.title.numberOfLines = numberOfLines
            }
            if let fontSize = title.fontSize {
                let iOSFontInfo = title.iOSFontInfo ?? FontInfo()
                vfcConfig.title.font = iOSFontInfo.getFont(fontSize)
            }
        }

        if let position = config.titlePosition {
            switch position {
            case .stacked:
                vfcConfig.titleLayoutConfiguration.titlePosition = .stacked
            default:
                vfcConfig.titleLayoutConfiguration.titlePosition = .nested
            }
        }

        if let titlePadding = config.titlePadding {
            if let top = titlePadding.top {
                vfcConfig.titleLayoutConfiguration.insets.top = top
            }
            if let right = titlePadding.right {
                vfcConfig.titleLayoutConfiguration.insets.right = right
            }
            if let bottom = titlePadding.bottom {
                vfcConfig.titleLayoutConfiguration.insets.bottom = bottom
            }
            if let left = titlePadding.left {
                vfcConfig.titleLayoutConfiguration.insets.left = left
            }
        }

        if let playIcon = config.playIcon {
            playIcon.apply(to: &vfcConfig.playIcon)
        }

        if let showAdBadge = config.showAdBadge {
            vfcConfig.sponsored.isHidden = !showAdBadge
        }

        if let enableAutoplay = config.enableAutoplay {
            vfcConfig.autoplay.isEnabled = enableAutoplay
        }

        if let opacity = config.shadow?.opacity {
            vfcConfig.shadow.opacity = opacity
        }

        if let shadowColor = config.shadow?.color, let color = shadowColor.uicolor() {
            vfcConfig.shadow.color = color
        }

        if let width = config.shadow?.width {
            vfcConfig.shadow.offset = CGSize(width: width, height: vfcConfig.shadow.offset.height)
        }

        if let height = config.shadow?.height {
            vfcConfig.shadow.offset = CGSize(width: vfcConfig.shadow.offset.width, height: height)
        }

        return vfcConfig
    }

    // swiftlint:disable:next cyclomatic_complexity function_body_length
    static func convertToVideoPlayerContentConfiguration(
        _ config: VideoPlayerConfiguration?
    ) -> VideoPlayerContentConfiguration? {
        guard let config = config else {
            return nil
        }

        var vpcConfig = VideoPlayerContentConfiguration()
        if let playerStyle = config.playerStyle {
            switch playerStyle {
            case .full:
                vpcConfig.playerStyle = .fullBleed
            case .fit:
                vpcConfig.playerStyle = .fit
            }
        }
        if let videoCompleteAction = config.videoCompleteAction {
            switch videoCompleteAction {
            case .loop:
                vpcConfig.videoCompleteAction = .loop
            default:
                vpcConfig.videoCompleteAction = .advanceToNext
            }
        }
        if let showShareButton = config.showShareButton {
            vpcConfig.shareButton.isHidden = !showShareButton
        }
        if let shareButtonPosition = config.shareButtonPosition {
            switch shareButtonPosition {
            case .videoDetailPage:
                vpcConfig.shareButton.position = .videoDetailPage
            case .playerTopArea:
                vpcConfig.shareButton.position = .playerTopArea
            }
        }
        if let ctaButtonStyle = config.ctaButtonStyle {
            if let backgroundColor = ctaButtonStyle.backgroundColor, let color = backgroundColor.uicolor() {
                vpcConfig.ctaButton.contentConfiguration.backgroundColor = color
            }
            if let textcolor = ctaButtonStyle.textColor, let color = textcolor.uicolor() {
                vpcConfig.ctaButton.contentConfiguration.textColor = color
            }
            if let fontSize = ctaButtonStyle.fontSize {
                let iOSFontInfo = ctaButtonStyle.iOSFontInfo ?? FontInfo()
                vpcConfig.ctaButton.contentConfiguration.font = iOSFontInfo.getFont(fontSize)
            }
            if let shape = ctaButtonStyle.shape {
                vpcConfig.ctaButton.contentConfiguration.shape = shape.shape()
            }
        }
        if let showPlaybackButton = config.showPlaybackButton {
            vpcConfig.playbackButton.isHidden = !showPlaybackButton
        } else {
            vpcConfig.playbackButton.isHidden = false
        }

        if let showMuteButton = config.showMuteButton {
            vpcConfig.muteButton.isHidden = !showMuteButton
        }

        if let launchBehavior = gVideoLaunchBehavior {
            vpcConfig.onFirstLaunch = launchBehavior.behavior()
        }

        if let showBranding = config.showBranding {
            vpcConfig.videoDetail.fireworkAttribution.isHidden = !showBranding
        }

        if let shareBaseURL = config.shareBaseURL {
            vpcConfig.shareButton.behavior.baseURL = URL(string: shareBaseURL)
        } else if let shareBaseURL = gShareBaseURL {
            vpcConfig.shareButton.behavior.baseURL = URL(string: shareBaseURL)
        }

        if let ctaDelay = config.ctaDelay,
           let type = ctaDelay.type,
           let value = ctaDelay.value {
            switch type {
            case .constant:
                vpcConfig.ctaButton.behavior.delay = .constant(value)
            case .percentage:
                vpcConfig.ctaButton.behavior.delay = .percentage(Float(value))
            }
        }

        if let ctaHighlightDelay = config.ctaHighlightDelay,
           let type = ctaHighlightDelay.type,
           let value = ctaHighlightDelay.value {
            switch type {
            case .constant:
                vpcConfig.ctaButton.behavior.highlightDelay = .constant(value)
            case .percentage:
                vpcConfig.ctaButton.behavior.highlightDelay = .percentage(Float(value))
            }
        }

        if let ctaWidth = config.ctaWidth {
            vpcConfig.ctaButton.width = ctaWidth.width()
        }

        if let buttonConfiguration = config.buttonConfiguration {
            if let videoDetailButtonDisplay = buttonConfiguration.videoDetailButton?.getButtonDisplayConfiguration() {
                vpcConfig.videoDetailButtonDisplayConfiguration = videoDetailButtonDisplay
            }

            if let closeButtonDisplay = buttonConfiguration.closeButton?.getButtonDisplayConfiguration() {
                vpcConfig.closeButtonConfiguration.closeDisplay = closeButtonDisplay
            }

            if let muteButtonDisplay = buttonConfiguration.muteButton?.getButtonDisplayConfiguration() {
                vpcConfig.muteButton.muteDisplay = muteButtonDisplay
            }

            if let unmuteButtonDisplay = buttonConfiguration.unmuteButton?.getButtonDisplayConfiguration() {
                vpcConfig.muteButton.unmuteDisplay = unmuteButtonDisplay
            }

            if let playButtonDisplay = buttonConfiguration.playButton?.getButtonDisplayConfiguration() {
                vpcConfig.playbackButton.playDisplay = playButtonDisplay
            }

            if let pauseButtonDisplay = buttonConfiguration.pauseButton?.getButtonDisplayConfiguration() {
                vpcConfig.playbackButton.pauseDisplay = pauseButtonDisplay
            }
        }

        if let showMoreButton = config.showMoreButton {
            vpcConfig.videoDetailButton.isHidden = !showMoreButton
        }

        if let showVideoDetailTitle = config.showVideoDetailTitle {
            vpcConfig.videoDetail.titleConfiguration.isHidden = !showVideoDetailTitle
        }

        if let videoPlayerLogoConfiguration = config.videoPlayerLogoConfiguration {
            switch videoPlayerLogoConfiguration.option {
            case .disabled:
                vpcConfig.logoConfiguration.option = .disabled
            case .creator:
                if let encodedId = videoPlayerLogoConfiguration.encodedId {
                    vpcConfig.logoConfiguration.option = .creator(encodedId)
                }
            case .channelAggregator:
                if let encodedId = videoPlayerLogoConfiguration.encodedId {
                    vpcConfig.logoConfiguration.option = .channelAggregator(encodedId)
                }
            }

            if let isClickable = videoPlayerLogoConfiguration.isClickable {
                vpcConfig.logoConfiguration.isEnabled = isClickable
            }
        }

        if let replayBadgeConfiguration = config.replayBadgeConfiguration {
            vpcConfig.replayBadge.isHidden = replayBadgeConfiguration.isHidden
        }

        if let countdownTimerConfiguration = config.countdownTimerConfiguration {
            vpcConfig.countdownTimerConfiguration = countdownTimerConfiguration.fwCountdownTimerConfiguration()
        }

        if let shouldExtendMediaOutsideSafeArea = config.shouldExtendMediaOutsideSafeArea {
            vpcConfig.shouldExtendMediaOutsideSafeArea = shouldExtendMediaOutsideSafeArea
        }

        if let statusBarHidden = config.statusBarHidden {
            vpcConfig.statusBarHidden = statusBarHidden
        }

        if let statusBarStyle = config.statusBarStyle {
            vpcConfig.statusBarStyle = statusBarStyle.toUIStatusBarStyle()
        }

        if let pipPlacement = config.pipPlacement {
            vpcConfig.pipPlacement = pipPlacement.convertToFWPipPlacement()
        }

        if let scrollDirection = config.scrollDirection {
            switch scrollDirection {
            case .horizontal:
                vpcConfig.scrollDirection = .horizontal
            case .vertical:
                vpcConfig.scrollDirection = .vertical
            }
        }

        if let isArrowButtonVisible = config.isArrowButtonVisible {
            vpcConfig.scrollNavigation.isArrowButtonVisible = isArrowButtonVisible
        }

        if let feedCompleteAction = config.feedCompleteAction {
            switch feedCompleteAction {
            case .loop:
                vpcConfig.feedCompleteAction = .loop
            case .dismiss:
                vpcConfig.feedCompleteAction = .dismiss
            }
        }

        return vpcConfig
    }

    // The autoplay trigger threshold shared by the feed components. Fixed-height
    // feeds require full visibility (100) to stay aligned with Android (MS-1903).
    // Autosized feeds measure visibility against the screen viewport, where a
    // tall item is often only partially visible while the host page scrolls, so
    // they use the SDK default of 50 for a responsive autoplay hand-off.
    // Deliberately not exposed to JS for now.
    static func autoplayTriggerVisibilityPercentage(autosizing: Bool) -> CGFloat {
        autosizing ? 50 : 100
    }

    // The first launch mute behavior comes from the SDK init options rather than
    // from the JS player config, so components have to apply it separately;
    // otherwise it is lost whenever no videoPlayerConfiguration is provided and
    // convertToVideoPlayerContentConfiguration is never called.
    static func applyVideoLaunchBehavior(to config: inout VideoPlayerContentConfiguration) {
        if let launchBehavior = gVideoLaunchBehavior {
            config.onFirstLaunch = launchBehavior.behavior()
        }
    }

    private func convertToVideoFeedContentConfiguration() -> VideoFeedContentConfiguration {
        var videoConfig = VideoFeedContentConfiguration()
        videoConfig.useSafeAreaViewport = false

        let vfcConfig = VideoFeed.convertToVideoFeedItemContentConfiguration(feedViewConfig)
        let vpcConfig = VideoFeed.convertToVideoPlayerContentConfiguration(playerViewConfig)
        if let vfcConfig = vfcConfig {
            videoConfig.itemView = vfcConfig
        }
        if let vpcConfig = vpcConfig {
            videoConfig.playerView = vpcConfig
        }
        // Pin the autoplay trigger threshold (the iOS SDK default has changed)
        // here rather than in the item converter, so it also applies when no
        // feed view config is provided from JS.
        videoConfig.itemView.autoplay.triggerVisibilityPercentage =
            VideoFeed.autoplayTriggerVisibilityPercentage(autosizing: autosizing)
        // Applied here too so it survives a missing videoPlayerConfiguration.
        VideoFeed.applyVideoLaunchBehavior(to: &videoConfig.playerView)

        if let backgroundColor = feedViewConfig?.backgroundColor, let color = backgroundColor.uicolor() {
            videoConfig.backgroundColor = color
        }

        if let adBadge = FireworkSDKModule.convertToFWAdBadgeConfiguration(gAdBadgeConfiguration) {
            videoConfig.adBadge = adBadge
        }

        if let replayBadge = feedViewConfig?.replayBadge {
            videoConfig.replayBadge.isHidden = replayBadge.isHidden
        }

        return videoConfig
    }
}

extension VideoFeedLayout {
    fileprivate func isEqualToFeedLayout(_ other: VideoFeedLayout) -> Bool {
        if type(of: self) != type(of: other) {
            return false
        }

        if self.ignoresSafeAreaLayoutGuide == other.ignoresSafeAreaLayoutGuide,
           self.itemSpacing == other.itemSpacing,
           self.contentInsets == other.contentInsets {
            if let layout = self as? VideoFeedHorizontalLayout,
               let otherLayout = other as? VideoFeedHorizontalLayout,
               layout.itemWidthRatio == otherLayout.itemWidthRatio {
                return true
            } else if let layout = self as? VideoFeedGridLayout,
                      let otherLayout = other as? VideoFeedGridLayout,
                      layout.itemWidthRatio == otherLayout.itemWidthRatio,
                      layout.numberOfColumns == otherLayout.numberOfColumns {
                return true
            } else {
                return false
            }
        } else {
            return false
        }
    }
}

extension VideoFeedLayout {
    /// Whether this layout is a horizontally scrolling row. With autosizing enabled the
    /// SDK latches the layout kind when the feed view is created (a row keeps a fixed
    /// height, a grid expands to its content), so a row <-> grid swap needs a new feed view.
    fileprivate var isRowLayout: Bool {
        self is VideoFeedHorizontalLayout
    }
}
