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

import AVFoundation

class AVPlayerEngine: PlayerEngine {
  private let avPlayer = AVPlayer()

  private var timeControlObservation: NSKeyValueObservation?
  private var itemStatusObservation: NSKeyValueObservation?
  private var playbackLikelyObservation: NSKeyValueObservation?
  private var durationObservation: NSKeyValueObservation?
  private var timedMetadataObservation: NSKeyValueObservation?
  private var endOfTrackObserver: Any?
  private var failedToPlayObserver: Any?
  private var interruptionObserver: Any?
  private var routeChangeObserver: Any?

  // Whether to resume playback after an audio interruption ends.
  private var wasPlayingBeforeInterruption = false

  // Caching
  private let cache: AudioCache?
  let proxyServer: CacheProxyServer?
  private let _downloadCoordinator: DownloadCoordinator?

  /// Exposed so AudioPlayer can pass it to the Preloader.
  var downloadCoordinator: DownloadCoordinator? { _downloadCoordinator }

  /// Cache key for the currently loaded URL, used for cachedPosition.
  private(set) var currentCacheKey: String?

  /// Origin url + headers for a one-shot direct fallback. Non-nil exactly when
  /// the current load went through the cache proxy and a fallback has not yet
  /// been consumed. If a proxied load fails (e.g. the localhost proxy is
  /// suspended in the background), the engine retries once directly from the
  /// origin (uncached) instead of surfacing a failure. Cleared to nil for
  /// already-direct loads (file://, isLive, no-cache) and once consumed.
  private var pendingDirectFallback: (url: URL, headers: [String: String]?)?

  /// Monotonically increasing token incremented on every `load`/`reset`. Each
  /// item's observers capture the generation in effect when they were installed
  /// and ignore callbacks that arrive after the item has been superseded.
  ///
  /// AVFoundation can enqueue a KVO/notification block on the main queue for an
  /// item that is then replaced before the block runs — `invalidate()` /
  /// `removeObserver` cancel *future* deliveries but do not dequeue a block
  /// already scheduled on the run loop. Without this guard a stale failure from
  /// a superseded proxied item could cross-fire the fallback (or surface a
  /// spurious `onItemFailed`), and a stale `onItemReady`/`onItemPlayedToEnd`
  /// could fire for an item being torn down. All mutated on the main thread.
  private var loadGeneration: Int = 0

  // MARK: - Callbacks

  var onPlaybackStateChange: ((EnginePlaybackState) -> Void)?
  var onItemReady: (() -> Void)?
  var onItemFailed: ((_ code: String, _ message: String) -> Void)?
  var onItemPlayedToEnd: (() -> Void)?
  var onDurationChange: (() -> Void)?
  var onTimedMetadata: ((_ metadata: StreamMetadata) -> Void)?
  var onAssetMetadata: ((_ metadata: StreamMetadata) -> Void)?

  // MARK: - Properties

  var currentTime: Double {
    let seconds = avPlayer.currentTime().seconds
    return seconds.isNaN ? 0 : seconds
  }

  var duration: Double {
    if let seconds = avPlayer.currentItem?.duration.seconds, !seconds.isNaN {
      return seconds
    }
    if let range = avPlayer.currentItem?.seekableTimeRanges.last?.timeRangeValue,
       !range.duration.seconds.isNaN {
      return range.duration.seconds
    }
    return 0
  }

  var bufferedPosition: Double {
    avPlayer.currentItem?.loadedTimeRanges.last?.timeRangeValue.end.seconds ?? 0
  }

  var cachedPosition: Double {
    guard let cache = cache, let key = currentCacheKey else { return 0 }
    let bytes = cache.cachedBytes(for: key)
    guard bytes > 0 else { return 0 }

    if let info = cache.contentInfo(for: key), info.contentLength > 0 {
      let dur = duration
      guard dur > 0 else { return 0 }
      return (Double(bytes) / Double(info.contentLength)) * dur
    }

    // Content length unknown (download in progress, no response yet).
    // Fall back to bufferedPosition — since AVPlayer streams through
    // the proxy, what's buffered approximates what's cached.
    return bufferedPosition
  }

  var defaultRate: Float {
    get { avPlayer.defaultRate }
    set { avPlayer.defaultRate = newValue }
  }

  var currentRate: Float {
    get { avPlayer.rate }
    set { avPlayer.rate = newValue }
  }

  var volume: Float {
    get { avPlayer.volume }
    set { avPlayer.volume = newValue }
  }

  var isPlaying: Bool {
    avPlayer.timeControlStatus == .playing
  }

  var hasCurrentItem: Bool {
    avPlayer.currentItem != nil
  }

  private let handleAudioBecomingNoisy: Bool

  // MARK: - Init

