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

import SignalServiceKit

// This class offers a convenient way to build table views
// when performance is not critical, e.g. when the table
// only holds a screenful or two of cells and it's safe to
// retain a view model for each cell in memory at all times.
open class OWSTableViewController2: OWSViewController {

    public weak var delegate: OWSTableViewControllerDelegate?

    public var contents: OWSTableContents {
        get {
            _contents
        }
        set {
            _contents = newValue
            applyContents()
        }
    }

    private var _contents = OWSTableContents()

    public func setContents(_ contents: OWSTableContents, shouldReload: Bool = true) {
        _contents = contents
        applyContents(shouldReload: shouldReload)
    }

    public let tableView = OWSTableView(frame: .zero, style: .grouped)

    // This is an alternative to/replacement for UITableView.tableHeaderView.
    //
    // * It should usually be used with buildTopHeader(forView:).
    // * The top header view appears above the table and _does not_
    //   scroll with its content.
    // * The top header view's edge align with the edges of the cells.
    open var topHeader: UIView?

    open var bottomFooter: UIView?

    public var forceDarkMode = false {
        didSet {
            applyTheme()
        }
    }

    /// Whether or not this table view should avoid being hidden behind the
    /// keyboard.
    public var shouldAvoidKeyboard = false

    public enum SelectionBehavior {
        case actionWithAutoDeselect
        case toggleSelectionWithAction
    }
    public var selectionBehavior: SelectionBehavior = .actionWithAutoDeselect

    public var defaultHeaderHeight: CGFloat? = 0
    public var defaultFooterHeight: CGFloat? = 0
    public var defaultSpacingBetweenSections: CGFloat? = 20
    public var defaultLastSectionFooter: CGFloat = 20

    public lazy var defaultSeparatorInsetLeading: CGFloat = Self.cellHInnerMargin

    public var defaultSeparatorInsetTrailing: CGFloat = 0

    public var defaultCellHeight: CGFloat = 50

    public var isUsingPresentedStyle: Bool {
        return presentingViewController != nil
    }

    private static let cellIdentifier = "cellIdentifier"

    public override init() {
        super.init()

        // We also do this in applyTheme(), but we also need to do it here
        // for the case where we push multiple table views at the same time.
        Self.removeBackButtonText(viewController: self)

        tableView.tableViewDelegate = self
    }

    open override func viewDidLoad() {
        super.viewDidLoad()

        tableView.delegate = self
        tableView.dataSource = self
        tableView.tableFooterView = UIView()
        tableView.estimatedRowHeight = defaultCellHeight

        view.addSubview(tableView)

        // Pin top edge of tableView.
        if let topHeader = topHeader {
            view.addSubview(topHeader)
            topHeader.autoPin(toTopLayoutGuideOf: self, withInset: 0)
            topHeader.autoPinEdge(toSuperviewSafeArea: .leading)
            topHeader.autoPinEdge(toSuperviewSafeArea: .trailing)

            tableView.autoPinEdge(.top, to: .bottom, of: topHeader)

            topHeader.setContentHuggingVerticalHigh()
            topHeader.setCompressionResistanceVerticalHigh()
        } else {
            tableView.autoPinEdge(toSuperviewEdge: .top)
        }

        // Pin leading & trailing edges of tableView.
        tableView.autoPinEdge(toSuperviewEdge: .leading)
        tableView.autoPinEdge(toSuperviewEdge: .trailing)
        tableView.setContentHuggingVerticalLow()
        tableView.setCompressionResistanceVerticalLow()

        // Pin bottom edge of tableView.
        if let bottomFooter = bottomFooter {
            view.addSubview(bottomFooter)
            bottomFooter.autoPinEdge(.top, to: .bottom, of: tableView)
            bottomFooter.autoPinEdge(toSuperviewSafeArea: .leading)
            bottomFooter.autoPinEdge(toSuperviewSafeArea: .trailing)
            bottomFooter.setContentHuggingVerticalHigh()
            bottomFooter.setCompressionResistanceVerticalHigh()
        }

        updateBottomConstraint()

        tableView.register(UITableViewCell.self, forCellReuseIdentifier: Self.cellIdentifier)

        applyContents()
        applyTheme()
    }

    /// Applies theme and reloads table contents.
    ///
    /// Does not reload header/footer views. Subclasses that use header/footer
    /// views that need to update in response to theme changes should override
    /// this method to do so manually.
    open override func themeDidChange() {
        super.themeDidChange()

        applyTheme()
        applyContents()
    }

    open var tableBackgroundColor: UIColor {
        AssertIsOnMainThread()

        return Self.tableBackgroundColor(
            isUsingPresentedStyle: isUsingPresentedStyle,
            forceDarkMode: forceDarkMode
        )
    }

    private func applyTheme() {
        applyTheme(to: self)

        tableView.backgroundColor = self.tableBackgroundColor
        tableView.sectionIndexColor = forceDarkMode ? Theme.darkThemePrimaryColor : Theme.primaryTextColor

        updateNavbarStyling()

        tableView.separatorColor = .clear
        tableView.separatorInset = .zero
        tableView.separatorStyle = .none
    }

    public var shouldHideBottomFooter = false {
        didSet {
            let didChange = oldValue != shouldHideBottomFooter
            if didChange, isViewLoaded {
                updateBottomConstraint()
            }
        }
    }

    private var bottomFooterConstraint: NSLayoutConstraint?

