import SwiftUI
import UIKit

// swiftlint:disable file_length

/// `ScrollView().gesture(DragGesture(..))` conflicts with the scroll view's own gesture recognizer.
/// `ScrollView().gesture(ScrollViewInteroperableDragGesture(...))` allows you to add a drag gesture to a scroll view without messing up it's content dragging behavior
@available(iOS 18, *)
public struct ScrollViewInteroperableDragGesture: UIGestureRecognizerRepresentable {
    public struct Value: Equatable {
        public let translation: CGSize
        public let location: CGPoint
        public fileprivate(set) var velocity: CGSize
    }

    public struct Configuration {
        public var targetEdges: ScrollViewEdge
        public var ignoresScrollView: Bool
        public var sticksToEdges: Bool

        public init(
            ignoresScrollView: Bool,
            targetEdges: ScrollViewEdge,
            sticksToEdges: Bool
        ) {
            self.ignoresScrollView = ignoresScrollView
            self.sticksToEdges = sticksToEdges
            self.targetEdges = targetEdges
        }
    }

    public final class Coordinator: NSObject, UIGestureRecognizerDelegate {
        struct Tracking {
            var isDraggingX: Bool = false
            var isDraggingY: Bool = false
            var currentScrollController: ScrollController?
            var initialScrollableEdges: ScrollViewEdge = []
            var translation: CGSize = .zero
            var stickingEdges: ScrollViewEdge = []
        }

        var tracking: Tracking = .init()

        private let configuration: Configuration

        init(configuration: Configuration) {
            self.configuration = configuration
        }

        func purgeTrakingState() {
            tracking = .init()
        }

        @objc
        public func gestureRecognizer(
            _ gestureRecognizer: UIGestureRecognizer,
            shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
        ) -> Bool {
            guard let _gestureRecognizer = gestureRecognizer as? _ScrollViewDragGestureRecognizer else {
                assertionFailure("\(gestureRecognizer)")
                return false
            }

            guard !(otherGestureRecognizer is UIScreenEdgePanGestureRecognizer) else {
                return false
            }

            if configuration.ignoresScrollView {
                if otherGestureRecognizer.view is UIScrollView {
                    return false
                }
            }

            let result = _gestureRecognizer.trackingScrollView == otherGestureRecognizer.view

            return result
        }
    }

    private let coordinateSpaceInDragging: CoordinateSpaceProtocol
    private let _onChange: (Value) -> Void
    private let _onEnd: (Value) -> Void
    private let configuration: Configuration

    private let isScrollLockEnabled: Binding<Bool>

    public init(
        configuration: Configuration = .init(
            ignoresScrollView: false,
            targetEdges: .all,
            sticksToEdges: true
        ),
        isScrollLockEnabled: Binding<Bool> = .constant(false),
        coordinateSpaceInDragging: CoordinateSpaceProtocol,
        onChange: @escaping (Value) -> Void,
        onEnd: @escaping (Value) -> Void
    ) {
        self.configuration = configuration
        self.coordinateSpaceInDragging = coordinateSpaceInDragging
        self.isScrollLockEnabled = isScrollLockEnabled
        self._onChange = onChange
        self._onEnd = onEnd
    }

    public func makeCoordinator(converter _: CoordinateSpaceConverter) -> Coordinator {
        return .init(configuration: configuration)
    }

    public func makeUIGestureRecognizer(context: Context) -> _ScrollViewDragGestureRecognizer {
        let gesture = _ScrollViewDragGestureRecognizer()
        gesture.delegate = context.coordinator
        return gesture
    }

