import 'dart:convert';

import 'package:drift/drift.dart' as drift;
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/library_lists.dart';
import '../api/endpoints/likes.dart';
import '../api/endpoints/me.dart';
import '../auth/auth_provider.dart' show authControllerProvider;
import '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../cache/metadata_prefetcher.dart';
import '../cache/shuffle_source.dart';
import '../cache/tile_providers.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/album.dart';
import '../models/artist.dart';
import '../models/history_event.dart';
import '../models/quarantine_mine.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../quarantine/quarantine_provider.dart';
import '../shared/live_events_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../shared/widgets/skeletons.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
import 'widgets/album_card.dart';
import 'widgets/artist_card.dart';
import 'widgets/track_row.dart';

// Providers scoped to this screen. Each tab gets its own first page.
// Infinite scroll is a future enhancement; for v1 phone testing the
// first 50 rows per tab is enough.

final _libraryListsApiProvider = FutureProvider<LibraryListsApi>((ref) async {
  return LibraryListsApi(await ref.watch(dioProvider.future));
});

final _likesApiProvider = FutureProvider<LikesApi>((ref) async {
  return LikesApi(await ref.watch(dioProvider.future));
});

final _meApiProvider = FutureProvider<MeApi>((ref) async {
  return MeApi(await ref.watch(dioProvider.future));
});

/// Drift-first all-artists list. Reads cached_artists ordered by
/// sortName; GridView.builder takes care of lazy widget construction
/// so loading the full set up front is fine for typical library sizes.
/// SWR refresh on every subscription hits /api/artists with a generous
/// limit so newly-scanned-but-not-yet-synced rows land soon after.
///
/// The old AsyncNotifier+loadMore infinite-scroll path went away: the
/// previous "fetch one page at a time as you scroll" felt like the
/// rest of the app was buffering when this tab was the slow surface,
/// and SyncController already populates the full set of artist rows
/// via /api/library/sync.
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
  final db = ref.watch(appDbProvider);
  // LEFT JOIN cached_albums so each artist row carries a representative
  // album id for cover-URL reconstruction. Sorted by artist sortName
  // then album sortTitle: the first row per artist gets the
  // alphabetically-first album. Dedup happens in toResult.
  final query = db.select(db.cachedArtists).join([
    drift.leftOuterJoin(db.cachedAlbums,
        db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
  ])
    ..orderBy([
      drift.OrderingTerm.asc(db.cachedArtists.sortName),
      drift.OrderingTerm.asc(db.cachedAlbums.sortTitle),
    ]);
  return cacheFirst<drift.TypedResult, List<ArtistRef>>(
    driftStream: query.watch(),
    fetchAndPopulate: () async {
      // Cold-cache fallback: sync should have populated drift already,
      // but a fresh install + first Library visit before sync completes
      // will be empty. Fetch a generous chunk via /api/artists and
      // persist; subsequent SWR refreshes keep things current.
      final api = await ref.read(_libraryListsApiProvider.future);
      final fresh = await api.listArtists(limit: 1000, offset: 0);
      await db.batch((b) {
        b.insertAllOnConflictUpdate(
          db.cachedArtists,
          fresh.items.map((a) => a.toDrift()).toList(),
        );
      });
    },
    toResult: (rows) {
      // The join multiplies rows by album count; dedup by artist id
      // keeping the first occurrence so we get one ArtistRef per
      // artist with the alphabetically-first album's id for the
      // cover-URL projection. Artists with no albums in drift yet
      // come through as a single row with null cachedAlbums and an
      // empty coverUrl — UI falls back to the placeholder icon.
      final seen = <String>{};
      final out = <ArtistRef>[];
      for (final r in rows) {
        final a = r.readTable(db.cachedArtists);
        if (!seen.add(a.id)) continue;
        final album = r.readTableOrNull(db.cachedAlbums);
        out.add(a.toRef(coverAlbumId: album?.id ?? ''));
      }
      return out;
    },
    isOnline: () async => (await ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true)),
    alwaysRefresh: true,
    tag: 'libraryArtists',
  );
});

