//
//  SideMenuNavigationController.swift
//
//  Created by Jon Kent on 1/14/16.
//  Copyright © 2016 Jon Kent. All rights reserved.
//

import UIKit

@objc public enum SideMenuPushStyle: Int { case
    `default`,
    popWhenPossible,
    preserve,
    preserveAndHideBackButton,
    replace,
    subMenu

    internal var hidesBackButton: Bool {
        switch self {
        case .preserveAndHideBackButton, .replace: return true
        case .default, .popWhenPossible, .preserve, .subMenu: return false
        }
    }
}

internal protocol MenuModel {
    /// Prevents the same view controller (or a view controller of the same class) from being pushed more than once. Defaults to true.
    var allowPushOfSameClassTwice: Bool { get }
    /// Forces menus to always animate when appearing or disappearing, regardless of a pushed view controller's animation.
    var alwaysAnimate: Bool { get }
    /**
     The blur effect style of the menu if the menu's root view controller is a UITableViewController or UICollectionViewController.

     - Note: If you want cells in a UITableViewController menu to show vibrancy, make them a subclass of UITableViewVibrantCell.
     */
    var blurEffectStyle: UIBlurEffect.Style? { get }
    /// Animation curve of the remaining animation when the menu is partially dismissed with gestures. Default is .easeIn.
    var completionCurve: UIView.AnimationCurve { get }
    /// Automatically dismisses the menu when another view is presented from it.
    var dismissOnPresent: Bool { get }
    /// Automatically dismisses the menu when another view controller is pushed from it.
    var dismissOnPush: Bool { get }
    /// Automatically dismisses the menu when the screen is rotated.
    var dismissOnRotation: Bool { get }
    /// Automatically dismisses the menu when app goes to the background.
    var dismissWhenBackgrounded: Bool { get }
    /// Enable or disable a swipe gesture that dismisses the menu. Will not be triggered when `presentingViewControllerUserInteractionEnabled` is set to true. Default is true.
    var enableSwipeToDismissGesture: Bool { get }
    /// Enable or disable a tap gesture that dismisses the menu. Will not be triggered when `presentingViewControllerUserInteractionEnabled` is set to true. Default is true.
    var enableTapToDismissGesture: Bool { get }
    /**
     The push style of the menu.

     There are six modes in MenuPushStyle:
     - defaultBehavior: The view controller is pushed onto the stack.
     - popWhenPossible: If a view controller already in the stack is of the same class as the pushed view controller, the stack is instead popped back to the existing view controller. This behavior can help users from getting lost in a deep navigation stack.
     - preserve: If a view controller already in the stack is of the same class as the pushed view controller, the existing view controller is pushed to the end of the stack. This behavior is similar to a UITabBarController.
     - preserveAndHideBackButton: Same as .preserve and back buttons are automatically hidden.
     - replace: Any existing view controllers are released from the stack and replaced with the pushed view controller. Back buttons are automatically hidden. This behavior is ideal if view controllers require a lot of memory or their state doesn't need to be preserved..
     - subMenu: Unlike all other behaviors that push using the menu's presentingViewController, this behavior pushes view controllers within the menu.  Use this behavior if you want to display a sub menu.
     */
    var pushStyle: SideMenuPushStyle { get }
}

@objc public protocol SideMenuNavigationControllerDelegate {
    @objc optional func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool)
    @objc optional func sideMenuDidAppear(menu: SideMenuNavigationController, animated: Bool)
    @objc optional func sideMenuWillDisappear(menu: SideMenuNavigationController, animated: Bool)
    @objc optional func sideMenuDidDisappear(menu: SideMenuNavigationController, animated: Bool)
}

internal protocol SideMenuNavigationControllerTransitionDelegate: class {
    func sideMenuTransitionDidDismiss(menu: Menu)
}

