//
//  MediaBrowserViewController.swift
//  ATGMediaBrowser
//
//  Created by Suraj Thomas K on 7/10/18.
//  Copyright © 2018 Al Tayer Group LLC.
//
//  Permission is hereby granted, free of charge, to any person obtaining a copy of this software
//  and associated documentation files (the "Software"), to deal in the Software without
//  restriction, including without limitation the rights to use, copy, modify, merge, publish,
//  distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
//  Software is furnished to do so, subject to the following conditions:
//
//  The above copyright notice and this permission notice shall be included in all copies or
//  substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
//  BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
//  NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
//  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//

import UIKit

// MARK: - MediaBrowserViewControllerDataSource protocol
/// Protocol to supply media browser contents.
public protocol MediaBrowserViewControllerDataSource: class {

    /**
     Completion block for passing requested media image with details.
     - parameter index: Index of the requested media.
     - parameter image: Image to be passed back to media browser.
     - parameter zoomScale: Zoom scale to be applied to the image including min and max levels.
     - parameter error: Error received while fetching the media image.

     - note:
        Remember to pass the index received in the datasource method back.
        This index is used to set the image to the correct image view.
     */
    typealias CompletionBlock = (_ index: Int, _ image: UIImage?, _ zoomScale: ZoomScale?, _ error: Error?) -> Void

    /**
     Method to supply number of items to be shown in media browser.
     - parameter mediaBrowser: Reference to media browser object.
     - returns: An integer with number of items to be shown in media browser.
     */
    func numberOfItems(in mediaBrowser: MediaBrowserViewController) -> Int

    /**
     Method to supply image for specific index.
     - parameter mediaBrowser: Reference to media browser object.
     - parameter index: Index of the requested media.
     - parameter completion: Completion block to be executed on fetching the media image.
     */
    func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, imageAt index: Int, completion: @escaping CompletionBlock)

    /**
     This **optional method** callback is provided to update the styling of close button.
     - parameter mediaBrowser: Reference to media browser object.
     - parameter button: Reference to close button

     - note:
        You can modify the styling of the supplied button, and even add constraints to position
     the button relative to it's superview. Remember that if no constraints are applied on the button,
     default constraints will be applied on, and will be shown on top-right side of the view.

        On top of that you can add target to this button to handle the closebutton event manually. By
     default touch-up-inside event is used to close the media browser.
     */
    func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, updateCloseButton button: UIButton)

    /**
     This method is used to get the target frame into which the browser will perform the dismiss transition.
     - parameter mediaBrowser: Reference to media browser object.

     - note:
        If this method is not implemented, the media browser will perform slide up/down transition on dismissal.
    */
    func targetFrameForDismissal(_ mediaBrowser: MediaBrowserViewController) -> CGRect?
}

extension MediaBrowserViewControllerDataSource {

    public func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, updateCloseButton button: UIButton) {}
    public func targetFrameForDismissal(_ mediaBrowser: MediaBrowserViewController) -> CGRect? { return nil }
}

// MARK: - MediaBrowserViewControllerDelegate protocol

public protocol MediaBrowserViewControllerDelegate: class {

    /**
     Method invoked on scrolling to next/previous media items.
     - parameter mediaBrowser: Reference to media browser object.
     - parameter index: Index of the newly focussed media item.
     - note:
        This method will not be called on first load, and will be called only on swiping left and right.
     */
    func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, didChangeFocusTo index: Int)
}

extension MediaBrowserViewControllerDelegate {

    public func mediaBrowser(_ mediaBrowser: MediaBrowserViewController, didChangeFocusTo index: Int) {}
}

public class MediaBrowserViewController: UIViewController {

    // MARK: - Exposed Enumerations

    /**
     Enum to hold supported gesture directions.

     ```
     case horizontal
     case vertical
     ```
    */
    public enum GestureDirection {

        /// Horizontal (left - right) gestures.
        case horizontal
        /// Vertical (up - down) gestures.
        case vertical
    }

    /**
     Enum to hold supported browser styles.

     ```
     case linear
     case carousel
     ```
     */
    public enum BrowserStyle {

        /// Linear browser with *0* as first index and *numItems-1* as last index.
        case linear
        /// Carousel browser. The media items are repeated in a circular fashion.
        case carousel
    }

