import 'package:drift/native.dart';
import 'package:plezy/media/ids.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/episode_collection.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/sync_rule_executor.dart';

import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/media_items.dart';

JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection(
  machineId: 'jf-machine',
  userId: userId,
  serverName: 'Shared JF',
  userName: userId,
  accessToken: 'token-$userId',
  deviceId: 'device',
  createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);

void main() {
  setUp(resetSharedPreferencesForTest);

  test('profile-scoped Jellyfin sync rule executes through active client', () async {
    final db = AppDatabase.forTesting(NativeDatabase.memory());
    JellyfinApiCache.initialize(db);
    final manager = MultiServerManager();
    addTearDown(() async {
      manager.dispose();
      await db.close();
    });

    final pathsByUser = <String, List<String>>{'user-a': [], 'user-b': []};

    JellyfinClient clientFor(String userId) {
      return JellyfinClient.forTesting(
        connection: _jellyfinConnection(userId),
        httpClient: MockClient((request) async {
          pathsByUser[userId]!.add('${request.method} ${request.url.path}?${request.url.query}');
          if (request.method == 'GET' && request.url.path == '/Users/$userId/Items/show-1') {
            return http.Response(
              '{"Id":"show-1","Type":"Series","Name":"Show $userId","RecursiveItemCount":1}',
              200,
              headers: {'content-type': 'application/json'},
            );
          }
          if (request.method == 'GET' && request.url.path == '/Items') {
            return http.Response(
              '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode $userId","SeriesId":"show-1","UserData":{"PlayCount":0}}]}',
              200,
              headers: {'content-type': 'application/json'},
            );
          }
          return http.Response('not found', 404);
        }),
      );
    }

    final userA = clientFor('user-a');
    final userB = clientFor('user-b');
    addTearDown(userA.close);
    addTearDown(userB.close);

    manager.debugRegisterJellyfinClientForTesting(userB, online: false);
    manager.debugRegisterJellyfinClientForTesting(userA);

    await db.insertSyncRule(
      profileId: 'profile-a',
      serverId: ServerId('jf-machine'),
      ratingKey: 'show-1',
      globalKey: 'profile-a|jf-machine:show-1',
      targetType: 'show',
      episodeCount: 1,
    );
    await db.insertWatchAction(
      serverId: ServerId('jf-machine'),
      clientScopeId: 'jf-machine/user-b',
      ratingKey: 'ep-1',
      actionType: OfflineActionType.watched.id,
    );
    await db.insertWatchAction(
      profileId: 'profile-b',
      serverId: ServerId('jf-machine'),
      clientScopeId: 'jf-machine/user-a',
      ratingKey: 'ep-1',
      actionType: OfflineActionType.watched.id,
    );

    final queued = <({MediaItem item, MediaServerClient client})>[];
    final executor = SyncRuleExecutor(database: db);
    final results = await executor.executeSyncRules(
      profileId: 'profile-a',
      serverManager: manager,
      downloads: const {},
      metadata: const {},
      associateDownload: (_, _) async {},
      queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
        queued.add((item: item, client: client));
        return true;
      },
      isOffline: false,
      force: true,
    );

    expect(results.single.queuedCount, 1);
    expect(queued.single.client, same(userA));
    expect(pathsByUser['user-a']!.where((p) => p.startsWith('GET /Items?')), isNotEmpty);
    expect(pathsByUser['user-b'], isEmpty);
  });

  test('profile-scoped sync rule excludes only the active profile local watched actions', () async {
    final db = AppDatabase.forTesting(NativeDatabase.memory());
    JellyfinApiCache.initialize(db);
    final manager = MultiServerManager();
    addTearDown(() async {
      manager.dispose();
      await db.close();
    });

    final paths = <String>[];
    final userA = JellyfinClient.forTesting(
      connection: _jellyfinConnection('user-a'),
      httpClient: MockClient((request) async {
        paths.add('${request.method} ${request.url.path}?${request.url.query}');
        if (request.method == 'GET' && request.url.path == '/Users/user-a/Items/show-1') {
          return http.Response(
            '{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}',
            200,
            headers: {'content-type': 'application/json'},
          );
        }
        if (request.method == 'GET' && request.url.path == '/Items') {
          return http.Response(
            '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}',
            200,
            headers: {'content-type': 'application/json'},
          );
        }
        return http.Response('not found', 404);
      }),
    );
    addTearDown(userA.close);
    manager.debugRegisterJellyfinClientForTesting(userA);

    await db.insertSyncRule(
      profileId: 'profile-a',
      serverId: ServerId('jf-machine'),
      ratingKey: 'show-1',
      globalKey: 'profile-a|jf-machine:show-1',
      targetType: 'show',
      episodeCount: 1,
    );
    await db.insertWatchAction(
      profileId: 'profile-a',
      serverId: ServerId('jf-machine'),
      clientScopeId: 'jf-machine/user-a',
      ratingKey: 'ep-1',
      actionType: OfflineActionType.watched.id,
    );

    final queued = <MediaItem>[];
    final executor = SyncRuleExecutor(database: db);
    final results = await executor.executeSyncRules(
      profileId: 'profile-a',
      serverManager: manager,
      downloads: const {},
      metadata: const {},
      associateDownload: (_, _) async {},
      queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
        queued.add(item);
        return true;
      },
      isOffline: false,
      force: true,
    );

    expect(results, isEmpty);
    expect(queued, isEmpty);
    expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty);
  });

  test('profile-scoped Jellyfin sync rule does not execute for another profile', () async {
    final db = AppDatabase.forTesting(NativeDatabase.memory());
    JellyfinApiCache.initialize(db);
    final manager = MultiServerManager();
    addTearDown(() async {
      manager.dispose();
      await db.close();
    });

    final paths = <String>[];
    final userB = JellyfinClient.forTesting(
      connection: _jellyfinConnection('user-b'),
      httpClient: MockClient((request) async {
        paths.add('${request.method} ${request.url.path}?${request.url.query}');
        return http.Response('not found', 404);
      }),
    );
    addTearDown(userB.close);
    manager.debugRegisterJellyfinClientForTesting(userB);

    await db.insertSyncRule(
      profileId: 'profile-a',
      serverId: ServerId('jf-machine'),
      ratingKey: 'show-1',
      globalKey: 'profile-a|jf-machine:show-1',
      targetType: 'show',
      episodeCount: 1,
    );

    final executor = SyncRuleExecutor(database: db);
    final results = await executor.executeSyncRules(
      profileId: 'profile-b',
      serverManager: manager,
      downloads: const {},
      metadata: const {},
      associateDownload: (_, _) async {},
      queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true,
      isOffline: false,
      force: true,
    );

    expect(results, isEmpty);
    expect(paths, isEmpty);
  });

  test('profile-scoped Jellyfin sync rule counts shared public downloads as already present', () async {
    final db = AppDatabase.forTesting(NativeDatabase.memory());
    JellyfinApiCache.initialize(db);
    final manager = MultiServerManager();
    addTearDown(() async {
      manager.dispose();
      await db.close();
    });

    final paths = <String>[];
    final userB = JellyfinClient.forTesting(
      connection: _jellyfinConnection('user-b'),
      httpClient: MockClient((request) async {
        paths.add('${request.method} ${request.url.path}?${request.url.query}');
        if (request.method == 'GET' && request.url.path == '/Users/user-b/Items/show-1') {
          return http.Response(
            '{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}',
            200,
            headers: {'content-type': 'application/json'},
          );
        }
        if (request.method == 'GET' && request.url.path == '/Items') {
          return http.Response(
            '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}',
            200,
            headers: {'content-type': 'application/json'},
          );
        }
        return http.Response('not found', 404);
      }),
    );
    addTearDown(userB.close);
    manager.debugRegisterJellyfinClientForTesting(userB);

    await db.insertSyncRule(
      profileId: 'profile-b',
      serverId: ServerId('jf-machine'),
      ratingKey: 'show-1',
      globalKey: 'profile-b|jf-machine:show-1',
      targetType: 'show',
      episodeCount: 1,
    );

    final queued = <MediaItem>[];
    final associated = <String>[];
    final executor = SyncRuleExecutor(database: db);
    final results = await executor.executeSyncRules(
      profileId: 'profile-b',
      serverManager: manager,
      downloads: const {
        'jf-machine:ep-1': DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed),
      },
      metadata: const {},
      associateDownload: (_, globalKey) async => associated.add(globalKey),
      queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
        queued.add(item);
        return true;
      },
      isOffline: false,
      force: true,
    );

    expect(results, isEmpty);
    expect(queued, isEmpty);
    expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty);
    expect(associated, ['jf-machine:ep-1']);
    expect((await db.getSyncRule('profile-b|jf-machine:show-1'))!.downloadLinksInitialized, isTrue);
  });

  test('show sync rule respects includeSpecials=false when expanding episodes', () async {
    final db = AppDatabase.forTesting(NativeDatabase.memory());
    final manager = MultiServerManager();
    addTearDown(() async {
      manager.dispose();
      await db.close();
    });

    final client = _PlayableDescendantsClient([
      _episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'),
      _episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'),
      _episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-11-02'),
    ]);
    manager.debugRegisterClientForTesting(client);

    const ruleKey = 'profile-a|plex-machine:show-1';
    final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
    await db.insertSyncRule(
      profileId: 'profile-a',
      serverId: ServerId('plex-machine'),
      ratingKey: 'show-1',
      globalKey: ruleKey,
      targetType: 'show',
      episodeCount: 0,
      includeSpecials: false,
    );

    final queued = <MediaItem>[];
    final executor = SyncRuleExecutor(database: db);
    final results = await executor.executeSyncRules(
      profileId: 'profile-a',
      serverManager: manager,
      downloads: const {},
      metadata: {ruleKey: show},
      associateDownload: (_, _) async {},
      queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
        queued.add(item);
        return true;
      },
      isOffline: false,
      force: true,
    );

    expect(results.single.queuedCount, 2);
    expect(queued.map((item) => item.id), ['s1e1', 's1e2']);
    expect(client.fetchPlayableDescendantsCalls, ['show-1']);
  });

  test('Jellyfin playlist sync associates an already-downloaded member', () async {
    final db = AppDatabase.forTesting(NativeDatabase.memory());
    final manager = MultiServerManager();
    addTearDown(() async {
      manager.dispose();
      await db.close();
    });

    final client = _PlaylistPagingClient();
    manager.debugRegisterClientForTesting(client);
    const ruleKey = 'profile-a|jf-machine:playlist-1';
    await db.insertSyncRule(
      profileId: 'profile-a',
      serverId: ServerId('jf-machine'),
      ratingKey: 'playlist-1',
      globalKey: ruleKey,
      targetType: 'playlist',
      episodeCount: 0,
      downloadFilter: SyncRuleFilter.all,
    );
    final associated = <String>[];

    final results = await SyncRuleExecutor(database: db).executeSyncRules(
      profileId: 'profile-a',
      serverManager: manager,
      downloads: const {
        'jf-machine:episode-1': DownloadProgress(globalKey: 'jf-machine:episode-1', status: DownloadStatus.completed),
      },
      metadata: const {},
      associateDownload: (_, globalKey) async => associated.add(globalKey),
      queueSingleDownload: (_, _, {int mediaIndex = 0}) async {
        fail('an already-downloaded playlist member must not be queued');
      },
      isOffline: false,
      force: true,
    );

    expect(results, isEmpty);
    expect(associated, ['jf-machine:episode-1']);
    expect(client.playlistPageCalls, [(start: 0, size: 200)]);
    expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue);
  });

  test('collection sync rule pages through collection API instead of metadata children', () async {
    final db = AppDatabase.forTesting(NativeDatabase.memory());
    final manager = MultiServerManager();
    addTearDown(() async {
      manager.dispose();
      await db.close();
    });

    final client = _CollectionPagingClient();
    manager.debugRegisterClientForTesting(client);

    const ruleKey = 'profile-a|plex-machine:collection-1';
    final collection = testMediaItem(
      id: 'collection-1',
      backend: MediaBackend.plex,
      kind: MediaKind.collection,
      title: 'Collection',
      serverId: 'plex-machine',
    );

    await db.insertSyncRule(
      profileId: 'profile-a',
      serverId: ServerId('plex-machine'),
      ratingKey: 'collection-1',
      globalKey: ruleKey,
      targetType: 'collection',
      episodeCount: 0,
      downloadFilter: SyncRuleFilter.all,
    );

    final queued = <MediaItem>[];
    final associated = <String>[];
    final executor = SyncRuleExecutor(database: db);
    final results = await executor.executeSyncRules(
      profileId: 'profile-a',
      serverManager: manager,
      downloads: const {},
      metadata: {ruleKey: collection},
      associateDownload: (_, globalKey) async => associated.add(globalKey),
      queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
        queued.add(item);
        return true;
      },
      isOffline: false,
      force: true,
    );

    expect(results.single.queuedCount, 1);
    expect(queued.single.id, 'movie-1');
    expect(associated, ['plex-machine:movie-1']);
    expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue);
    expect(client.collectionPageCalls, [(start: 0, size: 100)]);
    expect(client.fetchChildrenCalled, isFalse);
  });

  test('legacy list backfill associates active members without queueing', () async {
    final db = AppDatabase.forTesting(NativeDatabase.memory());
    final manager = MultiServerManager();
    addTearDown(() async {
      manager.dispose();
      await db.close();
    });

    final client = _CollectionPagingClient();
    manager.debugRegisterClientForTesting(client);
    const ruleKey = 'profile-a|plex-machine:collection-1';
    final collection = testMediaItem(
      id: 'collection-1',
      backend: MediaBackend.plex,
      kind: MediaKind.collection,
      title: 'Collection',
      serverId: 'plex-machine',
    );
    await db.insertSyncRule(
      profileId: 'profile-a',
      serverId: ServerId('plex-machine'),
      ratingKey: 'collection-1',
      globalKey: ruleKey,
      targetType: 'collection',
      episodeCount: 0,
      downloadFilter: SyncRuleFilter.all,
    );
    final rule = (await db.getSyncRule(ruleKey))!;
    final associated = <String>[];

    final backfilled = await SyncRuleExecutor(database: db).backfillListRuleDownloadLinks(
      rule: rule,
      serverManager: manager,
      downloads: const {
        'plex-machine:movie-1': DownloadProgress(globalKey: 'plex-machine:movie-1', status: DownloadStatus.completed),
      },
      metadata: {ruleKey: collection},
      associateDownload: (_, globalKey) async => associated.add(globalKey),
    );

    expect(backfilled, isTrue);
    expect(associated, ['plex-machine:movie-1']);
    expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue);
  });

  test('collectListLeaves accepts tracks and expands albums/artists', () async {
    final albumTracks = [_track('album-track-1'), _track('album-track-2', played: true)];
    final client = _PlayableDescendantsClient(albumTracks);

    final items = [
      _track('loose-track'),
      testMediaItem(id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album'),
      testMediaItem(id: 'artist-1', backend: MediaBackend.plex, kind: MediaKind.artist, title: 'Artist'),
      // Still skipped: nested lists / unplayable kinds.
      testMediaItem(id: 'photo-1', backend: MediaBackend.plex, kind: MediaKind.photo, title: 'Photo'),
    ];

    final out = <MediaItem>[];
    await collectListLeaves(client, items, unwatchedOnly: false, out: out);

    expect(client.fetchPlayableDescendantsCalls, ['album-1', 'artist-1']);
    expect(out.map((i) => i.id), ['loose-track', 'album-track-1', 'album-track-2', 'album-track-1', 'album-track-2']);

    // unwatchedOnly applies the play-count filter to tracks too.
    final unwatched = <MediaItem>[];
    await collectListLeaves(
      client,
      [_track('played-track', played: true), items[1]],
      unwatchedOnly: true,
      out: unwatched,
    );
    expect(unwatched.map((i) => i.id), ['album-track-1']);
  });
}

