import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/io_client.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/database/tvos_database_recovery_store.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/credential_vault.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/utils/active_client_scope.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';

import '../test_helpers/download_fixtures.dart';
import '../test_helpers/prefs.dart';

final class _EnsureOpenTrackingInterceptor extends QueryInterceptor {
  var calls = 0;
  var completed = false;

  @override
  Future<bool> ensureOpen(QueryExecutor executor, QueryExecutorUser user) async {
    calls++;
    final result = await executor.ensureOpen(user);
    completed = true;
    return result;
  }
}

void main() {
  final suite = _AppDatabaseTestSuite();
  suite.registerTests();
}

class _AppDatabaseTestSuite {
  late AppDatabase db;

  void registerTests() {
    setUp(() {
      db = AppDatabase.forTesting(NativeDatabase.memory());
    });

    tearDown(() async {
      await db.close();
    });

    _registerSchemaTests();
    _registerApiCacheTests();
    _registerDownloadedMediaTests();
    _registerOfflineWatchProgressTests();
    _registerSyncRulesTests();
  }

  void _registerSchemaTests() {
    // ============================================================
    // Schema sanity
    // ============================================================

    group('schema', () {
      test('all tables are accessible and start empty', () async {
        expect(await db.select(db.downloadedMedia).get(), isEmpty);
        expect(await db.select(db.downloadOwners).get(), isEmpty);
        expect(await db.select(db.downloadQueue).get(), isEmpty);
        expect(await db.select(db.apiCache).get(), isEmpty);
        expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
        expect(await db.select(db.syncRules).get(), isEmpty);
        expect(await db.select(db.connections).get(), isEmpty);
        expect(await db.select(db.profiles).get(), isEmpty);
        expect(await db.select(db.profileConnections).get(), isEmpty);
      });

      test('ProfileConnections has no profile_id FK (virtual plex_home profiles)', () async {
        // v20 dropped the profile_id FK so virtual Plex Home profiles can
        // persist join rows without a parent `profiles` row. Profile deletion
        // instead cleans up join rows explicitly (via the teardown flow's
        // ProfileConnectionCleanup.removeAllProfileConnections) before deleting
        // the profile, so the cascade isn't needed.
        final now = DateTime.now().millisecondsSinceEpoch;
        await db
            .into(db.connections)
            .insert(
              ConnectionsCompanion.insert(id: 'c1', kind: 'plex', displayName: 'C1', configJson: '{}', createdAt: now),
            );
        // No `profiles` row inserted — this would have failed pre-v20.
        await db
            .into(db.profileConnections)
            .insert(
              ProfileConnectionsCompanion.insert(
                profileId: 'plex-home-c1-uuid',
                connectionId: 'c1',
                userIdentifier: 'uuid',
              ),
            );
        expect(await db.select(db.profileConnections).get(), hasLength(1));
      });

      test('ProfileConnections FK cascades when a Connection is deleted', () async {
        final now = DateTime.now().millisecondsSinceEpoch;
        await db
            .into(db.connections)
            .insert(
              ConnectionsCompanion.insert(id: 'c2', kind: 'plex', displayName: 'C2', configJson: '{}', createdAt: now),
            );
        await db
            .into(db.profiles)
            .insert(
              ProfilesCompanion.insert(id: 'p2', kind: 'local', displayName: 'P2', configJson: '{}', createdAt: now),
            );
        await db
            .into(db.profileConnections)
            .insert(ProfileConnectionsCompanion.insert(profileId: 'p2', connectionId: 'c2', userIdentifier: 'u2'));

        await (db.delete(db.connections)..where((t) => t.id.equals('c2'))).go();
        expect(await db.select(db.profileConnections).get(), isEmpty);
      });

      test('hot-query indices exist in sqlite_master', () async {
        // sqlite_master rows let us assert the indices physically exist
        // without depending on Drift's `Migrator` having run them.
        final rows = await db.customSelect("SELECT name FROM sqlite_master WHERE type = 'index'").get();
        final names = rows.map((r) => r.read<String>('name')).toSet();
        expect(
          names,
          containsAll(['idx_profiles_kind', 'idx_profile_connections_profile_id', 'idx_offline_watch_progress_server']),
        );
      });

      test('retried v14 migration tolerates existing indices', () async {
        await db.close();
        final tempDir = await Directory.systemTemp.createTemp('plezy_db_migration_test_');
        final file = File('${tempDir.path}/plezy_downloads.db');
        AppDatabase? seeded;
        AppDatabase? reopened;

        try {
          seeded = AppDatabase.forTesting(NativeDatabase(file));
          await seeded.select(seeded.connections).get();
          await seeded.customStatement('PRAGMA user_version = 13');
          await seeded.close();
          seeded = null;

          reopened = AppDatabase.forTesting(NativeDatabase(file));
          expect(await reopened.select(reopened.connections).get(), isEmpty);
          final rows = await reopened
              .customSelect("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_connections_kind'")
              .get();
          expect(rows, hasLength(1));
        } finally {
          await reopened?.close();
          await seeded?.close();
          await tempDir.delete(recursive: true);
          db = AppDatabase.forTesting(NativeDatabase.memory());
        }
      });
      test('v17 migration scopes pinned Plex metadata for every owner and removes bare rows', () async {
        await db.close();
        final tempDir = await Directory.systemTemp.createTemp('plezy_db_v17_migration_test_');
        final file = File('${tempDir.path}/plezy_downloads.db');
        AppDatabase? seeded;
        AppDatabase? reopened;
        AppDatabase? reopenedAgain;

        Future<void> expectMigratedState(AppDatabase database) async {
          final cacheKeys = (await database.select(database.apiCache).get()).map((row) => row.cacheKey).toSet();
          expect(cacheKeys, {
            'plex-server:/library/metadata/item/extras',
            'plex-server:/library/sections/1/all',
            'plex-server/~plex-profile/profile-a:/library/metadata/item',
            'plex-server/~plex-profile/profile-b:/library/metadata/item',
            'plex-server/~plex-profile/profile-a:/library/metadata/item/children',
            'plex-server/~plex-profile/profile-b:/library/metadata/item/children',
          });
          final downloads = await database.select(database.downloadedMedia).get();
          expect(downloads.map((row) => row.globalKey), ['plex-server:item']);
          expect(await database.getDownloadOwnerKeysForProfile('profile-a'), {'plex-server:item'});
          expect(await database.getDownloadOwnerKeysForProfile('profile-b'), {'plex-server:item'});
          expect(await database.getDownloadOwnerCount('plex-server:item'), 2);
        }

        try {
          seeded = AppDatabase.forTesting(NativeDatabase(file));
          await seeded.select(seeded.apiCache).get();
          final database = seeded;
          Future<void> insertCache(String key, {required bool pinned}) {
            return database
                .into(database.apiCache)
                .insert(ApiCacheCompanion.insert(cacheKey: key, data: '{}', pinned: Value(pinned)));
          }

          await insertCache('plex-server:/library/metadata/item', pinned: true);
          await insertCache('plex-server:/library/metadata/item/children', pinned: true);
          await insertCache('plex-server:/library/metadata/unpinned', pinned: false);
          await insertCache('plex-server:/library/metadata/item/extras', pinned: true);
          await insertCache('plex-server:/library/sections/1/all', pinned: true);
          await insertCache('plex-server/~plex-profile/profile-a:/library/metadata/item', pinned: true);
          await seeded
              .into(seeded.downloadedMedia)
              .insert(
                DownloadedMediaCompanion.insert(
                  serverId: 'plex-server',
                  ratingKey: 'item',
                  globalKey: 'plex-server:item',
                  type: 'movie',
                  status: DownloadStatus.completed.index,
                  videoFilePath: const Value('/synthetic/download/item.mkv'),
                ),
              );
          await seeded.addDownloadOwner(profileId: 'profile-a', globalKey: 'plex-server:item');
          await seeded.addDownloadOwner(profileId: 'profile-b', globalKey: 'plex-server:item');
          await seeded.customStatement('PRAGMA user_version = 16');
          await seeded.close();
          seeded = null;

          reopened = AppDatabase.forTesting(NativeDatabase(file));
          await expectMigratedState(reopened);
          await reopened.close();
          reopened = null;

          reopenedAgain = AppDatabase.forTesting(NativeDatabase(file));
          await expectMigratedState(reopenedAgain);
        } finally {
          await reopenedAgain?.close();
          await reopened?.close();
          await seeded?.close();
          await tempDir.delete(recursive: true);
          db = AppDatabase.forTesting(NativeDatabase.memory());
        }
      });
      test(
        'schema-v13 ownerless Plex download upgrades through sanitized transfer scope and stays adoptable on retry',
        () async {
          await db.close();
          final tempDir = await Directory.systemTemp.createTemp('plezy_db_v13_plex_transfer_test_');
          final file = File('${tempDir.path}/plezy_downloads.db');
          AppDatabase? seeded;
          AppDatabase? reopened;
          AppDatabase? reopenedAgain;
          DownloadManagerService? manager;
          MediaServerHttpClient? testHttp;
          const transferScope = 'plex-server/~plex-transfer';
          const privateFields = {
            'lastRatedAt',
            'lastViewedAt',
            'skipCount',
            'userRating',
            'viewCount',
            'viewOffset',
            'viewedLeafCount',
          };
          final leafPayload = jsonEncode({
            'MediaContainer': {
              'size': 1,
              'viewOffset': 9000,
              'Metadata': [
                {
                  'ratingKey': 'leaf',
                  'parentRatingKey': 'season',
                  'type': 'episode',
                  'title': 'Offline leaf',
                  'lastRatedAt': 1,
                  'lastViewedAt': 2,
                  'skipCount': 3,
                  'userRating': 8.5,
                  'viewCount': 4,
                  'viewOffset': 5000,
                  'viewedLeafCount': 6,
                  'nested': {'viewOffset': 777, 'safe': 'kept'},
                },
              ],
            },
          });
          final parentPayload = jsonEncode({
            'MediaContainer': {
              'Metadata': [
                {'ratingKey': 'season', 'type': 'season', 'title': 'Offline parent', 'viewCount': 5},
              ],
            },
          });

          Future<void> expectTransferState(AppDatabase database) async {
            final cacheRows = await database.select(database.apiCache).get();
            expect(cacheRows.map((row) => row.cacheKey).toSet(), {
              '$transferScope:/library/metadata/leaf',
              '$transferScope:/library/metadata/season',
              'plex-server:/library/sections/1/all',
            });
            for (final row in cacheRows.where((row) => row.cacheKey.startsWith(transferScope))) {
              final payload = jsonDecode(row.data);
              expect(_recursiveJsonKeys(payload).intersection(privateFields), isEmpty);
              expect(row.pinned, isTrue);
            }
            final transferredLeaf = cacheRows.singleWhere(
              (row) => row.cacheKey == '$transferScope:/library/metadata/leaf',
            );
            final leaf = jsonDecode(transferredLeaf.data) as Map<String, dynamic>;
            final leafMetadata =
                ((leaf['MediaContainer'] as Map<String, dynamic>)['Metadata'] as List).single as Map<String, dynamic>;
            expect(leafMetadata['title'], 'Offline leaf');
            expect((leafMetadata['nested'] as Map<String, dynamic>)['safe'], 'kept');

            final transferredParent = cacheRows.singleWhere(
              (row) => row.cacheKey == '$transferScope:/library/metadata/season',
            );
            final parent = jsonDecode(transferredParent.data) as Map<String, dynamic>;
            final parentMetadata =
                ((parent['MediaContainer'] as Map<String, dynamic>)['Metadata'] as List).single as Map<String, dynamic>;
            expect(parentMetadata['title'], 'Offline parent');

            final download = await database.select(database.downloadedMedia).getSingle();
            expect(download.globalKey, 'plex-server:leaf');
            expect(download.status, DownloadStatus.completed.index);
            expect(download.videoFilePath, '/offline/leaf.mkv');
            expect(download.clientScopeId, transferScope);
          }

          try {
            seeded = AppDatabase.forTesting(NativeDatabase(file));
            await _createSchemaV13Fixture(seeded);
            await seeded.customStatement(
              '''
                INSERT INTO downloaded_media (
                  server_id, rating_key, global_key, type, parent_rating_key, status, video_file_path
                ) VALUES (?, ?, ?, ?, ?, ?, ?)
              ''',
              [
                'plex-server',
                'leaf',
                'plex-server:leaf',
                'episode',
                'season',
                DownloadStatus.completed.index,
                '/offline/leaf.mkv',
              ],
            );
            for (final entry in {
              'plex-server:/library/metadata/leaf': leafPayload,
              'plex-server:/library/metadata/season': parentPayload,
              'plex-server:/library/metadata/orphan': jsonEncode({
                'MediaContainer': {
                  'Metadata': [
                    {'ratingKey': 'orphan', 'title': 'Must be removed'},
                  ],
                },
              }),
              'plex-server:/library/sections/1/all': '{}',
            }.entries) {
              await seeded.customStatement('INSERT INTO api_cache (cache_key, data, pinned) VALUES (?, ?, 1)', [
                entry.key,
                entry.value,
              ]);
            }
            await seeded.close();
            seeded = null;

            reopened = AppDatabase.forTesting(NativeDatabase(file));
            await expectTransferState(reopened);
            expect(await reopened.select(reopened.downloadOwners).get(), isEmpty);

            // Exercise the v17 statements a second time against their own
            // output, matching an interrupted upgrade whose version write did
            // not survive.
            await reopened.customStatement('PRAGMA user_version = 16');
            await reopened.close();
            reopened = null;

            reopenedAgain = AppDatabase.forTesting(NativeDatabase(file));
            await expectTransferState(reopenedAgain);
            expect(await reopenedAgain.select(reopenedAgain.downloadOwners).get(), isEmpty);

            for (final profileId in const ['profile-a', 'profile-b']) {
              await reopenedAgain
                  .into(reopenedAgain.profiles)
                  .insert(
                    ProfilesCompanion.insert(
                      id: profileId,
                      kind: 'local',
                      displayName: profileId,
                      configJson: '{}',
                      createdAt: 1000,
                    ),
                  );
            }
            await reopenedAgain.adoptLegacyDownloadsForProfile('profile-a');
            await reopenedAgain.adoptLegacyDownloadsForProfile('profile-a');
            await reopenedAgain.adoptLegacyDownloadsForProfile('profile-b');

            final owners = await reopenedAgain.select(reopenedAgain.downloadOwners).get();
            expect(owners, hasLength(1));
            expect(owners.single.profileId, 'profile-a');
            expect(owners.single.globalKey, 'plex-server:leaf');
            expect(owners.single.backend, 'plex');
            expect(owners.single.clientScopeId, transferScope);
            expect((await reopenedAgain.getDownloadedMedia('plex-server:leaf'))?.clientScopeId, transferScope);
            PlexApiCache.initialize(reopenedAgain);
            testHttp = MediaServerHttpClient(client: IOClient());
            manager = DownloadManagerService(
              database: reopenedAgain,
              storageService: DownloadStorageService.instance,
              clientResolver: (_, {clientScopeId}) => null,
              http: testHttp,
            );
            await manager.adoptTransferredPlexMetadataForProfile('profile-a');
            await manager.adoptTransferredPlexMetadataForProfile('profile-a');

            const profileScope = 'plex-server/~plex-profile/profile-a';
            final adoptedOwner = await reopenedAgain.getDownloadOwner(
              profileId: 'profile-a',
              globalKey: 'plex-server:leaf',
            );
            expect(adoptedOwner?.backend, 'plex');
            expect(adoptedOwner?.clientScopeId, profileScope);
            expect((await reopenedAgain.getDownloadedMedia('plex-server:leaf'))?.clientScopeId, profileScope);
            final adoptedCacheRows = await reopenedAgain.select(reopenedAgain.apiCache).get();
            expect(adoptedCacheRows.map((row) => row.cacheKey).toSet(), {
              '$profileScope:/library/metadata/leaf',
              '$profileScope:/library/metadata/season',
              'plex-server:/library/sections/1/all',
            });
            expect(adoptedCacheRows.where((row) => row.cacheKey.startsWith(transferScope)), isEmpty);
            for (final row in adoptedCacheRows.where((row) => row.cacheKey.startsWith(profileScope))) {
              expect(_recursiveJsonKeys(jsonDecode(row.data)).intersection(privateFields), isEmpty);
            }
          } finally {
            manager?.dispose();
            testHttp?.close();
            await reopenedAgain?.close();
            await reopened?.close();
            await seeded?.close();
            await tempDir.delete(recursive: true);
            db = AppDatabase.forTesting(NativeDatabase.memory());
          }
        },
      );
      test('tvOS protects legacy connection and profile tokens before the first recovery snapshot', () async {
        await db.close();
        resetSharedPreferencesForTest();
        CredentialVault.resetKeyForTesting();
        final tempDir = await Directory.systemTemp.createTemp('plezy_db_credential_recovery_test_');
        final file = File('${tempDir.path}/plezy_downloads.db');
        final prefs = await BaseSharedPreferencesService.sharedCache();
        AppDatabase? seeded;
        AppDatabase? opened;
        const accountToken = 'legacy-account-token-canary';
        const serverToken = 'legacy-server-token-canary';
        const profileToken = 'legacy-profile-token-canary';

        Future<void> expectProtectedConfig(String configJson) async {
          final config = jsonDecode(configJson) as Map<String, dynamic>;
          final protectedAccountToken = config['accountToken'] as String;
          final protectedServerToken =
              ((config['servers'] as List).single as Map<String, dynamic>)['accessToken'] as String;
          expect(CredentialVault.isProtected(protectedAccountToken), isTrue);
          expect(CredentialVault.isProtected(protectedServerToken), isTrue);
          expect(await CredentialVault.reveal(protectedAccountToken), accountToken);
          expect(await CredentialVault.reveal(protectedServerToken), serverToken);
        }

        try {
          seeded = AppDatabase.forTesting(NativeDatabase(file));
          await seeded.select(seeded.connections).get();
          await seeded
              .into(seeded.connections)
              .insert(
                ConnectionsCompanion.insert(
                  id: 'plex-account',
                  kind: 'plex',
                  displayName: 'Legacy Plex',
                  configJson: jsonEncode({
                    'accountToken': accountToken,
                    'servers': [
                      {'machineIdentifier': 'plex-server', 'accessToken': serverToken},
                    ],
                  }),
                  createdAt: 1000,
                ),
              );
          await seeded
              .into(seeded.profiles)
              .insert(
                ProfilesCompanion.insert(
                  id: 'profile-a',
                  kind: 'local',
                  displayName: 'Profile A',
                  configJson: '{}',
                  createdAt: 1000,
                ),
              );
          await seeded
              .into(seeded.profileConnections)
              .insert(
                ProfileConnectionsCompanion.insert(
                  profileId: 'profile-a',
                  connectionId: 'plex-account',
                  userToken: const Value(profileToken),
                  userIdentifier: 'plex-user',
                ),
              );
          await seeded.close();
          seeded = null;

          final bootstrap = await AppDatabase.open(isTvos: true, databaseFile: file, preferences: prefs);
          opened = bootstrap.database;
          expect(bootstrap.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);

          final connectionRow = await opened.select(opened.connections).getSingle();
          final profileConnectionRow = await opened.select(opened.profileConnections).getSingle();
          await expectProtectedConfig(connectionRow.configJson);
          expect(CredentialVault.isProtected(profileConnectionRow.userToken), isTrue);
          expect(await CredentialVault.reveal(profileConnectionRow.userToken), profileToken);

          final identityRaw = prefs.getString(TvosDatabaseRecoveryStore.identityKey);
          expect(identityRaw, isNotNull);
          expect(identityRaw, isNot(contains(accountToken)));
          expect(identityRaw, isNot(contains(serverToken)));
          expect(identityRaw, isNot(contains(profileToken)));
          final envelope = jsonDecode(identityRaw!) as Map<String, dynamic>;
          final recoveryRows = envelope['rows'] as Map<String, dynamic>;
          final snapshotConnection = (recoveryRows['connections'] as List).single as Map<String, dynamic>;
          final snapshotJoin = (recoveryRows['profileConnections'] as List).single as Map<String, dynamic>;
          await expectProtectedConfig(snapshotConnection['configJson'] as String);
          final snapshotProfileToken = snapshotJoin['userToken'] as String;
          expect(CredentialVault.isProtected(snapshotProfileToken), isTrue);
          expect(await CredentialVault.reveal(snapshotProfileToken), profileToken);
        } finally {
          await opened?.close();
          await seeded?.close();
          CredentialVault.resetKeyForTesting();
          await tempDir.delete(recursive: true);
          db = AppDatabase.forTesting(NativeDatabase.memory());
        }
      });
      test('v18 migration preserves v17 downloads and adds nullable SAF roots', () async {
        await db.close();
        final tempDir = await Directory.systemTemp.createTemp('plezy_db_v18_migration_test_');
        final file = File('${tempDir.path}/plezy_downloads.db');
        AppDatabase? seeded;
        AppDatabase? reopened;

        try {
          seeded = AppDatabase.forTesting(NativeDatabase(file));
          await seeded.select(seeded.downloadedMedia).get();
          await seeded
              .into(seeded.downloadedMedia)
              .insert(
                DownloadedMediaCompanion.insert(
                  id: const Value(42),
                  serverId: 'server',
                  clientScopeId: const Value('scope'),
                  ratingKey: 'rating',
                  globalKey: 'server:rating',
                  type: 'episode',
                  parentRatingKey: const Value('season'),
                  grandparentRatingKey: const Value('show'),
                  status: DownloadStatus.paused.index,
                  progress: const Value(37),
                  totalBytes: const Value(900),
                  downloadedBytes: const Value(333),
                  videoFilePath: const Value('content://video'),
                  thumbPath: const Value('artwork-key'),
                  downloadedAt: const Value(123456),
                  errorMessage: const Value('retryable'),
                  retryCount: const Value(2),
                  bgTaskId: const Value('task'),
                  mediaIndex: const Value(3),
                  mediaSourceId: const Value('source'),
                ),
              );
          await seeded.customStatement('ALTER TABLE downloaded_media DROP COLUMN saf_root_uri');
          await seeded.customStatement('PRAGMA user_version = 17');
          await seeded.close();
          seeded = null;

          reopened = AppDatabase.forTesting(NativeDatabase(file));
          final row = await reopened.select(reopened.downloadedMedia).getSingle();
          expect(row.id, 42);
          expect(row.serverId, 'server');
          expect(row.clientScopeId, 'scope');
          expect(row.ratingKey, 'rating');
          expect(row.globalKey, 'server:rating');
          expect(row.type, 'episode');
          expect(row.parentRatingKey, 'season');
          expect(row.grandparentRatingKey, 'show');
          expect(row.status, DownloadStatus.paused.index);
          expect(row.progress, 37);
          expect(row.totalBytes, 900);
          expect(row.downloadedBytes, 333);
          expect(row.videoFilePath, 'content://video');
          expect(row.safRootUri, isNull);
          expect(row.thumbPath, 'artwork-key');
          expect(row.downloadedAt, 123456);
          expect(row.errorMessage, 'retryable');
          expect(row.retryCount, 2);
          expect(row.bgTaskId, 'task');
          expect(row.mediaIndex, 3);
          expect(row.mediaSourceId, 'source');
        } finally {
          await reopened?.close();
          await seeded?.close();
          await tempDir.delete(recursive: true);
          db = AppDatabase.forTesting(NativeDatabase.memory());
        }
      });

      test('v19 migration derives cache scope independently for every download owner', () async {
        await db.close();
        final tempDir = await Directory.systemTemp.createTemp('plezy_db_v19_migration_test_');
        final file = File('${tempDir.path}/plezy_downloads.db');
        AppDatabase? seeded;
        AppDatabase? reopened;

        try {
          seeded = AppDatabase.forTesting(NativeDatabase(file));
          await seeded.select(seeded.downloadOwners).get();
          final now = DateTime.now().millisecondsSinceEpoch;
          for (final id in const [
            'jf-machine/user-a',
            'jf-machine/user-b',
            'jf-machine/user-z',
            'jf-machine',
            'jf%_machine/user-exact',
            'jf-wildXmachine/user-wrong',
          ]) {
            await seeded
                .into(seeded.connections)
                .insert(
                  ConnectionsCompanion.insert(
                    id: id,
                    kind: 'jellyfin',
                    displayName: id,
                    configJson: '{}',
                    createdAt: now,
                  ),
                );
          }
          await seeded
              .into(seeded.profileConnections)
              .insert(
                ProfileConnectionsCompanion.insert(
                  profileId: 'profile-a',
                  connectionId: 'jf-machine/user-a',
                  userIdentifier: 'user-a',
                ),
              );
          await seeded
              .into(seeded.profileConnections)
              .insert(
                ProfileConnectionsCompanion.insert(
                  profileId: 'profile-a',
                  connectionId: 'jf-machine/user-z',
                  userIdentifier: 'user-z',
                  isDefault: const Value(true),
                  lastUsedAt: Value(now),
                ),
              );
          await seeded
              .into(seeded.profileConnections)
              .insert(
                ProfileConnectionsCompanion.insert(
                  profileId: 'profile-b',
                  connectionId: 'jf-machine/user-b',
                  userIdentifier: 'user-b',
                ),
              );
          await seeded
              .into(seeded.profileConnections)
              .insert(
                ProfileConnectionsCompanion.insert(
                  profileId: 'profile-c',
                  connectionId: 'jf-machine',
                  userIdentifier: 'user-c',
                ),
              );
          await seeded
              .into(seeded.profileConnections)
              .insert(
                ProfileConnectionsCompanion.insert(
                  profileId: 'profile-literal',
                  connectionId: 'jf%_machine/user-exact',
                  userIdentifier: 'user-exact',
                ),
              );
          await seeded
              .into(seeded.profileConnections)
              .insert(
                ProfileConnectionsCompanion.insert(
                  profileId: 'profile-unrelated',
                  connectionId: 'jf-wildXmachine/user-wrong',
                  userIdentifier: 'user-wrong',
                ),
              );
          await seeded
              .into(seeded.downloadedMedia)
              .insert(
                DownloadedMediaCompanion.insert(
                  serverId: 'plex-server',
                  clientScopeId: const Value('plex-server/~plex-profile/profile-a'),
                  ratingKey: 'plex-item',
                  globalKey: 'plex-server:plex-item',
                  type: 'movie',
                  status: DownloadStatus.completed.index,
                ),
              );
          await seeded
              .into(seeded.downloadedMedia)
              .insert(
                DownloadedMediaCompanion.insert(
                  serverId: 'jf-machine',
                  clientScopeId: const Value('jf-machine/user-a'),
                  ratingKey: 'jf-item',
                  globalKey: 'jf-machine:jf-item',
                  type: 'movie',
                  status: DownloadStatus.completed.index,
                ),
              );
          await seeded
              .into(seeded.downloadedMedia)
              .insert(
                DownloadedMediaCompanion.insert(
                  serverId: 'jf%_machine',
                  clientScopeId: const Value('jf%_machine/legacy-user'),
                  ratingKey: 'literal-item',
                  globalKey: 'jf%_machine:literal-item',
                  type: 'movie',
                  status: DownloadStatus.completed.index,
                ),
              );
          for (final profileId in const ['profile-a', 'profile-b', 'profile-c']) {
            await seeded.addDownloadOwner(profileId: profileId, globalKey: 'plex-server:plex-item');
            await seeded.addDownloadOwner(profileId: profileId, globalKey: 'jf-machine:jf-item');
          }
          await seeded.addDownloadOwner(profileId: 'profile-literal', globalKey: 'jf%_machine:literal-item');
          await seeded.addDownloadOwner(profileId: 'profile-unrelated', globalKey: 'jf%_machine:literal-item');
          await seeded.customStatement('ALTER TABLE download_owners DROP COLUMN backend');
          await seeded.customStatement('ALTER TABLE download_owners DROP COLUMN client_scope_id');
          await seeded.customStatement('PRAGMA user_version = 18');
          await seeded.close();
          seeded = null;

          resetSharedPreferencesForTest();
          final prefs = await BaseSharedPreferencesService.sharedCache();
          final openTracker = _EnsureOpenTrackingInterceptor();
          final bootstrap = await AppDatabase.open(
            isTvos: false,
            databaseFile: file,
            preferences: prefs,
            executorFactory: (databaseFile) => NativeDatabase(databaseFile).interceptWith(openTracker),
          );
          reopened = bootstrap.database;
          expect(openTracker.calls, greaterThanOrEqualTo(1));
          expect(openTracker.completed, isTrue);
          final owners = await reopened.select(reopened.downloadOwners).get();
          DownloadOwnerItem owner(String profileId, String globalKey) =>
              owners.singleWhere((row) => row.profileId == profileId && row.globalKey == globalKey);

          expect(owner('profile-a', 'plex-server:plex-item').backend, 'plex');
          expect(owner('profile-a', 'plex-server:plex-item').clientScopeId, 'plex-server/~plex-profile/profile-a');
          expect(owner('profile-b', 'plex-server:plex-item').backend, 'plex');
          expect(owner('profile-b', 'plex-server:plex-item').clientScopeId, 'plex-server/~plex-profile/profile-b');
          expect(owner('profile-a', 'jf-machine:jf-item').backend, 'jellyfin');
          expect(owner('profile-a', 'jf-machine:jf-item').clientScopeId, 'jf-machine/user-z');
          expect(owner('profile-b', 'jf-machine:jf-item').backend, 'jellyfin');
          expect(owner('profile-b', 'jf-machine:jf-item').clientScopeId, 'jf-machine/user-b');
          expect(owner('profile-c', 'jf-machine:jf-item').backend, 'jellyfin');
          expect(owner('profile-c', 'jf-machine:jf-item').clientScopeId, 'jf-machine/user-c');
          expect(owner('profile-literal', 'jf%_machine:literal-item').backend, 'jellyfin');
          expect(owner('profile-literal', 'jf%_machine:literal-item').clientScopeId, 'jf%_machine/user-exact');
          expect(owner('profile-unrelated', 'jf%_machine:literal-item').backend, isNull);
          expect(owner('profile-unrelated', 'jf%_machine:literal-item').clientScopeId, isNull);
        } finally {
          await reopened?.close();
          await seeded?.close();
          await tempDir.delete(recursive: true);
          db = AppDatabase.forTesting(NativeDatabase.memory());
        }
      });
      test('v20 migration creates sync download associations without claiming legacy rules', () async {
        await db.close();
        final tempDir = await Directory.systemTemp.createTemp('plezy_db_v20_migration_test_');
        final file = File('${tempDir.path}/plezy_downloads.db');
        AppDatabase? seeded;
        AppDatabase? reopened;

        try {
          seeded = AppDatabase.forTesting(NativeDatabase(file));
          await seeded.select(seeded.syncRuleDownloads).get();
          await seeded.insertSyncRule(
            profileId: 'profile-a',
            serverId: ServerId('server'),
            ratingKey: 'playlist',
            globalKey: 'profile-a|server:playlist',
            targetType: 'playlist',
            episodeCount: 0,
          );
          await seeded.customStatement('DROP TABLE sync_rule_downloads');
          await seeded.customStatement('ALTER TABLE sync_rules DROP COLUMN download_links_initialized');
          await seeded.customStatement('PRAGMA user_version = 19');
          await seeded.close();
          seeded = null;

          reopened = AppDatabase.forTesting(NativeDatabase(file));
          final legacyRule = await reopened.getSyncRule('profile-a|server:playlist');
          expect(legacyRule, isNotNull);
          expect(legacyRule!.downloadLinksInitialized, isFalse);
          expect(await reopened.select(reopened.syncRuleDownloads).get(), isEmpty);

          await reopened.insertDownload(
            serverId: ServerId('server'),
            ratingKey: 'episode',
            globalKey: 'server:episode',
            type: 'episode',
            status: DownloadStatus.completed.index,
          );
          await reopened.associateSyncRuleDownload(legacyRule, 'server:episode');
          expect(await reopened.getSyncRuleDownloadLinks(legacyRule.id), hasLength(1));

          await reopened.deleteSyncRule(legacyRule.globalKey);
          expect(await reopened.getSyncRuleDownloadLinks(legacyRule.id), isEmpty);
        } finally {
          await reopened?.close();
          await seeded?.close();
          await tempDir.delete(recursive: true);
          db = AppDatabase.forTesting(NativeDatabase.memory());
        }
      });
    });

    _registerLegacyDesktopMigrationTests();
  }

