import 'dart:io';

import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';

import '../library/library_providers.dart' show dioProvider;
import 'db.dart';

/// Per-bucket cache usage (#427 S2). Rolling includes orphan files —
/// partials written by LockCachingAudioSource that were never indexed
/// (skip-before-fully-buffered) — so the rolling cap actually bounds
/// disk.
typedef BucketUsage = ({int liked, int rolling});

/// Owns the audio cache directory + drift index.
///
/// #427 S2: two storage buckets keyed by liked-ness, not by
/// CacheSource. A cached track currently in the user's liked set is
/// charged to (and evicted under) the Liked budget; everything else
/// is Rolling. This dedup is storage/eviction-only — it never filters
/// what the offline surfaces can play (S4). `CacheSource` is retained
/// on the API for callers but is now informational.
class AudioCacheManager {
  AudioCacheManager({
    required AppDb db,
    required Future<Dio> Function() dioFactory,
    Future<Directory> Function()? cacheDirFactory,
  })  : _db = db,
        _dioFactory = dioFactory,
        _cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory;

  final AppDb _db;
  final Future<Dio> Function() _dioFactory;
  final Future<Directory> Function() _cacheDirFactory;

  Future<String> _tracksDir() async {
    final base = await _cacheDirFactory();
    final d = Directory('${base.path}/audio_cache');
    if (!await d.exists()) await d.create(recursive: true);
    return d.path;
  }

  Future<bool> isCached(String trackId) async {
    final row = await (_db.select(_db.audioCacheIndex)
          ..where((t) => t.trackId.equals(trackId)))
        .getSingleOrNull();
    if (row == null) return false;
    return File(row.path).existsSync();
  }

  Future<String?> pathFor(String trackId) async {
    final row = await (_db.select(_db.audioCacheIndex)
          ..where((t) => t.trackId.equals(trackId)))
        .getSingleOrNull();
    if (row == null) return null;
    return File(row.path).existsSync() ? row.path : null;
  }

