import 'dart:convert';

import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import '../media/ids.dart';

import 'app_database.dart';
import '../models/download_models.dart';
import '../profiles/profile.dart';
import '../utils/active_client_scope.dart';

enum QueueDownloadOutcome {
  /// A missing or retryable row was durably admitted to the queue.
  admitted,

  /// The row was already queued; only its queue policy was refreshed.
  alreadyQueued,

  /// The existing row is active, paused, or complete and was left unchanged.
  unchanged,
}

extension DownloadDatabaseOperations on AppDatabase {
  Future<void> addDownloadOwner({
    required String profileId,
    required String globalKey,
    String? backendId,
    String? clientScopeId,
  }) async {
    if (profileId.isEmpty) return;
    await customUpdate(
      '''
      INSERT INTO download_owners (
        profile_id,
        global_key,
        backend,
        client_scope_id,
        created_at
      ) VALUES (?, ?, ?, ?, ?)
      ON CONFLICT(profile_id, global_key) DO UPDATE SET
        backend = COALESCE(excluded.backend, download_owners.backend),
        client_scope_id = COALESCE(excluded.client_scope_id, download_owners.client_scope_id)
      ''',
      variables: [
        Variable<String>(profileId),
        Variable<String>(globalKey),
        Variable<String>(backendId),
        Variable<String>(clientScopeId),
        Variable<int>(DateTime.now().millisecondsSinceEpoch),
      ],
      updates: {downloadOwners},
    );
  }

  Future<void> removeDownloadOwner({required String profileId, required String globalKey}) {
    return transaction(() async {
      await (delete(
        syncRuleDownloads,
      )..where((t) => t.profileId.equals(profileId) & t.downloadGlobalKey.equals(globalKey))).go();
      await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go();
    });
  }

  /// Removes one owner from a shared download while keeping an incomplete
  /// physical row usable by a remaining owner.
  ///
  /// When there is no remaining valid owner, nothing is removed so callers
  /// can delete the physical download before releasing its final durable
  /// owner. Selection, scope rebinding, and owner removal share a transaction.
  Future<({DownloadOwnerItem? removedOwner, bool hasRemainingOwner})>
  removeSharedDownloadOwnerAndRebindIncompleteMedia({required String profileId, required String globalKey}) {
    return transaction(() async {
      final departingOwner = await getDownloadOwner(profileId: profileId, globalKey: globalKey);
      final remainingOwners = (await _validDownloadOwnerRows(globalKey, excludingProfileId: profileId)).toList()
        ..sort((a, b) {
          final aHasScope = a.clientScopeId?.isNotEmpty ?? false;
          final bHasScope = b.clientScopeId?.isNotEmpty ?? false;
          if (aHasScope != bHasScope) return aHasScope ? -1 : 1;
          final createdAtComparison = a.createdAt.compareTo(b.createdAt);
          return createdAtComparison != 0 ? createdAtComparison : a.profileId.compareTo(b.profileId);
        });
      if (remainingOwners.isEmpty) {
        return (removedOwner: null, hasRemainingOwner: false);
      }

      if (departingOwner != null) {
        final media = await getDownloadedMedia(globalKey);
        final departingScope = departingOwner.clientScopeId;
        if (media != null &&
            media.status != DownloadStatus.completed.index &&
            departingScope != null &&
            departingScope.isNotEmpty &&
            media.clientScopeId == departingScope) {
          final replacementScope = remainingOwners.first.clientScopeId;
          if (replacementScope != media.clientScopeId) {
            await updateDownloadedMediaClientScope(globalKey, replacementScope);
          }
        }
        await removeDownloadOwner(profileId: profileId, globalKey: globalKey);
      }
      return (removedOwner: departingOwner, hasRemainingOwner: true);
    });
  }

  Future<DownloadOwnerItem?> getDownloadOwner({required String profileId, required String globalKey}) {
    return (select(
      downloadOwners,
    )..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).getSingleOrNull();
  }

  Future<void> clearAllDownloadOwners() {
    return transaction(() async {
      await delete(syncRuleDownloads).go();
      await update(syncRules).write(const SyncRulesCompanion(downloadLinksInitialized: Value(false)));
      await delete(downloadOwners).go();
    });
  }