    /**
     Enum to hold supported content draw orders.

     ```
     case previousToNext
     case nextToPrevious
     ```
     - note:
        Remember that this is draw order, not positioning. This order decides which item will
     be above or below other items, when they overlap.
     */
    public enum ContentDrawOrder {

        /// In this mode, media items are rendered in [previous]-[current]-[next] order.
        case previousToNext
        /// In this mode, media items are rendered in [next]-[current]-[previous] order.
        case nextToPrevious
    }

    /**
     Struct to hold support for customize title style

     ```
     font
     textColor
     ```
    */
    public struct TitleStyle {

        /// Title style font
        public var font: UIFont = UIFont.preferredFont(forTextStyle: .subheadline)
        /// Title style text color.
        public var textColor: UIColor = .white
    }

    // MARK: - Exposed variables

    /// Data-source object to supply media browser contents.
    public weak var dataSource: MediaBrowserViewControllerDataSource?
    /// Delegate object to get callbacks on media browser events.
    public weak var delegate: MediaBrowserViewControllerDelegate?

    /// Gesture direction. Default is `horizontal`.
    public var gestureDirection: GestureDirection = .horizontal
    /// Content transformer closure. Default is `horizontalMoveInOut`.
    public var contentTransformer: ContentTransformer = DefaultContentTransformers.horizontalMoveInOut {
        didSet {

            MediaContentView.contentTransformer = contentTransformer
            contentViews.forEach({ $0.updateTransform() })
        }
    }
    /// Content draw order. Default is `previousToNext`.
    public var drawOrder: ContentDrawOrder = .previousToNext {
        didSet {
            if oldValue != drawOrder {
                mediaContainerView.exchangeSubview(at: 0, withSubviewAt: 2)
            }
        }
    }
    /// Browser style. Default is carousel.
    public var browserStyle: BrowserStyle = .carousel
    /// Gap between consecutive media items. Default is `50.0`.
    public var gapBetweenMediaViews: CGFloat = Constants.gapBetweenContents {
        didSet {
            MediaContentView.interItemSpacing = gapBetweenMediaViews
            contentViews.forEach({ $0.updateTransform() })
        }
    }
    /// Variable to set title style in media browser.
    public var titleStyle: TitleStyle = TitleStyle() {
        didSet {
            configureTitleLabel()
        }
    }
    /// Variable to set title in media browser
    public override var title: String? {
        didSet {
            titleLabel.text = title
        }
    }
    /// Variable to hide/show title control in media browser. Default is false.
    public var shouldShowTitle: Bool = false {
        didSet {
            titleLabel.isHidden = !shouldShowTitle
        }
    }
    /// Variable to hide/show page control in media browser.
    public var shouldShowPageControl: Bool = true {
        didSet {
            pageControl.isHidden = !shouldShowPageControl
        }
    }
    /// Variable to hide/show controls(close & page control). Default is false.
    public var hideControls: Bool = false {
        didSet {
            hideControlViews(hideControls)
        }
    }
    /**
    Variable to schedule/cancel auto-hide controls(close & page control). Default is false.
    Default delay is `3.0` seconds.
    - todo: Update to accept auto-hide-delay.
     */
    public var autoHideControls: Bool = false {
        didSet {
            if autoHideControls {
                DispatchQueue.main.asyncAfter(
                    deadline: .now() + Constants.controlHideDelay,
                    execute: controlToggleTask
                )
            } else {
                controlToggleTask.cancel()
            }
        }
    }
    /// Enable or disable interactive dismissal. Default is enabled.
    public var enableInteractiveDismissal: Bool = true
    /// Item index of the current item. In range `0..<numMediaItems`
    public var currentItemIndex: Int {

        return sanitizeIndex(index)
    }

    // MARK: - Private Enumerations

    private enum Constants {

        static let gapBetweenContents: CGFloat = 50.0
        static let minimumVelocity: CGFloat = 15.0
        static let minimumTranslation: CGFloat = 0.1
        static let animationDuration = 0.3
        static let updateFrameRate: CGFloat = 60.0
        static let bounceFactor: CGFloat = 0.1
        static let controlHideDelay = 3.0

        enum Close {

            static let top: CGFloat = 8.0
            static let trailing: CGFloat = -8.0
            static let height: CGFloat = 30.0
            static let minWidth: CGFloat = 30.0
            static let contentInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0)
            static let borderWidth: CGFloat = 2.0
            static let borderColor: UIColor = .white
            static let title = "Close"
        }