public struct SideMenuSettings: Model, InitializableStruct {
    public var allowPushOfSameClassTwice: Bool = true
    public var alwaysAnimate: Bool = true
    public var animationOptions: UIView.AnimationOptions = .curveEaseInOut
    public var blurEffectStyle: UIBlurEffect.Style? = nil
    public var completeGestureDuration: Double = 0.35
    public var completionCurve: UIView.AnimationCurve = .easeIn
    public var dismissDuration: Double = 0.35
    public var dismissOnPresent: Bool = true
    public var dismissOnPush: Bool = true
    public var dismissOnRotation: Bool = true
    public var dismissWhenBackgrounded: Bool = true
    public var enableSwipeToDismissGesture: Bool = true
    public var enableTapToDismissGesture: Bool = true
    public var initialSpringVelocity: CGFloat = 1
    public var menuWidth: CGFloat = {
        let appScreenRect = UIApplication.shared.keyWindow?.bounds ?? UIWindow().bounds
        let minimumSize = min(appScreenRect.width, appScreenRect.height)
        return min(round(minimumSize * 0.75), 240)
    }()
    public var presentingViewControllerUserInteractionEnabled: Bool = false
    public var presentingViewControllerUseSnapshot: Bool = false
    public var presentDuration: Double = 0.35
    public var presentationStyle: SideMenuPresentationStyle = .viewSlideOut
    public var pushStyle: SideMenuPushStyle = .default
    public var statusBarEndAlpha: CGFloat = 0
    public var usingSpringWithDamping: CGFloat = 1

    public init() {}
}

internal typealias Menu = SideMenuNavigationController
typealias Model = MenuModel & PresentationModel & AnimationModel

@objcMembers
open class SideMenuNavigationController: UINavigationController {
    
    private lazy var _leftSide = Protected(false) { [weak self] oldValue, newValue in
        guard self?.isHidden != false else {
            Print.warning(.property, arguments: .leftSide, required: true)
            return oldValue
        }
        return newValue
    }

    private weak var _sideMenuManager: SideMenuManager?
    private weak var foundViewController: UIViewController?
    private var originalBackgroundColor: UIColor?
    private var rotating: Bool = false
    private var transitionController: SideMenuTransitionController?
    private var transitionInteractive: Bool = false

    /// Delegate for receiving appear and disappear related events. If `nil` the visible view controller that displays a `SideMenuNavigationController` automatically receives these events.
    public weak var sideMenuDelegate: SideMenuNavigationControllerDelegate?

    /// The swipe to dismiss gesture.
    open private(set) weak var swipeToDismissGesture: UIPanGestureRecognizer? = nil
    /// The tap to dismiss gesture.
    open private(set) weak var tapToDismissGesture: UITapGestureRecognizer? = nil

    open var sideMenuManager: SideMenuManager {
        get { return _sideMenuManager ?? SideMenuManager.default }
        set {
            newValue.setMenu(self, forLeftSide: leftSide)

            if let sideMenuManager = _sideMenuManager, sideMenuManager !== newValue {
                let side = SideMenuManager.PresentDirection(leftSide: leftSide)
                Print.warning(.menuAlreadyAssigned, arguments: String(describing: self.self), side.name, String(describing: newValue))
            }
            _sideMenuManager = newValue
        }
    }

    /// The menu settings.
    open var settings = SideMenuSettings() {
        didSet {
            setupBlur()
            if !enableSwipeToDismissGesture {
                swipeToDismissGesture?.remove()
            }
            if !enableTapToDismissGesture {
                tapToDismissGesture?.remove()
            }
        }
    }

