import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/utils/progress_stream_poller.dart';

final _log = AppLogger('LocalLibrary');

const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
final _prefs = SharedPreferences.getInstance();

class LocalLibraryState {
  final bool isScanning;
  final bool scanIsFinalizing;
  final double scanProgress;
  final String? scanCurrentFile;
  final int scanTotalFiles;
  final int scannedFiles;
  final int scanErrorCount;
  final bool scanWasCancelled;
  final int totalCount;
  final int loadedIndexVersion;
  final DateTime? lastScannedAt;
  final List<LocalLibrarySource> sources;
  final String? scanningSourceId;
  final int excludedDownloadedCount;
  final Set<String> _trackKeySet;
  final Set<String> _isrcSet;

  LocalLibraryState({
    this.isScanning = false,
    this.scanIsFinalizing = false,
    this.scanProgress = 0,
    this.scanCurrentFile,
    this.scanTotalFiles = 0,
    this.scannedFiles = 0,
    this.scanErrorCount = 0,
    this.scanWasCancelled = false,
    this.totalCount = 0,
    this.loadedIndexVersion = 0,
    this.lastScannedAt,
    this.sources = const <LocalLibrarySource>[],
    this.scanningSourceId,
    this.excludedDownloadedCount = 0,
    Set<String>? trackKeySet,
    Set<String>? isrcSet,
  }) : _trackKeySet = trackKeySet ?? const <String>{},
       _isrcSet = isrcSet ?? const <String>{};

  bool hasIsrc(String isrc) => _isrcSet.contains(isrc);

  bool hasTrack(String trackName, String artistName) {
    final key = LibraryDatabase.matchKeyFor(trackName, artistName);
    return _trackKeySet.contains(key);
  }

  bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
    if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) {
      return true;
    }
    if (trackName != null && artistName != null) {
      return hasTrack(trackName, artistName);
    }
    return false;
  }

  LocalLibraryState copyWith({
    bool? isScanning,
    bool? scanIsFinalizing,
    double? scanProgress,
    String? scanCurrentFile,
    int? scanTotalFiles,
    int? scannedFiles,
    int? scanErrorCount,
    bool? scanWasCancelled,
    int? totalCount,
    int? loadedIndexVersion,
    DateTime? lastScannedAt,
    bool clearLastScannedAt = false,
    List<LocalLibrarySource>? sources,
    String? scanningSourceId,
    bool clearScanningSourceId = false,
    int? excludedDownloadedCount,
    Set<String>? trackKeySet,
    Set<String>? isrcSet,
  }) {
    return LocalLibraryState(
      isScanning: isScanning ?? this.isScanning,
      scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing,
      scanProgress: scanProgress ?? this.scanProgress,
      scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
      scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
      scannedFiles: scannedFiles ?? this.scannedFiles,
      scanErrorCount: scanErrorCount ?? this.scanErrorCount,
      scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
      totalCount: totalCount ?? this.totalCount,
      loadedIndexVersion: loadedIndexVersion ?? this.loadedIndexVersion,
      lastScannedAt: clearLastScannedAt
          ? null
          : lastScannedAt ?? this.lastScannedAt,
      sources: sources ?? this.sources,
      scanningSourceId: clearScanningSourceId
          ? null
          : scanningSourceId ?? this.scanningSourceId,
      excludedDownloadedCount:
          excludedDownloadedCount ?? this.excludedDownloadedCount,
      trackKeySet: trackKeySet ?? _trackKeySet,
      isrcSet: isrcSet ?? _isrcSet,
    );
  }
}