        enum PageControl {

            static let bottom: CGFloat = -10.0
            static let tintColor: UIColor = .lightGray
            static let selectedTintColor: UIColor = .white
        }

        enum Title {
            static let top: CGFloat = 16.0
            static let rect: CGRect = CGRect(x: 0, y: 0, width: 30, height: 30)
        }
    }

    // MARK: - Private variables
    private(set) var index: Int = 0 {
        didSet {
            pageControl.currentPage = index
        }
    }

    private var contentViews: [MediaContentView] = []

    private var controlViews: [UIView] = []
    lazy private var controlToggleTask: DispatchWorkItem = { [unowned self] in

        let item = DispatchWorkItem {
            self.hideControls = true
        }
        return item
    }()
    lazy private var tapGestureRecognizer: UITapGestureRecognizer = { [unowned self] in
        let gesture = UITapGestureRecognizer()
        gesture.numberOfTapsRequired = 1
        gesture.numberOfTouchesRequired = 1
        gesture.delegate = self
        gesture.addTarget(self, action: #selector(tapGestureEvent(_:)))
        return gesture
    }()

    private var previousTranslation: CGPoint = .zero

    private var timer: Timer?
    private var distanceToMove: CGFloat = 0.0

    lazy private var panGestureRecognizer: UIPanGestureRecognizer = { [unowned self] in
        let gesture = UIPanGestureRecognizer()
        gesture.minimumNumberOfTouches = 1
        gesture.maximumNumberOfTouches = 1
        gesture.delegate = self
        gesture.addTarget(self, action: #selector(panGestureEvent(_:)))
        return gesture
    }()

    lazy internal private(set) var mediaContainerView: UIView = { [unowned self] in
        let container = UIView()
        container.backgroundColor = .clear
        return container
    }()

    lazy private var closeButton: UIButton = { [unowned self] in
        let button = UIButton()
        button.setTitle(Constants.Close.title, for: .normal)
        button.contentEdgeInsets = Constants.Close.contentInsets
        button.addTarget(self, action: #selector(didTapOnClose(_:)), for: .touchUpInside)
        button.layer.cornerRadius = Constants.Close.height * 0.5
        button.layer.borderColor = Constants.Close.borderColor.cgColor
        button.layer.borderWidth = Constants.Close.borderWidth
        return button
    }()

    lazy private var pageControl: UIPageControl = { [unowned self] in
        let pageControl = UIPageControl()
        pageControl.hidesForSinglePage = true
        pageControl.numberOfPages = numMediaItems
        pageControl.currentPageIndicatorTintColor = Constants.PageControl.selectedTintColor
        pageControl.tintColor = Constants.PageControl.tintColor
        pageControl.currentPage = index
        return pageControl
    }()

    lazy var titleLabel: UILabel = {
        let label = UILabel(frame: Constants.Title.rect)
        label.font = self.titleStyle.font
        label.textColor = self.titleStyle.textColor
        label.textAlignment = .center
        return label
    }()

    lazy internal private(set) var visualEffectContainer: UIView = UIView()
    lazy private var visualEffectContentView: UIImageView = { [unowned self] in
        let imageView = UIImageView(frame: view.frame)
        imageView.contentMode = .scaleAspectFill
        return imageView
    }()
    lazy private var blurEffect: UIBlurEffect = {
        return UIBlurEffect(style: .dark)
    }()
    lazy private var visualEffectView: UIVisualEffectView = { [unowned self] in
        return UIVisualEffectView(effect: blurEffect)
    }()

    private var numMediaItems = 0

    private lazy var dismissController = DismissAnimationController(
        gestureDirection: gestureDirection,
        viewController: self
    )

    // MARK: - Public methods

    /// Invoking this method reloads the contents media browser.
    public func reloadContentViews() {

        numMediaItems = dataSource?.numberOfItems(in: self) ?? 0
        if shouldShowPageControl {
            pageControl.numberOfPages = numMediaItems
        }

        for contentView in contentViews {

            updateContents(of: contentView)
        }

        dataSource?.mediaBrowser(self, updateCloseButton: closeButton)
    }

    // MARK: - Initializers

    public init(
        index: Int = 0,
        dataSource: MediaBrowserViewControllerDataSource,
        delegate: MediaBrowserViewControllerDelegate? = nil
        ) {

        self.index = index
        self.dataSource = dataSource
        self.delegate = delegate

        super.init(nibName: nil, bundle: nil)

        initialize()
    }

    public required init?(coder aDecoder: NSCoder) {

        super.init(coder: aDecoder)

        initialize()
    }

    private func initialize() {

        view.backgroundColor = .clear

        modalPresentationStyle = .custom

        modalTransitionStyle = .crossDissolve
    }
}

// MARK: - View Lifecycle and Events

extension MediaBrowserViewController {

    override public var prefersStatusBarHidden: Bool {

        return true
    }

    override public func viewDidLoad() {

        super.viewDidLoad()

        numMediaItems = dataSource?.numberOfItems(in: self) ?? 0

        addVisualEffectView()

        populateContentViews()

        addCloseButton()

        addPageControl()

        addTitleLabel()

        view.addGestureRecognizer(panGestureRecognizer)
        view.addGestureRecognizer(tapGestureRecognizer)
    }

    override public func viewDidAppear(_ animated: Bool) {

        super.viewDidAppear(animated)

        contentViews.forEach({ $0.updateTransform() })
    }

    override public func viewWillDisappear(_ animated: Bool) {

        super.viewWillDisappear(animated)

        if !controlToggleTask.isCancelled {
            controlToggleTask.cancel()
        }
    }

    public override func viewWillTransition(
        to size: CGSize,
        with coordinator: UIViewControllerTransitionCoordinator
        ) {

        coordinator.animate(alongsideTransition: { context in
            self.contentViews.forEach({ $0.handleChangeInViewSize(to: size) })
        }, completion: nil)

        super.viewWillTransition(to: size, with: coordinator)
    }

    private func addVisualEffectView() {

        view.addSubview(visualEffectContainer)
        visualEffectContainer.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            visualEffectContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            visualEffectContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            visualEffectContainer.topAnchor.constraint(equalTo: view.topAnchor),
            visualEffectContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])

        visualEffectContainer.addSubview(visualEffectContentView)
        visualEffectContentView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            visualEffectContentView.leadingAnchor.constraint(equalTo: visualEffectContainer.leadingAnchor),
            visualEffectContentView.trailingAnchor.constraint(equalTo: visualEffectContainer.trailingAnchor),
            visualEffectContentView.topAnchor.constraint(equalTo: visualEffectContainer.topAnchor),
            visualEffectContentView.bottomAnchor.constraint(equalTo: visualEffectContainer.bottomAnchor)
        ])

        visualEffectContainer.addSubview(visualEffectView)
        visualEffectView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            visualEffectView.leadingAnchor.constraint(equalTo: visualEffectContainer.leadingAnchor),
            visualEffectView.trailingAnchor.constraint(equalTo: visualEffectContainer.trailingAnchor),
            visualEffectView.topAnchor.constraint(equalTo: visualEffectContainer.topAnchor),
            visualEffectView.bottomAnchor.constraint(equalTo: visualEffectContainer.bottomAnchor)
        ])
    }

    private func populateContentViews() {

        view.addSubview(mediaContainerView)
        mediaContainerView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            mediaContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            mediaContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            mediaContainerView.topAnchor.constraint(equalTo: view.topAnchor),
            mediaContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])

        MediaContentView.interItemSpacing = gapBetweenMediaViews
        MediaContentView.contentTransformer = contentTransformer

        contentViews.forEach({ $0.removeFromSuperview() })
        contentViews.removeAll()

        for i in -1...1 {
            let mediaView = MediaContentView(
                index: i + index,
                position: CGFloat(i),
                frame: view.bounds
            )
            mediaContainerView.addSubview(mediaView)
            mediaView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                mediaView.leadingAnchor.constraint(equalTo: mediaContainerView.leadingAnchor),
                mediaView.trailingAnchor.constraint(equalTo: mediaContainerView.trailingAnchor),
                mediaView.topAnchor.constraint(equalTo: mediaContainerView.topAnchor),
                mediaView.bottomAnchor.constraint(equalTo: mediaContainerView.bottomAnchor)
            ])

            contentViews.append(mediaView)

            if numMediaItems > 0 {
                updateContents(of: mediaView)
            }
        }
        if drawOrder == .nextToPrevious {
            mediaContainerView.exchangeSubview(at: 0, withSubviewAt: 2)
        }
    }

    private func addCloseButton() {

        view.addSubview(closeButton)
        dataSource?.mediaBrowser(self, updateCloseButton: closeButton)

        if closeButton.constraints.isEmpty {
            closeButton.translatesAutoresizingMaskIntoConstraints = false
            var topAnchor = view.topAnchor
            if #available(iOS 11.0, *) {
                if view.responds(to: #selector(getter: UIView.safeAreaLayoutGuide)) {
                    topAnchor = view.safeAreaLayoutGuide.topAnchor
                }
            }

            NSLayoutConstraint.activate([
                closeButton.topAnchor.constraint(equalTo: topAnchor, constant: Constants.Close.top),
                closeButton.trailingAnchor.constraint(
                    equalTo: view.trailingAnchor,
                    constant: Constants.Close.trailing
                ),
                closeButton.widthAnchor.constraint(greaterThanOrEqualToConstant: Constants.Close.minWidth),
                closeButton.heightAnchor.constraint(equalToConstant: Constants.Close.height)
            ])
        }
        controlViews.append(closeButton)
    }

    private func addPageControl() {

        view.addSubview(pageControl)
        pageControl.translatesAutoresizingMaskIntoConstraints = false
        var bottomAnchor = view.bottomAnchor
        if #available(iOS 11.0, *) {
            if view.responds(to: #selector(getter: UIView.safeAreaLayoutGuide)) {
                bottomAnchor = view.safeAreaLayoutGuide.bottomAnchor
            }
        }
        NSLayoutConstraint.activate([
            pageControl.bottomAnchor.constraint(equalTo: bottomAnchor, constant: Constants.PageControl.bottom),
            pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])

        controlViews.append(pageControl)
    }

    private func addTitleLabel() {

        view.addSubview(titleLabel)
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        var topAnchor = view.topAnchor
        if #available(iOS 11.0, *) {
            if view.responds(to: #selector(getter: UIView.safeAreaLayoutGuide)) {
                topAnchor = view.safeAreaLayoutGuide.topAnchor
            }
        }
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: Constants.Title.top),
            titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
            ])

        controlViews.append(titleLabel)
    }

    private func configureTitleLabel() {

        titleLabel.font = self.titleStyle.font
        titleLabel.textColor = self.titleStyle.textColor
    }

    private func hideControlViews(_ hide: Bool) {

        UIView.animate(
            withDuration: Constants.animationDuration,
            delay: 0.0,
            options: .beginFromCurrentState,
            animations: {
                self.controlViews.forEach { $0.alpha = hide ? 0.0 : 1.0 }
            },
            completion: nil
        )
    }

    @objc private func didTapOnClose(_ sender: UIButton) {

        if let targetFrame = dataSource?.targetFrameForDismissal(self) {
            dismissController.image = sourceImage()
            dismissController.beginTransition()
            dismissController.animateToTargetFrame(targetFrame)
        } else {
            dismiss(animated: true, completion: nil)
        }
    }
}

