import AVFoundation
import Libmpv
import UIKit
import Flutter
import XCTest

@testable import Runner

final class ControllablePropertyCore: MpvPlayerCoreBase {
  var nextResult: Result<Void, Error>?
  private(set) var propertyCalls: [(String, String)] = []
  private var pendingCompletion: ((Result<Void, Error>) -> Void)?

  override func setPropertyAsync(
    _ name: String,
    value: String,
    completion: @escaping (Result<Void, Error>) -> Void
  ) {
    propertyCalls.append((name, value))
    if let nextResult {
      self.nextResult = nil
      completion(nextResult)
    } else {
      pendingCompletion = completion
    }
  }

  func finish(_ result: Result<Void, Error>) {
    let completion = pendingCompletion
    pendingCompletion = nil
    completion?(result)
  }
}

final class RecordingMpvPlugin: MpvPluginShared {
  var coreBase: MpvPlayerCoreBase?
  var eventSink: FlutterEventSink?
  var nameToId: [String: Int] = [:]
  private(set) var pauseHookValues: [String] = []

  init(core: MpvPlayerCoreBase?) {
    coreBase = core
  }

  func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {}
  func updatePlayerFrame() {}

  func didSetPauseProperty(value: String) {
    pauseHookValues.append(value)
  }
}

final class RecordingLifecycleDelegate: MpvPlayerDelegate {
  private(set) var events: [String] = []
  private(set) var properties: [String] = []

  func onPropertyChange(name: String, value: Any?) {
    properties.append(name)
  }

  func onEvent(name: String, data: [String: Any]?) {
    events.append(name)
  }
}

final class FakePictureInPictureController: MpvPictureInPictureControlling {
  var isPictureInPicturePossible = false
  private(set) var startCount = 0
  private(set) var stopCount = 0
  private(set) var automaticStartValues: [Bool] = []
  private(set) var invalidateCount = 0

  func startPictureInPicture() { startCount += 1 }
  func stopPictureInPicture() { stopCount += 1 }
  func setAutomaticStart(_ enabled: Bool) { automaticStartValues.append(enabled) }
  func invalidatePlaybackState() { invalidateCount += 1 }
}

final class RecordingPipDelegate: MpvPipDelegate {
  private(set) var events: [String] = []
  var onDidStart: (() -> Void)?
  func pipWillStart() { events.append("willStart") }
  func pipDidStart() {
    onDidStart?()
    events.append("didStart")
  }
  func pipDidStop(restored: Bool) { events.append("didStop:\(restored)") }
  func pipDidFailToStart(error: Error?) { events.append("failed") }
  func pipSetPlaying(_ playing: Bool) {}
  func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) { completion() }
  var isPipPlaying: Bool { true }
  var pipDuration: Double { 60 }
}

final class ReleaseTrackingCore: MpvPlayerCoreBase {
  let onDeinit: () -> Void
  init(onDeinit: @escaping () -> Void) {
    self.onDeinit = onDeinit
    super.init()
  }
  deinit { onDeinit() }
}

final class ProbeURLProtocol: URLProtocol {
  private static let lock = NSLock()
  private static var startHandler: ((ProbeURLProtocol) -> Void)?
  private static var stopHandler: (() -> Void)?

  static func configure(
    start: @escaping (ProbeURLProtocol) -> Void,
    stop: (() -> Void)? = nil
  ) {
    lock.lock()
    startHandler = start
    stopHandler = stop
    lock.unlock()
  }

  static func reset() {
    lock.lock()
    startHandler = nil
    stopHandler = nil
    lock.unlock()
  }

  override class func canInit(with request: URLRequest) -> Bool { true }
  override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

  override func startLoading() {
    Self.lock.lock()
    let handler = Self.startHandler
    Self.lock.unlock()
    handler?(self)
  }

  override func stopLoading() {
    Self.lock.lock()
    let handler = Self.stopHandler
    Self.lock.unlock()
    handler?()
  }
}

final class MpvPlayerContractTests: XCTestCase {
  private let failure = NSError(
    domain: "MpvPlayerContractTests",
    code: 1,
    userInfo: [NSLocalizedDescriptionKey: "controlled failure"]
  )

