//
//  ScrollingSegmentedControl.swift
//  Tusker
//
//  Created by Shadowfacts on 12/11/22.
//  Copyright © 2022 Shadowfacts. All rights reserved.
//

import UIKit

class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecognizerDelegate, UIPointerInteractionDelegate {
    
    private(set) var selectedOption: Value?
    var options: [Option] = [] {
        didSet {
            createOptionViews()
        }
    }
    var didSelectOption: ((Value?) -> Void)?
    
    private let optionsStack = UIStackView()
    private let selectedIndicatorView = UIView()
    private var selectedIndicatorViewAlignmentConstraints: [NSLayoutConstraint] = []
    private var changeSelectionPanRecognizer: UIGestureRecognizer!
    private var selectedOptionAtStartOfPan: Value?
    private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
    
    override var intrinsicContentSize: CGSize {
        let buttonWidths = optionsStack.arrangedSubviews.map(\.intrinsicContentSize.width).reduce(0, +)
        let spacing = (CGFloat(optionsStack.arrangedSubviews.count) - 1) * 8
        // add 16 to account for the spacing around optionsStack
        return CGSize(width: buttonWidths + spacing + 16, height: 44)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        showsHorizontalScrollIndicator = false
        
        optionsStack.axis = .horizontal
        optionsStack.spacing = 8
        optionsStack.distribution = .fillProportionally
        optionsStack.translatesAutoresizingMaskIntoConstraints = false
        addSubview(optionsStack)
        NSLayoutConstraint.activate([
            optionsStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor, constant: 8),
            optionsStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor, constant: -8),
            optionsStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
            optionsStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
            optionsStack.heightAnchor.constraint(equalTo: heightAnchor),
            
            // add 16 to account for the spacing around optionsStack
            widthAnchor.constraint(lessThanOrEqualTo: optionsStack.widthAnchor, constant: 16),
        ])
        let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
        self.changeSelectionPanRecognizer = panRecognizer
        panRecognizer.delegate = self
        optionsStack.addGestureRecognizer(panRecognizer)
        optionsStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(optionTapped)))
        optionsStack.addInteraction(UIPointerInteraction(delegate: self))
        
        self.panGestureRecognizer.delegate = self
        
        selectedIndicatorView.isHidden = true
        selectedIndicatorView.backgroundColor = .tintColor
        selectedIndicatorView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(selectedIndicatorView)
        NSLayoutConstraint.activate([
            selectedIndicatorView.heightAnchor.constraint(equalToConstant: 4),
            selectedIndicatorView.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
            invalidateIntrinsicContentSize()
        }
    }
    
    private func createOptionViews() {
        optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
        
        for (index, option) in options.enumerated() {
            let label = UILabel()
            label.text = option.name
            label.font = .preferredFont(forTextStyle: .headline)
            label.adjustsFontForContentSizeCategory = true
            label.textColor = .secondaryLabel
            label.textAlignment = .center
            label.accessibilityTraits = .button
            label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)"
            optionsStack.addArrangedSubview(label)
        }
        
        updateSelectedIndicatorView()
        invalidateIntrinsicContentSize()
    }
    
    func setSelectedOption(_ value: Value, animated: Bool) {
        guard selectedOption != value,
              options.contains(where: { $0.value == value }) else {
            return
        }
        
        if selectedOption != nil {
            selectionChangedFeedbackGenerator.selectionChanged()
        }
        
        selectedOption = value
        didSelectOption?(value)
        updateSelectedIndicatorView()
        
        if animated && !selectedIndicatorView.isHidden {
            let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
                self.layoutIfNeeded()
            }
            animator.startAnimation()
        }
    }
    
    private func updateSelectedIndicatorView() {
        guard let selectedOption,
              let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }) else {
            selectedIndicatorView.isHidden = true
            return
        }
        let selectedOptionView = optionsStack.arrangedSubviews[selectedIndex]
        selectedIndicatorView.isHidden = false
        NSLayoutConstraint.deactivate(selectedIndicatorViewAlignmentConstraints)
        selectedIndicatorViewAlignmentConstraints = [
            selectedIndicatorView.leadingAnchor.constraint(equalTo: selectedOptionView.leadingAnchor),
            selectedIndicatorView.trailingAnchor.constraint(equalTo: selectedOptionView.trailingAnchor),
        ]
        NSLayoutConstraint.activate(selectedIndicatorViewAlignmentConstraints)
        
        for (index, optionView) in optionsStack.arrangedSubviews.enumerated() {
            let label = optionView as! UILabel
            label.textColor = index == selectedIndex ? .label : .secondaryLabel
            label.accessibilityTraits = index == selectedIndex ? [.button, .selected] : .button
        }
    }
    
    // MARK: Interaction
    
    override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        let beganOnSelectedOption: Bool
        if let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }),
           optionsStack.arrangedSubviews[selectedIndex].frame.contains(self.panGestureRecognizer.location(in: optionsStack)) {
            beganOnSelectedOption = true
        } else {
            beganOnSelectedOption = false
        }
        
        // only begin changing selection if the gesutre started on the currently selected item
        // otherwise, let the scroll view handle things
        if gestureRecognizer == self.changeSelectionPanRecognizer {
            return beganOnSelectedOption
        } else {
            return !beganOnSelectedOption
        }
    }
    
    @objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
        let horizontalLocationInStack = CGPoint(x: recognizer.location(in: optionsStack).x, y: 0)
        switch recognizer.state {
        case .began:
                selectedOptionAtStartOfPan = selectedOption
                selectionChangedFeedbackGenerator.prepare()
            
        case .changed:
            if updateSelectionFor(location: horizontalLocationInStack) {
                selectionChangedFeedbackGenerator.prepare()
            }
            
        case .ended:
            if let selectedOptionAtStartOfPan {
                self.selectedOptionAtStartOfPan = nil
                if let selectedOption,
                   selectedOptionAtStartOfPan != selectedOption {
                    didSelectOption?(selectedOption)
                }
            }
            
        default:
            break
        }
    }
    
    @objc private func optionTapped(_ recognizer: UITapGestureRecognizer) {
        let location = recognizer.location(in: optionsStack)
        if updateSelectionFor(location: location) {
            didSelectOption?(selectedOption!)
        }
    }
    
    private func updateSelectionFor(location: CGPoint) -> Bool {
        for (index, optionView) in optionsStack.arrangedSubviews.enumerated() where optionView.frame.contains(location) {
            if selectedOption != options[index].value {
                selectedOption = options[index].value
                let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
                    self.updateSelectedIndicatorView()
                    self.scrollRectToVisible(optionView.frame.insetBy(dx: -16, dy: 0), animated: false)
                    self.layoutIfNeeded()
                }
                animator.startAnimation()
                selectionChangedFeedbackGenerator.selectionChanged()
                return true
            } else {
                return false
            }
        }
        return false
    }
    
    func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
        func distanceToAnyEdge(_ view: UIView) -> CGFloat {
            min(abs(view.frame.minX - request.location.x), abs(view.frame.maxX - request.location.x))
        }
        let (view, index, _) = optionsStack.arrangedSubviews.enumerated().map { ($0.1, $0.0, distanceToAnyEdge($0.1)) }.min(by: { $0.2 < $1.2 })!
        return UIPointerRegion(rect: view.frame.insetBy(dx: -8, dy: 0), identifier: index)
    }
    
    func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
        let index = region.identifier as! Int
        let optionView = optionsStack.arrangedSubviews[index]
        return UIPointerStyle(effect: .hover(UITargetedPreview(view: optionView)))
    }
    
    struct Option: Hashable {
        let value: Value
        let name: String
    }
}