    private func updateBottomConstraint() {
        bottomFooterConstraint?.autoRemove()
        bottomFooterConstraint = nil

        // Pin bottom edge of tableView.
        if !shouldHideBottomFooter,
           let bottomFooter = bottomFooter {
            if shouldAvoidKeyboard {
                bottomFooterConstraint = bottomFooter.autoPinEdge(.bottom, to: .bottom, of: keyboardLayoutGuideViewSafeArea)
            } else {
                bottomFooterConstraint = bottomFooter.autoPinEdge(toSuperviewEdge: .bottom)
            }
        } else if shouldAvoidKeyboard {
            bottomFooterConstraint = tableView.autoPinEdge(.bottom, to: .bottom, of: keyboardLayoutGuideViewSafeArea)
        } else {
            bottomFooterConstraint = tableView.autoPinEdge(toSuperviewEdge: .bottom)
        }

        bottomFooter?.isHidden = shouldHideBottomFooter

        guard hasViewAppeared else {
            return
        }

        struct ViewFrame {
            let view: UIView
            let frame: CGRect

            func apply() {
                view.frame = self.frame
            }
        }
        func viewFrames(for views: [UIView]) -> [ViewFrame] {
            views.map { ViewFrame(view: $0, frame: $0.frame) }
        }
        var animatedViews: [UIView] = [ tableView ]
        if let bottomFooter = bottomFooter {
            animatedViews.append(bottomFooter)
        }
        let viewFramesBefore = viewFrames(for: animatedViews)
        self.view.layoutIfNeeded()
        let viewFramesAfter = viewFrames(for: animatedViews)
        for viewFrame in viewFramesBefore { viewFrame.apply() }
        UIView.animate(withDuration: 0.15) {
            for viewFrame in viewFramesAfter { viewFrame.apply() }
        }
    }

    /// Reloads table contents when content size category changes.
    ///
    /// Does not reload header/footer views. Subclasses that use header/footer
    /// views that need to update in response to content size category changes
    /// should override this method to do so manually.
    open override func contentSizeCategoryDidChange() {
        super.contentSizeCategoryDidChange()

        // Reload when content size might need to change.
        applyContents()
    }

    private var usesSolidNavbarStyle: Bool {
        return tableView.contentOffset.y <= (defaultSpacingBetweenSections ?? 0) - tableView.adjustedContentInset.top
    }

    open var preferredNavigationBarStyle: OWSNavigationBarStyle {
        return usesSolidNavbarStyle ? .solid : .blur
    }

    open var navbarBackgroundColorOverride: UIColor? {
        return usesSolidNavbarStyle ? tableBackgroundColor : nil
    }

    private var hasViewAppeared = false

    open override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        applyTheme()

        tableView.tableFooterView = UIView()

        hasViewAppeared = true
    }

    private func section(for index: Int) -> OWSTableSection? {
        AssertIsOnMainThread()

        guard let section = contents.sections[safe: index] else {
            owsFailDebug("Missing section: \(index)")
            return nil
        }
        return section
    }

    private func item(for indexPath: IndexPath) -> OWSTableItem? {
        AssertIsOnMainThread()

        guard let section = contents.sections[safe: indexPath.section] else {
            owsFailDebug("Missing section: \(indexPath.section)")
            return nil
        }
        guard let item = section.items[safe: indexPath.row] else {
            owsFailDebug("Missing item: \(indexPath.row)")
            return nil
        }
        return item
    }

    public var shouldDeferInitialLoad = true

    private func applyContents(shouldReload: Bool = true) {
        AssertIsOnMainThread()

        if let title = contents.title, !title.isEmpty {
            self.title = title
        }

        var shouldReload = shouldReload
        if shouldDeferInitialLoad {
            shouldReload = (shouldReload &&
                                self.isViewLoaded &&
                                tableView.width > 0)
        }

        if shouldReload {
            tableView.reloadData()
        }
    }

    public static func buildTopHeader(forView wrappedView: UIView,
                                      vMargin: CGFloat = 0) -> UIView {
        buildTopHeader(forView: wrappedView,
                       topMargin: vMargin,
                       bottomMargin: vMargin)
    }

    public static func buildTopHeader(forView wrappedView: UIView,
                                      topMargin: CGFloat = 0,
                                      bottomMargin: CGFloat = 0) -> UIView {
        let wrapperStack = UIStackView()
        wrapperStack.addArrangedSubview(wrappedView)
        wrapperStack.axis = .vertical
        wrapperStack.alignment = .fill
        wrapperStack.isLayoutMarginsRelativeArrangement = true
        let layoutMargins = cellOuterInsets(in: wrappedView)
        wrapperStack.layoutMargins = layoutMargins
        return wrapperStack
    }
}

// MARK: -

extension OWSTableViewController2: UITableViewDataSource, UITableViewDelegate, OWSNavigationChildController {

    public func tableView(_ tableView: UITableView, numberOfRowsInSection sectionIndex: Int) -> Int {
        guard let section = self.section(for: sectionIndex) else {
            owsFailDebug("Missing section: \(sectionIndex)")
            return 0
        }
        return section.items.count
    }

    public func numberOfSections(in tableView: UITableView) -> Int {
        contents.sections.count
    }

    public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let item = self.item(for: indexPath) else {
            owsFailDebug("Missing item: \(indexPath)")
            let cell = OWSTableItem.newCell()
            configureCellBackground(cell, indexPath: indexPath)
            return cell
        }

        item.tableViewController = self

        if let cell = item.getOrBuildCustomCell(tableView) {
            configureCellBackground(cell, indexPath: indexPath)
            return cell
        }

