// lib/model/source/collection_source.dart
import 'dart:async';
import 'dart:ui';

import 'package:aves/model/covers.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/catalog.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/location.dart';
import 'package:aves/model/entry/sort.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/container/album_group.dart';
import 'package:aves/model/filters/container/tag_group.dart';
import 'package:aves/model/filters/covered/location.dart';
import 'package:aves/model/filters/covered/stored_album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/grouping/common.dart';
import 'package:aves/model/grouping/convert.dart';
import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/events.dart';
import 'package:aves/model/source/location/country.dart';
import 'package:aves/model/source/location/location.dart';
import 'package:aves/model/source/location/place.dart';
import 'package:aves/model/source/location/state.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/model/source/trash.dart';
import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/services/analysis_service.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
import 'package:leak_tracker/leak_tracker.dart';

import 'package:aves/model/db/folder_stats.dart';
import 'package:aves/model/db/db_sqflite.dart';

// Remote origin
import 'package:aves/remote/remote_origin.dart';
import 'package:aves/remote/remote_sync_bus.dart';

typedef SourceScope = Set<CollectionFilter>?;

// Trace opzionale: mostra chi nasconderebbe i remoti, senza nasconderli.
const bool kTraceHiddenRemotes = true;

mixin SourceBase {
  EventBus get eventBus;

  Map<int, AvesEntry> get entryById;

  Map<String, FolderStats> get folderMap;

  Set<AvesEntry> get allEntries;

  Set<AvesEntry> get visibleEntries;

  Set<AvesEntry> get trashedEntries;

  List<AvesEntry> get sortedEntriesByDate;

  ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);

  set state(SourceState value) => stateNotifier.value = value;

  SourceState get state => stateNotifier.value;

  bool get isReady => state == SourceState.ready;

  // Debug: forza visibilità remota per testing.
  bool debugForceRemoteVisible = true;

  bool get remoteVisible {
    if (debugForceRemoteVisible) return true;
    final state = RemoteSyncBus.instance.stateNotifier.value;
    return state == RemoteSyncState.syncing || state == RemoteSyncState.upToDate;
  }

  ValueNotifier<ProgressEvent> progressNotifier =
      ValueNotifier(const ProgressEvent(done: 0, total: 0));

  void setProgress({required int done, required int total}) =>
      progressNotifier.value = ProgressEvent(done: done, total: total);

  void invalidateEntries();
}