  init(handleAudioBecomingNoisy: Bool = true, cache: AudioCache? = nil) {
    self.handleAudioBecomingNoisy = handleAudioBecomingNoisy
    self.cache = cache

    if let cache = cache {
      let coordinator = DownloadCoordinator(cache: cache)
      self._downloadCoordinator = coordinator
      self.proxyServer = try? CacheProxyServer(coordinator: coordinator, cache: cache)
      self.proxyServer?.start()
    } else {
      self._downloadCoordinator = nil
      self.proxyServer = nil
    }

    avPlayer.allowsExternalPlayback = true
    avPlayer.usesExternalPlaybackWhileExternalScreenIsActive = true
    observeTimeControlStatus()
    #if canImport(UIKit)
    observeAudioInterruptions()
    if handleAudioBecomingNoisy {
      observeRouteChanges()
    }
    #endif
  }

  deinit {
    proxyServer?.stop()
    timeControlObservation?.invalidate()
    removeItemObservers()
    if let observer = interruptionObserver {
      NotificationCenter.default.removeObserver(observer)
    }
    if let observer = routeChangeObserver {
      NotificationCenter.default.removeObserver(observer)
    }
  }

  // MARK: - Playback

  func play() {
    avPlayer.play()
  }

  func pause() {
    avPlayer.pause()
  }

  func load(url: URL, headers: [String: String]? = nil, isLive: Bool = false) {
    if !isLive, let proxy = proxyServer, let cache = cache,
       url.scheme?.hasPrefix("http") == true {
      // Best-effort caching: route through the localhost cache proxy. If this
      // load fails (e.g. the proxy is suspended in the background), arm a
      // one-shot direct fallback to the origin so playback is still guaranteed.
      let proxyURL = proxy.proxyURL(for: url, headers: headers)
      currentCacheKey = cache.cacheKey(for: url)
      pendingDirectFallback = (url: url, headers: headers)

      let asset = AVURLAsset(url: proxyURL)
      installItem(AVPlayerItem(asset: asset))
    } else {
      // Already-direct load (file://, isLive, or no-cache). Failures surface
      // immediately — there is no proxy to fall back from.
      loadDirect(url: url, headers: headers)
    }
  }

  /// Builds a direct (non-proxied) AVURLAsset and installs it as the current
  /// item. Reused by the normal direct `load()` branch and by the one-shot
  /// direct fallback. Clears `currentCacheKey` and arms no fallback context, so
  /// a subsequent failure surfaces normally.
  private func loadDirect(url: URL, headers: [String: String]?) {
    let asset: AVURLAsset
    if let headers = headers, !headers.isEmpty {
      asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
    } else {
      asset = AVURLAsset(url: url)
    }

    currentCacheKey = nil
    pendingDirectFallback = nil
    installItem(AVPlayerItem(asset: asset))
  }

  /// Swaps in `item` as the current item: bumps the load generation (so stale
  /// callbacks from the previous item are ignored), tears down the old item's
  /// observers, and installs fresh ones. Must run on the main thread.
  private func installItem(_ item: AVPlayerItem) {
    loadGeneration &+= 1
    removeItemObservers()
    avPlayer.replaceCurrentItem(with: item)
    observeItem(item, generation: loadGeneration)
  }

