import 'dart:async';

import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../api/client.dart';
import '../api/endpoints/library.dart';
import '../auth/auth_provider.dart';
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 '../models/album.dart';
import '../models/artist.dart';
import '../models/home_index.dart';
import '../models/track.dart';

/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
/// to the secure-storage `session_token` read — every other endpoint
/// awaits `dioProvider.future` rather than constructing its own dio.
///
/// Riverpod will invalidate any FutureProvider that watches this when
/// the server URL changes; auth state changes don't need to invalidate
/// the provider because the resolver re-reads the token on every request.
final dioProvider = FutureProvider<Dio>((ref) async {
  final url = await ref.watch(serverUrlProvider.future);
  if (url == null || url.isEmpty) {
    throw StateError('no server URL set');
  }
  final storage = ref.watch(secureStorageProvider);
  return ApiClient.buildDio(
    baseUrl: url,
    tokenResolver: () async => storage.read(key: 'session_token'),
    on401: () async => ref.read(authControllerProvider.notifier).clearSession(),
  );
});

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

/// Drift-first per-item home index. Reads from cached_home_index
/// (populated by /api/home/index discovery) and yields a HomeIndex
/// the screen consumes to lay out section→tile slots. Each tile is
/// rendered by a per-entity tile provider that hydrates itself.
///
/// Section keys mirror the server's response shape so the encode /
/// decode round-trip is straightforward — the table stores
/// (section, position, entityType, entityId), and toResult reassembles
/// the parallel ID lists.
final homeIndexProvider = StreamProvider<HomeIndex>((ref) {
  final db = ref.watch(appDbProvider);
  final query = (db.select(db.cachedHomeIndex)
        ..orderBy([
          (t) => drift.OrderingTerm.asc(t.section),
          (t) => drift.OrderingTerm.asc(t.position),
        ]))
      .watch();
  return cacheFirst<CachedHomeIndexData, HomeIndex>(
    driftStream: query,
    fetchAndPopulate: () async {
      final api = await ref.read(libraryApiProvider.future);
      final fresh = await api.getHomeIndex();
      // Full replace in a transaction so the watch() sees exactly one
      // post-fetch emission with the merged state. Section ordering is
      // re-asserted on read (orderBy above) so the write order doesn't
      // matter — letting us batch flat instead of section-by-section.
      await db.transaction(() async {
        await db.delete(db.cachedHomeIndex).go();
        await db.batch((b) {
          void rows(String section, String entityType, List<String> ids) {
            for (var i = 0; i < ids.length; i++) {
              b.insert(
                db.cachedHomeIndex,
                CachedHomeIndexCompanion.insert(
                  section: section,
                  position: i,
                  entityType: entityType,
                  entityId: ids[i],
                ),
              );
            }
          }

          rows('recently_added_albums', 'album', fresh.recentlyAddedAlbums);
          rows('rediscover_albums', 'album', fresh.rediscoverAlbums);
          rows('rediscover_artists', 'artist', fresh.rediscoverArtists);
          rows('most_played_tracks', 'track', fresh.mostPlayedTracks);
          rows('last_played_artists', 'artist', fresh.lastPlayedArtists);
        });
      });
    },
    toResult: (rows) {
      final recentlyAddedAlbums = <String>[];
      final rediscoverAlbums = <String>[];
      final rediscoverArtists = <String>[];
      final mostPlayedTracks = <String>[];
      final lastPlayedArtists = <String>[];
      for (final r in rows) {
        switch (r.section) {
          case 'recently_added_albums':
            recentlyAddedAlbums.add(r.entityId);
          case 'rediscover_albums':
            rediscoverAlbums.add(r.entityId);
          case 'rediscover_artists':
            rediscoverArtists.add(r.entityId);
          case 'most_played_tracks':
            mostPlayedTracks.add(r.entityId);
          case 'last_played_artists':
            lastPlayedArtists.add(r.entityId);
        }
      }
      return HomeIndex(
        recentlyAddedAlbums: recentlyAddedAlbums,
        rediscoverAlbums: rediscoverAlbums,
        rediscoverArtists: rediscoverArtists,
        mostPlayedTracks: mostPlayedTracks,
        lastPlayedArtists: lastPlayedArtists,
      );
    },
    isOnline: () async => (await ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true)),
    // SWR: cached layout shows instantly; fresh /api/home/index lands
    // in the background and tiles re-resolve as the table mutates.
    alwaysRefresh: true,
    tag: 'homeIndex',
  );
});

