import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../core/models/cache_size.dart';
import '../../core/models/download_progress.dart';
import '../../core/models/track.dart';
import '../../core/repositories/download_preferences.dart';
import '../../core/repositories/download_repository.dart';
import '../../core/repositories/download_store.dart';
import '../../core/services/offline_cache_manager.dart';
import '../../core/services/remote_track_downloader.dart';
import '../../core/services/routing_remote_track_downloader.dart';
import '../../core/sources/jellyfin/jellyfin_track_downloader.dart';
import '../../core/sources/plex/plex_track_downloader.dart';
import '../../core/sources/subsonic/subsonic_track_downloader.dart';
import '../../data/repositories/download_repository_provider.dart';
import '../../data/repositories/music_library_repository_provider.dart';
import '../settings/jellyfin/jellyfin_settings_controller.dart';
import '../settings/plex/plex_settings_controller.dart';
import '../settings/subsonic/subsonic_settings_controller.dart';

/// The live download status of a single track, for the Library row indicator.
/// The family argument is the track's provider-aware cache key
/// ([CachedTrack.cacheKeyForTrack]) so two providers' same-id copies each show
/// their own status. Defaults to [DownloadStatus.notDownloaded] until the
/// repository reports otherwise. Auto-disposed so off-screen rows drop their
/// subscription.
final trackDownloadStatusProvider =
    StreamProvider.autoDispose.family<DownloadStatus, String>((ref, cacheKey) {
  final repository = ref.watch(downloadRepositoryProvider);
  return repository.statusStream
      .map((statuses) => statuses[cacheKey] ?? DownloadStatus.notDownloaded)
      .distinct();
});

/// The live byte progress of a single in-flight download, for the row's
/// determinate ring. The family argument is the track's provider-aware cache key
/// ([CachedTrack.cacheKeyForTrack]). Null when the track isn't downloading (or
/// its server didn't report a size, leaving progress indeterminate).
/// Auto-disposed so off-screen rows drop the subscription.
final trackDownloadProgressProvider = StreamProvider.autoDispose
    .family<DownloadProgress?, String>((ref, cacheKey) {
  final repository = ref.watch(downloadRepositoryProvider);
  return repository.progressStream
      .map((progress) => progress[cacheKey])
      .distinct();
});

/// The catalog tracks that are fully available offline, recomputed whenever the
/// download status map changes. Powers the Downloads screen's finished list.
final downloadedTracksProvider = StreamProvider<List<Track>>((ref) async* {
  final repository = ref.watch(downloadRepositoryProvider);
  final library = ref.watch(musicLibraryRepositoryProvider);
  await for (final statuses in repository.statusStream) {
    final downloadedKeys = statuses.entries
        .where((e) => e.value == DownloadStatus.downloaded)
        .map((e) => e.key)
        .toSet();
    final tracks = await library.getAllTracks();
    yield tracks
        .where((t) => downloadedKeys.contains(CachedTrack.cacheKeyForTrack(t)))
        .toList();
  }
});

/// A track that is in flight or needs attention — queued, downloading, or
/// failed — paired with its live status.
typedef ActiveDownload = ({Track track, DownloadStatus status});

/// The catalog tracks that are queued, downloading, or failed — i.e. not yet
/// finished — recomputed whenever the status map changes. Ordered downloading →
/// queued → failed (then by id) so active work sits on top and the order is
/// stable across rebuilds. Powers the Downloads screen's "In progress" section,
/// which makes the bounded-parallel caching visible (and cancel/retry reachable)
/// in one place; finished downloads come from [downloadedTracksProvider].
final activeDownloadsProvider =
    StreamProvider<List<ActiveDownload>>((ref) async* {
  final repository = ref.watch(downloadRepositoryProvider);
  final library = ref.watch(musicLibraryRepositoryProvider);
  await for (final statuses in repository.statusStream) {
    // notDownloaded is never present in the map, so "not downloaded *yet*" is
    // exactly queued/downloading/failed.
    final active = <String, DownloadStatus>{
      for (final entry in statuses.entries)
        if (entry.value != DownloadStatus.downloaded) entry.key: entry.value,
    };
    if (active.isEmpty) {
      yield const <ActiveDownload>[];
      continue;
    }
    final tracks = await library.getAllTracks();
    final byKey = <String, Track>{
      for (final t in tracks) CachedTrack.cacheKeyForTrack(t): t,
    };
    yield <ActiveDownload>[
      for (final entry in active.entries)
        if (byKey[entry.key] != null)
          (track: byKey[entry.key]!, status: entry.value),
    ]..sort(_compareActiveDownloads);
  }
});