// MARK: - Gesture Recognizers

extension MediaBrowserViewController {

    @objc private func panGestureEvent(_ recognizer: UIPanGestureRecognizer) {

        if dismissController.interactionInProgress {
            dismissController.handleInteractiveTransition(recognizer)
            return
        }

        guard numMediaItems > 0 else {
            return
        }

        let translation = recognizer.translation(in: view)

        switch recognizer.state {
        case .began:
            previousTranslation = translation
            distanceToMove = 0.0
            timer?.invalidate()
            timer = nil
        case .changed:
            moveViews(by: CGPoint(x: translation.x - previousTranslation.x, y: translation.y - previousTranslation.y))
        case .ended, .failed, .cancelled:
            let velocity = recognizer.velocity(in: view)

            var viewsCopy = contentViews
            let previousView = viewsCopy.removeFirst()
            let middleView = viewsCopy.removeFirst()
            let nextView = viewsCopy.removeFirst()

            var toMove: CGFloat = 0.0
            let directionalVelocity = gestureDirection == .horizontal ? velocity.x : velocity.y

            if abs(directionalVelocity) < Constants.minimumVelocity &&
                abs(middleView.position) < Constants.minimumTranslation {
                toMove = -middleView.position
            } else if directionalVelocity < 0.0 {
                if middleView.position >= 0.0 {
                    toMove = -middleView.position
                } else {
                    toMove = -nextView.position
                }
            } else {
                if middleView.position <= 0.0 {
                    toMove = -middleView.position
                } else {
                    toMove = -previousView.position
                }
            }

            if browserStyle == .linear || numMediaItems <= 1 {
                if (middleView.index == 0 && ((middleView.position + toMove) > 0.0)) ||
                    (middleView.index == (numMediaItems - 1) && (middleView.position + toMove) < 0.0) {

                    toMove = -middleView.position
                }
            }

            distanceToMove = toMove

            if timer == nil {
                timer = Timer.scheduledTimer(
                    timeInterval: 1.0/Double(Constants.updateFrameRate),
                    target: self,
                    selector: #selector(update(_:)),
                    userInfo: nil,
                    repeats: true
                )
            }
        default:
            break
        }

        previousTranslation = translation
    }

