import 'dart:io';

import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

import '../api/endpoints/likes.dart' show LikeKind;
import '../likes/like_button.dart';
import '../models/track.dart';
import '../shared/widgets/server_image.dart';
import '../shared/widgets/track_actions/track_actions_button.dart';
import '../theme/theme_extension.dart';
import 'album_color_extractor.dart';
import 'player_provider.dart';

/// Hero tag shared between the mini player's cover and the full-screen
/// _AlbumArt's cover so tapping the mini bar animates the artwork from
/// the bar's footprint to the full-screen size. Stable per-route (not
/// keyed by media.id) so the transition works regardless of what's
/// playing.
const String kPlayerCoverHeroTag = 'player-cover';

/// Duration for the AnimatedSwitcher / AnimatedContainer track-change
/// crossfade. ~300ms reads as a smooth transition without dragging.
const Duration _trackChangeDuration = Duration(milliseconds: 300);

/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
/// transition (see routing.dart). Dismisses three ways:
///
/// - System back / leading button → Navigator.pop
/// - Vertical drag down past threshold → Navigator.pop (matches the
///   slide-down animation users expect from a "pull down to close"
///   sheet)
/// - Tap of the mini player ascends here; reverse motion descends back.
class NowPlayingScreen extends ConsumerStatefulWidget {
  const NowPlayingScreen({super.key});

  @override
  ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
}

