import 'dart:async';
import 'dart:io' show Platform;
import 'dart:math';

import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart';

import '../models/playback_queue.dart';
import '../models/playback_source.dart';
import '../models/playback_state.dart';
import '../models/repeat_mode.dart';
import '../models/replay_gain.dart';
import '../models/track.dart';
import 'local_playable_uri_resolver.dart';
import 'local_playback_controller.dart';
import 'playable_uri_resolver.dart';
import 'playback_candidate_source.dart';
import 'playback_controller.dart';
import 'stability_diagnostics.dart';
import 'stream_interruption.dart';

/// Notified when a track finishes playing (reaches its natural end), so play
/// history can be recorded. Carries only the catalog [Track] — never a resolved
/// or authenticated stream URL.
typedef TrackCompletionCallback = void Function(Track track);

/// What the on-device engine should do in response to an audio-focus change.
/// The outcome of [JustAudioPlaybackController.audioFocusAction], kept separate
/// so the standard-contract decision is pure and unit-testable.
enum AudioFocusAction {
  /// A transient loss (a call, a notification/lock sound): pause, and resume on
  /// the matching regain if we were playing.
  pauseTransient,

  /// A permanent loss (another media app took focus): pause and stay paused.
  pausePermanent,

  /// Focus regained — resume only if a transient loss had armed it. Always
  /// restores the engine volume first, so a prior duck can never linger.
  resume,

  /// A duckable transient began (`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`): attenuate
  /// the engine volume but keep playing, rather than going silent.
  duck,

  /// A duckable transient ended: restore the engine volume to its normal level.
  unduck,
}

/// [PlaybackController] backed by `just_audio`.
///
/// This is the only file in the app that knows `just_audio` exists. It adapts
/// the player's separate event streams (state, position, duration) into the
/// single immutable [PlaybackState] the UI renders from. Swapping the engine or
/// wrapping it for background playback later means replacing this class, not
/// the feature code.
///
/// It opens whatever URI a [PlayableUriResolver] returns rather than assuming a
/// local file path, so local files, Android SAF content URIs, and remote
/// (Jellyfin) streams all play through the same path. The default resolver
/// handles only on-device tracks; remote resolution is composed in at the
/// provider layer, keeping this class free of any source-specific knowledge.
class JustAudioPlaybackController implements LocalPlaybackController {
  JustAudioPlaybackController({
    AudioPlayer? player,
    PlayableUriResolver resolver = const LocalPlayableUriResolver(),
    PlaybackCandidateSource candidates = const NoFallbackCandidateSource(),
    PlayableUriResolver? streamingFallbackResolver,
    Random? random,
    TrackCompletionCallback? onTrackCompleted,
  })  : _player = player ?? _defaultPlayer(),
        _resolver = resolver,
        _candidates = candidates,
        _streamingFallbackResolver = streamingFallbackResolver,
        _random = random ?? Random(),
        _onTrackCompleted = onTrackCompleted,
        // Own audio focus only for the engine we created. An injected player
        // (tests, or a future custom engine) keeps whatever interruption
        // handling its owner configured, so unit tests never touch the
        // audio_session platform.
        _manageAudioFocus = player == null {
    _wire();
  }

  /// The default engine, tuned for resilient remote streaming. An injected
  /// [player] (tests, or a future custom engine) bypasses this.
  ///
  /// `handleInterruptions: false` deliberately turns *off* just_audio's built-in
  /// audio-focus handling. That handler pauses on a focus loss but then calls
  /// `play()` again on focus *regain* — so after a transient interruption (a
  /// notification, another app briefly taking focus, or the focus churn some
  /// OEMs emit on screen on/off and around battery-saver/Doze) the engine
  /// resumes on its own, underneath the controller and media session. That is
  /// the "music starts/resumes by itself when the screen turns on / when I
  /// switch apps" regression. [_wireAudioFocus] takes over: it pauses on a real
  /// focus loss and *never* auto-resumes — only an explicit user / media-session
  /// play resumes.
  static AudioPlayer _defaultPlayer() => AudioPlayer(
        audioLoadConfiguration: _streamBuffering,
        handleInterruptions: false,
      );

  /// Buffering tuned for remote streams so a brief network hiccup is absorbed
  /// instead of stalling playback: a generous look-ahead (up to ~2 minutes), a
  /// healthy minimum to resume on after a stall, and a quick initial start.
  /// Applied to the default engine only — an injected player (tests, or a future
  /// custom engine) is left untouched. just_audio exposes ExoPlayer's
  /// `LoadControl` here; there is no lower-level per-request buffer-size knob, so
  /// resilience also comes from the retry/recovery and preload paths.
  static const AudioLoadConfiguration _streamBuffering = AudioLoadConfiguration(
    androidLoadControl: AndroidLoadControl(
      minBufferDuration: Duration(seconds: 30),
      maxBufferDuration: Duration(minutes: 2),
      bufferForPlaybackDuration: Duration(seconds: 2),
      bufferForPlaybackAfterRebufferDuration: Duration(seconds: 5),
      prioritizeTimeOverSizeThresholds: true,
    ),
    darwinLoadControl: DarwinLoadControl(
      automaticallyWaitsToMinimizeStalling: true,
      preferredForwardBufferDuration: Duration(minutes: 1),
    ),
  );

  /// One bounded retry per track for a mid-stream failure, so a transient drop
  /// recovers without ever looping forever on a real outage/expiry.
  static const int _maxStreamRetries = 1;

  final AudioPlayer _player;
  final PlayableUriResolver _resolver;

  /// Supplies the ordered source candidates for the track being played, so a
  /// failed preferred copy can fall back to another copy of the same song. The
  /// default returns just the track itself (no fallback), so single-source
  /// playback is unchanged.
  final PlaybackCandidateSource _candidates;

  /// An optional resolver used to retry a track via streaming when its
  /// offline-cache file fails to open (reclaimed after the existence check,
  /// corrupt, or an unreadable codec). It resolves *past* the offline cache —
  /// the same router the offline-first resolver falls through to on a miss — so
  /// a cached copy that won't play falls back to the live stream instead of
  /// erroring, even for a single-source track with no sibling copy. Null (the
  /// default, and in tests) keeps the original behavior: a failed load simply
  /// moves to the next candidate.
  final PlayableUriResolver? _streamingFallbackResolver;

  final Random _random;

  /// Invoked once each time a track reaches its natural end, before the repeat
  /// mode decides what plays next. Null when no listener is wired (tests, or the
  /// default engine). Used to record play history.
  final TrackCompletionCallback? _onTrackCompleted;
  final StreamController<PlaybackState> _states =
      StreamController<PlaybackState>.broadcast();
  final List<StreamSubscription<void>> _subscriptions =
      <StreamSubscription<void>>[];

  /// Whether this controller owns audio-focus handling for its engine (true only
  /// for the default engine it created; an injected player is left to its owner).
  final bool _manageAudioFocus;