    public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        setup()
    }

    public init(rootViewController: UIViewController, settings: SideMenuSettings = SideMenuSettings()) {
        self.settings = settings
        super.init(rootViewController: rootViewController)
        setup()
    }

    required public init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }
    
    override open func awakeFromNib() {
        super.awakeFromNib()
        sideMenuManager.setMenu(self, forLeftSide: leftSide)
    }
    
    override open func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        if topViewController == nil {
            Print.warning(.emptyMenu)
        }

        // Dismiss keyboard to prevent weird keyboard animations from occurring during transition
        presentingViewController?.view.endEditing(true)

        foundViewController = nil
        activeDelegate?.sideMenuWillAppear?(menu: self, animated: animated)
    }
    
    override open func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        // We had presented a view before, so lets dismiss ourselves as already acted upon
        if view.isHidden {
            dismiss(animated: false, completion: { [weak self] in
                self?.view.isHidden = false
            })
        } else {
            activeDelegate?.sideMenuDidAppear?(menu: self, animated: animated)
        }
    }
    
    override open func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        defer { activeDelegate?.sideMenuWillDisappear?(menu: self, animated: animated) }

        guard !isBeingDismissed else { return }

        // When presenting a view controller from the menu, the menu view gets moved into another transition view above our transition container
        // which can break the visual layout we had before. So, we move the menu view back to its original transition view to preserve it.
        if let presentingView = presentingViewController?.view, let containerView = presentingView.superview {
            containerView.addSubview(view)
        }

        if dismissOnPresent {
            // We're presenting a view controller from the menu, so we need to hide the menu so it isn't showing when the presented view is dismissed.
            transitionController?.transition(presenting: false, animated: animated, alongsideTransition: { [weak self] in
                guard let self = self else { return }
                self.activeDelegate?.sideMenuWillDisappear?(menu: self, animated: animated)
                }, complete: false, completion: { [weak self] _ in
                    guard let self = self else { return }
                    self.activeDelegate?.sideMenuDidDisappear?(menu: self, animated: animated)
                    self.view.isHidden = true
            })
        }
    }

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

        // Work-around: if the menu is dismissed without animation the transition logic is never called to restore the
        // the view hierarchy leaving the screen black/empty. This is because the transition moves views within a container
        // view, but dismissing without animation removes the container view before the original hierarchy is restored.
        // This check corrects that.
        if isBeingDismissed {
            transitionController?.transition(presenting: false, animated: false)
        }

        // Clear selection on UITableViewControllers when reappearing using custom transitions
        if let tableViewController = topViewController as? UITableViewController,
            let tableView = tableViewController.tableView,
            let indexPaths = tableView.indexPathsForSelectedRows,
            tableViewController.clearsSelectionOnViewWillAppear {
            indexPaths.forEach { tableView.deselectRow(at: $0, animated: false) }
        }

        activeDelegate?.sideMenuDidDisappear?(menu: self, animated: animated)

        if isBeingDismissed {
            transitionController = nil
        } else if dismissOnPresent {
            view.isHidden = true
        }
    }
    
    override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        
        // Don't bother resizing if the view isn't visible
        guard let transitionController = transitionController, !view.isHidden else { return }

        rotating = true
        
        let dismiss = self.presentingViewControllerUseSnapshot || self.dismissOnRotation
        coordinator.animate(alongsideTransition: { _ in
            if dismiss {
                transitionController.transition(presenting: false, animated: false, complete: false)
            } else {
                transitionController.layout()
            }
        }) { [weak self] _ in
            guard let self = self else { return }
            if dismiss {
                self.dismissMenu(animated: false)
            }
            self.rotating = false
        }
    }

    open override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        transitionController?.layout()
    }
    
    override open func pushViewController(_ viewController: UIViewController, animated: Bool) {
        guard viewControllers.count > 0 else {
            // NOTE: pushViewController is called by init(rootViewController: UIViewController)
            // so we must perform the normal super method in this case
            return super.pushViewController(viewController, animated: animated)
        }

        var alongsideTransition: (() -> Void)? = nil
        if dismissOnPush {
            alongsideTransition = { [weak self] in
                guard let self = self else { return }
                self.dismissAnimation(animated: animated || self.alwaysAnimate)
            }
        }

        let pushed = SideMenuPushCoordinator(config:
            .init(
                allowPushOfSameClassTwice: allowPushOfSameClassTwice,
                alongsideTransition: alongsideTransition,
                animated: animated,
                fromViewController: self,
                pushStyle: pushStyle,
                toViewController: viewController
            )
            ).start()

        if !pushed {
            super.pushViewController(viewController, animated: animated)
        }
    }

    override open var transitioningDelegate: UIViewControllerTransitioningDelegate? {
        get {
            guard transitionController == nil else { return transitionController }
            transitionController = SideMenuTransitionController(leftSide: leftSide, config: settings)
            transitionController?.delegate = self
            transitionController?.interactive = transitionInteractive
            transitionInteractive = false
            return transitionController
        }
        set { Print.warning(.transitioningDelegate, required: true) }
    }
}