class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
  // Cumulative drag distance used to decide if a vertical drag should
  // dismiss. Reset on each drag start.
  double _dragOffset = 0;

  /// The MediaItem currently displayed on the screen. Held separately
  /// from the live mediaItemProvider so we can preload the new track's
  /// cover bytes + dominant color BEFORE flipping the visible state.
  /// Without this gate the full-player UI swapped to the new track's
  /// title/cover slot immediately while the image was still decoding,
  /// producing a visible "pop in" once the bytes arrived.
  MediaItem? _displayedMedia;

  /// Dominant color of [_displayedMedia]'s cover. Tweened by the
  /// backdrop AnimatedContainer when it changes.
  Color? _displayedDominant;

  /// The id of the most-recent track we kicked a preload for. Used to
  /// drop stale preload completions when the user skips rapidly past
  /// a track whose cover hadn't finished decoding yet.
  String? _pendingPreloadId;

  @override
  void initState() {
    super.initState();
    // Seed displayed state from the current mediaItem if a track is
    // already playing when the full player is opened. ref.listen
    // below only fires on CHANGES after subscription, so without
    // this seed the screen sits on "Nothing playing." even though
    // the mini bar shows a live track — the listener never gets a
    // mediaItem emission because the stream's current value hasn't
    // changed.
    final current = ref.read(mediaItemProvider).value;
    if (current == null) return;
    _displayedMedia = current;
    // Best-effort synchronous color seed: if albumColorProvider has
    // already extracted this album's dominant color earlier in the
    // session, pull it now so the backdrop renders correctly on
    // first frame. Otherwise the post-frame _scheduleSwap below
    // resolves it asynchronously and AnimatedContainer tweens.
    final albumId = current.extras?['album_id'] as String?;
    if (albumId != null && albumId.isNotEmpty) {
      _displayedDominant =
          ref.read(albumColorProvider(albumId)).asData?.value;
    }
    // Kick the preload pipeline post-frame so the cover bytes are
    // decoded + the dominant color is resolved even when the user
    // opens the player to a track that hasn't yet flowed through
    // ref.listen.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted) return;
      _scheduleSwap(current);
    });
  }

  /// Preload the new track's cover bytes + dominant color, then
  /// atomically flip _displayedMedia / _displayedDominant. Until both
  /// resolve, the screen continues to render the previous track —
  /// the new title/cover/gradient appear together in one frame.
  ///
  /// Concurrency: if a second track change arrives while the first
  /// preload is still in flight, the older preload's
  /// _pendingPreloadId check fails and its completion is dropped.
  Future<void> _scheduleSwap(MediaItem newMedia) async {
    _pendingPreloadId = newMedia.id;

    // Fast path: when Prefetcher has already warmed both the cover
    // bytes (file:// artUri populated via _toMediaItem's peekCached)
    // AND the palette color (AlbumColorCache.peekColor returns
    // non-null), we can commit synchronously. The slow async path
    // exists for genuine cold-cache moments (first play of an album
    // that sync hasn't seen yet); the fast path is what makes the
    // common in-queue auto-advance flip the visual in lockstep with
    // the audio transition instead of lagging a tick behind.
    final artUri = newMedia.artUri;
    final albumId = newMedia.extras?['album_id'] as String?;
    if (artUri != null &&
        artUri.isScheme('file') &&
        albumId != null &&
        albumId.isNotEmpty) {
      final cachedColor = ref.read(albumColorCacheProvider).peekColor(albumId);
      if (cachedColor != null) {
        if (!mounted) return;
        if (_pendingPreloadId != newMedia.id) return;
        setState(() {
          _displayedMedia = newMedia;
          _displayedDominant = cachedColor;
        });
        return;
      }
    }

    // Slow path: cover and/or color are not yet cached. Hold the
    // current displayed state, preload, then atomic-commit.
    //
    // 1. Precache the cover image bytes so when _AlbumArt mounts with
    //    the new media, FileImage paints synchronously. Non-file
    //    artUris fall through to ServerImage which handles its own
    //    network load + 120ms fade — they won't snap.
    if (artUri != null && artUri.isScheme('file') && context.mounted) {
      try {
        await precacheImage(FileImage(File.fromUri(artUri)), context);
      } catch (_) {
        // Decode failed (missing file, corrupt bytes) — proceed
        // anyway; _AlbumArt's errorBuilder shows the slate fallback.
      }
    }

    // 2. Wait for the dominant-color extraction so the gradient is
    //    populated when we swap. PaletteGenerator runs against the
    //    same FileImage, typically resolving within ~50ms once the
    //    decode completes.
    Color? newDominant;
    if (albumId != null && albumId.isNotEmpty) {
      try {
        newDominant = await ref.read(albumColorProvider(albumId).future);
      } catch (_) {
        // Color extraction failed — keep the previous dominant so the
        // backdrop doesn't drop to obsidian on the swap.
      }
    }

    // 3. Atomic flip. Bail if a newer swap superseded us, or the
    //    screen was disposed mid-preload.
    if (!mounted) return;
    if (_pendingPreloadId != newMedia.id) return;
    setState(() {
      _displayedMedia = newMedia;
      if (newDominant != null) _displayedDominant = newDominant;
    });
  }

  void _onDragStart(DragStartDetails _) {
    _dragOffset = 0;
  }

  void _onDragUpdate(DragUpdateDetails d) {
    _dragOffset += d.delta.dy;
  }

  void _onDragEnd(DragEndDetails d) {
    // Pop when either the user has dragged > 80px down OR flicked
    // downward at speed (>500 px/s). Either should feel responsive
    // without dismissing on accidental drags.
    final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500;
    if (_dragOffset > 80 || flicked) {
      Navigator.of(context).maybePop();
    }
    _dragOffset = 0;
  }

  @override
  Widget build(BuildContext context) {
    final fs = Theme.of(context).extension<FabledSwordTheme>()!;

    // Listen for mediaItem changes and schedule a preload-then-swap.
    // Build renders _displayedMedia / _displayedDominant; the live
    // mediaItemProvider only drives the swap pipeline.
    //
    // Decision process:
    //   - On the first non-null mediaItem after mount, seed
    //     _displayedMedia immediately so the screen has something to
    //     paint.
    //   - On a track id change, kick off a preload. The new track's
    //     cover bytes + dominant color must both resolve before we
    //     flip the displayed state. The user sees the previous track
    //     in full until the new one is fully ready, then a clean
    //     atomic swap (no fade, no placeholder flash).
    //   - On the same track id but the artUri-bearing rebroadcast
    //     (audio_handler sends MediaItem twice on track change),
    //     refresh through the same preload pipeline so the displayed
    //     cover reflects the freshly-written AlbumCoverCache file.
    ref.listen<AsyncValue<MediaItem?>>(mediaItemProvider, (prev, next) {
      final newMedia = next.asData?.value;
      if (newMedia == null) {
        if (_displayedMedia != null) {
          setState(() {
            _displayedMedia = null;
            _displayedDominant = null;
            _pendingPreloadId = null;
          });
          // Session was torn down (#52 idle/dismiss) while the full
          // player was open. Don't strand the user on an empty
          // "Nothing playing." screen — minimize back (the mini bar is
          // already gone too). maybePop is a no-op if this is somehow
          // the root route.
          WidgetsBinding.instance.addPostFrameCallback((_) {
            if (mounted) Navigator.of(context).maybePop();
          });
        }
        return;
      }
      if (_displayedMedia == null) {
        // First mount with a track playing — seed immediately, then
        // kick the preload pipeline to update the dominant color and
        // ensure the cover is decoded.
        setState(() => _displayedMedia = newMedia);
        _scheduleSwap(newMedia);
        return;
      }
      final isNewTrack = newMedia.id != _displayedMedia!.id;
      final coverGotPopulated =
          newMedia.id == _displayedMedia!.id &&
              newMedia.artUri != null &&
              _displayedMedia!.artUri == null;
      if (isNewTrack || coverGotPopulated) {
        _scheduleSwap(newMedia);
      }
    });

    final playback = ref.watch(playbackStateProvider).value;
    final displayedMedia = _displayedMedia;

    if (displayedMedia == null) {
      // Either nothing playing, or the very first mount before a
      // mediaItem has been emitted. The ref.listen above will seed
      // _displayedMedia as soon as a non-null MediaItem arrives.
      return const Scaffold(body: Center(child: Text('Nothing playing.')));
    }

    // Use positionProvider (just_audio's positionStream, ~200ms) for
    // the seek bar so it scrubs live; PlaybackState.updatePosition
    // only fires on state transitions and would leave the bar frozen
    // between them.
    final pos = ref.watch(positionProvider).value ?? Duration.zero;
    final dur = displayedMedia.duration ?? Duration.zero;
    final isPlaying = playback?.playing == true;
    final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
    final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
    final actions = ref.read(playerActionsProvider);
    final albumId = (displayedMedia.extras?['album_id'] as String?) ?? '';

    // Backdrop color: render the dominant we've already committed to
    // _displayedDominant. AnimatedContainer tweens between successive
    // values, so the only color changes the user sees are the
    // atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps
    // the gradient present without overwhelming the title/artist
    // text below.
    final dominant = _displayedDominant ?? fs.obsidian;
    final gradientTop = dominant.withValues(alpha: 0.55);

    return Scaffold(
      backgroundColor: fs.obsidian,
      // The whole screen accepts vertical drag for dismissal. Buttons
      // and the seek slider are still tappable since gesture arena
      // gives priority to their child gesture detectors.
      body: GestureDetector(
        onVerticalDragStart: _onDragStart,
        onVerticalDragUpdate: _onDragUpdate,
        onVerticalDragEnd: _onDragEnd,
        behavior: HitTestBehavior.translucent,
        child: AnimatedContainer(
          duration: _trackChangeDuration,
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [gradientTop, fs.obsidian],
              stops: const [0.0, 0.7],
            ),
          ),
          child: SafeArea(
          child: Column(
            children: [
              _TopBar(fs: fs),
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 24),
                  child: Column(
                    children: [
                      const Spacer(),
                      // No AnimatedSwitcher: _displayedMedia only
                      // advances after the preload pipeline has the
                      // new cover + color ready, so a track change
                      // is an atomic swap. Fading would just smear a
                      // transition the user explicitly asked us not
                      // to add.
                      _AlbumArt(
                          media: displayedMedia, albumId: albumId, fs: fs),
                      const SizedBox(height: 28),
                      _TitleRow(media: displayedMedia, fs: fs),
                      const SizedBox(height: 4),
                      Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Text(
                            displayedMedia.artist ?? '',
                            style: TextStyle(color: fs.ash, fontSize: 14),
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                          ),
                          if ((displayedMedia.album ?? '').isNotEmpty) ...[
                            const SizedBox(height: 2),
                            Text(
                              displayedMedia.album!,
                              style: TextStyle(color: fs.ash, fontSize: 12),
                              maxLines: 1,
                              overflow: TextOverflow.ellipsis,
                            ),
                          ],
                        ],
                      ),
                      const SizedBox(height: 24),
                      _SecondaryControls(
                        fs: fs,
                        actions: actions,
                        shuffleOn: shuffleOn,
                        repeatMode: repeatMode,
                        media: displayedMedia,
                      ),
                      const SizedBox(height: 8),
                      _SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
                      const SizedBox(height: 24),
                      _PrimaryControls(
                        fs: fs,
                        ref: ref,
                        isPlaying: isPlaying,
                      ),
                      const SizedBox(height: 24),
                    ],
                  ),
                ),
              ),
            ],
          ),
          ),
        ),
      ),
    );
  }
}