  /// The audio-session subscriptions, kept so [dispose] can cancel them. Null
  /// until [_wireAudioFocus] attaches (real Android/iOS device only).
  StreamSubscription<AudioInterruptionEvent>? _interruptionSub;
  StreamSubscription<void>? _becomingNoisySub;

  /// Whether a focus *regain* should resume playback. Armed only when a
  /// **transient** focus loss (a call, a notification/lock sound) paused us
  /// while we were actively playing; cleared on a permanent loss, a
  /// becoming-noisy pause, and once consumed. So another media app taking focus,
  /// a plain screen-on, or a track the user had paused never auto-resumes, while
  /// a brief transient interruption to background playback recovers.
  bool _resumeAfterTransientLoss = false;

  /// Whether another app currently holds a *duckable* transient focus
  /// (`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`), so the engine volume is attenuated
  /// to [_duckVolumeFactor]. Set on the duck, and cleared — with the volume
  /// restored — on the matching unduck and, as a safety net, on any focus
  /// regain or a pause. So a navigation prompt / assistant lowers the music
  /// briefly instead of either silencing it or leaving it stuck quiet once
  /// focus returns. Exposed read-only for tests via [isDuckedForTesting].
  bool _ducked = false;

  /// The fraction of the normal engine volume used while another app holds a
  /// duckable transient focus. Audible (not silent), so Linthra "keeps playing
  /// with sound" through the duck and cooperates with the focus request, and
  /// always restored to full on regain so it can never be left muted.
  static const double _duckVolumeFactor = 0.3;

  /// A pending transient-loss pause awaiting [focusPauseDebounce]. A brief
  /// screen-off / Doze focus *blip* (a loss immediately followed by a regain,
  /// which some OEMs emit when the screen turns off) is cancelled here before it
  /// ever pauses — so the foreground media service is never demoted and the OS
  /// can't freeze background playback (which would only recover on reopening the
  /// app). Null when no transient loss is in flight.
  Timer? _pendingFocusPause;

  /// How long a transient focus loss must persist before it actually pauses, so
  /// a brief screen-off/Doze focus blip is absorbed without pausing while a real
  /// call / another app's voice session (which holds focus) still pauses.
  /// Settable in tests to keep them fast; never changed in production.
  @visibleForTesting
  Duration focusPauseDebounce = const Duration(milliseconds: 250);

  // --- Serialized, last-intent-wins focus engine effects -------------------
  //
  // Focus events arrive in quick, messy bursts (back-to-back transient losses,
  // a regain landing right as the debounced pause fires, a foreground restore
  // racing a regain). Issuing pause()/play()/setVolume() straight from each
  // handler let those overlap at the engine, so recovery only succeeded about
  // half the time. Every focus-driven engine effect now runs through one chain
  // so they apply strictly in order (a pause fully settles before the following
  // resume). Transport actions also carry the [_focusEpoch] they were queued at:
  // any newer focus decision bumps the epoch, so an older queued pause/play — or
  // a debounce timer that fires late — is skipped instead of clobbering the
  // newer intent. Net effect: the final engine state is a deterministic function
  // of the *last* focus decision, regardless of event ordering or timing.
  Future<void> _focusChain = Future<void>.value();
  int _focusEpoch = 0;

  PlaybackState _state = PlaybackState.idle;
  PlaybackQueue _queue = PlaybackQueue.empty;

  // When true a cast receiver is the active output, so queue changes update the
  // current track/up-next but never load or play through the engine. Set/cleared
  // by [suspend]/[resume], driven by the ActivePlaybackController.
  bool _suspended = false;

  // Shuffle and repeat are session-wide playback modes owned here (not by the
  // engine, which only ever has one source loaded at a time). They persist
  // across track changes and are mirrored onto every emitted state.
  bool _shuffleEnabled = false;
  RepeatMode _repeatMode = RepeatMode.off;

  // Whether ReplayGain volume normalization is applied. Off by default so audio
  // is never altered until the listener opts in. Mirrored onto the engine's
  // volume whenever a track loads (and immediately when toggled).
  bool _normalizeVolume = false;

  // How many times the current track has been retried after a mid-stream
  // failure. Reset when a fresh track loads and when playback reaches `playing`,
  // so each track (and each successful stretch) gets its own one-retry budget.
  int _retriesForCurrent = 0;

  // Guards against overlapping playback transitions. Every action that changes
  // what's playing — skip to next/previous, jump within the queue or history,
  // load a new queue, restart, resume after a cast handoff, or seek — bumps this
  // counter; the in-flight [_playCurrent] captures it at its start and abandons
  // its async result (resolve + engine load) the moment the counter moves on. So
  // when the user rapidly skips, only the latest action's track is ever loaded,
  // emitted, or played: a stale resolve that finishes late can't override the
  // track they actually landed on. The queue itself stays correct independently
  // of this — its mutations are synchronous — so this only gates the async
  // load/playback, never the queue order.
  int _playbackGeneration = 0;

  // The latest engine position awaiting a coalesced flush, and the timer that
  // flushes it. The engine's positionStream can fire several times a second
  // (more in bursts during seeking/buffering); emitting a new state for every
  // raw tick floods the unified stream — and the UI, media session, and
  // pre-cache services hanging off it. These coalesce position onto a steady
  // cadence; status/duration/track changes still emit immediately.
  Duration? _pendingPosition;
  Timer? _positionFlush;
  static const Duration _positionFlushInterval = Duration(milliseconds: 250);

  @override
  PlaybackState get state => _state;

  @override
  Stream<PlaybackState> get stateStream => _states.stream;

  void _wire() {
    _subscriptions.add(_player.playerStateStream.listen(
      handleEngineState,
      onError: (Object _, StackTrace __) {
        // Engine errors are recovered via [playbackEventStream] below; swallow
        // any duplicate that the player-state stream may forward so it never
        // becomes an unhandled async error (and so we never act on it twice).
      },
    ));
    _subscriptions.add(_player.positionStream.listen((position) {
      if (_suspended) return;
      // Coalesce raw position ticks onto a steady ~4 Hz flush so a high (or
      // bursty) engine tick rate can never flood the state stream with rebuilds.
      _pendingPosition = position;
      _positionFlush ??=
          Timer.periodic(_positionFlushInterval, (_) => _flushPosition());
    }));
    _subscriptions.add(_player.durationStream.listen((duration) {
      if (_suspended) return;
      if (duration != null) _emit(_state.copyWith(duration: duration));
    }));
    // A mid-stream failure (network drop, expired token, server gone) surfaces
    // here as a stream error. Recover gracefully rather than dying silently.
    _subscriptions.add(
      _player.playbackEventStream.listen((_) {}, onError: _onEngineError),
    );
    _wireAudioFocus();
  }