MediaItem _track(String id, {bool played = false}) {
  return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: id, viewCount: played ? 1 : 0);
}

MediaItem _episode(String id, {required int parentIndex, required int index, String? originallyAvailableAt}) {
  return testMediaItem(
    id: id,
    backend: MediaBackend.plex,
    kind: MediaKind.episode,
    title: id,
    parentIndex: parentIndex,
    index: index,
    originallyAvailableAt: originallyAvailableAt,
  );
}

class _PlayableDescendantsClient implements MediaServerClient {
  _PlayableDescendantsClient(this.leaves);

  final List<MediaItem> leaves;
  final fetchPlayableDescendantsCalls = <String>[];

  @override
  ServerId get serverId => ServerId('plex-machine');

  @override
  String? get serverName => 'Plex';

  @override
  MediaBackend get backend => MediaBackend.plex;

  @override
  ServerCapabilities get capabilities => ServerCapabilities.plex;

  @override
  bool get isOfflineMode => false;

  @override
  void close() {}

  @override
  Future<MediaItem?> fetchItem(String id) async => null;

  @override
  Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
    fetchPlayableDescendantsCalls.add(parentId);
    return leaves;
  }

  @override
  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

class _PlaylistPagingClient implements MediaServerClient {
  final playlistPageCalls = <({int? start, int? size})>[];

