import 'dart:async';

import 'package:drift/drift.dart' as drift;
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider;
import '../library/library_providers.dart' show dioProvider;
import 'audio_cache_manager.dart' show appDbProvider;
import 'db.dart';

/// Counts returned from a sync operation. Surfaced to the operator-
/// facing "Sync now" button + Settings card.
class SyncResult {
  const SyncResult({
    required this.upserts,
    required this.deletes,
    required this.cursor,
  });
  final int upserts;
  final int deletes;
  final int cursor;
}

/// Drives the delta-sync against the server (#357 Plan A endpoint).
/// Reads cursor from drift, calls `/api/library/sync?since={cursor}`,
/// applies upserts + deletes, advances cursor.
class SyncController extends AsyncNotifier<SyncResult?> {
  @override
  Future<SyncResult?> build() async => null;

  Future<SyncResult?> sync() async {
    state = const AsyncLoading();
    try {
      final db = ref.read(appDbProvider);
      final dio = await ref.read(dioProvider.future);

      final meta = await db.select(db.syncMetadata).getSingleOrNull();
      final cursor = meta?.cursor ?? 0;

      final resp = await dio.get<dynamic>(
        '/api/library/sync',
        queryParameters: {'since': cursor},
      );

      // 204 No Content — no changes since cursor.
      if (resp.statusCode == 204) {
        await db.into(db.syncMetadata).insertOnConflictUpdate(
              SyncMetadataCompanion.insert(
                lastSyncAt: drift.Value(DateTime.now()),
              ),
            );
        final r = SyncResult(upserts: 0, deletes: 0, cursor: cursor);
        state = AsyncData(r);
        return r;
      }

      // 410 Gone — cursor too old, server has compacted past it. Reset
      // local state and retry once with cursor=0.
      if (resp.statusCode == 410) {
        await db.transaction(() async {
          await db.delete(db.cachedArtists).go();
          await db.delete(db.cachedAlbums).go();
          await db.delete(db.cachedTracks).go();
          await db.delete(db.cachedLikes).go();
          await db.delete(db.cachedPlaylists).go();
          await db.delete(db.cachedPlaylistTracks).go();
          await db.into(db.syncMetadata).insertOnConflictUpdate(
                SyncMetadataCompanion.insert(cursor: const drift.Value(0)),
              );
        });
        return sync();
      }

      final body = resp.data as Map<String, dynamic>;
      final newCursor = (body['cursor'] as num).toInt();
      final upserts = body['upserts'] as Map<String, dynamic>? ?? {};
      final deletes = body['deletes'] as Map<String, dynamic>? ?? {};

      var upsertCount = 0;
      var deleteCount = 0;
      // IDs of upserted entities whose covers we'll pre-warm after the
      // transaction commits. Filled inside the loops; consumed by
      // _prewarmCovers fire-and-forget below. Artist covers are
      // server-derived from "most-recent album" and aren't
      // reconstructible client-side — album pre-warm already covers an
      // artist's primary visual via its detail-page header.
      final albumCoverIds = <String>[];
      final playlistCoverIds = <String>[];

      await db.transaction(() async {
        // ---- Upserts ----
        for (final a in (upserts['artist'] as List? ?? const [])) {
          await db.into(db.cachedArtists).insertOnConflictUpdate(
                _artistFromJson(a as Map<String, dynamic>),
              );
          upsertCount++;
        }
        for (final a in (upserts['album'] as List? ?? const [])) {
          final m = a as Map<String, dynamic>;
          await db.into(db.cachedAlbums).insertOnConflictUpdate(
                _albumFromJson(m),
              );
          final id = m['id'];
          if (id is String && id.isNotEmpty) albumCoverIds.add(id);
          upsertCount++;
        }
        for (final t in (upserts['track'] as List? ?? const [])) {
          await db.into(db.cachedTracks).insertOnConflictUpdate(
                _trackFromJson(t as Map<String, dynamic>),
              );
          upsertCount++;
        }
        for (final l in (upserts['like_track'] as List? ?? const [])) {
          final m = l as Map<String, dynamic>;
          await db.into(db.cachedLikes).insertOnConflictUpdate(
                CachedLikesCompanion.insert(
                  userId: m['user_id'] as String,
                  entityType: 'track',
                  entityId: m['track_id'] as String,
                ),
              );
          upsertCount++;
        }
        for (final l in (upserts['like_album'] as List? ?? const [])) {
          final m = l as Map<String, dynamic>;
          await db.into(db.cachedLikes).insertOnConflictUpdate(
                CachedLikesCompanion.insert(
                  userId: m['user_id'] as String,
                  entityType: 'album',
                  entityId: m['album_id'] as String,
                ),
              );
          upsertCount++;
        }
        for (final l in (upserts['like_artist'] as List? ?? const [])) {
          final m = l as Map<String, dynamic>;
          await db.into(db.cachedLikes).insertOnConflictUpdate(
                CachedLikesCompanion.insert(
                  userId: m['user_id'] as String,
                  entityType: 'artist',
                  entityId: m['artist_id'] as String,
                ),
              );
          upsertCount++;
        }
        for (final p in (upserts['playlist'] as List? ?? const [])) {
          final m = p as Map<String, dynamic>;
          await db.into(db.cachedPlaylists).insertOnConflictUpdate(
                _playlistFromJson(m),
              );
          final id = m['id'];
          if (id is String && id.isNotEmpty) playlistCoverIds.add(id);
          upsertCount++;
        }
        for (final pt in (upserts['playlist_track'] as List? ?? const [])) {
          final m = pt as Map<String, dynamic>;
          await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate(
                CachedPlaylistTracksCompanion.insert(
                  playlistId: m['playlist_id'] as String,
                  trackId: m['track_id'] as String,
                ),
              );
          upsertCount++;
        }

        // ---- Deletes ----
        for (final id in (deletes['artist'] as List? ?? const [])) {
          await (db.delete(db.cachedArtists)
                ..where((t) => t.id.equals(id as String)))
              .go();
          deleteCount++;
        }
        for (final id in (deletes['album'] as List? ?? const [])) {
          await (db.delete(db.cachedAlbums)
                ..where((t) => t.id.equals(id as String)))
              .go();
          deleteCount++;
        }
        for (final id in (deletes['track'] as List? ?? const [])) {
          await (db.delete(db.cachedTracks)
                ..where((t) => t.id.equals(id as String)))
              .go();
          deleteCount++;
        }
        for (final id in (deletes['like_track'] as List? ?? const [])) {
          final parts = (id as String).split(':');
          if (parts.length != 2) continue;
          await (db.delete(db.cachedLikes)
                ..where((t) =>
                    t.userId.equals(parts[0]) &
                    t.entityType.equals('track') &
                    t.entityId.equals(parts[1])))
              .go();
          deleteCount++;
        }
        for (final id in (deletes['like_album'] as List? ?? const [])) {
          final parts = (id as String).split(':');
          if (parts.length != 2) continue;
          await (db.delete(db.cachedLikes)
                ..where((t) =>
                    t.userId.equals(parts[0]) &
                    t.entityType.equals('album') &
                    t.entityId.equals(parts[1])))
              .go();
          deleteCount++;
        }
        for (final id in (deletes['like_artist'] as List? ?? const [])) {
          final parts = (id as String).split(':');
          if (parts.length != 2) continue;
          await (db.delete(db.cachedLikes)
                ..where((t) =>
                    t.userId.equals(parts[0]) &
                    t.entityType.equals('artist') &
                    t.entityId.equals(parts[1])))
              .go();
          deleteCount++;
        }
        for (final id in (deletes['playlist'] as List? ?? const [])) {
          await (db.delete(db.cachedPlaylists)
                ..where((t) => t.id.equals(id as String)))
              .go();
          deleteCount++;
        }
        for (final id in (deletes['playlist_track'] as List? ?? const [])) {
          final parts = (id as String).split(':');
          if (parts.length != 2) continue;
          await (db.delete(db.cachedPlaylistTracks)
                ..where((t) =>
                    t.playlistId.equals(parts[0]) &
                    t.trackId.equals(parts[1])))
              .go();
          deleteCount++;
        }

        // ---- Cursor + lastSyncAt ----
        await db.into(db.syncMetadata).insertOnConflictUpdate(
              SyncMetadataCompanion.insert(
                cursor: drift.Value(newCursor),
                lastSyncAt: drift.Value(DateTime.now()),
              ),
            );
      });

      // Fire-and-forget cover pre-warm so a cold-start scroll through
      // the home grid paints from disk on the very first frame instead
      // of firing one HTTP request per visible tile. Unawaited because
      // the sync's "done" signal should fire as soon as the metadata
      // delta is durable; cover downloads can finish in the background.
      if (albumCoverIds.isNotEmpty || playlistCoverIds.isNotEmpty) {
        unawaited(_prewarmCovers(albumCoverIds, playlistCoverIds));
      }

      final result = SyncResult(
        upserts: upsertCount,
        deletes: deleteCount,
        cursor: newCursor,
      );
      state = AsyncData(result);
      return result;
    } catch (e, st) {
      state = AsyncError(e, st);
      return null;
    }
  }

