part of 'download_provider.dart';

/// Owns downloaded metadata, artwork references, and profile-scoped watch
/// overlays. [DownloadProvider] remains responsible for queue and ownership
/// orchestration; this store keeps cache hydration and watch synchronization in
/// one lifecycle-bound component.
class _DownloadMetadataStore extends ChangeNotifier {
  _DownloadMetadataStore(this._downloadManager, this._database, {String? activeProfileId})
    : _activeProfileId = activeProfileId {
    _watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged);
    _watchStateStore.addListener(notifyListeners);
    _watchStateStore.setActiveProfileId(activeProfileId);
  }

  final DownloadManagerService _downloadManager;
  final AppDatabase _database;
  final WatchStateStore _watchStateStore = WatchStateStore();
  late final StreamSubscription<WatchStateEvent> _watchStateSubscription;
  Future<void> _watchStateWriteTail = Future<void>.value();

  final Map<String, MediaItem> items = {};
  final Map<String, DownloadedArtwork> artworkPaths = {};
  final Map<String, String?> _watchScopesByServer = {};
  String? _activeProfileId;

  void setActiveProfileId(String? profileId) {
    if (_activeProfileId == profileId) return;
    _activeProfileId = profileId;
    _watchScopesByServer.clear();
    _watchStateStore
      ..setActiveProfileId(profileId)
      ..setActiveClientScopesByServer(const {});
  }

  MediaItem applyWatchState(MediaItem item) => _watchStateStore.apply(item);

  MediaItem? resolved(String globalKey) {
    final item = items[globalKey];
    return item == null ? null : applyWatchState(item);
  }

  Map<String, MediaItem> get resolvedItems =>
      Map.unmodifiable({for (final entry in items.entries) entry.key: applyWatchState(entry.value)});

  /// Loads show/season or artist/album metadata from an already-fetched cache
  /// snapshot without issuing per-parent database queries.
  void loadParentMetadataFromMap(MediaItem leaf, Map<String, MediaItem> allMetadata, {String? clientScopeId}) {
    final serverId = leaf.serverId;
    if (serverId == null) return;

    MediaItem? lookupParent(String ratingKey) {
      if (clientScopeId != null && clientScopeId.isNotEmpty) {
        final scoped = allMetadata[buildGlobalKey(ServerId(clientScopeId), ratingKey)];
        if (scoped != null) return scoped;
      }
      return allMetadata[buildGlobalKey(ServerId(serverId), ratingKey)];
    }

    void loadParent(String? ratingKey) {
      if (ratingKey == null) return;
      final parentGlobalKey = buildGlobalKey(ServerId(serverId), ratingKey);
      if (items.containsKey(parentGlobalKey)) return;
      final parentMetadata = lookupParent(ratingKey);
      if (parentMetadata == null) return;
      items[parentGlobalKey] = parentMetadata;
      if (parentMetadata.thumbPath != null) {
        artworkPaths[parentGlobalKey] = DownloadedArtwork(thumbPath: parentMetadata.thumbPath);
      }
    }

    loadParent(leaf.grandparentId);
    loadParent(leaf.parentId);
  }

  /// Rehydrates queued offline watch actions into the canonical hierarchy-aware
  /// watch-state layer for the active profile.
  Future<void> hydrateOfflineWatchOverlay({bool Function()? isStale}) async {
    bool stale() => isStale?.call() ?? false;

    try {
      final profileId = _activeProfileId;
      if (profileId == null || profileId.isEmpty) {
        _watchStateStore.setHydratedPatches(const []);
        return;
      }

      final keys = <String>{};
      for (final item in items.values) {
        keys.add(item.globalKey);
        final serverId = serverIdOrNull(item.serverId);
        if (serverId == null) continue;
        for (final parentId in item.parentChain) {
          keys.add(buildGlobalKey(serverId, parentId));
        }
      }
      if (keys.isEmpty) {
        _watchStateStore.setHydratedPatches(const []);
        return;
      }

      final scopes = <String, String?>{};
      final scopesByServer = <String, String?>{};
      for (final key in keys) {
        final parsed = parseGlobalKey(key);
        if (parsed == null) continue;
        var scope = scopesByServer[parsed.serverId];
        if (!scopesByServer.containsKey(parsed.serverId)) {
          scope = await _offlineWatchScopeForServer(parsed.serverId);
          scopesByServer[parsed.serverId] = scope;
        }
        scopes[key] = scope;
        if (stale()) return;
      }

      _watchScopesByServer
        ..clear()
        ..addAll(scopesByServer);
      _watchStateStore.setActiveClientScopesByServer(_watchScopesByServer);

      final actions = await _database.getWatchActionsForKeys(
        keys,
        profileId: profileId,
        filterProfile: true,
        clientScopeIdsByGlobalKey: scopes,
      );
      if (stale()) return;

      final hydrated = <HydratedWatchStatePatch>[];
      for (final entry in actions.entries) {
        final snapshot = WatchStateResolver.fromActions(entry.value);
        if (snapshot.isEmpty) continue;
        final latest = entry.value.firstWhere(
          (action) =>
              action.actionType == 'watched' || action.actionType == 'unwatched' || action.actionType == 'progress',
        );
        final scopedKey = latest.clientScopeId != null && latest.clientScopeId!.isNotEmpty
            ? buildGlobalKey(ServerId(latest.clientScopeId!), latest.ratingKey)
            : latest.globalKey;
        hydrated.add(
          HydratedWatchStatePatch(globalKey: scopedKey, patch: snapshot, updatedAt: latest.updatedAt, order: latest.id),
        );
      }
      _watchStateStore.setHydratedPatches(hydrated);
    } catch (error) {
      appLogger.w('Failed to apply offline watch overlay', error: error);
    }
  }

  Future<String?> _offlineWatchScopeForServer(String serverId) async {
    final profileId = _activeProfileId;
    if (profileId == null || profileId.isEmpty) return null;
    return _downloadManager.profileClientScopeIdForServer(ServerId(serverId), profileId);
  }

  Future<void> waitForWatchStateWrites() async {
    // The notifier's broadcast stream delivers asynchronously. Yield once so
    // all events queued by the caller are represented in the write tail.
    await Future<void>.delayed(Duration.zero);
    await _watchStateWriteTail;
  }

  void _onWatchStateChanged(WatchStateEvent event) {
    final snapshot = WatchStateResolver.fromEvent(event);
    if (snapshot.isEmpty) return;

    final globalKey = buildGlobalKey(ServerId(event.serverId), event.itemId);
    final base = items[globalKey];
    final eventScope = event.cacheServerId;
    final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(event.serverId));
    if (activeScope != null && activeScope.isNotEmpty) {
      _watchScopesByServer[event.serverId] = activeScope;
      _watchStateStore.setActiveClientScopesByServer(_watchScopesByServer);
    }
    if (base == null || activeScope == null || activeScope.isEmpty) return;
    if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) {
      return;
    }

    final isWatched = snapshot.isWatched;
    final shouldPersistToCache =
        isWatched != null && (event.changeType != WatchStateChangeType.progressUpdate || event.isNowWatched == true);
    if (!shouldPersistToCache) return;

    _watchStateWriteTail = _watchStateWriteTail
        .then(
          (_) => ApiCache.forBackend(
            base.backend,
          ).applyWatchState(serverId: ServerId(activeScope), itemId: event.itemId, isWatched: isWatched),
        )
        .catchError((Object error, StackTrace stackTrace) {
          appLogger.w('Failed to apply watch state to cache for $globalKey', error: error, stackTrace: stackTrace);
        });
  }

  @override
  void dispose() {
    _watchStateSubscription.cancel();
    _watchStateStore
      ..removeListener(notifyListeners)
      ..dispose();
    super.dispose();
  }
}
