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

import AVFoundation

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

  private var timeControlObservation: NSKeyValueObservation?
  private var itemStatusObservation: NSKeyValueObservation?
  private var playbackLikelyObservation: NSKeyValueObservation?
  private var durationObservation: NSKeyValueObservation?
  /// The current item's timed-metadata output; identity also guards stale callbacks.
  private var metadataOutput: AVPlayerItemMetadataOutput?
  private var cachedMetadataTimeObserver: Any?
  private var cachedMetadataEvents: [AudioCache.ICYMetadataEvent] = []
  private var cachedMetadataContentLength: Int64 = 0
  private var cachedMetadataCurrentIndex: Int?
  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

  private struct SourceContext {
    let url: URL
    let headers: [String: String]?
    let isLive: Bool
  }

  private struct LiveRefresh {
    var generation: Int
    var completions: [(Bool) -> Void]
  }

  private var activeSource: SourceContext?
  private var liveRefresh: LiveRefresh?
  private var metadataOutputGeneration: Int?

  // Internal deterministic seam used by PlayerCoreTests. Production instances
  // leave both closures nil and exercise AVFoundation directly.
  private var testSeekableRanges: (() -> [CMTimeRange])?
  private var testSourceLoader: ((URL, [String: String]?, Bool, Int) -> Void)?

  static func liveEdgeTarget(for range: CMTimeRange) -> Double? {
    let start = range.start.seconds
    let duration = range.duration.seconds
    guard start.isFinite, duration.isFinite, duration >= 0 else { return nil }
    let end = range.end.seconds
    let target = max(start, end - 0.5)
    return target.isFinite ? target : nil
  }

  func configureLiveEdgeTestSeam(
    seekableRanges: @escaping () -> [CMTimeRange],
    onLoad: @escaping (URL, [String: String]?, Bool, Int) -> Void
  ) {
    testSeekableRanges = seekableRanges
    testSourceLoader = onLoad
  }

  func simulateItemReadyForTesting(generation: Int) {
    handleItemReady(generation: generation)
  }

  func simulateItemFailureForTesting(generation: Int) {
    handleItemFailure(generation: generation, code: "test_error", message: "Test failure")
  }

  func simulateTimedMetadataForTesting(_ metadata: StreamMetadata, generation: Int) {
    handleTimedMetadata(metadata, generation: generation)
  }

  // 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)
    } else {
      self._downloadCoordinator = nil
      self.proxyServer = nil
    }

    super.init()

    proxyServer?.start()

    // Coordinator callbacks arrive off-main; current item state is main-only.
    _downloadCoordinator?.onICYMetadata = { [weak self] cacheKey, metadata in
      DispatchQueue.main.async {
        guard let self, cacheKey == self.currentCacheKey else { return }
        self.onTimedMetadata?(metadata)
      }
    }

    // Fall back to AVPlayer when the proxy cannot safely frame an ICY response.
    _downloadCoordinator?.onDirectFallbackRequired = { [weak self] cacheKey in
      DispatchQueue.main.async {
        guard let self, cacheKey == self.currentCacheKey,
              let ctx = self.pendingDirectFallback else { return }
        self.pendingDirectFallback = nil
        self.loadDirect(url: ctx.url, headers: ctx.headers)
      }
    }

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

  deinit {
    proxyServer?.stop()
    _downloadCoordinator?.invalidate()
    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) {
    cancelLiveRefresh()
    activeSource = SourceContext(url: url, headers: headers, isLive: isLive)
    loadSource(url: url, headers: headers, isLive: isLive)
  }

  private func loadSource(url: URL, headers: [String: String]?, isLive: Bool) {
    if let testSourceLoader {
      loadGeneration &+= 1
      testSourceLoader(url, headers, isLive, loadGeneration)
      return
    }
    if let proxy = proxyServer, let cache = cache,
       url.scheme?.hasPrefix("http") == true {
      // Live resources use the in-memory proxy relay; finite resources use disk.
      let proxyURL = proxy.proxyURL(
        for: url,
        headers: headers,
        cacheable: !isLive,
        liveStream: isLive
      )
      let cacheKey = cache.cacheKey(for: url)
      currentCacheKey = cacheKey
      pendingDirectFallback = (url: url, headers: headers)

      let asset = AVURLAsset(url: proxyURL)
      installItem(AVPlayerItem(asset: asset))
      prepareCachedMetadataReplay(for: cacheKey)
    } else {
      // File URLs and configurations without a cache stay direct. Failures
      // surface immediately because there is no proxy fallback context.
      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]?) {
    // Ask the origin to interleave ICY metadata (matches media3) unless the
    // caller opted out; AVPlayer parses it natively on the direct path.
    var assetHeaders = headers ?? [:]
    let callerSetIcy = assetHeaders.keys.contains {
      $0.caseInsensitiveCompare("icy-metadata") == .orderedSame
    }
    if !callerSetIcy {
      assetHeaders["Icy-MetaData"] = "1"
    }
    let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": assetHeaders])

    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)
  }

  private func prepareCachedMetadataReplay(for key: String) {
    guard let cache,
          cache.isFullyCached(key: key),
          let info = cache.contentInfo(for: key),
          info.isICY == true,
          info.contentLength > 0 else { return }
    let events = cache.icyMetadata(for: key)
    guard !events.isEmpty else { return }

    cachedMetadataEvents = events
    cachedMetadataContentLength = info.contentLength
    cachedMetadataCurrentIndex = nil
    cachedMetadataTimeObserver = avPlayer.addPeriodicTimeObserver(
      forInterval: CMTime(seconds: 0.1, preferredTimescale: 600),
      queue: .main
    ) { [weak self] time in
      self?.replayCachedMetadata(at: time.seconds)
    }
  }

  private func replayCachedMetadata(at seconds: Double) {
    let itemDuration = duration
    guard itemDuration > 0, seconds.isFinite else { return }
    let clamped = min(max(seconds / itemDuration, 0), 1)
    let byteOffset = Int64(clamped * Double(cachedMetadataContentLength))
    var lowerBound = 0
    var upperBound = cachedMetadataEvents.count
    while lowerBound < upperBound {
      let middle = lowerBound + (upperBound - lowerBound) / 2
      if cachedMetadataEvents[middle].audioByteOffset <= byteOffset {
        lowerBound = middle + 1
      } else {
        upperBound = middle
      }
    }
    let index = lowerBound > 0 ? lowerBound - 1 : nil
    guard index != cachedMetadataCurrentIndex else { return }
    cachedMetadataCurrentIndex = index
    if let index {
      onTimedMetadata?(cachedMetadataEvents[index].metadata)
    }
  }

  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 seekToLiveEdge(completion: @escaping (Bool) -> Void) {
    let ranges = testSeekableRanges?() ?? avPlayer.currentItem?.seekableTimeRanges.map(\.timeRangeValue) ?? []
    if let range = ranges.last,
       let target = Self.liveEdgeTarget(for: range) {
      seek(to: target, completion: completion)
      return
    }
    refreshLiveSource(completion: completion)
  }

  private func refreshLiveSource(completion: @escaping (Bool) -> Void) {
    if liveRefresh != nil {
      liveRefresh?.completions.append(completion)
      return
    }
    guard let source = activeSource, source.isLive else {
      completion(false)
      return
    }
    liveRefresh = LiveRefresh(generation: -1, completions: [completion])
    loadSource(url: source.url, headers: source.headers, isLive: source.isLive)
    liveRefresh?.generation = loadGeneration
  }

  private func finishLiveRefresh(generation: Int, success: Bool) {
    guard let refresh = liveRefresh, refresh.generation == generation else { return }
    liveRefresh = nil
    refresh.completions.forEach { $0(success) }
  }

  private func cancelLiveRefresh() {
    guard let refresh = liveRefresh else { return }
    liveRefresh = nil
    refresh.completions.forEach { $0(false) }
  }

  func reset() {
    activeSource = nil
    cancelLiveRefresh()
    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 }
    finishLiveRefresh(generation: generation, success: false)

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

  private func handleItemReady(generation: Int) {
    guard generation == loadGeneration else { return }
    finishLiveRefresh(generation: generation, success: true)
    onItemReady?()
  }

  private func handleTimedMetadata(_ metadata: StreamMetadata, generation: Int) {
    guard generation == loadGeneration else { return }
    onTimedMetadata?(metadata)
  }

  // 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, generation: generation)
        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.handleItemReady(generation: generation)
        }
      }
    }

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

    // The deprecated timedMetadata KVO path misses repeated ICY updates (#2661).
    let metadataOutput = AVPlayerItemMetadataOutput(identifiers: nil)
    metadataOutput.setDelegate(self, queue: .main)
    item.add(metadataOutput)
    self.metadataOutput = metadataOutput
    metadataOutputGeneration = generation

    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() {
    if let observer = cachedMetadataTimeObserver {
      avPlayer.removeTimeObserver(observer)
      cachedMetadataTimeObserver = nil
    }
    cachedMetadataEvents = []
    cachedMetadataContentLength = 0
    cachedMetadataCurrentIndex = nil
    itemStatusObservation?.invalidate()
    itemStatusObservation = nil
    playbackLikelyObservation?.invalidate()
    playbackLikelyObservation = nil
    durationObservation?.invalidate()
    durationObservation = nil
    if let metadataOutput = metadataOutput {
      avPlayer.currentItem?.remove(metadataOutput)
      metadataOutput.setDelegate(nil, queue: nil)
      self.metadataOutput = nil
      metadataOutputGeneration = nil
    }
    if let observer = endOfTrackObserver {
      NotificationCenter.default.removeObserver(observer)
      endOfTrackObserver = nil
    }
    if let observer = failedToPlayObserver {
      NotificationCenter.default.removeObserver(observer)
      failedToPlayObserver = nil
    }
  }

  // MARK: - AVPlayerItemMetadataOutputPushDelegate

  func metadataOutput(_ output: AVPlayerItemMetadataOutput,
                      didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup],
                      from track: AVPlayerItemTrack?) {
    guard output === metadataOutput, let generation = metadataOutputGeneration else { return }
    let items = groups.flatMap { $0.items }
    guard !items.isEmpty else { return }
    let parsed = Self.parseTimedMetadata(items)
    guard !parsed.isEmpty else { return }
    handleTimedMetadata(parsed, generation: generation)
  }

  // MARK: - Asset Metadata

  /// Extracts static metadata (ID3, etc.) from the asset when the item becomes ready.
  private func emitAssetMetadata(from item: AVPlayerItem, generation: Int) {
    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 {
        // The item may have changed while metadata loaded asynchronously.
        guard let self, generation == self.loadGeneration else { return }
        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"
  }

  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)
  }
}