    // swiftlint:disable:next cyclomatic_complexity function_body_length
    public func handleUIGestureRecognizerAction(
        _ recognizer: _ScrollViewDragGestureRecognizer,
        context: Context
    ) {
        switch recognizer.state {
        case .possible:
            break

        case .began:
            if let scrollView = recognizer.trackingScrollView {
                context.coordinator.tracking.initialScrollableEdges = scrollView.scrollableEdges
            }
            fallthrough

        case .changed:

            func makeValue(translation: CGSize) -> Value {
                return Value(
                    translation: translation,
                    location: context.converter.location(in: coordinateSpaceInDragging),
                    velocity: { .init(width: $0.x, height: $0.y) }(
                        context.converter.velocity(in: coordinateSpaceInDragging) ?? .zero
                    )
                )
            }

            let (panDirection, diff) = recognizer.panDirection

            if let scrollView = recognizer.trackingScrollView {
                let scrollController = ScrollController(scrollView: scrollView)

                context.coordinator.tracking.currentScrollController = scrollController

                let scrollableEdges = scrollView.scrollableEdges

                var tracking = context.coordinator.tracking

                defer {
                    context.coordinator.tracking = tracking
                }

                if isScrollLockEnabled.wrappedValue {
                    scrollController.lockScrolling(direction: [.horizontal, .vertical])

                    tracking.translation.width += diff.x
                    tracking.translation.height += diff.y

                    _onChange(makeValue(translation: tracking.translation))

                    return
                }

                // handling scrolling in scrollview
                do {
                    if panDirection.contains(.up) {
                        if tracking.initialScrollableEdges.contains(.bottom) == false
                            &&
                            (
                                (
                                    tracking.initialScrollableEdges.contains(.bottom)
                                        &&
                                        configuration.targetEdges.contains(.bottom) && scrollableEdges.contains(.bottom) == false
                                )
                                    ||
                                    configuration.sticksToEdges && tracking.stickingEdges.contains(.top)
                            )
                        {
                            scrollController.lockScrolling(direction: .vertical)

                            if configuration.sticksToEdges && tracking.stickingEdges.contains(.top) == false {
                                scrollController.scrollTo(edge: .bottom)
                            }

                            tracking.isDraggingY = true

                            tracking.translation.height += diff.y
                            tracking.stickingEdges.insert(.bottom)
                            _onChange(makeValue(translation: tracking.translation))
                        } else {
                            scrollController.unlockScrolling(direction: .vertical)
                            tracking.isDraggingY = false
                        }
                    }

                    if panDirection.contains(.down) {
                        if tracking.initialScrollableEdges.contains(.top) == false
                            &&
                            (
                                (
                                    configuration.targetEdges.contains(.top)
                                        &&
                                        scrollableEdges.contains(.top) == false
                                )
                                    ||
                                    configuration.sticksToEdges && tracking.stickingEdges.contains(.bottom)
                            )
                        {
                            scrollController.lockScrolling(direction: .vertical)

                            if configuration.sticksToEdges && tracking.stickingEdges.contains(.bottom) == false {
                                scrollController.scrollTo(edge: .top)
                            }

                            tracking.translation.height += diff.y
                            tracking.isDraggingY = true
                            tracking.stickingEdges.insert(.top)

                            _onChange(makeValue(translation: tracking.translation))

                        } else {
                            scrollController.unlockScrolling(direction: .vertical)
                            tracking.isDraggingY = false
                        }
                    }

                    if panDirection.contains(.left) {
                        if tracking.initialScrollableEdges.contains(.right) == false
                            &&
                            (
                                (
                                    configuration.targetEdges.contains(.right)
                                        &&
                                        scrollableEdges.contains(.right) == false
                                )
                                    ||
                                    configuration.sticksToEdges && tracking.stickingEdges.contains(.left)
                            )
                        {
                            scrollController.lockScrolling(direction: .horizontal)

                            if configuration.sticksToEdges && tracking.stickingEdges.contains(.left) == false {
                                scrollController.scrollTo(edge: .right)
                            }

                            tracking.isDraggingX = true
                            tracking.translation.width += diff.x
                            tracking.stickingEdges.insert(.right)

                            _onChange(makeValue(translation: tracking.translation))

                        } else {
                            scrollController.unlockScrolling(direction: .horizontal)
                            tracking.isDraggingX = false
                        }
                    }

                    if panDirection.contains(.right) {
                        if tracking.initialScrollableEdges.contains(.right) == false
                            &&
                            (
                                (
                                    configuration.targetEdges.contains(.left)
                                        &&
                                        scrollableEdges.contains(.left) == false
                                )
                                    ||
                                    configuration.sticksToEdges && tracking.stickingEdges.contains(.right)
                            )
                        {
                            scrollController.lockScrolling(direction: .horizontal)

                            if configuration.sticksToEdges && tracking.stickingEdges.contains(.right) == false {
                                scrollController.scrollTo(edge: .left)
                            }

                            tracking.isDraggingX = true
                            tracking.translation.width += diff.x
                            tracking.stickingEdges.insert(.left)

                            _onChange(makeValue(translation: tracking.translation))

                        } else {
                            scrollController.unlockScrolling(direction: .horizontal)
                            tracking.isDraggingX = false
                        }
                    }
                }

            } else {
                context.coordinator.tracking.isDraggingX = true
                context.coordinator.tracking.isDraggingY = true

                context.coordinator.tracking.translation.width += diff.x
                context.coordinator.tracking.translation.height += diff.y

                _onChange(makeValue(translation: context.coordinator.tracking.translation))
            }

        case .ended, .cancelled, .failed:

            var value = Value(
                translation: context.coordinator.tracking.translation,
                location: context.converter.location(in: coordinateSpaceInDragging),
                velocity: { .init(width: $0.x, height: $0.y) }(
                    context.converter.velocity(in: coordinateSpaceInDragging) ?? .zero
                )
            )

            if context.coordinator.tracking.isDraggingX == false {
                value.velocity.width = 0
            }

            if context.coordinator.tracking.isDraggingY == false {
                value.velocity.height = 0
            }

            _onEnd(
                value
            )

            context.coordinator.purgeTrakingState()

        @unknown default:
            break
        }
    }
}