/// Drift-first all-albums list. Joins cached_albums with cached_artists
/// for the artistName field. Same cold-cache fallback + SWR refresh
/// pattern as libraryArtistsProvider.
final _libraryAlbumsProvider = StreamProvider<List<AlbumRef>>((ref) {
  final db = ref.watch(appDbProvider);
  final query = db.select(db.cachedAlbums).join([
    drift.leftOuterJoin(db.cachedArtists,
        db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
  ])
    ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
  return cacheFirst<drift.TypedResult, List<AlbumRef>>(
    driftStream: query.watch(),
    fetchAndPopulate: () async {
      final api = await ref.read(_libraryListsApiProvider.future);
      final fresh = await api.listAlbums(limit: 1000, offset: 0);
      await db.batch((b) {
        b.insertAllOnConflictUpdate(
          db.cachedAlbums,
          fresh.items.map((a) => a.toDrift()).toList(),
        );
      });
    },
    toResult: (rows) => rows.map((r) {
      final album = r.readTable(db.cachedAlbums);
      final artist = r.readTableOrNull(db.cachedArtists);
      return album.toRef(artistName: artist?.name ?? '');
    }).toList(),
    isOnline: () async => (await ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true)),
    alwaysRefresh: true,
    tag: 'libraryAlbums',
  );
});

// Drift-first History tab. Mirrors homeProvider's pattern: store the
// last /api/me/history page as JSON in a single-row drift table, yield
// it immediately on subscribe (so the tab paints from disk on cold
// open), then SWR-refresh in the background. Also gives basic offline
// scrollback — the last fetched page survives connectivity loss.
//
// JSON blob (vs columnar) because the page is small, always read whole,
// and the HistoryPage.fromJson constructor already accepts the wire
// shape — no schema-evolution pain when server-side fields change.
final _historyProvider = StreamProvider<HistoryPage>((ref) {
  final db = ref.watch(appDbProvider);
  return cacheFirst<CachedHistorySnapshotData, HistoryPage>(
    driftStream: db.select(db.cachedHistorySnapshot).watch(),
    fetchAndPopulate: () async {
      final api = await ref.read(_meApiProvider.future);
      final fresh = await api.history();
      await db.into(db.cachedHistorySnapshot).insertOnConflictUpdate(
            CachedHistorySnapshotCompanion.insert(
              json: _encodeHistoryPage(fresh),
              updatedAt: drift.Value(DateTime.now()),
            ),
          );
    },
    toResult: (rows) => rows.isEmpty
        ? const HistoryPage(events: [], hasMore: false)
        : HistoryPage.fromJson(
            jsonDecode(rows.first.json) as Map<String, dynamic>),
    isOnline: () async => (await ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true)),
    // SWR: yield cache instantly, then refresh in the background so the
    // tab reflects the freshest plays. Matches homeProvider behavior.
    alwaysRefresh: true,
    tag: 'history',
  );
});

/// Encodes HistoryPage back to the wire-format JSON shape
/// /api/me/history emits, so HistoryPage.fromJson can round-trip
/// through the drift cache.
String _encodeHistoryPage(HistoryPage h) => jsonEncode({
      'events': h.events
          .map((e) => {
                'id': e.id,
                'played_at': e.playedAt,
                'track': {
                  'id': e.track.id,
                  'title': e.track.title,
                  'album_id': e.track.albumId,
                  'album_title': e.track.albumTitle,
                  'artist_id': e.track.artistId,
                  'artist_name': e.track.artistName,
                  'track_number': e.track.trackNumber,
                  'disc_number': e.track.discNumber,
                  'duration_sec': e.track.durationSec,
                  'stream_url': e.track.streamUrl,
                },
              })
          .toList(),
      'has_more': h.hasMore,
    });

// Per-item Liked tabs (Slice E of the per-item rendering pass).
// Each provider yields just the ordered list of entity IDs; the UI
// then renders per-tile widgets that hydrate each entity individually
// via albumTileProvider / artistTileProvider / trackTileProvider.
//
// Reads come from cached_likes (sync- and optimistic-write-populated),
// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the
// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints
// that returned fully denormalized entities are no longer needed for
// this path since tile providers handle entity hydration themselves.
//
// likedAt ordering note: cached_likes.likedAt is whatever drift
// assigned via currentDateAndTime when the row was first inserted
// (either by sync or LikesController). insertOrIgnore on subsequent
// fetches preserves the existing likedAt so ordering stays stable.
// Approximate but acceptable — matches prior Slice 3 behavior.