  /// Downloads cover bytes for each id into the shared flutter_cache_
  /// manager disk cache that cached_network_image reads from.
  /// Best-effort: per-URL failures (404 collage-not-built-yet, 401
  /// during a token refresh race, network blip) are swallowed so one
  /// missing cover doesn't abort the rest.
  ///
  /// Concurrency 3 balances disk-warming throughput against starving
  /// foreground UI work — covers are ~30-200KB each, so three in
  /// flight saturates most LAN connections without pinning the radio.
  Future<void> _prewarmCovers(
    List<String> albumIds,
    List<String> playlistIds,
  ) async {
    final baseUrl = await ref.read(serverUrlProvider.future);
    if (baseUrl == null || baseUrl.isEmpty) return;
    final token = await ref.read(sessionTokenProvider.future);
    final headers = (token != null && token.isNotEmpty)
        ? {'Authorization': 'Bearer $token'}
        : <String, String>{};
    final base =
        baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
    final urls = <String>[
      for (final id in albumIds) '$base/api/albums/$id/cover',
      for (final id in playlistIds) '$base/api/playlists/$id/cover',
    ];
    final mgr = DefaultCacheManager();
    var index = 0;
    Future<void> worker() async {
      while (index < urls.length) {
        final i = index++;
        try {
          await mgr.downloadFile(urls[i], authHeaders: headers);
        } catch (_) {
          // Best-effort prewarm — failures are expected for system
          // playlists whose collage hasn't been built yet, and for
          // any transient auth race. Don't surface or abort.
        }
      }
    }

    await Future.wait(List.generate(3, (_) => worker()));
  }