class _TopBar extends StatelessWidget {
  const _TopBar({required this.fs});
  final FabledSwordTheme fs;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 48,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 4),
        child: Row(children: [
          IconButton(
            icon: Icon(LucideIcons.chevron_down, color: fs.parchment),
            tooltip: 'Close',
            onPressed: () => Navigator.of(context).maybePop(),
          ),
          const Spacer(),
          IconButton(
            icon: Icon(LucideIcons.list_music, color: fs.parchment),
            tooltip: 'Queue',
            onPressed: () => GoRouter.of(context).push('/queue'),
          ),
        ]),
      ),
    );
  }
}

class _AlbumArt extends StatelessWidget {
  const _AlbumArt({required this.media, required this.albumId, required this.fs});
  final MediaItem media;
  final String albumId;
  final FabledSwordTheme fs;

  @override
  Widget build(BuildContext context) {
    // Pick the best available source. AlbumCoverCache writes a file://
    // URI to media.artUri once the cover lands on disk; before then,
    // fall back to fetching via ServerImage from /api/albums/<id>/cover
    // (which carries the auth header and falls back gracefully on
    // failure).
    Widget cover;
    final artUri = media.artUri;
    if (artUri != null && artUri.isScheme('file')) {
      cover = Image(
        image: FileImage(File.fromUri(artUri)),
        fit: BoxFit.cover,
        errorBuilder: (_, __, ___) => Container(color: fs.slate),
      );
    } else if (albumId.isNotEmpty) {
      cover = ServerImage(
        url: '/api/albums/$albumId/cover',
        fit: BoxFit.cover,
        fallback: Container(color: fs.slate),
      );
    } else {
      cover = Container(color: fs.slate);
    }
    return AspectRatio(
      aspectRatio: 1,
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
        child: Hero(
          tag: kPlayerCoverHeroTag,
          // flightShuttleBuilder ensures the in-flight Hero renders the
          // destination's cover image (not the mini bar's small one)
          // during the entire animation, which reads as a smooth grow
          // rather than a swap mid-flight.
          flightShuttleBuilder:
              (_, __, ___, ____, toHeroContext) => toHeroContext.widget,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(8),
            child: cover,
          ),
        ),
      ),
    );
  }
}