// Interface
extension SideMenuNavigationController: Model {

    @IBInspectable open var allowPushOfSameClassTwice: Bool {
        get { return settings.allowPushOfSameClassTwice }
        set { settings.allowPushOfSameClassTwice = newValue }
    }

    @IBInspectable open var alwaysAnimate: Bool {
        get { return settings.alwaysAnimate }
        set { settings.alwaysAnimate = newValue }
    }

    @IBInspectable open var animationOptions: UIView.AnimationOptions {
        get { return settings.animationOptions }
        set { settings.animationOptions = newValue }
    }

    open var blurEffectStyle: UIBlurEffect.Style? {
        get { return settings.blurEffectStyle }
        set { settings.blurEffectStyle = newValue }
    }

    @IBInspectable open var completeGestureDuration: Double {
        get { return settings.completeGestureDuration }
        set { settings.completeGestureDuration = newValue }
    }

    @IBInspectable open var completionCurve: UIView.AnimationCurve {
        get { return settings.completionCurve }
        set { settings.completionCurve = newValue }
    }

    @IBInspectable open var dismissDuration: Double {
        get { return settings.dismissDuration }
        set { settings.dismissDuration = newValue }
    }

    @IBInspectable open var dismissOnPresent: Bool {
        get { return settings.dismissOnPresent }
        set { settings.dismissOnPresent = newValue }
    }

    @IBInspectable open var dismissOnPush: Bool {
        get { return settings.dismissOnPush }
        set { settings.dismissOnPush = newValue }
    }

    @IBInspectable open var dismissOnRotation: Bool {
        get { return settings.dismissOnRotation }
        set { settings.dismissOnRotation = newValue }
    }

    @IBInspectable open var dismissWhenBackgrounded: Bool {
        get { return settings.dismissWhenBackgrounded }
        set { settings.dismissWhenBackgrounded = newValue }
    }

    @IBInspectable open var enableSwipeToDismissGesture: Bool {
        get { return settings.enableSwipeToDismissGesture }
        set { settings.enableSwipeToDismissGesture = newValue }
    }

    @IBInspectable open var enableTapToDismissGesture: Bool {
        get { return settings.enableTapToDismissGesture }
        set { settings.enableTapToDismissGesture = newValue }
    }

    @IBInspectable open var initialSpringVelocity: CGFloat {
        get { return settings.initialSpringVelocity }
        set { settings.initialSpringVelocity = newValue }
    }

    /// Whether the menu appears on the right or left side of the screen. Right is the default. This property cannot be changed after the menu has loaded.
    @IBInspectable open var leftSide: Bool {
        get { return _leftSide.value }
        set { _leftSide.value = newValue }
    }
  
    /// Indicates if the menu is anywhere in the view hierarchy, even if covered by another view controller.
    open override var isHidden: Bool {
        return super.isHidden
    }

    @IBInspectable open var menuWidth: CGFloat {
        get { return settings.menuWidth }
        set { settings.menuWidth = newValue }
    }

    @IBInspectable open var presentingViewControllerUserInteractionEnabled: Bool {
        get { return settings.presentingViewControllerUserInteractionEnabled }
        set { settings.presentingViewControllerUserInteractionEnabled = newValue }
    }