/// Drift-first per #357 plan C. Watches cached_artists for the row;
/// when empty + online, fetches via REST + populates drift, which
/// re-emits via watch().
final artistProvider =
    StreamProvider.family<ArtistRef, String>((ref, id) {
  final db = ref.watch(appDbProvider);
  // LEFT JOIN cached_albums for cover-URL reconstruction (see
  // CachedArtistAdapter.toRef). First row carries the alphabetically-
  // first album.
  final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
      .join([
    drift.leftOuterJoin(db.cachedAlbums,
        db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
  ])
    ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
  return cacheFirst<drift.TypedResult, ArtistRef>(
    driftStream: query.watch(),
    fetchAndPopulate: () async {
      final api = await ref.read(libraryApiProvider.future);
      final fresh = await api.getArtist(id);
      await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
    },
    toResult: (rows) {
      if (rows.isEmpty) return const ArtistRef(id: '', name: '');
      final artist = rows.first.readTable(db.cachedArtists);
      final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
      return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
    },
    isOnline: () async => (await ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true)),
    // No alwaysRefresh: artist rows don't change frequently, and the
    // metadata prefetcher creates many subscriptions in parallel —
    // each silently re-fetching once would saturate the request
    // pipeline behind the user's actual playback request.
    tag: 'artist($id)',
  );
});