  void _registerLegacyDesktopMigrationTests() {
    // ============================================================
    // Legacy desktop DB-file relocation (Documents → AppSupport).
    // Regression coverage for #1022: cross-drive rename (e.g. OneDrive
    // Documents on X:, AppData on C:) used to throw an uncaught
    // FileSystemException out of _openConnection and strand the splash.
    // ============================================================

    group('legacy desktop DB migration', () {
      late Directory tempDir;

      setUp(() async {
        tempDir = await Directory.systemTemp.createTemp('plezy_legacy_migration_test_');
      });

      tearDown(() async {
        if (await tempDir.exists()) {
          await tempDir.delete(recursive: true);
        }
      });

      test('no-op when source does not exist', () async {
        final source = File('${tempDir.path}/Documents/plezy_downloads.db');
        final target = File('${tempDir.path}/AppData/plezy_downloads.db');
        await target.parent.create(recursive: true);

        await migrateLegacyDesktopDatabase(sourceOverride: source, target: target);

        expect(await source.exists(), isFalse);
        expect(await target.exists(), isFalse);
      });

      test('rename happy path moves the file and preserves content', () async {
        final source = File('${tempDir.path}/Documents/plezy_downloads.db');
        final target = File('${tempDir.path}/AppData/plezy_downloads.db');
        await source.parent.create(recursive: true);
        await target.parent.create(recursive: true);
        await source.writeAsBytes([1, 2, 3, 4, 5]);

        await migrateLegacyDesktopDatabase(sourceOverride: source, target: target);

        expect(await source.exists(), isFalse);
        expect(await target.exists(), isTrue);
        expect(await target.readAsBytes(), [1, 2, 3, 4, 5]);
      });

      test('cross-drive rename failure falls back to copy + delete', () async {
        // Simulate Windows ERROR_NOT_SAME_DEVICE by throwing the same
        // exception shape `File.rename` would emit when source and target
        // live on different volumes.
        final source = File('${tempDir.path}/Documents/plezy_downloads.db');
        final target = File('${tempDir.path}/AppData/plezy_downloads.db');
        await source.parent.create(recursive: true);
        await target.parent.create(recursive: true);
        await source.writeAsBytes([9, 8, 7]);

        await migrateLegacyDesktopDatabase(
          sourceOverride: source,
          target: target,
          renameOverride: (_, _) => throw const FileSystemException(
            'Cannot rename file across drives',
            '',
            OSError('The system cannot move the file to a different disk drive', 17),
          ),
        );

        expect(await source.exists(), isFalse, reason: 'source should be deleted after successful copy');
        expect(await target.exists(), isTrue);
        expect(await target.readAsBytes(), [9, 8, 7]);
      });

      test('interrupted fallback copy leaves no canonical partial file and retry succeeds', () async {
        final source = File('${tempDir.path}/Documents/plezy_downloads.db');
        final target = File('${tempDir.path}/AppData/plezy_downloads.db');
        const sourceBytes = [0xAA, 0xBB, 0xCC, 0xDD];
        await source.parent.create(recursive: true);
        await target.parent.create(recursive: true);
        await source.writeAsBytes(sourceBytes);

        File? partialTemporary;
        await expectLater(
          migrateLegacyDesktopDatabase(
            sourceOverride: source,
            target: target,
            renameOverride: (_, _) => throw const FileSystemException('cross-drive', ''),
            copyOverride: (_, temporary) async {
              partialTemporary = temporary;
              final output = await temporary.open(mode: FileMode.writeOnly);
              try {
                await output.writeFrom(sourceBytes, 0, 2);
                await output.flush();
              } finally {
                await output.close();
              }
              throw const FileSystemException('interrupted copy', '');
            },
          ),
          completes,
        );

        expect(await source.exists(), isTrue, reason: 'the intact legacy database must remain retryable');
        expect(await source.readAsBytes(), sourceBytes);
        expect(await target.exists(), isFalse, reason: 'a partial copy must never become canonical');
        expect(partialTemporary, isNotNull);
        expect(await partialTemporary!.exists(), isFalse, reason: 'failed temporary copies must be cleaned');

        await migrateLegacyDesktopDatabase(
          sourceOverride: source,
          target: target,
          renameOverride: (_, _) => throw const FileSystemException('cross-drive', ''),
        );

        expect(await source.exists(), isFalse, reason: 'successful retry removes the legacy database');
        expect(await target.readAsBytes(), sourceBytes);
      });

      test('overlapping fallback publisher cannot replace canonical updates with its stale copy', () async {
        final staleSource = File('${tempDir.path}/Documents/stale.db');
        final winnerSource = File('${tempDir.path}/Documents/winner.db');
        final target = File('${tempDir.path}/AppData/plezy_downloads.db');
        const staleBytes = [0x10, 0x20, 0x30];
        const winnerBytes = [0x40, 0x50, 0x60];
        const updatedCanonicalBytes = [0x70, 0x80, 0x90];
        await staleSource.parent.create(recursive: true);
        await target.parent.create(recursive: true);
        await staleSource.writeAsBytes(staleBytes);
        await winnerSource.writeAsBytes(winnerBytes);

        final staleCopyReady = Completer<void>();
        final releaseStaleCopy = Completer<void>();
        final staleCopyReleased = Completer<void>();
        final winnerPublished = Completer<void>();
        var stalePublishAttempted = false;

        Future<void> failCrossDriveRename(File _, String _) async {
          throw const FileSystemException('cross-drive', '');
        }

        final staleMigration = migrateLegacyDesktopDatabase(
          sourceOverride: staleSource,
          target: target,
          renameOverride: failCrossDriveRename,
          copyOverride: (source, temporary) async {
            await temporary.writeAsBytes(await source.readAsBytes(), flush: true);
            staleCopyReady.complete();
            await releaseStaleCopy.future;
            staleCopyReleased.complete();
          },
          publishOverride: (temporary, canonical) async {
            stalePublishAttempted = true;
            await temporary.rename(canonical.path);
          },
        );
        await staleCopyReady.future;

        final winnerMigration = migrateLegacyDesktopDatabase(
          sourceOverride: winnerSource,
          target: target,
          renameOverride: failCrossDriveRename,
          publishOverride: (temporary, canonical) async {
            await temporary.rename(canonical.path);
            winnerPublished.complete();
            await staleCopyReleased.future;
            await canonical.writeAsBytes(updatedCanonicalBytes, flush: true);
          },
        );
        await winnerPublished.future;

        releaseStaleCopy.complete();
        await Future.wait([winnerMigration, staleMigration]);

        expect(stalePublishAttempted, isFalse);
        expect(await target.readAsBytes(), updatedCanonicalBytes);
        expect(await winnerSource.exists(), isFalse, reason: 'the winning source is removed after publication');
        expect(await staleSource.readAsBytes(), staleBytes, reason: 'the skipped stale source remains retryable');
        final leakedTemporaries = target.parent.listSync().whereType<File>().where(
          (file) => file.path.endsWith('.tmp'),
        );
        expect(leakedTemporaries, isEmpty, reason: 'both publishers clean their sibling temporary copies');
      });

      test('documents directory lookup failure is a silent no-op', () async {
        final previousPathProvider = PathProviderPlatform.instance;
        final target = File('${tempDir.path}/AppData/plezy_downloads.db');
        PathProviderPlatform.instance = _ThrowingDocumentsPathProvider();

        try {
          await expectLater(migrateLegacyDesktopDatabase(target: target), completes);
        } finally {
          PathProviderPlatform.instance = previousPathProvider;
        }

        expect(await target.exists(), isFalse);
      });
    });
  }

