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

import SignalServiceKit

// MARK: - Delegate Protocols

public protocol StickerPacksToolbarDelegate: AnyObject {
    func presentManageStickersView()
}

public protocol StickerPickerPageViewDelegate: StickerPickerDelegate {
    func setItems(_ items: [StickerHorizontalListViewItem])
    func updateSelections(scrollToSelectedItem: Bool)
}

// MARK: - StickerPacksToolbar

class StickerPacksToolbar: UIStackView {
    private static let packCoverSize: CGFloat = 32
    private static let packCoverInset: CGFloat = 4
    private static let packCoverSpacing: CGFloat = 4
    private let forceDarkTheme: Bool
    lazy var packsCollectionView: StickerHorizontalListView = {
        let view = StickerHorizontalListView(
            cellSize: StickerPacksToolbar.packCoverSize,
            cellInset: StickerPacksToolbar.packCoverInset,
            spacing: StickerPacksToolbar.packCoverSpacing,
            forceDarkTheme: forceDarkTheme
        )

        view.contentInset = .zero
        view.autoSetDimension(.height, toSize: StickerPacksToolbar.packCoverSize + view.contentInset.top + view.contentInset.bottom)

        return view
    }()

    private lazy var manageButton: OWSButton = {
        let tintColor = forceDarkTheme ? Theme.darkThemeSecondaryTextAndIconColor : Theme.secondaryTextAndIconColor
        let button = OWSButton(imageName: "plus", tintColor: tintColor) { [weak self] in
            self?.delegate?.presentManageStickersView()
        }
        button.setContentHuggingHigh()
        button.setCompressionResistanceHigh()
        button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "manageButton")
        return button
    }()

    public weak var delegate: StickerPacksToolbarDelegate? {
        didSet {
            configureManageButton()
        }
    }

    init(forceDarkTheme: Bool = false) {
        self.forceDarkTheme = forceDarkTheme
        super.init(frame: .zero)
        populate()
    }

    required init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func populate() {
        spacing = Self.packCoverSpacing
        axis = .horizontal
        alignment = .center
        layoutMargins = UIEdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6)
        isLayoutMarginsRelativeArrangement = true

        packsCollectionView.backgroundColor = .clear
        addArrangedSubview(packsCollectionView)
    }

    private func configureManageButton() {
        if delegate != nil {
            // Show manage button
            addArrangedSubview(manageButton)
        } else {
            // Hide manage button
            removeArrangedSubview(manageButton)
        }
    }
}

// MARK: - StickerPickerPageView

public class StickerPickerPageView: UIView {

    public private(set) weak var delegate: StickerPickerPageViewDelegate?

    private let forceDarkTheme: Bool

    private var stickerPacks = [StickerPack]()

    private var selectedStickerPack: StickerPack? {
        didSet {
            selectedPackChanged(oldSelectedPack: oldValue)
        }
    }

    public init(delegate: StickerPickerPageViewDelegate, forceDarkTheme: Bool = false) {
        self.delegate = delegate
        self.forceDarkTheme = forceDarkTheme

        super.init(frame: .zero)

        layoutMargins = .zero

        setupPaging()

        reloadStickers()

        // By default, show the "recent" stickers.
        assert(nil == selectedStickerPack)

        NotificationCenter.default.addObserver(self,
                                               selector: #selector(stickersOrPacksDidChange),
                                               name: StickerManager.stickersOrPacksDidChange,
                                               object: nil)

        NotificationCenter.default.addObserver(self,
                                               selector: #selector(keyboardFrameDidChange),
                                               name: UIResponder.keyboardDidChangeFrameNotification,
                                               object: nil)
    }

    required public init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    public func wasPresented() {
        // If there are no recents, default to showing the first sticker pack.
        if currentPageCollectionView.stickerCount < 1 {
            updateSelectedStickerPack(stickerPacks.first)
        }

        updatePageConstraints()
    }