  func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() {
    let core = ControllablePropertyCore()
    let plugin = RecordingMpvPlugin(core: core)

    core.nextResult = .success(())
    let success = invokeSetProperty(plugin, name: "pause", value: "no")
    XCTAssertEqual(success.count, 1)
    XCTAssertNil(success[0])
    XCTAssertEqual(plugin.pauseHookValues, ["no"])

    core.nextResult = .failure(failure)
    let rejected = invokeSetProperty(plugin, name: "pause", value: "yes")
    XCTAssertEqual(rejected.count, 1)
    XCTAssertEqual((rejected[0] as? FlutterError)?.code, "SET_PROPERTY_FAILED")
    XCTAssertEqual(plugin.pauseHookValues, ["no"])

    plugin.coreBase = nil
    let missing = invokeSetProperty(plugin, name: "volume", value: "50")
    XCTAssertEqual(missing.count, 1)
    XCTAssertEqual((missing[0] as? FlutterError)?.code, "NOT_INITIALIZED")

    var invalidResults: [Any?] = []
    plugin.handleSetProperty(
      call: FlutterMethodCall(methodName: "setProperty", arguments: ["name": "pause"])
    ) {
      invalidResults.append($0)
    }
    XCTAssertEqual(invalidResults.count, 1)
    XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS")
  }

  func testSharedSetPropertyMapsLifecycleCancellationAsNotInitialized() {
    let core = ControllablePropertyCore()
    let plugin = RecordingMpvPlugin(core: core)
    core.nextResult = .failure(MpvLifecycleUnavailableError("controlled cancellation"))

    let cancelled = invokeSetProperty(plugin, name: "volume", value: "50")

    XCTAssertEqual(cancelled.count, 1)
    XCTAssertEqual((cancelled[0] as? FlutterError)?.code, "NOT_INITIALIZED")
  }

  func testRealSetPropertyValidInvalidNonexistentAndPauseCache() {
    let core = MpvAudioPlayerCore()
    XCTAssertTrue(core.initialize())
    defer {
      core.dispose()
      core.queue.sync {}
    }

    XCTAssertSuccess(awaitProperty(core, name: "volume", value: "50"))
    XCTAssertTrue(core.isPaused)

    XCTAssertFailure(awaitProperty(core, name: "pause", value: "not-a-flag"))
    XCTAssertTrue(core.isPaused, "A rejected raw pause write must not change the cache")

    XCTAssertFailure(
      awaitProperty(core, name: "plezy-property-does-not-exist", value: "ignored")
    )
    XCTAssertTrue(core.isPaused)

    XCTAssertSuccess(awaitProperty(core, name: "pause", value: "no"))
    XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
  }

  func testPauseIntentUpdatesCacheBeforeAsyncWriteCompletes() {
    let core = MpvAudioPlayerCore()
    XCTAssertTrue(core.initialize())
    defer {
      core.dispose()
      core.queue.sync {}
    }

    let queueEntered = expectation(description: "mpv queue blocked")
    let releaseQueue = DispatchSemaphore(value: 0)
    core.queue.async {
      queueEntered.fulfill()
      releaseQueue.wait()
    }
    wait(for: [queueEntered], timeout: 2)

    let completion = expectation(description: "pause write completed")
    core.setPropertyAsync("pause", value: "no") { result in
      if case .failure(let error) = result {
        XCTFail("Pause write failed: \(error)")
      }
      completion.fulfill()
    }

    XCTAssertFalse(core.isPaused, "The public pause intent must be visible before the native write completes")
    releaseQueue.signal()
    wait(for: [completion], timeout: 2)
  }

  func testOlderPauseReplyCannotOverwriteNewerUserIntent() {
    let core = ControllablePropertyCore()
    let olderResume = core.beginCachedPauseIntent(false)
    let newerPause = core.beginCachedPauseIntent(true)
    XCTAssertTrue(core.isPaused)

    core.finishCachedPauseIntent(olderResume, result: .success(()))
    XCTAssertTrue(
      core.isPaused,
      "An older resume reply must not overwrite a newer pending pause intent"
    )

    core.finishCachedPauseIntent(newerPause, result: .success(()))
    XCTAssertTrue(core.isPaused)
  }