  /// Takes over audio-focus handling from just_audio (disabled via
  /// `handleInterruptions: false`) so playback follows the standard Android
  /// audio-focus contract deliberately, rather than just_audio's
  /// resume-after-any-loss behaviour.
  ///
  /// Best-effort and platform-gated: only the engine we created manages focus,
  /// and only on a real Android/iOS device — a test host or desktop has no
  /// audio_session platform, so it stays inert (and unit tests never touch the
  /// platform channel). The classification is the pure [audioFocusAction]; the
  /// loud-output guard is [_onBecomingNoisy].
  void _wireAudioFocus() {
    if (!_manageAudioFocus) return;
    if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return;
    unawaited(_attachAudioSession());
  }

  Future<void> _attachAudioSession() async {
    try {
      final AudioSession session = await AudioSession.instance;
      // `androidWillPauseWhenDucked: true` stops the OS from *silently*
      // auto-ducking (and, on some devices, never restoring) our stream when
      // any other app grabs `AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK` — the
      // "another app takes focus and Linthra keeps playing but goes silent"
      // bug. With it set, the duck is delivered to our interruption handler
      // instead, so we own the volume change deterministically and always
      // restore it on regain (see [_onAudioInterruption]).
      await session.configure(
        const AudioSessionConfiguration.music()
            .copyWith(androidWillPauseWhenDucked: true),
      );
      _interruptionSub =
          session.interruptionEventStream.listen(_onAudioInterruption);
      _becomingNoisySub =
          session.becomingNoisyEventStream.listen((_) => _onBecomingNoisy());
    } catch (_) {
      // No audio_session platform here: skip focus management entirely. Playback
      // still works; we simply don't pause on a focus loss on this host.
    }
  }

  /// Reacts to an audio-focus [event] for the on-device engine, following the
  /// standard Android contract — *not* just_audio's resume-after-any-loss.
  ///
  /// The crucial distinction (encoded by audio_session in the event type):
  ///  - a **transient** loss (a call, a notification or lock sound briefly
  ///    grabbing focus) pauses and, when focus returns, *resumes* — but only if
  ///    we were actually playing. A momentary screen-off interruption to
  ///    background playback recovers instead of becoming a stuck pause.
  ///  - a **permanent** loss (another media app took focus for good) pauses and
  ///    **stays** paused; returning to Linthra must not auto-resume.
  ///  - a plain screen-on / app-return / exit-Doze emits *no* focus loss at all
  ///    (focus is independent of screen state), and a bare regain with nothing
  ///    to resume is ignored — so the screen turning on never starts playback.
  ///
  /// While suspended a cast receiver owns the audio and the engine is parked, so
  /// a local focus change is ignored. Each branch leaves a secret-free
  /// breadcrumb so a field report can tell exactly which event paused/resumed.
  void _onAudioInterruption(AudioInterruptionEvent event) {
    if (_suspended) return;
    switch (audioFocusAction(event)) {
      case AudioFocusAction.pauseTransient:
        // Resume afterwards only if this interrupted active playback; never
        // arm a resume for something the user had already paused. Crucially,
        // keep an already-armed resume armed: a single voice session can emit
        // *several* transient losses in a row (an app grabbing may-duck focus
        // then escalating to exclusive mic/voice focus, which — with the
        // session configured to pause when ducked — all surface as transient
        // pauses). Re-reading `isPlaying` on the 2nd event would see the
        // already-paused state and wrongly disarm, so the eventual regain would
        // never resume (the "voice ends and Linthra stays silent" bug).
        _resumeAfterTransientLoss =
            _state.isPlaying || _state.isBusy || _resumeAfterTransientLoss;
        // A real transient loss supersedes a duck: clear it so the resume (or a
        // later manual play) is at full volume, never stuck at the duck level.
        _restoreDuckedVolume();
        // Debounced: a brief screen-off / Doze focus blip is absorbed by the
        // matching regain before this fires, so it never pauses (and so never
        // demotes the foreground service). Only a loss that lasts past the
        // window actually pauses.
        _scheduleTransientPause();
        StabilityDiagnostics.audioFocus(
            'loss-transient:scheduled armed=$_resumeAfterTransientLoss');
      case AudioFocusAction.pausePermanent:
        _resumeAfterTransientLoss = false;
        _cancelPendingFocusPause();
        StabilityDiagnostics.audioFocus('loss-permanent:paused');
        _restoreDuckedVolume();
        _enqueueFocusPause();
      case AudioFocusAction.duck:
        // A duckable transient (a navigation prompt, an assistant, another app
        // opening with may-duck focus): lower the volume to an audible level and
        // keep playing — instead of letting the OS silently auto-duck us into a
        // stuck-quiet state it may never restore. Undone on the unduck/regain.
        _ducked = true;
        StabilityDiagnostics.audioFocus('loss-duck:ducked');
        _enqueueFocusVolume();
      case AudioFocusAction.unduck:
        // The duckable transient ended: bring the volume back to normal.
        StabilityDiagnostics.audioFocus(
            _ducked ? 'unduck:restored' : 'unduck:ignored');
        _restoreDuckedVolume();
      case AudioFocusAction.resume:
        // Focus regained. Restore the volume unconditionally (a safety net so a
        // prior duck can never leave us quiet), then decide on transport. A
        // resume below bumps the epoch (superseding any stale queued pause); a
        // no-op `ignored` leaves a resume another path may have just queued
        // intact, so two recovery paths racing still converge on playing.
        _restoreDuckedVolume();
        final bool wasPending = _cancelPendingFocusPause();
        if (wasPending) {
          // The transient loss never outlasted the debounce window — a brief
          // screen-off / Doze focus blip. We never paused, so just keep playing
          // and disarm the now-moot resume. Pausing+resuming here would demote
          // the foreground service and risk an OS freeze with the screen off.
          _resumeAfterTransientLoss = false;
          StabilityDiagnostics.audioFocus('regain:churn-absorbed');
        } else if (_resumeAfterTransientLoss) {
          _resumeAfterTransientLoss = false;
          StabilityDiagnostics.audioFocus('regain:resumed');
          _enqueueFocusResume();
        } else {
          // Focus came back but the loss was permanent (or we weren't playing):
          // the screen-wake / app-return / exit-Doze case — never auto-resume.
          StabilityDiagnostics.audioFocus('regain:ignored');
        }
    }
  }

  /// Schedules the pause for a transient focus loss after [focusPauseDebounce]
  /// rather than pausing immediately, so a brief screen-off / Doze focus blip (a
  /// loss the matching regain supersedes straight away) is absorbed without ever
  /// pausing — the foreground media service stays up and background playback
  /// can't be frozen. A loss that persists past the window (a real call / voice
  /// session) still pauses. Idempotent: a repeated loss while one is already
  /// pending keeps the single in-flight timer. When it fires it enqueues the
  /// pause onto the serialized chain at the *current* epoch, so a regain that
  /// arrives moments later (bumping the epoch) makes the pause a no-op instead
  /// of racing the resume.
  void _scheduleTransientPause() {
    if (_pendingFocusPause != null) return;
    _pendingFocusPause = Timer(focusPauseDebounce, () {
      _pendingFocusPause = null;
      StabilityDiagnostics.audioFocus(_resumeAfterTransientLoss
          ? 'loss-transient:paused'
          : 'loss-transient:already-paused');
      _enqueueFocusPause();
    });
  }