public struct ScrollViewEdge: OptionSet, CustomStringConvertible, Sendable {
    public let rawValue: Int

    public init(rawValue: Int) {
        self.rawValue = rawValue
    }

    public static let top = ScrollViewEdge(rawValue: 1 << 0)
    public static let bottom = ScrollViewEdge(rawValue: 1 << 1)
    public static let left = ScrollViewEdge(rawValue: 1 << 2)
    public static let right = ScrollViewEdge(rawValue: 1 << 3)

    public static var all: ScrollViewEdge {
        [.top, .bottom, .left, .right]
    }

    public static var vertical: ScrollViewEdge {
        [.top, .bottom]
    }

    public static var horizontal: ScrollViewEdge {
        [.left, .right]
    }

    public var description: String {
        var result: [String] = []
        if contains(.top) {
            result.append("top")
        }
        if contains(.bottom) {
            result.append("bottom")
        }
        if contains(.left) {
            result.append("left")
        }
        if contains(.right) {
            result.append("right")
        }
        return result.joined(separator: ", ")
    }
}

extension UIScrollView {
    /// These are the edges that the scroll view has space to scroll on.
    var scrollableEdges: ScrollViewEdge {
        var edges: ScrollViewEdge = []

        let contentInset: UIEdgeInsets = adjustedContentInset

        // Top
        if contentOffset.y > -contentInset.top {
            edges.insert(.top)
        }

        // Left
        if contentOffset.x > -contentInset.left {
            edges.insert(.left)
        }

        // bottom
        if contentOffset.y + bounds.height < (contentSize.height + contentInset.bottom) {
            edges.insert(.bottom)
        }

        // right

        if contentOffset.x + bounds.width < (contentSize.width + contentInset.right) {
            edges.insert(.right)
        }

        return edges
    }

    var contentOffsetToResetY: CGPoint {
        let contentInset = self.adjustedContentInset
        var contentOffset = contentOffset
        contentOffset.y = -contentInset.top
        return contentOffset
    }
}

public final class _ScrollViewDragGestureRecognizer: UIPanGestureRecognizer {
    struct PanDirection: OptionSet {
        let rawValue: Int

        static let up = PanDirection(rawValue: 1 << 0)
        static let down = PanDirection(rawValue: 1 << 1)
        static let left = PanDirection(rawValue: 1 << 2)
        static let right = PanDirection(rawValue: 1 << 3)
    }

    weak var trackingScrollView: UIScrollView?

    private var previousTranslation: CGPoint = .zero

    var panDirection: (PanDirection, diff: CGPoint) {
        let translation = self.translation(in: view)

        let diff = CGPoint(x: translation.x - previousTranslation.x, y: translation.y - previousTranslation.y)

        previousTranslation = translation

        var direction: PanDirection = []

        if diff.y > 0 {
            direction.insert(.down)
        } else if diff.y < 0 {
            direction.insert(.up)
        }

        if diff.x > 0 {
            direction.insert(.right)
        } else if diff.x < 0 {
            direction.insert(.left)
        }

        return (direction, diff)
    }

