package com.edde746.plezy.mpv

import android.app.Activity
import android.content.Context
import android.graphics.PixelFormat
import android.media.AudioAttributes
import android.media.ImageReader
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.PlayerDelegate
import com.edde746.plezy.shared.PlayerSurfaceHost
import com.edde746.plezy.shared.SurfacePlayerCore
import dev.jdtech.mpv.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

/**
 * mpv playback core. Two modes:
 * - Video (default): [context] is the host Activity, which is needed for the
 *   SurfaceView/window hierarchy, display refresh-rate reads and frame-rate
 *   matching.
 * - Audio-only ([audioOnly]): the music core. Built on the application
 *   context (no Activity dependency, so it survives activity teardown);
 *   never creates a surface, view, or frame-rate manager, and mpv is
 *   configured before init to never open a video output (`vid=no`,
 *   `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`).
 */
class MpvPlayerCore private constructor(
  private val context: Context,
  private val audioOnly: Boolean,
  private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
  initializedForTesting: Boolean
) : SurfaceHolder.Callback,
  SurfacePlayerCore {
  constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)

  internal constructor(
    context: Context,
    audioOnly: Boolean,
    propertyWriter: (suspend (String, String) -> Unit)?
  ) : this(context, audioOnly, propertyWriter, true)

  companion object {
    private const val TAG = "MpvPlayerCore"
  }

  /** Video-only paths. The plugin always constructs video cores with the
   * host Activity, and audio-only mode never touches these paths. */
  private val activity: Activity
    get() = context as Activity

  private var surfaceView: SurfaceView? = null
  private var surfaceContainer: android.widget.FrameLayout? = null
  private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null

  @Volatile private var disposing: Boolean = false

  @Volatile private var pendingSurface: Surface? = null

  @Volatile private var attachedSurface: Surface? = null
  private var placeholderImageReader: ImageReader? = null

  @Volatile private var placeholderSurface: Surface? = null

  @Volatile private var lastAppliedSurfaceSize: String? = null

  @Volatile private var lastKnownSurfaceWidth: Int = 0

  @Volatile private var lastKnownSurfaceHeight: Int = 0
  var delegate: PlayerDelegate? = null
  var isInitialized: Boolean = false
    private set

  init {
    if (initializedForTesting) isInitialized = true
  }

  @Volatile private var player: MpvPlayer? = null
  private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
  private val endFileDiagnostics = MpvEndFileDiagnostics()

  // mpv writes must stay off the main thread but run in submission order:
  // setupMpvFallback sets vo/ao/hwdec immediately before the loadfile command,
  // and an unordered pool can run loadfile first, leaving mpv with no video
  // output (#1482).
  private val mpvWriteDispatcher = Dispatchers.IO.limitedParallelism(1)

  // Frame rate matching
  private var frameRateManager: FrameRateManager? = null
  private val handler = Handler(Looper.getMainLooper())

  // Result-callback marshaling. Separate from [handler], whose queued
  // messages dispose() clears — pending method-channel results must still
  // complete after dispose.
  private val mainHandler = Handler(Looper.getMainLooper())

  /** Same semantics as Activity.runOnUiThread, without needing an Activity. */
  private fun runOnMain(block: () -> Unit) {
    if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
  }

  // Audio focus
  private var audioFocusManager: AudioFocusManager? = null

  @Volatile private var cachedPaused: Boolean = true

  @Volatile private var desiredPaused: Boolean = true

  @Volatile private var pausedForSurfaceLoss: Boolean = false

  @Volatile private var pausedForAudioFocusLoss: Boolean = false

  @Volatile private var hasAttachedSurface: Boolean = false

  @Volatile private var attachedToPlaceholder: Boolean = false

  @Volatile private var videoOutputRestoring: Boolean = false

  @Volatile private var deferredResumeRequested: Boolean = false

  @Volatile private var resumeBlockedByPublicPause: Boolean = false

  private data class PublicPauseIntent(
    val generation: Long,
    val previousBlocked: Boolean,
    val previousDesiredPaused: Boolean
  )

  private val publicPauseIntentLock = Any()
  private var publicPauseIntentGeneration = 0L
  private val publicPauseWriteMutex = Mutex()

  @Volatile private var videoOutputEpoch: Long = 0L
  private val videoOutputMutex = Mutex()
  private var pendingVideoOutputDisableJob: Job? = null
  private var pendingVideoOutputRefreshJob: Job? = null

  private var flutterOverlayApplied = false

  private fun ensureFlutterOverlayOnTop() {
    if (audioOnly || disposing || flutterOverlayApplied) return
    val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
    contentView.post {
      if (disposing || !isInitialized) return@post
      flutterOverlayApplied = PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
    }
  }

  private fun ensurePlaceholderSurface() {
    if (placeholderSurface?.isValid == true) return
    placeholderImageReader?.close()
    placeholderImageReader = ImageReader.newInstance(1, 1, PixelFormat.RGBA_8888, 2)
    placeholderSurface = placeholderImageReader?.surface
    Log.d(TAG, "Created MPV placeholder surface")
  }

  @Suppress("DEPRECATION")
  private fun currentDisplayFpsOverride(): String? {
    if (audioOnly) return null
    val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
      activity.display
    } else {
      activity.windowManager.defaultDisplay
    }
    val refreshRate = display?.mode?.refreshRate ?: return null
    if (refreshRate <= 0f) return null
    return refreshRate.toString()
  }

  private fun updateDisplayFpsOverride(p: MpvPlayer, reason: String, onComplete: () -> Unit = {}) {
    val fps = currentDisplayFpsOverride()
    if (fps == null) {
      Log.d(TAG, "Skipping display-fps-override update ($reason): no display rate")
      onComplete()
      return
    }
    if (!scope.isActive) {
      onComplete()
      return
    }

    scope.launch(mpvWriteDispatcher) {
      try {
        p.setProperty("display-fps-override", fps)
        Log.d(TAG, "Updated display-fps-override=$fps ($reason)")
      } catch (e: Exception) {
        Log.w(TAG, "Failed to update display-fps-override ($reason)", e)
      } finally {
        withContext(NonCancellable + Dispatchers.Main) {
          onComplete()
        }
      }
    }
  }

  fun initialize(onResult: (Boolean) -> Unit) {
    if (isInitialized) {
      Log.d(TAG, "Already initialized")
      onResult(true)
      return
    }

    try {
      disposing = false
      endFileDiagnostics.onStartFile()
      cachedPaused = true
      desiredPaused = true
      pausedForSurfaceLoss = false
      pausedForAudioFocusLoss = false
      pendingSurface = null
      attachedSurface = null
      attachedToPlaceholder = false
      hasAttachedSurface = false
      videoOutputRestoring = false
      deferredResumeRequested = false
      synchronized(publicPauseIntentLock) {
        publicPauseIntentGeneration += 1L
        resumeBlockedByPublicPause = false
      }
      videoOutputEpoch = 0L
      pendingVideoOutputDisableJob?.cancel()
      pendingVideoOutputDisableJob = null
      lastAppliedSurfaceSize = null
      lastKnownSurfaceWidth = 0
      lastKnownSurfaceHeight = 0
      if (!audioOnly) ensurePlaceholderSurface()

      // Initialize audio focus handling. mpv has none built in, so both modes
      // use the shared manager: pause on (transient) loss, auto-resume on
      // regain when the loss interrupted active playback.
      audioFocusManager = AudioFocusManager(
        context = context,
        handler = handler,
        contentType = if (audioOnly) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_MOVIE,
        onPause = {
          pauseForAudioFocusLoss()
        },
        onResume = {
          resumeAfterAudioFocusGain("audio focus gain")
        },
        isPaused = { desiredPaused }
      )
      if (!audioOnly) {
        frameRateManager = FrameRateManager(
          activity = activity,
          handler = handler,
          log = { emitLog("info", "framerate", it) }
        )

        surfaceContainer = PlayerSurfaceHost.createContainer(activity)
        surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
        surfaceContainer!!.addView(surfaceView)

        val contentView = PlayerSurfaceHost.attachToContent(activity, surfaceContainer!!)
        flutterOverlayApplied = PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
        ensureFlutterOverlayOnTop()
        overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
          ensureFlutterOverlayOnTop()
          val sv = surfaceView
          if (sv != null) applySurfaceSize(sv.width, sv.height)
        }
        contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)

        Log.d(TAG, "SurfaceView added to content view")
      }

      // Create MpvPlayer on background thread via coroutine
      scope.launch {
        try {
          if (disposing) {
            onResult(false)
            return@launch
          }
          val displayFpsOverride = currentDisplayFpsOverride()
          val p = MpvPlayer.create(context.applicationContext) {
            if (audioOnly) {
              // Pure audio core (all set before mpv_initialize, mirroring the
              // Windows/Linux audio instances): vid=no keeps embedded cover
              // art from ever becoming a video track, force-window and
              // audio-display make sure mpv never opens a video output for
              // it, and gapless-audio splices the pre-armed next playlist
              // entry into the running audio stream.
              setOption("vid", "no")
              setOption("force-window", "no")
              setOption("audio-display", "no")
              setOption("gapless-audio", "weak")
            } else {
              setOption("vo", "gpu")
              setOption("gpu-context", "android")
              setOption("opengl-es", "yes")
              setOption("vd-lavc-film-grain", "cpu")
              if (displayFpsOverride != null) {
                setOption("display-fps-override", displayFpsOverride)
              }
            }
            setOption("ao", "audiotrack,opensles")
            // Pause on the last frame at EOF instead of unloading the file, so a
            // seek after the video ends still works (matches Linux/Windows).
            setOption("keep-open", "yes")
          }
          if (displayFpsOverride != null) {
            Log.d(TAG, "Initial display-fps-override=$displayFpsOverride")
          }

          if (disposing) {
            p.close()
            onResult(false)
            return@launch
          }

          player = p
          isInitialized = true

          if (!audioOnly) refreshVideoOutput("initialize")

          // Start collecting events/properties/logs
          collectEvents(p)
          collectPropertyChanges(p)
          collectLogMessages(p)

          Log.d(TAG, "Initialized successfully")
          onResult(true)
        } catch (e: Exception) {
          Log.e(TAG, "Failed to initialize native: ${e.message}", e)
          onResult(false)
        }
      }
    } catch (e: Exception) {
      Log.e(TAG, "Failed to initialize: ${e.message}", e)
      onResult(false)
    }
  }

  // Flow collectors

  private fun emitLog(level: String, prefix: String, text: String) {
    delegate?.onEvent(
      "log-message",
      mapOf(
        "prefix" to prefix,
        "level" to level,
        "text" to text
      )
    )
  }

  private fun collectEvents(p: MpvPlayer) {
    scope.launch(start = CoroutineStart.UNDISPATCHED) {
      p.eventFlow.collect { event ->
        when (event) {
          is MpvEvent.EndFile -> {
            delegate?.onEvent("end-file", endFileDiagnostics.onEndFile(event))
          }
          is MpvEvent.StartFile -> {
            endFileDiagnostics.onStartFile()
            delegate?.onEvent("start-file", null)
          }
          is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
          is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
          else -> {}
        }
      }
    }
  }

  private fun collectPropertyChanges(p: MpvPlayer) {
    scope.launch(start = CoroutineStart.UNDISPATCHED) {
      p.propertyFlow.collect { change ->
        // Skip None — matches old MPVLib behavior where eventProperty(name)
        // with no value was a no-op. Forwarding null would incorrectly clear
        // track selections (aid/sid) before the file loads.
        if (change is PropertyChange.None) return@collect
        val value: Any? = when (change) {
          is PropertyChange.Flag -> change.value
          is PropertyChange.Int64 -> change.value
          is PropertyChange.Double -> change.value
          is PropertyChange.Str -> change.value
          is PropertyChange.None -> null
        }
        if (change.name == "pause" && change is PropertyChange.Flag) {
          cachedPaused = change.value
        }
        delegate?.onPropertyChange(change.name, value)
      }
    }
  }

  private fun collectLogMessages(p: MpvPlayer) {
    scope.launch(start = CoroutineStart.UNDISPATCHED) {
      p.logFlow.collect { msg ->
        endFileDiagnostics.onLogMessage(msg)
        emitLog(msg.level.name.lowercase(), msg.prefix, msg.text)
      }
    }
  }

  // Audio Focus

  override fun requestAudioFocus(): Boolean {
    val granted = audioFocusManager?.requestAudioFocus() ?: false
    if (granted && pausedForAudioFocusLoss) {
      resumeAfterAudioFocusGain("audio focus request granted")
    }
    return granted
  }

  override fun abandonAudioFocus() {
    audioFocusManager?.abandonAudioFocus()
  }

  // SurfaceHolder.Callback

  override fun surfaceCreated(holder: SurfaceHolder) {
    Log.d(TAG, "Surface created")
    if (disposing) return

    val surface = holder.surface
    pendingSurface = surface.takeIf { it.isValid }
    pendingVideoOutputDisableJob?.cancel()
    videoOutputEpoch += 1L
    rememberCurrentSurfaceSize()
    if (player == null) {
      Log.d(TAG, "Deferring video output refresh until MPV init completes")
      return
    }

    refreshVideoOutput("surfaceCreated")
  }

  override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
    Log.d(TAG, "Surface changed: ${width}x$height")
    rememberSurfaceSize(width, height)
    refreshVideoOutput("surfaceChanged")
  }

  override fun surfaceDestroyed(holder: SurfaceHolder) {
    Log.d(TAG, "Surface destroyed")
    pendingSurface = null
    if (player == null || disposing) return
    detachSurfaceInternal(reason = "surfaceDestroyed")
  }

  private fun rememberSurfaceSize(width: Int, height: Int) {
    if (width <= 0 || height <= 0) return
    lastKnownSurfaceWidth = width
    lastKnownSurfaceHeight = height
  }

  private fun rememberCurrentSurfaceSize() {
    val sv = surfaceView ?: return
    rememberSurfaceSize(sv.width, sv.height)
  }

  private fun currentCandidateSurface(): Surface? = surfaceView?.holder?.surface?.takeIf { it.isValid }
    ?: pendingSurface?.takeIf { it.isValid }

  private fun hasAttachedRealSurface(): Boolean = hasAttachedSurface && !attachedToPlaceholder && (attachedSurface?.isValid == true)

  // Audio-only mode has no video output to wait for — playback and resume
  // paths gated on output readiness must always proceed there.
  private fun hasReadyVideoOutput(): Boolean = audioOnly || (hasAttachedRealSurface() && !videoOutputRestoring)

  private fun isCurrentVideoOutputEpoch(epoch: Long): Boolean = !disposing && epoch == videoOutputEpoch

  private fun isVideoOutputRefreshCurrent(epoch: Long): Boolean {
    if (disposing) return false
    if (epoch != videoOutputEpoch) return false
    return hasAttachedRealSurface()
  }

  private fun refreshVideoOutput(reason: String) {
    if (audioOnly || disposing) return

    rememberCurrentSurfaceSize()
    val p = player
    val surface = currentCandidateSurface()
    if (p == null) {
      pendingSurface = surface?.takeIf { it.isValid }
      Log.d(TAG, "refreshVideoOutput($reason): player not ready yet")
      return
    }

    if (surface == null || !surface.isValid) {
      hasAttachedSurface = false
      attachedSurface = null
      attachedToPlaceholder = false
      pendingSurface = null
      lastAppliedSurfaceSize = null
      videoOutputRestoring = true
      Log.d(TAG, "refreshVideoOutput($reason): no valid surface available")
      return
    }

    val refreshEpoch = videoOutputEpoch
    pendingVideoOutputDisableJob?.cancel()
    videoOutputRestoring = true
    flutterOverlayApplied = false
    ensureFlutterOverlayOnTop()
    Log.d(TAG, "refreshVideoOutput($reason): scheduling async refresh (epoch=$refreshEpoch)")
    pendingVideoOutputRefreshJob = scope.launch(Dispatchers.IO) {
      try {
        videoOutputMutex.withLock {
          if (!isCurrentVideoOutputEpoch(refreshEpoch)) {
            Log.d(TAG, "Skipping stale MPV video output refresh ($reason, epoch=$refreshEpoch)")
            return@withLock
          }
          if (!surface.isValid) {
            hasAttachedSurface = false
            attachedSurface = null
            attachedToPlaceholder = false
            pendingSurface = null
            lastAppliedSurfaceSize = null
            videoOutputRestoring = true
            Log.d(TAG, "Skipping MPV video output refresh with invalid surface ($reason, epoch=$refreshEpoch)")
            return@withLock
          }

          val needsAttach = !hasAttachedSurface || attachedSurface !== surface
          val wasAttachedToPlaceholder = attachedToPlaceholder
          val wasPausedForSurfaceLoss = pausedForSurfaceLoss
          if (needsAttach) {
            p.attachSurface(surface)
            attachedSurface = surface
            hasAttachedSurface = true
            attachedToPlaceholder = false
            pendingSurface = null
            Log.d(TAG, "refreshVideoOutput($reason): attached surface")
          } else {
            Log.d(TAG, "refreshVideoOutput($reason): surface already attached, refreshing surface state")
          }

          if (!isVideoOutputRefreshCurrent(refreshEpoch)) {
            Log.d(TAG, "Skipping stale MPV video output refresh after attach ($reason, epoch=$refreshEpoch)")
            return@withLock
          }
          applySurfaceSizeInternal(p, force = true)
          if (!isVideoOutputRefreshCurrent(refreshEpoch)) {
            Log.d(TAG, "Skipping stale MPV video output refresh after surface size ($reason, epoch=$refreshEpoch)")
            return@withLock
          }
          videoOutputRestoring = false
          applyDeferredResumeIfNeeded(p, reason)
          if (wasPausedForSurfaceLoss) {
            pausedForSurfaceLoss = false
            Log.d(TAG, "Cleared surface-loss pause after $reason")
          }
          if (wasAttachedToPlaceholder) {
            Log.d(TAG, "Restored MPV real surface after placeholder ($reason)")
          }
          Log.d(TAG, "Video output ready after $reason")
        }
      } catch (e: CancellationException) {
        Log.d(TAG, "Canceled pending MPV video output refresh ($reason, epoch=$refreshEpoch)")
      } catch (e: Exception) {
        Log.w(TAG, "Failed to finalize MPV video output refresh ($reason)", e)
      }
    }
  }

  private fun applySurfaceSize(width: Int, height: Int) {
    val p = player ?: return
    if (disposing || width <= 0 || height <= 0) return
    rememberSurfaceSize(width, height)
    if (!hasReadyVideoOutput()) return
    scope.launch {
      try {
        applySurfaceSizeInternal(p)
      } catch (e: Exception) {
        Log.w(TAG, "Failed to apply surface size to MPV", e)
      }
    }
  }

  private suspend fun applySurfaceSizeInternal(p: MpvPlayer, force: Boolean = false) {
    if (disposing) return
    val width = lastKnownSurfaceWidth
    val height = lastKnownSurfaceHeight
    if (width <= 0 || height <= 0) return

    val size = "${width}x$height"
    if (!force && size == lastAppliedSurfaceSize) return
    p.setProperty("android-surface-size", size)
    lastAppliedSurfaceSize = size
    Log.d(TAG, "Applied MPV surface size $size${if (force) " (forced)" else ""}")
  }

  private fun schedulePlaceholderSurfaceAttach(
    p: MpvPlayer,
    reason: String,
    epoch: Long
  ) {
    pendingVideoOutputDisableJob?.cancel()
    pendingVideoOutputDisableJob = scope.launch(Dispatchers.IO) {
      try {
        videoOutputMutex.withLock {
          if (!isCurrentVideoOutputEpoch(epoch)) {
            Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)")
            return@withLock
          }
          publicPauseWriteMutex.withLock {
            val wasPaused = try {
              p.getFlag("pause") == true
            } catch (e: Exception) {
              cachedPaused
            }
            if (!wasPaused) {
              try {
                p.setProperty("pause", true)
                cachedPaused = true
                pausedForSurfaceLoss = true
                Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
              } catch (e: Exception) {
                pausedForSurfaceLoss = false
                Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
              }
            } else {
              pausedForSurfaceLoss = false
            }
          }
          val surface = placeholderSurface?.takeIf { it.isValid } ?: run {
            Log.w(TAG, "No valid MPV placeholder surface available for $reason")
            return@withLock
          }
          p.attachSurface(surface)
          attachedSurface = surface
          hasAttachedSurface = true
          attachedToPlaceholder = true
          lastAppliedSurfaceSize = null
          Log.d(TAG, "Attached MPV placeholder surface ($reason, epoch=$epoch)")
        }
      } catch (e: CancellationException) {
        Log.d(TAG, "Canceled pending MPV placeholder attach ($reason, epoch=$epoch)")
      } catch (e: Exception) {
        Log.w(TAG, "Failed to attach MPV placeholder surface ($reason)", e)
      }
    }
  }

  private fun detachSurfaceInternal(reason: String) {
    val hadAttachedSurface = hasAttachedSurface || attachedSurface != null
    hasAttachedSurface = false
    attachedSurface = null
    attachedToPlaceholder = false
    videoOutputRestoring = true
    lastAppliedSurfaceSize = null
    val detachEpoch = videoOutputEpoch + 1L
    videoOutputEpoch = detachEpoch

    val p = player ?: return
    if (!hadAttachedSurface) {
      Log.d(TAG, "detachSurfaceInternal($reason): no attached surface to clear")
      return
    }

    schedulePlaceholderSurfaceAttach(
      p = p,
      reason = reason,
      epoch = detachEpoch
    )
    Log.d(TAG, "Cleared MPV surface attachment ($reason, epoch=$detachEpoch)")
  }

  private fun normalizePauseValue(value: String): Boolean? = when (value.lowercase()) {
    "yes", "true", "1" -> true
    "no", "false", "0" -> false
    else -> null
  }

  private fun pauseForAudioFocusLoss() {
    val shouldPause = synchronized(publicPauseIntentLock) {
      (!desiredPaused).also { pausedForAudioFocusLoss = it }
    }
    if (!shouldPause) {
      Log.d(TAG, "Skipping audio-focus pause because playback is already desirably paused")
      return
    }

    scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
      try {
        publicPauseWriteMutex.withLock {
          if (!pausedForAudioFocusLoss || disposing) return@withLock
          writeProperty("pause", "yes")
          cachedPaused = true
        }
      } catch (error: CancellationException) {
        Log.d(TAG, "Canceled audio-focus pause")
      } catch (error: Exception) {
        Log.w(TAG, "Failed to pause on focus loss", error)
      }
    }
  }

  private fun resumeAfterAudioFocusGain(reason: String) {
    val shouldResume = synchronized(publicPauseIntentLock) {
      if (!pausedForAudioFocusLoss) {
        false
      } else {
        pausedForAudioFocusLoss = false
        true
      }
    }
    if (shouldResume) requestAutoResume(reason)
  }

  private fun rollbackFailedPublicPauseIntent(intent: PublicPauseIntent) {
    synchronized(publicPauseIntentLock) {
      if (publicPauseIntentGeneration == intent.generation) {
        resumeBlockedByPublicPause = intent.previousBlocked
        desiredPaused = intent.previousDesiredPaused
      }
    }
  }

  private fun completePublicResumeNoOp(onComplete: ((Result<Unit>) -> Unit)?) {
    runOnMain {
      val completion: Result<Unit> = if (disposing || !isInitialized || !scope.isActive) {
        Result.failure(CancellationException("MPV core unavailable"))
      } else {
        Result.success(Unit)
      }
      onComplete?.invoke(completion)
    }
  }

  private fun requestAutoResume(reason: String) {
    val p = player
    if (p == null && propertyWriterOverride == null) return
    if (disposing) return

    val intentGeneration = synchronized(publicPauseIntentLock) {
      if (resumeBlockedByPublicPause) {
        deferredResumeRequested = false
        Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
        return
      }

      if (!hasReadyVideoOutput()) {
        deferredResumeRequested = true
        Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
        return
      }
      publicPauseIntentGeneration
    }

    scope.launch(mpvWriteDispatcher) {
      try {
        publicPauseWriteMutex.withLock {
          val shouldResume = synchronized(publicPauseIntentLock) {
            !pausedForAudioFocusLoss &&
              !resumeBlockedByPublicPause &&
              publicPauseIntentGeneration == intentGeneration
          }
          if (!shouldResume) {
            Log.d(TAG, "Skipping stale auto-resume after $reason")
            return@withLock
          }
          val isPaused = p?.getFlag("pause") ?: cachedPaused
          if (isPaused) {
            Log.d(TAG, "Auto-resuming playback after $reason")
            if (p != null) {
              p.setProperty("pause", false)
            } else {
              writeProperty("pause", "no")
            }
            cachedPaused = false
          } else {
            Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
          }
        }
      } catch (e: Exception) {
        Log.w(TAG, "Failed to resume after $reason", e)
      }
    }
  }

  private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) {
    publicPauseWriteMutex.withLock {
      val shouldResume = synchronized(publicPauseIntentLock) {
        if (!deferredResumeRequested) {
          false
        } else if (pausedForAudioFocusLoss) {
          Log.d(TAG, "Keeping deferred auto-resume pending after $reason until audio focus returns")
          false
        } else if (resumeBlockedByPublicPause) {
          deferredResumeRequested = false
          Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
          false
        } else {
          deferredResumeRequested = false
          true
        }
      }
      if (!shouldResume) return@withLock
      if (p.getFlag("pause") == true) {
        Log.d(TAG, "Applying deferred auto-resume after $reason")
        p.setProperty("pause", false)
        cachedPaused = false
      } else {
        Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
      }
    }
  }

  private suspend fun writeProperty(name: String, value: String) {
    val writer = propertyWriterOverride
    if (writer != null) {
      writer(name, value)
    } else {
      val currentPlayer = player ?: throw CancellationException("MPV player unavailable")
      currentPlayer.setProperty(name, value)
    }
  }

  // Public API
  /**
   * Atomically records the public pause intent applied by the next loadfile
   * operation. The load owns the native state transition, so this deliberately
   * does not enqueue a second pause property write.
   */
  fun setPauseIntentForLoad(paused: Boolean) {
    if (!isInitialized || disposing || !scope.isActive) return

    synchronized(publicPauseIntentLock) {
      publicPauseIntentGeneration += 1L
      desiredPaused = paused
      resumeBlockedByPublicPause = paused
      if (paused) {
        cachedPaused = true
        pausedForSurfaceLoss = false
        pausedForAudioFocusLoss = false
        deferredResumeRequested = false
      } else if (!pausedForSurfaceLoss && !pausedForAudioFocusLoss && !deferredResumeRequested) {
        cachedPaused = false
      }
    }
    Log.d(TAG, "Load pause intent updated: paused=$paused")
  }

  fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
    if (!isInitialized || disposing || !scope.isActive) {
      onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
      return
    }

    val paused = if (name == "pause") normalizePauseValue(value) else null
    val pauseIntent = paused?.let {
      synchronized(publicPauseIntentLock) {
        PublicPauseIntent(
          generation = ++publicPauseIntentGeneration,
          previousBlocked = resumeBlockedByPublicPause,
          previousDesiredPaused = desiredPaused
        ).also {
          resumeBlockedByPublicPause = paused
          desiredPaused = paused
        }
      }
    }

    if (paused == false && pauseIntent != null) {
      val shouldReclaimAudioFocus = synchronized(publicPauseIntentLock) {
        publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss
      }
      if (shouldReclaimAudioFocus) {
        val focusGranted = audioFocusManager?.requestAudioFocus() == true
        if (!focusGranted) {
          Log.w(TAG, "Audio focus request denied; keeping public resume pending")
          completePublicResumeNoOp(onComplete)
          return
        }

        synchronized(publicPauseIntentLock) {
          if (publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss) {
            pausedForAudioFocusLoss = false
          }
        }
      }
    }

    if (paused == false && pauseIntent != null && !hasReadyVideoOutput()) {
      runOnMain {
        if (!isInitialized || disposing || !scope.isActive) {
          onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
          return@runOnMain
        }
        var deferredForSurface = false
        val interruptedAgain = synchronized(publicPauseIntentLock) {
          if (publicPauseIntentGeneration != pauseIntent.generation) {
            false
          } else if (pausedForAudioFocusLoss) {
            true
          } else {
            deferredResumeRequested = true
            deferredForSurface = true
            false
          }
        }
        if (interruptedAgain) {
          Log.d(TAG, "Public resume deferred by a newer audio-focus loss")
          onComplete?.invoke(Result.success(Unit))
        } else {
          if (deferredForSurface) {
            Log.d(TAG, "Deferring public resume until video output is ready")
          }
          onComplete?.invoke(Result.success(Unit))
        }
      }
      return
    }

    scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
      var interruptedBeforeWrite = false
      val writeResult = try {
        if (pauseIntent == null) {
          writeProperty(name, value)
        } else {
          publicPauseWriteMutex.withLock {
            val shouldWrite = synchronized(publicPauseIntentLock) {
              val isCurrent = publicPauseIntentGeneration == pauseIntent.generation
              if (isCurrent && paused == false && pausedForAudioFocusLoss) {
                interruptedBeforeWrite = true
                false
              } else {
                isCurrent
              }
            }
            if (shouldWrite) writeProperty(name, value)
          }
        }
        if (interruptedBeforeWrite) {
          Log.d(TAG, "Public resume write skipped after a newer audio-focus loss")
        }
        Result.success(Unit)
      } catch (error: CancellationException) {
        Result.failure(error)
      } catch (error: Exception) {
        Log.w(TAG, "MPV property write failed")
        Result.failure(error)
      }

      if (writeResult.isFailure && pauseIntent != null) {
        rollbackFailedPublicPauseIntent(pauseIntent)
      }

      withContext(NonCancellable + Dispatchers.Main) {
        val completion = if (disposing || !isInitialized) {
          Result.failure(CancellationException("MPV core unavailable"))
        } else {
          writeResult
        }
        val isCurrent = pauseIntent == null ||
          synchronized(publicPauseIntentLock) {
            publicPauseIntentGeneration == pauseIntent.generation
          }
        if (isCurrent && completion.isSuccess && !interruptedBeforeWrite) {
          if (paused == true) {
            cachedPaused = true
            pausedForSurfaceLoss = false
            deferredResumeRequested = false
            Log.d(TAG, "Public pause state updated: paused=true")
          } else if (paused == false) {
            cachedPaused = false
            pausedForSurfaceLoss = false
            deferredResumeRequested = false
            Log.d(TAG, "Public pause state updated: paused=false")
          }
        }
        onComplete?.invoke(completion)
      }
    }
  }

  fun getProperty(name: String): String? {
    if (Looper.myLooper() == Looper.getMainLooper()) {
      Log.w(TAG, "Refusing synchronous getProperty($name) on the main thread")
      return null
    }
    return getPropertyBlocking(name)
  }

  private fun getPropertyBlocking(name: String): String? {
    if (!isInitialized || disposing) return null
    return try {
      runBlocking(Dispatchers.IO) { player?.getString(name) }
    } catch (e: Exception) {
      null
    }
  }

  fun getPropertyAsync(name: String, onResult: (String?) -> Unit) {
    if (!isInitialized || disposing) {
      onResult(null)
      return
    }

    Thread {
      val value = getPropertyBlocking(name)
      runOnMain {
        onResult(if (!disposing && isInitialized) value else null)
      }
    }.start()
  }

  /**
   * Returns MPV stats in the same key format used by the performance overlay.
   * This method performs synchronous native property reads and must not be
   * called on Android's main thread.
   */
  fun getStats(): Map<String, Any?> {
    if (Looper.myLooper() == Looper.getMainLooper()) {
      Log.w(TAG, "Refusing synchronous getStats() on the main thread")
      return mapOf("playerType" to "mpv")
    }

    val hasVideo = getProperty("video-params/w") != null

    val stats = mutableMapOf<String, Any?>(
      "playerType" to "mpv",
      "video-codec" to getProperty("video-codec"),
      "video-params/w" to getProperty("video-params/w"),
      "video-params/h" to getProperty("video-params/h"),
      "videoWidth" to getProperty("dwidth"),
      "videoHeight" to getProperty("dheight"),
      "container-fps" to getProperty("container-fps"),
      "estimated-vf-fps" to getProperty("estimated-vf-fps"),
      "video-bitrate" to getProperty("video-bitrate"),
      "hwdec-current" to getProperty("hwdec-current"),
      "audio-codec-name" to getProperty("audio-codec-name"),
      "audio-params/samplerate" to getProperty("audio-params/samplerate"),
      "audio-params/hr-channels" to getProperty("audio-params/hr-channels"),
      "audio-bitrate" to getProperty("audio-bitrate"),
      "total-avsync-change" to getProperty("total-avsync-change"),
      "cache-used" to getProperty("cache-used"),
      "demuxer-max-bytes" to getProperty("demuxer-max-bytes"),
      "cache-speed" to getProperty("cache-speed"),
      "frame-drop-count" to getProperty("frame-drop-count"),
      "decoder-frame-drop-count" to getProperty("decoder-frame-drop-count"),
      "demuxer-cache-duration" to getProperty("demuxer-cache-duration")
    )

    if (hasVideo) {
      stats["display-fps"] = getProperty("display-fps")
      stats["video-params/pixelformat"] = getProperty("video-params/pixelformat")
      stats["video-params/hw-pixelformat"] = getProperty("video-params/hw-pixelformat")
      stats["video-params/colormatrix"] = getProperty("video-params/colormatrix")
      stats["video-params/primaries"] = getProperty("video-params/primaries")
      stats["video-params/gamma"] = getProperty("video-params/gamma")
      stats["video-params/max-luma"] = getProperty("video-params/max-luma")
      stats["video-params/min-luma"] = getProperty("video-params/min-luma")
      stats["video-params/max-cll"] = getProperty("video-params/max-cll")
      stats["video-params/max-fall"] = getProperty("video-params/max-fall")
      stats["video-params/aspect-name"] = getProperty("video-params/aspect-name")
      stats["video-params/rotate"] = getProperty("video-params/rotate")
    }

    return stats
  }

  fun observeProperty(name: String, format: String) {
    val p = player ?: return
    if (!isInitialized) return
    val fmt = when (format) {
      "double" -> PropertyFormat.Double
      "flag" -> PropertyFormat.Flag
      "string" -> PropertyFormat.String
      else -> PropertyFormat.None
    }
    p.observeProperty(name, fmt)
  }

  fun command(args: Array<String>, onComplete: ((Boolean) -> Unit)? = null) {
    if (!isInitialized || disposing || args.isEmpty() || !scope.isActive) {
      onComplete?.invoke(false)
      return
    }
    scope.launch(mpvWriteDispatcher) {
      var success = false
      try {
        player?.command(*args)
        success = true
      } catch (e: Exception) {
        Log.w(TAG, "command failed", e)
      } finally {
        withContext(NonCancellable + Dispatchers.Main) {
          onComplete?.invoke(success)
        }
      }
    }
  }

  override fun setVisible(visible: Boolean) {
    // Audio-only: no render layer to show or hide — tolerated no-op.
    if (audioOnly || disposing) return
    runOnMain {
      if (disposing) return@runOnMain
      surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
      if (visible) {
        flutterOverlayApplied = false
        ensureFlutterOverlayOnTop()
        rememberCurrentSurfaceSize()
        val surface = currentCandidateSurface()
        if (surface != null) {
          pendingSurface = surface
          refreshVideoOutput("setVisible")
        } else {
          val sv = surfaceView
          if (sv != null) {
            applySurfaceSize(sv.width, sv.height)
          }
        }
      }
      Log.d(TAG, "setVisible($visible)")
    }
  }

  override fun onPipModeChanged(isInPipMode: Boolean) {
    // MPV handles aspect ratio internally via its own surface management
  }

  override fun updateFrame() {
    // Audio-only: no surface to refresh — tolerated no-op.
    if (audioOnly || disposing) return
    runOnMain {
      if (disposing) return@runOnMain
      flutterOverlayApplied = false
      ensureFlutterOverlayOnTop()
      rememberCurrentSurfaceSize()
      val p = player
      if (p == null) {
        Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because player is not ready")
        return@runOnMain
      }
      if (!hasReadyVideoOutput()) {
        val surface = currentCandidateSurface()
        if (surface != null) {
          pendingSurface = surface
          refreshVideoOutput("updateFrame")
        } else {
          Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because no surface is attached")
        }
        return@runOnMain
      }
      scope.launch {
        try {
          applySurfaceSizeInternal(p, force = true)
        } catch (e: Exception) {
          Log.w(TAG, "Failed to update Android MPV surface frame", e)
        }
      }
    }
  }

  // Frame Rate Matching

  override fun setVideoFrameRate(
    fps: Float,
    videoDurationMs: Long,
    extraDelayMs: Long,
    videoWidth: Int,
    videoHeight: Int,
    onComplete: (switched: Boolean) -> Unit
  ) {
    val mgr = frameRateManager
    if (mgr == null) {
      onComplete(false)
      return
    }
    mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight) { switched ->
      player?.let {
        updateDisplayFpsOverride(it, "frame rate switch, switched=$switched") {
          onComplete(switched)
        }
      } ?: onComplete(switched)
    }
  }

  override fun clearVideoFrameRate() {
    frameRateManager?.clearVideoFrameRate()
  }

  // Cleanup

  fun dispose(onComplete: (() -> Unit)? = null) {
    if (disposing) {
      onComplete?.invoke()
      return
    }
    disposing = true
    check(Looper.myLooper() == Looper.getMainLooper())
    Log.d(TAG, "Disposing")

    surfaceContainer?.let { container ->
      container.visibility = View.INVISIBLE
      Log.d(TAG, "Hiding surface container during dispose")
    }

    handler.removeCallbacksAndMessages(null)

    // Clean up frame rate and audio focus.
    // releasePending (not clearVideoFrameRate): symmetric with ExoPlayerCore —
    // dispose only releases the listener/pending future. Restoring the
    // display mode is the explicit Dart-side clearVideoFrameRate's job.
    frameRateManager?.releasePending()
    frameRateManager = null
    audioFocusManager?.release()
    audioFocusManager = null

    // Cancel all coroutines
    scope.cancel()
    pendingVideoOutputDisableJob?.cancel()
    pendingVideoOutputDisableJob = null
    pendingVideoOutputRefreshJob?.cancel()
    pendingVideoOutputRefreshJob = null

    // Clear surface state flags (no native calls on main thread to avoid ANR)
    val p = player
    if (p != null) {
      hasAttachedSurface = false
      attachedSurface = null
      pausedForSurfaceLoss = false
      attachedToPlaceholder = false
      videoOutputRestoring = false
      lastAppliedSurfaceSize = null
      videoOutputEpoch += 1L
    }

    // Capture locals for deferred cleanup (audio-only has no views)
    val sv = surfaceView
    val container = surfaceContainer
    val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)

    surfaceContainer = null
    surfaceView = null

    // Remove layout listener synchronously
    overlayLayoutListener?.let { listener ->
      contentView?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
    }
    overlayLayoutListener = null

    pendingSurface = null
    placeholderSurface?.release()
    placeholderSurface = null
    placeholderImageReader?.close()
    placeholderImageReader = null
    pausedForSurfaceLoss = false
    pausedForAudioFocusLoss = false
    attachedToPlaceholder = false
    videoOutputRestoring = false
    deferredResumeRequested = false
    synchronized(publicPauseIntentLock) {
      publicPauseIntentGeneration += 1L
      resumeBlockedByPublicPause = false
      desiredPaused = true
    }
    videoOutputEpoch = 0L
    pendingVideoOutputDisableJob = null
    isInitialized = false

    // Detach surface and close player on background thread, then remove views
    if (p != null) {
      Thread {
        try {
          // Detach surface BEFORE close to prevent GPU mutex contention with
          // view removal (audio-only never attached one)
          if (!audioOnly) {
            try {
              runBlocking {
                p.setProperty("force-window", "no")
                p.setProperty("vo", "null")
              }
              p.detachSurface()
            } catch (e: Exception) {
              Log.w(TAG, "Failed to detach surface during dispose", e)
            }
          }
          p.close()
        } catch (e: Exception) {
          Log.w(TAG, "MPV close failed", e)
        }
        player = null
        Log.d(TAG, "Disposed (native)")
        Handler(Looper.getMainLooper()).post {
          sv?.holder?.removeCallback(this)
          if (container?.parent != null) {
            contentView?.removeView(container)
          }
          onComplete?.invoke()
        }
      }.start()
    } else {
      // No player — safe to remove views immediately
      Handler(Looper.getMainLooper()).postAtFrontOfQueue {
        sv?.holder?.removeCallback(this)
        if (container?.parent != null) {
          contentView?.removeView(container)
        }
      }
      onComplete?.invoke()
    }

    // Reset scope for potential re-initialization
    scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
  }
}