  /// Cancels a not-yet-fired transient-loss pause. Returns whether one was
  /// pending (so a regain can tell an absorbed blip — we never paused — from a
  /// regain after a real, already-applied pause).
  bool _cancelPendingFocusPause() {
    final Timer? pending = _pendingFocusPause;
    if (pending == null) return false;
    pending.cancel();
    _pendingFocusPause = null;
    return true;
  }

  /// Marks any queued or in-flight focus transport as superseded — without
  /// enqueueing a new one — and cancels a pending debounce pause. Used by an
  /// explicit user/media-session transport so a stale focus pause/resume can't
  /// fight it.
  void _supersedeFocusTransport() {
    _focusEpoch++;
    _cancelPendingFocusPause();
  }

  /// Queues a serialized transport action and bumps [_focusEpoch], so this
  /// becomes the latest transport intent: any older pause/play still queued (or
  /// a debounce timer that fires late and enqueues a now-stale pause) is skipped
  /// instead of clobbering it. Crucially the epoch is bumped only when a *new*
  /// transport is queued — a no-op recovery (e.g. an `ignored` regain) leaves a
  /// resume another path just queued intact, so two recovery paths racing still
  /// converge on playing.
  void _enqueueTransport(Future<void> Function() action) {
    final int epoch = ++_focusEpoch;
    _focusChain = _focusChain.then((_) async {
      if (epoch != _focusEpoch) return; // a newer transport superseded this one
      await action();
    }).catchError((Object _) {
      // A transport failure must never break the chain or playback.
    });
  }

  /// Queues a transport pause for a focus loss.
  void _enqueueFocusPause() => _enqueueTransport(() => _player.pause());

  /// Queues a transport resume for a focus regain. A cast receiver owns audio
  /// while suspended, so it is a no-op then.
  ///
  /// `_player.play()`'s future completes only when playback *ends*, so awaiting
  /// it on the serialized chain would keep the chain occupied for the rest of
  /// the track — every later focus action (a new pause, a duck, a volume
  /// restore) would be blocked until the song finished (e.g. a phone call after
  /// a recovered prompt would play through). Fire it without awaiting; the chain
  /// still guarantees the resume is *issued* only after any preceding pause has
  /// settled, which is all the ordering we need.
  void _enqueueFocusResume() {
    if (_suspended) return;
    _enqueueTransport(() async {
      unawaited(_player.play());
    });
  }

  /// Queues the engine volume for the current duck state on the same serialized
  /// chain (so it never overlaps a pause/play), capturing the target now so it
  /// can't be read from a later-mutated [_ducked]. Not epoch-guarded, so a duck
  /// and its restore both apply in order and the last (full volume on recovery)
  /// wins — Linthra can never be left stuck ducked.
  void _enqueueFocusVolume() {
    if (_suspended) return;
    final double base =
        volumeFor(_queue.current, normalizeVolume: _normalizeVolume);
    final double target = _ducked ? base * _duckVolumeFactor : base;
    _focusChain = _focusChain.then((_) async {
      try {
        await _player.setVolume(target);
      } catch (_) {
        // A volume failure must never break playback; leave the prior volume.
      }
    }).catchError((Object _) {});
  }

  /// Clears an active duck and pushes the normal (un-attenuated) volume back to
  /// the engine. A no-op when not ducked, so calling it on every regain/pause is
  /// safe and guarantees Linthra is never left in a ducked/muted state.
  void _restoreDuckedVolume() {
    if (!_ducked) return;
    _ducked = false;
    _enqueueFocusVolume();
  }

  /// Drives [_onAudioInterruption] from a test (an injected player turns off the
  /// real audio_session wiring), so the duck/restore and pause/resume contract
  /// can be exercised without a platform channel.
  @visibleForTesting
  void onAudioInterruption(AudioInterruptionEvent event) =>
      _onAudioInterruption(event);

  /// Whether the engine volume is currently attenuated by an active duck — so a
  /// test can assert the duck/restore lifecycle.
  @visibleForTesting
  bool get isDuckedForTesting => _ducked;

  /// A volume safety-net for when the app returns to the foreground.
  ///
  /// If a duck somehow lingered (e.g. an iOS duck whose unduck was never
  /// delivered), restore full volume so Linthra is never left quietly ducked.
  ///
  /// It deliberately does **not** resume playback. The only valid signal that a
  /// transient interruption has ended is an `AUDIOFOCUS_GAIN`, which is now
  /// processed reliably in the background (the media service stays foreground
  /// across a pause, so the isolate isn't frozen — see the audio-service config
  /// in `LinthraAudioHandler`). Resuming on a mere foreground event could
  /// restart audio over an interruption that is *still* holding focus — e.g.
  /// opening Linthra while a call is ongoing — so resume is left entirely to the
  /// real focus regain. A no-op while playing or paused-without-a-duck.
  @override
  void onAppForegrounded() {
    if (_suspended) return;
    _restoreDuckedVolume();
  }

  /// Classifies an audio-focus [event] into the action the engine should take,
  /// per the standard Android contract. Pure and unit-tested, so the
  /// "screen-on never resumes / a permanent loss stays paused / a transient loss
  /// recovers" rules can be exercised without any platform.
  ///
  /// audio_session maps Android's focus changes onto the event type:
  ///  - begin + `pause`   ← `AUDIOFOCUS_LOSS_TRANSIENT` (a transient loss)
  ///  - begin + `unknown` ← `AUDIOFOCUS_LOSS` (a permanent loss; focus abandoned)
  ///  - begin + `duck`    ← `AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`
  ///  - end   + any       ← `AUDIOFOCUS_GAIN` (focus regained)
  ///
  /// Note: because the session is configured with `androidWillPauseWhenDucked:
  /// true` (see [_attachAudioSession]), audio_session converts a real
  /// `AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK` into a `pause` event rather than a
  /// `duck` — so on a live Android device a duckable transient is handled as a
  /// transient pause (and the matching regain resumes). The `duck`/`unduck`
  /// branches still exist for iOS and any caller that ducks explicitly, and keep
  /// the volume-restore contract honest there.
  @visibleForTesting
  static AudioFocusAction audioFocusAction(AudioInterruptionEvent event) {
    if (event.begin) {
      switch (event.type) {
        case AudioInterruptionType.pause:
          return AudioFocusAction.pauseTransient;
        case AudioInterruptionType.unknown:
          return AudioFocusAction.pausePermanent;
        case AudioInterruptionType.duck:
          return AudioFocusAction.duck;
      }
    }
    // Interruption ended / focus regained.
    switch (event.type) {
      case AudioInterruptionType.pause:
      case AudioInterruptionType.unknown:
        return AudioFocusAction.resume;
      case AudioInterruptionType.duck:
        return AudioFocusAction.unduck;
    }
  }

