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

import XCTest

@testable import PlayerCore

private struct MockItem: AudioItem {
  var sourceUrl: String
  var sourceType: SourceType = .stream
  var headers: [String: String]?
  var isLive: Bool = false
  var title: String?
  var artist: String?
  var albumTitle: String?

  init(_ id: String) {
    self.sourceUrl = "https://example.com/\(id).mp3"
    self.title = id
  }

  #if canImport(UIKit)
  func getArtwork(_ handler: @escaping (UIImage?) -> Void) {
    handler(nil)
  }
  #endif
}

private func item(_ id: String) -> MockItem { MockItem(id) }

private func localItem(_ path: String) -> MockItem {
  var mock = MockItem("local")
  mock.sourceUrl = path
  mock.sourceType = .file
  return mock
}

final class AudioPlayerTests: XCTestCase {
  private var engine: MockPlayerEngine!
  private var player: AudioPlayer!

  override func setUp() {
    super.setUp()
    engine = MockPlayerEngine()
    player = AudioPlayer(engine: engine)
  }

  // MARK: - Initial State

  func testInitialState() {
    XCTAssertEqual(player.state, .idle)
    XCTAssertNil(player.currentItem)
    XCTAssertEqual(player.currentIndex, -1)
    XCTAssertTrue(player.items.isEmpty)
  }

  // MARK: - Load

  func testLoadSetsLoadingState() {
    player.load(item: item("a"))

    XCTAssertEqual(player.state, .loading)
    XCTAssertEqual(engine.loadedURLs.count, 1)
    XCTAssertEqual(player.currentItem?.title, "a")
  }

  func testLoadFiresCurrentItemChanged() {
    var receivedTitle: String?
    var receivedIndex: Int?
    player.onCurrentItemChanged = { item, index in
      receivedTitle = item?.title
      receivedIndex = index
    }

    player.load(item: item("a"))

    XCTAssertEqual(receivedTitle, "a")
    XCTAssertEqual(receivedIndex, 0)
  }

  func testLoadThenPlay() {
    player.load(item: item("a"))
    player.play()

    XCTAssertEqual(engine.playCallCount, 1)
  }

  func testLoadDoesNotAutoPlay() {
    player.load(item: item("a"))

    XCTAssertEqual(engine.playCallCount, 0)
  }

  // MARK: - Local File Dispatch

  func testLoadLocalFileUsesFileURL() {
    player.load(item: localItem("/tmp/song.mp3"))

    XCTAssertEqual(engine.loadedURLs.count, 1)
    let loaded = engine.loadedURLs[0]
    XCTAssertTrue(loaded.isFileURL)
    XCTAssertEqual(loaded.path, "/tmp/song.mp3")
  }

  func testLoadRemoteUsesStringURL() {
    player.load(item: item("a"))

    XCTAssertEqual(engine.loadedURLs.count, 1)
    let loaded = engine.loadedURLs[0]
    XCTAssertFalse(loaded.isFileURL)
    XCTAssertEqual(loaded.absoluteString, "https://example.com/a.mp3")
  }

  // MARK: - Item Ready

  func testItemReadyTransitionsFromLoading() {
    player.load(item: item("a"))
    XCTAssertEqual(player.state, .loading)

    engine.simulateItemReady()

    XCTAssertEqual(player.state, .ready)
  }

  func testItemReadyIgnoredWhenNotLoading() {
    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)
    XCTAssertEqual(player.state, .playing)

    engine.simulateItemReady()