  void _registerApiCacheTests() {
    // ============================================================
    // ApiCache schema defaults and constraints
    // ============================================================

    group('ApiCache', () {
      test('default pinned=false, custom pinned=true is honored', () async {
        await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k1', data: 'a'));
        await db
            .into(db.apiCache)
            .insert(ApiCacheCompanion.insert(cacheKey: 'k2', data: 'b', pinned: const Value(true)));

        final rows = await (db.select(db.apiCache)..orderBy([(t) => OrderingTerm.asc(t.cacheKey)])).get();
        expect(rows.map((r) => r.pinned).toList(), [false, true]);
      });

      test('cacheKey is the primary key (duplicate insert without replace fails)', () async {
        await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'first'));
        expect(
          () => db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'second')),
          throwsA(isA<Exception>()),
        );
      });
    });
  }

  void _registerDownloadedMediaTests() {
    // ============================================================
    // DownloadedMedia: persistence, defaults, constraints, and helpers
    // ============================================================

    group('DownloadedMedia', () {
      Future<int> insertMovie({
        String serverId = 'srv1',
        String? clientScopeId,
        String ratingKey = '100',
        int status = 0, // queued
        int progress = 0,
      }) async {
        return db
            .into(db.downloadedMedia)
            .insert(
              DownloadedMediaCompanion.insert(
                serverId: serverId,
                clientScopeId: Value(clientScopeId),
                ratingKey: ratingKey,
                globalKey: '$serverId:$ratingKey',
                type: 'movie',
                status: status,
                progress: Value(progress),
              ),
            );
      }

      test('insert + select round-trip preserves fields', () async {
        await insertMovie();

        final rows = await db.select(db.downloadedMedia).get();
        expect(rows, hasLength(1));
        expect(rows.first.serverId, 'srv1');
        expect(rows.first.clientScopeId, isNull);
        expect(rows.first.ratingKey, '100');
        expect(rows.first.globalKey, 'srv1:100');
        expect(rows.first.type, 'movie');
        expect(rows.first.status, 0);
        expect(rows.first.progress, 0);
        expect(rows.first.downloadedBytes, 0); // default
        expect(rows.first.retryCount, 0); // default
        expect(rows.first.mediaIndex, 0); // default
        expect(rows.first.bgTaskId, isNull);
        expect(rows.first.totalBytes, isNull);
      });

      test('clientScopeId is persisted for user-scoped downloads', () async {
        await insertMovie(serverId: 'jf-machine', clientScopeId: 'jf-machine/user-a');

        final row = await db.select(db.downloadedMedia).getSingle();
        expect(row.serverId, 'jf-machine');
        expect(row.clientScopeId, 'jf-machine/user-a');
      });

      test('requeue preserves SAF ownership fields while resetting failed state', () async {
        await db
            .into(db.downloadedMedia)
            .insert(
              DownloadedMediaCompanion.insert(
                serverId: ServerId('srv1'),
                ratingKey: 'saf-retry',
                globalKey: 'srv1:saf-retry',
                type: 'movie',
                status: DownloadStatus.failed.index,
                progress: const Value(73),
                videoFilePath: const Value('content://downloads/video.mkv'),
                safRootUri: const Value('content://downloads'),
                errorMessage: const Value('stale failure'),
                retryCount: const Value(4),
                bgTaskId: const Value('stale-task'),
              ),
            );

        await db.insertDownload(
          serverId: ServerId('srv1'),
          ratingKey: 'saf-retry',
          globalKey: 'srv1:saf-retry',
          type: 'movie',
          status: DownloadStatus.queued.index,
        );

        final row = await db.getDownloadedMedia('srv1:saf-retry');
        expect(row?.videoFilePath, 'content://downloads/video.mkv');
        expect(row?.safRootUri, 'content://downloads');
        expect(row?.bgTaskId, 'stale-task');
        expect(row?.status, DownloadStatus.queued.index);
        expect(row?.progress, 0);
        expect(row?.errorMessage, isNull);
        expect(row?.retryCount, 0);
      });

      test('globalKey unique constraint blocks duplicate insert', () async {
        await insertMovie();
        expect(insertMovie(), throwsA(isA<Exception>()));
      });

      test('getAllDownloadedMetadata returns only completed items', () async {
        await insertMovie(ratingKey: '1', status: DownloadStatus.queued.index);
        await insertMovie(ratingKey: '2', status: DownloadStatus.completed.index);
        await insertMovie(ratingKey: '3', status: DownloadStatus.failed.index);
        await insertMovie(ratingKey: '4', status: DownloadStatus.completed.index);

        final completed = await db.getAllDownloadedMetadata();
        expect(completed.map((i) => i.ratingKey).toSet(), {'2', '4'});
      });

      test('download owners keep profile visibility separate for one physical row', () async {
        await insertMovie(ratingKey: '1', status: DownloadStatus.completed.index);

        await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:1');
        await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv1:1');

        expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'});
        expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {'srv1:1'});
        expect(await db.getDownloadOwnerCount('srv1:1'), 2);

        await db.removeDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:1');
        expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty);
        expect(await db.hasDownloadOwner('srv1:1'), isTrue);
        expect(await db.hasDownloadOwner('srv1:1', excludingProfileId: 'profile-b'), isFalse);
      });

      test('shared owner release rebinds only incomplete media and retains the final owner', () async {
        final now = DateTime.now().millisecondsSinceEpoch;
        for (final profileId in const ['profile-a', 'profile-b']) {
          await db
              .into(db.profiles)
              .insert(
                ProfilesCompanion.insert(
                  id: profileId,
                  kind: 'local',
                  displayName: profileId,
                  configJson: '{}',
                  createdAt: now,
                ),
              );
        }

        Future<void> seedOwners(String globalKey) async {
          await db.addDownloadOwner(
            profileId: 'profile-a',
            globalKey: globalKey,
            backendId: 'plex',
            clientScopeId: 'srv1/plex-profile/profile-a',
          );
          await db.addDownloadOwner(
            profileId: 'profile-b',
            globalKey: globalKey,
            backendId: 'plex',
            clientScopeId: 'srv1/plex-profile/profile-b',
          );
        }

        await insertMovie(
          clientScopeId: 'srv1/plex-profile/profile-a',
          ratingKey: 'queued-shared',
          status: DownloadStatus.queued.index,
        );
        await seedOwners('srv1:queued-shared');

        final queuedResult = await db.removeSharedDownloadOwnerAndRebindIncompleteMedia(
          profileId: 'profile-a',
          globalKey: 'srv1:queued-shared',
        );

        expect(queuedResult.hasRemainingOwner, isTrue);
        expect(queuedResult.removedOwner?.profileId, 'profile-a');
        expect((await db.getDownloadedMedia('srv1:queued-shared'))?.clientScopeId, 'srv1/plex-profile/profile-b');
        expect(await db.getDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:queued-shared'), isNull);
        expect(await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'srv1:queued-shared'), isNotNull);

        final finalOwnerResult = await db.removeSharedDownloadOwnerAndRebindIncompleteMedia(
          profileId: 'profile-b',
          globalKey: 'srv1:queued-shared',
        );
        expect(finalOwnerResult.hasRemainingOwner, isFalse);
        expect(await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'srv1:queued-shared'), isNotNull);

        await insertMovie(
          clientScopeId: 'srv1/plex-profile/profile-a',
          ratingKey: 'completed-shared',
          status: DownloadStatus.completed.index,
        );
        await seedOwners('srv1:completed-shared');

        final completedResult = await db.removeSharedDownloadOwnerAndRebindIncompleteMedia(
          profileId: 'profile-a',
          globalKey: 'srv1:completed-shared',
        );

        expect(completedResult.hasRemainingOwner, isTrue);
        expect((await db.getDownloadedMedia('srv1:completed-shared'))?.clientScopeId, 'srv1/plex-profile/profile-a');
      });

      test('adoptLegacyDownloadsForProfile claims only ownerless physical rows', () async {
        await insertMovie(ratingKey: '1', status: DownloadStatus.completed.index);
        await insertMovie(ratingKey: '2', status: DownloadStatus.completed.index);
        await db.addDownloadOwner(profileId: 'profile-existing', globalKey: 'srv1:2');

        await db.adoptLegacyDownloadsForProfile('profile-a');

        expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'});
        expect(await db.getDownloadOwnerKeysForProfile('profile-existing'), {'srv1:2'});
      });

      test(
        'ownerless Jellyfin download adopts the profile connection scope instead of the removed user scope',
        () async {
          final now = DateTime.now().millisecondsSinceEpoch;
          await db
              .into(db.profiles)
              .insert(
                ProfilesCompanion.insert(
                  id: 'profile-b',
                  kind: 'local',
                  displayName: 'Profile B',
                  configJson: '{}',
                  createdAt: now,
                ),
              );
          await db
              .into(db.connections)
              .insert(
                ConnectionsCompanion.insert(
                  id: 'jf-machine/user-b',
                  kind: 'jellyfin',
                  displayName: 'User B',
                  configJson: jsonEncode({'serverMachineId': 'jf-machine', 'userId': 'user-b'}),
                  createdAt: now,
                ),
              );
          await db
              .into(db.profileConnections)
              .insert(
                ProfileConnectionsCompanion.insert(
                  profileId: 'profile-b',
                  connectionId: 'jf-machine/user-b',
                  userIdentifier: 'user-b',
                ),
              );
          await insertMovie(
            serverId: 'jf-machine',
            clientScopeId: 'jf-machine/user-a',
            ratingKey: 'ownerless',
            status: DownloadStatus.completed.index,
          );

          await db.adoptLegacyDownloadsForProfile('profile-b');

          final row = await db.getDownloadedMedia('jf-machine:ownerless');
          final owner = await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:ownerless');
          expect(row?.clientScopeId, 'jf-machine/user-b');
          expect(owner?.backend, 'jellyfin');
          expect(owner?.clientScopeId, 'jf-machine/user-b');
        },
      );

      test('ownerless Jellyfin download defers adoption when the profile has multiple user scopes', () async {
        final now = DateTime.now().millisecondsSinceEpoch;
        for (final userId in const ['user-b', 'user-c']) {
          await db
              .into(db.connections)
              .insert(
                ConnectionsCompanion.insert(
                  id: 'jf-machine/$userId',
                  kind: 'jellyfin',
                  displayName: userId,
                  configJson: '{}',
                  createdAt: now,
                ),
              );
          await db
              .into(db.profileConnections)
              .insert(
                ProfileConnectionsCompanion.insert(
                  profileId: 'profile-b',
                  connectionId: 'jf-machine/$userId',
                  userIdentifier: userId,
                ),
              );
        }
        await insertMovie(
          serverId: 'jf-machine',
          clientScopeId: 'jf-machine/user-a',
          ratingKey: 'ambiguous',
          status: DownloadStatus.completed.index,
        );

        await db.adoptLegacyDownloadsForProfile('profile-b');

        expect(await db.getDownloadOwnerKeysForProfile('profile-b'), isEmpty);
        expect((await db.getDownloadedMedia('jf-machine:ambiguous'))?.clientScopeId, 'jf-machine/user-a');
      });

      test('legacy Plex download adoption respects the persisted profile scope', () async {
        final profileAScope = buildPlexProfileScopeId(serverId: ServerId('srv1'), profileId: 'profile-a');
        await insertMovie(
          clientScopeId: profileAScope,
          ratingKey: 'profile-a-item',
          status: DownloadStatus.completed.index,
        );

        await db.adoptLegacyDownloadsForProfile('profile-b');
        expect(await db.getDownloadOwnerKeysForProfile('profile-b'), isEmpty);

        await db.adoptLegacyDownloadsForProfile('profile-a');
        expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:profile-a-item'});
      });
    });
  }

  void _registerOfflineWatchProgressTests() {
    // ============================================================
    // OfflineWatchProgress helpers
    // ============================================================

    group('OfflineWatchProgress', () {
      Future<int> insertAction({
        String serverId = 's',
        required String ratingKey,
        String? profileId,
        String? clientScopeId,
        required String actionType,
        required int updatedAt,
      }) {
        return db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: serverId,
                profileId: Value(profileId),
                clientScopeId: Value(clientScopeId),
                ratingKey: ratingKey,
                globalKey: '$serverId:$ratingKey',
                actionType: actionType,
                createdAt: updatedAt,
                updatedAt: updatedAt,
              ),
            );
      }

      test('upsertProgressAction inserts a new progress row', () async {
        await db.upsertProgressAction(
          serverId: ServerId('srv'),
          ratingKey: '42',
          viewOffset: 5000,
          duration: 10000,
          shouldMarkWatched: false,
        );

        final rows = await db.select(db.offlineWatchProgress).get();
        expect(rows, hasLength(1));
        expect(rows.first.globalKey, 'srv:42');
        expect(rows.first.clientScopeId, isNull);
        expect(rows.first.actionType, OfflineActionType.progress.id);
        expect(rows.first.viewOffset, 5000);
        expect(rows.first.duration, 10000);
        expect(rows.first.shouldMarkWatched, isFalse);
        expect(rows.first.syncAttempts, 0);
      });

      test('upsertProgressAction merges into the existing progress row', () async {
        await db.upsertProgressAction(
          serverId: ServerId('srv'),
          ratingKey: '42',
          viewOffset: 1000,
          duration: 10000,
          shouldMarkWatched: false,
        );
        await db.upsertProgressAction(
          serverId: ServerId('srv'),
          ratingKey: '42',
          viewOffset: 9500,
          duration: 10000,
          shouldMarkWatched: true,
        );

        final rows = await db.select(db.offlineWatchProgress).get();
        expect(rows, hasLength(1));
        expect(rows.first.viewOffset, 9500);
        expect(rows.first.shouldMarkWatched, isTrue);
      });

      test('upsertProgressAction keeps scoped Jellyfin users separate', () async {
        await db.upsertProgressAction(
          serverId: ServerId('srv'),
          clientScopeId: 'srv/user-a',
          ratingKey: '42',
          viewOffset: 1000,
          duration: 10000,
          shouldMarkWatched: false,
        );
        await db.upsertProgressAction(
          serverId: ServerId('srv'),
          clientScopeId: 'srv/user-b',
          ratingKey: '42',
          viewOffset: 9000,
          duration: 10000,
          shouldMarkWatched: true,
        );

        final rows = await (db.select(
          db.offlineWatchProgress,
        )..orderBy([(t) => OrderingTerm.asc(t.clientScopeId)])).get();
        expect(rows, hasLength(2));
        expect(rows.map((r) => r.clientScopeId), ['srv/user-a', 'srv/user-b']);
        expect(rows.map((r) => r.viewOffset), [1000, 9000]);
      });

      test('insertWatchAction (watched) clears prior progress + insert single row', () async {
        // Existing progress row for the same item
        await db.upsertProgressAction(
          serverId: ServerId('srv'),
          ratingKey: '42',
          viewOffset: 5000,
          duration: 10000,
          shouldMarkWatched: false,
        );

        await db.insertWatchAction(
          serverId: ServerId('srv'),
          ratingKey: '42',
          actionType: OfflineActionType.watched.id,
        );

        final rows = await db.select(db.offlineWatchProgress).get();
        expect(rows, hasLength(1));
        expect(rows.first.actionType, OfflineActionType.watched.id);
        expect(rows.first.viewOffset, isNull);
      });

      test('insertWatchAction clears only matching clientScopeId conflicts', () async {
        await db.upsertProgressAction(
          serverId: ServerId('srv'),
          clientScopeId: 'srv/user-a',
          ratingKey: '42',
          viewOffset: 1000,
          duration: 10000,
          shouldMarkWatched: false,
        );
        await db.upsertProgressAction(
          serverId: ServerId('srv'),
          clientScopeId: 'srv/user-b',
          ratingKey: '42',
          viewOffset: 2000,
          duration: 10000,
          shouldMarkWatched: false,
        );

        await db.insertWatchAction(
          serverId: ServerId('srv'),
          clientScopeId: 'srv/user-a',
          ratingKey: '42',
          actionType: OfflineActionType.watched.id,
        );

        final rows = await (db.select(
          db.offlineWatchProgress,
        )..orderBy([(t) => OrderingTerm.asc(t.clientScopeId), (t) => OrderingTerm.asc(t.actionType)])).get();
        expect(rows, hasLength(2));
        expect(rows.map((r) => (r.clientScopeId, r.actionType)).toList(), [
          ('srv/user-a', OfflineActionType.watched.id),
          ('srv/user-b', OfflineActionType.progress.id),
        ]);
      });

      test('getPendingWatchActions returns rows ordered by createdAt asc', () async {
        // Inject deterministic createdAt by raw inserts
        final now = DateTime.now().millisecondsSinceEpoch;
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 's',
                ratingKey: '1',
                globalKey: 's:1',
                actionType: OfflineActionType.watched.id,
                createdAt: now + 100,
                updatedAt: now + 100,
              ),
            );
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 's',
                ratingKey: '2',
                globalKey: 's:2',
                actionType: OfflineActionType.watched.id,
                createdAt: now + 50,
                updatedAt: now + 50,
              ),
            );

        final pending = await db.getPendingWatchActions();
        expect(pending.map((p) => p.ratingKey).toList(), ['2', '1']);
      });

      test('adoptLegacyOfflineWatchActionsForProfile claims null-profile rows', () async {
        await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
        await db.insertWatchAction(
          profileId: 'profile-existing',
          serverId: ServerId('s'),
          ratingKey: '2',
          actionType: OfflineActionType.watched.id,
        );

        await db.adoptLegacyOfflineWatchActionsForProfile('profile-a');

        expect((await db.getPendingWatchActions(profileId: 'profile-a')).map((r) => r.ratingKey), ['1']);
        expect((await db.getPendingWatchActions(profileId: 'profile-existing')).map((r) => r.ratingKey), ['2']);
      });

      test('getPendingWatchActionsForServer filters by serverId', () async {
        await db.insertWatchAction(serverId: ServerId('a'), ratingKey: '1', actionType: OfflineActionType.watched.id);
        await db.insertWatchAction(serverId: ServerId('b'), ratingKey: '2', actionType: OfflineActionType.watched.id);
        await db.insertWatchAction(serverId: ServerId('a'), ratingKey: '3', actionType: OfflineActionType.unwatched.id);

        final aRows = await db.getPendingWatchActionsForServer(ServerId('a'));
        expect(aRows.map((r) => r.ratingKey).toSet(), {'1', '3'});

        final bRows = await db.getPendingWatchActionsForServer(ServerId('b'));
        expect(bRows.map((r) => r.ratingKey).toSet(), {'2'});
      });

      test('getLatestWatchAction picks the most recently updated row', () async {
        final now = DateTime.now().millisecondsSinceEpoch;
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 's',
                ratingKey: '1',
                globalKey: 's:1',
                actionType: OfflineActionType.progress.id,
                createdAt: now,
                updatedAt: now - 100,
              ),
            );
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 's',
                ratingKey: '1',
                globalKey: 's:1',
                actionType: OfflineActionType.watched.id,
                createdAt: now,
                updatedAt: now + 50,
              ),
            );

        final latest = await db.getLatestWatchAction('s:1');
        expect(latest, isNotNull);
        expect(latest!.actionType, OfflineActionType.watched.id);
      });

      test('getLatestWatchAction returns null when no rows', () async {
        expect(await db.getLatestWatchAction('nope:nope'), isNull);
      });

      test('single and batched watch-action reads preserve newest-first ordering', () async {
        final oldestId = await insertAction(
          ratingKey: 'ordered',
          actionType: OfflineActionType.progress.id,
          updatedAt: 100,
        );
        final olderTieId = await insertAction(
          ratingKey: 'ordered',
          actionType: OfflineActionType.watched.id,
          updatedAt: 200,
        );
        final newerTieId = await insertAction(
          ratingKey: 'ordered',
          actionType: OfflineActionType.unwatched.id,
          updatedAt: 200,
        );
        final expectedIds = [newerTieId, olderTieId, oldestId];

        final single = await db.getWatchActionsForKey('s:ordered');
        final batched = await db.getWatchActionsForKeys({'s:ordered'});

        expect(single.map((action) => action.id), expectedIds);
        expect(batched['s:ordered']!.map((action) => action.id), expectedIds);
        expect((await db.getLatestWatchAction('s:ordered'))!.id, newerTieId);
        expect((await db.getLatestWatchActionsForKeys({'s:ordered'}))['s:ordered']!.id, newerTieId);
      });

      test('single and batched watch-action reads distinguish null and compound scopes', () async {
        final nullScopeId = await insertAction(
          ratingKey: 'scoped',
          actionType: OfflineActionType.unwatched.id,
          updatedAt: 100,
        );
        final compoundScopeId = await insertAction(
          ratingKey: 'scoped',
          clientScopeId: 's/user-a',
          actionType: OfflineActionType.watched.id,
          updatedAt: 200,
        );

        final singleNull = await db.getWatchActionsForKey('s:scoped', filterClientScope: true);
        final singleCompound = await db.getWatchActionsForKey(
          's:scoped',
          clientScopeId: 's/user-a',
          filterClientScope: true,
        );
        final batchedNull = await db.getWatchActionsForKeys(
          {'s:scoped'},
          clientScopeIdsByGlobalKey: {'s:scoped': null},
        );
        final batchedCompound = await db.getWatchActionsForKeys(
          {'s:scoped'},
          clientScopeIdsByGlobalKey: {'s:scoped': 's/user-a'},
        );

        expect(singleNull.map((action) => action.id), [nullScopeId]);
        expect(singleCompound.map((action) => action.id), [compoundScopeId]);
        expect(batchedNull['s:scoped']!.map((action) => action.id), [nullScopeId]);
        expect(batchedCompound['s:scoped']!.map((action) => action.id), [compoundScopeId]);
        expect((await db.getLatestWatchAction('s:scoped', filterClientScope: true))!.id, nullScopeId);
        expect(
          (await db.getLatestWatchAction('s:scoped', clientScopeId: 's/user-a', filterClientScope: true))!.id,
          compoundScopeId,
        );
        expect(
          (await db.getLatestWatchActionsForKeys(
            {'s:scoped'},
            clientScopeIdsByGlobalKey: {'s:scoped': null},
          ))['s:scoped']!.id,
          nullScopeId,
        );
        expect(
          (await db.getLatestWatchActionsForKeys(
            {'s:scoped'},
            clientScopeIdsByGlobalKey: {'s:scoped': 's/user-a'},
          ))['s:scoped']!.id,
          compoundScopeId,
        );
      });

      test('single and batched watch-action reads isolate profiles', () async {
        final profileAId = await insertAction(
          ratingKey: 'profiled',
          profileId: 'profile-a',
          actionType: OfflineActionType.unwatched.id,
          updatedAt: 100,
        );
        await insertAction(
          ratingKey: 'profiled',
          profileId: 'profile-b',
          actionType: OfflineActionType.watched.id,
          updatedAt: 200,
        );

        final single = await db.getWatchActionsForKey('s:profiled', profileId: 'profile-a', filterProfile: true);
        final batched = await db.getWatchActionsForKeys({'s:profiled'}, profileId: 'profile-a', filterProfile: true);

        expect(single.map((action) => action.id), [profileAId]);
        expect(batched['s:profiled']!.map((action) => action.id), [profileAId]);
        expect(
          (await db.getLatestWatchAction('s:profiled', profileId: 'profile-a', filterProfile: true))!.id,
          profileAId,
        );
        expect(
          (await db.getLatestWatchActionsForKeys(
            {'s:profiled'},
            profileId: 'profile-a',
            filterProfile: true,
          ))['s:profiled']!.id,
          profileAId,
        );
      });

      test('watch-action query values treat wildcard characters literally', () async {
        final exactId = await insertAction(
          serverId: 'server_%',
          ratingKey: 'item%_',
          profileId: 'profile_%',
          clientScopeId: 'server_%/user_1',
          actionType: OfflineActionType.watched.id,
          updatedAt: 100,
        );
        await insertAction(
          serverId: 'server_%',
          ratingKey: 'item%_',
          profileId: 'profile_abc',
          clientScopeId: 'server_%/user_1',
          actionType: OfflineActionType.unwatched.id,
          updatedAt: 400,
        );
        await insertAction(
          serverId: 'server_%',
          ratingKey: 'item%_',
          profileId: 'profile_%',
          clientScopeId: 'server_abc/user_a',
          actionType: OfflineActionType.unwatched.id,
          updatedAt: 300,
        );
        await insertAction(
          serverId: 'server_abc',
          ratingKey: 'itemZZZx',
          profileId: 'profile_%',
          clientScopeId: 'server_%/user_1',
          actionType: OfflineActionType.unwatched.id,
          updatedAt: 200,
        );
        const globalKey = 'server_%:item%_';
        const scopes = {globalKey: 'server_%/user_1'};

        final single = await db.getWatchActionsForKey(
          globalKey,
          profileId: 'profile_%',
          filterProfile: true,
          clientScopeId: 'server_%/user_1',
          filterClientScope: true,
        );
        final batched = await db.getWatchActionsForKeys(
          {globalKey},
          profileId: 'profile_%',
          filterProfile: true,
          clientScopeIdsByGlobalKey: scopes,
        );

        expect(single.map((action) => action.id), [exactId]);
        expect(single.single.serverId, 'server_%');
        expect(single.single.ratingKey, 'item%_');
        expect(batched[globalKey]!.map((action) => action.id), [exactId]);
        expect(
          (await db.getLatestWatchAction(
            globalKey,
            profileId: 'profile_%',
            filterProfile: true,
            clientScopeId: 'server_%/user_1',
            filterClientScope: true,
          ))!.id,
          exactId,
        );
        expect(
          (await db.getLatestWatchActionsForKeys(
            {globalKey},
            profileId: 'profile_%',
            filterProfile: true,
            clientScopeIdsByGlobalKey: scopes,
          ))[globalKey]!.id,
          exactId,
        );
      });

      test('getLatestWatchActionsForKeys batches lookups, latest per key', () async {
        final now = DateTime.now().millisecondsSinceEpoch;
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 's',
                ratingKey: '1',
                globalKey: 's:1',
                actionType: OfflineActionType.progress.id,
                createdAt: now,
                updatedAt: now,
              ),
            );
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 's',
                ratingKey: '1',
                globalKey: 's:1',
                actionType: OfflineActionType.watched.id,
                createdAt: now,
                updatedAt: now + 100,
              ),
            );
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 's',
                ratingKey: '2',
                globalKey: 's:2',
                actionType: OfflineActionType.unwatched.id,
                createdAt: now,
                updatedAt: now,
              ),
            );

        final result = await db.getLatestWatchActionsForKeys({'s:1', 's:2', 's:3-missing'});
        expect(result.keys.toSet(), {'s:1', 's:2'});
        expect(result['s:1']!.actionType, OfflineActionType.watched.id);
        expect(result['s:2']!.actionType, OfflineActionType.unwatched.id);
      });

      test('getLatestWatchActionsForKeys filters by expected clientScopeId', () async {
        final now = DateTime.now().millisecondsSinceEpoch;
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 'jf',
                clientScopeId: const Value('jf/user-a'),
                ratingKey: '1',
                globalKey: 'jf:1',
                actionType: OfflineActionType.unwatched.id,
                createdAt: now,
                updatedAt: now,
              ),
            );
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 'jf',
                clientScopeId: const Value('jf/user-b'),
                ratingKey: '1',
                globalKey: 'jf:1',
                actionType: OfflineActionType.watched.id,
                createdAt: now,
                updatedAt: now + 100,
              ),
            );

        final userA = await db.getLatestWatchActionsForKeys({'jf:1'}, clientScopeIdsByGlobalKey: {'jf:1': 'jf/user-a'});
        final userB = await db.getLatestWatchActionsForKeys({'jf:1'}, clientScopeIdsByGlobalKey: {'jf:1': 'jf/user-b'});

        expect(userA['jf:1']!.actionType, OfflineActionType.unwatched.id);
        expect(userB['jf:1']!.actionType, OfflineActionType.watched.id);
      });

      test('getLatestWatchActionsForKeys filters by profile when requested', () async {
        final now = DateTime.now().millisecondsSinceEpoch;
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 's',
                profileId: const Value('profile-a'),
                ratingKey: '1',
                globalKey: 's:1',
                actionType: OfflineActionType.unwatched.id,
                createdAt: now,
                updatedAt: now,
              ),
            );
        await db
            .into(db.offlineWatchProgress)
            .insert(
              OfflineWatchProgressCompanion.insert(
                serverId: 's',
                profileId: const Value('profile-b'),
                ratingKey: '1',
                globalKey: 's:1',
                actionType: OfflineActionType.watched.id,
                createdAt: now,
                updatedAt: now + 100,
              ),
            );

        final profileA = await db.getLatestWatchActionsForKeys({'s:1'}, profileId: 'profile-a', filterProfile: true);
        final profileB = await db.getLatestWatchActionsForKeys({'s:1'}, profileId: 'profile-b', filterProfile: true);
        final globalLatest = await db.getLatestWatchActionsForKeys({'s:1'});

        expect(profileA['s:1']!.actionType, OfflineActionType.unwatched.id);
        expect(profileB['s:1']!.actionType, OfflineActionType.watched.id);
        expect(globalLatest['s:1']!.actionType, OfflineActionType.watched.id);
      });

      test('getLatestWatchActionsForKeys with empty input returns empty map (no query)', () async {
        expect(await db.getLatestWatchActionsForKeys({}), isEmpty);
      });

      test('updateSyncAttempt increments syncAttempts and stores lastError', () async {
        await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
        final inserted = (await db.select(db.offlineWatchProgress).get()).single;

        await db.updateSyncAttempt(inserted.id, 'boom');
        var row = (await db.select(db.offlineWatchProgress).get()).single;
        expect(row.syncAttempts, 1);
        expect(row.lastError, 'boom');

        await db.updateSyncAttempt(inserted.id, null);
        row = (await db.select(db.offlineWatchProgress).get()).single;
        expect(row.syncAttempts, 2);
        expect(row.lastError, isNull);
      });

      test('updateSyncAttempt is a no-op when id does not exist', () async {
        await db.updateSyncAttempt(999, 'irrelevant');
        expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
      });

      test('deleteWatchAction removes only the matching row', () async {
        await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
        await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.watched.id);
        final rows = await db.select(db.offlineWatchProgress).get();
        expect(rows, hasLength(2));

        await db.deleteWatchAction(rows.first.id);
        expect(await db.select(db.offlineWatchProgress).get(), hasLength(1));
      });

      test('getPendingSyncCount counts every row', () async {
        expect(await db.getPendingSyncCount(), 0);

        await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
        await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.unwatched.id);
        expect(await db.getPendingSyncCount(), 2);
      });

      test('clearAllWatchActions empties the table', () async {
        await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
        await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.unwatched.id);

        await db.clearAllWatchActions();
        expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
      });
    });
  }

  void _registerSyncRulesTests() {
    // ============================================================
    // Sync Rules helpers
    // ============================================================

    group('SyncRules', () {
      test('insertSyncRule + getSyncRules round-trip with defaults', () async {
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 5,
        );

        final rules = await db.getSyncRules();
        expect(rules, hasLength(1));
        expect(rules.first.targetType, 'show');
        expect(rules.first.profileId, '');
        expect(rules.first.episodeCount, 5);
        expect(rules.first.enabled, isTrue); // default
        expect(rules.first.downloadFilter, 'unwatched'); // default
        expect(rules.first.mediaIndex, 0); // default
        expect(rules.first.includeSpecials, isTrue); // default
        expect(rules.first.lastExecutedAt, isNull);
      });

      test('insertSyncRule upserts on the UNIQUE globalKey instead of crashing', () async {
        // The auto-incremented primary key never collides, so a duplicate
        // globalKey would fail the UNIQUE constraint with a vanilla
        // `insertOnConflictUpdate`. The helper drives the upsert off
        // [globalKey] so re-creating a rule for the same target updates the
        // existing row rather than throwing.
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 5,
        );
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'season',
          episodeCount: 99,
          downloadFilter: 'all',
          includeSpecials: false,
        );

        final rules = await db.getSyncRules();
        expect(rules, hasLength(1));
        expect(rules.first.targetType, 'season');
        expect(rules.first.episodeCount, 99);
        expect(rules.first.downloadFilter, 'all');
        expect(rules.first.includeSpecials, isFalse);
      });

      test('insertSyncRule allows the same server item for different profiles', () async {
        await db.insertSyncRule(
          profileId: 'profile-a',
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'profile-a|srv:10',
          targetType: 'show',
          episodeCount: 5,
        );
        await db.insertSyncRule(
          profileId: 'profile-b',
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'profile-b|srv:10',
          targetType: 'show',
          episodeCount: 9,
        );

        expect(await db.getSyncRules(profileId: 'profile-a'), hasLength(1));
        expect((await db.getSyncRules(profileId: 'profile-a')).single.episodeCount, 5);
        expect(await db.getSyncRules(profileId: 'profile-b'), hasLength(1));
        expect((await db.getSyncRules(profileId: 'profile-b')).single.episodeCount, 9);
      });

      test('insertSyncRule preserves enabled + lastExecutedAt across upserts', () async {
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 5,
        );
        await db.updateSyncRuleEnabled('srv:10', false);
        await db.updateSyncRuleLastExecuted('srv:10');
        final firstRun = (await db.getSyncRule('srv:10'))!;

        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 8,
        );
        final afterUpsert = (await db.getSyncRule('srv:10'))!;
        expect(afterUpsert.episodeCount, 8);
        expect(afterUpsert.enabled, isFalse, reason: 'upsert should preserve disabled flag');
        expect(afterUpsert.lastExecutedAt, firstRun.lastExecutedAt);
      });

      test('getSyncRule returns the matching rule or null', () async {
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 5,
        );
        expect(await db.getSyncRule('srv:10'), isNotNull);
        expect(await db.getSyncRule('srv:nope'), isNull);
      });

      test('updateSyncRuleCount mutates only the count', () async {
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 5,
        );
        await db.updateSyncRuleCount('srv:10', 12);

        final rule = await db.getSyncRule('srv:10');
        expect(rule!.episodeCount, 12);
        expect(rule.targetType, 'show'); // unchanged
      });

      test('updateSyncRuleFilter mutates the filter', () async {
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 5,
        );
        await db.updateSyncRuleFilter('srv:10', 'all');

        final rule = await db.getSyncRule('srv:10');
        expect(rule!.downloadFilter, 'all');
      });

      test('updateSyncRuleEnabled toggles enabled', () async {
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 5,
        );
        await db.updateSyncRuleEnabled('srv:10', false);
        expect((await db.getSyncRule('srv:10'))!.enabled, isFalse);

        await db.updateSyncRuleEnabled('srv:10', true);
        expect((await db.getSyncRule('srv:10'))!.enabled, isTrue);
      });

      test('updateSyncRuleLastExecuted writes a timestamp', () async {
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 5,
        );
        final before = DateTime.now().millisecondsSinceEpoch;
        await db.updateSyncRuleLastExecuted('srv:10');
        final after = DateTime.now().millisecondsSinceEpoch;

        final rule = await db.getSyncRule('srv:10');
        expect(rule!.lastExecutedAt, isNotNull);
        expect(rule.lastExecutedAt! >= before, isTrue);
        expect(rule.lastExecutedAt! <= after, isTrue);
      });

      test('deleteSyncRule removes the matching row', () async {
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '10',
          globalKey: 'srv:10',
          targetType: 'show',
          episodeCount: 5,
        );
        await db.insertSyncRule(
          serverId: ServerId('srv'),
          ratingKey: '11',
          globalKey: 'srv:11',
          targetType: 'show',
          episodeCount: 5,
        );

        await db.deleteSyncRule('srv:10');

        final remaining = await db.getSyncRules();
        expect(remaining, hasLength(1));
        expect(remaining.first.globalKey, 'srv:11');
      });
      test('exclusive sync download keys preserve downloads covered by another rule', () async {
        Future<SyncRuleItem> insertRule(String profileId, String id) async {
          final globalKey = '$profileId|srv:$id';
          await db.insertSyncRule(
            profileId: profileId,
            serverId: ServerId('srv'),
            ratingKey: id,
            globalKey: globalKey,
            targetType: 'playlist',
            episodeCount: 0,
          );
          return (await db.getSyncRule(globalKey))!;
        }

        Future<void> insertOwnedDownload(String id, List<String> profileIds) async {
          final globalKey = 'srv:$id';
          await db.insertDownload(
            serverId: ServerId('srv'),
            ratingKey: id,
            globalKey: globalKey,
            type: 'episode',
            status: DownloadStatus.completed.index,
          );
          for (final profileId in profileIds) {
            await db.addDownloadOwner(profileId: profileId, globalKey: globalKey);
          }
        }

        final target = await insertRule('profile-a', 'playlist-a');
        final sibling = await insertRule('profile-a', 'playlist-b');
        final otherProfile = await insertRule('profile-b', 'playlist-c');
        await insertOwnedDownload('exclusive', ['profile-a']);
        await insertOwnedDownload('sibling-shared', ['profile-a']);
        await insertOwnedDownload('profile-shared', ['profile-a', 'profile-b']);

        await db.associateSyncRuleDownload(target, 'srv:exclusive');
        await db.associateSyncRuleDownload(target, 'srv:sibling-shared');
        await db.associateSyncRuleDownload(target, 'srv:profile-shared');
        await db.associateSyncRuleDownload(sibling, 'srv:sibling-shared');
        await db.associateSyncRuleDownload(otherProfile, 'srv:profile-shared');

        expect(
          await db.getExclusiveSyncRuleDownloadKeys(target),
          unorderedEquals(['srv:exclusive', 'srv:profile-shared']),
        );

        await db.removeDownloadOwner(profileId: 'profile-a', globalKey: 'srv:profile-shared');
        expect(await db.getSyncRuleDownloadLinks(otherProfile.id), hasLength(1));
      });
    });
  }
}

