//
// Copyright (c) Double Symmetry GmbH
// Commercial use requires a license. See https://rntp.dev/pricing
//

import AVFoundation
import Foundation

#if canImport(UIKit)
import MediaPlayer
#endif

class AudioPlayer {
  // MARK: - Callbacks

  var onStateChange: ((PlayerState) -> Void)?
  var onCurrentItemChanged: ((AudioItem?, Int) -> Void)?
  var onMetadataReceived: ((_ metadata: StreamMetadata) -> Void)?
  var onPlaybackError: ((_ code: String, _ message: String) -> Void)?

  // MARK: - Private Properties

  private let engine: PlayerEngine
  private let queue = QueueManager()
  private let cache: AudioCache?
  private(set) var preloader: Preloader?
  #if canImport(UIKit)
  private let artworkCache = NSCache<NSString, UIImage>()
  #endif

  var preloadWindow: Int = 0

  private static let previousThreshold: Double = 3.0
  private var lastEmittedMetadata: StreamMetadata?
  private var pendingItemMetadata: StreamMetadata?

  // MARK: - State

  private(set) var state: PlayerState = .idle {
    didSet {
      guard oldValue != state else { return }
      onStateChange?(state)
      updateNowPlayingPlaybackInfo()
    }
  }

  var repeatMode: RepeatMode = .off

  var shuffleEnabled: Bool {
    get { queue.isShuffled }
    set { queue.setShuffle(enabled: newValue) }
  }

  // MARK: - Computed Properties

  var currentTime: Double { engine.currentTime }
  var duration: Double { engine.duration }
  var bufferedPosition: Double { engine.bufferedPosition }
  var cachedPosition: Double { engine.cachedPosition }
  var currentItem: AudioItem? { queue.current }
  var currentIndex: Int { queue.currentIndex }
  var items: [AudioItem] { queue.items }

  var volume: Float {
    get { engine.volume }
    set { engine.volume = max(0, min(1, newValue)) }
  }

  /// Playback speed (1.0 = normal). Uses `defaultRate` so `play()`/`pause()` just work.
  var rate: Float {
    get { engine.defaultRate }
    set {
      engine.defaultRate = newValue
      if engine.isPlaying {
        engine.currentRate = newValue
      }
      updateNowPlayingPlaybackInfo()
    }
  }

  // MARK: - Init

  init(engine: PlayerEngine, cache: AudioCache? = nil, coordinator: DownloadCoordinator? = nil) {
    self.engine = engine
    self.cache = cache
    if let cache = cache, let coordinator = coordinator {
      self.preloader = Preloader(cache: cache, coordinator: coordinator)
    }
    coordinator?.onDownloadComplete = { [weak self] key, isFull in
      guard let self = self, isFull else { return }
      if let engine = self.engine as? AVPlayerEngine,
         engine.currentCacheKey == key {
        DispatchQueue.main.async {
          self.triggerAutoPreloadIfNeeded()
        }
      }
    }
    setupEngineCallbacks()
  }

  #if os(iOS)
  convenience init(handleAudioBecomingNoisy: Bool = true, cache: AudioCache? = nil) {
    let engine = AVPlayerEngine(handleAudioBecomingNoisy: handleAudioBecomingNoisy, cache: cache)
    self.init(
      engine: engine,
      cache: cache,
      coordinator: engine.downloadCoordinator
    )
  }
  #endif

  private func setupEngineCallbacks() {
    engine.onPlaybackStateChange = { [weak self] status in
      self?.handlePlaybackStateChange(status)
    }
    engine.onItemReady = { [weak self] in
      guard let self = self, self.state == .loading else { return }
      self.state = .ready
    }
    engine.onItemFailed = { [weak self] code, message in
      self?.onPlaybackError?(code, message)
      self?.state = .failed
    }
    engine.onItemPlayedToEnd = { [weak self] in
      self?.handleItemDidPlayToEnd()
    }
    engine.onDurationChange = { [weak self] in
      self?.updateNowPlayingPlaybackInfo()
    }
    engine.onTimedMetadata = { [weak self] metadata in
      self?.handleTimedMetadata(metadata)
    }
    engine.onAssetMetadata = { [weak self] assetMetadata in
      self?.handleAssetMetadata(assetMetadata)
    }
  }

  // MARK: - Playback

  func play() {
    activateAudioSession()
    engine.play()
  }

  func pause() {
    engine.pause()
  }

  func stop() {
    engine.pause()
    engine.reset()
    state = .idle
    clearNowPlayingInfo()
    deactivateAudioSession()
  }

  func seek(to seconds: TimeInterval) {
    engine.seek(to: seconds) { [weak self] _ in
      self?.updateNowPlayingPlaybackInfo()
    }
  }

  /// Re-prepares the current media item after a failure. No-op if not in failed state.
  func retry() {
    guard state == .failed, let item = queue.current else { return }

    let url: URL?
    switch item.sourceType {
    case .file:  url = URL(fileURLWithPath: item.sourceUrl)
    case .stream: url = URL(string: item.sourceUrl)
    }

    guard let itemUrl = url else { return }

    engine.load(url: itemUrl, headers: item.headers, isLive: item.isLive)
    state = .loading
  }