int _activeStatusRank(DownloadStatus status) {
  switch (status) {
    case DownloadStatus.downloading:
      return 0;
    case DownloadStatus.queued:
      return 1;
    case DownloadStatus.failed:
      return 2;
    case DownloadStatus.downloaded:
    case DownloadStatus.notDownloaded:
      return 3;
  }
}

int _compareActiveDownloads(ActiveDownload a, ActiveDownload b) {
  final int byStatus =
      _activeStatusRank(a.status).compareTo(_activeStatusRank(b.status));
  return byStatus != 0 ? byStatus : a.track.id.compareTo(b.track.id);
}

/// Live offline-cache usage + per-track metadata (size, pinned, timestamps),
/// re-emitted whenever the cache changes. Powers the Settings cache card and
/// the per-row size/pin affordances on the Downloads screen.
final cacheSnapshotProvider = StreamProvider<CacheSnapshot>((ref) {
  return ref.watch(offlineCacheManagerProvider).cacheStream;
});

/// The cached-track metadata keyed by the provider-aware cache key
/// ([CachedTrack.cacheKey]), for quick per-row lookups. Keyed by cache key (not
/// the bare id) so two providers' same-id copies never shadow each other; look up
/// with [CachedTrack.cacheKeyForTrack].
final cacheEntriesByKeyProvider = Provider<Map<String, CachedTrack>>((ref) {
  final snapshot = ref.watch(cacheSnapshotProvider).valueOrNull;
  if (snapshot == null) return const <String, CachedTrack>{};
  return <String, CachedTrack>{
    for (final entry in snapshot.entries) entry.cacheKey: entry,
  };
});

/// The provider-aware cache keys ([CachedTrack.cacheKey]) of tracks that have an
/// offline copy on disk (a downloaded or pre-cached copy with managed bytes) —
/// the safe, in-memory signal the playback source strategy uses to favour
/// cache/local copies, without a disk scan or any path/URL. Keyed by cache key
/// (not bare id), so a download of `subsonic:101` never makes `jellyfin:101` look
/// cached; join with [CachedTrack.cacheKeyForTrack].
///
/// Defaults to empty so the library/playback layers don't pull the whole cache
/// stack in tests; `main` applies [offlineAvailableTrackKeysOverride] to derive
/// it live from the cache snapshot. Tests override it directly with a set.
final offlineAvailableTrackKeysProvider =
    Provider<Set<String>>((ref) => const <String>{});

/// Production binding: the live set of offline-available cache keys, recomputed
/// from [cacheEntriesByKeyProvider] whenever the cache changes. Only entries with
/// managed bytes count (an on-device marker with no file is not a cached copy).
final offlineAvailableTrackKeysOverride =
    offlineAvailableTrackKeysProvider.overrideWith((ref) {
  final Map<String, CachedTrack> entries = ref.watch(cacheEntriesByKeyProvider);
  return <String>{
    for (final CachedTrack entry in entries.values)
      if (entry.isManaged) entry.cacheKey,
  };
});

/// Owns the "Maximum cache size" limit: loads the persisted value and writes
/// changes back through [DownloadPreferences]. Clamped to the supported range.
class MaxCacheBytesController extends AsyncNotifier<int> {
  @override
  Future<int> build() {
    return ref.read(downloadPreferencesProvider).maxCacheBytes();
  }