  /// Headphones unplugged / the output became the phone speaker: pause so audio
  /// doesn't suddenly blast out loud, and clear any armed transient-resume so it
  /// never auto-resumes when they're plugged back in.
  void _onBecomingNoisy() {
    if (_suspended) return;
    _resumeAfterTransientLoss = false;
    // Headphones really were pulled: cancel a pending debounce pause and pause
    // now (the enqueued pause bumps the epoch, superseding any queued resume).
    _cancelPendingFocusPause();
    StabilityDiagnostics.audioFocus('noisy:paused');
    // Clear any active duck so the next play is at full volume, not stuck quiet.
    _restoreDuckedVolume();
    _enqueueFocusPause();
  }

  /// Maps one raw engine [PlayerState] onto the unified [PlaybackState] and
  /// emits it. The [_wire] player-state subscription forwards every event here;
  /// it is exposed for tests so the screen-off transient-idle guard below can be
  /// exercised without a platform channel.
  @visibleForTesting
  void handleEngineState(PlayerState playerState) {
    // While suspended a cast receiver owns playback; ignore the (paused) local
    // engine's events entirely, so it can never auto-advance or report stale
    // status underneath the cast session.
    if (_suspended) return;
    final status = _statusFor(playerState);
    // just_audio pushes a fresh, default PlaybackEvent — whose processingState
    // is `idle` — synchronously at the *start* of every setAudioSource/setUrl
    // call, before the new source begins loading. That happens on every track
    // transition and every mid-stream retry reload, while `playing` is still
    // true. Forwarding that idle would make the media session report
    // `playing: false` + `idle` mid-transition, demoting audio_service's
    // foreground media service (and dropping the CPU/Wi-Fi wake locks it holds)
    // at the exact moment a new track loads with the screen off — letting the
    // OS freeze the backgrounded process so audio only resumes when the app is
    // reopened (the screen-off cutout bug). The controller is the sole
    // authority on going idle: it starts idle and emits its own idle from
    // stop(), so a raw engine idle is never needed here — drop it so a reload
    // can never demote the service. Buffering/loading still flow through (they
    // map to a still-`playing` session) and a real pause/completion/error is
    // unaffected.
    if (status == PlaybackStatus.idle) return;
    // Playback is healthy again: give a later, independent drop a fresh retry.
    if (status == PlaybackStatus.playing) _retriesForCurrent = 0;
    // When a track finishes, what happens next depends on the repeat mode.
    if (status == PlaybackStatus.completed) {
      _onCompleted();
      return;
    }
    _emit(_state.copyWith(status: status));
  }

  /// Recovers from an engine error that happens **while streaming**. A transient
  /// drop gets one bounded retry that re-resolves and reloads at the preserved
  /// position; an auth/format failure (or an exhausted retry) shows a friendly,
  /// secret-free message. The raw [error] (which can carry a tokenized URL) is
  /// never logged or surfaced — only its classification is used.
  void _onEngineError(Object error, StackTrace _) {
    // A cast receiver owns the audio while suspended: never touch local
    // playback, so a local engine glitch can't pull output back from the
    // receiver or start duplicate audio.
    if (_suspended) return;
    // Only recover from a failure that happens once we're actually streaming; an
    // initial-load failure is handled where setUrl is awaited (with its own
    // friendly message), so we don't fight it here.
    if (_state.status != PlaybackStatus.playing &&
        _state.status != PlaybackStatus.buffering) {
      return;
    }
    final Track? track = _queue.current;
    if (track == null) return;

    final StreamInterruption interruption = classifyEngineError(error);
    // Secret-free breadcrumb: only the classified *kind*, never the raw error
    // (which can echo a tokenized stream URL).
    StabilityDiagnostics.playbackError(interruption.kind.name);
    if (interruption.retryable && _retriesForCurrent < _maxStreamRetries) {
      _retriesForCurrent++;
      // Re-resolve and reload at the preserved position. The resolver re-checks
      // the session/server, so a real expiry/outage surfaces a precise friendly
      // error while a transient blip simply recovers. setUrl replaces the
      // source, so there is no duplicate playback and no jump to the start.
      unawaited(_playCurrent(startAt: _state.position, isRetry: true));
      return;
    }
    _emitError(track, interruption.message);
  }

  /// Maps the engine's (playing, processingState) pair to a [PlaybackStatus].
  ///
  /// The key distinction: `buffering` *while the engine wants to play* is a
  /// mid-stream re-buffer ([PlaybackStatus.buffering] — a calm "Buffering…"
  /// hint, not a frozen player), whereas `loading`, or buffering *before* the
  /// first play, is still the initial preparing state ([PlaybackStatus.loading]).
  @visibleForTesting
  static PlaybackStatus statusFor(PlayerState playerState) =>
      _statusFor(playerState);

  static PlaybackStatus _statusFor(PlayerState playerState) {
    switch (playerState.processingState) {
      case ProcessingState.idle:
        return PlaybackStatus.idle;
      case ProcessingState.loading:
        return PlaybackStatus.loading;
      case ProcessingState.buffering:
        return playerState.playing
            ? PlaybackStatus.buffering
            : PlaybackStatus.loading;
      case ProcessingState.ready:
        return playerState.playing
            ? PlaybackStatus.playing
            : PlaybackStatus.paused;
      case ProcessingState.completed:
        return PlaybackStatus.completed;
    }
  }

  void _emit(PlaybackState next, {bool force = false}) {
    // [force] bypasses the equality guard for a state that differs only in a way
    // PlaybackState == can't see — namely a same-bare-id provider swap, where
    // Track == compares only the bare id (jellyfin:101 == subsonic:101), so the
    // new state compares equal even though a different provider's copy is now
    // playing. Without it the update would be dropped and UI/reporting would keep
    // showing the failed provider.
    if (!force && next == _state) return;
    _state = next;
    if (!_states.isClosed) _states.add(next);
  }

  /// Emits the latest coalesced position. When nothing new has arrived since the
  /// last flush (paused/stopped), it stops the timer so it never ticks idly; the
  /// next position event restarts it.
  void _flushPosition() {
    final Duration? position = _pendingPosition;
    _pendingPosition = null;
    if (position == null) {
      _positionFlush?.cancel();
      _positionFlush = null;
      return;
    }
    if (_suspended) return;
    _emit(_state.copyWith(position: position));
  }

  /// Drops any pending position and stops the flush timer, so a stale tick from
  /// the previous source can't flush onto a freshly established state (a new
  /// track loading, stop, or a cast handoff).
  void _resetPositionFlush() {
    _pendingPosition = null;
    _positionFlush?.cancel();
    _positionFlush = null;
  }

  @override
  Future<void> playTrack(Track track) => playTracks(<Track>[track]);