Future<void> _createSchemaV13Fixture(AppDatabase database) async {
  await database.select(database.apiCache).get();
  await database.customStatement('DROP INDEX IF EXISTS idx_offline_watch_progress_profile');
  await database.customStatement('DROP INDEX IF EXISTS idx_offline_watch_progress_server');
  await database.customStatement('DROP INDEX IF EXISTS idx_sync_rules_profile');
  await database.customStatement('DROP TABLE IF EXISTS profile_connections');
  await database.customStatement('DROP TABLE IF EXISTS profiles');
  await database.customStatement('DROP TABLE IF EXISTS connections');
  await database.customStatement('DROP TABLE IF EXISTS download_owners');
  await database.customStatement('ALTER TABLE downloaded_media DROP COLUMN client_scope_id');
  await database.customStatement('ALTER TABLE downloaded_media DROP COLUMN media_source_id');
  await database.customStatement('ALTER TABLE downloaded_media DROP COLUMN saf_root_uri');
  await database.customStatement('ALTER TABLE offline_watch_progress DROP COLUMN client_scope_id');
  await database.customStatement('ALTER TABLE offline_watch_progress DROP COLUMN profile_id');
  await database.customStatement('ALTER TABLE sync_rules DROP COLUMN profile_id');
  await database.customStatement('ALTER TABLE sync_rules DROP COLUMN include_specials');
  await database.customStatement('PRAGMA user_version = 13');
}

Set<String> _recursiveJsonKeys(Object? value) {
  if (value is Map<String, dynamic>) {
    return {...value.keys, for (final nested in value.values) ..._recursiveJsonKeys(nested)};
  }
  if (value is List) {
    return {for (final nested in value) ..._recursiveJsonKeys(nested)};
  }
  return const {};
}

class _ThrowingDocumentsPathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin {
  @override
  Future<String?> getApplicationDocumentsPath() async => throw Exception('documents unavailable');
}