List<String> _idsForEntity(List<CachedLike> rows) =>
    rows.map((r) => r.entityId).toList(growable: false);

final _likedTrackIdsProvider = StreamProvider<List<String>>((ref) {
  final db = ref.watch(appDbProvider);
  final query = (db.select(db.cachedLikes)
        ..where((t) => t.entityType.equals('track'))
        ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
      .watch();
  return cacheFirst<CachedLike, List<String>>(
    driftStream: query,
    fetchAndPopulate: () => _populateLikeIds(ref),
    toResult: _idsForEntity,
    isOnline: () async => (await ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true)),
    alwaysRefresh: true,
    tag: 'likedTrackIds',
  );
});

final _likedAlbumIdsProvider = StreamProvider<List<String>>((ref) {
  final db = ref.watch(appDbProvider);
  final query = (db.select(db.cachedLikes)
        ..where((t) => t.entityType.equals('album'))
        ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
      .watch();
  return cacheFirst<CachedLike, List<String>>(
    driftStream: query,
    fetchAndPopulate: () => _populateLikeIds(ref),
    toResult: _idsForEntity,
    isOnline: () async => (await ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true)),
    alwaysRefresh: true,
    tag: 'likedAlbumIds',
  );
});

final _likedArtistIdsProvider = StreamProvider<List<String>>((ref) {
  final db = ref.watch(appDbProvider);
  final query = (db.select(db.cachedLikes)
        ..where((t) => t.entityType.equals('artist'))
        ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
      .watch();
  return cacheFirst<CachedLike, List<String>>(
    driftStream: query,
    fetchAndPopulate: () => _populateLikeIds(ref),
    toResult: _idsForEntity,
    isOnline: () async => (await ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true)),
    alwaysRefresh: true,
    tag: 'likedArtistIds',
  );
});

/// Shared cold-cache populator: hits /api/likes/ids once and writes
/// rows for all three entity types via insertOrIgnore. The three
/// providers above all trigger this on their first empty-drift
/// emission; the dedup in cacheFirst's revalidate state plus drift's
/// insertOrIgnore semantics make the multiple-trigger case cheap.
Future<void> _populateLikeIds(Ref ref) async {
  final api = await ref.read(_likesApiProvider.future);
  final user = ref.read(authControllerProvider).value;
  if (user == null) return;
  final fresh = await api.ids();
  final db = ref.read(appDbProvider);
  await db.batch((b) {
    for (final id in fresh.tracks) {
      b.insert(
        db.cachedLikes,
        CachedLikesCompanion.insert(
          userId: user.id,
          entityType: 'track',
          entityId: id,
        ),
        mode: drift.InsertMode.insertOrIgnore,
      );
    }
    for (final id in fresh.albums) {
      b.insert(
        db.cachedLikes,
        CachedLikesCompanion.insert(
          userId: user.id,
          entityType: 'album',
          entityId: id,
        ),
        mode: drift.InsertMode.insertOrIgnore,
      );
    }
    for (final id in fresh.artists) {
      b.insert(
        db.cachedLikes,
        CachedLikesCompanion.insert(
          userId: user.id,
          entityType: 'artist',
          entityId: id,
        ),
        mode: drift.InsertMode.insertOrIgnore,
      );
    }
  });
}

// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
// optimistic flag/unflag) so flagging from any kebab and unflagging from
// the Hidden tab keep one source of truth.

class LibraryScreen extends ConsumerStatefulWidget {
  const LibraryScreen({super.key});

  @override
  ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
}