  func seek(to seconds: TimeInterval, completion: @escaping (Bool) -> Void) {
    let time = CMTimeMakeWithSeconds(seconds, preferredTimescale: 1000)
    avPlayer.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero, completionHandler: completion)
  }

  func reset() {
    currentCacheKey = nil
    pendingDirectFallback = nil
    // Bump the generation so any in-flight callback from the item being torn
    // down is ignored (it captured the previous generation).
    loadGeneration &+= 1
    removeItemObservers()
    avPlayer.replaceCurrentItem(with: nil)
  }

  // MARK: - Item Failure Handling

  /// Single choke point for item-failure surfacing. If a proxied load is still
  /// eligible for a one-shot direct fallback, retry the load directly from the
  /// origin (uncached) instead of surfacing the failure. Otherwise — including
  /// when the direct fallback itself fails — surface `onItemFailed`.
  ///
  /// `generation` is the load generation captured when the failing item's
  /// observers were installed. A failure whose generation no longer matches
  /// `loadGeneration` is stale (its item was already superseded) and is dropped,
  /// closing the window where an already-enqueued block from an old item runs
  /// after a swap.
  ///
  /// Note: the fallback re-loads from the start; playback position is not
  /// preserved. This is acceptable for v1. A proxy that hangs without ever
  /// failing the item (no `.failed` / `FailedToPlayToEndTime`) is out of scope —
  /// only item-level failures arm the fallback.
  ///
  /// Must run on the main thread (both failure observers dispatch to main).
  private func handleItemFailure(generation: Int, code: String, message: String) {
    guard generation == loadGeneration else { return }

    if let ctx = pendingDirectFallback {
      pendingDirectFallback = nil
      loadDirect(url: ctx.url, headers: ctx.headers)
      return
    }
    onItemFailed?(code, message)
  }

  // MARK: - Audio Interruption Handling

  #if canImport(UIKit)
  private func observeAudioInterruptions() {
    interruptionObserver = NotificationCenter.default.addObserver(
      forName: AVAudioSession.interruptionNotification,
      object: AVAudioSession.sharedInstance(),
      queue: .main
    ) { [weak self] notification in
      self?.handleInterruption(notification)
    }
  }

  private func handleInterruption(_ notification: Notification) {
    guard let userInfo = notification.userInfo,
          let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
          let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
      return
    }

    switch type {
    case .began:
      wasPlayingBeforeInterruption = isPlaying
      if isPlaying {
        pause()
      }

    case .ended:
      guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
      let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
      if options.contains(.shouldResume) && wasPlayingBeforeInterruption {
        play()
      }
      wasPlayingBeforeInterruption = false

    @unknown default:
      break
    }
  }

  // MARK: - Audio Route Change Handling

  private func observeRouteChanges() {
    routeChangeObserver = NotificationCenter.default.addObserver(
      forName: AVAudioSession.routeChangeNotification,
      object: AVAudioSession.sharedInstance(),
      queue: .main
    ) { [weak self] notification in
      self?.handleRouteChange(notification)
    }
  }

  private func handleRouteChange(_ notification: Notification) {
    guard let userInfo = notification.userInfo,
          let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
          let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
      return
    }

    if reason == .oldDeviceUnavailable {
      pause()
    }
  }
  #endif

  // MARK: - KVO: Player

  private func observeTimeControlStatus() {
    timeControlObservation = avPlayer.observe(\.timeControlStatus, options: [.new]) {
      [weak self] player, _ in
      DispatchQueue.main.async {
        switch player.timeControlStatus {
        case .playing:
          self?.onPlaybackStateChange?(.playing)
        case .paused:
          self?.onPlaybackStateChange?(.paused)
        case .waitingToPlayAtSpecifiedRate:
          self?.onPlaybackStateChange?(.waitingToPlay)
        @unknown default:
          break
        }
      }
    }
  }

  // MARK: - KVO: Player Item

  /// Installs all per-item observers, each capturing `generation` so a callback
  /// that fires after the item has been superseded is ignored. `.status` is
  /// observed with `.new` only (not `.initial`): the observer acts solely on
  /// `.readyToPlay` / `.failed`, both of which arrive as `.new`, so `.initial`
  /// (a synchronous, re-entrant fire of the freshly-created item's `.unknown`
  /// status) would only widen the re-entrancy surface during a fallback swap.
  private func observeItem(_ item: AVPlayerItem, generation: Int) {
    itemStatusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in
      DispatchQueue.main.async {
        guard let self = self, generation == self.loadGeneration else { return }
        switch item.status {
        case .readyToPlay:
          self.emitAssetMetadata(from: item)
        case .failed:
          let nsError = item.error as NSError?
          let code = Self.classifyError(nsError)
          let message = nsError?.localizedDescription ?? "Unknown playback error"
          self.handleItemFailure(generation: generation, code: code, message: message)
        default:
          break
        }
      }
    }

    playbackLikelyObservation = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) {
      [weak self] item, _ in
      DispatchQueue.main.async {
        guard let self = self, generation == self.loadGeneration else { return }
        if item.isPlaybackLikelyToKeepUp {
          self.onItemReady?()
        }
      }
    }

    durationObservation = item.observe(\.duration, options: [.new]) { [weak self] _, _ in
      DispatchQueue.main.async {
        guard let self = self, generation == self.loadGeneration else { return }
        self.onDurationChange?()
      }
    }

    timedMetadataObservation = item.observe(\.timedMetadata, options: [.new]) {
      [weak self] item, _ in
      guard let metadata = item.timedMetadata, !metadata.isEmpty else { return }
      let parsed = Self.parseTimedMetadata(metadata)
      guard !parsed.isEmpty else { return }
      DispatchQueue.main.async {
        guard let self = self, generation == self.loadGeneration else { return }
        self.onTimedMetadata?(parsed)
      }
    }

    endOfTrackObserver = NotificationCenter.default.addObserver(
      forName: .AVPlayerItemDidPlayToEndTime,
      object: item,
      queue: .main
    ) { [weak self] _ in
      guard let self = self, generation == self.loadGeneration else { return }
      self.onItemPlayedToEnd?()
    }

    failedToPlayObserver = NotificationCenter.default.addObserver(
      forName: .AVPlayerItemFailedToPlayToEndTime,
      object: item,
      queue: .main
    ) { [weak self] notification in
      guard let self = self else { return }
      let nsError = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
      let code = Self.classifyError(nsError)
      let message = nsError?.localizedDescription ?? "Playback failed"
      self.handleItemFailure(generation: generation, code: code, message: message)
    }
  }

  private func removeItemObservers() {
    itemStatusObservation?.invalidate()
    itemStatusObservation = nil
    playbackLikelyObservation?.invalidate()
    playbackLikelyObservation = nil
    durationObservation?.invalidate()
    durationObservation = nil
    timedMetadataObservation?.invalidate()
    timedMetadataObservation = nil
    if let observer = endOfTrackObserver {
      NotificationCenter.default.removeObserver(observer)
      endOfTrackObserver = nil
    }
    if let observer = failedToPlayObserver {
      NotificationCenter.default.removeObserver(observer)
      failedToPlayObserver = nil
    }
  }

  // MARK: - Asset Metadata

  /// Extracts static metadata (ID3, etc.) from the asset when the item becomes ready.
  private func emitAssetMetadata(from item: AVPlayerItem) {
    let asset = item.asset as! AVURLAsset
    asset.loadValuesAsynchronously(forKeys: ["commonMetadata"]) { [weak self] in
      var error: NSError?
      let status = asset.statusOfValue(forKey: "commonMetadata", error: &error)

      var title: String?
      var artist: String?
      var albumTitle: String?
      var genre: String?

      if status == .loaded {
        for meta in asset.commonMetadata {
          guard let commonKey = meta.commonKey else { continue }
          switch commonKey {
          case .commonKeyTitle:
            title = meta.stringValue
          case .commonKeyArtist:
            artist = meta.stringValue
          case .commonKeyAlbumName:
            albumTitle = meta.stringValue
          case .commonKeyType:
            genre = meta.stringValue
          default:
            break
          }
        }
      }

      let metadata = StreamMetadata(
        title: title,
        artist: artist,
        albumTitle: albumTitle,
        artworkUri: nil,
        genre: genre
      )
      DispatchQueue.main.async {
        self?.onAssetMetadata?(metadata)
      }
    }
  }


  // MARK: - Timed Metadata Parsing

  /// Parses ICY / ID3 timed metadata from a live stream into a `StreamMetadata`.
  static func parseTimedMetadata(_ items: [AVMetadataItem]) -> StreamMetadata {
    var title: String?
    var artist: String?
    var albumTitle: String?
    var artworkUri: String?
    var genre: String?

    for item in items {
      if let commonKey = item.commonKey {
        switch commonKey {
        case .commonKeyTitle:
          if let value = item.stringValue {
            let parsed = parseStreamTitle(value)
            title = parsed.title
            if artist == nil { artist = parsed.artist }
          }
        case .commonKeyArtist:
          if let value = item.stringValue { artist = value }
        case .commonKeyAlbumName:
          if let value = item.stringValue { albumTitle = value }
        case .commonKeyType:
          if let value = item.stringValue { genre = value }
        default:
          break
        }
        continue
      }

      if let key = item.key as? String {
        switch key.lowercased() {
        case "streamtitle":
          if let value = item.stringValue {
            let parsed = parseStreamTitle(value)
            title = parsed.title
            if artist == nil { artist = parsed.artist }
          }
        case "streamurl":
          if let value = item.stringValue, !value.isEmpty { artworkUri = value }
        default:
          break
        }
      }
    }

    return StreamMetadata(
      title: title,
      artist: artist,
      albumTitle: albumTitle,
      artworkUri: artworkUri,
      genre: genre
    )
  }

  // MARK: - Error Classification

  static func classifyError(_ error: NSError?) -> String {
    guard let error = error else { return "unknown" }

    if error.domain == NSURLErrorDomain {
      switch error.code {
      case NSURLErrorTimedOut,
           NSURLErrorCannotFindHost,
           NSURLErrorCannotConnectToHost,
           NSURLErrorNetworkConnectionLost,
           NSURLErrorDNSLookupFailed,
           NSURLErrorNotConnectedToInternet,
           NSURLErrorSecureConnectionFailed:
        return "network"
      default:
        return "source"
      }
    }

    return "unknown"
  }

  private static func parseStreamTitle(_ raw: String) -> (title: String?, artist: String?) {
    let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { return (nil, nil) }

    if let separatorRange = trimmed.range(of: " - ") {
      let artist = String(trimmed[trimmed.startIndex..<separatorRange.lowerBound])
        .trimmingCharacters(in: .whitespaces)
      let title = String(trimmed[separatorRange.upperBound...])
        .trimmingCharacters(in: .whitespaces)
      return (
        title.isEmpty ? nil : title,
        artist.isEmpty ? nil : artist
      )
    }

    return (trimmed, nil)
  }
}