    @objc private func tapGestureEvent(_ recognizer: UITapGestureRecognizer) {

        guard !dismissController.interactionInProgress else {
            return
        }

        if !controlToggleTask.isCancelled {
            controlToggleTask.cancel()
        }
        hideControls = !hideControls
    }
}

// MARK: - Updating View Positions

extension MediaBrowserViewController {

    @objc private func update(_ timeInterval: TimeInterval) {

        guard distanceToMove != 0.0 else {

            timer?.invalidate()
            timer = nil
            return
        }

        let distance = distanceToMove / (Constants.updateFrameRate * 0.1)
        distanceToMove -= distance
        moveViewsNormalized(by: CGPoint(x: distance, y: distance))

        let translation = CGPoint(
            x: distance * (view.frame.size.width + gapBetweenMediaViews),
            y: distance * (view.frame.size.height + gapBetweenMediaViews)
        )
        let directionalTranslation = (gestureDirection == .horizontal) ? translation.x : translation.y
        if abs(directionalTranslation) < 0.1 {

            moveViewsNormalized(by: CGPoint(x: distanceToMove, y: distanceToMove))
            distanceToMove = 0.0
            timer?.invalidate()
            timer = nil
        }
    }

    private func moveViews(by translation: CGPoint) {

        let viewSizeIncludingGap = CGSize(
            width: view.frame.size.width + gapBetweenMediaViews,
            height: view.frame.size.height + gapBetweenMediaViews
        )

        let normalizedTranslation = calculateNormalizedTranslation(
            translation: translation,
            viewSize: viewSizeIncludingGap
        )

        moveViewsNormalized(by: normalizedTranslation)
    }