class _LibraryScreenState extends ConsumerState<LibraryScreen>
    with TickerProviderStateMixin {
  late final TabController _ctrl = TabController(length: 5, vsync: this);

  @override
  void dispose() {
    _ctrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final fs = Theme.of(context).extension<FabledSwordTheme>()!;
    // Pre-warm every tab's provider on Library mount so swiping
    // between tabs feels instant rather than each tab paying its
    // own cold-cache cost on first visit. ref.listen subscribes
    // without rebuilding LibraryScreen on emit; subscriptions stay
    // alive for the lifetime of this widget. cacheFirst handles
    // dedupe of concurrent fetchAndPopulate triggers.
    ref.listen(_libraryArtistsProvider, (_, __) {});
    ref.listen(_libraryAlbumsProvider, (_, __) {});
    ref.listen(_historyProvider, (_, __) {});
    ref.listen(_likedTrackIdsProvider, (_, __) {});
    ref.listen(_likedAlbumIdsProvider, (_, __) {});
    ref.listen(_likedArtistIdsProvider, (_, __) {});
    ref.listen(myQuarantineProvider, (_, __) {});
    return Scaffold(
      backgroundColor: fs.obsidian,
      appBar: AppBar(
        backgroundColor: fs.obsidian,
        elevation: 0,
        title: Text('Library', style: TextStyle(color: fs.parchment)),
        actions: [
          IconButton(
            key: const Key('shuffle_all_button'),
            tooltip: 'Shuffle all',
            icon: Icon(LucideIcons.shuffle, color: fs.parchment),
            onPressed: () async {
              final messenger = ScaffoldMessenger.of(context);
              final refs = await ref.read(shuffleSourceProvider).tracks();
              if (refs.isEmpty) {
                messenger.showSnackBar(const SnackBar(
                  content: Text('Nothing to shuffle yet'),
                ));
                return;
              }
              await ref
                  .read(playerActionsProvider)
                  .playTracks(refs, shuffle: true);
            },
          ),
          const MainAppBarActions(currentRoute: '/library'),
        ],
        bottom: TabBar(
          controller: _ctrl,
          isScrollable: true,
          tabAlignment: TabAlignment.start,
          labelColor: fs.parchment,
          unselectedLabelColor: fs.ash,
          indicatorColor: fs.accent,
          tabs: const [
            Tab(text: 'Artists'),
            Tab(text: 'Albums'),
            Tab(text: 'History'),
            Tab(text: 'Liked'),
            Tab(text: 'Hidden'),
          ],
        ),
      ),
      body: TabBarView(
        controller: _ctrl,
        children: const [
          _ArtistsTab(),
          _AlbumsTab(),
          _HistoryTab(),
          _LikedTab(),
          _HiddenTab(),
        ],
      ),
    );
  }
}

class _ArtistsTab extends ConsumerWidget {
  const _ArtistsTab();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final fs = Theme.of(context).extension<FabledSwordTheme>()!;
    return ref.watch(_libraryArtistsProvider).when(
          loading: () => const Center(child: CircularProgressIndicator()),
          error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
          data: (artists) {
            // Warm details for the first screenful so taps are instant.
            ref
                .read(metadataPrefetcherProvider)
                .warmArtists(artists.map((a) => a.id));
            return artists.isEmpty
              ? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
              : RefreshIndicator(
                  onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
                  // LayoutBuilder + cell-aware ArtistCard width mirrors
                  // the AlbumsTab pattern so the circular avatar stays
                  // a true circle on narrow grid cells.
                  //
                  // Drift-first: GridView holds the full sorted list;
                  // .builder lazily realizes only visible cells, so
                  // even on large libraries the up-front cost is just
                  // a sort over cached_artists, not N network round
                  // trips.
                  child: LayoutBuilder(builder: (ctx, constraints) {
                    const cols = 3;
                    const sidePad = 8.0;
                    const gap = 8.0;
                    final cellW = (constraints.maxWidth -
                            sidePad * 2 -
                            gap * (cols - 1)) /
                        cols;
                    // avatar (cellW - 16) + gap (8) + 1 line name (~18)
                    // + slack matched to AlbumsTab's overflow guard.
                    final cellH = (cellW - 16) + 8 + 18 + 8;
                    return GridView.builder(
                      padding: const EdgeInsets.all(sidePad),
                      gridDelegate:
                          SliverGridDelegateWithFixedCrossAxisCount(
                        crossAxisCount: cols,
                        mainAxisExtent: cellH,
                        mainAxisSpacing: gap,
                        crossAxisSpacing: gap,
                      ),
                      itemCount: artists.length,
                      itemBuilder: (ctx, i) {
                        final artist = artists[i];
                        return ArtistCard(
                          artist: artist,
                          width: cellW,
                          onTap: () => ctx.push('/artists/${artist.id}',
                              extra: artist),
                        );
                      },
                    );
                  }),
                );
          },
        );
  }
}