    private let reusableStickerViewCache = StickerViewCache(maxSize: 32)
    private func reloadStickers() {
        let oldStickerPacks = stickerPacks

        databaseStorage.read { (transaction) in
            self.stickerPacks = StickerManager.installedStickerPacks(transaction: transaction).sorted {
                $0.dateCreated > $1.dateCreated
            }
        }

        var items = [StickerHorizontalListViewItem]()
        items.append(StickerHorizontalListViewItemRecents(
            didSelectBlock: { [weak self] in
                self?.recentsButtonWasTapped()
            },
            isSelectedBlock: { [weak self] in
                self?.selectedStickerPack == nil
            },
            forceDarkTheme: forceDarkTheme
        ))
        items += stickerPacks.map { (stickerPack) in
            StickerHorizontalListViewItemSticker(
                stickerInfo: stickerPack.coverInfo,
                didSelectBlock: { [weak self] in
                    self?.updateSelectedStickerPack(stickerPack)
                },
                isSelectedBlock: { [weak self] in
                    self?.selectedStickerPack?.info == stickerPack.info
                },
                cache: reusableStickerViewCache
            )
        }
        delegate?.setItems(items)

        guard stickerPacks.count > 0 else {
            _ = resignFirstResponder()
            return
        }

        guard oldStickerPacks != stickerPacks else { return }

        // If the selected pack was uninstalled, select the first pack.
        if let selectedStickerPack = selectedStickerPack, !stickerPacks.contains(selectedStickerPack) {
            updateSelectedStickerPack(stickerPacks.first)
        }

        resetStickerPages()
    }

    // MARK: Events

    @objc
    func stickersOrPacksDidChange() {
        AssertIsOnMainThread()

        reloadStickers()
    }

    @objc
    func keyboardFrameDidChange() {
        updatePageConstraints(ignoreScrollingState: true)
    }

    private func recentsButtonWasTapped() {
        AssertIsOnMainThread()

        // nil is used for the recents special-case.
        updateSelectedStickerPack(nil)
    }

    private func updateSelectedStickerPack(_ stickerPack: StickerPack?, scrollToSelected: Bool = false) {
        selectedStickerPack = stickerPack
        delegate?.updateSelections(scrollToSelectedItem: scrollToSelected)
    }

    // MARK: Paging

    /// This array always includes three collection views, where the indices represent:
    /// 0 - Previous Page
    /// 1 - Current Page
    /// 2 - Next Page
    var stickerPackCollectionViews = [
        StickerPackCollectionView(),
        StickerPackCollectionView(),
        StickerPackCollectionView()
    ]
    private var stickerPackCollectionViewConstraints = [NSLayoutConstraint]()

    private var currentPageCollectionView: StickerPackCollectionView {
        return stickerPackCollectionViews[1]
    }

    private var nextPageCollectionView: StickerPackCollectionView {
        return stickerPackCollectionViews[2]
    }

    private var previousPageCollectionView: StickerPackCollectionView {
        return stickerPackCollectionViews[0]
    }

