import 'dart:async';
import 'dart:io';

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

import '../cache/audio_cache_manager.dart';
import '../models/track.dart';
import 'album_cover_cache.dart';

class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
  MinstrelAudioHandler() {
    _player.playbackEventStream.listen(
      _broadcastState,
      // ExoPlayer surfaces stream errors (404, premature EOS, decoder
      // failure, network drop) here. Logging alone leaves the UI
      // claiming "now playing X" while audio is silent — the user-
      // observable mismatch between visual and audio state. Skip
      // forward so the queue advances past the failed track and
      // mediaItem updates to whatever's actually playing. If the
      // failure is at the queue tail, just_audio will go idle on its
      // own and _broadcastState reflects that.
      onError: (Object e, StackTrace st) {
        debugPrint('audio_handler: playbackEventStream error: $e\n$st');
        unawaited(_handlePlaybackError());
      },
    );
    _player.currentIndexStream.listen(_onCurrentIndexChanged);
    // Re-broadcast on shuffle/repeat changes so the PlaybackState's
    // shuffleMode + repeatMode fields stay current for UI subscribers.
    _player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
    _player.loopModeStream.listen((_) => _broadcastState(null));
    // Watch buffered-position so we can register stream-cached files
    // in the audio cache index once they're fully downloaded. Without
    // this, files written by LockCachingAudioSource never appear in
    // the index and the eviction loop can't reclaim them.
    _player.bufferedPositionStream
        .listen((_) => unawaited(_maybeRegisterStreamCache()));
    unawaited(_configureAudioSession());
  }

  final AudioPlayer _player = AudioPlayer();
  String _baseUrl = '';
  String? _token;
  AlbumCoverCache? _coverCache;
  AudioCacheManager? _audioCacheManager;
  LikeBridge? _likeBridge;

  /// Trackers to dedupe registration — once we've inserted an index row
  /// for a trackId, don't repeat the work on every buffered-position
  /// emit. Cleared on dispose only; surviving across queue rebuilds is
  /// fine because the index is itself the source of truth.
  final Set<String> _streamCacheRegistered = {};

  /// Cached on first use so we don't hit the platform channel every
  /// time the buffered-position stream emits (~200ms cadence).
  String? _cacheDirPath;

  /// How long the session may sit not-actively-playing (paused, or a
  /// finished queue) before we tear it down so the Wear tile / lock
  /// screen / notification don't linger on a stale track. The
  /// notification is configured ongoing (main.dart), so nothing else
  /// ever drives the MediaSession to a terminal state.
  static const _idleStopTimeout = Duration(minutes: 5);

  /// Single-shot cleanup timer, armed while not actively playing and
  /// cancelled the moment playback resumes or a new queue is set.
  Timer? _idleStopTimer;

  /// Periodic PlaybackState re-broadcast while actively playing so
  /// external surfaces (lock screen, Wear, Android Auto) interpolate the
  /// scrubber smoothly. The in-app bar uses positionStream and doesn't
  /// need this. Active only while playing; cancelled otherwise.
  Timer? _positionBroadcastTimer;

  /// Buffering-stall watchdog. On poor coverage a stream hangs in
  /// ProcessingState.buffering with NO error event, so the onError
  /// recovery never fires. Armed while playing+buffering; on fire, if
  /// buffered position hasn't advanced it's a dead stream → recover.
  /// Re-arms a fresh window while buffering-but-progressing (slow-but-
  /// downloading is not a stall).
  static const _stallTimeout = Duration(seconds: 15);
  Timer? _stallTimer;
  Duration _stallMarkBuffered = Duration.zero;

  /// Per-track recovery budget. Reset when a track reaches
  /// ready+playing. One retry of the same source (rebuilt fresh), then
  /// skip to the next cached track (or pause) instead of thrashing
  /// through unreachable streams.
  static const _maxRecoverRetries = 1;
  String? _recoveringTrackId;
  int _recoverAttempts = 0;

  /// Player volume captured when an OS duck interruption begins,
  /// restored when it ends. Null when not currently ducked.
  double? _volumeBeforeDuck;

  /// True when an interruption (call / other media) paused playback that
  /// was actively going, so a transient (pause-type) interruption end
  /// auto-resumes. Cleared on resume or a non-resumable end.
  bool _interruptedWhilePlaying = false;

  /// True while _fillRemainingSources is doing backward-fill inserts
  /// at index 0..initialIndex-1. Each insert shifts the player's
  /// currentIndex (it tracks the actively-playing source through
  /// list mutations), and the resulting _onCurrentIndexChanged
  /// callbacks would push the wrong MediaItem onto the stream
  /// (queue.value[shifted_idx] != actively-playing track). When
  /// the fill completes, currentIndex == initialIndex, mediaItem
  /// is already correct, and we re-enable normal listener behavior.
  bool _suppressIndexUpdates = false;

  /// Increments on every setQueueFromTracks call. The background
  /// fill task captures the value at start and bails if the live
  /// counter has moved on — without this, a stale fill will keep
  /// addAudioSource'ing tracks from the previous playlist into the
  /// new queue, leaving the player "locked" to a corrupted state.
  int _queueGeneration = 0;

  /// Logical-queue index that just_audio player-index 0 currently maps
  /// to. setQueueFromTracks fast-starts with a SINGLE source at player
  /// index 0 while queue.value already holds the full list, so the
  /// playing track's logical index is clampedInitial, not 0. The
  /// transient currentIndexStream→0 emission that arrives just after
  /// setAudioSources resolves (and after _suppressIndexUpdates is back
  /// to false) would otherwise make _onCurrentIndexChanged broadcast
  /// queue.value[0] — the FIRST track — over the correct item, pinning
  /// the mini bar / playlist marker to the wrong track until a later
  /// index event. Decremented in lockstep with the backward fill's
  /// front inserts so player-index → logical-index stays correct and
  /// lands at 0 once the player list fully matches queue.value.
  int _logicalIndexBase = 0;

  /// Tracks the most recent setQueueFromTracks() input so
  /// skipToQueueItem can reconstruct the source list. just_audio
  /// requires every source to be built before it can be a skip
  /// target, but setQueueFromTracks only builds the initial source
  /// and fills the rest in the background — so a skip to an index
  /// past the fill front needs to rebuild from the stored tracks.
  List<TrackRef> _lastTracks = const [];

  /// #415: which system playlist this queue was seeded from
  /// ('for_you' | 'discover'), or null for library / user-playlist /
  /// radio. The play-events reporter reads this so play_started
  /// carries `source` and the server advances that rotation. A fresh
  /// setQueueFromTracks from a non-system surface clears it; internal
  /// rebuilds (skipToQueueItem) preserve it.
  String? _queueSource;
  String? get queueSource => _queueSource;

  /// The full TrackRef list backing the current queue (even when only
  /// part is built as just_audio sources). Empty when nothing is
  /// queued. Exposed for resume-state persistence (#54).
  List<TrackRef> get queuedTracks => _lastTracks;

  /// Volume stream for UI subscribers. Mirrors the just_audio player's
  /// volume directly; set via setVolume(double).
  Stream<double> get volumeStream => _player.volumeStream;
  double get volume => _player.volume;

  /// Position stream for UI subscribers. just_audio emits roughly every
  /// 200ms while playing, which is what makes the seek bar advance
  /// smoothly. PlaybackState.updatePosition only changes on event
  /// transitions (play/pause/buffer), so it's too coarse for live
  /// scrubbing UI.
  Stream<Duration> get positionStream => _player.positionStream;
  Duration get position => _player.position;

  /// Broadcasts the title of a track that just failed to play (404,
  /// decoder failure, premature EOS, network drop) and was auto-skipped
  /// or paused by _handlePlaybackError. The app listens and surfaces a
  /// debounced/coalesced SnackBar so a silent skip isn't mysterious.
  /// App-lifetime singleton handler — intentionally never closed.
  final _playbackErrors = StreamController<String>.broadcast();
  Stream<String> get playbackErrorStream => _playbackErrors.stream;

  void configure({
    required String baseUrl,
    required String? token,
    AlbumCoverCache? coverCache,
    AudioCacheManager? audioCacheManager,
    LikeBridge? likeBridge,
  }) {
    _baseUrl = baseUrl;
    _token = token;
    if (coverCache != null) _coverCache = coverCache;
    if (likeBridge != null) _likeBridge = likeBridge;
    if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
  }

  /// Invoked by play() when nothing is loaded (mediaItem == null) so a
  /// media-button press after the #52 teardown resumes the last
  /// persisted session (#448). Registered by ResumeController.start();
  /// null until then (then it's a no-op fall-through to _player.play()).
  Future<void> Function()? _resumeHook;
  void setResumeHook(Future<void> Function() hook) => _resumeHook = hook;

  Future<void> setQueueFromTracks(
    List<TrackRef> tracks, {
    int initialIndex = 0,
    String? source,
  }) async {
    if (tracks.isEmpty) return;
    // New playback supersedes any pending idle cleanup. play() below
    // re-broadcasts and _reconcileIdleTimer would cancel anyway; doing
    // it up front avoids a race during the pause→swap window.
    _idleStopTimer?.cancel();
    _idleStopTimer = null;
    _queueSource = source;
    final clampedInitial = initialIndex.clamp(0, tracks.length - 1);

    // Bump the generation FIRST. Any in-flight _fillRemainingSources
    // from a previous play will see the mismatch on its next gen
    // check and stop calling player mutations — important so a stale
    // fill doesn't append old-playlist tracks into the new queue.
    final myGen = ++_queueGeneration;
    _lastTracks = tracks;

    // Pause the old source immediately so the previous track stops
    // audibly the moment the user taps, instead of bleeding through
    // until the new source finishes building. setAudioSources below
    // will swap the source list cleanly; pause is the simplest way
    // to silence the player during the (possibly multi-100ms) build.
    if (_player.playing) {
      await _player.pause();
    }

    // Build MediaItems up front (pure — no side effects); we'll
    // broadcast queue/mediaItem only AFTER setAudioSources resolves
    // so the audio engine and the UI flip together. If the build
    // throws, the UI stays on the previous track (correct: audio
    // also stays on the previous track since setAudioSources never
    // ran).
    final items = tracks.map(_toMediaItem).toList();

    // Build only the initial source for fast start. Remaining
    // sources stream in via _fillRemainingSources() — addAudioSource
    // for next/auto-advance tracks, insertAudioSource for skipPrev.
    final AudioSource initial;
    try {
      initial = await _buildAudioSource(tracks[clampedInitial]);
    } catch (e, st) {
      // Source build failed (bad URL, missing baseUrl, etc.). Don't
      // broadcast the new state — leaving queue/mediaItem on the
      // previous track keeps UI in sync with what the player is
      // actually doing (which is "still on the previous track,
      // paused").
      debugPrint('audio_handler: _buildAudioSource failed: $e\n$st');
      return;
    }
    if (myGen != _queueGeneration) {
      debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
      return;
    }

    // Suppress _onCurrentIndexChanged side effects while the source
    // list is being swapped — without this, a transient currentIndex
    // emission during setAudioSources could broadcast the OLD queue's
    // entry at the NEW index. Re-enabled after the broadcasts land.
    _suppressIndexUpdates = true;
    try {
      await _player.setAudioSources([initial], initialIndex: 0);
      // Player-index 0 holds the single fast-start source, which is
      // logical-queue index clampedInitial. Record the offset before
      // broadcasting so the post-resolve currentIndexStream→0 emission
      // maps back to the correct item instead of queue.value[0].
      _logicalIndexBase = clampedInitial;
      // Broadcast in this order: queue first (so any consumer that
      // reacts to mediaItem and reads queue.value sees the consistent
      // pair), then mediaItem.
      queue.add(items);
      mediaItem.add(items[clampedInitial]);
    } finally {
      _suppressIndexUpdates = false;
    }

    unawaited(_loadArtForCurrentItem());
    unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
  }

  /// Switches playback to the [index]th queue item. The full
  /// just_audio source list isn't necessarily built yet
  /// (_fillRemainingSources runs in the background), so we
  /// reconstruct from the stored TrackRef list rather than calling
  /// _player.seek(index: ...) on a possibly-missing source. Calling
  /// setQueueFromTracks again is the safe path: it bumps the
  /// generation, cancels any in-flight fill, rebuilds source[0] as
  /// the target, and re-fills around. play() restarts playback so
  /// queue taps feel like "jump to this song" rather than "set the
  /// pointer and wait for me to press play."
  @override
  Future<void> skipToQueueItem(int index) async {
    if (index < 0 || index >= _lastTracks.length) return;
    // Preserve the system-playlist source across an internal rebuild
    // — a queue-item skip is still playing from the same playlist.
    await setQueueFromTracks(_lastTracks, initialIndex: index, source: _queueSource);
    await play();
  }

  /// Background fill of the rest of the just_audio source list after
  /// the initial source is playing. Forward direction first (most
  /// common skipNext target). Backward inserts shift the player's
  /// currentIndex; we suppress _onCurrentIndexChanged side effects
  /// for those so the mediaItem stream doesn't bounce to the wrong
  /// queue entry.
  ///
  /// `gen` is the queue generation captured when this fill started.
  /// Every loop iteration checks against _queueGeneration; if a
  /// newer setQueueFromTracks has run (user tapped play on something
  /// else), bail immediately so we don't pollute the new queue with
  /// addAudioSource calls from this stale fill.
  Future<void> _fillRemainingSources(
      List<TrackRef> tracks, int initialIndex, int gen) async {
    for (var i = initialIndex + 1; i < tracks.length; i++) {
      if (gen != _queueGeneration) return;
      try {
        final src = await _buildAudioSource(tracks[i]);
        if (gen != _queueGeneration) return;
        await _player.addAudioSource(src);
      } catch (e) {
        debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e');
      }
    }
    if (initialIndex > 0) {
      _suppressIndexUpdates = true;
      try {
        for (var i = 0; i < initialIndex; i++) {
          if (gen != _queueGeneration) return;
          final src = await _buildAudioSource(tracks[i]);
          if (gen != _queueGeneration) return;
          await _player.insertAudioSource(i, src);
          // Each front insert shifts the playing source's player index
          // up by one; drop the base in lockstep so player-index →
          // logical-index stays correct (and reaches 0 once the player
          // list fully matches queue.value).
          _logicalIndexBase -= 1;
        }
      } catch (e) {
        debugPrint('audio_handler: backward fill failed: $e');
      } finally {
        // Only release the flag if we're still the active gen — a
        // newer setQueueFromTracks already reset it for itself.
        if (gen == _queueGeneration) _suppressIndexUpdates = false;
      }
    }
  }

  String _resolveStreamUrl(TrackRef t) {
    if (t.streamUrl.isEmpty) {
      return '$_baseUrl/api/tracks/${t.id}/stream';
    }
    final parsed = Uri.tryParse(t.streamUrl);
    if (parsed != null && parsed.hasScheme) {
      return t.streamUrl;
    }
    return '$_baseUrl${t.streamUrl}';
  }

  /// Builds an AudioSource for a track. Cache-aware:
  /// 1. If the track is fully cached on disk, returns a file:// source.
  /// 2. Else returns a LockCachingAudioSource that streams + caches as
  ///    it plays (subsequent plays will hit the cache).
  ///
  /// Without an audio cache manager configured (e.g. in older code paths
  /// that pre-date #357), falls back to a plain network AudioSource.uri.
  Future<AudioSource> _buildAudioSource(TrackRef t) async {
    final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
    final mgr = _audioCacheManager;

    // 1. Cache hit: play from disk, no headers needed.
    if (mgr != null) {
      final path = await mgr.pathFor(t.id);
      if (path != null) {
        return AudioSource.uri(Uri.file(path));
      }
    }

    final url = _resolveStreamUrl(t);
    final parsed = Uri.parse(url);
    if (!parsed.hasScheme || parsed.host.isEmpty) {
      throw StateError(
        'audio_handler: refused to play scheme-less URL "$url" '
        '(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", '
        'track.id="${t.id}"). configure() must be called with a '
        'non-empty baseUrl before setQueueFromTracks().',
      );
    }

    // 2. Cache miss WITH manager: stream + cache-as-you-play. Future
    // plays of this track will hit the cache. If the cache write
    // completes, we don't currently register an index row — that would
    // require a download-complete hook from just_audio that's not
    // exposed cleanly. Acceptable for v1: prefetcher / explicit
    // pin / Download buttons cover the index path; LockCaching handles
    // the network optimization.
    if (mgr != null) {
      _cacheDirPath ??= (await getApplicationCacheDirectory()).path;
      final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3');
      // ignore: experimental_member_use
      return LockCachingAudioSource(parsed,
          headers: headers, cacheFile: cacheFile);
    }

    // 3. No manager configured: plain network source (legacy path).
    return AudioSource.uri(parsed, headers: headers);
  }

  /// Inserts [track] right after the currently-playing item so it plays
  /// next. If nothing is playing, appends to the end.
  Future<void> playNext(TrackRef track) async {
    final source = await _buildAudioSource(track);
    final item = _toMediaItem(track);
    final currentIdx = _player.currentIndex;
    final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1;
    await _player.insertAudioSource(insertAt, source);
    final current = queue.value;
    queue.add([
      ...current.sublist(0, insertAt),
      item,
      ...current.sublist(insertAt),
    ]);
  }

  /// Appends [track] to the end of the queue.
  Future<void> enqueue(TrackRef track) async {
    final source = await _buildAudioSource(track);
    final item = _toMediaItem(track);
    await _player.addAudioSource(source);
    queue.add([...queue.value, item]);
  }

  MediaItem _toMediaItem(TrackRef t) {
    // Stash album_id + artist_id in extras so widgets reconstructing
    // a TrackRef from the MediaItem (player kebab → "Go to artist",
    // "Go to album") have the IDs they need to navigate. Earlier code
    // only carried album_id which left "Go to artist" pushing
    // /artists/ (empty id, route 404).
    final extras = <String, dynamic>{};
    if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
    if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
    // Sync-peek the album cover cache so warm-cache tracks broadcast
    // with artUri populated on the first frame. External media
    // controllers (Android Wear, Bluetooth dashes, Auto, lock screen)
    // can only render the cover bytes that audio_service hands them
    // at MediaItem broadcast time; the later async _loadArtForCurrent
    // Item path repopulates for cold-cache tracks. Without this seed,
    // every track change starts with a generic icon on the watch and
    // only gets the real cover after one or two seconds.
    final coverPath = (t.albumId.isNotEmpty && _coverCache != null)
        ? _coverCache!.peekCached(t.albumId)
        : null;
    // MediaItem.rating intentionally NOT set: audio_service propagates
    // it to MediaSession.setRating(), but the Android session also
    // needs setRatingType(RATING_HEART) configured to expose that to
    // controllers — audio_service doesn't surface that config knob,
    // and broadcasting an unanchored rating made Wear OS reject the
    // session entirely. The LikeBridge wiring stays in place so
    // setRating can still fire from any surface that DOES route it,
    // we just don't advertise it.
    return MediaItem(
      id: t.id,
      title: t.title,
      artist: t.artistName,
      album: t.albumTitle,
      duration: Duration(seconds: t.durationSec),
      artUri: coverPath != null ? Uri.file(coverPath) : null,
      extras: extras.isEmpty ? null : extras,
    );
  }

  /// Once a track is fully buffered (LockCaching has written the whole
  /// file to disk), insert an audio_cache_index row so the file shows
  /// up to AudioCacheManager.evict() and clearAll(). No-op if the
  /// cache manager isn't configured, no current track, the file isn't
  /// fully buffered yet, the on-disk file is missing, or we already
  /// registered this trackId during this subscription.
  Future<void> _maybeRegisterStreamCache() async {
    final mgr = _audioCacheManager;
    if (mgr == null) return;
    final current = mediaItem.value;
    if (current == null) return;
    final trackId = current.id;
    if (_streamCacheRegistered.contains(trackId)) return;

    final dur = _player.duration;
    if (dur == null) return;
    final buf = _player.bufferedPosition;
    // 200ms slack for header bytes / encoding rounding.
    if (buf < dur - const Duration(milliseconds: 200)) return;

    _cacheDirPath ??= (await getApplicationCacheDirectory()).path;
    final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3';
    final file = File(path);
    if (!await file.exists()) return;
    final size = await file.length();
    if (size <= 0) return;

    _streamCacheRegistered.add(trackId);
    await mgr.registerStreamCache(trackId, path, size);
  }

  /// Called when playbackEventStream emits an error. Skips past the
  /// failing track so the UI and audio re-converge — without this,
  /// _player goes silent on a 404 / decoder failure / EOS but
  /// mediaItem stays on the failed track and the user sees a "now
  /// playing" header for something that isn't.
  ///
  /// If we're at the last track, seekToNext is a no-op; the state
  /// drops to idle and _broadcastState reflects that.
  /// onError entrypoint (404 / decoder / premature EOS / network drop).
  /// Routes into the unified recovery path (retry the track once, then
  /// skip to the next cached track or pause) instead of immediately
  /// skipping the literal next — possibly also-unreachable — source.
  Future<void> _handlePlaybackError() async {
    await _recoverPlayback();
  }

  void _onCurrentIndexChanged(int? idx) {
    if (idx == null) return;
    if (_suppressIndexUpdates) return;
    // Push the new track's MediaItem onto the mediaItem stream so
    // the player UI rebuilds with the new title/artist/album/cover.
    // Without this, the bar and full player stayed pinned to whichever
    // track was passed via setQueueFromTracks(initialIndex:) regardless
    // of skip/auto-advance.
    final items = queue.value;
    // Map the just_audio player index back to the logical queue index.
    // During the fast-start/fill window the player list is a moving
    // window offset from queue.value by _logicalIndexBase; mapping the
    // raw player index straight in here is what previously broadcast
    // queue.value[0] (the first track) over the correct item on every
    // fast-start with initialIndex > 0.
    final logical = idx + _logicalIndexBase;
    if (logical >= 0 && logical < items.length) {
      mediaItem.add(items[logical]);
    }
    unawaited(_loadArtForCurrentItem());
  }

  /// Async-fetches the cover for whichever item is currently active and
  /// pushes a MediaItem update with artUri set. No-op if no cache is
  /// configured, no current item, the item has no album_id in extras,
  /// or the fetch returns null.
  Future<void> _loadArtForCurrentItem() async {
    final cache = _coverCache;
    if (cache == null) return;
    final current = mediaItem.value;
    if (current == null) return;
    final albumId = current.extras?['album_id'] as String?;
    if (albumId == null || albumId.isEmpty) return;
    if (current.artUri != null) return; // already set
    final path = await cache.getOrFetch(albumId);
    if (path == null) return;
    // Discard if the user advanced to another track while we waited.
    if (mediaItem.value?.id != current.id) return;
    mediaItem.add(current.copyWith(artUri: Uri.file(path)));
  }

  /// Display-state-only teardown. Stops playback, clears the queue and
  /// current track, and broadcasts idle so external surfaces (Wear tile,
  /// notification, in-app mini bar) drop their visible state — but does
  /// NOT call super.stop(), so the FGS and MediaSessionCompat remain
  /// alive and addressable.
  ///
  /// Why this matters (Fable #472): the paired Wear OS companion app
  /// caches a MediaController bound to our MediaSession's token. If
  /// the system destroys our service (super.stop() → stopSelf() makes
  /// it eligible under memory pressure), the next play() spins up a
  /// new MediaSession with a fresh token; the companion's cached
  /// controller still points at the dead one and transport taps from
  /// notification + watch silently no-op. Keeping the service alive
  /// across idle periods preserves the binding.
  ///
  /// Used by the #52 idle timeout. Full termination (super.stop) is
  /// reserved for the explicit-close path (onTaskRemoved while idle).
  Future<void> _softTeardown() async {
    _idleStopTimer?.cancel();
    _idleStopTimer = null;
    _positionBroadcastTimer?.cancel();
    _positionBroadcastTimer = null;
    _stallTimer?.cancel();
    _stallTimer = null;
    try {
      await _player.stop();
    } catch (_) {}
    playbackState.add(playbackState.value.copyWith(
      playing: false,
      processingState: AudioProcessingState.idle,
    ));
    mediaItem.add(null);
    queue.add([]);
  }

  /// Full termination — soft teardown plus super.stop(), which calls
  /// stopSelf() on the AudioService and lets the OS reclaim it. Reserved
  /// for the explicit-close path (onTaskRemoved when not playing). On the
  /// idle path we use _softTeardown instead to preserve the Wear OS
  /// MediaController binding (Fable #472).
  @override
  Future<void> stop() async {
    await _softTeardown();
    await super.stop();
  }

  /// App swiped away from recents. Keep playing if audio is active
  /// (standard media behaviour — music shouldn't die because the app
  /// left recents); otherwise stop so the watch tile / notification
  /// don't linger on a stale paused track.
  @override
  Future<void> onTaskRemoved() async {
    if (_player.playing &&
        _player.processingState != ProcessingState.completed) {
      await super.onTaskRemoved();
      return;
    }
    await stop();
  }

  @override
  Future<void> play() async {
    // Nothing loaded (fresh, or torn down by the #52 idle/dismiss
    // teardown): a media-button press resumes the last persisted
    // session instead of no-op'ing (#448). The hook restores the queue
    // and starts playback itself, so we return without calling
    // _player.play() on an empty player.
    if (mediaItem.value == null && _resumeHook != null) {
      await _resumeHook!();
      return;
    }
    await _player.play();
  }

  @override
  Future<void> pause() => _player.pause();

  @override
  Future<void> seek(Duration position) => _player.seek(position);

  @override
  Future<void> skipToNext() => _player.seekToNext();

  @override
  Future<void> skipToPrevious() => _player.seekToPrevious();

  /// Heart rating from external surfaces (Wear's favorite button,
  /// lock-screen like) → LikesController.toggle(track). We only
  /// route through the bridge when the rating actually flips relative
  /// to the current state, so repeated taps from a flaky controller
  /// don't ping-pong the like.
  @override
  Future<void> setRating(Rating rating, [Map<String, dynamic>? extras]) async {
    final media = mediaItem.value;
    final bridge = _likeBridge;
    if (media == null || bridge == null) return;
    final currentlyLiked = bridge.isTrackLiked(media.id);
    final wantLiked = rating.hasHeart();
    if (currentlyLiked == wantLiked) return;
    try {
      await bridge.toggleTrackLike(media.id);
    } catch (_) {
      // LikesController already rolls back on REST failure; nothing
      // to do here beyond letting the broadcast skip.
      return;
    }
    // Re-emit so the watch's heart icon updates immediately.
    mediaItem.add(media.copyWith(rating: Rating.newHeartRating(wantLiked)));
  }

  /// Re-emits the current mediaItem with a fresh rating pulled from
  /// the LikeBridge. Called by PlayerActions on likedIdsProvider
  /// changes so the watch / lock-screen heart icon updates when the
  /// user toggles a like from TrackRow, the kebab menu, or another
  /// device's playback (SSE-routed). No-op if no track is playing
  /// or the like state didn't change.
  void refreshCurrentRating() {
    final media = mediaItem.value;
    final bridge = _likeBridge;
    if (media == null || bridge == null) return;
    final liked = bridge.isTrackLiked(media.id);
    if (media.rating?.hasHeart() == liked) return;
    mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked)));
  }

  /// Re-broadcasts PlaybackState so the notification favorite control's
  /// icon/label reflects a like toggled from elsewhere (TrackRow, kebab,
  /// another device via SSE). Sibling to refreshCurrentRating, which
  /// updates the Wear/lock heart via MediaItem.rating.
  void refreshFavoriteControl() => _broadcastState(null);

  @override
  Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
    await _player
        .setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
    // _broadcastState picks up the change via shuffleModeEnabledStream.
  }

  @override
  Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
    final loop = switch (repeatMode) {
      AudioServiceRepeatMode.none => LoopMode.off,
      AudioServiceRepeatMode.one => LoopMode.one,
      AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
    };
    await _player.setLoopMode(loop);
  }

  /// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie
  /// page audio to system volume — this is the in-app slider for parity
  /// with desktop/web; on mobile the system volume is the real control.
  Future<void> setVolume(double v) async {
    await _player.setVolume(v.clamp(0.0, 1.0));
  }

  /// Handles the notification favorite control (and any future custom
  /// actions). Toggles the current track's like via the LikeBridge,
  /// then re-broadcasts so the heart icon/label flips. Signature
  /// matches `AudioHandler.customAction` (`Future<dynamic>`).
  @override
  Future<dynamic> customAction(String name,
      [Map<String, dynamic>? extras]) async {
    if (name == 'minstrel.favorite') {
      final media = mediaItem.value;
      final bridge = _likeBridge;
      if (media == null || bridge == null) return null;
      try {
        await bridge.toggleTrackLike(media.id);
      } catch (_) {}
      _broadcastState(null);
      return null;
    }
    return super.customAction(name, extras);
  }

  // _broadcastState accepts a nullable event because the shuffle/repeat
  // listeners don't have one — we just want to re-emit PlaybackState
  // with up-to-date shuffleMode/repeatMode fields.
  void _broadcastState(PlaybackEvent? event) {
    final playing = _player.playing;
    // No custom favorite MediaControl here. audio_service builds a
    // PlaybackStateCompat.CustomAction for it and throws
    // "You must specify an icon resource id to build a CustomAction"
    // on real builds (the androidIcon doesn't resolve to a usable id),
    // and that exception aborts the ENTIRE media notification — no
    // tray or Wear controls at all. Removed; like/favorite remains
    // available in-app and via the standard lock-screen surface.
    playbackState.add(PlaybackState(
      controls: [
        MediaControl.skipToPrevious,
        if (playing) MediaControl.pause else MediaControl.play,
        MediaControl.skipToNext,
      ],
      // androidCompactActionIndices tells the system which controls
      // appear in the collapsed/lock-screen view. Without this, some
      // Android versions render the player without working buttons.
      androidCompactActionIndices: const [0, 1, 2],
      // systemActions enumerates which actions the system can invoke
      // on us — without play/pause/skip in here, taps on lock-screen
      // controls don't route back to the handler on Android 13+.
      //
      // v2026.05.13.3 added stop, skipToQueueItem, setShuffleMode,
      // setRepeatMode, and setRating to this set; reverted because
      // Pixel Watch 2 stopped showing controls entirely after that
      // change. The MediaSession contract on Wear OS requires more
      // setup than just advertising the action (e.g. setRatingType
      // for setRating) and audio_service doesn't expose those knobs.
      // Keep the override methods themselves (skipToQueueItem is
      // still routed via QueueScreen's direct handler call;
      // setRating is harmless if never invoked) so we don't lose
      // the underlying functionality — just don't tell the system
      // we support them.
      systemActions: const {
        MediaAction.play,
        MediaAction.pause,
        MediaAction.skipToNext,
        MediaAction.skipToPrevious,
        MediaAction.seek,
      },
      processingState: switch (_player.processingState) {
        ProcessingState.idle => AudioProcessingState.idle,
        ProcessingState.loading => AudioProcessingState.loading,
        ProcessingState.buffering => AudioProcessingState.buffering,
        ProcessingState.ready => AudioProcessingState.ready,
        ProcessingState.completed => AudioProcessingState.completed,
      },
      playing: playing,
      updatePosition: _player.position,
      bufferedPosition: _player.bufferedPosition,
      speed: _player.speed,
      queueIndex: event?.currentIndex ?? _player.currentIndex,
      shuffleMode: _player.shuffleModeEnabled
          ? AudioServiceShuffleMode.all
          : AudioServiceShuffleMode.none,
      repeatMode: switch (_player.loopMode) {
        LoopMode.off => AudioServiceRepeatMode.none,
        LoopMode.one => AudioServiceRepeatMode.one,
        LoopMode.all => AudioServiceRepeatMode.all,
      },
    ));
    _reconcileIdleTimer();
    _reconcilePositionBroadcast();
    _reconcileStallWatchdog();
  }

  /// Arms (or cancels) the idle-cleanup timer based on the current
  /// player state. Driven from _broadcastState, which fires on every
  /// play/pause/buffer/complete transition, so paused AND finished-queue
  /// both arm it. Idle (already stopped) never re-arms — otherwise stop()
  /// would loop every _idleStopTimeout.
  void _reconcileIdleTimer() {
    final ps = _player.processingState;
    if (ps == ProcessingState.idle) {
      _idleStopTimer?.cancel();
      _idleStopTimer = null;
      return;
    }
    final activelyPlaying =
        _player.playing && ps != ProcessingState.completed;
    if (activelyPlaying) {
      _idleStopTimer?.cancel();
      _idleStopTimer = null;
      return;
    }
    _idleStopTimer ??= Timer(_idleStopTimeout, _onIdleTimeout);
  }

  void _onIdleTimeout() {
    _idleStopTimer = null;
    final ps = _player.processingState;
    if (ps == ProcessingState.idle) return; // already torn down
    if (_player.playing && ps != ProcessingState.completed) {
      return; // resumed between arm and fire
    }
    // _softTeardown — not full stop() — to preserve the MediaSession
    // binding for the paired Wear OS companion (Fable #472).
    unawaited(_softTeardown());
  }

  /// While actively playing, keeps a 1s periodic PlaybackState re-
  /// broadcast running so updateTime/updatePosition stay fresh and
  /// external surfaces interpolate the scrubber smoothly. Idempotent and
  /// driven from _broadcastState — which the periodic tick itself calls,
  /// so the "already running" guard prevents pile-up. Cancels the moment
  /// playback is no longer active.
  void _reconcilePositionBroadcast() {
    final ps = _player.processingState;
    final active = _player.playing &&
        ps != ProcessingState.idle &&
        ps != ProcessingState.completed;
    if (!active) {
      _positionBroadcastTimer?.cancel();
      _positionBroadcastTimer = null;
      return;
    }
    _positionBroadcastTimer ??= Timer.periodic(
      const Duration(seconds: 1),
      (_) => _broadcastState(null),
    );
  }

  /// Buffering-stall watchdog. Armed while playing+buffering/loading.
  /// On fire: still stuck with no >=1s buffered progress → dead stream,
  /// recover; progressing → re-arm a fresh window (slow-but-downloading
  /// is fine). When the track is happily ready+playing, clears the
  /// per-track recovery budget. Driven from _broadcastState; mirrors
  /// the other reconcile helpers.
  void _reconcileStallWatchdog() {
    final ps = _player.processingState;
    if (ps == ProcessingState.ready && _player.playing) {
      _recoveringTrackId = null;
      _recoverAttempts = 0;
    }
    final buffering = _player.playing &&
        (ps == ProcessingState.buffering || ps == ProcessingState.loading);
    if (!buffering) {
      _stallTimer?.cancel();
      _stallTimer = null;
      return;
    }
    if (_stallTimer != null) return; // window already running
    _stallMarkBuffered = _player.bufferedPosition;
    _stallTimer = Timer(_stallTimeout, () {
      _stallTimer = null;
      final ps2 = _player.processingState;
      final stillBuffering = _player.playing &&
          (ps2 == ProcessingState.buffering ||
              ps2 == ProcessingState.loading);
      if (!stillBuffering) return;
      final progressed = (_player.bufferedPosition - _stallMarkBuffered) >=
          const Duration(seconds: 1);
      if (progressed) {
        _reconcileStallWatchdog(); // slow but downloading — re-arm
      } else {
        unawaited(_recoverPlayback());
      }
    });
  }

  /// Unified recovery for a failed OR stalled stream. Retries the
  /// current track once (rebuilt from scratch via skipToQueueItem so a
  /// transient blip doesn't lose it); on exhaustion, surfaces the
  /// failure (#58 SnackBar) and skips to the next cached track — or
  /// pauses — instead of blindly walking into more unreachable streams.
  Future<void> _recoverPlayback() async {
    _stallTimer?.cancel();
    _stallTimer = null;
    final media = mediaItem.value;
    if (media == null) return;
    final trackId = media.id;
    final curIdx = _lastTracks.indexWhere((t) => t.id == trackId);
    if (curIdx < 0) {
      try {
        await _player.pause();
      } catch (_) {}
      return;
    }
    if (_recoveringTrackId != trackId) {
      _recoveringTrackId = trackId;
      _recoverAttempts = 0;
    }
    if (_recoverAttempts < _maxRecoverRetries) {
      _recoverAttempts++;
      // Rebuild + restart the SAME track (fresh source / HTTP).
      await skipToQueueItem(curIdx);
      return;
    }
    if (media.title.isNotEmpty) _playbackErrors.add(media.title);
    _recoveringTrackId = null;
    _recoverAttempts = 0;
    await _skipToNextCachedOrPause(curIdx);
  }

  /// Skips to the first track after [fromIdx] that is fully cached on
  /// disk (plays with zero network). If none, pauses rather than
  /// thrashing through unreachable streams on a dead connection.
  Future<void> _skipToNextCachedOrPause(int fromIdx) async {
    final mgr = _audioCacheManager;
    if (mgr != null) {
      for (var i = fromIdx + 1; i < _lastTracks.length; i++) {
        if (await mgr.pathFor(_lastTracks[i].id) != null) {
          await skipToQueueItem(i);
          return;
        }
      }
    }
    try {
      await _player.pause();
    } catch (_) {}
  }

  /// Configures the OS audio session for music playback and wires
  /// interruption + becoming-noisy handling. just_audio auto-activates /
  /// deactivates the session around playback once it's configured, but
  /// does NOT handle interruptions or the headphones-unplugged event
  /// itself — that's done here. Best effort: a failure must not break
  /// playback.
  Future<void> _configureAudioSession() async {
    try {
      final session = await AudioSession.instance;
      await session.configure(const AudioSessionConfiguration.music());
      session.interruptionEventStream.listen(_onInterruption);
      session.becomingNoisyEventStream.listen((_) {
        // Headphones unplugged / Bluetooth disconnected — never blast the
        // phone speaker; pause like every other media app.
        unawaited(_player.pause());
      });
    } catch (e, st) {
      debugPrint('audio_handler: audio session configure failed: $e\n$st');
    }
  }

  void _onInterruption(AudioInterruptionEvent event) {
    if (event.begin) {
      switch (event.type) {
        case AudioInterruptionType.duck:
          // Transient: another app wants the foreground briefly. Lower
          // our volume rather than stopping (OS may also auto-duck).
          _volumeBeforeDuck = _player.volume;
          unawaited(_player.setVolume(0.3));
        case AudioInterruptionType.pause:
        case AudioInterruptionType.unknown:
          // Call / other media took focus. Remember whether we were
          // actively playing so a transient end can resume us.
          _interruptedWhilePlaying = _player.playing &&
              _player.processingState != ProcessingState.completed;
          unawaited(_player.pause());
      }
    } else {
      switch (event.type) {
        case AudioInterruptionType.duck:
          unawaited(_player.setVolume(_volumeBeforeDuck ?? 1.0));
          _volumeBeforeDuck = null;
        case AudioInterruptionType.pause:
          // Transient interruption ended — resume only if WE paused it
          // and the session is still alive (a long call may have let the
          // #52 idle timer tear it down; re-init is resume-last-session
          // territory, out of scope here).
          if (_interruptedWhilePlaying &&
              _player.processingState != ProcessingState.idle) {
            unawaited(_player.play());
          }
          _interruptedWhilePlaying = false;
        case AudioInterruptionType.unknown:
          // Unknown end — do not auto-resume (could surprise the user).
          _interruptedWhilePlaying = false;
      }
    }
  }
}

/// Adapter that lets the audio handler call into the app's
/// LikesController without depending on Riverpod directly. Constructed
/// in PlayerActions where ref is available; consumed inside the audio
/// handler's setRating override and MediaItem builder to keep external
/// media controllers (Wear, lock screen, Auto) in sync with the
/// likedIds drift cache.
class LikeBridge {
  const LikeBridge({
    required this.toggleTrackLike,
    required this.isTrackLiked,
  });

  /// Flip the like state for [trackId]. Returns a Future that resolves
  /// once the underlying LikesController has both updated drift
  /// optimistically and rolled the change through the REST API
  /// (errors result in drift rollback inside LikesController).
  final Future<void> Function(String trackId) toggleTrackLike;

  /// Read the current like state for [trackId] from the drift-backed
  /// likedIdsProvider. Synchronous because the audio handler needs
  /// to populate MediaItem.rating on the broadcast hot path.
  final bool Function(String trackId) isTrackLiked;
}