class _AlbumsTab extends ConsumerWidget {
  const _AlbumsTab();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final fs = Theme.of(context).extension<FabledSwordTheme>()!;
    return ref.watch(_libraryAlbumsProvider).when(
          loading: () => const Center(child: CircularProgressIndicator()),
          error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
          data: (albums) {
            return albums.isEmpty
              ? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
              : RefreshIndicator(
                  onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
                  // Same responsive 3-up grid as the artist detail
                  // album list — LayoutBuilder + AlbumCard sized to the
                  // cell, mainAxisExtent matched to actual card height.
                  // Drift-first; full sorted list, lazy realization via
                  // GridView.builder.
                  child: LayoutBuilder(builder: (ctx, constraints) {
                    const cols = 3;
                    const sidePad = 8.0;
                    const gap = 8.0;
                    final cellW = (constraints.maxWidth -
                            sidePad * 2 -
                            gap * (cols - 1)) /
                        cols;
                    // cover (cellW - 16) + gap (8) + 2-line title (~36)
                    // + artist line (~16) + slack. Slack is generous on
                    // purpose — line-height + font scaling variations
                    // would otherwise overflow the cell by a pixel
                    // (logged as a noisy RenderFlex warning).
                    final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
                    return GridView.builder(
                      padding: const EdgeInsets.all(sidePad),
                      gridDelegate:
                          SliverGridDelegateWithFixedCrossAxisCount(
                        crossAxisCount: cols,
                        mainAxisExtent: cellH,
                        mainAxisSpacing: gap,
                        crossAxisSpacing: gap,
                      ),
                      itemCount: albums.length,
                      itemBuilder: (ctx, i) {
                        final album = albums[i];
                        return AlbumCard(
                          album: album,
                          width: cellW,
                          titleMaxLines: 2,
                          onTap: () => ctx.push('/albums/${album.id}',
                              extra: album),
                        );
                      },
                    );
                  }),
                );
          },
        );
  }
}

class _HistoryTab extends ConsumerWidget {
  const _HistoryTab();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final fs = Theme.of(context).extension<FabledSwordTheme>()!;
    return ref.watch(_historyProvider).when(
          loading: () => const Center(child: CircularProgressIndicator()),
          error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
          data: (page) => page.events.isEmpty
              ? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash)))
              : RefreshIndicator(
                  onRefresh: () async => ref.refresh(_historyProvider.future),
                  child: ListView.separated(
                    itemCount: page.events.length,
                    separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
                    itemBuilder: (ctx, i) {
                      final event = page.events[i];
                      return TrackRow(
                        track: event.track,
                        onTap: () => ref
                            .read(playerActionsProvider)
                            .playTracks([event.track]),
                        trailing: Text(
                          _relativeTime(event.playedAt),
                          style: TextStyle(color: fs.ash, fontSize: 12),
                        ),
                      );
                    },
                  ),
                ),
        );
  }
}

class _LikedTab extends ConsumerWidget {
  const _LikedTab();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final fs = Theme.of(context).extension<FabledSwordTheme>()!;
    // SSE wire-up: any cross-device like / unlike triggers a refresh
    // of the discovery providers. LikesController handles local
    // mutations optimistically through the same cached_likes table,
    // so toggling a like locally re-emits the streams instantly
    // without needing an invalidate here.
    ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
      final e = next.asData?.value;
      if (e == null) return;
      switch (e.kind) {
        case 'track.liked':
        case 'track.unliked':
        case 'album.liked':
        case 'album.unliked':
        case 'artist.liked':
        case 'artist.unliked':
          ref.invalidate(_likedTrackIdsProvider);
          ref.invalidate(_likedAlbumIdsProvider);
          ref.invalidate(_likedArtistIdsProvider);
      }
    });
    final tracksA = ref.watch(_likedTrackIdsProvider);
    final albumsA = ref.watch(_likedAlbumIdsProvider);
    final artistsA = ref.watch(_likedArtistIdsProvider);

    if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
      return const Center(child: CircularProgressIndicator());
    }
    final trackIds = tracksA.value;
    final albumIds = albumsA.value;
    final artistIds = artistsA.value;
    if (trackIds == null || albumIds == null || artistIds == null) {
      return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
    }
    if (trackIds.isEmpty && albumIds.isEmpty && artistIds.isEmpty) {
      return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
    }
    return RefreshIndicator(
      onRefresh: () async {
        await Future.wait([
          ref.refresh(_likedTrackIdsProvider.future),
          ref.refresh(_likedAlbumIdsProvider.future),
          ref.refresh(_likedArtistIdsProvider.future),
        ]);
      },
      child: ListView(children: [
        if (artistIds.isNotEmpty) ...[
          _SectionHeader(label: 'Artists', count: artistIds.length),
          SizedBox(
            height: 168,
            child: ListView.builder(
              scrollDirection: Axis.horizontal,
              padding: const EdgeInsets.symmetric(horizontal: 8),
              itemCount: artistIds.length,
              itemBuilder: (ctx, i) =>
                  _LikedArtistTile(id: artistIds[i]),
            ),
          ),
        ],
        if (albumIds.isNotEmpty) ...[
          _SectionHeader(label: 'Albums', count: albumIds.length),
          SizedBox(
            height: 200,
            child: ListView.builder(
              scrollDirection: Axis.horizontal,
              padding: const EdgeInsets.symmetric(horizontal: 8),
              itemCount: albumIds.length,
              itemBuilder: (ctx, i) =>
                  _LikedAlbumTile(id: albumIds[i]),
            ),
          ),
        ],
        if (trackIds.isNotEmpty) ...[
          _SectionHeader(label: 'Tracks', count: trackIds.length),
          ...trackIds.map(
            (id) => _LikedTrackRow(id: id, sectionIds: trackIds),
          ),
        ],
        const SizedBox(height: 96),
      ]),
    );
  }
}