  Future<Set<String>> getDownloadOwnerKeysForProfile(String profileId) async {
    if (profileId.isEmpty) return const {};
    final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
    return rows.map((row) => row.globalKey).toSet();
  }

  Future<List<DownloadOwnerItem>> getDownloadOwnersForProfile(String profileId) {
    if (profileId.isEmpty) return Future.value(const []);
    return (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
  }

  Future<void> updateDownloadOwnerScope({
    required String profileId,
    required String globalKey,
    required String backendId,
    required String clientScopeId,
  }) {
    return (update(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).write(
      DownloadOwnersCompanion(backend: Value(backendId), clientScopeId: Value(clientScopeId)),
    );
  }

  Future<void> updateDownloadedMediaClientScope(String globalKey, String? clientScopeId) {
    return (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
      DownloadedMediaCompanion(clientScopeId: Value(clientScopeId)),
    );
  }

  Future<int> getDownloadOwnerCount(String globalKey) async {
    return (await _validDownloadOwnerRows(globalKey)).length;
  }

  @visibleForTesting
  Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
    final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
    return rows.isNotEmpty;
  }

  Future<List<DownloadOwnerItem>> getValidDownloadOwnersForKey(String globalKey) {
    return _validDownloadOwnerRows(globalKey);
  }

  Future<bool> hasDownloadOwnerForCacheScope(
    String globalKey, {
    required String backendId,
    required String clientScopeId,
  }) async {
    final owners = await _validDownloadOwnerRows(globalKey);
    return owners.any((owner) => owner.backend == backendId && owner.clientScopeId == clientScopeId);
  }

  Future<List<DownloadOwnerItem>> _validDownloadOwnerRows(String globalKey, {String? excludingProfileId}) async {
    final rows = await (select(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).get();
    if (rows.isEmpty) return const [];
    final candidates = rows
        .where((row) => excludingProfileId == null || excludingProfileId.isEmpty || row.profileId != excludingProfileId)
        .toList(growable: false);
    if (candidates.isEmpty) return const [];

    final localProfileRows = await select(profiles).get();
    final localProfileIds = localProfileRows.map((row) => row.id).toSet();
    final connectionRows = await select(connections).get();
    final connectionIds = connectionRows.map((row) => row.id).toSet();
    return candidates
        .where((row) => _isValidDownloadOwner(row, localProfileIds: localProfileIds, connectionIds: connectionIds))
        .toList(growable: false);
  }

  /// Claim pre-v17 shared download rows for [profileId]. Rows that already
  /// have any valid owner are left untouched so later profiles do not
  /// inherit them.
  ///
  /// Runs on every profile switch — validity context is computed once and
  /// applied in memory instead of the per-download full-table rescan
  /// `getDownloadOwnerCount` would do.
  Future<void> adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async {
    if (profileId.isEmpty) return;
    if (isStillActive != null && !isStillActive()) return;
    final rows = await select(downloadedMedia).get();
    if (rows.isEmpty) return;

    final owners = await select(downloadOwners).get();
    final localProfileIds = (await select(profiles).get()).map((row) => row.id).toSet();
    final connectionRows = await select(connections).get();
    final connectionIds = connectionRows.map((row) => row.id).toSet();
    final connectionKindsById = {for (final row in connectionRows) row.id: row.kind};
    final jellyfinIdentities = <String, ({String machineId, String? userId})>{};
    final jellyfinMachineIds = <String>{};
    for (final connection in connectionRows.where((row) => row.kind == 'jellyfin')) {
      final identity = _jellyfinConnectionIdentity(connection);
      jellyfinIdentities[connection.id] = identity;
      jellyfinMachineIds.add(identity.machineId);
    }
    final jellyfinScopesByProfileAndMachine = <String, Map<String, Set<String>>>{};
    for (final binding in await select(profileConnections).get()) {
      if (binding.userIdentifier.isEmpty) continue;
      final identity = jellyfinIdentities[binding.connectionId];
      if (identity == null || identity.userId != null && identity.userId != binding.userIdentifier) continue;
      jellyfinScopesByProfileAndMachine
          .putIfAbsent(binding.profileId, () => <String, Set<String>>{})
          .putIfAbsent(identity.machineId, () => <String>{})
          .add('${identity.machineId}/${binding.userIdentifier}');
    }
    final ownedKeys = <String>{
      for (final owner in owners)
        if (_isValidDownloadOwner(owner, localProfileIds: localProfileIds, connectionIds: connectionIds))
          owner.globalKey,
    };
    for (final row in rows) {
      if (!ownedKeys.contains(row.globalKey)) {
        if (isStillActive != null && !isStillActive()) return;
        final scopeId = row.clientScopeId;
        final plexScope = PlexProfileScopeId.tryParse(scopeId ?? '');
        final transferScope = PlexTransferScopeId.tryParse(scopeId ?? '');
        // A scoped Plex row already identifies the Plezy profile whose token
        // and cache namespace produced it. Logout first moves preserved rows
        // through a sanitized transfer namespace so a new profile can adopt
        // the physical file without inheriting the old profile's watch state.
        if (plexScope != null && plexScope.profileId != profileId) continue;
        if (plexScope != null || transferScope != null) {
          await addDownloadOwner(
            profileId: profileId,
            globalKey: row.globalKey,
            backendId: 'plex',
            clientScopeId: scopeId,
          );
          continue;
        }

        final jellyfinScopes = jellyfinScopesByProfileAndMachine[profileId]?[row.serverId] ?? const <String>{};
        if (jellyfinScopes.length == 1) {
          final adoptingScope = jellyfinScopes.single;
          await transaction(() async {
            if (isStillActive != null && !isStillActive()) return;
            await updateDownloadedMediaClientScope(row.globalKey, adoptingScope);
            await addDownloadOwner(
              profileId: profileId,
              globalKey: row.globalKey,
              backendId: 'jellyfin',
              clientScopeId: adoptingScope,
            );
          });
          continue;
        }

        // A compound non-Plex scope is a legacy Jellyfin user namespace.
        // Never attach it to another profile unless that profile has exactly
        // one matching Jellyfin binding. The same applies when persisted
        // Jellyfin connections identify the machine but the profile has zero
        // or multiple possible users.
        final hasLegacyJellyfinScope = scopeId?.startsWith('${row.serverId}/') ?? false;
        if (hasLegacyJellyfinScope || jellyfinMachineIds.contains(row.serverId)) continue;

        final backendId = connectionKindsById[scopeId];
        await addDownloadOwner(
          profileId: profileId,
          globalKey: row.globalKey,
          backendId: backendId,
          clientScopeId: scopeId,
        );
      }
    }
  }

  Future<void> addToQueue({
    required String mediaGlobalKey,
    int priority = 0,
    bool downloadSubtitles = true,
    bool downloadArtwork = true,
  }) async {
    await into(downloadQueue).insert(
      DownloadQueueCompanion.insert(
        mediaGlobalKey: mediaGlobalKey,
        priority: Value(priority),
        addedAt: DateTime.now().millisecondsSinceEpoch,
        downloadSubtitles: Value(downloadSubtitles),
        downloadArtwork: Value(downloadArtwork),
      ),
      mode: InsertMode.insertOrReplace,
    );
  }

  Future<void> updateSupplementaryQueueIntent(
    String mediaGlobalKey, {
    required bool downloadSubtitles,
    required bool downloadArtwork,
  }) async {
    await (update(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).write(
      DownloadQueueCompanion(downloadSubtitles: Value(downloadSubtitles), downloadArtwork: Value(downloadArtwork)),
    );
  }

  /// Atomically admits a durable media row and its executable queue item.
  ///
  /// Existing active, paused, and completed media rows are never rewritten.
  /// Failed, cancelled, and partial attempts keep their stable row identity
  /// and physical-file fields while their request and attempt state is refreshed.
  Future<QueueDownloadOutcome> insertQueuedDownload({
    required ServerId serverId,
    String? clientScopeId,
    required String ratingKey,
    required String globalKey,
    required String type,
    String? parentRatingKey,
    String? grandparentRatingKey,
    int mediaIndex = 0,
    String? mediaSourceId,
    int priority = 0,
    bool downloadSubtitles = true,
    bool downloadArtwork = true,
  }) {
    return transaction(() async {
      final admitted = await customUpdate(
        '''
        INSERT INTO downloaded_media (
          server_id,
          client_scope_id,
          rating_key,
          global_key,
          type,
          parent_rating_key,
          grandparent_rating_key,
          status,
          media_index,
          media_source_id
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(global_key) DO UPDATE SET
          server_id = excluded.server_id,
          client_scope_id = excluded.client_scope_id,
          rating_key = excluded.rating_key,
          type = excluded.type,
          parent_rating_key = excluded.parent_rating_key,
          grandparent_rating_key = excluded.grandparent_rating_key,
          status = excluded.status,
          progress = 0,
          total_bytes = NULL,
          downloaded_bytes = 0,
          error_message = NULL,
          retry_count = 0,
          bg_task_id = NULL,
          media_index = excluded.media_index,
          media_source_id = excluded.media_source_id
        WHERE downloaded_media.status IN (?, ?, ?)
        ''',
        variables: [
          Variable<String>(serverId),
          Variable<String>(clientScopeId),
          Variable<String>(ratingKey),
          Variable<String>(globalKey),
          Variable<String>(type),
          Variable<String>(parentRatingKey),
          Variable<String>(grandparentRatingKey),
          Variable<int>(DownloadStatus.queued.index),
          Variable<int>(mediaIndex),
          Variable<String>(mediaSourceId),
          Variable<int>(DownloadStatus.failed.index),
          Variable<int>(DownloadStatus.cancelled.index),
          Variable<int>(DownloadStatus.partial.index),
        ],
        updates: {downloadedMedia},
      );

      if (admitted > 0) {
        await addToQueue(
          mediaGlobalKey: globalKey,
          priority: priority,
          downloadSubtitles: downloadSubtitles,
          downloadArtwork: downloadArtwork,
        );
        return QueueDownloadOutcome.admitted;
      }

      final current = await getDownloadedMedia(globalKey);
      if (current?.status == DownloadStatus.queued.index) {
        await addToQueue(
          mediaGlobalKey: globalKey,
          priority: priority,
          downloadSubtitles: downloadSubtitles,
          downloadArtwork: downloadArtwork,
        );
        return QueueDownloadOutcome.alreadyQueued;
      }
      return QueueDownloadOutcome.unchanged;
    });
  }

  /// Restores queue items omitted by legacy non-atomic queue creation.
  ///
  /// Existing queue rows are never rewritten because they retain the original
  /// priority and supplementary-download policy.
  Future<int> repairMissingQueuedDownloadEntries() async {
    return transaction(() async {
      final queuedMedia = await (select(
        downloadedMedia,
      )..where((t) => t.status.equals(DownloadStatus.queued.index))).get();
      if (queuedMedia.isEmpty) return 0;

      final existingKeys = (await select(downloadQueue).get()).map((row) => row.mediaGlobalKey).toSet();
      var repaired = 0;
      for (final media in queuedMedia) {
        if (existingKeys.contains(media.globalKey)) continue;
        await addToQueue(mediaGlobalKey: media.globalKey);
        existingKeys.add(media.globalKey);
        repaired++;
      }
      return repaired;
    });
  }

  /// Get next item from queue (highest priority, oldest first)
  /// Only returns items that are not paused
  Future<DownloadQueueItem?> getNextQueueItem() async {
    final query = select(
      downloadQueue,
    ).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);

    query
      ..where(downloadedMedia.status.equals(DownloadStatus.queued.index))
      ..orderBy([
        OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc),
        OrderingTerm(expression: downloadQueue.addedAt),
      ])
      ..limit(1);

    final result = await query.getSingleOrNull();
    return result?.readTable(downloadQueue);
  }

  /// Completed videos whose retained queue row records unsettled
  /// supplementary download intent.
  Future<List<DownloadQueueItem>> getPendingSupplementaryQueueItems() async {
    final query = select(
      downloadQueue,
    ).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
    query
      ..where(downloadedMedia.status.equals(DownloadStatus.completed.index) & downloadedMedia.videoFilePath.isNotNull())
      ..orderBy([OrderingTerm(expression: downloadQueue.addedAt)]);
    final rows = await query.get();
    return rows.map((row) => row.readTable(downloadQueue)).toList(growable: false);
  }

  /// Fails every queued or active download after the target filesystem fills.
  ///
  /// Clearing native task ids and queue rows prevents startup recovery from
  /// immediately re-enqueueing work after partial files have been discarded.
  Future<int> failActiveDownloadsForStorageFull(String errorMessage) {
    return transaction(() async {
      final active = await (select(
        downloadedMedia,
      )..where((row) => row.status.isIn([DownloadStatus.queued.index, DownloadStatus.downloading.index]))).get();
      if (active.isEmpty) return 0;

      final globalKeys = active.map((item) => item.globalKey).toList(growable: false);
      await (update(downloadedMedia)..where((row) => row.globalKey.isIn(globalKeys))).write(
        DownloadedMediaCompanion(
          status: Value(DownloadStatus.failed.index),
          bgTaskId: const Value(null),
          errorMessage: Value(errorMessage),
        ),
      );
      await (delete(downloadQueue)..where((row) => row.mediaGlobalKey.isIn(globalKeys))).go();
      return globalKeys.length;
    });
  }

  Future<void> updateDownloadStatus(String globalKey, int status) async {
    await (update(
      downloadedMedia,
    )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
  }

  Future<void> updateDownloadMediaSource(String globalKey, String? mediaSourceId) async {
    await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
      DownloadedMediaCompanion(mediaSourceId: Value(mediaSourceId)),
    );
  }

  Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
    await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
      DownloadedMediaCompanion(
        progress: Value(progress),
        downloadedBytes: Value(downloadedBytes),
        totalBytes: Value(totalBytes),
      ),
    );
  }

  Future<void> updateVideoFilePath(String globalKey, String filePath) async {
    await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
      DownloadedMediaCompanion(
        videoFilePath: Value(filePath),
        downloadedAt: Value(DateTime.now().millisecondsSinceEpoch),
      ),
    );
  }

  Future<void> updateDownloadSafRoot(String globalKey, String? safRootUri) async {
    await (update(
      downloadedMedia,
    )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(safRootUri: Value(safRootUri)));
  }

  Future<int> countDownloadsReferencingSafRoot(String safRootUri) async {
    final count = downloadedMedia.id.count();
    final query = selectOnly(downloadedMedia)
      ..addColumns([count])
      ..where(downloadedMedia.safRootUri.equals(safRootUri));
    return (await query.map((row) => row.read(count) ?? 0).getSingle());
  }

  @visibleForTesting
  Future<Set<String>> getReferencedDownloadSafRoots() async {
    final rows =
        await (selectOnly(downloadedMedia)
              ..addColumns([downloadedMedia.safRootUri])
              ..where(downloadedMedia.safRootUri.isNotNull()))
            .map((row) => row.read(downloadedMedia.safRootUri))
            .get();
    return rows.whereType<String>().toSet();
  }

  Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
    await (update(
      downloadedMedia,
    )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
  }

  Future<void> updateDownloadError(String globalKey, String errorMessage) async {
    final existing = await getDownloadedMedia(globalKey);
    final currentCount = existing?.retryCount ?? 0;

    await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
      DownloadedMediaCompanion(errorMessage: Value(errorMessage), retryCount: Value(currentCount + 1)),
    );
  }

  Future<void> clearDownloadError(String globalKey) async {
    await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
      const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
    );
  }

  Future<void> removeFromQueue(String mediaGlobalKey) async {
    await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
  }

  Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
    return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
  }

  /// Removes the physical row and all dependent queue/owner state atomically,
  /// returning the row's SAF root only after the transaction commits.
  ///
  /// The caller owns persisted-grant reconciliation after this returns.
  Future<String?> deleteDownload(String globalKey) async {
    late String? safRootUri;
    await transaction(() async {
      safRootUri = (await getDownloadedMedia(globalKey))?.safRootUri;
      await (delete(syncRuleDownloads)..where((t) => t.downloadGlobalKey.equals(globalKey))).go();
      await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
      await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
      await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
    });
    return safRootUri;
  }

  Future<List<DownloadedMediaItem>> getEpisodesBySeason(
    String seasonKey, {
    ServerId? serverId,
    String? clientScopeId,
    bool filterClientScope = false,
  }) {
    return (select(downloadedMedia)..where(
          (t) =>
              t.parentRatingKey.equals(seasonKey) &
              _optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
              _optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
        ))
        .get();
  }

  Future<List<DownloadedMediaItem>> getEpisodesByShow(
    String showKey, {
    ServerId? serverId,
    String? clientScopeId,
    bool filterClientScope = false,
  }) {
    return (select(downloadedMedia)..where(
          (t) =>
              t.grandparentRatingKey.equals(showKey) &
              _optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
              _optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
        ))
        .get();
  }

  /// Downloaded tracks belonging to an album (parentRatingKey). Mirrors
  /// [getEpisodesBySeason] but filters on type so an id collision with a
  /// season key can never mix media kinds.
  Future<List<DownloadedMediaItem>> getTracksByAlbum(
    String albumKey, {
    ServerId? serverId,
    String? clientScopeId,
    bool filterClientScope = false,
  }) {
    return (select(downloadedMedia)..where(
          (t) =>
              t.type.equals('track') &
              t.parentRatingKey.equals(albumKey) &
              _optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
              _optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
        ))
        .get();
  }

  /// Downloaded tracks belonging to an artist (grandparentRatingKey). Mirrors
  /// [getEpisodesByShow] with the same type filter as [getTracksByAlbum].
  Future<List<DownloadedMediaItem>> getTracksByArtist(
    String artistKey, {
    ServerId? serverId,
    String? clientScopeId,
    bool filterClientScope = false,
  }) {
    return (select(downloadedMedia)..where(
          (t) =>
              t.type.equals('track') &
              t.grandparentRatingKey.equals(artistKey) &
              _optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
              _optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
        ))
        .get();
  }

  Future<List<DownloadedMediaItem>> getDownloadsByServerId(ServerId serverId) {
    return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
  }

  Expression<bool> _optionalServerPredicate(GeneratedColumn<String> column, ServerId? serverId) {
    return serverId == null ? const Constant(true) : column.equals(serverId);
  }

  Expression<bool> _optionalClientScopePredicate(
    GeneratedColumn<String> column,
    String? clientScopeId, {
    required bool filterClientScope,
  }) {
    if (!filterClientScope && (clientScopeId == null || clientScopeId.isEmpty)) {
      return const Constant(true);
    }
    if (clientScopeId == null || clientScopeId.isEmpty) {
      return column.isNull() | column.equals('');
    }
    return column.equals(clientScopeId);
  }

  Future<void> updateBgTaskId(String globalKey, String? taskId) async {
    await (update(
      downloadedMedia,
    )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
  }

  Future<String?> getBgTaskId(String globalKey) async {
    final item = await getDownloadedMedia(globalKey);
    return item?.bgTaskId;
  }
}