abstract class CollectionSource
    with
        SourceBase,
        AlbumMixin,
        CountryMixin,
        PlaceMixin,
        StateMixin,
        LocationMixin,
        TagMixin,
        TrashMixin {
  static const fullScope = <CollectionFilter>{};

  CollectionSource() {
    if (kFlutterMemoryAllocationsEnabled) {
      LeakTracking.dispatchObjectCreated(
        library: 'aves',
        className: '$CollectionSource',
        object: this,
      );
    }

    settings.updateStream
        .where((event) => event.key == SettingKeys.localeKey)
        .listen((_) => invalidateStoredAlbumDisplayNames());

    settings.updateStream
        .where((event) => event.key == SettingKeys.hiddenFiltersKey)
        .listen((event) {
      final oldValue = event.oldValue;
      if (oldValue is List<String>?) {
        final oldHiddenFilters =
            (oldValue ?? []).map(CollectionFilter.fromJson).nonNulls.toSet();
        final newlyVisibleFilters =
            oldHiddenFilters.whereNot(settings.hiddenFilters.contains).toSet();
        _onFilterVisibilityChanged(newlyVisibleFilters);
      }
    });

    vaults.addListener(_onVaultsChanged);
  }

  @mustCallSuper
  void dispose() {
    if (kFlutterMemoryAllocationsEnabled) {
      LeakTracking.dispatchObjectDisposed(object: this);
    }

    vaults.removeListener(_onVaultsChanged);
    _rawEntries.forEach((v) => v.dispose());
  }

  set canAnalyze(bool enabled);

  final EventBus _eventBus = EventBus();

  @override
  EventBus get eventBus => _eventBus;

  final Map<int, AvesEntry> _entryById = {};

  @override
  Map<int, AvesEntry> get entryById => Map.unmodifiable(_entryById);

  final Set<AvesEntry> _rawEntries = {};

  @override
  Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);

  Set<AvesEntry>? _visibleEntries, _trashedEntries;

  @override
  Set<AvesEntry> get visibleEntries {
    _visibleEntries ??= Set.unmodifiable(_applyHiddenFilters(_rawEntries));
    return _visibleEntries!;
  }

  @override
  Set<AvesEntry> get trashedEntries {
    _trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries));
    return _trashedEntries!;
  }

  List<AvesEntry>? _sortedEntriesByDate;

  @override
  List<AvesEntry> get sortedEntriesByDate {
    _sortedEntriesByDate ??=
        List.unmodifiable(visibleEntries.toList()..sort(AvesEntrySort.compareByDate));
    return _sortedEntriesByDate!;
  }

  // known date by entry ID
  late Map<int?, int?> _savedDates;

  // === FOLDER MAP: fonte dati unica per AlbumPage e InfoPage ===
  late Map<String, FolderStats> _folderMap = {};

  @override
  Map<String, FolderStats> get folderMap => _folderMap;

  Future<void> computeFolderMap() async {
    // Evita di sovrascrivere una folderMap valida con una vuota durante startup.
    if (_rawEntries.isEmpty) {
      debugPrint('[folders] computeFolderMap skipped rawEntries=0');
      return;
    }

    // Reset cache derivate.
    _visibleEntries = null;
    _trashedEntries = null;
    _sortedEntriesByDate = null;

    final entries = visibleEntries;

    debugPrint(
      '[folders] computeFolderMap start '
      'rawEntries=${_rawEntries.length} '
      'visibleEntries=${entries.length}',
    );

    final map = <String, FolderStats>{};

    for (final entry in entries) {
      final dir = entry.directory;
      if (dir == null || dir.isEmpty) continue;

      map.putIfAbsent(dir, () {
        return FolderStats(
          path: dir,
          displayName: dir.split('/').last,
          fileCount: 0,
          totalSize: 0,
          thumbEntryId: entry.id,
          thumbDate: entry.dateModifiedMillis,
          albumType: 0,
          priority: 0,
          isRemote: entry.origin,
          user: null,
          deviceId: null,
          hybridType: null,
          hidden: 0,
        );
      });

      final stats = map[dir]!;

      stats.fileCount++;
      stats.totalSize += entry.sizeBytes ?? 0;

      // Usa come thumbnail l'entry più recente della cartella.
      final currentThumbDate = stats.thumbDate ?? 0;
      final entryDate = entry.dateModifiedMillis ?? 0;

      if (entryDate > currentThumbDate) {
        stats.thumbEntryId = entry.id;
        stats.thumbDate = entry.dateModifiedMillis;
      }
    }

    debugPrint(
      '[folders] computeFolderMap built folders=${map.length}',
    );

    await finalizeFolders(map);
  }

  Future<void> finalizeFolders(Map<String, FolderStats> map) async {
    debugPrint(
      '[folders] finalizeFolders start folders=${map.length}',
    );

    await localMediaDb.saveFoldersBatch(map.values.toList());

    _folderMap = Map.unmodifiable(map);

    debugPrint(
      '[folders] finalizeFolders done folderMap=${_folderMap.length}',
    );

    eventBus.fire(AlbumsChangedEvent());
  }

  List<String> foldersForEntry(AvesEntry entry) {
    final dir = entry.directory;
    if (dir == null) return const [];
    return _folderMap.containsKey(dir) ? [dir] : const [];
  }

  Future<void> loadDates() async {
    _savedDates = Map.unmodifiable(await localMediaDb.loadDates());
  }

  Set<CollectionFilter> _getAppHiddenFilters() => {
        ...settings.hiddenFilters,
        ...vaults.vaultDirectories
            .where(vaults.isLocked)
            .map((v) => StoredAlbumFilter(v, null)),
      };

  Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
    final hiddenFilters = {
      TrashFilter.instance,
      ..._getAppHiddenFilters(),
    };

    final remoteOn = remoteVisible;

    if (!remoteOn) {
      entries = entries.where((e) => e.origin == 0).toList();
    }

    return entries.where((entry) {
      if (entry.origin == RemoteOrigin.value) {
        if (kTraceHiddenRemotes) {
          final hiddenBy = hiddenFilters.firstWhereOrNull((f) => f.test(entry));
          if (hiddenBy != null && !TrashFilter.instance.test(entry)) {
            debugPrint(
              '[hidden][trace] remote id=${entry.id} '
              'rid=${entry.remoteId} by=${hiddenBy.runtimeType}',
            );
          }
        }

        // Remoti: nascondi solo se nel cestino.
        return !TrashFilter.instance.test(entry);
      }

      // Locali: logica originale.
      return !hiddenFilters.any((filter) => filter.test(entry));
    });
  }

  Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
    final hiddenFilters = _getAppHiddenFilters();
    return entries
        .where(TrashFilter.instance.test)
        .where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
  }

  void _invalidate({Set<AvesEntry>? entries, bool notify = true}) {
    if (_rawEntries.isEmpty) return;

    invalidateEntries();

    // invalidateAlbumFilterSummary(entries: entries, notify: notify);
    invalidateCountryFilterSummary(entries: entries, notify: notify);
    invalidatePlaceFilterSummary(entries: entries, notify: notify);
    invalidateStateFilterSummary(entries: entries, notify: notify);
    invalidateTagFilterSummary(entries: entries, notify: notify);
  }

  @override
  void invalidateEntries() {
    _visibleEntries = null;
    _trashedEntries = null;
    _sortedEntriesByDate = null;
  }

  Future<void> reloadEntriesFromDb() async {
    final dbEntries = await localMediaDb.loadEntries();

    _rawEntries
      ..clear()
      ..addAll(dbEntries);

    _entryById
      ..clear()
      ..addEntries(dbEntries.map((e) => MapEntry(e.id, e)));

    await computeFolderMap();
  }

  void updateDerivedFilters([Set<AvesEntry>? entries]) {
    _invalidate(entries: entries);

    // It is possible for entries hidden by a filter type to impact other types.
    // updateDirectories() è gestito dalla folderMap/computeFolderMap.
    updateLocations();
    updateTags();
  }

  Future<void> addEntries(Set<AvesEntry> entries, {bool notify = true}) async {
    if (entries.isEmpty) return;

    await localMediaDb.insertEntries(entries);

    final dbEntries = await localMediaDb.loadEntries();

    _rawEntries
      ..clear()
      ..addAll(dbEntries);

    _entryById
      ..clear()
      ..addEntries(dbEntries.map((e) => MapEntry(e.id, e)));

    _invalidate(entries: entries, notify: notify);

    await computeFolderMap();

    if (notify) {
      eventBus.fire(EntryAddedEvent(entries));
    }
  }

  void removeEntriesFromMemory(Set<AvesEntry> entries, {bool notify = true}) {
    if (entries.isEmpty) return;

    for (final e in entries) {
      _entryById.remove(e.id);
    }

    _rawEntries.removeAll(entries);
    updateDerivedFilters(entries);

    unawaited(computeFolderMap());

    if (notify) {
      eventBus.fire(EntryRemovedEvent(entries));
    }
  }

  Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
    if (uris.isEmpty) return;

    final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();

    if (!includeTrash) {
      entries.removeWhere(TrashFilter.instance.test);
    }

    if (entries.isEmpty) return;

    final ids = entries.map((entry) => entry.id).toSet();

    await favourites.removeIds(ids);
    await covers.removeIds(ids);
    await localMediaDb.removeIds(ids);

    for (final id in ids) {
      _entryById.remove(id);
    }

    _rawEntries.removeAll(entries);
    updateDerivedFilters(entries);

    await computeFolderMap();

    eventBus.fire(EntryRemovedEvent(entries));
  }

  void clearEntries() {
    _entryById.clear();
    _rawEntries.clear();
    _folderMap = {};

    _invalidate();

    // Do not update directories/locations/tags here.
  }

  /// Carica dal DB tutte le entry remote e le aggiunge alla sorgente corrente.
  Future<void> appendRemoteEntries({bool notify = true}) async {
    final remotes = await localMediaDb.loadEntries(origin: RemoteOrigin.value);
    if (remotes.isEmpty) return;

    final visibleRemotes = remotes.where((e) => !e.trashed).toSet();
    if (visibleRemotes.isEmpty) return;

    await localMediaDb.insertEntries(visibleRemotes);

    final dbEntries = await localMediaDb.loadEntries();

    _rawEntries
      ..clear()
      ..addAll(dbEntries);

    _entryById
      ..clear()
      ..addEntries(dbEntries.map((e) => MapEntry(e.id, e)));

    _invalidate(entries: visibleRemotes, notify: notify);

    await computeFolderMap();

    if (notify) {
      eventBus.fire(EntryAddedEvent(visibleRemotes));
    }
  }

  Future<void> _moveEntry(
    AvesEntry entry,
    Map newFields, {
    required bool persist,
  }) async {
    newFields.keys.forEach((key) {
      final newValue = newFields[key];

      switch (key) {
        case EntryFields.contentId:
          entry.contentId = newValue as int?;
        case EntryFields.dateModifiedMillis:
          entry.dateModifiedMillis = newValue as int?;
        case EntryFields.path:
          entry.path = newValue as String?;
        case EntryFields.title:
          entry.sourceTitle = newValue as String?;
        case EntryFields.trashed:
          final trashed = newValue as bool;
          entry.trashed = trashed;
          entry.trashDetails = trashed
              ? TrashDetails(
                  id: entry.id,
                  path: newFields[EntryFields.trashPath] as String,
                  dateMillis: DateTime.now().millisecondsSinceEpoch,
                )
              : null;
        case EntryFields.uri:
          entry.uri = newValue as String;
        case EntryFields.origin:
          entry.origin = newValue as int;
      }
    });

    if (entry.trashed) {
      final trashPath = entry.trashDetails?.path;
      if (trashPath != null) {
        entry.contentId = null;
        entry.uri = Uri.file(trashPath).toString();
      } else {
        debugPrint('failed to update uri from unknown trash path for uri=${entry.uri}');
      }
    }

    if (persist) {
      await covers.moveEntry(entry);
      final id = entry.id;
      await localMediaDb.updateEntry(id, entry);
      await localMediaDb.updateCatalogMetadata(id, entry.catalogMetadata);
      await localMediaDb.updateAddress(id, entry.addressDetails);
      await localMediaDb.updateTrash(id, entry.trashDetails);
    }
  }

  Future<void> updateAfterMove({
    required Set<AvesEntry> todoEntries,
    required MoveType moveType,
    required Set<String> destinationAlbums,
    required Set<MoveOpEvent> movedOps,
  }) async {
    if (movedOps.isEmpty) return;

    final replacedUris = movedOps
        .map((movedOp) => movedOp.newFields[EntryFields.path] as String?)
        .map((targetPath) {
          final existingEntry = _rawEntries.firstWhereOrNull(
            (entry) => entry.path == targetPath && !entry.trashed,
          );
          return existingEntry?.uri;
        })
        .nonNulls
        .toSet();

    await removeEntries(replacedUris, includeTrash: false);

    final fromAlbums = <String?>{};
    final movedEntries = <AvesEntry>{};
    final copy = moveType == MoveType.copy;

    if (copy) {
      movedOps.forEach((movedOp) {
        final sourceUri = movedOp.uri;
        final newFields = movedOp.newFields;
        final sourceEntry =
            todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);

        if (sourceEntry != null) {
          fromAlbums.add(sourceEntry.directory);
          movedEntries.add(
            sourceEntry.copyWith(
              id: localMediaDb.nextId,
              uri: newFields[EntryFields.uri] as String?,
              path: newFields[EntryFields.path] as String?,
              contentId: newFields[EntryFields.contentId] as int?,
              title: newFields[EntryFields.title] as String?,
              dateAddedSecs: newFields[EntryFields.dateAddedSecs] as int?,
              dateModifiedMillis:
                  newFields[EntryFields.dateModifiedMillis] as int?,
              origin: newFields[EntryFields.origin] as int?,
            ),
          );
        } else {
          debugPrint('failed to find source entry with uri=$sourceUri');
        }
      });

      await localMediaDb.insertEntries(movedEntries);
      await localMediaDb.saveCatalogMetadata(
        movedEntries.map((entry) => entry.catalogMetadata).nonNulls.toSet(),
      );
      await localMediaDb.saveAddresses(
        movedEntries.map((entry) => entry.addressDetails).nonNulls.toSet(),
      );
    } else {
      await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
        final newFields = movedOp.newFields;
        if (newFields.isNotEmpty) {
          final sourceUri = movedOp.uri;
          final entry = todoEntries.firstWhereOrNull(
            (entry) => entry.uri == sourceUri,
          );

          if (entry != null) {
            if (moveType == MoveType.fromBin) {
              newFields[EntryFields.trashed] = false;
            } else {
              fromAlbums.add(entry.directory);
            }

            movedEntries.add(entry);
            await _moveEntry(entry, newFields, persist: true);
          }
        }
      });
    }

    switch (moveType) {
      case MoveType.copy:
        await addEntries(movedEntries);
      case MoveType.move:
      case MoveType.export:
        cleanEmptyAlbums(fromAlbums.nonNulls.toSet());
        addDirectories(albums: destinationAlbums);
      case MoveType.toBin:
      case MoveType.fromBin:
        updateDerivedFilters(movedEntries);
    }

    invalidateAlbumFilterSummary(directories: fromAlbums);
    _invalidate(entries: movedEntries);

    await computeFolderMap();

    eventBus.fire(EntryMovedEvent(moveType, movedEntries));
  }

  Future<void> updateAfterRename({
    required Set<AvesEntry> todoEntries,
    required Set<MoveOpEvent> movedOps,
    required bool persist,
  }) async {
    if (movedOps.isEmpty) return;

    final movedEntries = <AvesEntry>{};

    await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
      final newFields = movedOp.newFields;
      if (newFields.isNotEmpty) {
        final sourceUri = movedOp.uri;
        final entry =
            todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);

        if (entry != null) {
          movedEntries.add(entry);
          await _moveEntry(entry, newFields, persist: persist);
        }
      }
    });

    await computeFolderMap();

    eventBus.fire(EntryMovedEvent(MoveType.move, movedEntries));
  }

  SourceScope get loadedScope;

  SourceScope get targetScope;

  @override
  Future<void> init({
    required SourceScope scope,
    AnalysisController? analysisController,
    bool loadTopEntriesFirst = false,
  }) async {
    // MediaStoreSource.init() è l’unica vera implementazione.
  }

  Future<Set<String>> refreshUris(
    Set<String> changedUris, {
    AnalysisController? analysisController,
  });

  Future<void> refreshEntries(
    Set<AvesEntry> entries,
    Set<EntryDataType> dataTypes,
  ) async {
    const background = false;
    const persist = true;

    await Future.forEach(entries, (entry) async {
      await entry.refresh(
        background: background,
        persist: persist,
        dataTypes: dataTypes,
      );
    });

    if (dataTypes.contains(EntryDataType.aspectRatio)) {
      onAspectRatioChanged();
    }

    if (dataTypes.contains(EntryDataType.catalog)) {
      await deviceService.requestGarbageCollection();

      await Future.forEach(entries, (entry) async {
        await entry.catalog(
          background: background,
          force: dataTypes.contains(EntryDataType.catalog),
          persist: persist,
        );
        await localMediaDb.updateCatalogMetadata(entry.id, entry.catalogMetadata);
      });

      onCatalogMetadataChanged();
    }

    if (dataTypes.contains(EntryDataType.address)) {
      await Future.forEach(entries, (entry) async {
        await entry.locate(
          background: background,
          force: dataTypes.contains(EntryDataType.address),
          geocoderLocale: settings.appliedLocale,
        );
        await localMediaDb.updateAddress(entry.id, entry.addressDetails);
      });

      onAddressMetadataChanged();
    }

    updateDerivedFilters(entries);

    await computeFolderMap();

    eventBus.fire(EntryRefreshedEvent(entries));
  }

  Future<void> analyze(
    AnalysisController? analysisController, {
    Set<AvesEntry>? entries,
  }) async {
    final todoEntries = entries ?? allEntries;
    final defaultAnalysisController = AnalysisController();
    final _analysisController = analysisController ?? defaultAnalysisController;
    final force = _analysisController.force;

    if (!_analysisController.isStopping) {
      var startAnalysisService = false;

      if (_analysisController.canStartService && settings.canUseAnalysisService) {
        if (!startAnalysisService) {
          final opCount =
              (force ? todoEntries : todoEntries.where(TagMixin.catalogEntriesTest))
                  .length;
          startAnalysisService = opCount > TagMixin.commitCountThreshold;
        }

        if (!startAnalysisService && await availability.canLocatePlaces) {
          final opCount = (force
                  ? todoEntries.where((entry) => entry.hasGps)
                  : todoEntries.where(LocationMixin.locatePlacesTest))
              .length;
          startAnalysisService = opCount > LocationMixin.commitCountThreshold;
        }
      }

      debugPrint(
        'analyze ${todoEntries.length} entries, '
        'force=$force, starting service=$startAnalysisService',
      );

      if (startAnalysisService) {
        final lifecycleState = AvesApp.lifecycleStateNotifier.value;

        switch (lifecycleState) {
          case AppLifecycleState.resumed:
          case AppLifecycleState.inactive:
            await AnalysisService.startService(
              force: force,
              entryIds: entries?.map((entry) => entry.id).toList(),
            );
          default:
            unawaited(
              reportService.log(
                'analysis service not started because app is in state=$lifecycleState',
              ),
            );
        }
      } else {
        await deviceService.requestGarbageCollection();

        await catalogEntries(_analysisController, todoEntries);
        updateDerivedFilters(todoEntries);

        await locateEntries(_analysisController, todoEntries);
        updateDerivedFilters(todoEntries);
      }
    }

    defaultAnalysisController.dispose();

    await computeFolderMap();

    state = SourceState.ready;
  }

  void onAspectRatioChanged() => eventBus.fire(AspectRatioChangedEvent());

  // Monitoring

  bool _canRefresh = true;

  void pauseMonitoring() => _canRefresh = false;

  void resumeMonitoring() => _canRefresh = true;

  bool get canRefresh => _canRefresh;

  // Filter summary

  int count(CollectionFilter filter) {
    switch (filter) {
      case AlbumBaseFilter _:
        return albumEntryCount(filter);
      case LocationFilter(level: LocationLevel.country):
        return countryEntryCount(filter);
      case LocationFilter(level: LocationLevel.state):
        return stateEntryCount(filter);
      case LocationFilter(level: LocationLevel.place):
        return placeEntryCount(filter);
      case TagBaseFilter _:
        return tagEntryCount(filter);
    }

    return 0;
  }

  int size(CollectionFilter filter) {
    switch (filter) {
      case AlbumBaseFilter _:
        return albumSize(filter);
      case LocationFilter(level: LocationLevel.country):
        return countrySize(filter);
      case LocationFilter(level: LocationLevel.state):
        return stateSize(filter);
      case LocationFilter(level: LocationLevel.place):
        return placeSize(filter);
      case TagBaseFilter _:
        return tagSize(filter);
    }

    return 0;
  }

  AvesEntry? recentEntry(CollectionFilter filter) {
    switch (filter) {
      case AlbumBaseFilter _:
        return albumRecentEntry(filter);
      case LocationFilter(level: LocationLevel.country):
        return countryRecentEntry(filter);
      case LocationFilter(level: LocationLevel.state):
        return stateRecentEntry(filter);
      case LocationFilter(level: LocationLevel.place):
        return placeRecentEntry(filter);
      case TagBaseFilter _:
        return tagRecentEntry(filter);
    }

    return null;
  }

  AvesEntry? coverEntry(CollectionFilter filter) {
    final id = covers.of(filter)?.$1;

    if (id != null) {
      final entry = visibleEntries.firstWhereOrNull((e) => e.id == id);
      if (entry != null) return entry;
    }

    if (filter is StoredAlbumFilter) {
      final album = filter.album;

      final e = sortedEntriesByDate.firstWhereOrNull(
        (entry) => entry.directory == album,
      );

      if (e != null) return e;
    }

    return recentEntry(filter);
  }

  void _onFilterVisibilityChanged(Set<CollectionFilter> newlyVisibleFilters) {
    updateDerivedFilters();
    eventBus.fire(const FilterVisibilityChangedEvent());

    unawaited(computeFolderMap());

    if (newlyVisibleFilters.isNotEmpty) {
      final candidateEntries = visibleEntries
          .where((entry) => newlyVisibleFilters.any((f) => f.test(entry)))
          .toSet();

      analyze(null, entries: candidateEntries);
    }
  }

  void _onVaultsChanged() {
    final newlyVisibleFilters = vaults.vaultDirectories
        .whereNot(vaults.isLocked)
        .map((v) => StoredAlbumFilter(v, null))
        .toSet();

    _onFilterVisibilityChanged(newlyVisibleFilters);
  }
}

class AspectRatioChangedEvent {}