  CachedArtistsCompanion _artistFromJson(Map<String, dynamic> j) =>
      CachedArtistsCompanion.insert(
        id: j['id'] as String,
        name: (j['name'] as String?) ?? '',
        sortName: (j['sort_name'] as String?) ?? '',
        mbid: drift.Value(j['mbid'] as String?),
        artistThumbPath: drift.Value(j['artist_thumb_path'] as String?),
        artistFanartPath: drift.Value(j['artist_fanart_path'] as String?),
      );

  CachedAlbumsCompanion _albumFromJson(Map<String, dynamic> j) =>
      CachedAlbumsCompanion.insert(
        id: j['id'] as String,
        artistId: j['artist_id'] as String,
        title: (j['title'] as String?) ?? '',
        sortTitle: (j['sort_title'] as String?) ?? '',
        releaseDate: drift.Value(j['release_date'] as String?),
        coverPath: drift.Value(j['cover_art_path'] as String?),
        mbid: drift.Value(j['mbid'] as String?),
      );

  CachedTracksCompanion _trackFromJson(Map<String, dynamic> j) =>
      CachedTracksCompanion.insert(
        id: j['id'] as String,
        albumId: j['album_id'] as String,
        artistId: j['artist_id'] as String,
        title: (j['title'] as String?) ?? '',
        durationMs: drift.Value((j['duration_ms'] as num?)?.toInt() ?? 0),
        trackNumber: drift.Value((j['track_number'] as num?)?.toInt()),
        discNumber: drift.Value((j['disc_number'] as num?)?.toInt()),
        filePath: drift.Value(j['file_path'] as String?),
        fileFormat: drift.Value(j['file_format'] as String?),
        genre: drift.Value(j['genre'] as String?),
      );

  CachedPlaylistsCompanion _playlistFromJson(Map<String, dynamic> j) =>
      CachedPlaylistsCompanion.insert(
        id: j['id'] as String,
        userId: j['user_id'] as String,
        name: (j['name'] as String?) ?? '',
        description: drift.Value((j['description'] as String?) ?? ''),
        isPublic: drift.Value((j['is_public'] as bool?) ?? false),
        coverPath: drift.Value(j['cover_path'] as String?),
        trackCount: drift.Value((j['track_count'] as num?)?.toInt() ?? 0),
        durationSec: drift.Value((j['duration_sec'] as num?)?.toInt() ?? 0),
        systemVariant: drift.Value(j['system_variant'] as String?),
      );
}

final syncControllerProvider =
    AsyncNotifierProvider<SyncController, SyncResult?>(SyncController.new);
