// Created by Bryn Bodayle on 1/20/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.

#if canImport(SwiftUI)
import SwiftUI

// MARK: - LottieView

/// A wrapper which exposes Lottie's `LottieAnimationView` to SwiftUI
public struct LottieView<Placeholder: View>: UIViewConfiguringSwiftUIView {

  // MARK: Lifecycle

  /// Creates a `LottieView` that displays the given animation
  public init(animation: LottieAnimation?) where Placeholder == EmptyView {
    localAnimation = animation.map(LottieAnimationSource.lottieAnimation)
    placeholder = nil
  }

  /// Initializes a `LottieView` with the provided `DotLottieFile` for display.
  ///
  /// - Important: Avoid using this initializer with the `SynchronouslyBlockingCurrentThread` APIs.
  ///              If decompression of a `.lottie` file is necessary, prefer using the `.init(_ loadAnimation:)`
  ///              initializer, which takes an asynchronous closure:
  /// ```
  /// LottieView {
  ///  try await DotLottieFile.named(name)
  /// }
  /// ```
  public init(dotLottieFile: DotLottieFile?) where Placeholder == EmptyView {
    localAnimation = dotLottieFile.map(LottieAnimationSource.dotLottieFile)
    placeholder = nil
  }

  /// Creates a `LottieView` that asynchronously loads and displays the given `LottieAnimation`.
  /// The `loadAnimation` closure is called exactly once in `onAppear`.
  /// If you wish to call `loadAnimation` again at a different time, you can use `.reloadAnimationTrigger(...)`.
  public init(_ loadAnimation: @escaping () async throws -> LottieAnimation?) where Placeholder == EmptyView {
    self.init(loadAnimation, placeholder: EmptyView.init)
  }

  /// Creates a `LottieView` that asynchronously loads and displays the given `LottieAnimation`.
  /// The `loadAnimation` closure is called exactly once in `onAppear`.
  /// If you wish to call `loadAnimation` again at a different time, you can use `.reloadAnimationTrigger(...)`.
  /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`.
  public init(
    _ loadAnimation: @escaping () async throws -> LottieAnimation?,
    @ViewBuilder placeholder: @escaping (() -> Placeholder)
  ) {
    self.init {
      try await loadAnimation().map(LottieAnimationSource.lottieAnimation)
    } placeholder: {
      placeholder()
    }
  }

  /// Creates a `LottieView` that asynchronously loads and displays the given `DotLottieFile`.
  /// The `loadDotLottieFile` closure is called exactly once in `onAppear`.
  /// If you wish to call `loadAnimation` again at a different time, you can use `.reloadAnimationTrigger(...)`.
  /// You can use the `DotLottieFile` static methods API which use Swift concurrency to load your `.lottie` files:
  /// ```
  /// LottieView {
  ///  try await DotLottieFile.named(name)
  /// }
  /// ```
  public init(_ loadDotLottieFile: @escaping () async throws -> DotLottieFile?) where Placeholder == EmptyView {
    self.init(loadDotLottieFile, placeholder: EmptyView.init)
  }

  /// Creates a `LottieView` that asynchronously loads and displays the given `DotLottieFile`.
  /// The `loadDotLottieFile` closure is called exactly once in `onAppear`.
  /// If you wish to call `loadAnimation` again at a different time, you can use `.reloadAnimationTrigger(...)`.
  /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`.
  /// You can use the `DotLottieFile` static methods API which use Swift concurrency to load your `.lottie` files:
  /// ```
  /// LottieView {
  ///  try await DotLottieFile.named(name)
  /// } placeholder: {
  ///  LoadingView()
  /// }
  /// ```
  public init(
    _ loadDotLottieFile: @escaping () async throws -> DotLottieFile?,
    @ViewBuilder placeholder: @escaping (() -> Placeholder)
  ) {
    self.init {
      try await loadDotLottieFile().map(LottieAnimationSource.dotLottieFile)
    } placeholder: {
      placeholder()
    }
  }