        guard let cell: UITableViewCell = tableView.dequeueReusableCell(withIdentifier: Self.cellIdentifier) else {
            owsFailDebug("Missing cell: \(indexPath)")
            let cell = OWSTableItem.newCell()
            configureCellBackground(cell, indexPath: indexPath)
            return cell
        }

        OWSTableItem.configureCell(cell)

        if let title = item.title {
            cell.textLabel?.text = title
        }

        configureCellBackground(cell, indexPath: indexPath)

        return cell
    }

    public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        guard let item = self.item(for: indexPath) else {
            owsFailDebug("Missing item: \(indexPath)")
            return
        }

        if let willDisplayBlock = item.willDisplayBlock {
            willDisplayBlock(cell)
        }
    }

    private func configureCellBackground(_ cell: UITableViewCell, indexPath: IndexPath) {
        guard let section = contents.sections[safe: indexPath.section] else {
            owsFailDebug("Missing section: \(indexPath.section)")
            return
        }

        cell.backgroundView?.removeFromSuperview()
        cell.backgroundView = nil
        cell.selectedBackgroundView?.removeFromSuperview()
        cell.backgroundColor = .clear
        cell.contentView.backgroundColor = .clear

        guard section.hasBackground else {
            let selectedBackgroundView = UIView()
            selectedBackgroundView.backgroundColor = forceDarkMode
            ? Theme.darkThemeTableCell2SelectedBackgroundColor
            : Theme.tableCell2SelectedBackgroundColor
            cell.selectedBackgroundView = selectedBackgroundView
            return
        }

        let cellBackgroundColor: UIColor
        let cellSelectedBackgroundColor: UIColor
        if let customCell = cell as? CustomBackgroundColorCell {
            cellBackgroundColor = customCell.customBackgroundColor(forceDarkMode: forceDarkMode)
            cellSelectedBackgroundColor = customCell.customSelectedBackgroundColor(forceDarkMode: forceDarkMode)
        } else {
            cellBackgroundColor = self.cellBackgroundColor
            cellSelectedBackgroundColor = self.cellSelectedBackgroundColor
        }

        cell.backgroundView = buildCellBackgroundView(
            indexPath: indexPath,
            section: section,
            backgroundColor: cellBackgroundColor
        )
        cell.selectedBackgroundView = buildCellSelectedBackgroundView(
            indexPath: indexPath,
            section: section,
            backgroundColor: cellSelectedBackgroundColor
        )

        // We use cellHOuterMargin _outside_ the background and cellHInnerMargin
        // _inside_.
        //
        // By applying it to the cell, ensure the correct behavior for accessories.
        cell.layoutMargins = cellOuterInsetsWithMargin(hMargin: Self.cellHInnerMargin, vMargin: 0)
        var contentMargins = UIEdgeInsets(
            hMargin: 0,
            vMargin: Self.cellVInnerMargin
        )
        // Our table code is going to be vastly simpler if we DRY up the
        // spacing between the cell content and the accessory here.
        let hasAccessory = (cell.accessoryView != nil || cell.accessoryType != .none)
        if hasAccessory {
            if CurrentAppContext().isRTL {
                contentMargins.left += 8
            } else {
                contentMargins.right += 8
            }
        }
        cell.contentView.layoutMargins = contentMargins
    }

    /// Returns the frame representing the shape of a cell's visible pill, in
    /// the given view.
    public func cellPillFrame(view: UIView) -> CGRect {
        var pillFrame = view.bounds.inset(by: self.cellOuterInsets)

        pillFrame.x += view.safeAreaInsets.left
        pillFrame.size.width -= view.safeAreaInsets.left + view.safeAreaInsets.right

        return pillFrame
    }

    /// Configures the given layer to draw the background pill for a cell, in
    /// the given view.
    private func configureCellPillLayer(
        pillLayer: CAShapeLayer,
        view: UIView,
        isFirstInSection: Bool,
        isLastInSection: Bool,
        backgroundColor: UIColor
    ) {
        pillLayer.frame = view.bounds
        pillLayer.fillColor = backgroundColor.cgColor

        let pillFrame = cellPillFrame(view: view)

        if
            pillFrame.width > 0,
            pillFrame.height > 0
        {
            var roundingCorners: UIRectCorner = []

            if isFirstInSection {
                roundingCorners.formUnion(.topLeft)
                roundingCorners.formUnion(.topRight)
            }

            if isLastInSection {
                roundingCorners.formUnion(.bottomLeft)
                roundingCorners.formUnion(.bottomRight)
            }

            pillLayer.path = UIBezierPath(
                roundedRect: pillFrame,
                byRoundingCorners: roundingCorners,
                cornerRadii: .square(OWSTableViewController2.cellRounding)
            ).cgPath
        } else {
            pillLayer.path = nil
        }
    }

    private func configureCellSeparatorLayer(
        separatorLayer: CAShapeLayer,
        view: UIView,
        sectionSeparatorInsetLeading: CGFloat?,
        sectionSeparatorInsetTrailing: CGFloat?,
        separatorColor: UIColor
    ) {
        separatorLayer.frame = view.bounds
        separatorLayer.fillColor = separatorColor.cgColor

        var separatorFrame = cellPillFrame(view: view)
        let separatorThickness: CGFloat = .hairlineWidth

        separatorFrame.y = separatorFrame.height - separatorThickness
        separatorFrame.size.height = separatorThickness

        let separatorInsetLeading = sectionSeparatorInsetLeading ?? self.defaultSeparatorInsetLeading
        let separatorInsetTrailing = sectionSeparatorInsetTrailing ?? self.defaultSeparatorInsetTrailing

        separatorFrame.x += separatorInsetLeading
        separatorFrame.size.width -= (separatorInsetLeading + separatorInsetTrailing)
        separatorLayer.path = UIBezierPath(rect: separatorFrame).cgPath
    }

    private func buildCellBackgroundView(
        indexPath: IndexPath,
        section: OWSTableSection,
        backgroundColor: UIColor
    ) -> UIView {
        let isFirstInSection = indexPath.row == 0
        let isLastInSection = indexPath.row == tableView(tableView, numberOfRowsInSection: indexPath.section) - 1

        let pillLayer = CAShapeLayer()
        var separatorLayer: CAShapeLayer?

        let backgroundView = OWSLayerView(frame: .zero) { [weak self] view in
            guard let self = self else { return }

            self.configureCellPillLayer(
                pillLayer: pillLayer,
                view: view,
                isFirstInSection: isFirstInSection,
                isLastInSection: isLastInSection,
                backgroundColor: backgroundColor
            )

            if let separatorLayer {
                self.configureCellSeparatorLayer(
                    separatorLayer: separatorLayer,
                    view: view,
                    sectionSeparatorInsetLeading: section.separatorInsetLeading,
                    sectionSeparatorInsetTrailing: section.separatorInsetTrailing,
                    separatorColor: self.separatorColor
                )
            }
        }

        backgroundView.layer.addSublayer(pillLayer)

        if
            section.hasSeparators,
            !isLastInSection
        {
            let separator = CAShapeLayer()
            separatorLayer = separator

            backgroundView.layer.addSublayer(separator)
        }

        return backgroundView
    }

    private func buildCellSelectedBackgroundView(
        indexPath: IndexPath,
        section: OWSTableSection,
        backgroundColor: UIColor
    ) -> UIView {
        let pillLayer = CAShapeLayer()
        let isFirstInSection = indexPath.row == 0
        let isLastInSection = indexPath.row == tableView(tableView, numberOfRowsInSection: indexPath.section) - 1

        let backgroundView = OWSLayerView(frame: .zero) { [weak self] view in
            guard let self = self else { return }

            self.configureCellPillLayer(
                pillLayer: pillLayer,
                view: view,
                isFirstInSection: isFirstInSection,
                isLastInSection: isLastInSection,
                backgroundColor: backgroundColor
            )
        }

        backgroundView.layer.addSublayer(pillLayer)
        return backgroundView
    }

    public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        guard let item = self.item(for: indexPath) else {
            owsFailDebug("Missing item: \(indexPath)")
            return defaultCellHeight
        }
        if let customRowHeight = item.customRowHeight {
            return customRowHeight
        }
        return UITableView.automaticDimension
    }

    public static let cellRounding: CGFloat = 10

    public static var maximumInnerWidth: CGFloat { 496 }

    public static var defaultHOuterMargin: CGFloat {
        UIDevice.current.isPlusSizePhone ? 20 : 16
    }

    // The distance from the edge of the view to the cell border.
    public static func cellOuterInsets(in view: UIView) -> UIEdgeInsets {
        var insets = UIEdgeInsets()

        if view.safeAreaInsets.left <= 0 {
            insets.left = defaultHOuterMargin
        }

        if view.safeAreaInsets.right <= 0 {
            insets.right = defaultHOuterMargin
        }

        let totalInnerWidth = view.width - insets.totalWidth
        if totalInnerWidth > maximumInnerWidth {
            let excessInnerWidth = totalInnerWidth - maximumInnerWidth
            insets.left += excessInnerWidth / 2
            insets.right += excessInnerWidth / 2
        }

        return insets
    }

    public var cellOuterInsets: UIEdgeInsets { Self.cellOuterInsets(in: view) }

    public func cellOuterInsetsWithMargin(top: CGFloat = .zero, left: CGFloat = .zero, bottom: CGFloat = .zero, right: CGFloat = .zero) -> UIEdgeInsets {
        UIEdgeInsets(
            top: top,
            left: left + cellHOuterLeftMargin,
            bottom: bottom,
            right: right + cellHOuterRightMargin
        )
    }

    public func cellOuterInsetsWithMargin(hMargin: CGFloat, vMargin: CGFloat) -> UIEdgeInsets {
        cellOuterInsetsWithMargin(top: vMargin, left: hMargin, bottom: vMargin, right: hMargin)
    }

    public static func cellHOuterLeftMargin(in view: UIView) -> CGFloat {
        cellOuterInsets(in: view).left
    }

    public var cellHOuterLeftMargin: CGFloat { Self.cellHOuterLeftMargin(in: view) }

    public static func cellHOuterRightMargin(in view: UIView) -> CGFloat {
        cellOuterInsets(in: view).right
    }

    public var cellHOuterRightMargin: CGFloat { Self.cellHOuterRightMargin(in: view) }

    // The distance from the cell border to the cell content.
    public static var cellHInnerMargin: CGFloat {
        UIDevice.current.isPlusSizePhone ? 20 : 16
    }

    // The distance from the cell border to the cell content.
    public static var cellVInnerMargin: CGFloat {
        13
    }

    private var automaticDimension: CGFloat {
        UITableView.automaticDimension
    }

    private var headerFont: UIFont { .dynamicTypeBodyClamped.semibold() }
    private var footerFont: UIFont { .dynamicTypeCaption1Clamped }

    private func headerTextContainerInsets(for section: OWSTableSection) -> UIEdgeInsets {
        headerTextContainerInsets(useDeepInsets: section.hasBackground)
    }

    private func headerTextContainerInsets(useDeepInsets: Bool) -> UIEdgeInsets {
        var textContainerInset = cellOuterInsetsWithMargin(
            top: (defaultSpacingBetweenSections ?? 0) + 12,
            bottom: 10
        )

        if useDeepInsets {
            textContainerInset.left += Self.cellHInnerMargin * 0.5
            textContainerInset.right += Self.cellHInnerMargin * 0.5
        }

        textContainerInset.left += tableView.safeAreaInsets.left
        textContainerInset.right += tableView.safeAreaInsets.right
        return textContainerInset
    }

    private func footerTextContainerInsets(for section: OWSTableSection) -> UIEdgeInsets {
        footerTextContainerInsets(useDeepInsets: section.hasBackground)
    }

    private func footerTextContainerInsets(useDeepInsets: Bool) -> UIEdgeInsets {
        var textContainerInset = cellOuterInsetsWithMargin(top: 12)

        if useDeepInsets {
            textContainerInset.left += Self.cellHInnerMargin
            textContainerInset.right += Self.cellHInnerMargin
        }

        textContainerInset.left += tableView.safeAreaInsets.left
        textContainerInset.right += tableView.safeAreaInsets.right

        return textContainerInset
    }

    private func buildDefaultHeaderOrFooter(height: CGFloat) -> UIView {
        let view = UIView()
        view.backgroundColor = .clear
        view.autoSetDimension(.height, toSize: height)
        return view
    }

    private func buildHeaderOrFooterTextView() -> UITextView {
        let textView = LinkingTextView()
        textView.backgroundColor = self.tableBackgroundColor
        return textView
    }

    public func buildHeaderTextView(forSection section: OWSTableSection) -> UITextView {
        let textView = buildHeaderTextView(withDeepInsets: section.hasBackground)
        textView.delegate = section.headerTextViewDelegate
        return textView
    }

    public func buildHeaderTextView(withDeepInsets: Bool) -> UITextView {
        let textView = buildHeaderOrFooterTextView()

        textView.textColor = (Theme.isDarkThemeEnabled || forceDarkMode) ? UIColor.ows_gray05 : UIColor.ows_gray90
        textView.font = headerFont
        textView.textContainerInset = headerTextContainerInsets(useDeepInsets: withDeepInsets)

        return textView
    }

    public func buildFooterTextView(forSection section: OWSTableSection) -> UITextView {
        let textView = buildFooterTextView(withDeepInsets: section.hasBackground)
        textView.delegate = section.footerTextViewDelegate
        return textView
    }

    public func buildFooterTextView(withDeepInsets: Bool) -> UITextView {
        let textView = buildHeaderOrFooterTextView()

        textView.textColor = forceDarkMode ? Theme.darkThemeSecondaryTextAndIconColor : Theme.secondaryTextAndIconColor
        textView.font = footerFont

        let linkTextAttributes: [NSAttributedString.Key: Any] = [
            NSAttributedString.Key.foregroundColor: forceDarkMode ? Theme.darkThemePrimaryColor : Theme.primaryTextColor,
            NSAttributedString.Key.font: footerFont,
            NSAttributedString.Key.underlineStyle: 0
        ]
        textView.linkTextAttributes = linkTextAttributes

        textView.textContainerInset = footerTextContainerInsets(useDeepInsets: withDeepInsets)

        return textView
    }

    public func tableView(_ tableView: UITableView, viewForHeaderInSection sectionIndex: Int) -> UIView? {
        guard let section = contents.sections[safe: sectionIndex] else {
            owsFailDebug("Missing section: \(sectionIndex)")
            return nil
        }

        if let customHeaderView = section.customHeaderView {
            return customHeaderView
        } else if let headerTitle = section.headerTitle,
                  !headerTitle.isEmpty {
            let textView = buildHeaderTextView(forSection: section)
            textView.text = headerTitle

            return textView
        } else if let headerAttributedTitle = section.headerAttributedTitle,
                  !headerAttributedTitle.isEmpty {
            let textView = buildHeaderTextView(forSection: section)
            textView.attributedText = headerAttributedTitle

            return textView
        } else if let customHeaderHeight = section.customHeaderHeight,
                  customHeaderHeight > 0 {
            return buildDefaultHeaderOrFooter(height: customHeaderHeight)
        } else if let defaultHeaderHeight = defaultHeaderHeight,
                  defaultHeaderHeight > 0 {
            return buildDefaultHeaderOrFooter(height: defaultHeaderHeight)
        } else if let defaultSpacingBetweenSections = defaultSpacingBetweenSections,
                  defaultSpacingBetweenSections > 0, !section.items.isEmpty {
            return buildDefaultHeaderOrFooter(height: defaultSpacingBetweenSections)
        } else {
            return nil
        }
    }

    public func tableView(_ tableView: UITableView, viewForFooterInSection sectionIndex: Int) -> UIView? {
        guard let section = contents.sections[safe: sectionIndex] else {
            owsFailDebug("Missing section: \(sectionIndex)")
            return nil
        }

        if let customFooterView = section.customFooterView {
            return customFooterView
        } else if let footerTitle = section.footerTitle,
                  !footerTitle.isEmpty {
            let textView = buildFooterTextView(forSection: section)
            textView.text = footerTitle

            return textView
        } else if let footerAttributedTitle = section.footerAttributedTitle,
                  !footerAttributedTitle.isEmpty {
            let textView = buildFooterTextView(forSection: section)
            textView.attributedText = footerAttributedTitle

            return textView
        } else if let customFooterHeight = section.customFooterHeight,
                  customFooterHeight > 0 {
            return buildDefaultHeaderOrFooter(height: customFooterHeight)
        } else if let defaultFooterHeight = defaultFooterHeight,
                  defaultFooterHeight > 0 {
            return buildDefaultHeaderOrFooter(height: defaultFooterHeight)
        } else if isLastSection(tableView, sectionIndex: sectionIndex),
                  defaultLastSectionFooter > 0 {
            return buildDefaultHeaderOrFooter(height: defaultLastSectionFooter)
        } else {
            return nil
        }
    }

    private func isLastSection(_ tableView: UITableView, sectionIndex: Int) -> Bool {
        sectionIndex == numberOfSections(in: tableView) - 1
    }

    public func tableView(_ tableView: UITableView, heightForHeaderInSection sectionIndex: Int) -> CGFloat {
        guard let section = contents.sections[safe: sectionIndex] else {
            owsFailDebug("Missing section: \(sectionIndex)")
            return 0
        }

        if let customHeaderHeight = section.customHeaderHeight {
            owsAssertDebug(customHeaderHeight > 0 || customHeaderHeight == automaticDimension)
            return customHeaderHeight
        } else if let headerTitle = section.headerTitle, !headerTitle.isEmpty {
            // Get around a bug sizing UITextView in iOS 16/17 by manually sizing instead
            // of relying on UITableView.automaticDimension
            if #available(iOS 18, *) { owsFailDebug("Canary to check if this has been fixed") }
            let insets = headerTextContainerInsets(for: section)
            // Reuse sizing code for CVText even though we aren't using a CVText here.
            let height = CVText.measureLabel(
                config: CVLabelConfig.unstyledText(
                    headerTitle,
                    font: headerFont,
                    textColor: .black, // doesn't matter for sizing
                    numberOfLines: 0,
                    lineBreakMode: .byWordWrapping,
                    textAlignment: .natural
                ),
                maxWidth: tableView.frame.width - insets.totalWidth
            ).height
            return height + insets.totalHeight
        } else if let headerTitle = section.headerAttributedTitle, !headerTitle.isEmpty {
            // Get around a bug sizing UITextView in iOS 16/17 by manually sizing instead
            // of relying on UITableView.automaticDimension
            if #available(iOS 18, *) { owsFailDebug("Canary to check if this has been fixed") }
            let insets = headerTextContainerInsets(for: section)
            // Reuse sizing code for CVText even though we aren't using a CVText here.
            let height = CVText.measureLabel(
                config: CVLabelConfig(
                    text: .attributedText(headerTitle),
                    displayConfig: .forMeasurement(font: headerFont),
                    font: headerFont,
                    textColor: .black, // doesn't matter for sizing
                    numberOfLines: 0,
                    lineBreakMode: .byWordWrapping,
                    textAlignment: .natural
                ),
                maxWidth: tableView.frame.width - insets.totalWidth
            ).height
            return height + insets.totalHeight
        } else if nil != self.tableView(tableView, viewForHeaderInSection: sectionIndex) {
            return automaticDimension
        } else {
            return 0
        }
    }

    public func tableView(_ tableView: UITableView, heightForFooterInSection sectionIndex: Int) -> CGFloat {
        guard let section = contents.sections[safe: sectionIndex] else {
            owsFailDebug("Missing section: \(sectionIndex)")
            return 0
        }

        if let customFooterHeight = section.customFooterHeight {
            owsAssertDebug(customFooterHeight > 0 || customFooterHeight == automaticDimension)
            return customFooterHeight
        } else if let footerTitle = section.footerTitle, !footerTitle.isEmpty {
            // Get around a bug sizing UITextView in iOS 16/17 by manually sizing instead
            // of relying on UITableView.automaticDimension
            if #available(iOS 18, *) { owsFailDebug("Canary to check if this has been fixed") }
            let insets = footerTextContainerInsets(for: section)
            // Reuse sizing code for CVText even though we aren't using a CVText here.
            let height = CVText.measureLabel(
                config: CVLabelConfig.unstyledText(
                    footerTitle,
                    font: footerFont,
                    textColor: .black, // doesn't matter for sizing
                    numberOfLines: 0,
                    lineBreakMode: .byWordWrapping,
                    textAlignment: .natural
                ),
                maxWidth: tableView.frame.width - insets.totalWidth
            ).height
            return height + insets.totalHeight
        } else if let footerTitle = section.footerAttributedTitle, !footerTitle.isEmpty {
            // Get around a bug sizing UITextView in iOS 16/17 by manually sizing instead
            // of relying on UITableView.automaticDimension
            if #available(iOS 18, *) { owsFailDebug("Canary to check if this has been fixed") }
            let insets = footerTextContainerInsets(for: section)
            // Reuse sizing code for CVText even though we aren't using a CVText here.
            let height = CVText.measureLabel(
                config: CVLabelConfig(
                    text: .attributedText(footerTitle),
                    displayConfig: .forMeasurement(font: footerFont),
                    font: footerFont,
                    textColor: .black, // doesn't matter for sizing
                    numberOfLines: 0,
                    lineBreakMode: .byWordWrapping,
                    textAlignment: .natural
                ),
                maxWidth: tableView.frame.width - insets.totalWidth
            ).height
            return height + insets.totalHeight
        } else if nil != self.tableView(tableView, viewForFooterInSection: sectionIndex) {
            return automaticDimension
        } else {
            return 0
        }
    }

    // Called before the user changes the selection. Return a new indexPath, or nil, to change the proposed selection.
    public func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
        guard let item = self.item(for: indexPath) else {
            owsFailDebug("Missing item: \(indexPath)")
            return nil
        }
        if item.actionBlock != nil {
            return indexPath
        } else {
            return nil
        }
    }

    public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        switch selectionBehavior {
        case .actionWithAutoDeselect:
            tableView.deselectRow(at: indexPath, animated: false)
        case .toggleSelectionWithAction:
            break
        }

        performAction(indexPath: indexPath)
    }

    public func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        switch selectionBehavior {
        case .actionWithAutoDeselect:
            return
        case .toggleSelectionWithAction:
            performAction(indexPath: indexPath)
        }
    }

    private func performAction(indexPath: IndexPath) {
        guard let item = self.item(for: indexPath) else {
            owsFailDebug("Missing item: \(indexPath)")
            return
        }
        if let actionBlock = item.actionBlock {
            actionBlock()
        }
    }

    public func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
        guard let section = contents.sections[safe: indexPath.section] else {
            owsFailDebug("Missing section: \(indexPath.section)")
            return true
        }
        return !section.shouldDisableCellSelection
    }

    // MARK: - Index

    // tell table which section corresponds to section title/index (e.g. "B",1))
    public func tableView(_ tableView: UITableView, sectionForSectionIndexTitle title: String, at index: Int) -> Int {
        guard let sectionForSectionIndexTitleBlock = contents.sectionForSectionIndexTitleBlock else {
            return 0
        }
        return sectionForSectionIndexTitleBlock(title, index)
    }

    // return list of section titles to display in section index view (e.g. "ABCD...Z#")
    public func sectionIndexTitles(for tableView: UITableView) -> [String]? {
        guard let sectionIndexTitlesForTableViewBlock = contents.sectionIndexTitlesForTableViewBlock else {
            return nil
        }
        return sectionIndexTitlesForTableViewBlock()
    }

    // MARK: - Presentation

    public func present(fromViewController: UIViewController) {
        let navigationController = OWSNavigationController(rootViewController: self)
        navigationItem.leftBarButtonItem = .doneButton(dismissingFrom: self)
        fromViewController.present(navigationController, animated: true, completion: nil)
    }

    // MARK: - UIScrollViewDelegate

    public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        delegate?.tableViewWillBeginDragging(tableView)
    }

    // MARK: - Theme

    public static func tableBackgroundColor(
        isUsingPresentedStyle: Bool,
        forceDarkMode: Bool = false
    ) -> UIColor {
        AssertIsOnMainThread()

        if isUsingPresentedStyle {
            return forceDarkMode ? Theme.darkThemeTableView2PresentedBackgroundColor : Theme.tableView2PresentedBackgroundColor
        } else {
            return forceDarkMode ? Theme.darkThemeTableView2BackgroundColor : Theme.tableView2BackgroundColor
        }
    }

    public var cellBackgroundColor: UIColor {
        Self.cellBackgroundColor(
            isUsingPresentedStyle: isUsingPresentedStyle,
            forceDarkMode: forceDarkMode
        )
    }

    public static func cellBackgroundColor(
        isUsingPresentedStyle: Bool,
        forceDarkMode: Bool = false
    ) -> UIColor {
        if isUsingPresentedStyle {
            return forceDarkMode ? Theme.darkThemeTableCell2PresentedBackgroundColor : Theme.tableCell2PresentedBackgroundColor
        } else {
            return forceDarkMode ? Theme.darkThemeTableCell2BackgroundColor : Theme.tableCell2BackgroundColor
        }
    }

    public var cellSelectedBackgroundColor: UIColor {
        if isUsingPresentedStyle {
            return forceDarkMode ? Theme.darkThemeTableCell2PresentedSelectedBackgroundColor : Theme.tableCell2PresentedSelectedBackgroundColor
        } else {
            return forceDarkMode ? Theme.darkThemeTableCell2SelectedBackgroundColor : Theme.tableCell2SelectedBackgroundColor
        }
    }

    public var separatorColor: UIColor {
        if isUsingPresentedStyle {
            return forceDarkMode ? Theme.darkThemeTableView2PresentedSeparatorColor : Theme.tableView2PresentedSeparatorColor
        } else {
            return forceDarkMode ? Theme.darkThemeTableView2SeparatorColor : Theme.tableView2SeparatorColor
        }
    }

    public func applyTheme(to viewController: UIViewController) {
        AssertIsOnMainThread()

        viewController.view.backgroundColor = self.tableBackgroundColor

        if
            let owsNavigationController = viewController.owsNavigationController,
            ((viewController as? OWSViewController)?.lifecycle ?? .appeared) == .appeared
        {
            owsNavigationController.updateNavbarAppearance()
        }

        Self.removeBackButtonText(viewController: viewController)

        if viewController != self { applyTheme() }
    }

    public static func removeBackButtonText(viewController: UIViewController) {
        // We never want to show titles on back buttons, so we replace it with
        // blank spaces. We pad it out slightly so that it's more tappable.
        viewController.navigationItem.backBarButtonItem = .init(title: "   ", style: .plain, target: nil, action: nil)
    }

    private func updateNavbarStyling() {
        if lifecycle == .appeared {
            owsNavigationController?.updateNavbarAppearance(animated: true)
        }
    }

    open func scrollViewDidScroll(_ scrollView: UIScrollView) {
        updateNavbarStyling()
    }

    open override var isEditing: Bool {
        get { tableView.isEditing }
        set { tableView.isEditing = newValue }
    }

    public override func setEditing(_ editing: Bool, animated: Bool) {
        tableView.setEditing(editing, animated: animated)
    }

    public func setEditing(_ editing: Bool) {
        tableView.setEditing(editing, animated: false)
    }

    // MARK: -

    open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        guard isViewLoaded else {
            return
        }

        // There is a subtle difference in when the split view controller
        // transitions between collapsed and expanded state on iPad vs
        // when it does on iPhone. We reloadData here in order to ensure
        // the background color of all of our cells is updated to reflect
        // the current state, so it's important that we're only doing this
        // once the state is ready, otherwise there will be a flash of the
        // wrong background color. For iPad, this moment is _before_ the
        // transition occurs. For iPhone, this moment is _during_ the
        // transition. We reload in the right places accordingly.
        if UIDevice.current.isIPad {
            applyContents()
        }

        coordinator.animate { [weak self] _ in
            self?.applyContents()
        } completion: { [weak self] _ in
            self?.applyContents()
        }
    }

    public override func viewSafeAreaInsetsDidChange() {
        applyContents()
    }

    public func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
        if
            let item = self.item(for: indexPath),
            let contextMenuActionProvider = item.contextMenuActionProvider
        {
            return UIContextMenuConfiguration(actionProvider: contextMenuActionProvider)
        }
        return nil
    }
}