    @IBInspectable open var presentingViewControllerUseSnapshot: Bool {
        get { return settings.presentingViewControllerUseSnapshot }
        set { settings.presentingViewControllerUseSnapshot = newValue }
    }

    @IBInspectable open var presentDuration: Double {
        get { return settings.presentDuration }
        set { settings.presentDuration = newValue }
    }

    open var presentationStyle: SideMenuPresentationStyle {
        get { return settings.presentationStyle }
        set { settings.presentationStyle = newValue }
    }

    @IBInspectable open var pushStyle: SideMenuPushStyle {
        get { return settings.pushStyle }
        set { settings.pushStyle = newValue }
    }

    @IBInspectable open var statusBarEndAlpha: CGFloat {
        get { return settings.statusBarEndAlpha }
        set { settings.statusBarEndAlpha = newValue }
    }

    @IBInspectable open var usingSpringWithDamping: CGFloat {
        get { return settings.usingSpringWithDamping }
        set { settings.usingSpringWithDamping = newValue }
    }
}

extension SideMenuNavigationController: SideMenuTransitionControllerDelegate {

    func sideMenuTransitionController(_ transitionController: SideMenuTransitionController, didDismiss viewController: UIViewController) {
        sideMenuManager.sideMenuTransitionDidDismiss(menu: self)
    }

    func sideMenuTransitionController(_ transitionController: SideMenuTransitionController, didPresent viewController: UIViewController) {
        swipeToDismissGesture?.remove()
        swipeToDismissGesture = addSwipeToDismissGesture(to: view.superview)
        tapToDismissGesture = addTapToDismissGesture(to: view.superview)
    }
}

internal extension SideMenuNavigationController {

    func handleMenuPan(_ gesture: UIPanGestureRecognizer, _ presenting: Bool) {
        let width = menuWidth
        let distance = gesture.xTranslation / width
        let progress = max(min(distance * factor(presenting), 1), 0)
        switch (gesture.state) {
        case .began:
            if !presenting {
                dismissMenu(interactively: true)
            }
            fallthrough
        case .changed:
            transitionController?.handle(state: .update(progress: progress))
        case .ended:
            let velocity = gesture.xVelocity * factor(presenting)
            let finished = velocity >= 100 || velocity >= -50 && abs(progress) >= 0.5
            transitionController?.handle(state: finished ? .finish : .cancel)
        default:
            transitionController?.handle(state: .cancel)
        }
    }

    func cancelMenuPan(_ gesture: UIPanGestureRecognizer) {
        transitionController?.handle(state: .cancel)
    }

    func dismissMenu(animated flag: Bool = true, interactively: Bool = false, completion: (() -> Void)? = nil) {
        guard !isHidden else { return }
        transitionController?.interactive = interactively
        dismiss(animated: flag, completion: completion)
    }

    // Note: although this method is syntactically reversed it allows the interactive property to scoped privately
    func present(from viewController: UIViewController?, interactively: Bool, completion: (() -> Void)? = nil) {
        guard let viewController = viewController else { return }
        transitionInteractive = interactively
        viewController.present(self, animated: true, completion: completion)
    }
}

private extension SideMenuNavigationController {

    weak var activeDelegate: SideMenuNavigationControllerDelegate? {
        guard !view.isHidden else { return nil }
        if let sideMenuDelegate = sideMenuDelegate { return sideMenuDelegate }
        return findViewController as? SideMenuNavigationControllerDelegate
    }

    var findViewController: UIViewController? {
        foundViewController = foundViewController ?? presentingViewController?.activeViewController
        return foundViewController
    }

    func dismissAnimation(animated: Bool) {
        transitionController?.transition(presenting: false, animated: animated, alongsideTransition: { [weak self] in
            guard let self = self else { return }
            self.activeDelegate?.sideMenuWillDisappear?(menu: self, animated: animated)
            }, completion: { [weak self] _ in
                guard let self = self else { return }
                self.activeDelegate?.sideMenuDidDisappear?(menu: self, animated: animated)
                self.dismiss(animated: false, completion: nil)
                self.foundViewController = nil
        })
    }