  @override
  ServerId get serverId => ServerId('jf-machine');

  @override
  String? get serverName => 'Jellyfin';

  @override
  MediaBackend get backend => MediaBackend.jellyfin;

  @override
  ServerCapabilities get capabilities => ServerCapabilities.jellyfin;

  @override
  bool get isOfflineMode => false;

  @override
  void close() {}

  @override
  Future<MediaItem?> fetchItem(String id) async => null;

  @override
  Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, abort}) async {
    playlistPageCalls.add((start: start, size: size));
    expect(id, 'playlist-1');
    return LibraryPage(
      items: [
        testMediaItem(id: 'episode-1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, title: 'Episode'),
      ],
      totalCount: 1,
      offset: start ?? 0,
    );
  }

  @override
  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

class _CollectionPagingClient implements MediaServerClient {
  bool fetchChildrenCalled = false;
  final collectionPageCalls = <({int? start, int? size})>[];

  @override
  ServerId get serverId => ServerId('plex-machine');

  @override
  String? get serverName => 'Plex';

  @override
  MediaBackend get backend => MediaBackend.plex;

  @override
  ServerCapabilities get capabilities => ServerCapabilities.plex;

  @override
  bool get isOfflineMode => false;

  @override
  void close() {}

  @override
  Future<MediaItem?> fetchItem(String id) async => null;

  @override
  Future<List<MediaItem>> fetchChildren(String parentId) async {
    fetchChildrenCalled = true;
    throw StateError('collection rules must not use fetchChildren');
  }

  @override
  Future<LibraryPage<MediaItem>> fetchCollectionPage(
    String collectionId, {
    int? start,
    int? size,
    abort,
    String? libraryId,
    String? libraryTitle,
  }) async {
    collectionPageCalls.add((start: start, size: size));
    expect(collectionId, 'collection-1');
    return LibraryPage(
      items: [testMediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie')],
      totalCount: 1,
      offset: start ?? 0,
    );
  }

  @override
  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