    private lazy var stickerPagingScrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.isPagingEnabled = true
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.isDirectionalLockEnabled = true
        scrollView.delegate = self
        scrollView.clipsToBounds = false
        return scrollView
    }()

    private var nextPageStickerPack: StickerPack? {
        // If we don't have a pack defined, the first pack is always up next
        guard let stickerPack = selectedStickerPack else { return stickerPacks.first }

        // If we don't have an index, or we're at the end of the array, recents is up next
        guard let index = stickerPacks.firstIndex(of: stickerPack), index < (stickerPacks.count - 1) else { return nil }

        // Otherwise, use the next pack in the array
        return stickerPacks[index + 1]
    }

    private var previousPageStickerPack: StickerPack? {
        // If we don't have a pack defined, the last pack is always previous
        guard let stickerPack = selectedStickerPack else { return stickerPacks.last }

        // If we don't have an index, or we're at the start of the array, recents is previous
        guard let index = stickerPacks.firstIndex(of: stickerPack), index > 0 else { return nil }

        // Otherwise, use the previous pack in the array
        return stickerPacks[index - 1]
    }

    private var pageWidth: CGFloat { return stickerPagingScrollView.frame.width }
    private var numberOfPages: CGFloat { return CGFloat(stickerPackCollectionViews.count) }
    private let pageSpacing: CGFloat = 40

    // These thresholds indicate the offset at which we update the next / previous page.
    // They're not exactly half way through the transition, to avoid us continuously
    // bouncing back and forth between pages.
    private var previousPageThreshold: CGFloat { return pageWidth * 0.45 }
    private var nextPageThreshold: CGFloat { return pageWidth + previousPageThreshold }

    private func setupPaging() {
        addSubview(stickerPagingScrollView)
        stickerPagingScrollView.autoPinHeightToSuperview()
        stickerPagingScrollView.autoPinEdge(toSuperviewMargin: .leading, withInset: -0.5 * pageSpacing)
        stickerPagingScrollView.autoPinEdge(toSuperviewMargin: .trailing, withInset: -0.5 * pageSpacing)

        let stickerPagesContainer = UIView()
        stickerPagingScrollView.addSubview(stickerPagesContainer)
        stickerPagesContainer.autoPinEdgesToSuperviewEdges()
        stickerPagesContainer.autoMatch(.height, to: .height, of: stickerPagingScrollView)
        stickerPagesContainer.autoMatch(.width, to: .width, of: stickerPagingScrollView, withMultiplier: numberOfPages)

        for (index, collectionView) in stickerPackCollectionViews.enumerated() {
            collectionView.backgroundColor = .clear
            collectionView.isDirectionalLockEnabled = true
            collectionView.stickerDelegate = self

            // We want the current page on top, to prevent weird
            // animations when we initially calculate our frame.
            if collectionView == currentPageCollectionView {
                stickerPagesContainer.addSubview(collectionView)
            } else {
                stickerPagesContainer.insertSubview(collectionView, at: 0)
            }

            collectionView.autoMatch(.width, to: .width, of: stickerPagingScrollView, withOffset: -pageSpacing)
            collectionView.autoMatch(.height, to: .height, of: stickerPagingScrollView)

            collectionView.autoPinEdge(toSuperviewEdge: .top)
            collectionView.autoPinEdge(toSuperviewEdge: .bottom)

            stickerPackCollectionViewConstraints.append(
                collectionView.autoPinEdge(toSuperviewEdge: .left, withInset: CGFloat(index) * pageWidth + 0.5 * pageSpacing)
            )
        }
    }

    private var pendingPageChangeUpdates: (() -> Void)?
    private func applyPendingPageChangeUpdates() {
        pendingPageChangeUpdates?()
        pendingPageChangeUpdates = nil
    }

    private func selectedPackChanged(oldSelectedPack: StickerPack?) {
        AssertIsOnMainThread()

        // We're paging backwards!
        if oldSelectedPack == nextPageStickerPack {
            // The previous page becomes the current page and the current page becomes
            // the next page. We have to load the new previous.

            stickerPackCollectionViews.insert(stickerPackCollectionViews.removeLast(), at: 0)
            stickerPackCollectionViewConstraints.insert(stickerPackCollectionViewConstraints.removeLast(), at: 0)

            pendingPageChangeUpdates = {
                self.previousPageCollectionView.showInstalledPackOrRecents(stickerPack: self.previousPageStickerPack)
            }

        // We're paging forwards!
        } else if oldSelectedPack == previousPageStickerPack {
            // The next page becomes the current page and the current page becomes
            // the previous page. We have to load the new next.

            stickerPackCollectionViews.append(stickerPackCollectionViews.removeFirst())
            stickerPackCollectionViewConstraints.append(stickerPackCollectionViewConstraints.removeFirst())

            pendingPageChangeUpdates = {
                self.nextPageCollectionView.showInstalledPackOrRecents(stickerPack: self.nextPageStickerPack)
            }

        // We didn't get here through paging, stuff probably changed. Reload all the things.
        } else {
            currentPageCollectionView.showInstalledPackOrRecents(stickerPack: selectedStickerPack)
            previousPageCollectionView.showInstalledPackOrRecents(stickerPack: previousPageStickerPack)
            nextPageCollectionView.showInstalledPackOrRecents(stickerPack: nextPageStickerPack)

            pendingPageChangeUpdates = nil
        }

        // If we're not currently scrolling, apply the page change updates immediately.
        if !isScrollingChange { applyPendingPageChangeUpdates() }

        updatePageConstraints()
    }

    private func resetStickerPages() {
        currentPageCollectionView.showInstalledPackOrRecents(stickerPack: selectedStickerPack)
        previousPageCollectionView.showInstalledPackOrRecents(stickerPack: previousPageStickerPack)
        nextPageCollectionView.showInstalledPackOrRecents(stickerPack: nextPageStickerPack)

        pendingPageChangeUpdates = nil

        updatePageConstraints()

        delegate?.updateSelections(scrollToSelectedItem: false)
    }

    private func updatePageConstraints(ignoreScrollingState: Bool = false) {
        // Setup the collection views in their page positions
        for (index, constraint) in stickerPackCollectionViewConstraints.enumerated() {
            constraint.constant = CGFloat(index) * pageWidth + 0.5 * pageSpacing
        }

        // Scrolling backwards
        if !ignoreScrollingState && stickerPagingScrollView.contentOffset.x <= previousPageThreshold {
            stickerPagingScrollView.contentOffset.x += pageWidth

        // Scrolling forward
        } else if !ignoreScrollingState && stickerPagingScrollView.contentOffset.x >= nextPageThreshold {
            stickerPagingScrollView.contentOffset.x -= pageWidth

        // Not moving forward or back, just scroll back to center so we can go forward and back again
        } else {
            stickerPagingScrollView.contentOffset.x = pageWidth
        }
    }

    // MARK: - Scroll state management

    /// Indicates that the user stopped actively scrolling, but
    /// we still haven't reached their final destination.
    private var isWaitingForDeceleration = false

    /// Indicates that the user started scrolling and we've yet
    /// to reach their final destination.
    private var isUserScrolling = false

    /// Indicates that we're currently changing pages due to a
    /// user initiated scroll action.
    private var isScrollingChange = false

    private func userStartedScrolling() {
        isWaitingForDeceleration = false
        isUserScrolling = true
    }

    private func userStoppedScrolling(waitingForDeceleration: Bool = false) {
        guard isUserScrolling else { return }

        if waitingForDeceleration {
            isWaitingForDeceleration = true
        } else {
            isWaitingForDeceleration = false
            isUserScrolling = false
        }
    }

    private func checkForPageChange() {
        // Ignore any page changes unless the user is triggering them.
        guard isUserScrolling else { return }

        isScrollingChange = true

        let offsetX = stickerPagingScrollView.contentOffset.x

        // Scrolled left a page
        if offsetX <= previousPageThreshold {
            updateSelectedStickerPack(previousPageStickerPack, scrollToSelected: true)

            // Scrolled right a page
        } else if offsetX >= nextPageThreshold {
            updateSelectedStickerPack(nextPageStickerPack, scrollToSelected: true)

            // We're about to cross the threshold into a new page, execute any pending updates.
            // We wait to execute these until we're sure we're going to cross over as it
            // can cause some UI jitter that interrupts scrolling.
        } else if offsetX >= pageWidth * 0.95 && offsetX <= pageWidth * 1.05 {
            applyPendingPageChangeUpdates()
        }

        isScrollingChange = false
    }
}