/// Skeleton→content cross-fade duration. Matches home_screen so the
/// reveal feel is consistent across surfaces. See _tileRevealDuration
/// in home_screen.dart for the rationale.
const Duration _likedTileReveal = Duration(milliseconds: 220);

/// Liked-Artists carousel tile. Skeleton until artistTileProvider
/// yields a populated row.
class _LikedArtistTile extends ConsumerWidget {
  const _LikedArtistTile({required this.id});
  final String id;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final artist = ref.watch(artistTileProvider(id)).asData?.value;
    return AnimatedSwitcher(
      duration: _likedTileReveal,
      switchInCurve: Curves.easeOut,
      child: artist == null
          ? const SkeletonArtistTile(key: ValueKey('skeleton'))
          : ArtistCard(
              key: ValueKey('artist-${artist.id}'),
              artist: artist,
              onTap: () =>
                  context.push('/artists/${artist.id}', extra: artist),
            ),
    );
  }
}

/// Liked-Albums carousel tile. Skeleton until albumTileProvider
/// yields a populated row.
class _LikedAlbumTile extends ConsumerWidget {
  const _LikedAlbumTile({required this.id});
  final String id;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final album = ref.watch(albumTileProvider(id)).asData?.value;
    return AnimatedSwitcher(
      duration: _likedTileReveal,
      switchInCurve: Curves.easeOut,
      child: album == null
          ? const SkeletonAlbumTile(key: ValueKey('skeleton'))
          : AlbumCard(
              key: ValueKey('album-${album.id}'),
              album: album,
              onTap: () => context.push('/albums/${album.id}', extra: album),
            ),
    );
  }
}

/// Liked-Tracks list row. Skeleton until trackTileProvider yields a
/// populated row. Tap plays the section starting at this track,
/// using whichever tracks are currently hydrated; still-loading
/// tracks are skipped from the play queue and join on next rebuild.
class _LikedTrackRow extends ConsumerWidget {
  const _LikedTrackRow({required this.id, required this.sectionIds});
  final String id;
  final List<String> sectionIds;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final track = ref.watch(trackTileProvider(id)).asData?.value;
    return AnimatedSwitcher(
      duration: _likedTileReveal,
      switchInCurve: Curves.easeOut,
      child: track == null
          ? const SkeletonTrackRow(key: ValueKey('skeleton'))
          : TrackRow(
              key: ValueKey('track-${track.id}'),
              track: track,
              onTap: () {
                final hydrated = <TrackRef>[];
                for (final i in sectionIds) {
                  final v = ref.read(trackTileProvider(i)).asData?.value;
                  if (v != null) hydrated.add(v);
                }
                final start = hydrated.indexWhere((t) => t.id == id);
                ref.read(playerActionsProvider).playTracks(
                      hydrated,
                      initialIndex: start < 0 ? 0 : start,
                    );
              },
            ),
    );
  }
}