  // MARK: - Queue: Load / Set
  // Note: Any method that changes the queue or current item must call
  // triggerAutoPreloadIfNeeded() so the preload window stays in sync.

  func load(item: AudioItem) {
    queue.replaceCurrentItem(with: item)
    loadCurrentItem()
  }

  func add(items newItems: [AudioItem]) {
    let wasEmpty = queue.items.isEmpty
    queue.add(newItems)
    if wasEmpty && !queue.items.isEmpty {
      try? queue.jump(to: 0)
      loadCurrentItem()
    }
    triggerAutoPreloadIfNeeded()
  }

  func add(items newItems: [AudioItem], at index: Int) throws {
    let wasEmpty = queue.items.isEmpty
    try queue.add(newItems, at: index)
    if wasEmpty && !queue.items.isEmpty {
      try queue.jump(to: 0)
      loadCurrentItem()
    }
    triggerAutoPreloadIfNeeded()
  }

  // MARK: - Queue: Navigate

  func next() {
    guard queue.next(wrap: repeatMode == .queue) else { return }
    loadCurrentItem()
    play()
  }

  /// If past the threshold, restarts the current track; otherwise goes to previous.
  func previous() {
    if currentTime > AudioPlayer.previousThreshold {
      seek(to: 0)
      return
    }
    guard queue.previous(wrap: repeatMode == .queue) else { return }
    loadCurrentItem()
    play()
  }

  func skipTo(index: Int) {
    guard index >= 0 && index < queue.items.count else { return }
    try? queue.jump(to: index)
    loadCurrentItem()
    play()
  }

  // MARK: - Queue: Replace & Update

  func replaceItem(at index: Int, with newItem: AudioItem) {
    guard index >= 0 && index < queue.items.count else { return }

    if index == queue.currentIndex {
      let sameUrl = queue.current?.sourceUrl == newItem.sourceUrl
      if sameUrl {
        try? queue.replaceItem(at: index, with: newItem)
        updateNowPlayingMetadata(for: newItem)
      } else {
        let wasPlaying = state == .playing
        try? queue.replaceItem(at: index, with: newItem)
        loadCurrentItem()
        if wasPlaying { play() }
      }
    } else {
      try? queue.replaceItem(at: index, with: newItem)
      triggerAutoPreloadIfNeeded()
    }
  }

  func updateMetadata(at index: Int, with updatedItem: AudioItem) {
    guard index >= 0 && index < queue.items.count else { return }
    try? queue.replaceItem(at: index, with: updatedItem)
    if index == queue.currentIndex {
      updateNowPlayingMetadata(for: updatedItem)
    }
  }

  // MARK: - Queue: Remove

  func removeItem(at index: Int) throws {
    let wasCurrentIndex = index == queue.currentIndex
    let wasPlaying = state == .playing
    try queue.removeItem(at: index)
    if wasCurrentIndex {
      if queue.current != nil {
        loadCurrentItem()
        if wasPlaying { play() }
      } else {
        engine.reset()
        state = .idle
        clearNowPlayingInfo()
      }
    } else {
      triggerAutoPreloadIfNeeded()
    }
  }

  func clear() {
    let hadCurrentItem = queue.current != nil
    queue.clear()
    engine.reset()
    state = .idle
    clearNowPlayingInfo()
    deactivateAudioSession()
    // Notify listeners the active item is now nil so they reflect the empty queue.
    if hadCurrentItem {
      onCurrentItemChanged?(nil, queue.currentIndex)
    }
  }

  /// Cancel all active downloads (preloads and in-progress streaming).
  func cancelAllDownloads() {
    preloader?.cancelAll()
    if let engine = engine as? AVPlayerEngine {
      engine.downloadCoordinator?.cancelAll()
    }
  }

  // MARK: - Destroy

  func destroy() {
    preloader?.cancelAll()
    clear()
    onStateChange = nil
    onCurrentItemChanged = nil
    onMetadataReceived = nil
    onPlaybackError = nil
    #if canImport(UIKit)
    artworkCache.removeAllObjects()
    #endif
  }

  // MARK: - Internal: Load Current Item

  private func loadCurrentItem() {
    guard let item = queue.current else {
      engine.reset()
      state = .idle
      clearNowPlayingInfo()
      deactivateAudioSession()
      return
    }

    let url: URL?
    switch item.sourceType {
    case .file:  url = URL(fileURLWithPath: item.sourceUrl)
    case .stream: url = URL(string: item.sourceUrl)
    }

    guard let itemUrl = url else {
      state = .failed
      return
    }

    lastEmittedMetadata = nil
    pendingItemMetadata = StreamMetadata(
      title: item.title,
      artist: item.artist,
      albumTitle: item.albumTitle,
      artworkUri: nil,
      genre: nil
    )
    engine.load(url: itemUrl, headers: item.headers, isLive: item.isLive)
    state = .loading
    updateNowPlayingMetadata(for: item)
    onCurrentItemChanged?(item, queue.currentIndex)
    triggerAutoPreloadIfNeeded()
  }