class _TitleRow extends StatelessWidget {
  const _TitleRow({required this.media, required this.fs});
  final MediaItem media;
  final FabledSwordTheme fs;

  @override
  Widget build(BuildContext context) {
    // Title-only — like + kebab moved into _SecondaryControls above
    // the seek bar so they share the same row as shuffle/repeat/queue.
    return Text(
      media.title,
      style: TextStyle(color: fs.parchment, fontSize: 22),
      maxLines: 1,
      overflow: TextOverflow.ellipsis,
      textAlign: TextAlign.center,
    );
  }
}

class _SeekRow extends StatelessWidget {
  const _SeekRow({
    required this.position,
    required this.duration,
    required this.fs,
    required this.ref,
  });
  final Duration position;
  final Duration duration;
  final FabledSwordTheme fs;
  final WidgetRef ref;

  String _fmt(Duration d) {
    final m = d.inMinutes.remainder(60).toString();
    final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
    return '$m:$s';
  }

  @override
  Widget build(BuildContext context) {
    final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
    return Column(children: [
      SliderTheme(
        data: SliderTheme.of(context).copyWith(
          trackHeight: 3,
          thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
          overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
        ),
        child: Slider(
          activeColor: fs.accent,
          inactiveColor: fs.slate,
          min: 0,
          max: maxMs,
          value: position.inMilliseconds
              .toDouble()
              .clamp(0.0, duration.inMilliseconds.toDouble()),
          onChanged: (v) => ref
              .read(audioHandlerProvider)
              .seek(Duration(milliseconds: v.toInt())),
        ),
      ),
      Padding(
        padding: const EdgeInsets.symmetric(horizontal: 4),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)),
            Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)),
          ],
        ),
      ),
    ]);
  }
}