    private func moveViewsNormalized(by normalizedTranslation: CGPoint) {

        let isGestureHorizontal = (gestureDirection == .horizontal)

        contentViews.forEach({
            $0.position += isGestureHorizontal ? normalizedTranslation.x : normalizedTranslation.y
        })

        var viewsCopy = contentViews
        let previousView = viewsCopy.removeFirst()
        let middleView = viewsCopy.removeFirst()
        let nextView = viewsCopy.removeFirst()

        let viewSizeIncludingGap = CGSize(
            width: view.frame.size.width + gapBetweenMediaViews,
            height: view.frame.size.height + gapBetweenMediaViews
        )

        let viewSize = isGestureHorizontal ? viewSizeIncludingGap.width : viewSizeIncludingGap.height
        let normalizedGap = gapBetweenMediaViews/viewSize
        let normalizedCenter = (middleView.frame.size.width / viewSize) * 0.5
        let viewCount = contentViews.count

        if middleView.position < -(normalizedGap + normalizedCenter) {

            index = sanitizeIndex(index + 1)

            // Previous item is taken and placed on right/down most side
            previousView.position += CGFloat(viewCount)
            previousView.index += viewCount
            updateContents(of: previousView)

            if let image = nextView.image {
                self.visualEffectContentView.image = image
            }

            contentViews.removeFirst()
            contentViews.append(previousView)

            switch drawOrder {
            case .previousToNext:
                mediaContainerView.bringSubviewToFront(previousView)
            case .nextToPrevious:
                mediaContainerView.sendSubviewToBack(previousView)
            }

            delegate?.mediaBrowser(self, didChangeFocusTo: index)

        } else if middleView.position > (1 + normalizedGap - normalizedCenter) {

            index = sanitizeIndex(index - 1)

            // Next item is taken and placed on left/top most side
            nextView.position -= CGFloat(viewCount)
            nextView.index -= viewCount
            updateContents(of: nextView)

            if let image = previousView.image {
                self.visualEffectContentView.image = image
            }

            contentViews.removeLast()
            contentViews.insert(nextView, at: 0)

            switch drawOrder {
            case .previousToNext:
                mediaContainerView.sendSubviewToBack(nextView)
            case .nextToPrevious:
                mediaContainerView.bringSubviewToFront(nextView)
            }

            delegate?.mediaBrowser(self, didChangeFocusTo: index)
        }
    }

