import SwiftUI

public extension AnyChangeEffect {
    /// The rate of the spin effect.
    enum SpinRate {
        case `default`
        case fast

        fileprivate var maximumVelocity: Angle {
            switch self {
            case .fast: return .degrees(360 * 4)
            case .default: return .degrees(360 * 2)
            }
        }

        fileprivate var initialVelocity: Angle {
            switch self {
            case .fast: return .degrees(900)
            case .default: return .degrees(360)
            }
        }

        fileprivate var additionalVelocity: Angle {
            switch self {
            case .fast: return .degrees(900)
            case .default: return .degrees(360)
            }
        }
    }

    /// An effect that spins the view when a change happens.
    static var spin: AnyChangeEffect {
        spin(axis: (0, 1, 0))
    }

    /// An effect that spins the view when a change happens.
    ///
    /// - Parameters:
    ///   - axis: The x, y and z elements that specify the axis of rotation.
    ///   - anchor: The location with a default of center that defines a point in 3D space about which the rotation is anchored.
    ///   - anchorZ: The location with a default of 0 that defines a point in 3D space about which the rotation is anchored.
    ///   - perspective: The relative vanishing point with a default of 1 / 6 for this rotation.
    ///   - rate: The rate of the spin.
    static func spin(axis: (x: CGFloat, y: CGFloat, z: CGFloat), anchor: UnitPoint = .center, anchorZ: CGFloat = 0, perspective: CGFloat = 1 / 6, rate: SpinRate = .default) -> AnyChangeEffect {
        .simulation { change in
            SpinSimulationModifier(impulseCount: change, axis: axis, anchor: anchor, anchorZ: anchorZ, perspective: perspective, rate: rate)
        }
    }
}

internal struct SpinSimulationModifier: ViewModifier, Simulative {
    var impulseCount: Int

    var initialVelocity: CGFloat = 0

    let spring = Spring(zeta: 1 / 2, stiffness: 7)

    var axis: (x: CGFloat, y: CGFloat, z: CGFloat)

    var anchor: UnitPoint

    var anchorZ: CGFloat

    var perspective: CGFloat

    var rate: AnyChangeEffect.SpinRate

    @State
    private var targetAngle: Angle = .zero

    @State
    private var angle: Angle = .zero

    @State
    private var angleVelocity: Angle = .zero

    private var transformEffect: some ViewModifier {
        Transform3DEffect(
            translation: (0, 0, anchorZ),
            angle: angle,
            axis: (axis.x, axis.y, axis.z),
            anchor: anchor,
            anchorZ: anchorZ,
            perspective: perspective
        )
        .shaded(lightSource: (-0.5, -1, 0))
    }

    private var isSimulationPaused: Bool {
        targetAngle == angle && abs(angleVelocity.degrees) <= 0.2
    }

    public func body(content: Content) -> some View {
        TimelineView(.animation(paused: isSimulationPaused)) { context in
            content
                .modifier(transformEffect)
                .onChange(of: context.date) { (newValue: Date) in
                    let duration = Double(newValue.timeIntervalSince(context.date))
                    withAnimation(nil) {
                        update(max(0, min(duration, 1 / 30)))
                    }
                }
        }
        .onChange(of: impulseCount) { newValue in
            withAnimation(nil) {
                if angleVelocity <= .degrees(10) {
                    angleVelocity = rate.initialVelocity
                } else {
                    angleVelocity += rate.additionalVelocity
                }

                angleVelocity = min(angleVelocity, rate.maximumVelocity)
            }
        }
    }

    private func update(_ step: Double) {
        let newValue: Double
        let newVelocity: Double

        if abs(angleVelocity.degrees) > 240 {
            newValue = angle.degrees + angleVelocity.degrees * step
            newVelocity = angleVelocity.degrees * 0.99
            targetAngle = .degrees((angle.degrees / 360.0).rounded(.up) * 360.0)
        } else if spring.response > 0 {
            (newValue, newVelocity) = spring.value(
                from: angle.degrees,
                to: targetAngle.degrees,
                velocity: angleVelocity.degrees,
                timestep: step
            )
        } else {
            newValue = targetAngle.degrees
            newVelocity = .zero
        }

        angle = .degrees(newValue)
        angleVelocity = .degrees(newVelocity)

        if abs(newValue - targetAngle.degrees) < 0.04, newVelocity < 0.04 {
            angle = targetAngle
            angleVelocity = .zero
        }
    }
}