class _PrimaryControls extends StatelessWidget {
  const _PrimaryControls({
    required this.fs,
    required this.ref,
    required this.isPlaying,
  });
  final FabledSwordTheme fs;
  final WidgetRef ref;
  final bool isPlaying;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        IconButton(
          iconSize: 36,
          icon: Icon(LucideIcons.skip_back, color: fs.parchment),
          onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
        ),
        IconButton(
          iconSize: 72,
          icon: Icon(
            isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play,
            color: fs.accent,
          ),
          onPressed: () {
            final h = ref.read(audioHandlerProvider);
            if (isPlaying) {
              h.pause();
            } else {
              h.play();
            }
          },
        ),
        IconButton(
          iconSize: 36,
          icon: Icon(LucideIcons.skip_forward, color: fs.parchment),
          onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
        ),
      ],
    );
  }
}

/// Action row sitting just above the seek bar. Holds shuffle / repeat
/// / queue plus the like + kebab that used to live in the title row,
/// so the title can sit truly centered above and this row carries
/// every per-track action in one place.
class _SecondaryControls extends StatelessWidget {
  const _SecondaryControls({
    required this.fs,
    required this.actions,
    required this.shuffleOn,
    required this.repeatMode,
    required this.media,
  });
  final FabledSwordTheme fs;
  final PlayerActions actions;
  final bool shuffleOn;
  final AudioServiceRepeatMode repeatMode;
  final MediaItem media;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        IconButton(
          tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
          icon: Icon(
            LucideIcons.shuffle,
            color: shuffleOn ? fs.accent : fs.ash,
          ),
          onPressed: actions.toggleShuffle,
        ),
        IconButton(
          tooltip: switch (repeatMode) {
            AudioServiceRepeatMode.none => 'Repeat off',
            AudioServiceRepeatMode.one => 'Repeat one',
            _ => 'Repeat all',
          },
          icon: Icon(
            repeatMode == AudioServiceRepeatMode.one
                ? LucideIcons.repeat_1
                : LucideIcons.repeat,
            color: repeatMode == AudioServiceRepeatMode.none
                ? fs.ash
                : fs.accent,
          ),
          onPressed: actions.cycleRepeat,
        ),
        IconButton(
          tooltip: 'Queue',
          icon: Icon(LucideIcons.list_music, color: fs.ash),
          onPressed: () => GoRouter.of(context).push('/queue'),
        ),
        LikeButton(kind: LikeKind.track, id: media.id, size: 22),
        TrackActionsButton(
          track: TrackRef(
            id: media.id,
            title: media.title,
            albumId: (media.extras?['album_id'] as String?) ?? '',
            albumTitle: media.album ?? '',
            artistId: (media.extras?['artist_id'] as String?) ?? '',
            artistName: media.artist ?? '',
            durationSec: media.duration?.inSeconds ?? 0,
            streamUrl: '',
          ),
          hideQueueActions: true,
          // /now-playing lives outside the ShellRoute. Pushing
          // /artists/:id or /albums/:id (both shell-children) on top
          // of it would make go_router try to mount a duplicate
          // ShellRoute (the same _debugCheckDuplicatedPageKeys crash
          // we hit before with /queue). Await our own pop fully
          // before pushing the destination so go_router never sees
          // both pages active in the same frame.
          onNavigate: (path) async {
            await Navigator.of(context).maybePop();
            if (context.mounted) GoRouter.of(context).push(path);
          },
        ),
      ],
    );
  }
}
