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

import AVFoundation
import XCTest

@testable import PlayerCore

final class AVPlayerEngineIntegrationTests: XCTestCase {
  /// Cache-proxy playback tests need a longer wait on slow CI simulators.
  private let playbackTimeout: TimeInterval = 30.0

  private var engine: AVPlayerEngine!
  private var testFileURL: URL!

  override func setUp() {
    super.setUp()
    engine = AVPlayerEngine()
    testFileURL = createSilentAudioFile(duration: 0.3)
  }

  override func tearDown() {
    engine = nil
    if let url = testFileURL {
      try? FileManager.default.removeItem(at: url)
    }
    super.tearDown()
  }

  private func createSilentAudioFile(duration: Double) -> URL {
    let url = FileManager.default.temporaryDirectory
      .appendingPathComponent("test_\(UUID().uuidString).wav")
    let sampleRate: Double = 44100
    let frameCount = AVAudioFrameCount(sampleRate * duration)
    let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
    let file = try! AVAudioFile(forWriting: url, settings: format.settings)
    let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount)!
    buffer.frameLength = frameCount
    try! file.write(from: buffer)
    return url
  }

  /// Creates ADTS AAC data — a streamable format that AVPlayer can play
  /// without a Content-Length header.
  private func createADTSAACData(duration: Double) -> Data? {
    let sampleRate: Double = 44100
    let frameCount = AVAudioFrameCount(sampleRate * duration)

    let pcmFormat = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
    let silentBuffer = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: frameCount)!
    silentBuffer.frameLength = frameCount

    guard let aacFormat = AVAudioFormat(
      commonFormat: .pcmFormatFloat32,
      sampleRate: sampleRate,
      channels: 1,
      interleaved: false
    ) else { return nil }

    var classDesc = AudioClassDescription(
      mType: kAudioEncoderComponentType,
      mSubType: kAudioFormatMPEG4AAC,
      mManufacturer: kAppleSoftwareAudioCodecManufacturer
    )
    var outputASBD = AudioStreamBasicDescription(
      mSampleRate: sampleRate,
      mFormatID: kAudioFormatMPEG4AAC,
      mFormatFlags: 0,
      mBytesPerPacket: 0,
      mFramesPerPacket: 1024,
      mBytesPerFrame: 0,
      mChannelsPerFrame: 1,
      mBitsPerChannel: 0,
      mReserved: 0
    )
    guard let aacOutputFormat = AVAudioFormat(streamDescription: &outputASBD) else { return nil }
    guard let converter = AVAudioConverter(from: pcmFormat, to: aacOutputFormat) else { return nil }

    var aacData = Data()
    let outputBuffer = AVAudioCompressedBuffer(format: aacOutputFormat, packetCapacity: 1, maximumPacketSize: 768)

    var inputConsumed = false
    let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
      if inputConsumed {
        outStatus.pointee = .endOfStream
        return nil
      }
      inputConsumed = true
      outStatus.pointee = .haveData
      return silentBuffer
    }

    while true {
      outputBuffer.packetCount = 0
      outputBuffer.byteLength = 0
      var error: NSError?
      let status = converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
      if outputBuffer.byteLength > 0 {
        // Wrap each AAC packet in an ADTS header
        let packetSize = Int(outputBuffer.byteLength)
        let frameLength = packetSize + 7
        var adtsHeader = Data(count: 7)
        adtsHeader[0] = 0xFF
        adtsHeader[1] = 0xF1 // MPEG-4, Layer 0, no CRC
        adtsHeader[2] = 0x50 // AAC-LC, 44100Hz, mono (upper bits)
        adtsHeader[3] = UInt8(0x80) | UInt8((frameLength >> 11) & 0x03)
        adtsHeader[4] = UInt8((frameLength >> 3) & 0xFF)
        adtsHeader[5] = UInt8(((frameLength & 0x07) << 5) | 0x1F)
        adtsHeader[6] = 0xFC
        aacData.append(adtsHeader)
        aacData.append(Data(bytes: outputBuffer.data, count: packetSize))
      }
      if status == .endOfStream || status == .error { break }
    }

    return aacData.isEmpty ? nil : aacData
  }

  private func loadAndWaitForReady() {
    let ready = expectation(description: "item ready")
    ready.assertForOverFulfill = false
    engine.onItemReady = { ready.fulfill() }
    engine.load(url: testFileURL)
    wait(for: [ready], timeout: 5.0)
  }

  // MARK: - Load / Reset

  func testLoadSetsHasCurrentItem() {
    engine.load(url: testFileURL)
    XCTAssertTrue(engine.hasCurrentItem)
  }

  func testResetClearsCurrentItem() {
    engine.load(url: testFileURL)
    engine.reset()
    XCTAssertFalse(engine.hasCurrentItem)
  }

  // MARK: - KVO: isPlaybackLikelyToKeepUp → onItemReady

  func testOnItemReadyFires() {
    let ready = expectation(description: "item ready")
    ready.assertForOverFulfill = false
    engine.onItemReady = { ready.fulfill() }

    engine.load(url: testFileURL)

    wait(for: [ready], timeout: 5.0)
  }

  // MARK: - KVO: timeControlStatus → onPlaybackStateChange

  func testPlayTriggersPlayingCallback() {
    loadAndWaitForReady()

    let playing = expectation(description: "playing")
    playing.assertForOverFulfill = false
    engine.onPlaybackStateChange = { state in
      if state == .playing { playing.fulfill() }
    }

    engine.play()
    wait(for: [playing], timeout: 5.0)
    XCTAssertTrue(engine.isPlaying)
  }

  func testPauseTriggersPausedCallback() {
    loadAndWaitForReady()

    let playing = expectation(description: "playing")
    playing.assertForOverFulfill = false
    engine.onPlaybackStateChange = { state in
      if state == .playing { playing.fulfill() }
    }
    engine.play()
    wait(for: [playing], timeout: 5.0)

    let paused = expectation(description: "paused")
    paused.assertForOverFulfill = false
    engine.onPlaybackStateChange = { state in
      if state == .paused { paused.fulfill() }
    }
    engine.pause()
    wait(for: [paused], timeout: 5.0)
    XCTAssertFalse(engine.isPlaying)
  }

  // MARK: - NotificationCenter: AVPlayerItemDidPlayToEndTime → onItemPlayedToEnd

  func testOnItemPlayedToEndFires() {
    let ended = expectation(description: "track ended")
    engine.onItemPlayedToEnd = { ended.fulfill() }

    engine.load(url: testFileURL)
    engine.play()

    wait(for: [ended], timeout: 5.0)
  }

  // MARK: - KVO: status → onItemFailed

  func testOnItemFailedFiresForInvalidFile() {
    let invalidURL = FileManager.default.temporaryDirectory
      .appendingPathComponent("invalid_\(UUID().uuidString).mp3")
    try! "not audio data".data(using: .utf8)!.write(to: invalidURL)
    defer { try? FileManager.default.removeItem(at: invalidURL) }

    let failed = expectation(description: "item failed")
    failed.assertForOverFulfill = false
    engine.onItemFailed = { _, _ in failed.fulfill() }

    engine.load(url: invalidURL)

    wait(for: [failed], timeout: 5.0)
  }

  // MARK: - Duration (KVO: duration → onDurationChange)

  func testDurationAvailableAfterReady() {
    loadAndWaitForReady()

    XCTAssertGreaterThan(engine.duration, 0)
    XCTAssertLessThan(engine.duration, 1.0)
  }

  // MARK: - Seek

  func testLiveEdgeTargetUsesHalfSecondSafetyOffset() throws {
    let range = CMTimeRange(
      start: CMTime(seconds: 10, preferredTimescale: 600),
      duration: CMTime(seconds: 20, preferredTimescale: 600)
    )

    XCTAssertEqual(try XCTUnwrap(AVPlayerEngine.liveEdgeTarget(for: range)), 29.5, accuracy: 0.001)
  }

  func testLiveEdgeTargetNeverPrecedesRangeStart() throws {
    let range = CMTimeRange(
      start: CMTime(seconds: 10, preferredTimescale: 600),
      duration: CMTime(seconds: 0.2, preferredTimescale: 600)
    )

    XCTAssertEqual(try XCTUnwrap(AVPlayerEngine.liveEdgeTarget(for: range)), 10, accuracy: 0.001)
  }

  func testInvalidLiveEdgeRangeFallsBack() {
    let invalid = CMTimeRange(start: .invalid, duration: .invalid)
    XCTAssertNil(AVPlayerEngine.liveEdgeTarget(for: invalid))
  }

  func testNoSeekableRangeReloadsRetainedSourceAndCompletesCoalescedWaitersOnceOnExactReady() {
    let url = URL(string: "https://example.com/live.aac")!
    let headers = ["Authorization": "Bearer token", "X-Test": "live"]
    var loads: [(URL, [String: String]?, Bool, Int)] = []
    engine.configureLiveEdgeTestSeam(seekableRanges: { [] }) { url, headers, isLive, generation in
      loads.append((url, headers, isLive, generation))
    }
    engine.load(url: url, headers: headers, isLive: true)
    let initialGeneration = loads.last!.3
    var firstResults: [Bool] = []
    var secondResults: [Bool] = []

    engine.seekToLiveEdge { firstResults.append($0) }
    engine.seekToLiveEdge { secondResults.append($0) }
    let refreshGeneration = loads.last!.3

    XCTAssertEqual(loads.count, 2)
    XCTAssertEqual(refreshGeneration, initialGeneration + 1)
    XCTAssertEqual(loads.last!.0, url)
    XCTAssertEqual(loads.last!.1, headers)
    XCTAssertTrue(loads.last!.2)
    XCTAssertTrue(firstResults.isEmpty)
    XCTAssertTrue(secondResults.isEmpty)

    engine.simulateItemReadyForTesting(generation: refreshGeneration)
    engine.simulateItemReadyForTesting(generation: refreshGeneration)

    XCTAssertEqual(firstResults, [true])
    XCTAssertEqual(secondResults, [true])
  }

  func testInvalidSeekableRangeReloadsRetainedLiveSourceAndCompletesOnReady() {
    let url = URL(string: "https://example.com/invalid-range-live.aac")!
    let headers = ["Authorization": "Bearer invalid-range"]
    let invalidRange = CMTimeRange(start: .invalid, duration: .invalid)
    var loads: [(URL, [String: String]?, Bool, Int)] = []
    engine.configureLiveEdgeTestSeam(seekableRanges: { [invalidRange] }) {
      url, headers, isLive, generation in
      loads.append((url, headers, isLive, generation))
    }
    engine.load(url: url, headers: headers, isLive: true)
    var results: [Bool] = []

    engine.seekToLiveEdge { results.append($0) }

    XCTAssertEqual(loads.count, 2)
    XCTAssertEqual(loads.last!.0, url)
    XCTAssertEqual(loads.last!.1, headers)
    XCTAssertTrue(loads.last!.2)
    XCTAssertTrue(results.isEmpty)

    engine.simulateItemReadyForTesting(generation: loads.last!.3)

    XCTAssertEqual(results, [true])
  }

  func testExactGenerationFailureCompletesEveryWaiterExactlyOnce() {
    var generations: [Int] = []
    engine.configureLiveEdgeTestSeam(seekableRanges: { [] }) { _, _, _, generation in
      generations.append(generation)
    }
    engine.load(url: URL(string: "https://example.com/live.aac")!, isLive: true)
    var results: [Bool] = []
    engine.seekToLiveEdge { results.append($0) }
    engine.seekToLiveEdge { results.append($0) }
    let refreshGeneration = generations.last!

    engine.simulateItemFailureForTesting(generation: refreshGeneration)
    engine.simulateItemFailureForTesting(generation: refreshGeneration)

    XCTAssertEqual(results, [false, false])
  }

  func testResetCancelsAllRefreshWaitersExactlyOnce() {
    engine.configureLiveEdgeTestSeam(seekableRanges: { [] }) { _, _, _, _ in }
    engine.load(url: URL(string: "https://example.com/live.aac")!, isLive: true)
    var results: [Bool] = []
    engine.seekToLiveEdge { results.append($0) }
    engine.seekToLiveEdge { results.append($0) }

    engine.reset()
    engine.reset()

    XCTAssertEqual(results, [false, false])
  }

  func testSupersededGenerationCannotCompleteOrEmitIntoNewRefresh() {
    let oldURL = URL(string: "https://example.com/old.aac")!
    let newURL = URL(string: "https://example.com/new.aac")!
    var generations: [Int] = []
    engine.configureLiveEdgeTestSeam(seekableRanges: { [] }) { _, _, _, generation in
      generations.append(generation)
    }
    var oldResults: [Bool] = []
    var newResults: [Bool] = []
    var readyCount = 0
    var failureCount = 0
    var metadata: [StreamMetadata] = []
    engine.onItemReady = { readyCount += 1 }
    engine.onItemFailed = { _, _ in failureCount += 1 }
    engine.onTimedMetadata = { metadata.append($0) }
    engine.load(url: oldURL, headers: ["X-Source": "old"], isLive: true)
    engine.seekToLiveEdge { oldResults.append($0) }
    let oldRefreshGeneration = generations.last!

    engine.load(url: newURL, headers: ["X-Source": "new"], isLive: true)
    engine.seekToLiveEdge { newResults.append($0) }
    let newRefreshGeneration = generations.last!

    let staleMetadata = StreamMetadata(
      title: "stale", artist: nil, albumTitle: nil, artworkUri: nil, genre: nil
    )
    engine.simulateItemReadyForTesting(generation: oldRefreshGeneration)
    engine.simulateItemFailureForTesting(generation: oldRefreshGeneration)
    engine.simulateTimedMetadataForTesting(staleMetadata, generation: oldRefreshGeneration)

    XCTAssertEqual(oldResults, [false])
    XCTAssertTrue(newResults.isEmpty)
    XCTAssertEqual(readyCount, 0)
    XCTAssertEqual(failureCount, 0)
    XCTAssertTrue(metadata.isEmpty)

    engine.simulateItemReadyForTesting(generation: newRefreshGeneration)

    XCTAssertEqual(newResults, [true])
    XCTAssertEqual(readyCount, 1)
  }

  func testSeekUpdatesPosition() {
    loadAndWaitForReady()

    let seekDone = expectation(description: "seek complete")
    engine.seek(to: 0.1) { success in
      XCTAssertTrue(success)
      seekDone.fulfill()
    }
    wait(for: [seekDone], timeout: 5.0)

    XCTAssertGreaterThan(engine.currentTime, 0)
  }

  // MARK: - Observer Cleanup

  func testLoadingNewItemCleansUpOldObservers() {
    loadAndWaitForReady()

    let secondFileURL = createSilentAudioFile(duration: 0.3)
    defer { try? FileManager.default.removeItem(at: secondFileURL) }

    let secondReady = expectation(description: "second item ready")
    secondReady.assertForOverFulfill = false
    engine.onItemReady = { secondReady.fulfill() }
    engine.load(url: secondFileURL)
    wait(for: [secondReady], timeout: 5.0)

    XCTAssertTrue(engine.hasCurrentItem)
  }

  // MARK: - Full Lifecycle

  func testFullPlaybackLifecycle() {
    var receivedStates: [EnginePlaybackState] = []

    let ready = expectation(description: "ready")
    ready.assertForOverFulfill = false
    engine.onItemReady = { ready.fulfill() }
    engine.onPlaybackStateChange = { state in
      receivedStates.append(state)
    }

    engine.load(url: testFileURL)
    wait(for: [ready], timeout: 5.0)

    let ended = expectation(description: "ended")
    engine.onItemPlayedToEnd = { ended.fulfill() }
    engine.play()
    wait(for: [ended], timeout: 5.0)

    XCTAssertTrue(receivedStates.contains(.playing), "should have received .playing")
  }

  // MARK: - Caching Without Content-Length

  func testCachedPlaybackCompletesWithContentLength() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    // Use ADTS AAC — a streamable format that the HTTP proxy can serve
    // progressively. WAV requires byte-range access which is not available
    // until the full file is cached.
    guard let audioData = createADTSAACData(duration: 1.0) else {
      throw XCTSkip("Could not create ADTS AAC test data")
    }
    let server = try LocalAudioServer(
      audioData: audioData,
      options: .init(includeContentLength: true, contentType: "audio/aac")
    )
    server.start()
    defer { server.stop() }

    let ready = expectation(description: "ready")
    ready.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready.fulfill() }

    let failed = expectation(description: "should not fail")
    failed.isInverted = true
    cachedEngine.onItemFailed = { _, _ in failed.fulfill() }

    cachedEngine.load(url: server.url)
    wait(for: [ready], timeout: playbackTimeout)

    let ended = expectation(description: "ended")
    cachedEngine.onItemPlayedToEnd = { ended.fulfill() }
    cachedEngine.play()
    wait(for: [ended, failed], timeout: playbackTimeout)
  }

  func testCachedPlaybackCompletesWithoutContentLength() throws {
    // Use ADTS AAC — a streamable format that AVPlayer can handle without
    // Content-Length (unlike WAV/M4A which require it).
    guard let audioData = createADTSAACData(duration: 1.0) else {
      throw XCTSkip("Could not create ADTS AAC test data")
    }

    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }
    let server = try LocalAudioServer(
      audioData: audioData,
      options: .init(includeContentLength: false, contentType: "audio/aac")
    )
    server.start()
    defer { server.stop() }

    let ready = expectation(description: "ready")
    ready.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready.fulfill() }

    let failed = expectation(description: "should not fail")
    failed.isInverted = true
    cachedEngine.onItemFailed = { _, _ in failed.fulfill() }

    cachedEngine.load(url: server.url)
    wait(for: [ready], timeout: playbackTimeout)

    let ended = expectation(description: "ended")
    cachedEngine.onItemPlayedToEnd = { ended.fulfill() }
    cachedEngine.play()
    wait(for: [ended, failed], timeout: playbackTimeout)
  }

  // MARK: - Cache Hit Playback

  func testPlaybackFromCacheHit() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    // Use ADTS AAC — streamable format
    guard let audioData = createADTSAACData(duration: 1.0) else {
      throw XCTSkip("Could not create ADTS AAC test data")
    }
    let server = try LocalAudioServer(
      audioData: audioData,
      options: .init(includeContentLength: true, contentType: "audio/aac")
    )
    server.start()

    // First play — downloads and caches through the proxy.
    let ready1 = expectation(description: "first play ready")
    ready1.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready1.fulfill() }
    cachedEngine.load(url: server.url)
    wait(for: [ready1], timeout: 10.0)

    let ended1 = expectation(description: "first play ended")
    cachedEngine.onItemPlayedToEnd = { ended1.fulfill() }
    cachedEngine.play()
    wait(for: [ended1], timeout: 10.0)

    // Stop the server — second play must come from cache.
    server.stop()

    let ready2 = expectation(description: "cache hit ready")
    ready2.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready2.fulfill() }
    cachedEngine.load(url: server.url)
    wait(for: [ready2], timeout: 10.0)

    let ended2 = expectation(description: "cache hit ended")
    cachedEngine.onItemPlayedToEnd = { ended2.fulfill() }
    cachedEngine.play()
    wait(for: [ended2], timeout: 10.0)
  }

  // MARK: - Skip Mid-Download Preserves Partial Cache

  func testSkipMidDownloadPreservesPartialCache() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    guard let aacData = createADTSAACData(duration: 5.0) else {
      throw XCTSkip("Could not create AAC test data")
    }
    let server = try LocalAudioServer(
      audioData: aacData,
      options: .init(includeContentLength: true, contentType: "audio/aac")
    )
    server.start()
    defer { server.stop() }

    let ready = expectation(description: "ready")
    ready.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready.fulfill() }
    cachedEngine.load(url: server.url)
    wait(for: [ready], timeout: 10.0)

    // Reset (simulates skipping to next track).
    cachedEngine.reset()

    // Check that some data was cached (partial).
    let key = cache.cacheKey(for: server.url)
    let cachedBytes = cache.cachedBytes(for: key)
    XCTAssertGreaterThan(cachedBytes, 0, "Some data should be cached after partial play")
  }

  // MARK: - HLS Through Proxy (issue #2655)

  /// A VOD media playlist with two relative audio segments. Relative URIs are
  /// the case that broke before the proxy became HLS-aware: they used to resolve
  /// against the localhost proxy base and 400.
  private func hlsMediaPlaylist() -> String {
    return """
    #EXTM3U
    #EXT-X-VERSION:3
    #EXT-X-TARGETDURATION:1
    #EXT-X-MEDIA-SEQUENCE:0
    #EXTINF:0.5,
    seg0.aac
    #EXTINF:0.5,
    seg1.aac
    #EXT-X-ENDLIST
    """
  }

  private func makeHLSServer() throws -> (server: LocalAudioServer, seg0: Data, seg1: Data)? {
    guard let seg0 = createADTSAACData(duration: 0.5),
          let seg1 = createADTSAACData(duration: 0.5) else { return nil }
    let server = try LocalAudioServer(routes: [
      "/index.m3u8": .init(data: Data(hlsMediaPlaylist().utf8), contentType: "application/vnd.apple.mpegurl"),
      "/seg0.aac": .init(data: seg0, contentType: "audio/aac"),
      "/seg1.aac": .init(data: seg1, contentType: "audio/aac"),
    ])
    return (server, seg0, seg1)
  }

  /// The core fix for #2655: a non-live HLS stream must play to the end through
  /// the cache proxy (it used to error with `source / resource unavailable`).
  func testCachedHLSPlaybackCompletesThroughProxy() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    guard let (server, _, _) = try makeHLSServer() else {
      throw XCTSkip("Could not create ADTS AAC segment data")
    }
    server.start()
    defer { server.stop() }

    let ready = expectation(description: "ready")
    ready.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready.fulfill() }

    let failed = expectation(description: "should not fail")
    failed.isInverted = true
    cachedEngine.onItemFailed = { _, _ in failed.fulfill() }

    cachedEngine.load(url: server.url(forPath: "/index.m3u8"))
    wait(for: [ready], timeout: playbackTimeout)

    let ended = expectation(description: "ended")
    cachedEngine.onItemPlayedToEnd = { ended.fulfill() }
    cachedEngine.play()
    wait(for: [ended, failed], timeout: playbackTimeout)
  }

  /// After HLS playback, each segment must be cached by its own absolute upstream
  /// URL — proving the proxy fetched segments per-URL (true per-segment caching).
  func testHLSSegmentsAreCachedAfterPlayback() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    guard let (server, _, _) = try makeHLSServer() else {
      throw XCTSkip("Could not create ADTS AAC segment data")
    }
    server.start()
    defer { server.stop() }

    let ready = expectation(description: "ready")
    ready.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready.fulfill() }
    cachedEngine.load(url: server.url(forPath: "/index.m3u8"))
    wait(for: [ready], timeout: playbackTimeout)

    let ended = expectation(description: "ended")
    cachedEngine.onItemPlayedToEnd = { ended.fulfill() }
    cachedEngine.play()
    wait(for: [ended], timeout: playbackTimeout)

    // Allow cache writes to flush.
    Thread.sleep(forTimeInterval: 0.5)

    let seg0Key = cache.cacheKey(for: server.url(forPath: "/seg0.aac"))
    let seg1Key = cache.cacheKey(for: server.url(forPath: "/seg1.aac"))
    XCTAssertTrue(cache.isFullyCached(key: seg0Key), "seg0 must be cached by its own URL")
    XCTAssertTrue(cache.isFullyCached(key: seg1Key), "seg1 must be cached by its own URL")
  }

  /// A previously-played HLS stream must replay from cache when the origin is
  /// offline — manifest (VOD) and every segment served from disk.
  func testCachedHLSReplaysOffline() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    guard let (server, _, _) = try makeHLSServer() else {
      throw XCTSkip("Could not create ADTS AAC segment data")
    }
    server.start()

    // First play — warms the cache.
    let ready1 = expectation(description: "ready 1")
    ready1.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready1.fulfill() }
    cachedEngine.load(url: server.url(forPath: "/index.m3u8"))
    wait(for: [ready1], timeout: playbackTimeout)

    let ended1 = expectation(description: "ended 1")
    cachedEngine.onItemPlayedToEnd = { ended1.fulfill() }
    cachedEngine.play()
    wait(for: [ended1], timeout: playbackTimeout)
    Thread.sleep(forTimeInterval: 0.5)

    // Origin goes away — replay must come entirely from cache.
    server.stop()
    cachedEngine.reset()

    let ready2 = expectation(description: "ready 2 (offline)")
    ready2.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready2.fulfill() }

    let failed = expectation(description: "should not fail offline")
    failed.isInverted = true
    cachedEngine.onItemFailed = { _, _ in failed.fulfill() }

    cachedEngine.load(url: server.url(forPath: "/index.m3u8"))
    wait(for: [ready2], timeout: playbackTimeout)

    let ended2 = expectation(description: "ended 2 (offline)")
    cachedEngine.onItemPlayedToEnd = { ended2.fulfill() }
    cachedEngine.play()
    wait(for: [ended2, failed], timeout: playbackTimeout)
  }

  /// Best-effort caching with a guaranteed-playback floor: if the localhost
  /// cache proxy is unavailable (e.g. suspended in the background), an HLS load
  /// must fall back to playing directly from the origin rather than erroring.
  func testHLSFallsBackToDirectWhenProxyUnavailable() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    guard let (server, _, _) = try makeHLSServer() else {
      throw XCTSkip("Could not create ADTS AAC segment data")
    }
    server.start()
    defer { server.stop() }

    // Simulate the proxy being unavailable (background suspension): proxied
    // requests will fail, forcing the engine to fall back to direct playback.
    cachedEngine.proxyServer?.stop()

    let ready = expectation(description: "ready via direct fallback")
    ready.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready.fulfill() }

    let failed = expectation(description: "must not surface a failure")
    failed.isInverted = true
    cachedEngine.onItemFailed = { _, _ in failed.fulfill() }

    cachedEngine.load(url: server.url(forPath: "/index.m3u8"))
    wait(for: [ready], timeout: playbackTimeout)

    let ended = expectation(description: "ended via direct fallback")
    cachedEngine.onItemPlayedToEnd = { ended.fulfill() }
    cachedEngine.play()
    wait(for: [ended, failed], timeout: playbackTimeout)
  }

  /// A progressive (non-HLS) ADTS-AAC load must also fall back to direct origin
  /// playback when the cache proxy is unavailable — the fallback is not
  /// HLS-specific. Guards the progressive branch of `load()`.
  func testProgressiveFallsBackToDirectWhenProxyUnavailable() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    guard let audioData = createADTSAACData(duration: 1.0) else {
      throw XCTSkip("Could not create ADTS AAC test data")
    }
    let server = try LocalAudioServer(
      audioData: audioData,
      options: .init(includeContentLength: true, contentType: "audio/aac")
    )
    server.start()
    defer { server.stop() }

    // Simulate the proxy being unavailable (background suspension): proxied
    // requests fail, forcing the engine to fall back to direct playback.
    cachedEngine.proxyServer?.stop()

    let ready = expectation(description: "ready via direct fallback")
    ready.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready.fulfill() }

    let failed = expectation(description: "must not surface a failure")
    failed.isInverted = true
    cachedEngine.onItemFailed = { _, _ in failed.fulfill() }

    cachedEngine.load(url: server.url)
    wait(for: [ready], timeout: playbackTimeout)

    let ended = expectation(description: "ended via direct fallback")
    cachedEngine.onItemPlayedToEnd = { ended.fulfill() }
    cachedEngine.play()
    wait(for: [ended, failed], timeout: playbackTimeout)
  }

  /// A genuinely-unreachable origin must still surface `onItemFailed` even on a
  /// cache-enabled engine: the proxied load fails, the one-shot direct fallback
  /// is consumed, the direct load *also* fails, and the failure surfaces (the
  /// fallback is not an infinite loop, and the failure is not swallowed).
  ///
  /// Note: a single failed item can emit both `.status == .failed` and
  /// `AVPlayerItemFailedToPlayToEndTime`, so `onItemFailed` may fire more than
  /// once — this matches the existing `testOnItemFailedFiresForInvalidFile`
  /// contract. The guarantee under test is that the fallback is consumed once
  /// and the failure does not loop unboundedly, not an exact callback count.
  func testUnreachableOriginSurfacesFailureOnCachedEngine() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    // Reserved-but-unreachable origin: a routable address whose connection is
    // refused. Both the proxied attempt and the direct fallback must fail.
    let deadURL = URL(string: "http://127.0.0.1:1/nonexistent.aac")!

    let failed = expectation(description: "item failed")
    failed.assertForOverFulfill = false
    var failureCount = 0
    cachedEngine.onItemFailed = { _, _ in
      failureCount += 1
      failed.fulfill()
    }

    cachedEngine.load(url: deadURL)
    wait(for: [failed], timeout: playbackTimeout)

    // The direct fallback cleared the cache key (the fallback is uncached).
    XCTAssertNil(cachedEngine.currentCacheKey)
    XCTAssertEqual(cachedEngine.cachedPosition, 0)

    // Let any looping fallback run; the count must stay bounded (the one-shot
    // fallback is consumed, it does not retry-and-fail indefinitely).
    let settle = expectation(description: "failures settle")
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { settle.fulfill() }
    wait(for: [settle], timeout: 1.5)
    XCTAssertGreaterThanOrEqual(failureCount, 1, "failure must surface at least once")
    XCTAssertLessThanOrEqual(
      failureCount, 2,
      "failure must not loop: at most the two observers (.failed + FailedToPlayToEnd) of the single direct item"
    )
  }

  /// After a proxy-unavailable direct fallback completes, the engine must treat
  /// the load as uncached: `currentCacheKey` is cleared and `cachedPosition` is
  /// zero (the direct origin bypasses the cache proxy).
  func testCacheKeyClearedAfterDirectFallback() throws {
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }

    guard let audioData = createADTSAACData(duration: 1.0) else {
      throw XCTSkip("Could not create ADTS AAC test data")
    }
    let server = try LocalAudioServer(
      audioData: audioData,
      options: .init(includeContentLength: true, contentType: "audio/aac")
    )
    server.start()
    defer { server.stop() }

    cachedEngine.proxyServer?.stop()

    let ready = expectation(description: "ready via direct fallback")
    ready.assertForOverFulfill = false
    cachedEngine.onItemReady = { ready.fulfill() }
    cachedEngine.load(url: server.url)
    wait(for: [ready], timeout: playbackTimeout)

    XCTAssertNil(cachedEngine.currentCacheKey, "direct fallback must be uncached")
    XCTAssertEqual(cachedEngine.cachedPosition, 0)
  }

  // MARK: - ICY

  /// Interleave clean audio with ICY metadata: after each full `metaint` block a
  /// length byte, and a `StreamTitle` block for chunk indices in `titleAtChunk`.
  /// The clean reconstruction equals `audio` exactly.
  private func interleaveICY(_ audio: Data, metaint: Int, titleAtChunk: [Int: String]) -> Data {
    var body = Data()
    var offset = 0
    var chunkIndex = 0
    while offset < audio.count {
      let end = min(offset + metaint, audio.count)
      body.append(audio[audio.index(audio.startIndex, offsetBy: offset)..<audio.index(audio.startIndex, offsetBy: end)])
      if end - offset == metaint {
        if let title = titleAtChunk[chunkIndex] {
          let metaBytes = Data("StreamTitle='\(title)';".utf8)
          let blocks = (metaBytes.count + 15) / 16
          body.append(UInt8(blocks))
          var padded = metaBytes
          padded.append(Data(count: blocks * 16 - metaBytes.count))
          body.append(padded)
        } else {
          body.append(UInt8(0))
        }
        chunkIndex += 1
      }
      offset = end
    }
    return body
  }

  private func waitUntil(timeout: TimeInterval, _ condition: () -> Bool) {
    let deadline = Date().addingTimeInterval(timeout)
    while !condition() && Date() < deadline {
      RunLoop.current.run(until: Date().addingTimeInterval(0.05))
    }
  }

  func testLoadDirectSendsIcyMetadataHeader() throws {
    guard let audioData = createADTSAACData(duration: 0.5) else {
      throw XCTSkip("Could not create ADTS AAC test data")
    }
    let server = try LocalAudioServer(
      audioData: audioData, options: .init(includeContentLength: true, contentType: "audio/aac"))
    server.start(); defer { server.stop() }

    let ready = expectation(description: "ready"); ready.assertForOverFulfill = false
    engine.onItemReady = { ready.fulfill() }
    engine.load(url: server.url, isLive: true)  // no cache → direct path
    wait(for: [ready], timeout: playbackTimeout)

    let icy = server.lastRequestHeaders.first { $0.key.caseInsensitiveCompare("Icy-MetaData") == .orderedSame }
    XCTAssertEqual(icy?.value, "1")
  }

  func testProxiedFiniteICYEmitsTimedMetadata() throws {
    guard let aac = createADTSAACData(duration: 2.0), aac.count >= 256 else {
      throw XCTSkip("Could not create ADTS AAC test data")
    }
    // Adapt the interval to the actual data so at least ~4 full blocks exist and
    // a metadata block lands at chunk 0 regardless of the encoder's output size.
    let metaint = max(64, min(800, aac.count / 4))
    let body = interleaveICY(aac, metaint: metaint, titleAtChunk: [0: "Artist - Song"])
    XCTAssertGreaterThan(body.count, aac.count, "a metadata block must be interleaved")

    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }
    let server = try LocalAudioServer(routes: [
      "/icy.aac": .init(data: body, contentType: "audio/aac", includeContentLength: true, icyMetaint: metaint)
    ])
    server.start(); defer { server.stop() }

    let gotMeta = expectation(description: "timed metadata")
    gotMeta.assertForOverFulfill = false
    var title: String?
    cachedEngine.onTimedMetadata = { md in
      if let t = md.title { title = t; gotMeta.fulfill() }
    }
    cachedEngine.load(url: server.url(forPath: "/icy.aac"))  // isLive false → proxy
    wait(for: [gotMeta], timeout: playbackTimeout)

    XCTAssertEqual(title, "Song")
  }

  func testFullyCachedFiniteICYReplaysTimedMetadataOffline() throws {
    guard let aac = createADTSAACData(duration: 2.0), aac.count >= 256 else {
      throw XCTSkip("Could not create ADTS AAC test data")
    }
    let metaint = max(64, min(800, aac.count / 4))
    let body = interleaveICY(
      aac,
      metaint: metaint,
      titleAtChunk: [0: "Artist - Cached Song"]
    )
    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }
    let server = try LocalAudioServer(routes: [
      "/cached-icy.aac": .init(
        data: body,
        contentType: "audio/aac",
        includeContentLength: true,
        icyMetaint: metaint
      )
    ])
    server.start()
    defer { server.stop() }
    let url = server.url(forPath: "/cached-icy.aac")
    let key = cache.cacheKey(for: url)

    let initialMetadata = expectation(description: "initial ICY metadata")
    initialMetadata.assertForOverFulfill = false
    cachedEngine.onTimedMetadata = { metadata in
      if metadata.title == "Cached Song" { initialMetadata.fulfill() }
    }
    cachedEngine.load(url: url)
    wait(for: [initialMetadata], timeout: playbackTimeout)
    waitUntil(timeout: playbackTimeout) { cache.isFullyCached(key: key) }
    XCTAssertTrue(cache.isFullyCached(key: key))

    cachedEngine.reset()
    server.stop()

    let replayedMetadata = expectation(description: "offline cached ICY metadata")
    replayedMetadata.assertForOverFulfill = false
    cachedEngine.onTimedMetadata = { metadata in
      if metadata.title == "Cached Song" { replayedMetadata.fulfill() }
    }
    cachedEngine.load(url: url)
    cachedEngine.play()
    wait(for: [replayedMetadata], timeout: playbackTimeout)
  }

  func testLiveICYUsesProxyRelayAndEmitsMetadataWithoutCaching() throws {
    guard let aac = createADTSAACData(duration: 1.0), aac.count >= 128 else {
      throw XCTSkip("Could not create ADTS AAC test data")
    }
    let metaint = max(64, min(800, aac.count / 3))
    let body = interleaveICY(
      aac,
      metaint: metaint,
      titleAtChunk: [0: "Artist - Live Song"]
    )

    let cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    let cachedEngine = AVPlayerEngine(cache: cache)
    defer { cachedEngine.reset() }
    // No Content-Length + isLive:true: relay clean audio in memory and parse
    // ICY ourselves instead of relying on AVFoundation timed metadata.
    let server = try LocalAudioServer(routes: [
      "/live.aac": .init(data: body, contentType: "audio/aac", includeContentLength: false, icyMetaint: metaint)
    ])
    server.start(); defer { server.stop() }

    let gotMeta = expectation(description: "live ICY metadata")
    var metadata: StreamMetadata?
    cachedEngine.onTimedMetadata = { value in
      if value.title == "Live Song" {
        metadata = value
        gotMeta.fulfill()
      }
    }

    let url = server.url(forPath: "/live.aac")
    cachedEngine.load(url: url, isLive: true)
    wait(for: [gotMeta], timeout: playbackTimeout)

    XCTAssertEqual(metadata?.artist, "Artist")
    XCTAssertNotNil(cachedEngine.currentCacheKey, "live item must stay on the proxy relay")
    let key = cache.cacheKey(for: url)
    XCTAssertEqual(cache.cachedBytes(for: key), 0, "live relay must never persist audio")
    XCTAssertNil(cache.contentInfo(for: key))
  }

  // MARK: - Helpers

  private lazy var cacheDir: URL = {
    FileManager.default.temporaryDirectory
      .appendingPathComponent("CacheTests-\(UUID().uuidString)", isDirectory: true)
  }()
}