  func testPauseObservationAndUserIntentResolveInEventOrder() {
    let core = ControllablePropertyCore()
    let olderResume = core.beginCachedPauseIntent(false)

    core.observeCachedPauseForTesting(true)
    core.finishCachedPauseIntent(olderResume, result: .success(()))
    XCTAssertTrue(
      core.isPaused,
      "A native pause observation must invalidate the older resume write's delayed reply"
    )

    let newerResume = core.beginCachedPauseIntent(false)
    core.finishCachedPauseIntent(newerResume, result: .success(()))
    XCTAssertFalse(
      core.isPaused,
      "A user intent created after the native observation must remain authoritative"
    )
  }

  func testPauseObservationRetiresOutOfOrderIntentsForSuccessAndFailure() {
    let newerResults: [Result<Void, Error>] = [
      .success(()),
      .failure(failure),
    ]

    for newerResult in newerResults {
      let core = ControllablePropertyCore()
      let generationOneResume = core.beginCachedPauseIntent(false)
      let generationTwoPause = core.beginCachedPauseIntent(true)

      core.observeCachedPauseForTesting(true)
      core.finishCachedPauseIntent(generationTwoPause, result: newerResult)
      XCTAssertTrue(
        core.isPaused,
        "The observed native pause must survive the newer pending pause's completion"
      )

      core.finishCachedPauseIntent(generationOneResume, result: .success(()))
      XCTAssertTrue(
        core.isPaused,
        "A late older resume must be inert after a newer intent resolves"
      )

      let postObservationResume = core.beginCachedPauseIntent(false)
      core.finishCachedPauseIntent(postObservationResume, result: .success(()))
      XCTAssertFalse(
        core.isPaused,
        "A resume created after the native observation must remain authoritative"
      )
    }
  }

  func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
    let core = MpvAudioPlayerCore()
    XCTAssertTrue(core.initialize())

    let queueEntered = expectation(description: "mpv queue blocked")
    let releaseQueue = DispatchSemaphore(value: 0)
    core.queue.async {
      queueEntered.fulfill()
      releaseQueue.wait()
    }
    wait(for: [queueEntered], timeout: 2)

    let completion = expectation(description: "cancelled property completion")
    completion.assertForOverFulfill = true
    var completionCount = 0
    var completionError: Error?
    core.setPropertyAsync("volume", value: "51") { result in
      completionCount += 1
      switch result {
      case .success:
        XCTFail("Disposal must fail an accepted-but-pending property request")
      case .failure(let error):
        completionError = error
      }
      completion.fulfill()
    }