  /// Downloads the track's stream to disk and indexes it. Idempotent.
  /// lastPlayedAt is set now — pinning is play-intent.
  Future<String?> pin(String trackId, {required CacheSource source}) async {
    final existing = await pathFor(trackId);
    if (existing != null) {
      await touch(trackId);
      return existing;
    }
    final dir = await _tracksDir();
    final path = '$dir/$trackId.mp3';
    final dio = await _dioFactory();
    try {
      await dio.download('/api/tracks/$trackId/stream', path);
    } catch (_) {
      final f = File(path);
      if (f.existsSync()) await f.delete();
      return null;
    }
    final size = await File(path).length();
    await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
          AudioCacheIndexCompanion.insert(
            trackId: trackId,
            path: path,
            sizeBytes: size,
            source: source,
            lastPlayedAt: Value(DateTime.now()),
          ),
        );
    return path;
  }

  /// Registers a file LockCachingAudioSource wrote itself. Called
  /// once a track is fully buffered; it's being played, so stamp
  /// lastPlayedAt.
  Future<void> registerStreamCache(
    String trackId,
    String path,
    int sizeBytes, {
    CacheSource source = CacheSource.incidental,
  }) async {
    await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
          AudioCacheIndexCompanion.insert(
            trackId: trackId,
            path: path,
            sizeBytes: sizeBytes,
            source: source,
            lastPlayedAt: Value(DateTime.now()),
          ),
        );
  }

  /// Bumps lastPlayedAt for an already-indexed track so the rolling
  /// LRU + the offline "Recently played" view reflect real plays
  /// (not just download time). No-op if not indexed.
  Future<void> touch(String trackId) async {
    await (_db.update(_db.audioCacheIndex)
          ..where((t) => t.trackId.equals(trackId)))
        .write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now())));
  }

  Future<void> unpin(String trackId) async {
    final row = await (_db.select(_db.audioCacheIndex)
          ..where((t) => t.trackId.equals(trackId)))
        .getSingleOrNull();
    if (row == null) return;
    final f = File(row.path);
    if (f.existsSync()) await f.delete();
    await (_db.delete(_db.audioCacheIndex)
          ..where((t) => t.trackId.equals(trackId)))
        .go();
  }

  /// Total bytes on disk (directory walk — authoritative; catches
  /// orphan partials the index misses).
  Future<int> usageBytes() async {
    final dir = Directory(await _tracksDir());
    if (!await dir.exists()) return 0;
    var total = 0;
    await for (final e in dir.list(followLinks: false)) {
      if (e is File) {
        try {
          total += await e.length();
        } catch (_) {}
      }
    }
    return total;
  }

  /// Bytes per bucket. Indexed rows split by liked-ness; on-disk
  /// files with no index row (orphan partials) count as Rolling so
  /// the rolling cap genuinely bounds disk.
  Future<BucketUsage> bucketUsage(Set<String> liked) async {
    final rows = await _db.select(_db.audioCacheIndex).get();
    final indexed = <String>{};
    var likedB = 0;
    var rollingB = 0;
    for (final r in rows) {
      indexed.add(r.trackId);
      if (liked.contains(r.trackId)) {
        likedB += r.sizeBytes;
      } else {
        rollingB += r.sizeBytes;
      }
    }
    final dir = Directory(await _tracksDir());
    if (await dir.exists()) {
      await for (final e in dir.list(followLinks: false)) {
        if (e is! File) continue;
        final name = e.uri.pathSegments.last;
        final id = name.endsWith('.mp3')
            ? name.substring(0, name.length - 4)
            : name;
        if (indexed.contains(id)) continue;
        try {
          rollingB += await e.length();
        } catch (_) {}
      }
    }
    return (liked: likedB, rolling: rollingB);
  }

  /// Enforces both budgets independently (0 = unlimited).
  ///
  /// Rolling: evict non-liked indexed rows LRU (oldest lastPlayedAt
  /// /cachedAt first), then sweep orphan files oldest-by-mtime, until
  /// rolling usage ≤ rollingCap. Liked: evict liked indexed rows LRU
  /// until ≤ likedCap. Liked is only ever touched by its own (large)
  /// cap, so normal use never evicts the user's liked library.
  Future<void> evictBuckets({
    required int likedCap,
    required int rollingCap,
    required Set<String> liked,
  }) async {
    final usage = await bucketUsage(liked);

    if (rollingCap > 0 && usage.rolling > rollingCap) {
      var over = usage.rolling - rollingCap;
      final rolling = await (_db.select(_db.audioCacheIndex)
            ..where((t) => t.trackId.isNotIn(liked.toList()))
            ..orderBy([
              (t) => OrderingTerm.asc(t.lastPlayedAt),
              (t) => OrderingTerm.asc(t.cachedAt),
            ]))
          .get();
      for (final r in rolling) {
        if (over <= 0) break;
        final f = File(r.path);
        if (f.existsSync()) await f.delete();
        await (_db.delete(_db.audioCacheIndex)
              ..where((t) => t.trackId.equals(r.trackId)))
            .go();
        over -= r.sizeBytes;
      }
      if (over > 0) await _sweepOrphans(over);
    }

    if (likedCap > 0 && usage.liked > likedCap) {
      var over = usage.liked - likedCap;
      final likedRows = await (_db.select(_db.audioCacheIndex)
            ..where((t) => t.trackId.isIn(liked.toList()))
            ..orderBy([
              (t) => OrderingTerm.asc(t.lastPlayedAt),
              (t) => OrderingTerm.asc(t.cachedAt),
            ]))
          .get();
      for (final r in likedRows) {
        if (over <= 0) break;
        final f = File(r.path);
        if (f.existsSync()) await f.delete();
        await (_db.delete(_db.audioCacheIndex)
              ..where((t) => t.trackId.equals(r.trackId)))
            .go();
        over -= r.sizeBytes;
      }
    }
  }

  /// Deletes orphan files (on disk, no index row) oldest-mtime-first
  /// until `over` bytes are reclaimed. These are unindexed partials,
  /// always Rolling, always evict-first.
  Future<void> _sweepOrphans(int over) async {
    final indexed = {
      for (final r in await _db.select(_db.audioCacheIndex).get()) r.trackId
    };
    final dir = Directory(await _tracksDir());
    if (!await dir.exists()) return;
    final orphans = <({File f, int size, DateTime mtime})>[];
    await for (final e in dir.list(followLinks: false)) {
      if (e is! File) continue;
      final name = e.uri.pathSegments.last;
      final id =
          name.endsWith('.mp3') ? name.substring(0, name.length - 4) : name;
      if (indexed.contains(id)) continue;
      try {
        final st = e.statSync();
        orphans.add((f: e, size: st.size, mtime: st.modified));
      } catch (_) {}
    }
    orphans.sort((a, b) => a.mtime.compareTo(b.mtime));
    var remaining = over;
    for (final o in orphans) {
      if (remaining <= 0) break;
      try {
        await o.f.delete();
        remaining -= o.size;
      } catch (_) {}
    }
  }

  /// Clears EVERY row + EVERY file. Wired to "Clear cache".
  Future<void> clearAll() async {
    final dir = await _tracksDir();
    final d = Directory(dir);
    if (d.existsSync()) {
      for (final e in d.listSync()) {
        if (e is File) await e.delete();
      }
    }
    await _db.delete(_db.audioCacheIndex).go();
  }
}

/// AppDb singleton. One per app run; ref.onDispose closes it.
final appDbProvider = Provider<AppDb>((ref) {
  final db = AppDb();
  ref.onDispose(db.close);
  return db;
});

final audioCacheManagerProvider = Provider<AudioCacheManager>((ref) {
  final db = ref.watch(appDbProvider);
  return AudioCacheManager(
    db: db,
    dioFactory: () async => ref.read(dioProvider.future),
  );
});