class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
  final LibraryDatabase _db = LibraryDatabase.instance;
  final HistoryDatabase _historyDb = HistoryDatabase.instance;
  final NotificationService _notificationService = NotificationService();
  static const _progressPollingInterval = Duration(milliseconds: 350);
  static const _progressStreamBootstrapTimeout = Duration(milliseconds: 900);
  late final ProgressStreamPoller<Map<String, dynamic>> _progressPoller =
      ProgressStreamPoller<Map<String, dynamic>>(
        streamProvider: PlatformBridge.libraryScanProgressStream,
        pollProvider: PlatformBridge.getLibraryScanProgress,
        onProgress: _handleLibraryScanProgress,
        pollingInterval: _progressPollingInterval,
        bootstrapTimeout: _progressStreamBootstrapTimeout,
        onStreamProcessingError: (e) =>
            _log.w('Library scan progress stream processing failed: $e'),
        onStreamFailed: (error) => _log.w(
          'Library scan progress stream failed, fallback to polling: $error',
        ),
        onStreamTimeout: () =>
            _log.w('Library scan progress stream timeout, fallback to polling'),
        onPollError: (e) => _log.w('Library scan progress polling failed: $e'),
      );
  bool _isLoaded = false;
  bool _hasLoadedFromDatabase = false;
  Future<void>? _loadFuture;
  bool _scanCancelRequested = false;
  bool _scanInProgress = false;
  StreamSubscription<void>? _storageEventsSubscription;
  Timer? _storageEventDebounce;
  static const _scanNotificationHeartbeat = Duration(seconds: 4);
  int _lastScanNotificationPercent = -1;
  int _lastScanNotificationTotalFiles = -1;
  DateTime _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0);

  @override
  LocalLibraryState build() {
    ref.onDispose(() {
      _progressPoller.stop();
      _storageEventsSubscription?.cancel();
      _storageEventDebounce?.cancel();
    });

    if (Platform.isAndroid) {
      _storageEventsSubscription = PlatformBridge.libraryStorageEvents().listen(
        (_) {
          _storageEventDebounce?.cancel();
          _storageEventDebounce = Timer(
            const Duration(milliseconds: 900),
            () => refreshSourceAvailability(scanReconnected: true),
          );
        },
        onError: (Object e) =>
            _log.w('Library storage event stream failed: $e'),
      );
    }

    Future.microtask(_ensureLoadedFromDatabase);
    return LocalLibraryState();
  }

  Future<void> _ensureLoadedFromDatabase() {
    if (_hasLoadedFromDatabase) {
      return Future<void>.value();
    }
    return _loadFuture ??= _loadFromDatabase();
  }

  Future<void> _loadFromDatabase() async {
    if (_hasLoadedFromDatabase) return;
    if (_isLoaded) {
      return _loadFuture ?? Future<void>.value();
    }
    _isLoaded = true;

    try {
      await _migrateLegacySource();
      final reconnectedSources = await _refreshSourceAvailabilityInDatabase();
      final countFuture = _db.getCount();
      final indexFuture = _db.getLookupIndex();
      final sourcesFuture = _db.getSources();
      final prefsFuture = _prefs;
      final count = await countFuture;
      final lookupIndex = await indexFuture;
      final sources = await sourcesFuture;

      DateTime? lastScannedAt;
      var excludedDownloadedCount = 0;
      try {
        final prefs = await prefsFuture;
        lastScannedAt = readLocalLibraryLastScannedAt(prefs);
        excludedDownloadedCount =
            prefs.getInt(_excludedDownloadedCountKey) ?? 0;
      } catch (e) {
        _log.w('Failed to load lastScannedAt: $e');
      }

      state = state.copyWith(
        totalCount: count,
        loadedIndexVersion: state.loadedIndexVersion + 1,
        lastScannedAt: lastScannedAt,
        excludedDownloadedCount: excludedDownloadedCount,
        sources: sources,
        trackKeySet: lookupIndex.matchKeys,
        isrcSet: lookupIndex.isrcs,
      );
      _log.i(
        'Loaded local library summary: $count items, lastScannedAt: '
        '$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
      );
      _hasLoadedFromDatabase = true;
      if (reconnectedSources.isNotEmpty) {
        unawaited(
          Future<void>.microtask(
            () => _scanSourcesSequentially(reconnectedSources),
          ),
        );
      }
    } catch (e, stack) {
      _isLoaded = false;
      _log.e('Failed to load library from database: $e', e, stack);
    } finally {
      _loadFuture = null;
    }
  }

  Future<void> reloadFromStorage() async {
    _isLoaded = false;
    _hasLoadedFromDatabase = false;
    _loadFuture = null;
    await _ensureLoadedFromDatabase();
  }

  Future<LocalLibrarySource> addSource({
    required String path,
    required String displayName,
    String? bookmark,
    String? volumeId,
    bool isRemovable = false,
  }) async {
    final trimmedPath = path.trim();
    if (trimmedPath.isEmpty) {
      throw const FormatException('Library folder path is empty');
    }
    final existing = await _db.getSourceByPath(trimmedPath);
    if (existing != null) {
      await _db.updateSourceState(
        existing.id,
        enabled: true,
        available: true,
        lastSeenAt: DateTime.now(),
      );
      await _refreshSummaryFromStorage();
      return existing.copyWith(
        enabled: true,
        available: true,
        lastSeenAt: DateTime.now(),
      );
    }

    final source = LocalLibrarySource(
      id: _newSourceId(trimmedPath),
      path: trimmedPath,
      displayName: displayName.trim().isEmpty
          ? _displayNameForPath(trimmedPath)
          : displayName.trim(),
      bookmark: bookmark?.trim().isEmpty == true ? null : bookmark,
      volumeId: volumeId?.trim().isEmpty == true ? null : volumeId,
      isRemovable: isRemovable || _looksLikeRemovablePath(trimmedPath),
      enabled: true,
      available: true,
      lastSeenAt: DateTime.now(),
    );
    await _db.upsertSource(source);
    await _refreshSummaryFromStorage();
    return source;
  }

  String _newSourceId(String path) {
    const offset = 0xcbf29ce484222325;
    const prime = 0x100000001b3;
    const mask = 0xffffffffffffffff;
    var hash = offset;
    for (final unit in path.codeUnits) {
      hash = ((hash ^ unit) * prime) & mask;
    }
    return 'source_${hash.toRadixString(16).padLeft(16, '0')}';
  }

  Future<void> setSourceEnabled(String sourceId, bool enabled) async {
    final source = state.sources
        .where((entry) => entry.id == sourceId)
        .firstOrNull;
    await _db.updateSourceState(sourceId, enabled: enabled);
    await _refreshSummaryFromStorage();
    if (enabled &&
        source?.available == true &&
        source?.isIndexed == true &&
        !_scanInProgress) {
      unawaited(startSourceScan(sourceId));
    }
  }

  Future<void> removeSource(String sourceId) async {
    await _db.removeSource(sourceId);
    await _refreshSummaryFromStorage();
  }

  Future<void> refreshSourceAvailability({bool scanReconnected = false}) async {
    await _ensureLoadedFromDatabase();
    final before = await _db.getSources();
    final reconnected = <String>[];
    for (final source in before) {
      final available = await _isPathAvailable(
        source.path,
        bookmark: source.bookmark,
      );
      if (available != source.available) {
        await _db.updateSourceState(
          source.id,
          available: available,
          lastSeenAt: available ? DateTime.now() : null,
        );
        if (available && source.enabled && source.isIndexed) {
          reconnected.add(source.id);
        }
      }
    }
    await _refreshSummaryFromStorage();

    // Existing rows become visible as soon as availability flips. The
    // incremental pass then verifies changes made while storage was detached
    // without re-reading metadata for unchanged files.
    if (scanReconnected && reconnected.isNotEmpty && !_scanInProgress) {
      unawaited(_scanSourcesSequentially(reconnected));
    }
  }

  Future<void> _scanSourcesSequentially(
    Iterable<String> sourceIds, {
    bool forceFullScan = false,
  }) async {
    for (final sourceId in sourceIds) {
      await startSourceScan(sourceId, forceFullScan: forceFullScan);
      if (_scanCancelRequested) break;
    }
  }

  Future<void> scanAllSources({bool forceFullScan = false}) async {
    await _ensureLoadedFromDatabase();
    final sourceIds = state.sources
        .where((source) => source.enabled && source.available)
        .map((source) => source.id)
        .toList(growable: false);
    await _scanSourcesSequentially(sourceIds, forceFullScan: forceFullScan);
  }

  Future<void> startSourceScan(
    String sourceId, {
    bool forceFullScan = false,
  }) async {
    await _ensureLoadedFromDatabase();
    final source = (await _db.getSources())
        .where((entry) => entry.id == sourceId)
        .firstOrNull;
    if (source == null || !source.enabled || !source.available) return;
    await startScan(
      source.path,
      forceFullScan: forceFullScan,
      iosBookmark: source.bookmark,
      sourceId: source.id,
    );
  }

  Future<void> _refreshSummaryFromStorage({
    DateTime? lastScannedAt,
    int? excludedDownloadedCount,
  }) async {
    final countFuture = _db.getCount();
    final indexFuture = _db.getLookupIndex();
    final sourcesFuture = _db.getSources();
    final count = await countFuture;
    final index = await indexFuture;
    final sources = await sourcesFuture;
    final latestSourceScan = sources
        .map((source) => source.lastScannedAt)
        .whereType<DateTime>()
        .fold<DateTime?>(
          null,
          (latest, value) =>
              latest == null || value.isAfter(latest) ? value : latest,
        );
    state = state.copyWith(
      totalCount: count,
      loadedIndexVersion: state.loadedIndexVersion + 1,
      lastScannedAt: lastScannedAt ?? latestSourceScan,
      excludedDownloadedCount: excludedDownloadedCount,
      sources: sources,
      trackKeySet: index.matchKeys,
      isrcSet: index.isrcs,
    );
    _hasLoadedFromDatabase = true;
    _isLoaded = true;
  }

  Future<void> _migrateLegacySource() async {
    final settings = ref.read(settingsProvider);
    final path = settings.localLibraryPath.trim();
    if (path.isEmpty) return;
    if (await _db.getSourceByPath(path) != null) return;
    DateTime? legacyLastScannedAt;
    try {
      legacyLastScannedAt = readLocalLibraryLastScannedAt(await _prefs);
    } catch (_) {}
    await _db.migrateLegacySource(
      path: path,
      displayName: _displayNameForPath(path),
      bookmark: settings.localLibraryBookmark.trim().isEmpty
          ? null
          : settings.localLibraryBookmark,
      isRemovable: _looksLikeRemovablePath(path),
      available: await _isPathAvailable(
        path,
        bookmark: settings.localLibraryBookmark,
      ),
      lastScannedAt: legacyLastScannedAt,
    );
  }

  String _displayNameForPath(String path) {
    if (path.startsWith('content://')) {
      try {
        final decoded = Uri.decodeComponent(Uri.parse(path).pathSegments.last);
        final relative = decoded.contains(':')
            ? decoded.substring(decoded.indexOf(':') + 1)
            : decoded;
        if (relative.trim().isNotEmpty) {
          return relative.split('/').last;
        }
      } catch (_) {}
      return 'Music';
    }
    final normalized = path
        .replaceAll('\\', '/')
        .replaceAll(RegExp(r'/+$'), '');
    final name = normalized.split('/').last;
    return name.isEmpty ? path : name;
  }

  bool _looksLikeRemovablePath(String path) {
    if (path.startsWith('content://')) {
      try {
        final decoded = Uri.decodeComponent(Uri.parse(path).pathSegments.last);
        return !decoded.startsWith('primary:');
      } catch (_) {
        return false;
      }
    }
    final normalized = path.replaceAll('\\', '/').toLowerCase();
    return normalized.startsWith('/storage/') &&
        !normalized.startsWith('/storage/emulated/');
  }

  Future<bool> _isPathAvailable(String path, {String? bookmark}) async {
    if (Platform.isAndroid && path.startsWith('content://')) {
      return PlatformBridge.isSafTreeAccessible(path);
    }
    if (Platform.isIOS && bookmark != null && bookmark.trim().isNotEmpty) {
      final access = await PlatformBridge.startAccessingIosBookmark(bookmark);
      if (access == null) return false;
      try {
        return await Directory(access.path).exists();
      } finally {
        await PlatformBridge.stopAccessingIosBookmark(access);
      }
    }
    return await Directory(path).exists();
  }

  Future<List<String>> _refreshSourceAvailabilityInDatabase() async {
    final sources = await _db.getSources();
    final reconnected = <String>[];
    for (final source in sources) {
      final available = await _isPathAvailable(
        source.path,
        bookmark: source.bookmark,
      );
      if (available != source.available) {
        await _db.updateSourceState(
          source.id,
          available: available,
          lastSeenAt: available ? DateTime.now() : null,
        );
        if (available && source.enabled && source.isIndexed) {
          reconnected.add(source.id);
        }
      }
    }
    return reconnected;
  }

  bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
    if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
      return false;
    }
    final candidateKeys = buildPathMatchKeys(filePath);
    for (final key in candidateKeys) {
      if (downloadedPathKeys.contains(key)) {
        return true;
      }
    }
    return false;
  }

  Future<({int inserted, int skipped})?> _replaceFromFullScanStream({
    required String sourceId,
    required String folderPath,
    required bool isSaf,
    required Set<String> downloadedPathKeys,
  }) async {
    if (_scanCancelRequested) return null;
    final scanFile = isSaf
        ? await PlatformBridge.scanSafTreeToNDJSONFile(
            folderPath,
            isCancelled: () => _scanCancelRequested,
          )
        : await PlatformBridge.scanLibraryFolderToNDJSONFile(
            folderPath,
            isCancelled: () => _scanCancelRequested,
          );
    var skipped = 0;
    try {
      if (_scanCancelRequested) return null;
      state = state.copyWith(
        scanIsFinalizing: true,
        scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
        scanCurrentFile: null,
      );
      Stream<Map<String, dynamic>> filteredRows() async* {
        var decodedRows = 0;
        await for (final json in scanFile.rows()) {
          if (_scanCancelRequested) {
            throw StateError('Library scan cancelled during ingestion');
          }
          decodedRows++;
          final filePath = json['filePath'] as String?;
          if (_isDownloadedPath(filePath, downloadedPathKeys)) {
            skipped++;
            continue;
          }
          yield json;
        }
        if (decodedRows != scanFile.expectedCount) {
          throw FormatException(
            'Library scan row count mismatch: decoded $decodedRows, '
            'expected ${scanFile.expectedCount}',
          );
        }
      }

      final inserted = await _db.replaceSourceStream(sourceId, filteredRows());
      _log.i(
        'Stream-ingested $inserted/${scanFile.expectedCount} scan rows '
        '($skipped downloads excluded)',
      );
      return (inserted: inserted, skipped: skipped);
    } finally {
      await scanFile.delete();
    }
  }

  Future<void> startScan(
    String folderPath, {
    bool forceFullScan = false,
    String? iosBookmark,
    String? sourceId,
  }) async {
    if (_scanInProgress || state.isScanning) {
      _log.w('Scan already in progress');
      return;
    }

    var activeSourceId = sourceId;
    if (activeSourceId == null) {
      final existingSource = await _db.getSourceByPath(folderPath);
      activeSourceId = existingSource?.id;
      if (activeSourceId == null) {
        final source = await addSource(
          path: folderPath,
          displayName: _displayNameForPath(folderPath),
          bookmark: iosBookmark,
          isRemovable: _looksLikeRemovablePath(folderPath),
        );
        activeSourceId = source.id;
      }
    }

    if (!await _isPathAvailable(folderPath, bookmark: iosBookmark)) {
      await _db.updateSourceState(activeSourceId, available: false);
      await _refreshSummaryFromStorage();
      return;
    }

    _scanInProgress = true;
    _scanCancelRequested = false;
    _log.i(
      'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
    );
    state = state.copyWith(
      isScanning: true,
      scanIsFinalizing: false,
      scanProgress: 0,
      scanCurrentFile: null,
      scanTotalFiles: 0,
      scannedFiles: 0,
      scanErrorCount: 0,
      scanWasCancelled: false,
      scanningSourceId: activeSourceId,
    );
    _resetScanNotificationTracking();
    if (_shouldShowScanProgressNotification(
      progress: 0,
      totalFiles: 0,
      isComplete: false,
    )) {
      await _showScanProgressNotification(
        progress: 0,
        scannedFiles: 0,
        totalFiles: 0,
        currentFile: null,
      );
    }

    try {
      final appSupportDir = await getApplicationSupportDirectory();
      final coverCacheDir = '${appSupportDir.path}/library_covers';
      await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
      _log.i('Cover cache directory set to: $coverCacheDir');
    } catch (e) {
      _log.w('Failed to set cover cache directory: $e');
    }

    _startProgressPolling();

    String? resolvedPath;
    IosSecurityScopedAccess? securityAccess;
    if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
      securityAccess = await PlatformBridge.startAccessingIosBookmark(
        iosBookmark,
      );
      resolvedPath = securityAccess?.path;
      if (securityAccess != null) {
        _log.i('Started iOS security-scoped access: $resolvedPath');
      } else {
        _log.w(
          'Failed to start iOS security-scoped access, '
          'falling back to original path',
        );
      }
    }
    final effectiveFolderPath = resolvedPath ?? folderPath;

    try {
      final isSaf = effectiveFolderPath.startsWith('content://');

      final downloadedPaths = await _historyDb.getAllFilePaths();
      final inMemoryHistoryPaths = ref
          .read(downloadHistoryProvider)
          .items
          .map((item) => item.filePath)
          .where((path) => path.isNotEmpty);
      final allHistoryPaths = <String>{
        ...downloadedPaths,
        ...inMemoryHistoryPaths,
      };
      final downloadedPathKeys = <String>{};
      for (final path in allHistoryPaths) {
        downloadedPathKeys.addAll(buildPathMatchKeys(path));
      }
      _log.i(
        'Excluding ${allHistoryPaths.length} downloaded files from library scan '
        '(${downloadedPathKeys.length} path keys)',
      );

      final useStreamingFullScan =
          forceFullScan || await _db.getSourceCount(activeSourceId) == 0;
      if (useStreamingFullScan) {
        final scanResult = await _replaceFromFullScanStream(
          sourceId: activeSourceId,
          folderPath: effectiveFolderPath,
          isSaf: isSaf,
          downloadedPathKeys: downloadedPathKeys,
        );
        if (scanResult == null || _scanCancelRequested) {
          state = state.copyWith(
            isScanning: false,
            scanIsFinalizing: false,
            scanWasCancelled: true,
          );
          await _showScanCancelledNotification();
          return;
        }

        state = state.copyWith(
          scanIsFinalizing: true,
          scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
          scanCurrentFile: null,
        );

        final skippedDownloads = scanResult.skipped;

        if (skippedDownloads > 0) {
          _log.i('Skipped $skippedDownloads files already in download history');
        }

        final now = DateTime.now();
        await _db.updateSourceState(
          activeSourceId,
          available: true,
          lastSeenAt: now,
          lastScannedAt: now,
          clearLastScanError: true,
        );
        try {
          final prefs = await SharedPreferences.getInstance();
          await writeLocalLibraryLastScannedAt(prefs, now);
          await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
          _log.d('Saved lastScannedAt: $now');
        } catch (e) {
          _log.w('Failed to save lastScannedAt: $e');
        }

        await _refreshSummaryFromStorage(
          lastScannedAt: now,
          excludedDownloadedCount: skippedDownloads,
        );
        state = state.copyWith(
          isScanning: false,
          scanIsFinalizing: false,
          scanProgress: 100,
          lastScannedAt: now,
          scanWasCancelled: false,
          excludedDownloadedCount: skippedDownloads,
        );
        await _pruneLibraryCoverCache();

        _log.i(
          'Full scan complete: ${state.totalCount} tracks found, '
          '$skippedDownloads already in downloads',
        );
        await _showScanCompleteNotification(
          totalTracks: state.totalCount,
          excludedDownloadedCount: skippedDownloads,
          errorCount: state.scanErrorCount,
        );
      } else {
        final existingFiles = await _db.getFileModTimes(
          sourceId: activeSourceId,
        );
        _log.i(
          'Incremental scan: ${existingFiles.length} existing files in database',
        );

        final backfilledModTimes = await _backfillLegacyFileModTimes(
          isSaf: isSaf,
          existingFiles: existingFiles,
        );
        if (backfilledModTimes.isNotEmpty) {
          await _db.updateFileModTimes(backfilledModTimes);
          existingFiles.addAll(backfilledModTimes);
          _log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
        }

        final useSnapshotBridge =
            Platform.isAndroid && existingFiles.isNotEmpty;
        final snapshotPath = useSnapshotBridge
            ? await _db.writeFileModTimesSnapshot(sourceId: activeSourceId)
            : null;

        Map<String, dynamic> result;
        try {
          if (_scanCancelRequested) {
            throw StateError('Library scan cancelled before native scan');
          }
          if (isSaf) {
            result = useSnapshotBridge && snapshotPath != null
                ? await PlatformBridge.scanSafTreeIncrementalFromSnapshot(
                    effectiveFolderPath,
                    snapshotPath,
                  )
                : await PlatformBridge.scanSafTreeIncremental(
                    effectiveFolderPath,
                    existingFiles,
                  );
          } else {
            result = useSnapshotBridge && snapshotPath != null
                ? await PlatformBridge.scanLibraryFolderIncrementalFromSnapshot(
                    effectiveFolderPath,
                    snapshotPath,
                  )
                : await PlatformBridge.scanLibraryFolderIncremental(
                    effectiveFolderPath,
                    existingFiles,
                  );
          }
        } finally {
          if (snapshotPath != null) {
            try {
              await File(snapshotPath).delete();
            } catch (_) {}
          }
        }

        if (_scanCancelRequested) {
          state = state.copyWith(
            isScanning: false,
            scanIsFinalizing: false,
            scanWasCancelled: true,
          );
          await _showScanCancelledNotification();
          return;
        }

        state = state.copyWith(
          scanIsFinalizing: true,
          scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
          scanCurrentFile: null,
        );

        final scannedList =
            (result['files'] as List<dynamic>?) ??
            (result['scanned'] as List<dynamic>?) ??
            [];
        final deletedPaths =
            (result['removedUris'] as List<dynamic>?)
                ?.map((e) => e as String)
                .toList() ??
            (result['deletedPaths'] as List<dynamic>?)
                ?.map((e) => e as String)
                .toList() ??
            [];
        final skippedCount = result['skippedCount'] as int? ?? 0;
        final totalFiles = result['totalFiles'] as int? ?? 0;

        _log.i(
          'Incremental result: ${scannedList.length} scanned, '
          '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
        );

        final existingPaths = existingFiles.keys.toList(growable: false);
        final existingDownloadedPaths = <String>[];
        for (final path in existingPaths) {
          if (_isDownloadedPath(path, downloadedPathKeys)) {
            existingDownloadedPaths.add(path);
          }
        }
        if (existingDownloadedPaths.isNotEmpty) {
          final removed = await _db.deleteByPaths(existingDownloadedPaths);
          _log.i(
            'Removed $removed downloaded tracks already present in local library index',
          );
        }

        final updatedItems = <LocalLibraryItem>[];
        int skippedDownloads = existingDownloadedPaths.length;
        if (scannedList.isNotEmpty) {
          for (final json in scannedList) {
            final map = json as Map<String, dynamic>;
            final filePath = map['filePath'] as String?;
            if (_isDownloadedPath(filePath, downloadedPathKeys)) {
              skippedDownloads++;
              continue;
            }
            final item = LocalLibraryItem.fromJson(map);
            updatedItems.add(item);
          }
          if (updatedItems.isNotEmpty) {
            await _db.upsertBatch(
              updatedItems.map((e) => e.toJson()).toList(),
              sourceId: activeSourceId,
            );
            _log.i('Upserted ${updatedItems.length} items');
          }
          if (skippedDownloads > 0) {
            _log.i(
              'Skipped $skippedDownloads files already in download history',
            );
          }
        }

        if (deletedPaths.isNotEmpty) {
          final deleteCount = await _db.deleteByPaths(deletedPaths);
          _log.i('Deleted $deleteCount items from database');
        }

        final now = DateTime.now();
        await _db.updateSourceState(
          activeSourceId,
          available: true,
          lastSeenAt: now,
          lastScannedAt: now,
          clearLastScanError: true,
        );
        try {
          final prefs = await SharedPreferences.getInstance();
          await writeLocalLibraryLastScannedAt(prefs, now);
          await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
          _log.d('Saved lastScannedAt: $now');
        } catch (e) {
          _log.w('Failed to save lastScannedAt: $e');
        }

        await _refreshSummaryFromStorage(
          lastScannedAt: now,
          excludedDownloadedCount: skippedDownloads,
        );
        state = state.copyWith(
          isScanning: false,
          scanIsFinalizing: false,
          scanProgress: 100,
          lastScannedAt: now,
          scanWasCancelled: false,
          excludedDownloadedCount: skippedDownloads,
        );

        _log.i(
          'Incremental scan complete: ${state.totalCount} total tracks '
          '(${scannedList.length} new/updated, $skippedCount unchanged, '
          '${deletedPaths.length} removed, $skippedDownloads already in downloads)',
        );
        await _showScanCompleteNotification(
          totalTracks: state.totalCount,
          excludedDownloadedCount: skippedDownloads,
          errorCount: state.scanErrorCount,
        );
      }
    } catch (e, stack) {
      if (_scanCancelRequested) {
        _log.i('Library scan cancelled');
        state = state.copyWith(
          isScanning: false,
          scanIsFinalizing: false,
          scanWasCancelled: true,
        );
        await _showScanCancelledNotification();
        return;
      }
      _log.e('Library scan failed: $e', e, stack);
      await _db.updateSourceState(activeSourceId, lastScanError: e.toString());
      await _refreshSummaryFromStorage();
      state = state.copyWith(
        isScanning: false,
        scanIsFinalizing: false,
        scanWasCancelled: false,
      );
      await _showScanFailedNotification(e.toString());
    } finally {
      if (securityAccess != null) {
        await PlatformBridge.stopAccessingIosBookmark(securityAccess);
        _log.i('Stopped iOS security-scoped access');
      }
      _stopProgressPolling();
      _scanInProgress = false;
      state = state.copyWith(clearScanningSourceId: true);
    }
  }

  void _startProgressPolling() {
    final useStream = Platform.isAndroid || Platform.isIOS;
    _progressPoller.start(useStream: useStream);
    if (useStream) {
      Future<void>.microtask(
        () => _progressPoller.pollOnce(
          (e) => _log.w('Initial library scan progress fetch failed: $e'),
        ),
      );
    }
  }

  Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
    if (_scanCancelRequested) return;
    final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
    final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
      0.0,
      100.0,
    );
    final isComplete = progress['is_complete'] == true;
    final displayProgress = isComplete
        ? 99.0
        : (normalizedProgress >= 100.0 ? 99.0 : normalizedProgress);
    final currentFile = progress['current_file'] as String?;
    final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
    final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
    final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;

    final shouldUpdateState =
        state.scanProgress != displayProgress ||
        state.scanIsFinalizing != isComplete ||
        state.scanCurrentFile != currentFile ||
        state.scanTotalFiles != totalFiles ||
        state.scannedFiles != scannedFiles ||
        state.scanErrorCount != errorCount;

    if (shouldUpdateState) {
      state = state.copyWith(
        scanIsFinalizing: isComplete,
        scanProgress: displayProgress,
        scanCurrentFile: isComplete ? null : currentFile,
        scanTotalFiles: totalFiles,
        scannedFiles: scannedFiles,
        scanErrorCount: errorCount,
      );
    }

    if (_shouldShowScanProgressNotification(
      progress: normalizedProgress,
      totalFiles: totalFiles,
      isComplete: isComplete,
    )) {
      await _showScanProgressNotification(
        progress: normalizedProgress,
        scannedFiles: scannedFiles,
        totalFiles: totalFiles,
        currentFile: currentFile,
      );
    }

    if (isComplete) {
      _stopProgressPolling();
    }
  }

  void _stopProgressPolling() {
    _progressPoller.stop();
    _resetScanNotificationTracking();
  }

  void _resetScanNotificationTracking() {
    _lastScanNotificationPercent = -1;
    _lastScanNotificationTotalFiles = -1;
    _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0);
  }

  bool _shouldShowScanProgressNotification({
    required double progress,
    required int totalFiles,
    required bool isComplete,
  }) {
    final now = DateTime.now();
    final percent = progress.round().clamp(0, 100);
    final percentChanged = percent != _lastScanNotificationPercent;
    final totalFilesChanged = totalFiles != _lastScanNotificationTotalFiles;
    final heartbeatDue =
        now.difference(_lastScanNotificationAt) >= _scanNotificationHeartbeat;

    if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) {
      return false;
    }

    _lastScanNotificationPercent = percent;
    _lastScanNotificationTotalFiles = totalFiles;
    _lastScanNotificationAt = now;
    return true;
  }

  Future<void> cancelScan() async {
    if (!state.isScanning) return;

    _log.i('Cancelling library scan');
    _scanCancelRequested = true;
    await PlatformBridge.cancelLibraryScan();
    state = state.copyWith(scanIsFinalizing: false, scanWasCancelled: true);
    _stopProgressPolling();
    await _showScanCancelledNotification();
  }

  Future<void> _showScanProgressNotification({
    required double progress,
    required int scannedFiles,
    required int totalFiles,
    required String? currentFile,
  }) async {
    try {
      await _notificationService.showLibraryScanProgress(
        progress: progress,
        scannedFiles: scannedFiles,
        totalFiles: totalFiles,
        currentFile: _shortenFileForNotification(currentFile),
      );
    } catch (e) {
      _log.w('Failed to show scan progress notification: $e');
    }
  }

  Future<void> _showScanCompleteNotification({
    required int totalTracks,
    required int excludedDownloadedCount,
    required int errorCount,
  }) async {
    try {
      await _notificationService.showLibraryScanComplete(
        totalTracks: totalTracks,
        excludedDownloadedCount: excludedDownloadedCount,
        errorCount: errorCount,
      );
    } catch (e) {
      _log.w('Failed to show scan complete notification: $e');
    }
  }

  Future<void> _showScanFailedNotification(String message) async {
    try {
      await _notificationService.showLibraryScanFailed(message);
    } catch (e) {
      _log.w('Failed to show scan failure notification: $e');
    }
  }

  Future<void> _showScanCancelledNotification() async {
    try {
      await _notificationService.showLibraryScanCancelled();
    } catch (e) {
      _log.w('Failed to show scan cancelled notification: $e');
    }
  }

  String? _shortenFileForNotification(String? path) {
    final raw = path?.trim() ?? '';
    if (raw.isEmpty) return null;

    var decoded = raw;
    try {
      decoded = Uri.decodeFull(raw);
    } catch (_) {}

    final slashIdx = decoded.lastIndexOf('/');
    final backslashIdx = decoded.lastIndexOf('\\');
    final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx;
    if (cut >= 0 && cut < decoded.length - 1) {
      return decoded.substring(cut + 1);
    }
    return decoded;
  }

  Future<int> cleanupMissingFiles({String? iosBookmark}) async {
    await refreshSourceAvailability();
    var removed = 0;
    for (final source in state.sources) {
      if (!source.enabled || !source.available) continue;
      IosSecurityScopedAccess? securityAccess;
      try {
        if (Platform.isIOS &&
            source.bookmark != null &&
            source.bookmark!.isNotEmpty) {
          securityAccess = await PlatformBridge.startAccessingIosBookmark(
            source.bookmark!,
          );
          if (securityAccess == null) continue;
        }
        removed += await _db.cleanupMissingFiles(sourceId: source.id);
      } finally {
        if (securityAccess != null) {
          await PlatformBridge.stopAccessingIosBookmark(securityAccess);
        }
      }
    }
    if (removed > 0) await _refreshSummaryFromStorage();
    return removed;
  }

  Future<void> clearLibrary() async {
    await _db.clearAll();

    try {
      final prefs = await SharedPreferences.getInstance();
      await clearLocalLibraryLastScannedAt(prefs);
      await prefs.remove(_excludedDownloadedCountKey);
    } catch (e) {
      _log.w('Failed to clear lastScannedAt: $e');
    }

    await _refreshSummaryFromStorage();
    state = state.copyWith(clearLastScannedAt: true);
    _log.i('Library cleared');
  }

  Future<void> _pruneLibraryCoverCache() async {
    try {
      final appSupportDir = await getApplicationSupportDirectory();
      final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
      if (!await libraryCoverDir.exists()) {
        return;
      }

      final referencedCoverPaths = <String>{};
      var offset = 0;
      const pageSize = 500;
      while (true) {
        final page = await _db.getCoverPaths(limit: pageSize, offset: offset);
        if (page.isEmpty) break;
        referencedCoverPaths.addAll(page);
        if (page.length < pageSize) break;
        offset += pageSize;
      }

      var deletedCount = 0;
      await for (final entity in libraryCoverDir.list(
        recursive: true,
        followLinks: false,
      )) {
        if (entity is! File || referencedCoverPaths.contains(entity.path)) {
          continue;
        }
        try {
          await entity.delete();
          deletedCount++;
        } catch (e) {
          _log.w(
            'Failed deleting stale library cover cache ${entity.path}: $e',
          );
        }
      }

      if (deletedCount > 0) {
        _log.i('Pruned $deletedCount stale library cover cache files');
      }
    } catch (e) {
      _log.w('Failed pruning library cover cache: $e');
    }
  }

  Future<void> removeItem(String id) async {
    await _db.delete(id);
    await _refreshSummaryFromStorage();
  }

  Future<LocalLibraryItem?> getById(String id) async {
    final json = await _db.getById(id);
    return json == null ? null : LocalLibraryItem.fromJson(json);
  }

  Future<LocalLibraryItem?> getByIsrcAsync(String isrc) async {
    final json = await _db.getByIsrc(isrc);
    return json == null ? null : LocalLibraryItem.fromJson(json);
  }

  Future<LocalLibraryItem?> findByTrackAndArtistAsync(
    String trackName,
    String artistName,
  ) async {
    final json = await _db.findFirstByTrackAndArtist(trackName, artistName);
    return json == null ? null : LocalLibraryItem.fromJson(json);
  }

  Future<LocalLibraryItem?> findExistingAsync({
    String? id,
    String? isrc,
    String? trackName,
    String? artistName,
  }) async {
    if (id != null && id.isNotEmpty) {
      final byId = await getById(id);
      if (byId != null) return byId;
    }
    if (isrc != null && isrc.isNotEmpty) {
      final byIsrc = await getByIsrcAsync(isrc);
      if (byIsrc != null) return byIsrc;
    }
    if (trackName != null && artistName != null) {
      return findByTrackAndArtistAsync(trackName, artistName);
    }
    return null;
  }

  Future<Map<String, int>> _backfillLegacyFileModTimes({
    required bool isSaf,
    required Map<String, int> existingFiles,
  }) async {
    final legacyPaths = existingFiles.entries
        .where((entry) => entry.value <= 0)
        .map((entry) => entry.key)
        .toList();
    if (legacyPaths.isEmpty) {
      return const {};
    }

    if (isSaf) {
      final uris = legacyPaths
          .where((path) => path.startsWith('content://'))
          .toList();
      if (uris.isEmpty) {
        return const {};
      }
      const chunkSize = 500;
      final backfilled = <String, int>{};
      try {
        for (var i = 0; i < uris.length; i += chunkSize) {
          if (_scanCancelRequested) {
            break;
          }
          final end = (i + chunkSize < uris.length)
              ? i + chunkSize
              : uris.length;
          final chunk = uris.sublist(i, end);
          final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
          backfilled.addAll(chunkResult);
        }
        return backfilled;
      } catch (e) {
        _log.w('Failed to backfill SAF mod times: $e');
        return const {};
      }
    }

    final paths = legacyPaths
        .where((path) => !path.startsWith('content://'))
        .toList(growable: false);
    const chunkSize = 24;
    final backfilled = <String, int>{};

    for (var i = 0; i < paths.length; i += chunkSize) {
      if (_scanCancelRequested) {
        break;
      }
      final end = (i + chunkSize < paths.length) ? i + chunkSize : paths.length;
      final chunk = paths.sublist(i, end);
      final chunkEntries = await Future.wait<MapEntry<String, int>?>(
        chunk.map((path) async {
          try {
            final stat = await File(path).stat();
            if (stat.type == FileSystemEntityType.file) {
              return MapEntry(path, stat.modified.millisecondsSinceEpoch);
            }
          } catch (_) {}
          return null;
        }),
      );
      for (final entry in chunkEntries) {
        if (entry != null) {
          backfilled[entry.key] = entry.value;
        }
      }
    }
    return backfilled;
  }
}