    override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        trackingScrollView = event.findVerticalScrollView()
        previousTranslation = .zero
        super.touchesBegan(touches, with: event)
    }
}

private extension UIEvent {
    @MainActor
    func findVerticalScrollView() -> UIScrollView? {
        guard
            let firstTouch = allTouches?.first,
            let targetView = firstTouch.view
        else { return nil }

        let scrollView = Array(sequence(first: targetView, next: { $0.next }))
            .last {
                guard let scrollView = $0 as? UIScrollView else {
                    return false
                }

                @MainActor
                func isScrollable(scrollView: UIScrollView) -> Bool {
                    let contentInset: UIEdgeInsets = scrollView.adjustedContentInset

                    return (scrollView.bounds.width - (contentInset.right + contentInset.left)
                        <= scrollView.contentSize.width)
                        || (scrollView.bounds.height - (contentInset.top + contentInset.bottom)
                            <= scrollView.contentSize.height)
                }

                return isScrollable(scrollView: scrollView)
            }

        return (scrollView as? UIScrollView)
    }
}

@MainActor
final class ScrollController {
    enum Edge {
        case top
        case bottom
        case left
        case right
    }

    struct LockingDirection: OptionSet {
        let rawValue: Int

        static let vertical = LockingDirection(rawValue: 1 << 0)
        static let horizontal = LockingDirection(rawValue: 1 << 1)
    }

    private var scrollObserver: NSKeyValueObservation!
    private(set) var lockingDirection: LockingDirection = []
    private var previousValue: CGPoint?
    let scrollView: UIScrollView

    init(scrollView: UIScrollView) {
        self.scrollView = scrollView
        scrollObserver = scrollView.observe(\.contentOffset, options: .old) {
            [weak self, weak _scrollView = scrollView] _, change in

            guard let scrollView = _scrollView else { return }
            guard let self = self else { return }

            MainActor.assumeIsolated {
                self.handleScrollViewEvent(scrollView: scrollView, change: change)
            }
        }
    }

    deinit {
        MainActor.assumeIsolated {
            endTracking()
        }
    }

    func lockScrolling(direction: LockingDirection) {
        lockingDirection.insert(direction)
    }

    func unlockScrolling(direction: LockingDirection) {
        lockingDirection.remove(direction)
    }

    func setShowsVerticalScrollIndicator(_ flag: Bool) {
        scrollView.showsVerticalScrollIndicator = flag
    }

    func endTracking() {
        unlockScrolling(direction: [.vertical, .horizontal])
        scrollObserver.invalidate()
    }

    func scrollTo(edge: Edge) {
        let contentInset = scrollView.adjustedContentInset
        let contentOffset = scrollView.contentOffset
        var offset = contentOffset
        switch edge {
        case .top:
            offset.y = -contentInset.top
        case .bottom:
            offset.y = scrollView.contentSize.height - scrollView.bounds.height + contentInset.bottom
        case .left:
            offset.x = -contentInset.left
        case .right:
            offset.x = scrollView.contentSize.width - scrollView.bounds.width + contentInset.right
        }
        setContentOffset(offset)
    }

    func resetContentOffsetY() {
        let contentInset = scrollView.adjustedContentInset
        if scrollView.contentOffset.y < -contentInset.top {
            setContentOffset(scrollView.contentOffsetToResetY)
        }
    }

    func setContentOffset(_ offset: CGPoint) {
        let previous = lockingDirection
        lockingDirection = []
        defer {
            lockingDirection = previous
        }
        scrollView.contentOffset = offset
    }

    private func handleScrollViewEvent(
        scrollView: UIScrollView,
        change: NSKeyValueObservedChange<CGPoint>
    ) {
        // For debugging

        guard let oldValue = change.oldValue else { return }

        guard lockingDirection.isEmpty == false else {
            return
        }

        //    guard scrollView.contentOffset != oldValue else { return }

        guard oldValue != previousValue else { return }

        previousValue = scrollView.contentOffset

        var fixedOffset = scrollView.contentOffset

        if lockingDirection.contains(.vertical) {
            fixedOffset.y = oldValue.y
        }

        if lockingDirection.contains(.horizontal) {
            fixedOffset.x = oldValue.x
        }

        scrollView.setContentOffset(fixedOffset, animated: false)
    }
}