bool _isValidDownloadOwner(
  DownloadOwnerItem owner, {
  required Set<String> localProfileIds,
  required Set<String> connectionIds,
}) {
  if (localProfileIds.contains(owner.profileId)) return true;
  final plexHome = parsePlexHomeProfileId(owner.profileId);
  if (plexHome != null) return connectionIds.contains(plexHome.accountConnectionId);
  return localProfileIds.isEmpty;
}

({String machineId, String? userId}) _jellyfinConnectionIdentity(ConnectionRow connection) {
  final separator = connection.id.indexOf('/');
  var machineId = separator < 0 ? connection.id : connection.id.substring(0, separator);
  String? userId = separator < 0 || separator == connection.id.length - 1
      ? null
      : connection.id.substring(separator + 1);
  try {
    final config = jsonDecode(connection.configJson);
    if (config is Map<String, dynamic>) {
      final configuredMachineId = config['serverMachineId'];
      final configuredUserId = config['userId'];
      if (configuredMachineId is String && configuredMachineId.isNotEmpty) machineId = configuredMachineId;
      if (configuredUserId is String && configuredUserId.isNotEmpty) userId = configuredUserId;
    }
  } on FormatException {
    // Legacy rows still carry enough identity in their canonical id.
  }
  return (machineId: machineId, userId: userId);
}