// MARK: -

public extension UITableViewCell {
    func addBackgroundView(backgroundColor: UIColor) {
        let backgroundView = UIView()
        backgroundView.backgroundColor = backgroundColor
        contentView.addSubview(backgroundView)
        contentView.sendSubviewToBack(backgroundView)
        backgroundView.autoPinEdgesToSuperviewEdges()
    }
}

// MARK: -

extension OWSTableViewController2: OWSTableViewDelegate {
    func tableViewDidChangeWidth() {
        applyContents()
    }
}

// MARK: -

private protocol OWSTableViewDelegate: AnyObject {
    func tableViewDidChangeWidth()
}

// MARK: -

public class OWSTableView: UITableView {
    fileprivate weak var tableViewDelegate: OWSTableViewDelegate?

    public override var frame: CGRect {
        didSet {
            let didChangeWidth = frame.width != oldValue.width
            if didChangeWidth {
                tableViewDelegate?.tableViewDidChangeWidth()
            }
        }
    }

    public override var bounds: CGRect {
        didSet {
            let didChangeWidth = bounds.width != oldValue.width
            if didChangeWidth {
                tableViewDelegate?.tableViewDidChangeWidth()
            }
        }
    }
}

// MARK: - TextViewWithPlaceholderDelegate

extension TextViewWithPlaceholderDelegate where Self: OWSTableViewController2 {
    /// Creates an ``OWSTableItem`` with the text view.
    /// - Parameters:
    ///   - textView: The ``TextViewWithPlaceholder`` to use in the cell.
    ///   - minimumHeight: An optional minimum height to constrain the text view to.
    ///   - dataDetectorTypes: The types of data that convert to tappable URLs in the text view.
    /// - Returns: An ``OWSTableItem`` with the `textView` embedded.
    public func textViewItem(
        _ textView: TextViewWithPlaceholder,
        minimumHeight: CGFloat? = nil,
        dataDetectorTypes: UIDataDetectorTypes? = nil
    ) -> OWSTableItem {
        .init(customCellBlock: { [weak self] in
            guard let self else { return OWSTableItem.newCell() }

            let cell = OWSTableItem.newCell()
            cell.selectionStyle = .none
            cell.contentView.addSubview(textView)

            /// `UITextView` has default top and bottom insets of 8 which we
            /// need to subtract off. See ``TextViewWithPlaceholder``'s
            /// `buildTextView()`  for why they can't just be set to 0.
            textView.autoPinEdgesToSuperviewMargins(with: .init(hMargin: 0, vMargin: -8))

            if let minimumHeight {
                textView.autoSetDimension(
                    .height,
                    toSize: minimumHeight,
                    relation: .greaterThanOrEqual
                )
            }

            if let dataDetectorTypes {
                textView.dataDetectorTypes = dataDetectorTypes
            }

            if textView.delegate == nil {
                textView.delegate = self
            }

            return cell
        }, actionBlock: {
            textView.becomeFirstResponder()
        })
    }

    /// A default handler for
    /// `TextViewWithPlaceholderDelegate.textViewDidUpdateSelection(_:)`
    /// when used within an ``OWSTableViewController2``.
    public func _textViewDidUpdateSelection(_ textView: TextViewWithPlaceholder) {
        textView.scrollToFocus(in: tableView, animated: true)
    }

    /// A default handler for
    /// `TextViewWithPlaceholderDelegate.textViewDidUpdateText(_:)`
    /// when used with an ``OWSTableViewController2``.
    public func _textViewDidUpdateText(_ textView: TextViewWithPlaceholder) {
        // Kick the tableview so it recalculates sizes
        UIView.performWithoutAnimation {
            tableView.performBatchUpdates(nil) { (_) in
                // And when the size changes have finished, make sure we're
                // scrolled to the focused line.
                textView.scrollToFocus(in: self.tableView, animated: false)
            }
        }
    }

    // MARK: Default implementation

    public func textViewDidUpdateSelection(_ textView: TextViewWithPlaceholder) {
        _textViewDidUpdateSelection(textView)
    }

    public func textViewDidUpdateText(_ textView: TextViewWithPlaceholder) {
        _textViewDidUpdateText(textView)
    }
}