final artistAlbumsProvider =
    StreamProvider.family<List<AlbumRef>, String>((ref, artistId) {
  final db = ref.watch(appDbProvider);
  // Join cached_albums + cached_artists to fill artist_name on each row.
  final query = db.select(db.cachedAlbums).join([
    drift.leftOuterJoin(db.cachedArtists,
        db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
  ])..where(db.cachedAlbums.artistId.equals(artistId));

  return cacheFirst<drift.TypedResult, List<AlbumRef>>(
    driftStream: query.watch(),
    fetchAndPopulate: () async {
      final api = await ref.read(libraryApiProvider.future);
      final fresh = await api.getArtistAlbums(artistId);
      await db.batch((b) {
        b.insertAllOnConflictUpdate(
            db.cachedAlbums, fresh.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)),
    // SWR: re-fetch the full album list on every visit. Without this,
    // a previously-incomplete drift cache (e.g. user had only opened
    // one album by this artist, so cachedAlbums had just that row)
    // would render forever as a partial list. The prefetcher only
    // warms artistProvider (single row), so this provider isn't
    // mass-instantiated and the storm risk that motivated dropping
    // alwaysRefresh elsewhere doesn't apply here.
    alwaysRefresh: true,
    tag: 'artistAlbums($artistId)',
  );
});

final artistTracksProvider =
    StreamProvider.family<List<TrackRef>, String>((ref, artistId) {
  final db = ref.watch(appDbProvider);
  final query = db.select(db.cachedTracks).join([
    drift.leftOuterJoin(db.cachedArtists,
        db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
    drift.leftOuterJoin(db.cachedAlbums,
        db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
  ])..where(db.cachedTracks.artistId.equals(artistId));

  return cacheFirst<drift.TypedResult, List<TrackRef>>(
    driftStream: query.watch(),
    fetchAndPopulate: () async {
      final api = await ref.read(libraryApiProvider.future);
      final fresh = await api.getArtistTracks(artistId);
      await db.batch((b) {
        b.insertAllOnConflictUpdate(
            db.cachedTracks, fresh.map((t) => t.toDrift()).toList());
      });
    },
    toResult: (rows) => rows.map((r) {
      final track = r.readTable(db.cachedTracks);
      final artist = r.readTableOrNull(db.cachedArtists);
      final album = r.readTableOrNull(db.cachedAlbums);
      return track.toRef(
        artistName: artist?.name ?? '',
        albumTitle: album?.title ?? '',
      );
    }).toList(),
    isOnline: () async => (await ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true)),
    tag: 'artistTracks($artistId)',
  );
});

/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
/// for reactive updates over both rows.
final albumProvider = StreamProvider.family<
    ({AlbumRef album, List<TrackRef> tracks}), String>((ref, albumId) async* {
  final db = ref.watch(appDbProvider);

  final albumQuery = (db.select(db.cachedAlbums)
        ..where((t) => t.id.equals(albumId)))
      .join([
    drift.leftOuterJoin(db.cachedArtists,
        db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
  ]);

  final tracksQuery = (db.select(db.cachedTracks)
        ..where((t) => t.albumId.equals(albumId))
        ..orderBy([
          (t) => drift.OrderingTerm.asc(t.discNumber),
          (t) => drift.OrderingTerm.asc(t.trackNumber),
        ]))
      .join([
    drift.leftOuterJoin(db.cachedArtists,
        db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
  ]);

  // Once-per-subscription guard so we don't re-fetch in a loop if the
  // server genuinely returns zero tracks (or if the fetch fails).
  var fetchAttempted = false;
  // (revalidated flag removed; see SWR note below the yield.)

  Future<bool> isOnline() async {
    try {
      return await ref
          .read(connectivityProvider.future)
          .timeout(const Duration(seconds: 3), onTimeout: () => true);
    } catch (_) {
      return true;
    }
  }

  // Cold-cache fetch: pulls the album + its tracks + any unique
  // artists referenced and writes all three tables in one batch.
  // Returns true on success (drift watch will re-emit), false on
  // failure so the caller can yield an empty result.
  Future<bool> fetchAndPopulate() async {
    try {
      final api = await ref.read(libraryApiProvider.future);
      final fresh = await api
          .getAlbum(albumId)
          .timeout(const Duration(seconds: 10));
      // Collect every artist mentioned by the album + its tracks so
      // the JOINs that drive both the album header and the track rows
      // have something to bind to. Without this, drift returns null
      // for the artist row and artistName surfaces empty — visible
      // as a missing artist line in the mini player and row list.
      final artists = <String, ArtistRef>{};
      if (fresh.album.artistId.isNotEmpty &&
          fresh.album.artistName.isNotEmpty) {
        artists[fresh.album.artistId] = ArtistRef(
          id: fresh.album.artistId,
          name: fresh.album.artistName,
        );
      }
      for (final t in fresh.tracks) {
        if (t.artistId.isNotEmpty &&
            t.artistName.isNotEmpty &&
            !artists.containsKey(t.artistId)) {
          artists[t.artistId] = ArtistRef(
            id: t.artistId,
            name: t.artistName,
          );
        }
      }
      await db.batch((b) {
        if (artists.isNotEmpty) {
          b.insertAllOnConflictUpdate(
              db.cachedArtists, artists.values.map((a) => a.toDrift()).toList());
        }
        b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
        b.insertAllOnConflictUpdate(db.cachedTracks,
            fresh.tracks.map((t) => t.toDrift()).toList());
      });
      return true;
    } catch (e, st) {
      debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
      return false;
    }
  }

  await for (final albumRows in albumQuery.watch()) {
    // Case 1: no album row at all → cold-fetch.
    if (albumRows.isEmpty) {
      if (fetchAttempted) {
        // Already tried and got nothing back.
        yield (
          album: const AlbumRef(id: '', title: '', artistId: ''),
          tracks: const <TrackRef>[],
        );
        continue;
      }
      fetchAttempted = true;
      if (!await isOnline()) {
        yield (
          album: const AlbumRef(id: '', title: '', artistId: ''),
          tracks: const <TrackRef>[],
        );
        continue;
      }
      final ok = await fetchAndPopulate();
      if (!ok) {
        yield (
          album: const AlbumRef(id: '', title: '', artistId: ''),
          tracks: const <TrackRef>[],
        );
      }
      // On success, drift watch re-emits with rows; loop continues.
      continue;
    }

    final albumRow = albumRows.first;
    final album = albumRow.readTable(db.cachedAlbums).toRef(
          artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '',
        );

    final trackRows = await tracksQuery.get();

    // Case 2: album row exists but no tracks. This happens when an
    // upstream provider (artistAlbumsProvider, sync, etc.) populated
    // album rows without their track lists. Trigger the same
    // fetchAndPopulate so the album becomes complete; drift watch
    // re-emits and we land in the populated branch on the next pass.
    if (trackRows.isEmpty && !fetchAttempted) {
      fetchAttempted = true;
      if (await isOnline()) {
        final ok = await fetchAndPopulate();
        if (ok) continue; // wait for watch re-emit
      }
      // Fall through and yield with the album + empty tracks if the
      // fetch failed or we're offline.
    }

    final tracks = trackRows.map((r) {
      final track = r.readTable(db.cachedTracks);
      final artist = r.readTableOrNull(db.cachedArtists);
      return track.toRef(
        artistName: artist?.name ?? '',
        albumTitle: album.title,
      );
    }).toList();

    // Note: NO SWR here on purpose. Prior code kicked a background
    // refresh on every first cache hit, which combined with the
    // metadata prefetcher meant every prewarmed album id triggered
    // an extra fetch even when drift was already populated. Tracks
    // and album metadata don't change on the same timescale as
    // playlists; a stale read is fine until the user invalidates
    // (pull-to-refresh) or the album is genuinely re-fetched.

    yield (album: album, tracks: tracks);
  }
});