#if os(iOS) && DEBUG
struct SpinSimulation_Previews: PreviewProvider {
    struct Preview: View {
        @State
        var impulseCount = 0

        var body: some View {

            VStack {
                ZStack {
                    Circle().fill(.red)

                    Image(systemName: "circle.and.line.horizontal")
                        .font(.system(size: 40))
                        .imageScale(.large)
                        .foregroundColor(.white)
                }
                .frame(width: 100, height: 100)
                .changeEffect(.spin(axis: (1, 0, 0)), value: impulseCount)

                if #available(iOS 16.0, *) {
                    ZStack {
                        Image(systemName: "hand.thumbsup.fill")
                            .font(.system(size: 40))
                            .imageScale(.large)
                            .foregroundStyle(.blue.gradient)
                    }
                    .changeEffect(.spin(axis: (0, 1, 0), rate: .fast), value: impulseCount)
                    .frame(width: 100, height: 100)
                }

                ZStack {
                    Circle().fill(.yellow)

                    Image(systemName: "circle.and.line.horizontal")
                        .rotationEffect(.degrees(90))
                        .font(.system(size: 40))
                        .imageScale(.large)
                        .foregroundColor(.white)
                }
                .frame(width: 100, height: 100)
                .changeEffect(.spin(axis: (0, 1, 0)), value: impulseCount)

                ZStack {
                    Circle().fill(.green)

                    Image(systemName: "arrow.triangle.2.circlepath")
                        .font(.system(size: 40))
                        .imageScale(.large)
                        .foregroundColor(.white)
                }
                .frame(width: 100, height: 100)
                .changeEffect(.spin(axis: (0, 0, 1)), value: impulseCount)

                ZStack {
                    Circle().fill(.blue)

                    Image(systemName: "circle.and.line.horizontal")
                        .rotationEffect(.degrees(45))
                        .font(.system(size: 40))
                        .imageScale(.large)
                        .foregroundColor(.white)
                }
                .frame(width: 100, height: 100)
                .changeEffect(.spin(axis: (1, 1, 0)), value: impulseCount)

                ZStack {
                    Circle().fill(.blue)

                    Image(systemName: "circle.and.line.horizontal")
                        .rotationEffect(.degrees(-45))
                        .font(.system(size: 40))
                        .imageScale(.large)
                        .foregroundColor(.white)
                }
                .frame(width: 100, height: 100)
                .changeEffect(.spin(axis: (-1, 1, 0), anchorZ: -100), value: impulseCount)

                Button("Spin") {
                    impulseCount += 1
                }
                .buttonStyle(.bordered)
            }
            .padding()
        }
    }

    struct Preview2: View {
        @State
        var text = ""

        var body: some View {
            VStack(alignment: .trailing) {
                VStack(alignment: .leading, spacing: 0) {
                    TextEditor(text: $text)
                        .mask({
                            RoundedRectangle(cornerRadius: 8, style: .continuous)
                        })
                        .overlay(content: {
                            RoundedRectangle(cornerRadius: 8, style: .continuous)
                                .stroke(.gray)
                        })
                        .frame(height: 140)

                    Text(text.count.formatted())
                        .font(.caption)
                        .monospacedDigit()
                        .foregroundColor(.white)
                        .padding(.vertical, 4)
                        .padding(.horizontal, 12)
                        .background(.gray, in: Capsule())
                        .changeEffect(.spin(axis: (1, 0, 0), anchor: .top), value: text.count)
                        .mask(Rectangle())
                }

                Button("Send") {

                }
                .buttonStyle(.borderedProminent)
                #if os(iOS)
                .buttonBorderShape(.capsule)
                #endif
                .tint(.green)
            }
            .padding()
        }
    }

    static var previews: some View {
        Group {
            NavigationView {
                Preview()
            }

            NavigationView {
                Preview2()
            }
        }
    }
}
#endif