    func setup() {
        modalPresentationStyle = .overFullScreen

        setupBlur()
        if #available(iOS 13.0, *) {} else {
            registerForNotifications()
        }
    }

    func setupBlur() {
        removeBlur()

        guard let blurEffectStyle = blurEffectStyle,
            let view = topViewController?.view,
            !UIAccessibility.isReduceTransparencyEnabled else {
                return
        }

        originalBackgroundColor = originalBackgroundColor ?? view.backgroundColor

        let blurEffect = UIBlurEffect(style: blurEffectStyle)
        let blurView = UIVisualEffectView(effect: blurEffect)
        view.backgroundColor = UIColor.clear
        if let tableViewController = topViewController as? UITableViewController {
            tableViewController.tableView.backgroundView = blurView
            tableViewController.tableView.separatorEffect = UIVibrancyEffect(blurEffect: blurEffect)
            tableViewController.tableView.reloadData()
        } else {
            blurView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
            blurView.frame = view.bounds
            view.insertSubview(blurView, at: 0)
        }
    }

    func removeBlur() {
        guard let originalBackgroundColor = originalBackgroundColor,
            let view = topViewController?.view else {
                return
        }

        self.originalBackgroundColor = nil
        view.backgroundColor = originalBackgroundColor

        if let tableViewController = topViewController as? UITableViewController {
            tableViewController.tableView.backgroundView = nil
            tableViewController.tableView.separatorEffect = nil
            tableViewController.tableView.reloadData()
        } else if let blurView = view.subviews.first as? UIVisualEffectView {
            blurView.removeFromSuperview()
        }
    }

    @available(iOS, deprecated: 13.0)
    func registerForNotifications() {
        NotificationCenter.default.removeObserver(self)

        [UIApplication.willChangeStatusBarFrameNotification,
         UIApplication.didEnterBackgroundNotification].forEach {
            NotificationCenter.default.addObserver(self, selector: #selector(handleNotification), name: $0, object: nil)
        }
    }

    @available(iOS, deprecated: 13.0)
    @objc func handleNotification(notification: NSNotification) {
        guard isHidden else { return }

        switch notification.name {
        case UIApplication.willChangeStatusBarFrameNotification:
            // Dismiss for in-call status bar changes but not rotation
            if !rotating {
                dismissMenu()
            }
        case UIApplication.didEnterBackgroundNotification:
            if dismissWhenBackgrounded {
                dismissMenu()
            }
        default: break
        }
    }

    @discardableResult func addSwipeToDismissGesture(to view: UIView?) -> UIPanGestureRecognizer? {
        guard enableSwipeToDismissGesture else { return nil }
        return UIPanGestureRecognizer(addTo: view, target: self, action: #selector(handleDismissMenuPan(_:)))?.with {
            $0.cancelsTouchesInView = false
        }
    }

    @discardableResult func addTapToDismissGesture(to view: UIView?) -> UITapGestureRecognizer? {
        guard enableTapToDismissGesture else { return nil }
        return UITapGestureRecognizer(addTo: view, target: self, action: #selector(handleDismissMenuTap(_:)))?.with {
            $0.cancelsTouchesInView = false
        }
    }

    @objc func handleDismissMenuTap(_ tap: UITapGestureRecognizer) {
        let hitTest = view.window?.hitTest(tap.location(in: view.superview), with: nil)
        guard hitTest == view.superview else { return }
        dismissMenu()
    }

    @objc func handleDismissMenuPan(_ gesture: UIPanGestureRecognizer) {
        handleMenuPan(gesture, false)
    }

    func factor(_ presenting: Bool) -> CGFloat {
        return presenting ? presentFactor : hideFactor
    }

    var presentFactor: CGFloat {
        return leftSide ? 1 : -1
    }

    var hideFactor: CGFloat {
        return -presentFactor
    }
}