// MARK: UIScrollViewDelegate

extension StickerPickerPageView: UIScrollViewDelegate {
    public func scrollViewDidScroll(_ scrollView: UIScrollView) {
        checkForPageChange()
    }

    public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        userStartedScrolling()
    }

    public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        userStoppedScrolling(waitingForDeceleration: decelerate)
    }

    public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        userStoppedScrolling()
    }
}

extension StickerPickerPageView: StickerPackCollectionViewDelegate {
    public func didSelectSticker(stickerInfo: StickerInfo) {
        AssertIsOnMainThread()

        delegate?.didSelectSticker(stickerInfo: stickerInfo)
    }

    public var storyStickerConfiguration: StoryStickerConfiguration {
        delegate?.storyStickerConfiguration ?? .hide
    }

    public func stickerPreviewHostView() -> UIView? {
        AssertIsOnMainThread()

        return window
    }

    public func stickerPreviewHasOverlay() -> Bool {
        return true
    }
}

// MARK: - StickerViewCache

public class StickerViewCache {

    private typealias CacheType = LRUCache<StickerInfo, ThreadSafeCacheHandle<StickerReusableView>>
    private let backingCache: CacheType

    public init(maxSize: Int) {
        // Always use a nseMaxSize of zero.
        backingCache = LRUCache(maxSize: maxSize,
                                nseMaxSize: 0,
                                shouldEvacuateInBackground: true)
    }

    public func get(key: StickerInfo) -> StickerReusableView? {
        self.backingCache.get(key: key)?.value
    }

    public func set(key: StickerInfo, value: StickerReusableView) {
        self.backingCache.set(key: key, value: ThreadSafeCacheHandle(value))
    }

    public func remove(key: StickerInfo) {
        self.backingCache.remove(key: key)
    }

    public func clear() {
        self.backingCache.clear()
    }

    // MARK: NSCache Compatibility

    public func setObject(_ value: StickerReusableView, forKey key: StickerInfo) {
        set(key: key, value: value)
    }

    public func object(forKey key: StickerInfo) -> StickerReusableView? {
        self.get(key: key)
    }

    public func removeObject(forKey key: StickerInfo) {
        remove(key: key)
    }

    public func removeAllObjects() {
        clear()
    }
}