  /// Check if auto preloading should trigger and start it if so.
  /// Preloads run when the current item is live, a local file, fully cached
  /// on disk, or when the queue changes while one of those is already true.
  private func triggerAutoPreloadIfNeeded() {
    guard preloadWindow > 0, let preloader = preloader else { return }

    let shouldPreload: Bool
    if let current = queue.current, (current.isLive || current.sourceType == .file) {
      shouldPreload = true
    } else if let engine = engine as? AVPlayerEngine,
              let key = engine.currentCacheKey,
              let cache = cache, cache.isFullyCached(key: key) {
      shouldPreload = true
    } else {
      shouldPreload = false
    }
    guard shouldPreload else { return }

    preloader.cancelAll()

    let startIndex = queue.currentIndex + 1
    let endIndex = min(startIndex + preloadWindow, queue.items.count)
    guard startIndex < endIndex else { return }

    for i in startIndex..<endIndex {
      let item = queue.items[i]
      guard !item.isLive, item.sourceType == .stream,
            let url = URL(string: item.sourceUrl) else { continue }
      preloader.preload(url: url, headers: item.headers)
    }
  }

  // MARK: - State Handling

  private func handlePlaybackStateChange(_ status: EnginePlaybackState) {
    switch status {
    case .paused:
      if !engine.hasCurrentItem {
        if state != .idle { state = .idle }
      } else if state != .failed && state != .ended && state != .loading {
        state = .paused
      }

    case .waitingToPlay:
      if engine.hasCurrentItem {
        state = .buffering
      }

    case .playing:
      state = .playing
    }
  }

  // MARK: - Metadata

  /// Merges asset-extracted metadata (ID3, etc.) with app-provided metadata and
  /// emits a single metadata-received event. Asset values win where present.
  private func handleAssetMetadata(_ assetMetadata: StreamMetadata) {
    let app = pendingItemMetadata
    pendingItemMetadata = nil
    let merged = StreamMetadata(
      title: assetMetadata.title ?? app?.title,
      artist: assetMetadata.artist ?? app?.artist,
      albumTitle: assetMetadata.albumTitle ?? app?.albumTitle,
      artworkUri: assetMetadata.artworkUri ?? app?.artworkUri,
      genre: assetMetadata.genre ?? app?.genre
    )
    guard !merged.isEmpty else { return }
    handleTimedMetadata(merged)
  }

  private func handleTimedMetadata(_ metadata: StreamMetadata) {
    guard metadata != lastEmittedMetadata else { return }
    lastEmittedMetadata = metadata
    onMetadataReceived?(metadata)
  }

  // MARK: - End of Track

  private func handleItemDidPlayToEnd() {
    if repeatMode == .track {
      engine.pause()
      DispatchQueue.main.asyncAfter(deadline: .now() + 0.032) { [weak self] in
        self?.seek(to: 0)
        self?.engine.play()
      }
    } else if queue.currentIndex < queue.items.count - 1 || repeatMode == .queue {
      queue.next(wrap: repeatMode == .queue)
      loadCurrentItem()
      play()
    } else {
      state = .ended
      deactivateAudioSession()
    }
  }

  // MARK: - Now Playing Info

  private func updateNowPlayingMetadata(for item: AudioItem) {
    #if canImport(UIKit)
    var info: [String: Any] = [:]
    if let title = item.title { info[MPMediaItemPropertyTitle] = title }
    if let artist = item.artist { info[MPMediaItemPropertyArtist] = artist }
    if let albumTitle = item.albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle }
    info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0
    info[MPNowPlayingInfoPropertyPlaybackRate] = 0
    MPNowPlayingInfoCenter.default().nowPlayingInfo = info

    loadArtwork(for: item)
    #endif
  }

  #if canImport(UIKit)
  private func loadArtwork(for item: AudioItem) {
    item.getArtwork { [weak self] image in
      guard let image = image else { return }
      DispatchQueue.main.async {
        guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
        let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
        info[MPMediaItemPropertyArtwork] = artwork
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
      }
    }
  }
  #endif

  private func updateNowPlayingPlaybackInfo() {
    #if canImport(UIKit)
    guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
    info[MPMediaItemPropertyPlaybackDuration] = duration
    info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
    info[MPNowPlayingInfoPropertyPlaybackRate] = state == .playing ? Double(rate) : 0.0
    MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    #endif
  }

  private func clearNowPlayingInfo() {
    #if canImport(UIKit)
    MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
    #endif
  }

  // MARK: - Audio Session

  private func activateAudioSession() {
    #if canImport(UIKit)
    try? AVAudioSession.sharedInstance().setActive(true)
    #endif
  }

  private func deactivateAudioSession() {
    #if canImport(UIKit)
    try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
    #endif
  }
}