    core.dispose()
    releaseQueue.signal()
    wait(for: [completion], timeout: 2)
    core.queue.sync {}
    XCTAssertEqual(completionCount, 1)
    XCTAssertTrue(completionError is MpvLifecycleUnavailableError)
    let unavailableResult = awaitProperty(core, name: "volume", value: "52")
    XCTAssertFailure(unavailableResult)
    if case .failure(let error) = unavailableResult {
      XCTAssertTrue(error is MpvLifecycleUnavailableError)
    }
  }

  func testRapidAudioCoreReplacementOwnsLifecycleOnce() {
    for _ in 0..<5 {
      autoreleasepool {
        let core = MpvAudioPlayerCore()
        XCTAssertTrue(core.initialize())
        core.dispose()
        core.dispose()
        core.queue.sync {}
        XCTAssertFalse(core.hasActiveMpv)
      }
    }
  }

  func testQueuedDelegateDeliveryIsDroppedAfterTerminalTransition() {
    let core = MpvPlayerCoreBase()
    let delegate = RecordingLifecycleDelegate()
    core.delegate = delegate
    let enqueueAndDispose = {
      core.dispatchDelegateEvent(name: "file-loaded", data: nil)
      core.dispatchDelegateProperty(name: "time-pos", value: 1.0)
      XCTAssertTrue(core.beginDisposal())
    }
    if Thread.isMainThread {
      enqueueAndDispose()
    } else {
      DispatchQueue.main.sync(execute: enqueueAndDispose)
    }

    let drained = expectation(description: "main delivery drained")
    DispatchQueue.main.async { drained.fulfill() }
    wait(for: [drained], timeout: 2)
    XCTAssertTrue(delegate.events.isEmpty)
    XCTAssertTrue(delegate.properties.isEmpty)
  }

  func testWakeupContextDoesNotRetainCallbackTarget() {
    let released = expectation(description: "callback target released")
    var core: ReleaseTrackingCore? = ReleaseTrackingCore { released.fulfill() }
    weak var weakCore = core
    let context = MpvWakeupCallbackContext(core: core!)

    core = nil
    wait(for: [released], timeout: 2)
    XCTAssertNil(weakCore)
    context.dispatchWakeup()
    context.detach()
  }

  func testUnavailablePropertyCompletionRunsExactlyOnceOnMainThread() {
    let core = MpvAudioPlayerCore()
    XCTAssertTrue(core.initialize())
    core.dispose()
    core.queue.sync {}

    let completed = expectation(description: "unavailable property completed")
    completed.assertForOverFulfill = true
    var completionCount = 0
    DispatchQueue.global().async {
      core.getPropertyAsync("volume") { result in
        XCTAssertTrue(Thread.isMainThread)
        if case .success = result { XCTFail("Expected unavailable property failure") }
        completionCount += 1
        completed.fulfill()
      }
    }
    wait(for: [completed], timeout: 2)
    XCTAssertEqual(completionCount, 1)
  }

  func testNormalizedPlaybackDelayStringsPassThroughUnchanged() {
    let core = ControllablePropertyCore()
    let plugin = RecordingMpvPlugin(core: core)
    let values = ["0.25", "-0.5", "0", "0.25"]

    for value in values {
      core.nextResult = .success(())
      let result = invokeSetProperty(plugin, name: "audio-delay", value: value)
      XCTAssertEqual(result.count, 1)
      XCTAssertNil(result[0])
    }
    XCTAssertEqual(core.propertyCalls.map(\.1), values)
  }

  func testNodeConversionBoundsAndDiscardsMalformedSiblings() {
    let core = MpvPlayerCoreBase()
    var valid = mpv_node()
    valid.format = MPV_FORMAT_INT64
    valid.u.int64 = 7
    var malformed = mpv_node()
    malformed.format = MPV_FORMAT_NONE
    var values = [valid, malformed, valid]
    var decoded: Any?

    let valueCount = values.count
    values.withUnsafeMutableBufferPointer { valuesPointer in
      var list = mpv_node_list()
      list.num = Int32(valueCount)
      list.values = valuesPointer.baseAddress
      withUnsafeMutablePointer(to: &list) { listPointer in
        var root = mpv_node()
        root.format = MPV_FORMAT_NODE_ARRAY
        root.u.list = listPointer
        decoded = core.convertNode(root)
      }
    }
    XCTAssertEqual(decoded as? [Int64], [7, 7])

    var oversizedBytes = mpv_byte_array()
    oversizedBytes.size = 16 * 1_024 * 1_024 + 1
    withUnsafeMutablePointer(to: &oversizedBytes) { bytePointer in
      var root = mpv_node()
      root.format = MPV_FORMAT_BYTE_ARRAY
      root.u.ba = bytePointer
      XCTAssertNil(core.convertNode(root))
    }

    var invalidList = mpv_node_list()
    invalidList.num = -1
    withUnsafeMutablePointer(to: &invalidList) { listPointer in
      var root = mpv_node()
      root.format = MPV_FORMAT_NODE_ARRAY
      root.u.list = listPointer
      XCTAssertNil(core.convertNode(root))
    }
    XCTAssertTrue(core.validateSideDataDimensions(width: 3_840, height: 2_160))
    XCTAssertFalse(core.validateSideDataDimensions(width: 0, height: 2_160))
    XCTAssertFalse(core.validateSideDataDimensions(width: 65_536, height: 2_160))
    XCTAssertFalse(core.validateSideDataDimensions(width: 16_384, height: 16_384))
  }

  func testRawEc3LoaderBoundsAndIgnoresLateCallbacksForBothModes() {
    for finiteLength in [false, true] {
      let loader = RawEc3Loader(
        source: URL(string: "https://example.invalid/test.ec3")!,
        finiteLength: finiteLength,
        maximumBufferedBytes: 8,
        sessionConfiguration: .ephemeral
      )
      let session = URLSession(configuration: .ephemeral)
      let task = session.dataTask(with: URL(string: "https://example.invalid/test.ec3")!)

      loader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
      var snapshot = loader.statusSnapshot()
      XCTAssertEqual(snapshot.bytesReceived, 4)
      XCTAssertEqual(snapshot.retainedBytes, 4)
      XCTAssertNil(snapshot.errorCode)

      loader.urlSession(session, dataTask: task, didReceive: Data([5, 6, 7, 8, 9]))
      snapshot = loader.statusSnapshot()
      XCTAssertEqual(snapshot.bytesReceived, 4)
      XCTAssertEqual(snapshot.retainedBytes, 0)
      XCTAssertEqual(snapshot.errorCode, "response_too_large")

      loader.urlSession(session, dataTask: task, didReceive: Data([10]))
      let lateSnapshot = loader.statusSnapshot()
      XCTAssertEqual(lateSnapshot.bytesReceived, snapshot.bytesReceived)
      XCTAssertEqual(lateSnapshot.retainedBytes, 0)
      loader.cancel()
      loader.cancel()
      XCTAssertEqual(loader.statusSnapshot().pendingRequestCount, 0)
      let cancelledLoader = RawEc3Loader(
        source: URL(string: "https://example.invalid/cancel.ec3")!,
        finiteLength: finiteLength,
        maximumBufferedBytes: 8,
        sessionConfiguration: .ephemeral
      )
      cancelledLoader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
      XCTAssertEqual(cancelledLoader.statusSnapshot().retainedBytes, 4)
      cancelledLoader.cancel()
      cancelledLoader.cancel()
      let cancelledSnapshot = cancelledLoader.statusSnapshot()
      XCTAssertEqual(cancelledSnapshot.retainedBytes, 0)
      XCTAssertEqual(cancelledSnapshot.pendingRequestCount, 0)
      session.invalidateAndCancel()
    }
  }

  func testRawEc3LoaderCompletesThroughInjectedURLProtocolForBothModes() {
    defer { ProbeURLProtocol.reset() }
    for finiteLength in [false, true] {
      let requestStarted = expectation(description: "probe request started")
      let loaderFinished = expectation(description: "probe loader finished")
      ProbeURLProtocol.configure { protocolInstance in
        let response = URLResponse(
          url: protocolInstance.request.url!,
          mimeType: "audio/eac3",
          expectedContentLength: -1,
          textEncodingName: nil
        )
        protocolInstance.client?.urlProtocol(
          protocolInstance,
          didReceive: response,
          cacheStoragePolicy: .notAllowed
        )
        protocolInstance.client?.urlProtocol(protocolInstance, didLoad: Data([1, 2, 3, 4]))
        protocolInstance.client?.urlProtocolDidFinishLoading(protocolInstance)
        requestStarted.fulfill()
      }

      let configuration = URLSessionConfiguration.ephemeral
      configuration.protocolClasses = [ProbeURLProtocol.self]
      let loader = RawEc3Loader(
        source: URL(string: "https://probe.test/audio.ec3")!,
        finiteLength: finiteLength,
        maximumBufferedBytes: 8,
        sessionConfiguration: configuration,
        terminalHandlerForTesting: { loaderFinished.fulfill() }
      )
      loader.begin()
      wait(for: [requestStarted, loaderFinished], timeout: 2)

      let snapshot = loader.statusSnapshot()
      XCTAssertTrue(snapshot.isFinished)
      XCTAssertEqual(snapshot.bytesReceived, 4)
      XCTAssertEqual(snapshot.retainedBytes, 4)
      XCTAssertNil(snapshot.errorCode)

      loader.cancel()
      let cancelled = loader.statusSnapshot()
      XCTAssertEqual(cancelled.retainedBytes, 0)
      XCTAssertEqual(cancelled.pendingRequestCount, 0)
    }
  }

  func testPipStartWaitsForDelegateAndCompletesOnce() {
    let fake = FakePictureInPictureController()
    fake.isPictureInPicturePossible = true
    let controller = MpvPipController(
      controller: fake,
      readiness: { (true, true, true) },
      retryScheduler: { $0() }
    )
    let delegate = RecordingPipDelegate()
    controller.delegate = delegate
    var results: [Bool] = []

    delegate.onDidStart = {
      XCTAssertEqual(results, [true], "Manual result must resolve before delegate suspension")
    }
    controller.startPip { results.append($0) }
    XCTAssertEqual(fake.startCount, 1)
    XCTAssertTrue(results.isEmpty)
    controller.pictureInPictureWillStart()
    controller.pictureInPictureDidStart()
    XCTAssertEqual(Array(delegate.events.prefix(2)), ["willStart", "didStart"])
    XCTAssertEqual(results, [true])

    controller.pictureInPictureDidStart()
    controller.teardown()
    XCTAssertEqual(results, [true])
  }

  func testRepeatedAutoStartDuringCurrentControllerStartDoesNotRejectDidStart() {
    let fake = FakePictureInPictureController()
    let controller = MpvPipController(
      controller: fake,
      readiness: { (true, true, true) },
      retryScheduler: { $0() }
    )
    let delegate = RecordingPipDelegate()
    controller.delegate = delegate

    controller.setAutoStart(true)
    controller.pictureInPictureWillStart(from: fake)
    controller.setAutoStart(true)
    controller.pictureInPictureDidStart(from: fake)

    XCTAssertEqual(fake.automaticStartValues, [true, true])
    XCTAssertEqual(delegate.events, ["willStart", "didStart"])
    XCTAssertEqual(
      fake.stopCount,
      0,
      "Reasserting an enabled auto-start setting must not reject the in-flight system start"
    )
  }

  func testPipStartTimesOutWithoutDelegateOutcome() {
    let fake = FakePictureInPictureController()
    var timeouts: [() -> Void] = []
    let controller = MpvPipController(
      controller: fake,
      readiness: { (true, true, true) },
      retryScheduler: { $0() },
      startTimeoutScheduler: { timeouts.append($0) }
    )
    var results: [Bool] = []

    controller.startPip { results.append($0) }
    XCTAssertEqual(fake.startCount, 1)
    XCTAssertTrue(results.isEmpty)
    XCTAssertEqual(timeouts.count, 1)

    timeouts[0]()
    XCTAssertEqual(results, [false])
    XCTAssertEqual(fake.stopCount, 1)

    controller.pictureInPictureDidStart()
    controller.pictureInPictureFailedToStart(error: NSError(domain: "late", code: 1))
    XCTAssertEqual(results, [false])
  }

  func testPipTimeoutRecreatesControllerAndRejectsRetiredCallbacks() {
    let displayLayer = AVSampleBufferDisplayLayer()
    let retired = FakePictureInPictureController()
    let replacement = FakePictureInPictureController()
    retired.isPictureInPicturePossible = true
    replacement.isPictureInPicturePossible = true
    var timeouts: [() -> Void] = []
    var replacementLayers: [AVSampleBufferDisplayLayer?] = []
    let controller = MpvPipController(
      controller: retired,
      sampleBufferDisplayLayer: displayLayer,
      readiness: { (true, true, true) },
      retryScheduler: { $0() },
      startTimeoutScheduler: { timeouts.append($0) },
      replacementControllerFactory: { layer in
        replacementLayers.append(layer)
        return replacement
      }
    )
    let delegate = RecordingPipDelegate()
    controller.delegate = delegate
    controller.setAutoStart(true)
    var results: [Bool] = []

    controller.startPip { results.append($0) }
    XCTAssertEqual(retired.startCount, 1)
    XCTAssertEqual(timeouts.count, 1)

    timeouts[0]()
    XCTAssertEqual(results, [false])
    XCTAssertEqual(retired.stopCount, 1)
    XCTAssertEqual(retired.automaticStartValues, [true, false])
    XCTAssertEqual(replacementLayers.count, 1)
    XCTAssertTrue(replacementLayers[0] === displayLayer)
    XCTAssertEqual(replacement.automaticStartValues, [true])

    controller.startPip { results.append($0) }
    XCTAssertEqual(replacement.startCount, 1)
    XCTAssertEqual(timeouts.count, 2)

    controller.pictureInPictureWillStart(from: retired)
    controller.pictureInPictureDidStart(from: retired)
    controller.pictureInPictureFailedToStart(
      from: retired,
      error: NSError(domain: "late-retired-controller", code: 1)
    )
    controller.pictureInPictureDidStop(from: retired)
    XCTAssertEqual(results, [false])
    XCTAssertTrue(delegate.events.isEmpty)
    XCTAssertEqual(retired.stopCount, 1)
    XCTAssertEqual(
      replacement.startCount,
      1,
      "A retired controller callback must not disturb the replacement's pending start"
    )

    controller.pictureInPictureWillStart(from: replacement)
    controller.pictureInPictureDidStart(from: replacement)
    XCTAssertEqual(results, [false, true])
    XCTAssertEqual(delegate.events, ["willStart", "didStart"])
    XCTAssertEqual(replacementLayers.count, 1)
  }

  func testPipTeardownCancelsRetryAndLateWork() {
    let fake = FakePictureInPictureController()
    var possible = false
    var retries: [() -> Void] = []
    let controller = MpvPipController(
      controller: fake,
      readiness: { (possible, true, true) },
      retryScheduler: { retries.append($0) }
    )
    var results: [Bool] = []

    controller.startPip { results.append($0) }
    XCTAssertEqual(retries.count, 1)
    controller.teardown()
    XCTAssertEqual(results, [false])
    possible = true
    retries.forEach { $0() }
    XCTAssertEqual(fake.startCount, 0)
    XCTAssertEqual(results, [false])
  }

  func testPipFailureAndLateRestoreRemainSingleShot() {
    let fake = FakePictureInPictureController()
    let controller = MpvPipController(
      controller: fake,
      readiness: { (true, true, true) },
      retryScheduler: { $0() }
    )
    let delegate = RecordingPipDelegate()
    controller.delegate = delegate
    var startResults: [Bool] = []

    controller.startPip { startResults.append($0) }
    controller.pictureInPictureWillStart()
    controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 1))
    controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 2))
    XCTAssertEqual(startResults, [false])
    XCTAssertEqual(delegate.events.filter { $0 == "failed" }.count, 1)

    controller.startPip { startResults.append($0) }
    controller.pictureInPictureWillStart()
    controller.pictureInPictureDidStart()
    XCTAssertEqual(startResults, [false, true])

    var restoreResults: [Bool] = []
    controller.restoreUserInterface { restoreResults.append($0) }
    controller.teardown()
    controller.restoreUserInterface { restoreResults.append($0) }
    XCTAssertEqual(restoreResults, [true, false])
  }

  private func invokeSetProperty(
    _ plugin: RecordingMpvPlugin,
    name: String,
    value: String
  ) -> [Any?] {
    var results: [Any?] = []
    plugin.handleSetProperty(
      call: FlutterMethodCall(
        methodName: "setProperty",
        arguments: ["name": name, "value": value]
      )
    ) {
      results.append($0)
    }
    return results
  }

  private func awaitProperty(
    _ core: MpvPlayerCoreBase,
    name: String,
    value: String
  ) -> Result<Void, Error> {
    let completion = expectation(description: "set \(name)")
    var propertyResult: Result<Void, Error>?
    core.setPropertyAsync(name, value: value) {
      propertyResult = $0
      completion.fulfill()
    }
    wait(for: [completion], timeout: 2)
    return propertyResult ?? .failure(failure)
  }

  private func XCTAssertSuccess(
    _ result: Result<Void, Error>,
    file: StaticString = #filePath,
    line: UInt = #line
  ) {
    if case .failure(let error) = result {
      XCTFail("Expected success, received \(error)", file: file, line: line)
    }
  }

  private func XCTAssertFailure(
    _ result: Result<Void, Error>,
    file: StaticString = #filePath,
    line: UInt = #line
  ) {
    if case .success = result {
      XCTFail("Expected failure", file: file, line: line)
    }
  }
}