    XCTAssertEqual(player.state, .playing)
  }

  // MARK: - Playback Controls

  func testPlayCallsEngine() {
    player.load(item: item("a"))
    player.play()

    XCTAssertEqual(engine.playCallCount, 1)
  }

  func testPauseCallsEngine() {
    player.load(item: item("a"))
    player.pause()

    XCTAssertEqual(engine.pauseCallCount, 1)
  }

  func testSeekCallsEngine() {
    player.load(item: item("a"))
    player.seek(to: 30.0)

    XCTAssertEqual(engine.seekTargets, [30.0])
  }

  // MARK: - Playback State Changes

  func testPlayingStateChange() {
    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)

    XCTAssertEqual(player.state, .playing)
  }

  func testPausedStateChange() {
    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)
    engine.simulatePlaybackState(.paused)

    XCTAssertEqual(player.state, .paused)
  }

  func testBufferingStateChange() {
    player.load(item: item("a"))
    engine.simulatePlaybackState(.waitingToPlay)

    XCTAssertEqual(player.state, .buffering)
  }

  func testPausedWithNoItemStaysIdle() {
    engine.hasCurrentItem = false
    engine.simulatePlaybackState(.paused)

    XCTAssertEqual(player.state, .idle)
  }

  func testPausedPreservesFailed() {
    player.load(item: item("a"))
    engine.simulateItemFailed()
    XCTAssertEqual(player.state, .failed)

    engine.simulatePlaybackState(.paused)

    XCTAssertEqual(player.state, .failed)
  }

  func testPausedPreservesEnded() {
    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)
    engine.simulatePlayedToEnd()
    XCTAssertEqual(player.state, .ended)

    engine.simulatePlaybackState(.paused)

    XCTAssertEqual(player.state, .ended)
  }

  func testPausedPreservesLoading() {
    player.load(item: item("a"))
    XCTAssertEqual(player.state, .loading)

    engine.simulatePlaybackState(.paused)

    XCTAssertEqual(player.state, .loading)
  }

  // MARK: - Stop

  func testStop() {
    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)

    player.stop()

    XCTAssertEqual(player.state, .idle)
    XCTAssertEqual(engine.pauseCallCount, 1)
    XCTAssertEqual(engine.resetCallCount, 1)
  }

  // MARK: - Item Failed

  func testItemFailed() {
    player.load(item: item("a"))
    engine.simulateItemFailed()

    XCTAssertEqual(player.state, .failed)
  }

  // MARK: - State Change Callback

  func testStateChangeCallbackFiresInOrder() {
    var states: [PlayerState] = []
    player.onStateChange = { states.append($0) }

    player.load(item: item("a"))
    engine.simulateItemReady()
    engine.simulatePlaybackState(.playing)
    engine.simulatePlaybackState(.paused)

    XCTAssertEqual(states, [.loading, .ready, .playing, .paused])
  }

  func testStateChangeDoesNotFireForDuplicates() {
    var callCount = 0
    player.onStateChange = { _ in callCount += 1 }

    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)
    engine.simulatePlaybackState(.playing)

    XCTAssertEqual(callCount, 2, "loading + playing, no duplicate playing")
  }

  // MARK: - End of Track: Repeat Off

  func testEndOfTrackLastItemRepeatOff() {
    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)
    let playCountBefore = engine.playCallCount

    engine.simulatePlayedToEnd()

    XCTAssertEqual(player.state, .ended)
    XCTAssertEqual(engine.playCallCount, playCountBefore, "does not call play when queue is exhausted")
  }

  func testEndOfTrackMidQueueAdvances() {
    player.add(items: [item("a"), item("b"), item("c")])
    engine.simulatePlaybackState(.playing)

    engine.simulatePlayedToEnd()

    XCTAssertEqual(player.currentIndex, 1)
    XCTAssertEqual(player.currentItem?.title, "b")
    XCTAssertEqual(player.state, .loading)
    XCTAssertEqual(engine.playCallCount, 1, "auto-plays next track")
  }

  func testEndOfTrackAdvancesThroughQueue() {
    player.add(items: [item("a"), item("b"), item("c")])
    engine.simulatePlaybackState(.playing)

    engine.simulatePlayedToEnd()
    XCTAssertEqual(player.currentItem?.title, "b")

    engine.simulatePlaybackState(.playing)
    engine.simulatePlayedToEnd()
    XCTAssertEqual(player.currentItem?.title, "c")

    engine.simulatePlaybackState(.playing)
    engine.simulatePlayedToEnd()
    XCTAssertEqual(player.state, .ended, "ends after last track")
  }

  // MARK: - End of Track: Repeat Queue

  func testEndOfTrackRepeatQueueWraps() {
    player.repeatMode = .queue
    player.add(items: [item("a"), item("b")])
    engine.simulatePlaybackState(.playing)

    player.next()
    engine.simulatePlaybackState(.playing)
    XCTAssertEqual(player.currentItem?.title, "b")

    engine.simulatePlayedToEnd()

    XCTAssertEqual(player.currentIndex, 0)
    XCTAssertEqual(player.currentItem?.title, "a")
    XCTAssertEqual(player.state, .loading)
  }

  // MARK: - End of Track: Repeat Track

  func testEndOfTrackRepeatTrackReplays() {
    player.repeatMode = .track
    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)

    engine.simulatePlayedToEnd()

    XCTAssertEqual(engine.pauseCallCount, 1)
    XCTAssertEqual(player.currentIndex, 0, "stays on same item")

    let expectation = expectation(description: "repeat track replays")
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
      XCTAssertTrue(self.engine.seekTargets.contains(0))
      XCTAssertEqual(self.engine.playCallCount, 1, "replays the track")
      expectation.fulfill()
    }
    wait(for: [expectation], timeout: 1.0)
  }

  // MARK: - Next / Previous

  func testNextAdvances() {
    player.add(items: [item("a"), item("b"), item("c")])

    player.next()

    XCTAssertEqual(player.currentIndex, 1)
    XCTAssertEqual(player.currentItem?.title, "b")
    XCTAssertEqual(engine.playCallCount, 1, "plays when navigating")
  }

  func testNextAtEndRepeatOffStays() {
    player.add(items: [item("a"), item("b")])
    player.next()
    let loadCount = engine.loadedURLs.count

    player.next()

    XCTAssertEqual(player.currentIndex, 1, "stays at last")
    XCTAssertEqual(engine.loadedURLs.count, loadCount, "does not reload")
  }

  func testNextAtEndRepeatQueueWraps() {
    player.repeatMode = .queue
    player.add(items: [item("a"), item("b")])
    player.next()

    player.next()

    XCTAssertEqual(player.currentIndex, 0)
    XCTAssertEqual(player.currentItem?.title, "a")
  }

  func testPreviousGoesBack() {
    player.add(items: [item("a"), item("b"), item("c")])
    player.next()
    player.next()
    let playCountBefore = engine.playCallCount

    player.previous()

    XCTAssertEqual(player.currentIndex, 1)
    XCTAssertEqual(player.currentItem?.title, "b")
    XCTAssertEqual(engine.playCallCount, playCountBefore + 1, "auto-plays when navigating back")
  }

  func testPreviousAtStartRepeatOffStays() {
    player.add(items: [item("a"), item("b")])
    let loadCount = engine.loadedURLs.count

    player.previous()

    XCTAssertEqual(player.currentIndex, 0, "stays at first")
    XCTAssertEqual(engine.loadedURLs.count, loadCount, "does not reload")
  }

  func testPreviousAtStartRepeatQueueWraps() {
    player.repeatMode = .queue
    player.add(items: [item("a"), item("b"), item("c")])

    player.previous()

    XCTAssertEqual(player.currentIndex, 2)
    XCTAssertEqual(player.currentItem?.title, "c")
  }

  // MARK: - Add to Queue

  func testAddToEmptyQueueAutoLoads() {
    player.add(items: [item("a"), item("b")])

    XCTAssertEqual(player.currentIndex, 0)
    XCTAssertEqual(player.currentItem?.title, "a")
    XCTAssertEqual(player.state, .loading)
    XCTAssertEqual(engine.loadedURLs.count, 1)
    XCTAssertEqual(engine.playCallCount, 0, "loads but does not auto-play")
  }

  func testAddToEmptyQueueThenPlay() {
    player.add(items: [item("a")])
    player.play()

    XCTAssertEqual(engine.playCallCount, 1)
  }

  func testAddToNonEmptyQueueDoesNotReload() {
    player.add(items: [item("a")])
    let loadCount = engine.loadedURLs.count

    player.add(items: [item("b"), item("c")])

    XCTAssertEqual(engine.loadedURLs.count, loadCount)
    XCTAssertEqual(player.items.count, 3)
    XCTAssertEqual(player.currentIndex, 0)
  }

  // MARK: - Remove

  func testRemoveCurrentItemLoadsNext() {
    player.add(items: [item("a"), item("b")])
    engine.simulatePlaybackState(.playing)

    try! player.removeItem(at: 0)

    XCTAssertEqual(player.currentIndex, 0)
    XCTAssertEqual(player.currentItem?.title, "b")
    XCTAssertEqual(player.state, .loading)
    XCTAssertEqual(engine.playCallCount, 1, "auto-plays since was playing")
  }

  func testRemoveCurrentItemWhenPausedDoesNotAutoPlay() {
    player.add(items: [item("a"), item("b")])
    engine.simulatePlaybackState(.paused)

    try! player.removeItem(at: 0)

    XCTAssertEqual(player.currentItem?.title, "b")
    XCTAssertEqual(engine.playCallCount, 0, "does not auto-play since was paused")
  }

  func testRemoveLastItemGoesIdle() {
    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)

    try! player.removeItem(at: 0)

    XCTAssertEqual(player.state, .idle)
    XCTAssertNil(player.currentItem)
    XCTAssertEqual(engine.resetCallCount, 1)
  }

  func testRemoveNonCurrentItem() {
    player.add(items: [item("a"), item("b"), item("c")])
    engine.simulatePlaybackState(.playing)

    try! player.removeItem(at: 2)

    XCTAssertEqual(player.state, .playing)
    XCTAssertEqual(player.currentIndex, 0)
    XCTAssertEqual(player.items.count, 2)
  }

  // MARK: - Clear

  func testClear() {
    player.add(items: [item("a"), item("b")])
    engine.simulatePlaybackState(.playing)

    player.clear()

    XCTAssertEqual(player.state, .idle)
    XCTAssertTrue(player.items.isEmpty)
    XCTAssertNil(player.currentItem)
    XCTAssertEqual(engine.resetCallCount, 1)
  }

  func testClearFiresCurrentItemChangedToNil() {
    player.add(items: [item("a"), item("b")])
    engine.simulatePlaybackState(.playing)

    var received: (item: AudioItem?, index: Int)?
    player.onCurrentItemChanged = { item, index in
      received = (item, index)
    }

    player.clear()

    XCTAssertNotNil(received, "clear should emit a current-item-changed event")
    XCTAssertNil(received?.item, "active item should become nil after clear")
    XCTAssertEqual(received?.index, -1)
  }

  func testClearEmptyQueueDoesNotFireCurrentItemChanged() {
    var fired = false
    player.onCurrentItemChanged = { _, _ in fired = true }

    player.clear()

    XCTAssertFalse(fired, "clearing an already-empty queue should not emit a transition")
  }

  // MARK: - Rate

  func testRateChange() {
    player.rate = 2.0

    XCTAssertEqual(engine.defaultRate, 2.0)
  }

  func testRateChangeWhilePlaying() {
    player.load(item: item("a"))
    engine.isPlaying = true

    player.rate = 1.5

    XCTAssertEqual(engine.defaultRate, 1.5)
    XCTAssertEqual(engine.currentRate, 1.5)
  }

  func testRateChangeWhilePausedDoesNotSetCurrentRate() {
    player.load(item: item("a"))
    engine.isPlaying = false

    player.rate = 1.5

    XCTAssertEqual(engine.defaultRate, 1.5)
    XCTAssertEqual(engine.currentRate, 0.0, "should not change current rate when paused")
  }

  // MARK: - Computed Properties Delegate to Engine

  func testCurrentTimeDelegatesToEngine() {
    engine.currentTime = 42.5
    XCTAssertEqual(player.currentTime, 42.5)
  }

  func testDurationDelegatesToEngine() {
    engine.duration = 180.0
    XCTAssertEqual(player.duration, 180.0)
  }

  func testBufferedPositionDelegatesToEngine() {
    engine.bufferedPosition = 60.0
    XCTAssertEqual(player.bufferedPosition, 60.0)
  }

  // MARK: - Complex Flows

  func testFullPlaybackLifecycle() {
    var states: [PlayerState] = []
    player.onStateChange = { states.append($0) }

    player.load(item: item("a"))
    player.play()
    engine.simulatePlaybackState(.waitingToPlay)
    engine.simulateItemReady()
    engine.simulatePlaybackState(.playing)
    player.pause()
    engine.simulatePlaybackState(.paused)
    player.play()
    engine.simulatePlaybackState(.playing)
    engine.simulatePlayedToEnd()

    XCTAssertEqual(states, [
      .loading, .buffering, .playing, .paused, .playing, .ended
    ])
  }

  func testQueuePlaythrough() {
    player.add(items: [item("a"), item("b"), item("c")])

    for expected in ["a", "b", "c"] {
      XCTAssertEqual(player.currentItem?.title, expected)
      engine.simulatePlaybackState(.playing)
      if expected != "c" {
        engine.simulatePlayedToEnd()
      }
    }

    engine.simulatePlayedToEnd()
    XCTAssertEqual(player.state, .ended)
  }

  func testQueueLoopWithRepeatQueue() {
    player.repeatMode = .queue
    player.add(items: [item("a"), item("b")])

    engine.simulatePlaybackState(.playing)
    engine.simulatePlayedToEnd()
    XCTAssertEqual(player.currentItem?.title, "b")

    engine.simulatePlaybackState(.playing)
    engine.simulatePlayedToEnd()
    XCTAssertEqual(player.currentItem?.title, "a", "wraps back to first")

    engine.simulatePlaybackState(.playing)
    engine.simulatePlayedToEnd()
    XCTAssertEqual(player.currentItem?.title, "b", "continues looping")
  }

  // MARK: - Skip To Index

  func testSkipToIndex() {
    player.add(items: [item("a"), item("b"), item("c")])

    player.skipTo(index: 2)

    XCTAssertEqual(player.currentIndex, 2)
    XCTAssertEqual(player.currentItem?.title, "c")
    XCTAssertEqual(engine.playCallCount, 1, "auto-plays when skipping")
  }

  func testSkipToSameIndex() {
    player.add(items: [item("a"), item("b")])

    player.skipTo(index: 0)

    XCTAssertEqual(player.currentIndex, 0)
    XCTAssertEqual(engine.loadedURLs.count, 2, "reloads even if same index")
  }

  func testSkipToInvalidIndexDoesNothing() {
    player.add(items: [item("a"), item("b")])
    let loadCount = engine.loadedURLs.count

    player.skipTo(index: 5)

    XCTAssertEqual(engine.loadedURLs.count, loadCount)
    XCTAssertEqual(player.currentIndex, 0)
  }

  func testSkipToNegativeIndexDoesNothing() {
    player.add(items: [item("a")])
    let loadCount = engine.loadedURLs.count

    player.skipTo(index: -1)

    XCTAssertEqual(engine.loadedURLs.count, loadCount)
  }

  // MARK: - Volume

  func testSetVolume() {
    player.volume = 0.5

    XCTAssertEqual(engine.volume, 0.5)
  }

  func testVolumeClampedToRange() {
    player.volume = 1.5
    XCTAssertEqual(engine.volume, 1.0)

    player.volume = -0.5
    XCTAssertEqual(engine.volume, 0.0)
  }

  func testGetVolume() {
    engine.volume = 0.75
    XCTAssertEqual(player.volume, 0.75)
  }

  // MARK: - Previous with Threshold

  func testPreviousBeforeThresholdGoesToPreviousTrack() {
    player.add(items: [item("a"), item("b"), item("c")])
    player.next()
    engine.currentTime = 2.0

    player.previous()

    XCTAssertEqual(player.currentIndex, 0)
    XCTAssertEqual(player.currentItem?.title, "a")
  }

  func testPreviousPastThresholdRestartsCurrentTrack() {
    player.add(items: [item("a"), item("b"), item("c")])
    player.next()
    engine.currentTime = 5.0

    player.previous()

    XCTAssertEqual(player.currentIndex, 1, "stays on same track")
    XCTAssertTrue(engine.seekTargets.contains(0), "seeks to beginning")
  }

  func testPreviousAtExactThresholdGoesToPrevious() {
    player.add(items: [item("a"), item("b")])
    player.next()
    engine.currentTime = 3.0

    player.previous()

    XCTAssertEqual(player.currentIndex, 0, "at exactly threshold goes to previous")
  }

  // MARK: - Shuffle

  func testShuffleEnableDisable() {
    player.add(items: [item("a"), item("b"), item("c")])

    player.shuffleEnabled = true
    XCTAssertTrue(player.shuffleEnabled)

    player.shuffleEnabled = false
    XCTAssertFalse(player.shuffleEnabled)
  }

  func testShufflePreservesCurrentItem() {
    player.add(items: [item("a"), item("b"), item("c")])
    player.next()
    XCTAssertEqual(player.currentItem?.title, "b")

    player.shuffleEnabled = true

    XCTAssertEqual(player.currentItem?.title, "b", "current item unchanged after enabling shuffle")
  }

  func testShuffleNavigatesAllItems() {
    player.add(items: [item("a"), item("b"), item("c")])
    player.shuffleEnabled = true

    var visited = Set<String>()
    visited.insert(player.currentItem!.title!)

    for _ in 0..<2 {
      player.next()
      visited.insert(player.currentItem!.title!)
    }

    XCTAssertEqual(visited.count, 3, "all items visited through shuffle")
  }

  // MARK: - Replace Item

  func testReplaceCurrentItemSameUrlPreservesPlayback() {
    player.add(items: [item("a")])
    player.play()
    engine.simulatePlaybackState(.playing)
    let loadCountBefore = engine.loadedURLs.count

    var replacement = item("a")
    replacement.title = "Updated Title"
    player.replaceItem(at: 0, with: replacement)

    XCTAssertEqual(player.items[0].title, "Updated Title")
    XCTAssertEqual(engine.loadedURLs.count, loadCountBefore, "should not reload when URL is the same")
    XCTAssertEqual(player.state, .playing, "playback continues uninterrupted")
  }

  func testReplaceCurrentItemDifferentUrlReloads() {
    player.add(items: [item("a")])
    player.play()
    engine.simulatePlaybackState(.playing)
    let loadCountBefore = engine.loadedURLs.count

    let replacement = item("b")
    player.replaceItem(at: 0, with: replacement)

    XCTAssertEqual(player.items[0].title, "b")
    XCTAssertEqual(engine.loadedURLs.count, loadCountBefore + 1, "reloads when URL changed")
  }

  func testReplaceNonCurrentItem() {
    player.add(items: [item("a"), item("b"), item("c")])
    let loadCountBefore = engine.loadedURLs.count

    var replacement = item("x")
    replacement.title = "Replaced"
    player.replaceItem(at: 2, with: replacement)

    XCTAssertEqual(player.items[2].title, "Replaced")
    XCTAssertEqual(engine.loadedURLs.count, loadCountBefore, "should not reload non-current item")
    XCTAssertEqual(player.currentIndex, 0, "current index unchanged")
  }

  func testReplaceItemOutOfBoundsDoesNothing() {
    player.add(items: [item("a")])
    let loadCountBefore = engine.loadedURLs.count

    player.replaceItem(at: 5, with: item("x"))

    XCTAssertEqual(player.items.count, 1)
    XCTAssertEqual(engine.loadedURLs.count, loadCountBefore)
  }

  // MARK: - Update Metadata

  func testUpdateMetadataUpdatesQueueItem() {
    player.add(items: [item("a"), item("b"), item("c")])

    var updatedItem = item("x")
    updatedItem.title = "Updated Title"
    player.updateMetadata(at: 1, with: updatedItem)

    XCTAssertEqual(player.items[1].title, "Updated Title")
  }

  func testUpdateMetadataCurrentItemDoesNotInterruptPlayback() {
    player.add(items: [item("a")])
    player.play()
    let playCountBefore = engine.playCallCount
    let loadCountBefore = engine.loadedURLs.count

    var updatedItem = item("a")
    updatedItem.title = "Now Playing: New Song"
    player.updateMetadata(at: 0, with: updatedItem)

    XCTAssertEqual(player.items[0].title, "Now Playing: New Song")
    XCTAssertEqual(engine.playCallCount, playCountBefore, "should not call play again")
    XCTAssertEqual(engine.loadedURLs.count, loadCountBefore, "should not reload the item")
  }

  func testUpdateMetadataOutOfBoundsDoesNothing() {
    player.add(items: [item("a")])

    var updatedItem = item("x")
    updatedItem.title = "Bad"
    player.updateMetadata(at: 5, with: updatedItem)

    XCTAssertEqual(player.items.count, 1)
    XCTAssertEqual(player.items[0].title, "a")
  }

  // MARK: - Error Reporting

  func testItemFailedEmitsErrorCallback() {
    player.load(item: item("a"))

    var receivedCode: String?
    var receivedMessage: String?
    player.onPlaybackError = { code, message in
      receivedCode = code
      receivedMessage = message
    }

    engine.simulateItemFailed(code: "network", message: "Connection lost")

    XCTAssertEqual(player.state, .failed)
    XCTAssertEqual(receivedCode, "network")
    XCTAssertEqual(receivedMessage, "Connection lost")
  }

  func testItemFailedEmitsErrorBeforeStateChange() {
    player.load(item: item("a"))

    var errorReceivedBeforeFailed = false
    player.onPlaybackError = { _, _ in
      errorReceivedBeforeFailed = true
    }
    player.onStateChange = { state in
      if state == .failed {
        XCTAssertTrue(errorReceivedBeforeFailed, "error callback should fire before state transitions to failed")
      }
    }

    engine.simulateItemFailed(code: "source", message: "File not found")
  }

  // MARK: - Retry

  func testRetryReloadsCurrentItem() {
    player.load(item: item("a"))
    engine.simulateItemFailed()
    XCTAssertEqual(player.state, .failed)
    let loadCountBefore = engine.loadedURLs.count

    player.retry()

    XCTAssertEqual(player.state, .loading)
    XCTAssertEqual(engine.loadedURLs.count, loadCountBefore + 1, "reloads the same URL")
  }

  func testRetryDoesNotAutoPlay() {
    player.load(item: item("a"))
    engine.simulateItemFailed()
    let playCountBefore = engine.playCallCount

    player.retry()

    XCTAssertEqual(engine.playCallCount, playCountBefore, "retry should not auto-play")
  }

  func testRetryIsNoOpWhenNotFailed() {
    player.load(item: item("a"))
    engine.simulatePlaybackState(.playing)
    let loadCountBefore = engine.loadedURLs.count

    player.retry()

    XCTAssertEqual(engine.loadedURLs.count, loadCountBefore, "should not reload when not in failed state")
    XCTAssertEqual(player.state, .playing)
  }

  func testRetryPreservesQueue() {
    player.add(items: [item("a"), item("b"), item("c")])
    engine.simulateItemFailed()

    player.retry()

    XCTAssertEqual(player.items.count, 3, "queue unchanged")
    XCTAssertEqual(player.currentIndex, 0, "index unchanged")
    XCTAssertEqual(player.currentItem?.title, "a", "same item")
  }

  func testRetryDoesNotFireCurrentItemChanged() {
    player.load(item: item("a"))
    engine.simulateItemFailed()

    var itemChangedCount = 0
    player.onCurrentItemChanged = { _, _ in itemChangedCount += 1 }

    player.retry()

    XCTAssertEqual(itemChangedCount, 0, "retry should not emit item transition")
  }

  // MARK: - Destroy

  func testDestroy() {
    player.add(items: [item("a"), item("b")])
    engine.simulatePlaybackState(.playing)

    player.destroy()

    XCTAssertEqual(player.state, .idle)
    XCTAssertTrue(player.items.isEmpty)
    XCTAssertNil(player.currentItem)
  }

  func testDestroyNilsCallbacks() {
    var stateChangeCount = 0
    player.onStateChange = { _ in stateChangeCount += 1 }

    player.load(item: item("a"))
    XCTAssertEqual(stateChangeCount, 1)

    player.destroy()
    stateChangeCount = 0

    player.load(item: item("b"))
    XCTAssertEqual(stateChangeCount, 0, "callback should not fire after destroy")
  }

  // MARK: - Headers

  func testLoadWithHeaders() {
    var headerItem = item("a")
    headerItem.headers = ["Authorization": "Bearer token123"]

    player.load(item: headerItem)

    XCTAssertEqual(engine.loadedHeaders.count, 1)
    XCTAssertEqual(engine.loadedHeaders[0]?["Authorization"], "Bearer token123")
  }

  func testLoadWithoutHeaders() {
    player.load(item: item("a"))

    XCTAssertEqual(engine.loadedHeaders.count, 1)
    XCTAssertNil(engine.loadedHeaders[0])
  }

  // MARK: - Timed Metadata (Live Streams)

  func testTimedMetadataNotifiesCallback() {
    player.add(items: [item("a")])

    var received: StreamMetadata?
    player.onMetadataReceived = { metadata in
      received = metadata
    }

    engine.simulateTimedMetadata(title: "New Song", artist: "New Artist")

    XCTAssertEqual(received?.title, "New Song")
    XCTAssertEqual(received?.artist, "New Artist")
  }

  func testTimedMetadataForwardsAllFields() {
    player.add(items: [item("a")])

    var received: StreamMetadata?
    player.onMetadataReceived = { metadata in
      received = metadata
    }

    let full = StreamMetadata(title: "Song", artist: "Artist", albumTitle: "Album", artworkUri: "https://art.jpg", genre: "Rock")
    engine.simulateTimedMetadata(full)

    XCTAssertEqual(received?.title, "Song")
    XCTAssertEqual(received?.artist, "Artist")
    XCTAssertEqual(received?.albumTitle, "Album")
    XCTAssertEqual(received?.artworkUri, "https://art.jpg")
    XCTAssertEqual(received?.genre, "Rock")
  }

  func testTimedMetadataDeduplicates() {
    player.add(items: [item("a")])

    var callCount = 0
    player.onMetadataReceived = { _ in callCount += 1 }

    engine.simulateTimedMetadata(title: "Song A", artist: nil)
    engine.simulateTimedMetadata(title: "Song A", artist: nil)
    engine.simulateTimedMetadata(title: "Song A", artist: nil)

    XCTAssertEqual(callCount, 1)
  }

  func testTimedMetadataResetsOnTrackTransition() {
    player.add(items: [item("a"), item("b")])

    var callCount = 0
    player.onMetadataReceived = { _ in callCount += 1 }

    engine.simulateTimedMetadata(title: "Song A", artist: nil)
    XCTAssertEqual(callCount, 1)

    player.next()

    engine.simulateTimedMetadata(title: "Song A", artist: nil)
    XCTAssertEqual(callCount, 2, "same metadata after track change should fire again")
  }

  func testTimedMetadataDoesNotInterruptPlayback() {
    player.add(items: [item("a")])
    player.play()
    let loadCountBefore = engine.loadedURLs.count
    let playCountBefore = engine.playCallCount

    engine.simulateTimedMetadata(title: "Live Song", artist: "DJ")

    XCTAssertEqual(engine.loadedURLs.count, loadCountBefore)
    XCTAssertEqual(engine.playCallCount, playCountBefore)
  }
}
