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

import SignalCoreKit

public protocol ResizingView: UIView {
    var contentOffset: CGPoint { get set }
    var contentSize: CGSize { get }
}

extension UIScrollView: ResizingView {}

public protocol ResizingScrollViewDelegate: AnyObject {
    var resizingViewMinimumHeight: CGFloat { get }
    var resizingViewMaximumHeight: CGFloat { get }
}

public class ResizingScrollView<ResizingViewType: ResizingView>: UIView, UIScrollViewDelegate {
    public weak var resizingView: ResizingViewType? {
        didSet {
            oldValue?.removeGestureRecognizer(gestureScrollView.panGestureRecognizer)
            resizingView?.addGestureRecognizer(gestureScrollView.panGestureRecognizer)
            refreshHeightConstraints()
        }
    }
    public weak var delegate: ResizingScrollViewDelegate? {
        didSet { refreshHeightConstraints() }
    }

    // We utilize this scroll view *only* for it's panGestureRecognizer. No
    // interaction happens with the scrollView directly, instead we translate
    // its movement onto the view that is being resized. Doing this allows us
    // to get all the properties around tracking, bouncing, and decelerating
    // of an actual scrollView in contexts that aren't achievable with a
    // scrollView directly.
    private let gestureScrollView = UIScrollView()
    private lazy var heightConstraint = gestureScrollView.autoSetDimension(.height, toSize: 0, relation: .lessThanOrEqual)

    public init() {
        super.init(frame: .zero)

        isUserInteractionEnabled = false
        gestureScrollView.delegate = self

        addSubview(gestureScrollView)
        gestureScrollView.autoPinEdgesToSuperviewEdges()

        NSLayoutConstraint.autoSetPriority(.defaultHigh) {
            gestureScrollView.autoSetDimension(.height, toSize: .greatestFiniteMagnitude)
        }
    }

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

    private var current: State = .zero
    private struct State: Equatable {
        let minimumHeight: CGFloat
        let maximumHeight: CGFloat
        let contentSize: CGSize

        static var zero: Self { .init(minimumHeight: 0, maximumHeight: 0, contentSize: .zero) }
    }

    /// Call to notify the resizing scroll view that it's minimum and/or maximum
    /// bound has changed.
    public func refreshHeightConstraints() {
        guard let resizingView = resizingView, let delegate = delegate else { return }

        let new = State(
            minimumHeight: delegate.resizingViewMinimumHeight,
            maximumHeight: delegate.resizingViewMaximumHeight,
            contentSize: resizingView.contentSize
        )

        guard new.maximumHeight >= new.minimumHeight else {
            return owsFailDebug("Unexpectedly had a minimum height that is larger than the maximum height")
        }

        guard new.maximumHeight >= 0, new.minimumHeight >= 0 else {
            return owsFailDebug("Unexpectedly had a negative height value")
        }

        // Our minimum and maximum possible height could be changed at any
        // point by our delegate. When it does change, we need to adjust
        // the properties of the gestureScrollView to reflect that. For
        // example, the min/max bounds may change when a keyboard is
        // presented. It's the delegates responsibility to notify us
        // when this change occurs by calling `refreshHeightConstraints`,
        // but we also do our best to proactively update since doing so
        // should be cheap.
        guard new != current else { return }

        layoutIfNeeded()

        let currentHeight = gestureScrollView.height
        let currentOffset = gestureScrollView.contentOffset.y

        // The inset represents the difference between the min
        // and max sizes. When the scroll view's offset is less
        // than 0 (in the inset range) we're resizing the view.
        // When it's >= 0, we're scrolling the view.
        let newInset = new.maximumHeight - new.minimumHeight

        let newHeight: CGFloat
        if currentHeight >= new.minimumHeight && currentHeight <= new.maximumHeight {
            newHeight = currentHeight
        } else if currentHeight < new.minimumHeight {
            newHeight = new.minimumHeight
        } else if currentHeight > new.maximumHeight {
            newHeight = new.maximumHeight
        } else if new.maximumHeight > current.maximumHeight {
            // If the amount of space we can take up is growing,
            // grow to fill the new space.
            newHeight = min(currentHeight + (new.maximumHeight - current.maximumHeight), new.maximumHeight)
        } else {
            // If the amount of space we can take up is shrinking,
            // shrink to accommodate the new space.
            newHeight = max(currentHeight - (current.maximumHeight - new.maximumHeight), new.minimumHeight)
        }

        let newOffset: CGFloat
        if newHeight < new.maximumHeight {
            // If we're less than the maximum height, our
            // offset is always a representation of the
            // difference between the maximum height and
            // our current height.
            newOffset = newHeight - new.maximumHeight
        } else {
            // If we're above the maximum height, the offset
            // represents the current scroll position.
            newOffset = max(0, currentOffset)
        }

        heightConstraint.constant = newHeight

        gestureScrollView.contentSize = new.contentSize
        gestureScrollView.contentInset.top = newInset
        gestureScrollView.bounds.origin.y = newOffset

        current = new
    }

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

    public func scrollViewDidScroll(_ scrollView: UIScrollView) {
        owsAssertDebug(gestureScrollView == scrollView)
        guard let resizingView = resizingView, let delegate = delegate else { return }

        // Whenever the gesture scrollView scrolls, we need to update:
        // * our height, which the resizing view will reference
        // * the offset of the resizing view.
        //
        // If our offset is less than 0, we're in the "inset" range.
        // This range represents the difference between the min and
        // max height and is the space in which we should resize rather
        // than scroll.
        //
        // If our offset is >=0, the view should always be the max height
        // and our offset can be directly translated to the resizing view.

        if scrollView.contentOffset.y < 0 {
            let difference = scrollView.contentInset.top + scrollView.contentOffset.y
            if difference < 0 {
                heightConstraint.constant = delegate.resizingViewMinimumHeight
                resizingView.contentOffset.y = difference
            } else {
                heightConstraint.constant = delegate.resizingViewMinimumHeight + difference
                resizingView.contentOffset = .zero
            }
        } else {
            heightConstraint.constant = delegate.resizingViewMaximumHeight
            resizingView.contentOffset = scrollView.contentOffset
        }
    }
}