  @override
  Future<void> playTracks(List<Track> tracks, {int startIndex = 0}) {
    var queue = PlaybackQueue.of(tracks, startIndex: startIndex);
    // Shuffle is a persistent mode: a queue loaded while it's on starts
    // shuffled, with the chosen track kept as the one that plays first.
    if (_shuffleEnabled) queue = queue.shuffled(_random);
    _queue = queue;
    return _playCurrent();
  }

  @override
  void playNext(Track track) {
    final bool wasEmpty = _queue.current == null;
    _queue = _queue.enqueueNext(track);
    if (wasEmpty) {
      // Nothing was playing: "play next" has nothing to play after, so start
      // the track now rather than silently leaving it queued.
      unawaited(_playCurrent());
      return;
    }
    // The current track keeps playing; only the up-next list changes.
    _emit(_state.copyWith(upNext: _queue.upNext));
  }

  @override
  void addToQueue(Track track) {
    final bool wasEmpty = _queue.current == null;
    _queue = _queue.appended(track);
    if (wasEmpty) {
      // An empty queue has nothing playing to append behind: start the track.
      unawaited(_playCurrent());
      return;
    }
    // The current track keeps playing; only the up-next list grows.
    _emit(_state.copyWith(upNext: _queue.upNext));
  }

  @override
  void removeFromQueue(int upNextIndex) {
    final updated = _queue.removeUpNextAt(upNextIndex);
    if (identical(updated, _queue)) return; // out of range: nothing to do
    _queue = updated;
    // Only the up-next list shrank; the current track and its audio are
    // untouched — no reload, no restart.
    _emit(_state.copyWith(upNext: _queue.upNext));
  }

  @override
  void reorderQueue(int oldIndex, int newIndex) {
    final updated = _queue.reorderUpNext(oldIndex, newIndex);
    if (identical(updated, _queue)) return;
    _queue = updated;
    // The current track keeps playing; only the order of what follows changes.
    _emit(_state.copyWith(upNext: _queue.upNext));
  }

  @override
  Future<void> playFromQueue(int upNextIndex) async {
    final jumped = _queue.jumpToUpNext(upNextIndex);
    if (identical(jumped, _queue)) return;
    _queue = jumped;
    await _playCurrent();
  }

  @override
  Future<void> playFromHistory(int previousIndex) async {
    final jumped = _queue.jumpToHistory(previousIndex);
    if (identical(jumped, _queue)) return;
    _queue = jumped;
    await _playCurrent();
  }

  @override
  Future<void> skipToNext() async {
    if (!_queue.hasNext) return;
    _queue = _queue.next();
    await _playCurrent();
  }

  @override
  Future<void> skipToPrevious() async {
    if (!_queue.hasPrevious) return;
    _queue = _queue.previous();
    await _playCurrent();
  }

  @override
  void clearQueue() {
    _queue = _queue.cleared();
    // Clearing keeps only the current track, so both the up-next list and the
    // history collapse to empty; the current track's audio is untouched.
    _emit(_state.copyWith(
      upNext: _queue.upNext,
      previous: _queue.history,
      hasPrevious: false,
    ));
  }

  @override
  void setShuffleEnabled(bool enabled) {
    if (enabled == _shuffleEnabled) return;
    _shuffleEnabled = enabled;
    // Reorder in place: the current track keeps playing; only the up-next list
    // (and whether a previous track now exists) changes — no reload.
    _queue = enabled ? _queue.shuffled(_random) : _queue.unshuffled();
    _emit(_state.copyWith(
      upNext: _queue.upNext,
      previous: _queue.history,
      hasPrevious: _queue.hasPrevious,
      shuffleEnabled: _shuffleEnabled,
    ));
  }

  @override
  void setRepeatMode(RepeatMode mode) {
    if (mode == _repeatMode) return;
    _repeatMode = mode;
    _emit(_state.copyWith(repeatMode: _repeatMode));
  }

  @override
  void setVolumeNormalizationEnabled(bool enabled) {
    if (enabled == _normalizeVolume) return;
    _normalizeVolume = enabled;
    // Re-level whatever is loaded so toggling takes effect now, not only on the
    // next track. Best-effort and silent: a volume tweak must never surface as a
    // playback error or interrupt audio.
    unawaited(_applyVolume());
  }

  /// The engine volume (0.0–1.0) to use for [track] given whether normalization
  /// is on. With it off, or no track loaded, returns 1.0 (full, untouched).
  /// With it on, returns the track's safe ReplayGain multiplier — attenuation
  /// only and clip-safe (see [ReplayGain.linearVolume]).
  @visibleForTesting
  static double volumeFor(Track? track, {required bool normalizeVolume}) {
    if (!normalizeVolume || track == null) return 1.0;
    return track.replayGain.linearVolume();
  }

  /// Pushes the target volume for the current track onto the engine. A cast
  /// receiver owns its own volume while suspended, so this is a no-op then.
  Future<void> _applyVolume() async {
    if (_suspended) return;
    double volume =
        volumeFor(_queue.current, normalizeVolume: _normalizeVolume);
    // While another app holds a duckable transient focus, attenuate (but keep
    // playing). [_restoreDuckedVolume] clears the flag and re-applies full
    // volume on the unduck/regain, so the engine is never left ducked.
    if (_ducked) volume *= _duckVolumeFactor;
    try {
      await _player.setVolume(volume);
    } catch (_) {
      // A volume failure must never break playback; leave the prior volume.
    }
  }

  /// Decides what to play when the current track finishes, per [_repeatMode]:
  /// repeat-one replays the same track, repeat-all advances (wrapping past the
  /// end), and off advances until the queue runs out and then settles on
  /// [PlaybackStatus.completed].
  void _onCompleted() {
    // The track that just finished is still current here, before any advance.
    // Record the completed play once, regardless of what plays next.
    final Track? finished = _queue.current;
    if (finished != null) _onTrackCompleted?.call(finished);
    switch (_repeatMode) {
      case RepeatMode.one:
        unawaited(_replayCurrent());
      case RepeatMode.all:
        if (_queue.hasNext) {
          skipToNext();
        } else {
          _queue = _queue.restarted();
          unawaited(_playCurrent());
        }
      case RepeatMode.off:
        if (_queue.hasNext) {
          skipToNext();
        } else {
          _emit(_state.copyWith(status: PlaybackStatus.completed));
        }
    }
  }

  /// Replays the current track from the start without re-resolving its URI, so
  /// repeat-one never re-mints a stream URL or re-hits the cache each loop.
  Future<void> _replayCurrent() async {
    await _player.seek(Duration.zero);
    unawaited(_player.play());
  }

