//
// 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 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: - Helpers

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