// Background metadata sweeper that walks the drift cache for missing
// relations and fills them via /api/artists/:id + /api/albums/:id.
// Cover bytes for newly-filled albums are pre-warmed too so the
// per-tile display path doesn't have to wait on network.
//
// Why this layer on top of SyncController and HydrationQueue:
//   - SyncController.sync only ingests entities the server emitted
//     into the library_changes delta for this user. For a fresh
//     install, that's everything; for an existing user, only recent
//     changes. The per-artist album list and per-album track list
//     are NOT in those deltas — they're derived at query time.
//   - HydrationQueue fills these lazily on tile render. CacheFiller
//     fills them proactively on a slow schedule so tapping an artist
//     surfaces albums immediately instead of triggering a fresh
//     /api/artists/:id at tap time.
//
// Pacing is conservative — 200ms between requests, max 200 entities
// per sweep, 5-minute interval. Designed to never compete with user
// activity. Wall time on a 1000-artist library: ~3-4 minutes spread
// over multiple sweeps.

import 'dart:async';

import 'package:drift/drift.dart' show Variable;
import 'package:flutter/foundation.dart' show debugPrint;
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 '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/connectivity_provider.dart';
import '../library/library_providers.dart' show libraryApiProvider;

class CacheFiller {
  CacheFiller(this._ref);
  final Ref _ref;

  Timer? _initialTimer;
  Timer? _intervalTimer;
  bool _running = false;
  bool _disposed = false;

  /// Delay between launch and the first sweep. Long enough for
  /// SyncController to land its initial /api/library/sync so the
  /// CacheFiller's "unfilled relations" query has meaningful work.
  static const _initialDelay = Duration(seconds: 10);

  /// Cadence between sweeps. Once a sweep finds nothing to do (steady
  /// state) the interval becomes a cheap drift query + early exit.
  static const _interval = Duration(minutes: 5);

  /// Throttle between per-entity REST requests so the filler doesn't
  /// saturate the server or compete with user-initiated playback.
  static const _requestThrottle = Duration(milliseconds: 200);

  /// Per-sweep cap. Without this, a fresh install with thousands of
  /// artists would tie up the network for many minutes in one go.
  /// The next sweep continues where this one left off (the WHERE
  /// NOT EXISTS query naturally skips already-filled rows).
  static const _maxIdsPerSweep = 200;

  void start() {
    _initialTimer = Timer(_initialDelay, _sweep);
    _intervalTimer = Timer.periodic(_interval, (_) => _sweep());
  }

  Future<void> _sweep() async {
    if (_disposed || _running) return;
    final online = await _ref
        .read(connectivityProvider.future)
        .timeout(const Duration(seconds: 3), onTimeout: () => true);
    if (!online) return;
    _running = true;
    try {
      await _fillArtists();
      if (_disposed) return;
      await _fillAlbums();
    } catch (e, st) {
      debugPrint('cache_filler: sweep failed: $e\n$st');
    } finally {
      _running = false;
    }
  }

  /// Find artists with no albums in cache and fetch /api/artists/:id
  /// for each (single round-trip yields artist + albums via the new
  /// getArtistDetail API method). Newly-discovered album covers
  /// land in flutter_cache_manager's disk cache too so the next
  /// visit to the artist's albums grid paints from disk.
  Future<void> _fillArtists() async {
    final db = _ref.read(appDbProvider);
    final rows = await db.customSelect(
      '''
      SELECT a.id FROM cached_artists a
      WHERE NOT EXISTS (
        SELECT 1 FROM cached_albums b WHERE b.artist_id = a.id
      )
      LIMIT ?
      ''',
      variables: [Variable.withInt(_maxIdsPerSweep)],
    ).get();
    final ids = rows.map((r) => r.read<String>('id')).toList();
    if (ids.isEmpty) return;

    final api = await _ref.read(libraryApiProvider.future);
    final newAlbumIds = <String>[];

    for (final id in ids) {
      if (_disposed) return;
      try {
        final detail = await api.getArtistDetail(id);
        await db.transaction(() async {
          await db
              .into(db.cachedArtists)
              .insertOnConflictUpdate(detail.artist.toDrift());
          if (detail.albums.isNotEmpty) {
            await db.batch((b) {
              b.insertAllOnConflictUpdate(
                db.cachedAlbums,
                detail.albums.map((a) => a.toDrift()).toList(),
              );
            });
            newAlbumIds.addAll(detail.albums.map((a) => a.id));
          }
        });
      } catch (e) {
        debugPrint('cache_filler: fillArtist($id) failed: $e');
      }
      await Future.delayed(_requestThrottle);
    }

    if (newAlbumIds.isNotEmpty) {
      await _prewarmCovers(newAlbumIds);
    }
  }

  /// Find albums with no tracks in cache and fetch /api/albums/:id
  /// for each. The bulk response carries the album's track list which
  /// we persist into cached_tracks for instant album detail render
  /// on the next visit.
  Future<void> _fillAlbums() async {
    final db = _ref.read(appDbProvider);
    final rows = await db.customSelect(
      '''
      SELECT a.id FROM cached_albums a
      WHERE NOT EXISTS (
        SELECT 1 FROM cached_tracks t WHERE t.album_id = a.id
      )
      LIMIT ?
      ''',
      variables: [Variable.withInt(_maxIdsPerSweep)],
    ).get();
    final ids = rows.map((r) => r.read<String>('id')).toList();
    if (ids.isEmpty) return;

    final api = await _ref.read(libraryApiProvider.future);
    for (final id in ids) {
      if (_disposed) return;
      try {
        final result = await api.getAlbum(id);
        if (result.tracks.isNotEmpty) {
          await db.batch((b) {
            b.insertAllOnConflictUpdate(
              db.cachedTracks,
              result.tracks.map((t) => t.toDrift()).toList(),
            );
          });
        }
      } catch (e) {
        debugPrint('cache_filler: fillAlbum($id) failed: $e');
      }
      await Future.delayed(_requestThrottle);
    }
  }

  /// Pre-warm album cover bytes via flutter_cache_manager so the
  /// per-tile ServerImage / CachedNetworkImage paints from disk on
  /// the user's first visit. Mirrors SyncController's prewarm
  /// helper — duplicated here intentionally to keep this file self-
  /// contained; a shared helper can come out of #357 follow-up if
  /// the duplication grows.
  Future<void> _prewarmCovers(List<String> albumIds) 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 mgr = DefaultCacheManager();

    var idx = 0;
    Future<void> worker() async {
      while (idx < albumIds.length) {
        if (_disposed) return;
        final i = idx++;
        try {
          await mgr.downloadFile(
            '$base/api/albums/${albumIds[i]}/cover',
            authHeaders: headers,
          );
        } catch (_) {
          // Best-effort prewarm — 404 (missing collage) and 401
          // (token refresh races) shouldn't abort the rest.
        }
      }
    }

    // Concurrency 3 — same as SyncController's prewarm. Covers are
    // small enough that more parallelism doesn't help much and
    // burns radio.
    await Future.wait(List.generate(3, (_) => worker()));
  }

  void dispose() {
    _disposed = true;
    _initialTimer?.cancel();
    _intervalTimer?.cancel();
  }
}

/// Read once at app start (from app.dart's postFrameCallback) to
/// activate the filler. Disposed via ref.onDispose when the provider
/// scope tears down; in practice the scope lives for the app's
/// lifetime so dispose only fires on uninstall / process death.
final cacheFillerProvider = Provider<CacheFiller>((ref) {
  final filler = CacheFiller(ref);
  ref.onDispose(filler.dispose);
  filler.start();
  return filler;
});