  /// Creates a `LottieView` that asynchronously loads and displays the given `LottieAnimationSource`.
  /// The `loadAnimation` closure is called exactly once in `onAppear`.
  /// If you wish to call `loadAnimation` again at a different time, you can use `.reloadAnimationTrigger(...)`.
  /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`.
  public init(_ loadAnimation: @escaping () async throws -> LottieAnimationSource?) where Placeholder == EmptyView {
    self.init(loadAnimation, placeholder: EmptyView.init)
  }

  /// Creates a `LottieView` that asynchronously loads and displays the given `LottieAnimationSource`.
  /// The `loadAnimation` closure is called exactly once in `onAppear`.
  /// If you wish to call `loadAnimation` again at a different time, you can use `.reloadAnimationTrigger(...)`.
  /// While the animation is loading, the `placeholder` view is shown in place of the `LottieAnimationView`.
  public init(
    _ loadAnimation: @escaping () async throws -> LottieAnimationSource?,
    @ViewBuilder placeholder: @escaping () -> Placeholder
  ) {
    localAnimation = nil
    self.loadAnimation = loadAnimation
    self.placeholder = placeholder
  }

  // MARK: Public

  public var body: some View {
    LottieAnimationView.swiftUIView {
      LottieAnimationView(
        animationSource: animationSource,
        imageProvider: imageProviderConfiguration?.imageProvider,
        textProvider: textProvider,
        fontProvider: fontProvider,
        configuration: configuration,
        logger: logger
      )
    }
    .sizing(sizing)
    .configure { context in
      applyCurrentAnimationConfiguration(to: context.view, in: context.container)
    }
    .configurations(configurations)
    .opacity(animationSource == nil ? 0 : 1)
    .overlay {
      placeholder?()
        .opacity(animationSource == nil ? 1 : 0)
    }
    .onAppear {
      loadAnimationIfNecessary()
    }
    .valueChanged(value: reloadAnimationTrigger) { _ in
      reloadAnimationTriggerDidChange()
    }
  }

  /// Returns a copy of this `LottieView` updated to have the specific configuration property
  /// applied to its represented `LottieAnimationView` whenever it is updated via the `updateUIView(...)`
  /// or `updateNSView(...)` methods.
  ///
  /// - note: This configuration will be applied on every SwiftUI render pass.
  ///         Be wary of applying heavy side-effects as configuration values.
  public func configure<Property>(
    _ property: ReferenceWritableKeyPath<LottieAnimationView, Property>,
    to value: Property
  ) -> Self {
    configure { $0[keyPath: property] = value }
  }

  /// Returns a copy of this `LottieView` updated to have the specific configuration property
  /// applied to its represented `LottieAnimationView` whenever it is updated via the `updateUIView(...)`
  /// or `updateNSView(...)` methods.
  ///
  /// - note: If the `value` is already the currently-applied configuration value, it won't be applied
  public func configure<Property: Equatable>(
    _ property: ReferenceWritableKeyPath<LottieAnimationView, Property>,
    to value: Property
  ) -> Self {
    configure {
      guard $0[keyPath: property] != value else { return }
      $0[keyPath: property] = value
    }
  }

  /// Returns a copy of this `LottieView` updated to have the given closure applied to its
  /// represented `LottieAnimationView` whenever it is updated via the `updateUIView(…)`
  /// or `updateNSView(…)` method.
  ///
  /// - note: This configuration closure will be executed on every SwiftUI render pass.
  ///         Be wary of applying heavy side-effects inside it.
  public func configure(_ configure: @escaping (LottieAnimationView) -> Void) -> Self {
    var copy = self
    copy.configurations.append { context in
      configure(context.view)
    }
    return copy
  }

  /// Returns a copy of this view that can be resized by scaling its animation
  /// to always fit the size offered by its parent.
  public func resizable() -> Self {
    var copy = self
    copy.sizing = .proposed
    return copy
  }

  /// Returns a copy of this view that adopts the intrinsic size of the animation,
  /// up to the proposed size.
  public func intrinsicSize() -> Self {
    var copy = self
    copy.sizing = .intrinsic
    return copy
  }

  @available(*, deprecated, renamed: "playing()", message: "Will be removed in a future major release.")
  public func play() -> Self {
    playbackMode(.playing(.fromProgress(nil, toProgress: 1, loopMode: .playOnce)))
  }

  /// Returns a copy of this view that loops its animation from the start to end whenever visible
  public func looping() -> Self {
    playbackMode(.playing(.fromProgress(0, toProgress: 1, loopMode: .loop)))
  }

  @available(*, deprecated, renamed: "playing(_:)", message: "Will be removed in a future major release.")
  public func play(loopMode: LottieLoopMode = .playOnce) -> Self {
    playbackMode(.playing(.fromProgress(nil, toProgress: 1, loopMode: loopMode)))
  }

  @available(*, deprecated, renamed: "playbackMode(_:)", message: "Will be removed in a future major release.")
  public func play(_ playbackMode: LottiePlaybackMode) -> Self {
    self.playbackMode(playbackMode)
  }

  /// Returns a copy of this view playing with the given playback mode
  public func playing(_ mode: LottiePlaybackMode.PlaybackMode) -> Self {
    playbackMode(.playing(mode))
  }

  /// Returns a copy of this view playing from the current frame to the end frame,
  /// with the given `LottiePlaybackMode`.
  public func playing(loopMode: LottieLoopMode) -> Self {
    playbackMode(.playing(.fromProgress(nil, toProgress: 1, loopMode: loopMode)))
  }

  /// Returns a copy of this view playing once from the current frame to the end frame
  public func playing() -> Self {
    playbackMode(.playing(.fromProgress(nil, toProgress: 1, loopMode: .playOnce)))
  }

  /// Returns a copy of this view paused with the given state
  public func paused(at state: LottiePlaybackMode.PausedState = .currentFrame) -> Self {
    playbackMode(.paused(at: state))
  }

  /// Returns a copy of this view using the given `LottiePlaybackMode`
  public func playbackMode(_ playbackMode: LottiePlaybackMode) -> Self {
    var copy = self
    copy.playbackMode = playbackMode
    return copy
  }

  /// Returns a copy of this view playing its animation at the given speed
  public func animationSpeed(_ animationSpeed: Double) -> Self {
    var copy = self
    copy.animationSpeed = animationSpeed
    return copy
  }

  /// Returns a copy of this view with the given closure that is called whenever the
  /// `LottieAnimationSource` provided via `init` is loaded and applied to the underlying `LottieAnimationView`.
  public func animationDidLoad(_ animationDidLoad: @escaping (LottieAnimationSource) -> Void) -> Self {
    var copy = self
    copy.animationDidLoad = animationDidLoad
    return copy
  }

  /// Returns a copy of this view with the given `LottieCompletionBlock` that is called
  /// when an animation finishes playing.
  public func animationDidFinish(_ animationCompletionHandler: LottieCompletionBlock?) -> Self {
    var copy = self
    copy.animationCompletionHandler = { [previousCompletionHandler = self.animationCompletionHandler] completed in
      previousCompletionHandler?(completed)
      animationCompletionHandler?(completed)
    }
    return copy
  }

  /// Returns a copy of this view updated to have the provided background behavior.
  public func backgroundBehavior(_ value: LottieBackgroundBehavior) -> Self {
    configure { view in
      view.backgroundBehavior = value
    }
  }

  /// Returns a copy of this view with its accessibility label updated to the given value.
  public func accessibilityLabel(_ accessibilityLabel: String?) -> Self {
    configure { view in
      #if os(macOS)
      view.setAccessibilityElement(accessibilityLabel != nil)
      view.setAccessibilityLabel(accessibilityLabel)
      #else
      view.isAccessibilityElement = accessibilityLabel != nil
      view.accessibilityLabel = accessibilityLabel
      #endif
    }
  }

  /// Returns a copy of this view with its `LottieConfiguration` updated to the given value.
  public func configuration(_ configuration: LottieConfiguration) -> Self {
    var copy = self
    copy.configuration = configuration

    copy = copy.configure { view in
      if view.configuration != configuration {
        view.configuration = configuration
      }
    }

    return copy
  }

  /// Returns a copy of this view with its `LottieLogger` updated to the given value.
  ///  - The underlying `LottieAnimationView`'s `LottieLogger` is immutable after configured,
  ///    so this value is only used when initializing the `LottieAnimationView` for the first time.
  public func logger(_ logger: LottieLogger) -> Self {
    var copy = self
    copy.logger = logger
    return copy
  }

  /// Returns a copy of this view with its image provider updated to the given value.
  /// The image provider must be `Equatable` to avoid unnecessary state updates / re-renders.
  public func imageProvider<ImageProvider: AnimationImageProvider & Equatable>(_ imageProvider: ImageProvider) -> Self {
    var copy = self

    copy.imageProviderConfiguration = (
      imageProvider: imageProvider,
      imageProvidersAreEqual: { untypedLHS, untypedRHS in
        guard
          let lhs = untypedLHS as? ImageProvider,
          let rhs = untypedRHS as? ImageProvider
        else { return false }

        return lhs == rhs
      }
    )

    return copy
  }

  /// Returns a copy of this view with its text provider updated to the given value.
  /// The image provider must be `Equatable` to avoid unnecessary state updates / re-renders.
  public func textProvider<TextProvider: AnimationKeypathTextProvider & Equatable>(_ textProvider: TextProvider) -> Self {
    var copy = self
    copy.textProvider = textProvider

    copy = copy.configure { view in
      if (view.textProvider as? TextProvider) != textProvider {
        view.textProvider = textProvider
      }
    }

    return copy
  }

  /// Returns a copy of this view with its image provider updated to the given value.
  /// The image provider must be `Equatable` to avoid unnecessary state updates / re-renders.
  public func fontProvider<FontProvider: AnimationFontProvider & Equatable>(_ fontProvider: FontProvider) -> Self {
    var copy = self
    copy.fontProvider = fontProvider

    copy = configure { view in
      if (view.fontProvider as? FontProvider) != fontProvider {
        view.fontProvider = fontProvider
      }
    }

    return copy
  }

  /// Returns a copy of this view using the given value provider for the given keypath.
  /// The value provider must be `Equatable` to avoid unnecessary state updates / re-renders.
  public func valueProvider<ValueProvider: AnyValueProvider & Equatable>(
    _ valueProvider: ValueProvider,
    for keypath: AnimationKeypath
  ) -> Self {
    configure { view in
      if (view.valueProviders[keypath] as? ValueProvider) != valueProvider {
        view.setValueProvider(valueProvider, keypath: keypath)
      }
    }
  }

  /// Returns a copy of this view updated to display the given `AnimationProgressTime`.
  ///  - If the `currentProgress` value is provided, the `currentProgress` of the
  ///    underlying `LottieAnimationView` is updated. This will pause any existing animations.
  ///  - If the `animationProgress` is `nil`, no changes will be made and any existing animations
  ///    will continue playing uninterrupted.
  public func currentProgress(_ currentProgress: AnimationProgressTime?) -> Self {
    guard let currentProgress else { return self }
    var copy = self
    copy.playbackMode = .paused(at: .progress(currentProgress))
    return copy
  }

  /// Returns a copy of this view updated to display the given `AnimationFrameTime`.
  ///  - If the `currentFrame` value is provided, the `currentFrame` of the
  ///    underlying `LottieAnimationView` is updated. This will pause any existing animations.
  ///  - If the `currentFrame` is `nil`, no changes will be made and any existing animations
  ///    will continue playing uninterrupted.
  public func currentFrame(_ currentFrame: AnimationFrameTime?) -> Self {
    guard let currentFrame else { return self }
    var copy = self
    copy.playbackMode = .paused(at: .frame(currentFrame))
    return copy
  }

  /// Returns a copy of this view updated to display the given time value.
  ///  - If the `currentTime` value is provided, the `currentTime` of the
  ///    underlying `LottieAnimationView` is updated. This will pause any existing animations.
  ///  - If the `currentTime` is `nil`, no changes will be made and any existing animations
  ///    will continue playing uninterrupted.
  public func currentTime(_ currentTime: TimeInterval?) -> Self {
    guard let currentTime else { return self }
    var copy = self
    copy.playbackMode = .paused(at: .time(currentTime))
    return copy
  }

  /// Returns a new instance of this view, which will invoke the provided `loadAnimation` closure
  /// whenever the `binding` value is updated.
  ///
  /// - Note: This function requires a valid `loadAnimation` closure provided during view initialization,
  ///         otherwise `reloadAnimationTrigger` will have no effect.
  /// - Parameters:
  ///   - binding: The binding that triggers the reloading when its value changes.
  ///   - showPlaceholder: When `true`, the current animation will be removed before invoking `loadAnimation`,
  ///     displaying the `Placeholder` until the new animation loads.
  ///     When `false`, the previous animation remains visible while the new one loads.
  public func reloadAnimationTrigger(_ value: some Equatable, showPlaceholder: Bool = true) -> Self {
    var copy = self
    copy.reloadAnimationTrigger = AnyEquatable(value)
    copy.showPlaceholderWhileReloading = showPlaceholder
    return copy
  }

  /// Returns a view that updates the given binding each frame with the animation's `realtimeAnimationProgress`.
  /// The `LottieView` is wrapped in a `TimelineView` with the `.animation` schedule.
  ///  - This is a one-way binding. Its value is updated but never read.
  ///  - If provided, the binding will be updated each frame with the `realtimeAnimationProgress`
  ///    of the underlying `LottieAnimationView`. This is potentially expensive since it triggers
  ///    a state update every frame.
  ///  - If the binding is `nil`, the `TimelineView` will be paused and no updates will occur to the binding.
  @available(iOS 15.0, tvOS 15.0, macOS 12.0, *)
  public func getRealtimeAnimationProgress(_ realtimeAnimationProgress: Binding<AnimationProgressTime>?) -> some View {
    TimelineView(.animation(paused: realtimeAnimationProgress == nil)) { _ in
      configure { view in
        if let realtimeAnimationProgress {
          DispatchQueue.main.async {
            realtimeAnimationProgress.wrappedValue = view.realtimeAnimationProgress
          }
        }
      }
    }
  }

  /// Returns a view that updates the given binding each frame with the animation's `realtimeAnimationProgress`.
  /// The `LottieView` is wrapped in a `TimelineView` with the `.animation` schedule.
  ///  - This is a one-way binding. Its value is updated but never read.
  ///  - If provided, the binding will be updated each frame with the `realtimeAnimationProgress`
  ///    of the underlying `LottieAnimationView`. This is potentially expensive since it triggers
  ///    a state update every frame.
  ///  - If the binding is `nil`, the `TimelineView` will be paused and no updates will occur to the binding.
  @available(iOS 15.0, tvOS 15.0, macOS 12.0, *)
  public func getRealtimeAnimationFrame(_ realtimeAnimationFrame: Binding<AnimationFrameTime>?) -> some View {
    TimelineView(.animation(paused: realtimeAnimationFrame == nil)) { _ in
      configure { view in
        if let realtimeAnimationFrame {
          DispatchQueue.main.async {
            realtimeAnimationFrame.wrappedValue = view.realtimeAnimationFrame
          }
        }
      }
    }
  }

  /// Returns a copy of this view with the `DotLottieConfigurationComponents`
  /// updated to the given value.
  ///  - Defaults to `[.imageProvider]`
  ///  - If a component is specified here, that value in the `DotLottieConfiguration`
  ///    of an active dotLottie animation will override any value provided via other methods.
  public func dotLottieConfigurationComponents(
    _ dotLottieConfigurationComponents: DotLottieConfigurationComponents
  ) -> Self {
    var copy = self
    copy.dotLottieConfigurationComponents = dotLottieConfigurationComponents
    return copy
  }

  // MARK: Internal

  var configurations = [SwiftUIView<LottieAnimationView, Void>.Configuration]()

  // MARK: Private

  @State private var remoteAnimation: LottieAnimationSource?

  private let localAnimation: LottieAnimationSource?
  private var playbackMode: LottiePlaybackMode?
  private var animationSpeed: Double?
  private var reloadAnimationTrigger: AnyEquatable?
  private var loadAnimation: (() async throws -> LottieAnimationSource?)?
  private var animationDidLoad: ((LottieAnimationSource) -> Void)?
  private var animationCompletionHandler: LottieCompletionBlock?
  private var showPlaceholderWhileReloading = false
  private var textProvider: AnimationKeypathTextProvider = DefaultTextProvider()
  private var fontProvider: AnimationFontProvider = DefaultFontProvider()
  private var configuration = LottieConfiguration.shared
  private var dotLottieConfigurationComponents = DotLottieConfigurationComponents.imageProvider
  private var logger = LottieLogger.shared
  private var sizing = SwiftUIMeasurementContainerStrategy.automatic
  private let placeholder: (() -> Placeholder)?

  private var imageProviderConfiguration: (
    imageProvider: AnimationImageProvider,
    imageProvidersAreEqual: (AnimationImageProvider, AnimationImageProvider) -> Bool
  )?

  private var animationSource: LottieAnimationSource? {
    localAnimation ?? remoteAnimation
  }

  private func loadAnimationIfNecessary() {
    guard let loadAnimation else { return }

    Task {
      do {
        remoteAnimation = try await loadAnimation()
      } catch {
        logger.warn("Failed to load asynchronous Lottie animation with error: \(error)")
      }
    }
  }

  private func reloadAnimationTriggerDidChange() {
    guard loadAnimation != nil else { return }

    if showPlaceholderWhileReloading {
      remoteAnimation = nil
    }

    loadAnimationIfNecessary()
  }

  /// Applies playback configuration for the current animation to the `LottieAnimationView`
  private func applyCurrentAnimationConfiguration(
    to view: LottieAnimationView,
    in container: SwiftUIMeasurementContainer<LottieAnimationView>
  ) {
    guard let animationSource else { return }
    var imageProviderConfiguration = imageProviderConfiguration
    var playbackMode = playbackMode
    var animationSpeed = animationSpeed

    // When playing a dotLottie animation, its `DotLottieConfiguration`
    // can override some behavior of the animation.
    if let dotLottieConfiguration = animationSource.dotLottieAnimation?.configuration {
      // Only use the value from the `DotLottieConfiguration` is that component
      // is specified in the list of `dotLottieConfigurationComponents`.
      if dotLottieConfigurationComponents.contains(.loopMode) {
        playbackMode = playbackMode?.loopMode(dotLottieConfiguration.loopMode)
      }

      if dotLottieConfigurationComponents.contains(.animationSpeed) {
        animationSpeed = dotLottieConfiguration.speed
      }

      if
        dotLottieConfigurationComponents.contains(.imageProvider),
        let dotLottieImageProvider = dotLottieConfiguration.dotLottieImageProvider
      {
        imageProviderConfiguration = (
          imageProvider: dotLottieImageProvider,
          imageProvidersAreEqual: { untypedLHS, untypedRHS in
            guard
              let lhs = untypedLHS as? DotLottieImageProvider,
              let rhs = untypedRHS as? DotLottieImageProvider
            else { return false }

            return lhs == rhs
          }
        )
      }
    }

    // We check referential equality of the animation before updating as updating the
    // animation has a side-effect of rebuilding the animation layer, and it would be
    // prohibitive to do so on every state update.
    if animationSource.animation !== view.animation {
      view.loadAnimation(animationSource)

      // Invalidate the intrinsic size of the SwiftUI measurement container,
      // since any cached measurements will be out of date after updating the animation.
      container.invalidateIntrinsicContentSize()

      // Call the `animationDidLoad` asynchronously
      // to prevent consumers that update state in that callback from
      // causing SwiftUI to emit warnings about modifying state during view updates.
      DispatchQueue.main.async {
        animationDidLoad?(animationSource)
      }
    }

    if
      let playbackMode,
      playbackMode != view.currentPlaybackMode
    {
      view.setPlaybackMode(playbackMode, completion: animationCompletionHandler)
    }

    if
      let (imageProvider, imageProvidersAreEqual) = imageProviderConfiguration,
      !imageProvidersAreEqual(imageProvider, view.imageProvider)
    {
      view.imageProvider = imageProvider
    }

    if
      let animationSpeed,
      animationSpeed != view.animationSpeed
    {
      view.animationSpeed = animationSpeed
    }
  }
}

extension View {

  /// The `.overlay` modifier that uses a `ViewBuilder` is available in iOS 15+, this helper function helps us to use the same API in older OSs
  fileprivate func overlay(
    @ViewBuilder content: () -> some View
  ) -> some View {
    overlay(content(), alignment: .center)
  }
}

#endif