final localLibraryProvider =
    NotifierProvider<LocalLibraryNotifier, LocalLibraryState>(
      LocalLibraryNotifier.new,
    );

class LocalLibraryCoverRequest {
  final String? isrc;
  final String trackName;
  final String artistName;

  const LocalLibraryCoverRequest({
    this.isrc,
    required this.trackName,
    required this.artistName,
  });

  @override
  bool operator ==(Object other) {
    return other is LocalLibraryCoverRequest &&
        other.isrc == isrc &&
        other.trackName == trackName &&
        other.artistName == artistName;
  }

  @override
  int get hashCode => Object.hash(isrc, trackName, artistName);
}

class LocalLibraryCoverBatchRequest {
  final List<LocalLibraryCoverRequest> tracks;

  const LocalLibraryCoverBatchRequest(this.tracks);

  @override
  bool operator ==(Object other) {
    if (other is! LocalLibraryCoverBatchRequest) return false;
    if (other.tracks.length != tracks.length) return false;
    for (var i = 0; i < tracks.length; i++) {
      if (other.tracks[i] != tracks[i]) return false;
    }
    return true;
  }

  @override
  int get hashCode => Object.hashAll(tracks);
}

String? _nonEmptyCoverPath(Map<String, dynamic>? json) {
  final coverPath = json?['coverPath'] as String?;
  final trimmed = coverPath?.trim();
  return trimmed == null || trimmed.isEmpty ? null : trimmed;
}

final localLibraryCoverProvider = FutureProvider.autoDispose
    .family<String?, LocalLibraryCoverRequest>((ref, request) {
      ref.watch(
        localLibraryProvider.select((state) => state.loadedIndexVersion),
      );
      return LibraryDatabase.instance
          .findExisting(
            isrc: request.isrc,
            trackName: request.trackName,
            artistName: request.artistName,
          )
          .then(_nonEmptyCoverPath);
    });

final localLibraryFirstCoverProvider = FutureProvider.autoDispose
    .family<String?, LocalLibraryCoverBatchRequest>((ref, request) async {
      ref.watch(
        localLibraryProvider.select((state) => state.loadedIndexVersion),
      );
      final rows = await LibraryDatabase.instance.findExistingBatch([
        for (final track in request.tracks)
          LocalLibraryBatchLookupRequest(
            isrc: track.isrc,
            trackName: track.trackName,
            artistName: track.artistName,
          ),
      ]);
      for (final row in rows) {
        final cover = _nonEmptyCoverPath(row);
        if (cover != null) return cover;
      }
      return null;
    });
