// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import AVFoundation
import Testing
@preconcurrency @testable import video_player_avfoundation
import video_player_avfoundation_objc

#if os(iOS)
  import Flutter
#else
  import FlutterMacOS
#endif

private let mp4TestURI =
  "https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4"
private let hlsTestURI =
  "https://flutter.github.io/assets-for-api-docs/assets/videos/hls/bee.m3u8"
private let mp3AudioTestURI =
  "https://flutter.github.io/assets-for-api-docs/assets/audio/rooster.mp3"
private let hlsAudioTestURI =
  "https://flutter.github.io/assets-for-api-docs/assets/videos/hls/bee_audio_only.m3u8"

@MainActor struct VideoPlayerTests {

  @Test func blankVideoBugWithEncryptedVideoStreamAndInvertedAspectRatioBugForSomeVideoStream()
    throws
  {
    // This is to fix 2 bugs: 1. blank video for encrypted video streams on iOS 16
    // (https://github.com/flutter/flutter/issues/111457) and 2. swapped width and height for some
    // video streams (not just iOS 16).  (https://github.com/flutter/flutter/issues/109116). An
    // invisible AVPlayerLayer is used to overwrite the protection of pixel buffers in those streams
    // for issue #1, and restore the correct width and height for issue #2.
    #if os(iOS)
      let view = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 10))
      let viewController = UIViewController()
      viewController.view = view
      let viewProvider = StubViewProvider(viewController: viewController)
    #else
      let view = NSView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
      view.wantsLayer = true
      let viewProvider = StubViewProvider(view: view)
    #endif
    let videoPlayerPlugin = try createInitializedPlugin(viewProvider: viewProvider)

    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
    let player =
      videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer

    #expect(player.playerLayer.superlayer == view.layer)
  }

  @Test func playerForPlatformViewDoesNotRegisterTexture() throws {
    let textureRegistry = TestTextureRegistry()
    let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
    let videoPlayerPlugin = try createInitializedPlugin(
      displayLinkFactory: stubDisplayLinkFactory,
      textureRegistry: textureRegistry)

    _ = try videoPlayerPlugin.createPlatformViewPlayer(
      options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))

    #expect(!textureRegistry.registeredTexture)
  }

  @Test func seekToWhilePausedStartsDisplayLinkTemporarily() async throws {
    let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
    let mockVideoOutput = TestPixelBufferSource()
    // Display link and frame updater wire-up is currently done in FVPVideoPlayerPlugin, so create
    // the player via the plugin instead of directly to include that logic in the test.
    let videoPlayerPlugin = try createInitializedPlugin(
      avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
      displayLinkFactory: stubDisplayLinkFactory)

    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
    let player =
      videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer

    // Ensure that the video playback is paused before seeking.
    var error: FlutterError?
    player.pauseWithError(&error)
    #expect(error == nil)

    await asyncSeekTo(player: player, time: 1234)

    // Seeking to a new position should start the display link temporarily.
    #expect(stubDisplayLinkFactory.displayLink.running)

    // Simulate a buffer being available.
    var bufferRef: CVPixelBuffer?
    CVPixelBufferCreate(nil, 1, 1, kCVPixelFormatType_32BGRA, nil, &bufferRef)
    mockVideoOutput.pixelBuffer = bufferRef
    // Simulate a callback from the engine to request a new frame.
    stubDisplayLinkFactory.fireDisplayLink?()
    player.copyPixelBuffer()
    // Since a frame was found, and the video is paused, the display link should be paused again.
    #expect(!stubDisplayLinkFactory.displayLink.running)
  }

  @Test func initStartsDisplayLinkTemporarily() throws {
    let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
    let mockVideoOutput = TestPixelBufferSource()
    let videoPlayerPlugin = try createInitializedPlugin(
      avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
      displayLinkFactory: stubDisplayLinkFactory)

    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))

    // Init should start the display link temporarily.
    #expect(stubDisplayLinkFactory.displayLink.running)

    // Simulate a buffer being available.
    var bufferRef: CVPixelBuffer?
    CVPixelBufferCreate(nil, 1, 1, kCVPixelFormatType_32BGRA, nil, &bufferRef)
    mockVideoOutput.pixelBuffer = bufferRef
    // Simulate a callback from the engine to request a new frame.
    let player =
      videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer
    stubDisplayLinkFactory.fireDisplayLink?()
    player.copyPixelBuffer()
    // Since a frame was found, and the video is paused, the display link should be paused again.
    #expect(!stubDisplayLinkFactory.displayLink.running)
  }

  @Test func seekToWhilePlayingDoesNotStopDisplayLink() async throws {
    let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
    let mockVideoOutput = TestPixelBufferSource()
    let videoPlayerPlugin = try createInitializedPlugin(
      avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
      displayLinkFactory: stubDisplayLinkFactory)

    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
    let player =
      videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer

    // Ensure that the video is playing before seeking.
    var error: FlutterError?
    player.playWithError(&error)
    #expect(error == nil)

    await asyncSeekTo(player: player, time: 1234)

    #expect(stubDisplayLinkFactory.displayLink.running)

    // Simulate a buffer being available.
    var bufferRef: CVPixelBuffer?
    CVPixelBufferCreate(nil, 1, 1, kCVPixelFormatType_32BGRA, nil, &bufferRef)
    mockVideoOutput.pixelBuffer = bufferRef
    // Simulate a callback from the engine to request a new frame.
    stubDisplayLinkFactory.fireDisplayLink?()
    // Since the video was playing, the display link should not be paused after getting a buffer.
    #expect(stubDisplayLinkFactory.displayLink.running)
  }

  @Test func pauseWhileWaitingForFrameDoesNotStopDisplayLink() throws {
    let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
    // Display link and frame updater wire-up is currently done in FVPVideoPlayerPlugin, so create
    // the player via the plugin instead of directly to include that logic in the test.
    let videoPlayerPlugin = try createInitializedPlugin(displayLinkFactory: stubDisplayLinkFactory)

    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
    let player =
      videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer

    // Run a play/pause cycle to force the pause codepath to run completely.
    var error: FlutterError?
    player.playWithError(&error)
    #expect(error == nil)
    player.pauseWithError(&error)
    #expect(error == nil)

    // Since a buffer hasn't been available yet, the pause should not have stopped the display link.
    #expect(stubDisplayLinkFactory.displayLink.running)
  }

  @Test func disposeWhilePlayingStopsDisplayLink() async throws {
    let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
    let mockVideoOutput = TestPixelBufferSource()
    let videoPlayerPlugin = try createInitializedPlugin(
      avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
      displayLinkFactory: stubDisplayLinkFactory)

    var error: FlutterError?
    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))
    let player =
      videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer

    player.playWithError(&error)
    #expect(error == nil)
    player.disposeWithError(&error)
    #expect(error == nil)

    #expect(!stubDisplayLinkFactory.displayLink.running)
  }

  @Test func deregistersFromPlayer() throws {
    let videoPlayerPlugin = try createInitializedPlugin()

    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
    let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])

    var error: FlutterError?
    player.disposeWithError(&error)
    #expect(error == nil)
    #expect(videoPlayerPlugin.playersByIdentifier.count == 0)
  }

  @Test func bufferingStateFromPlayer() async throws {
    // TODO(stuartmorgan): Rewrite this test to use stubs, instead of running for 10
    // seconds with a real player and hoping to get buffer status updates.
    let realObjectFactory = FVPDefaultAVFactory()
    let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)

    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
    let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])
    let avPlayer = player.player
    avPlayer.play()

    let eventSink: FlutterEventSink = { event in
      guard let event = event as? [String: Any], let eventType = event["event"] as? String else {
        return
      }
      if eventType == "bufferingEnd" {
        #expect(avPlayer.currentItem!.isPlaybackLikelyToKeepUp)
      }
      if eventType == "bufferingStart" {
        #expect(!avPlayer.currentItem!.isPlaybackLikelyToKeepUp)
      }
    }
    (player.eventListener as? FlutterStreamHandler)?.onListen(
      withArguments: nil, eventSink: eventSink)

    // Load for a while to let some buffer events happen.
    try await Task.sleep(nanoseconds: 10 * 1_000_000_000)
  }

  private func durationApproximatelyEquals(_ actual: Int64, _ expected: Int64, tolerance: Int64)
    -> Bool
  {
    return abs(actual - expected) < tolerance
  }

  @Test func videoControls() async throws {
    let eventListener = try await sanityTestURI(mp4TestURI)
    #expect(eventListener.initializationSize.height == 720)
    #expect(eventListener.initializationSize.width == 1280)
    #expect(durationApproximatelyEquals(eventListener.initializationDuration, 4000, tolerance: 200))
  }

  @Test func audioControls() async throws {
    let eventListener = try await sanityTestURI(mp3AudioTestURI)
    #expect(eventListener.initializationSize.height == 0)
    #expect(eventListener.initializationSize.width == 0)
    #expect(durationApproximatelyEquals(eventListener.initializationDuration, 5400, tolerance: 200))
  }

  @Test func hLSControls() async throws {
    let eventListener = try await sanityTestURI(hlsTestURI)
    #expect(eventListener.initializationSize.height == 720)
    #expect(eventListener.initializationSize.width == 1280)
    #expect(durationApproximatelyEquals(eventListener.initializationDuration, 4000, tolerance: 200))
  }

  @Test(.disabled("Flaky"), .bug("https://github.com/flutter/flutter/issues/164381"))
  func audioOnlyHLSControls() async throws {
    let eventListener = try await sanityTestURI(hlsAudioTestURI)
    #expect(eventListener.initializationSize.height == 0)
    #expect(eventListener.initializationSize.width == 0)
    #expect(durationApproximatelyEquals(eventListener.initializationDuration, 4000, tolerance: 200))
  }

  #if os(iOS)
    @Test func transformFixOrientationUp() {
      let size = CGSize(width: 800, height: 600)
      let naturalTransform = CGAffineTransform.identity
      let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
      #expect(t.tx == 0)
      #expect(t.ty == 0)
    }

    @Test func transformFixOrientationDown() {
      let size = CGSize(width: 800, height: 600)
      let naturalTransform = CGAffineTransform(a: -1, b: 0, c: 0, d: -1, tx: 0, ty: 0)
      let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
      #expect(t.tx == size.width)
      #expect(t.ty == size.height)
    }

    @Test func transformFixOrientationLeft() {
      let size = CGSize(width: 800, height: 600)
      let naturalTransform = CGAffineTransform(a: 0, b: -1, c: 1, d: 0, tx: 0, ty: 0)
      let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
      #expect(t.tx == 0)
      #expect(t.ty == size.width)
    }

    @Test func transformFixOrientationRight() {
      let size = CGSize(width: 800, height: 600)
      let naturalTransform = CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 0, ty: 0)
      let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
      #expect(t.tx == size.height)
      #expect(t.ty == 0)
    }

    @Test func transformFixOrientationUpMirrored() {
      let size = CGSize(width: 800, height: 600)
      let naturalTransform = CGAffineTransform(a: -1, b: 0, c: 0, d: 1, tx: 0, ty: 0)
      let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
      #expect(t.tx == size.width)
      #expect(t.ty == 0)
    }

    @Test func transformFixOrientationDownMirrored() {
      let size = CGSize(width: 800, height: 600)
      let naturalTransform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0)
      let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
      #expect(t.tx == 0)
      #expect(t.ty == size.height)
    }

    @Test func transformFixOrientationLeftMirrored() {
      let size = CGSize(width: 800, height: 600)
      let naturalTransform = CGAffineTransform(a: 0, b: -1, c: -1, d: 0, tx: 0, ty: 0)
      let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
      #expect(t.tx == size.height)
      #expect(t.ty == size.width)
    }

    @Test func transformFixOrientationRightMirrored() {
      let size = CGSize(width: 800, height: 600)
      let naturalTransform = CGAffineTransform(a: 0, b: 1, c: 1, d: 0, tx: 0, ty: 0)
      let t = FVPGetStandardizedTrackTransform(naturalTransform, size)
      #expect(t.tx == 0)
      #expect(t.ty == 0)
    }
  #endif

  @Test func seekToleranceWhenNotSeekingToEnd() async {
    let inspectableAVPlayer = InspectableAVPlayer()
    let stubAVFactory = StubFVPAVFactory(player: inspectableAVPlayer)
    let player = FVPVideoPlayer(
      playerItem: StubPlayerItem(),
      avFactory: stubAVFactory,
      viewProvider: StubViewProvider())
    let listener = StubEventListener()
    player.eventListener = listener

    await asyncSeekTo(player: player, time: 1234)

    #expect(inspectableAVPlayer.beforeTolerance?.intValue == 0)
    #expect(inspectableAVPlayer.afterTolerance?.intValue == 0)
  }

  @Test func seekToleranceWhenSeekingToEnd() async {
    let inspectableAVPlayer = InspectableAVPlayer()
    let stubAVFactory = StubFVPAVFactory(player: inspectableAVPlayer)
    let player = FVPVideoPlayer(
      playerItem: StubPlayerItem(),
      avFactory: stubAVFactory,
      viewProvider: StubViewProvider())
    let listener = StubEventListener()
    player.eventListener = listener

    await asyncSeekTo(player: player, time: 0)

    #expect((inspectableAVPlayer.beforeTolerance?.intValue ?? 0) > 0)
    #expect((inspectableAVPlayer.afterTolerance?.intValue ?? 0) > 0)
  }

  @Test func setPreventsDisplaySleepDuringVideoPlayback() {
    let stubAVFactory = StubFVPAVFactory(player: AVPlayer())
    let player = FVPVideoPlayer(
      playerItem: StubPlayerItem(),
      avFactory: stubAVFactory,
      viewProvider: StubViewProvider())
    let listener = StubEventListener()
    player.eventListener = listener

    var error: FlutterError?
    // The setter should update the underlying AVPlayer in both directions.
    player.setPreventsDisplaySleepDuringVideoPlayback(false, error: &error)
    #expect(error == nil)
    #expect(player.player.preventsDisplaySleepDuringVideoPlayback == false)

    player.setPreventsDisplaySleepDuringVideoPlayback(true, error: &error)
    #expect(error == nil)
    #expect(player.player.preventsDisplaySleepDuringVideoPlayback == true)
  }

  /// Sanity checks a video player playing the given URL with the actual AVPlayer. This is essentially
  /// a mini integration test of the player component.
  ///
  /// Returns the stub event listener to allow tests to inspect the call state.
  func sanityTestURI(_ testURI: String) async throws -> StubEventListener {
    let realObjectFactory = FVPDefaultAVFactory()
    let testURL = try #require(URL(string: testURI))
    let player = FVPVideoPlayer(
      playerItem: playerItem(with: testURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    let listener = StubEventListener()
    await withCheckedContinuation { continuation in
      listener.onInitialized = { continuation.resume() }
      player.eventListener = listener
    }

    // Starts paused.
    let avPlayer = player.player
    #expect(avPlayer.rate == 0)
    #expect(avPlayer.volume == 1)
    #expect(avPlayer.timeControlStatus == .paused)

    // Change playback speed.
    var error: FlutterError?
    player.setPlaybackSpeed(2, error: &error)
    #expect(error == nil)
    player.playWithError(&error)
    #expect(error == nil)
    #expect(avPlayer.rate == 2)
    #expect(avPlayer.timeControlStatus == .waitingToPlayAtSpecifiedRate)

    // Volume
    player.setVolume(0.1, error: &error)
    #expect(error == nil)
    #expect(avPlayer.volume == 0.1)

    return listener
  }

  // Checks whether [AVPlayer rate] KVO observations are correctly detached.
  // - https://github.com/flutter/flutter/issues/124937
  //
  // Failing to de-register results in a crash in [AVPlayer willChangeValueForKey:].
  @Test func doesNotCrashOnRateObservationAfterDisposal() async throws {
    let realObjectFactory = FVPDefaultAVFactory()

    var avPlayer: AVPlayer? = nil
    weak var weakPlayer: FVPVideoPlayer? = nil

    // Autoreleasepool is needed to simulate conditions of FVPVideoPlayer deallocation.
    try autoreleasepool {
      let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)

      var error: FlutterError?
      let identifiers = try videoPlayerPlugin.createTexturePlayer(
        options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))

      let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])
      weakPlayer = player
      avPlayer = player.player

      player.disposeWithError(&error)
      #expect(error == nil)
    }

    // Wait for the weak pointer to be invalidated, indicating that the player has been deallocated.
    let checkInterval = 0.1
    let maxTries = Int64(30 / checkInterval)
    for _ in 1...maxTries {
      if weakPlayer == nil {
        break
      }
      try await Task.sleep(nanoseconds: UInt64(checkInterval * 1_000_000_000))
    }

    await MainActor.run {
      avPlayer?.willChangeValue(forKey: "rate")
      avPlayer?.didChangeValue(forKey: "rate")
    }
    // No assertions needed. Lack of crash is a success.
  }

  // During the hot reload:
  //  1. `[FVPVideoPlayer onTextureUnregistered:]` gets called.
  //  2. `[FVPVideoPlayerPlugin initialize:]` gets called.
  //
  // Both of these methods dispatch [FVPVideoPlayer dispose] on the main thread
  // leading to a possible crash when de-registering observers twice.
  @Test func hotReloadDoesNotCrash() async throws {
    weak var weakPlayer: FVPVideoPlayer? = nil

    // Autoreleasepool is needed to simulate conditions of FVPVideoPlayer deallocation.
    try autoreleasepool {
      let videoPlayerPlugin = try createInitializedPlugin(avFactory: StubFVPAVFactory())

      let identifiers = try videoPlayerPlugin.createTexturePlayer(
        options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))

      let player =
        videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPTextureBasedVideoPlayer
      weakPlayer = player

      player.onTextureUnregistered(StubTexture())

      try videoPlayerPlugin.initialize()
    }

    // Wait for the weak pointer to be invalidated, indicating that the player has been deallocated.
    let checkInterval = 0.1
    let maxTries = Int64(30 / checkInterval)
    for _ in 1...maxTries {
      if weakPlayer == nil {
        break
      }
      try await Task.sleep(nanoseconds: UInt64(checkInterval * 1_000_000_000))
    }
    // No assertions needed. Lack of crash is a success.
  }

  @Test func failedToLoadVideoEventShouldBeAlwaysSent() async throws {
    // Use real objects to test a real failure flow.
    let realObjectFactory = FVPDefaultAVFactory()
    let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)

    // Provide a URI that is a valid URI, but not a video, to trigger a failure inside AVPlayer.
    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: "https://flutter.dev", httpHeaders: [:]))
    let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])

    await withCheckedContinuation { continuation in
      // TODO(stuartmorgan): Update this test to instead use a mock listener, and add separate unit
      // tests of FVPEventBridge.
      let eventSink: FlutterEventSink = { event in
        if event is FlutterError {
          continuation.resume()
        }
      }
      (player.eventListener as? FlutterStreamHandler)?.onListen(
        withArguments: nil, eventSink: eventSink)
    }
  }

  @Test func updatePlayingStateShouldNotResetRate() async throws {
    let realObjectFactory = FVPDefaultAVFactory()
    let testURL = try #require(URL(string: mp4TestURI))
    let player = FVPVideoPlayer(
      playerItem: playerItem(with: testURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    await withCheckedContinuation { continuation in
      let listener = StubEventListener(onInitialized: { continuation.resume() })
      player.eventListener = listener
    }

    var error: FlutterError?
    player.setPlaybackSpeed(2, error: &error)
    #expect(error == nil)
    player.playWithError(&error)
    #expect(error == nil)
    #expect(player.player.rate == 2)
  }

  @Test func playerShouldNotDropEverySecondFrame() throws {
    let textureRegistry = TestTextureRegistry()
    let stubDisplayLinkFactory = StubFVPDisplayLinkFactory()
    let mockVideoOutput = TestPixelBufferSource()
    let videoPlayerPlugin = try createInitializedPlugin(
      avFactory: StubFVPAVFactory(pixelBufferSource: mockVideoOutput),
      displayLinkFactory: stubDisplayLinkFactory,
      textureRegistry: textureRegistry)

    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
    let playerIdentifier = identifiers.playerId
    let player =
      videoPlayerPlugin.playersByIdentifier[playerIdentifier] as! FVPTextureBasedVideoPlayer

    func addFrame() {
      var bufferRef: CVPixelBuffer?
      CVPixelBufferCreate(nil, 1, 1, kCVPixelFormatType_32BGRA, nil, &bufferRef)
      mockVideoOutput.pixelBuffer = bufferRef
    }

    addFrame()
    stubDisplayLinkFactory.fireDisplayLink?()
    player.copyPixelBuffer()
    #expect(textureRegistry.textureFrameAvailableCount == 1)

    addFrame()
    stubDisplayLinkFactory.fireDisplayLink?()
    player.copyPixelBuffer()
    #expect(textureRegistry.textureFrameAvailableCount == 2)
  }

  @Test func videoOutputIsAddedWhenAVPlayerIsInitialized() async throws {
    let realObjectFactory = FVPDefaultAVFactory()
    let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)

    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))
    let player = try #require(videoPlayerPlugin.playersByIdentifier[identifiers.playerId])

    let listener = StubEventListener()
    await withCheckedContinuation { continuation in
      listener.onInitialized = { continuation.resume() }
      player.eventListener = listener
    }

    let item = try #require(player.player.currentItem)
    // Video output is added as soon as the status becomes ready to play.
    #expect(item.outputs.count == 1)
  }

  #if os(iOS)
    @Test func videoPlayerShouldNotOverwritePlayAndRecordNorDefaultToSpeaker() throws {
      let stubFactory = StubFVPAVFactory()
      let audioSession = TestAudioSession()
      stubFactory.audioSession = audioSession
      audioSession.category = .playAndRecord
      audioSession.categoryOptions = .defaultToSpeaker
      let videoPlayerPlugin = try createInitializedPlugin(avFactory: stubFactory)

      try videoPlayerPlugin.setMixWithOthers(true)
      #expect(audioSession.category == .playAndRecord)
      #expect(audioSession.categoryOptions.contains(.defaultToSpeaker))
      #expect(audioSession.categoryOptions.contains(.mixWithOthers))
    }

    @Test func setMixWithOthersShouldNoOpWhenNoChangesAreRequired() throws {
      let stubFactory = StubFVPAVFactory()
      let audioSession = TestAudioSession()
      stubFactory.audioSession = audioSession
      audioSession.category = .playAndRecord
      audioSession.categoryOptions = [.mixWithOthers, .defaultToSpeaker]
      let videoPlayerPlugin = try createInitializedPlugin(avFactory: stubFactory)

      try videoPlayerPlugin.setMixWithOthers(true)
      #expect(!audioSession.setCategoryCalled)
    }
  #endif

  // MARK: - Audio Track Tests

  // Tests getAudioTracks with a regular MP4 video file using real AVFoundation.
  // Regular MP4 files do not have media selection groups, so getAudioTracks returns an empty array.
  @Test func getAudioTracksWithRealMP4Video() async throws {
    let realObjectFactory = FVPDefaultAVFactory()
    let testURL = try #require(URL(string: mp4TestURI))
    let player = FVPVideoPlayer(
      playerItem: playerItem(with: testURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    await withCheckedContinuation { continuation in
      let listener = StubEventListener(onInitialized: { continuation.resume() })
      player.eventListener = listener
    }

    // Now test getAudioTracks
    var error: FlutterError?
    let result = try #require(player.getAudioTracks(&error))
    #expect(error == nil)

    // Regular MP4 files do not have media selection groups for audio.
    // getAudioTracks only returns selectable audio tracks from HLS streams.
    #expect(result.count == 0)

    player.disposeWithError(&error)
  }

  // Tests getAudioTracks with an HLS stream using real AVFoundation.
  // HLS streams use media selection groups for audio track selection.
  @Test func getAudioTracksWithRealHLSStream() async throws {
    let realObjectFactory = FVPDefaultAVFactory()
    let hlsURL = try #require(URL(string: hlsTestURI))

    let player = FVPVideoPlayer(
      playerItem: playerItem(with: hlsURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    await withCheckedContinuation { continuation in
      let listener = StubEventListener(onInitialized: { continuation.resume() })
      player.eventListener = listener
    }

    // Now test getAudioTracks
    var error: FlutterError?
    let result = try #require(player.getAudioTracks(&error))
    #expect(error == nil)

    // For HLS streams with multiple audio options, we get media selection tracks.
    // The bee.m3u8 stream may or may not have multiple audio tracks.
    // We verify the method returns valid data without crashing.
    for track in result {
      #expect(track.displayName != nil)
      #expect(track.index >= 0)
    }

    player.disposeWithError(&error)
  }

  // Tests that getAudioTracks returns valid data for audio-only files.
  // Regular audio files do not have media selection groups, so getAudioTracks returns an empty array.
  @Test func getAudioTracksWithRealAudioFile() async throws {
    // TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
    // can use a fake item/asset instead of loading an actual remote asset.
    let realObjectFactory = FVPDefaultAVFactory()
    let audioURL = try #require(URL(string: mp3AudioTestURI))

    let player = FVPVideoPlayer(
      playerItem: playerItem(with: audioURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    await withCheckedContinuation { continuation in
      let listener = StubEventListener(onInitialized: { continuation.resume() })
      player.eventListener = listener
    }

    // Now test getAudioTracks
    var error: FlutterError?
    let result = try #require(player.getAudioTracks(&error))
    #expect(error == nil)

    // Regular audio files do not have media selection groups.
    // getAudioTracks only returns selectable audio tracks from HLS streams.
    #expect(result.count == 0)

    player.disposeWithError(&error)
  }

  // Tests that getAudioTracks works correctly through the plugin API with a real video.
  // Regular MP4 files do not have media selection groups, so getAudioTracks returns an empty array.
  @Test func getAudioTracksViaPluginWithRealVideo() async throws {
    // TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
    // can use a fake item/asset instead of loading an actual remote asset.
    let realObjectFactory = FVPDefaultAVFactory()
    let testURL = try #require(URL(string: mp4TestURI))
    let player = FVPVideoPlayer(
      playerItem: playerItem(with: testURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    // Wait for player to become ready
    let listener = StubEventListener()
    await withCheckedContinuation { continuation in
      listener.onInitialized = { continuation.resume() }
      player.eventListener = listener
    }

    // Now test getAudioTracks
    var error: FlutterError?
    let result = try #require(player.getAudioTracks(&error))
    #expect(error == nil)

    // Regular MP4 files do not have media selection groups.
    // getAudioTracks only returns selectable audio tracks from HLS streams.
    #expect(result.count == 0)

    player.disposeWithError(&error)
  }

  @Test func loadTracksWithMediaTypeIsCalledOnNewerOS() {
    if #available(iOS 15.0, macOS 12.0, *) {
      let mockAsset = TestAsset(duration: CMTimeMake(value: 1, timescale: 1), tracks: [])
      let item = StubPlayerItem(asset: mockAsset)

      let stubAVFactory = StubFVPAVFactory(player: nil, playerItem: item, pixelBufferSource: nil)
      let stubViewProvider = StubViewProvider()
      let _ = FVPVideoPlayer(
        playerItem: item, avFactory: stubAVFactory, viewProvider: stubViewProvider)
      #expect(mockAsset.loadedTracksAsynchronously)
    }
  }

  @Test func videoOutputIsConfiguredWithBT709ColorProperties() throws {
    let item = StubPlayerItem()
    let stubAVFactory = StubFVPAVFactory(player: nil, playerItem: item, pixelBufferSource: nil)
    let stubViewProvider = StubViewProvider()
    let _ = FVPVideoPlayer(
      playerItem: item, avFactory: stubAVFactory, viewProvider: stubViewProvider)

    // BT.709 color properties are required so AVFoundation tone-maps HDR sources
    // into the Flutter texture. Without them HDR samples arrive unconverted and
    // render washed out. See flutter/flutter#91241.
    let settings = try #require(stubAVFactory.lastOutputSettings)
    let colorProperties = try #require(
      settings[AVVideoColorPropertiesKey] as? [String: Any])
    #expect(
      colorProperties[AVVideoColorPrimariesKey] as? String == AVVideoColorPrimaries_ITU_R_709_2)
    #expect(
      colorProperties[AVVideoTransferFunctionKey] as? String
        == AVVideoTransferFunction_ITU_R_709_2)
    #expect(
      colorProperties[AVVideoYCbCrMatrixKey] as? String == AVVideoYCbCrMatrix_ITU_R_709_2)
  }

  // MARK: - Video Track Tests

  // Integration test for getVideoTracks with a non-HLS MP4 video over a live network request.
  // Non-HLS MP4 files don't have adaptive bitrate variants, so we expect empty media selection
  // tracks.
  @Test func getVideoTracksWithRealMP4Video() async throws {
    // TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
    // can use a fake item/asset instead of loading an actual remote asset.
    let realObjectFactory = FVPDefaultAVFactory()
    let testURL = try #require(URL(string: mp4TestURI))

    let player = FVPVideoPlayer(
      playerItem: playerItem(with: testURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    await withCheckedContinuation { continuation in
      let listener = StubEventListener(onInitialized: { continuation.resume() })
      player.eventListener = listener
    }

    // Now test getVideoTracks
    await withCheckedContinuation { continuation in
      player.getVideoTracks { result, error in
        #expect(error == nil)
        #expect(result != nil)
        // For regular MP4 files, media selection tracks should be nil (no HLS variants)
        // The method returns empty data for non-HLS content
        continuation.resume()
      }
    }

    var disposeError: FlutterError?
    player.disposeWithError(&disposeError)
  }

  // Integration test for getVideoTracks with an HLS stream over a live network request.
  // HLS streams use AVAssetVariant API (iOS 15+) to enumerate available quality variants.
  @Test func getVideoTracksWithRealHLSStream() async throws {
    // TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
    // can use a fake item/asset instead of loading an actual remote asset.
    let realObjectFactory = FVPDefaultAVFactory()
    let hlsURL = try #require(URL(string: hlsTestURI))

    let player = FVPVideoPlayer(
      playerItem: playerItem(with: hlsURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    await withCheckedContinuation { continuation in
      let listener = StubEventListener(onInitialized: { continuation.resume() })
      player.eventListener = listener
    }

    // Now test getVideoTracks
    await withCheckedContinuation { continuation in
      player.getVideoTracks { result, error in
        #expect(error == nil)
        #expect(result != nil)

        // For HLS streams on iOS 15+, we may have media selection tracks (variants)
        if #available(iOS 15.0, macOS 12.0, *) {
          // The bee.m3u8 stream may or may not have multiple video variants.
          // We verify the method returns valid data without crashing.
          if let mediaSelectionTracks = result?.mediaSelectionTracks {
            // If media selection tracks exist, they should have valid structure
            for track in mediaSelectionTracks {
              #expect(track.variantIndex >= 0)
              // Bitrate should be positive if present
              if let bitrate = track.bitrate {
                #expect(bitrate.intValue > 0)
              }
            }
          }
        }
        continuation.resume()
      }
    }

    var disposeError: FlutterError?
    player.disposeWithError(&disposeError)
  }

  // Tests selectVideoTrack sets preferredPeakBitRate correctly.
  @Test func selectVideoTrackSetsBitrate() async throws {
    // TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
    // can use a fake item/asset instead of loading an actual remote asset.
    let realObjectFactory = FVPDefaultAVFactory()
    let testURL = try #require(URL(string: mp4TestURI))

    let player = FVPVideoPlayer(
      playerItem: playerItem(with: testURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    await withCheckedContinuation { continuation in
      let listener = StubEventListener(onInitialized: { continuation.resume() })
      player.eventListener = listener
    }

    var error: FlutterError?
    // Set a specific bitrate
    player.selectVideoTrack(withBitrate: 5_000_000, error: &error)
    #expect(error == nil)
    #expect(player.player.currentItem?.preferredPeakBitRate == 5_000_000)

    player.disposeWithError(&error)
  }

  // Tests selectVideoTrack with 0 bitrate enables auto quality selection.
  @Test func selectVideoTrackAutoQuality() async throws {
    // TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
    // can use a fake item/asset instead of loading an actual remote asset.
    let realObjectFactory = FVPDefaultAVFactory()
    let testURL = try #require(URL(string: mp4TestURI))

    let player = FVPVideoPlayer(
      playerItem: playerItem(with: testURL, factory: realObjectFactory),
      avFactory: realObjectFactory,
      viewProvider: StubViewProvider())

    await withCheckedContinuation { continuation in
      let listener = StubEventListener(onInitialized: { continuation.resume() })
      player.eventListener = listener
    }

    var error: FlutterError?
    // First set a specific bitrate
    player.selectVideoTrack(withBitrate: 5_000_000, error: &error)
    #expect(error == nil)
    #expect(player.player.currentItem?.preferredPeakBitRate == 5_000_000)

    // Then set to auto quality (0)
    player.selectVideoTrack(withBitrate: 0, error: &error)
    #expect(error == nil)
    #expect(player.player.currentItem?.preferredPeakBitRate == 0)

    player.disposeWithError(&error)
  }

  // Tests that getVideoTracks works correctly through the plugin API with a real video.
  @Test func getVideoTracksViaPluginWithRealVideo() async throws {
    // TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
    // can use a fake item/asset instead of loading an actual remote asset.
    let realObjectFactory = FVPDefaultAVFactory()
    let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)

    var error: FlutterError?
    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: mp4TestURI, httpHeaders: [:]))

    let player = videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPVideoPlayer
    #expect(player != nil)

    // Wait for player item to become ready
    let item = try #require(player.player.currentItem)
    try await waitForPlayerItemStatus(item, state: .readyToPlay)

    // Now test getVideoTracks
    await withCheckedContinuation { continuation in
      player.getVideoTracks { result, error in
        #expect(error == nil)
        #expect(result != nil)
        continuation.resume()
      }
    }

    player.disposeWithError(&error)
  }

  // Tests selectVideoTrack via plugin API with HLS stream.
  @Test func selectVideoTrackViaPluginWithHLSStream() async throws {
    // TODO(stuartmorgan): Add more use of protocols in FVPVideoPlayer so that this test
    // can use a fake item/asset instead of loading an actual remote asset.
    let realObjectFactory = FVPDefaultAVFactory()
    let videoPlayerPlugin = try createInitializedPlugin(avFactory: realObjectFactory)

    var error: FlutterError?
    // Use HLS stream which supports adaptive bitrate
    let identifiers = try videoPlayerPlugin.createTexturePlayer(
      options: CreationOptions(uri: hlsTestURI, httpHeaders: [:]))

    let player = videoPlayerPlugin.playersByIdentifier[identifiers.playerId] as! FVPVideoPlayer
    #expect(player != nil)

    // Wait for player item to become ready
    let item = try #require(player.player.currentItem)
    try await waitForPlayerItemStatus(item, state: .readyToPlay)

    // Test setting a specific bitrate
    player.selectVideoTrack(withBitrate: 1_000_000, error: &error)
    #expect(error == nil)
    #expect(player.player.currentItem?.preferredPeakBitRate == 1_000_000)

    // Test setting auto quality
    player.selectVideoTrack(withBitrate: 0, error: &error)
    #expect(error == nil)
    #expect(player.player.currentItem?.preferredPeakBitRate == 0)

    player.disposeWithError(&error)
  }

  // MARK: - Helper Methods

  /// Creates a plugin with the given dependencies, and default stubs for any that aren't provided,
  /// then initializes it.
  private func createInitializedPlugin(
    avFactory: FVPAVFactory = StubFVPAVFactory(),
    displayLinkFactory: DisplayLinkFactory = StubFVPDisplayLinkFactory(),
    binaryMessenger: FlutterBinaryMessenger = StubBinaryMessenger(),
    textureRegistry: FlutterTextureRegistry = TestTextureRegistry(),
    viewProvider: FVPViewProvider = StubViewProvider(),
    assetProvider: FVPAssetProvider = StubAssetProvider()
  ) throws -> VideoPlayerPlugin {
    let plugin = VideoPlayerPlugin(
      avFactory: avFactory,
      displayLinkFactory: displayLinkFactory,
      binaryMessenger: binaryMessenger,
      textureRegistry: textureRegistry,
      viewProvider: viewProvider,
      assetProvider: assetProvider)
    try plugin.initialize()
    return plugin
  }

  private func playerItem(with url: URL, factory: FVPAVFactory) -> FVPAVPlayerItem {
    let asset = factory.urlAsset(with: url, options: nil)
    return factory.playerItem(with: asset)
  }

  private func waitForPlayerItemStatus(_ item: AVPlayerItem, state: AVPlayerItem.Status)
    async
    throws
  {
    try await withCheckedThrowingContinuation { continuation in
      // Check whether it already has the desired status.
      if item.status == state {
        continuation.resume()
        return
      }

      if item.status == .failed {
        continuation.resume(throwing: item.error ?? NSError(domain: "VideoPlayerTests", code: 1))
        return
      }

      // If not, wait for that status. The observation token must be retained until the callback
      // fires; otherwise KVO unregisters immediately and the continuation is never resumed.
      var observation: NSKeyValueObservation?
      observation = item.observe(\.status, options: [.initial, .new]) { observedItem, _ in
        if observedItem.status == state {
          observation?.invalidate()
          observation = nil
          continuation.resume()
        } else if observedItem.status == .failed {
          observation?.invalidate()
          observation = nil
          continuation.resume(
            throwing: observedItem.error ?? NSError(domain: "VideoPlayerTests", code: 1))
        }
      }
    }
  }

  // TODO(stuartmorgan): Remove this in favor of just `await player.seek(...)` once
  // Pigeon is generating Swift 6-friendly output. Currently using the automatic async
  // conversion generates warnings due to the lack of concurrency annotations
  // ("non-sendable type 'FlutterError?' returned by implicitly asynchronous call to
  // nonisolated function cannot cross actor boundary").
  private func asyncSeekTo(player: FVPVideoPlayer, time: Int) async {
    await withCheckedContinuation { continuation in
      player.seek(to: time) { error in
        #expect(error == nil)
        continuation.resume()
      }
    }
  }
}