  /// Loads and plays the queue's current track, surfacing its up-next list.
  ///
  /// Resolution (local path / content URI / remote stream) happens through the
  /// [PlayableUriResolver]. A resolution failure carries its own friendly,
  /// secret-free message; a load failure after a successful resolve falls back
  /// to a generic one.
  Future<void> _playCurrent({
    Duration startAt = Duration.zero,
    bool autoplay = true,
    bool isRetry = false,
  }) async {
    // Open a new playback generation: this transition supersedes any earlier
    // one still resolving/loading. Captured locally so the awaits below can tell
    // when a newer skip/seek has taken over and bail before clobbering it.
    final int generation = ++_playbackGeneration;
    final track = _queue.current;
    if (track == null) return;

    // A fresh load (or retry) establishes a new position baseline; drop any
    // coalesced tick from the previous source so it can't flush onto it.
    _resetPositionFlush();

    // A fresh (non-retry) load starts a new track or a deliberate (re)play, so
    // reset the mid-stream recovery budget. A retry must keep its counter.
    if (!isRetry) _retriesForCurrent = 0;

    if (_suspended) {
      // A cast receiver owns the audio. Reflect only the queue/track so the UI
      // shows what is playing; the ActivePlaybackController overrides
      // status/position/duration from the cast session. No URI resolve (no need
      // to mint a local stream URL) and no engine work, so the phone stays
      // silent and the local and cast outputs never fight.
      _emit(PlaybackState(
        status: PlaybackStatus.paused,
        currentTrack: track,
        upNext: _queue.upNext,
        previous: _queue.history,
        hasPrevious: _queue.hasPrevious,
        shuffleEnabled: _shuffleEnabled,
        repeatMode: _repeatMode,
      ));
      return;
    }

    if (isRetry) {
      // Recovering from a mid-stream drop: keep the current track and position
      // visible and show a calm buffering state, rather than blanking to a fresh
      // load (which would look like the track jumped back to the start).
      _emit(_state.copyWith(status: PlaybackStatus.buffering));
    } else {
      // Reset position/duration up front so the UI doesn't show the previous
      // track's progress while the new one loads.
      _emit(PlaybackState(
        status: PlaybackStatus.loading,
        currentTrack: track,
        upNext: _queue.upNext,
        previous: _queue.history,
        hasPrevious: _queue.hasPrevious,
        shuffleEnabled: _shuffleEnabled,
        repeatMode: _repeatMode,
      ));
    }

    // Try the track's source candidates in deterministic, most-preferred-first
    // order, falling back to the next copy of the same song when the preferred
    // one can't resolve or start. A single-source track has just one candidate,
    // so this behaves exactly as a direct load did.
    final List<Track> candidates = _candidates.candidatesFor(track);
    final ({Track track, ResolvedPlayable resolved})? outcome;
    try {
      outcome = await _loadFirstWorkingCandidate(candidates, generation);
    } on PlaybackResolutionException catch (error) {
      // A failure from a transition the user has already skipped past must not
      // surface as an error on the track they actually landed on.
      if (generation != _playbackGeneration) return;
      // Every candidate failed: surface one friendly, secret-free message.
      _emitError(track, error.message);
      return;
    }

    // Superseded while resolving/loading — a newer skip/seek bumped the
    // generation (outcome is null when it raced before the engine load, or the
    // counter moved on after it returned). Drop this stale result without
    // touching the queue, the emitted state, or playback: the newer transition
    // now owns the engine and will load/play its own track.
    if (outcome == null || generation != _playbackGeneration) return;

    // Make the copy that actually started the current one, so the queue, the
    // mini-player, and the "Playing from …" indicator all reflect the source
    // that succeeded — not the preferred one that may have failed. The resolved
    // source rides along on later position/status updates until the next load.
    final Track played = outcome.track;
    // Compare by uri, not the bare id: a fallback can land on another provider's
    // copy that shares the bare id (jellyfin:101 -> subsonic:101), and the queue
    // must still swap to it so completion/retry read the copy that started.
    final bool swappedProvider = played.uri != track.uri;
    if (swappedProvider) _queue = _queue.replaceCurrent(played);
    _emit(
      _state.copyWith(
        currentTrack: played,
        source: outcome.resolved.source,
        upNext: _queue.upNext,
        previous: _queue.history,
        hasPrevious: _queue.hasPrevious,
      ),
      // A same-bare-id provider swap leaves every field equal under
      // PlaybackState == (Track == is by bare id), so force the emission —
      // otherwise UI/reporting keep showing the failed provider's copy.
      force: swappedProvider,
    );

    // Level this track before it's heard (its ReplayGain, or full volume when
    // normalization is off); resume at the preserved position after a cast
    // handoff; then start — the source is already loaded.
    await _applyVolume();
    if (startAt > Duration.zero) await _player.seek(startAt);
    // One last check before audio starts: a skip/seek that landed while this
    // track was loading owns playback now, so don't start a track the user has
    // already moved past.
    if (generation != _playbackGeneration) return;
    // play()'s future completes when playback ends, so we don't await it.
    if (autoplay) unawaited(_player.play());
  }

  /// Tries [candidates] in order — **at most once each** — resolving and loading
  /// the first that works, returning it with its resolved source. Throws a
  /// single, safe [PlaybackResolutionException] when every candidate fails.
  ///
  /// A one-candidate list (the common, single-source case) rethrows that
  /// candidate's own specific failure, so single-source playback errors are
  /// worded exactly as before; only a genuine multi-source all-fail collapses to
  /// the generic "any available source" message. It makes a single pass and
  /// never retries, so it always terminates.
  ///
  /// Returns null instead — without loading anything — when a newer playback
  /// transition supersedes [generation] (the captured [_playbackGeneration])
  /// before a resolved candidate reaches the engine, so a stale rapid-skip can't
  /// hand the engine a track the user has already moved past. Null is distinct
  /// from a real failure (which throws).
  Future<({Track track, ResolvedPlayable resolved})?>
      _loadFirstWorkingCandidate(
    List<Track> candidates,
    int generation,
  ) async {
    final List<PlaybackResolutionException> failures =
        <PlaybackResolutionException>[];
    for (final Track candidate in candidates) {
      final ResolvedPlayable resolved;
      try {
        resolved = await _resolver.resolve(candidate);
      } on PlaybackResolutionException catch (error) {
        // A resolver failure carries its own friendly, secret-free message.
        StabilityDiagnostics.playbackError('resolution');
        failures.add(error);
        continue;
      } catch (_) {
        // An unexpected error before we even know the source: stay generic.
        StabilityDiagnostics.playbackError('resolution-unknown');
        failures.add(const PlaybackResolutionException(
          "Couldn't play this track.",
          kind: PlaybackResolutionErrorKind.streamUnavailable,
        ));
        continue;
      }
      // The user skipped or seeked while this candidate was resolving: abandon
      // the load before handing a now-stale source to the engine, so only the
      // newest transition's track is ever loaded or played. Checked after the
      // resolve await (which is where the time goes) and before touching the
      // shared engine.
      if (generation != _playbackGeneration) return null;
      try {
        // setUrl handles file://, content:// (Android), and https:// URIs alike,
        // so local files, SAF documents, and remote streams share one path. The
        // resolver guarantees this is never a bare `jellyfin:`/`subsonic:` scheme
        // — that is turned into an authenticated stream URL before it gets here.
        await _player.setUrl(resolved.uri.toString());
        return (track: candidate, resolved: resolved);
      } catch (_) {
        // Resolved (and, for streams, probed) OK but the engine couldn't open
        // it: a start failure. Word it for the source.
        StabilityDiagnostics.playbackError('load');
        // A cached file that won't open (reclaimed after the existence check,
        // corrupt, or an unreadable codec) must not strand a single-source
        // track on an error: re-resolve the same copy past the offline cache
        // and try the live stream once before moving on.
        final ({Track track, ResolvedPlayable resolved})? streamed =
            await _retryFromStream(candidate, resolved, generation);
        if (streamed != null) return streamed;
        // Superseded by a newer skip/seek while the stream retry ran: drop this
        // stale transition rather than recording a failure on a track the user
        // has already moved past.
        if (generation != _playbackGeneration) return null;
        failures.add(PlaybackResolutionException(
          _loadErrorFor(resolved.source),
          kind: PlaybackResolutionErrorKind.streamUnavailable,
        ));
        continue;
      }
    }
    if (failures.length == 1) throw failures.first;
    throw const PlaybackResolutionException(
      "Couldn't play this track from any available source.",
      kind: PlaybackResolutionErrorKind.streamUnavailable,
    );
  }