  Future<void> setLimit(int bytes) async {
    final int clamped = CacheSize.clamp(bytes);
    await ref.read(downloadPreferencesProvider).setMaxCacheBytes(clamped);
    state = AsyncData<int>(clamped);
  }
}

final maxCacheBytesControllerProvider =
    AsyncNotifierProvider<MaxCacheBytesController, int>(
  MaxCacheBytesController.new,
);

/// Owns the "Allow mobile data" switch: loads the persisted value and writes
/// changes back through [DownloadPreferences]. When off (the safe default),
/// downloads and smart pre-cache run only on Wi-Fi.
class AllowMobileDataController extends AsyncNotifier<bool> {
  @override
  Future<bool> build() {
    return ref.read(downloadPreferencesProvider).allowMobileData();
  }

  Future<void> setAllowMobileData(bool value) async {
    await ref.read(downloadPreferencesProvider).setAllowMobileData(value);
    state = AsyncData<bool>(value);
  }
}

final allowMobileDataControllerProvider =
    AsyncNotifierProvider<AllowMobileDataController, bool>(
  AllowMobileDataController.new,
);

/// Owns the "Smart pre-cache" on/off switch: loads the persisted value and
/// writes changes back through [DownloadPreferences]. When on, the player warms
/// the next few queued tracks into the cache ahead of play.
class SmartPrecacheEnabledController extends AsyncNotifier<bool> {
  @override
  Future<bool> build() {
    return ref.read(downloadPreferencesProvider).preloadEnabled();
  }

  Future<void> setEnabled(bool value) async {
    await ref.read(downloadPreferencesProvider).setPreloadEnabled(value);
    state = AsyncData<bool>(value);
  }
}

final smartPrecacheEnabledProvider =
    AsyncNotifierProvider<SmartPrecacheEnabledController, bool>(
  SmartPrecacheEnabledController.new,
);

/// Owns the "Songs to pre-cache" count: loads the persisted value and writes
/// changes back through [DownloadPreferences], clamped to the supported range
/// ([kMinPrecacheCount]–[kMaxPrecacheCount]) so a corrupt or hand-typed value
/// can never disable pre-cache or queue a flood of downloads.
class PrecacheCountController extends AsyncNotifier<int> {
  @override
  Future<int> build() {
    return ref.read(downloadPreferencesProvider).precacheCount();
  }

  Future<void> setCount(int value) async {
    final int sanitized = sanitizePrecacheCount(value);
    await ref.read(downloadPreferencesProvider).setPrecacheCount(sanitized);
    state = AsyncData<int>(sanitized);
  }
}

final precacheCountProvider =
    AsyncNotifierProvider<PrecacheCountController, int>(
  PrecacheCountController.new,
);

/// Production binding: makes remote (Jellyfin, Subsonic/Navidrome, and Plex)
/// tracks downloadable for offline use by routing the remote downloader across
/// every source, each wired to its live signed-in source (read lazily, so
/// sign-in/out is picked up without a rebuild). The credential-bearing download
/// URL is minted only at fetch time inside each downloader; nothing here stores
/// it. Applied in `main`; tests override [remoteTrackDownloaderProvider] with
/// their own fake.
final remoteTrackDownloaderOverride =
    remoteTrackDownloaderProvider.overrideWith((ref) {
  // Read (not watch) the live sources lazily at fetch time, mirroring the
  // playback resolver — so the downloader is built once and signing in/out
  // doesn't rebuild it (which would reset in-flight download state).
  return RoutingRemoteTrackDownloader(<RemoteTrackDownloader>[
    JellyfinTrackDownloader(() => ref.read(jellyfinMusicSourceProvider)),
    SubsonicTrackDownloader(() => ref.read(subsonicMusicSourceProvider)),
    PlexTrackDownloader(() => ref.read(plexMusicSourceProvider)),
  ]);
});