class _HiddenTab extends ConsumerWidget {
  const _HiddenTab();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final fs = Theme.of(context).extension<FabledSwordTheme>()!;
    return ref.watch(myQuarantineProvider).when(
          loading: () => const Center(child: CircularProgressIndicator()),
          error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
          data: (rows) => rows.isEmpty
              ? Center(
                  child: Text(
                    'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.',
                    textAlign: TextAlign.center,
                    style: TextStyle(color: fs.ash),
                  ),
                )
              : RefreshIndicator(
                  onRefresh: () async => ref.refresh(myQuarantineProvider.future),
                  child: ListView.separated(
                    itemCount: rows.length,
                    separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
                    itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]),
                  ),
                ),
        );
  }
}

class _QuarantineTile extends ConsumerWidget {
  const _QuarantineTile({required this.row});
  final QuarantineMineRow row;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final fs = Theme.of(context).extension<FabledSwordTheme>()!;
    final reasonLabel = switch (row.reason) {
      'bad_rip' => 'Bad rip',
      'wrong_file' => 'Wrong file',
      'wrong_tags' => 'Wrong tags',
      'duplicate' => 'Duplicate',
      _ => 'Other',
    };
    final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : '';
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
      child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
        ClipRRect(
          borderRadius: BorderRadius.circular(4),
          child: SizedBox(
            width: 56,
            height: 56,
            child: coverUrl.isEmpty
                ? Container(color: fs.slate)
                : ServerImage(
                    url: coverUrl,
                    fit: BoxFit.cover,
                    fallback: Container(color: fs.slate),
                  ),
          ),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
            Text(
              row.trackTitle,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(color: fs.parchment, fontSize: 14),
            ),
            Text(
              '${row.artistName} · ${row.albumTitle}',
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(color: fs.ash, fontSize: 12),
            ),
            Padding(
              padding: const EdgeInsets.only(top: 4),
              child: Row(children: [
                Container(
                  padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
                  decoration: BoxDecoration(
                    color: fs.iron,
                    borderRadius: BorderRadius.circular(4),
                  ),
                  child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
                ),
                if (row.createdAt.isNotEmpty) ...[
                  const SizedBox(width: 8),
                  Text(_relativeTime(row.createdAt),
                      style: TextStyle(color: fs.ash, fontSize: 11)),
                ],
              ]),
            ),
          ]),
        ),
        IconButton(
          tooltip: 'Unhide',
          icon: Icon(LucideIcons.archive_restore, color: fs.ash, size: 20),
          onPressed: () async {
            try {
              await ref.read(myQuarantineProvider.notifier).unflag(row.trackId);
            } catch (_) {
              // Optimistic rollback handled by the notifier; surface
              // the failure only if the user kept the tab open.
              if (context.mounted) {
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(content: Text('Could not unhide; try again.')),
                );
              }
            }
          },
        ),
      ]),
    );
  }
}

class _SectionHeader extends StatelessWidget {
  const _SectionHeader({required this.label, required this.count});
  final String label;
  final int count;

  @override
  Widget build(BuildContext context) {
    final fs = Theme.of(context).extension<FabledSwordTheme>()!;
    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
      child: Row(children: [
        Text(label,
            style: TextStyle(
                color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)),
        const SizedBox(width: 8),
        Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)),
      ]),
    );
  }
}

/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago"
/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025".
/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime.
String _relativeTime(String iso) {
  final t = DateTime.tryParse(iso);
  if (t == null) return '';
  final now = DateTime.now();
  final diff = now.difference(t);
  if (diff.inMinutes < 60) {
    final m = diff.inMinutes < 1 ? 1 : diff.inMinutes;
    return '${m}m ago';
  }
  if (diff.inHours < 24) return '${diff.inHours}h ago';
  if (diff.inDays < 7) {
    const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
    final hh = t.hour.toString().padLeft(2, '0');
    final mm = t.minute.toString().padLeft(2, '0');
    return '${days[t.weekday - 1]} $hh:$mm';
  }
  const months = [
    'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
    'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
  ];
  if (t.year == now.year) return '${months[t.month - 1]} ${t.day}';
  return '${months[t.month - 1]} ${t.day}, ${t.year}';
}