  /// Re-resolves [candidate] through the streaming fallback resolver and loads
  /// it, for when its first resolution was an offline-cache file the engine
  /// wouldn't open. This is the "a cache failure falls back to streaming"
  /// guarantee — it works even for a single-source track with no sibling copy,
  /// because it re-resolves the *same* track past the cache.
  ///
  /// Returns the loaded result, or null when: there is no fallback resolver (the
  /// default / tests), the failed load was *not* a cache hit (a stream that
  /// won't open is a real source failure handled by the normal candidate
  /// fallback), a newer transition superseded [generation], or the stream
  /// itself can't resolve or open.
  Future<({Track track, ResolvedPlayable resolved})?> _retryFromStream(
    Track candidate,
    ResolvedPlayable failed,
    int generation,
  ) async {
    final PlayableUriResolver? streamResolver = _streamingFallbackResolver;
    if (streamResolver == null ||
        failed.source != PlaybackSource.offlineCache) {
      return null;
    }
    final ResolvedPlayable streamed;
    try {
      streamed = await streamResolver.resolve(candidate);
    } catch (_) {
      // The cached copy failed *and* the stream can't be resolved (offline, or
      // the session/server is down): let the caller record the cache-load
      // failure and try the next candidate.
      return null;
    }
    // Don't hand a now-stale source to the shared engine if the user skipped or
    // seeked while the stream resolved.
    if (generation != _playbackGeneration) return null;
    try {
      await _player.setUrl(streamed.uri.toString());
      return (track: candidate, resolved: streamed);
    } catch (_) {
      StabilityDiagnostics.playbackError('load');
      return null;
    }
  }

  /// The generic message for an engine load failure *after* a successful
  /// resolve, worded for the resolved [source] (a direct stream "couldn't
  /// stream", an on-device/cached file "couldn't play").
  static String _loadErrorFor(PlaybackSource source) =>
      source == PlaybackSource.streamingDirect
          ? "Couldn't stream this track."
          : "Couldn't play this track.";

  /// Emits an error state for [track] carrying a friendly [message], preserving
  /// the queue context so the UI keeps showing the right track and up-next.
  void _emitError(Track track, String message) {
    _emit(PlaybackState(
      status: PlaybackStatus.error,
      currentTrack: track,
      upNext: _queue.upNext,
      previous: _queue.history,
      hasPrevious: _queue.hasPrevious,
      shuffleEnabled: _shuffleEnabled,
      repeatMode: _repeatMode,
      errorMessage: message,
    ));
  }

  @override
  bool get isSuspended => _suspended;

  @override
  Future<void> suspend() async {
    if (_suspended) return;
    _suspended = true;
    // A cast receiver now owns position; stop flushing local ticks underneath it.
    _resetPositionFlush();
    // Silence the engine but keep the loaded source and queue intact, so a
    // later resume can pick up the same track instantly when nothing changed.
    await _player.pause();
  }

  @override
  Future<void> resume({Duration at = Duration.zero, bool play = false}) async {
    _suspended = false;
    // The current track may have changed during casting (skips re-cast onto the
    // receiver), so (re)load whatever is current rather than trusting the
    // engine's last-loaded source.
    await _playCurrent(startAt: at, autoplay: play);
  }

  @override
  Future<void> restartQueue() async {
    _queue = _queue.restarted();
    await _playCurrent();
  }

  @override
  Future<void> play() async {
    // A cast receiver owns playback while suspended; never start local audio
    // underneath it.
    if (_suspended) return;
    // An explicit user / media-session play overrides any focus intent: clear
    // the resume arming and supersede any pending or already-queued focus pause
    // so a stale focus action can't undo the user's play, then restore full
    // volume in case a duck was active.
    _resumeAfterTransientLoss = false;
    _supersedeFocusTransport();
    _restoreDuckedVolume();
    // play()'s future completes when playback ends, so we don't await it.
    unawaited(_player.play());
  }

  @override
  Future<void> pause() {
    // An explicit user / media-session pause overrides focus: clear the resume
    // arming and supersede any pending or queued focus pause/resume so the
    // regain after an interruption never auto-resumes a track the user paused.
    _resumeAfterTransientLoss = false;
    _supersedeFocusTransport();
    return _player.pause();
  }

  @override
  Future<void> stop() async {
    _resetPositionFlush();
    // A stop is definitive: drop any focus resume intent and supersede any
    // pending/queued focus action so it can't resurrect playback after stop.
    _resumeAfterTransientLoss = false;
    _supersedeFocusTransport();
    await _player.stop();
    final stopped = PlaybackState(
      currentTrack: _state.currentTrack,
      upNext: _queue.upNext,
      previous: _queue.history,
      hasPrevious: _queue.hasPrevious,
      shuffleEnabled: _shuffleEnabled,
      repeatMode: _repeatMode,
    );
    _emit(stopped);
  }

  @override
  Future<void> seek(Duration position) async {
    if (_suspended) return;
    // A seek is a playback action too: bump the generation so a still-resolving
    // earlier load can't complete afterwards and yank playback off the spot the
    // user just chose (or onto a different track). In normal flow nothing is
    // loading when a seek arrives — the progress bar only seeks once a duration
    // is known — so this just invalidates a stale in-flight load when one races.
    _playbackGeneration++;
    return _player.seek(position);
  }

  @override
  Future<void> dispose() async {
    _resetPositionFlush();
    _cancelPendingFocusPause();
    await _interruptionSub?.cancel();
    await _becomingNoisySub?.cancel();
    for (final subscription in _subscriptions) {
      await subscription.cancel();
    }
    await _states.close();
    await _player.dispose();
  }
}