    private func calculateNormalizedTranslation(translation: CGPoint, viewSize: CGSize) -> CGPoint {

        guard let middleView = mediaView(at: 1) else {
            return .zero
        }

        var normalizedTranslation = CGPoint(
            x: (translation.x)/viewSize.width,
            y: (translation.y)/viewSize.height
        )

        if browserStyle != .carousel || numMediaItems <= 1 {
            let isGestureHorizontal = (gestureDirection == .horizontal)
            let directionalTranslation = isGestureHorizontal ? normalizedTranslation.x : normalizedTranslation.y
            if (middleView.index == 0 && ((middleView.position + directionalTranslation) > 0.0)) ||
                (middleView.index == (numMediaItems - 1) && (middleView.position + directionalTranslation) < 0.0) {
                if isGestureHorizontal {
                    normalizedTranslation.x *= Constants.bounceFactor
                } else {
                    normalizedTranslation.y *= Constants.bounceFactor
                }
            }
        }
        return normalizedTranslation
    }

    private func updateContents(of contentView: MediaContentView) {

        contentView.image = nil
        let convertedIndex = sanitizeIndex(contentView.index)
        contentView.isLoading = true
        dataSource?.mediaBrowser(
            self,
            imageAt: convertedIndex,
            completion: { [weak self] (index, image, zoom, _) in

                guard let strongSelf = self else {
                    return
                }

                if index == strongSelf.sanitizeIndex(contentView.index) {
                    if image != nil {
                        contentView.image = image
                        contentView.zoomLevels = zoom

                        if index == strongSelf.index {
                            strongSelf.visualEffectContentView.image = image
                        }
                    }
                    contentView.isLoading = false
                }
            }
        )
    }

    private func sanitizeIndex(_ index: Int) -> Int {

        let newIndex = index % numMediaItems
        if newIndex < 0 {
            return newIndex + numMediaItems
        }
        return newIndex
    }

    private func sourceImage() -> UIImage? {

        return mediaView(at: 1)?.image
    }

    private func mediaView(at index: Int) -> MediaContentView? {

        guard index < contentViews.count else {

            assertionFailure("Content views does not have this many views. : \(index)")
            return nil
        }
        return contentViews[index]
    }
}

// MARK: - UIGestureRecognizerDelegate

extension MediaBrowserViewController: UIGestureRecognizerDelegate {

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {

        guard enableInteractiveDismissal else {
            return true
        }

        let middleView = mediaView(at: 1)
        if middleView?.zoomScale == middleView?.zoomLevels?.minimumZoomScale,
            let recognizer = gestureRecognizer as? UIPanGestureRecognizer {

            let translation = recognizer.translation(in: recognizer.view)

            if gestureDirection == .horizontal {
                dismissController.interactionInProgress = abs(translation.y) > abs(translation.x)
            } else {
                dismissController.interactionInProgress = abs(translation.x) > abs(translation.y)
            }
            if dismissController.interactionInProgress {
                dismissController.image = sourceImage()
            }
        }
        return true
    }

    public func gestureRecognizer(
        _ gestureRecognizer: UIGestureRecognizer,
        shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
        ) -> Bool {

        if gestureRecognizer is UIPanGestureRecognizer,
            let scrollView = otherGestureRecognizer.view as? MediaContentView {
            return scrollView.zoomScale == 1.0
        }
        return false
    }

    public func gestureRecognizer(
        _ gestureRecognizer: UIGestureRecognizer,
        shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
        ) -> Bool {

        if gestureRecognizer is UITapGestureRecognizer {
            return otherGestureRecognizer.view is MediaContentView
        }
        return false
    }
}
