import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/connection/connection.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_playlist.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/utils/device_identity.dart';
import 'package:plezy/utils/media_server_http_client.dart';

import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/http_fixtures.dart';
import '../test_helpers/paged_fakes.dart';
import '../test_helpers/media_items.dart';

JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) =>
    testJellyfinConnection(
      baseUrl: baseUrl,
      userName: 'edde',
      accessToken: accessToken,
      deviceId: 'dev-xyz',
      createdAt: DateTime.fromMillisecondsSinceEpoch(0),
    );

JellyfinClient _clientWithPlaybackInfo(
  Future<http.Response> Function(http.Request request) playbackInfo, {
  List<Map<String, dynamic>>? itemSources,
}) {
  final sources =
      itemSources ??
      [
        {
          'Id': 'src-1',
          'Container': 'mkv',
          'MediaStreams': [
            {'Index': 0, 'Type': 'Video'},
          ],
        },
      ];
  return JellyfinClient.forTesting(
    connection: _conn(),
    httpClient: MockClient((request) {
      if (request.url.path == '/Users/user-1/Items/item-1') {
        return Future.value(jsonResponse({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': sources}));
      }
      if (request.url.path == '/Items/item-1/PlaybackInfo') {
        return playbackInfo(request);
      }
      return Future.value(http.Response('{}', 404));
    }),
  );
}

/// Serves [routes] as JSON keyed by request path and records the last URL seen
/// for each path; every other path answers 404.
({JellyfinClient client, Map<String, Uri> requests}) _routedClient(Map<String, Object> routes) {
  final requests = <String, Uri>{};
  final client = JellyfinClient.forTesting(
    connection: _conn(),
    httpClient: MockClient((req) async {
      final body = routes[req.url.path];
      if (body == null) return http.Response('not found', 404);
      requests[req.url.path] = req.url;
      return jsonResponse(body);
    }),
  );
  addTearDown(client.close);
  return (client: client, requests: requests);
}

/// URL-builder smoke tests. We can't unit-test a network round-trip without
/// spinning up a Jellyfin server, but the URL shape is a clear unit-of-work:
/// query parameters must include the right keys and the auth token. These
/// tests pin the contract so the next iteration of the player (Task 8 wiring)
/// has something to point at.
void main() {
  // Pin device identity so JellyfinClient.create's MediaBrowser header falls
  // back to Device="Plezy" instead of resolving the host machine's name.
  setUpAll(() => DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Test')));
  tearDownAll(() => DeviceIdentityService.debugOverride(null));

  group('JellyfinClient URL builders', () {
    late JellyfinClient client;

    setUp(() async {
      client = await JellyfinClient.create(_conn());
    });

    tearDown(() {
      client.close();
    });

    test('buildDirectStreamUrl includes static flag, api_key, and device id', () {
      final url = client.buildDirectStreamUrl('item-99');
      final uri = Uri.parse(url);

      expect(uri.scheme, 'https');
      expect(uri.host, 'jf.example.com');
      expect(uri.path, '/Videos/item-99/stream');
      expect(uri.queryParameters['Static'], 'true');
      expect(uri.queryParameters['api_key'], 'tok-abc');
      expect(uri.queryParameters['DeviceId'], 'dev-xyz');
      expect(uri.queryParameters.containsKey('Container'), isFalse);
    });

    test('buildDirectStreamUrl appends Container when provided', () {
      final url = client.buildDirectStreamUrl('item-99', container: 'mp4');
      expect(Uri.parse(url).queryParameters['Container'], 'mp4');
    });

    test('buildDirectStreamUrl appends MediaSourceId when provided', () {
      // Items with multiple `MediaSources` need this param to disambiguate;
      // without it Jellyfin defaults to the primary source even if the URL's
      // {itemId} matches a non-primary.
      final url = client.buildDirectStreamUrl('item-99', mediaSourceId: 'src-2');
      expect(Uri.parse(url).queryParameters['MediaSourceId'], 'src-2');
    });

    test('buildDirectStreamUrl appends AudioStreamIndex when provided', () {
      final url = client.buildDirectStreamUrl('item-99', audioStreamIndex: 4);
      expect(Uri.parse(url).queryParameters['AudioStreamIndex'], '4');
    });

    test('buildDirectStreamUrl omits MediaSourceId by default', () {
      final url = client.buildDirectStreamUrl('item-99');
      expect(Uri.parse(url).queryParameters.containsKey('MediaSourceId'), isFalse);
    });

    test('buildDirectStreamUrl path-encodes reserved item id characters', () {
      final url = client.buildDirectStreamUrl('folder/item #1?x');
      expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/stream');
    });

    test('buildAudioDirectStreamUrl targets /Audio with the same static-stream contract', () {
      final url = client.buildAudioDirectStreamUrl('track-7');
      final uri = Uri.parse(url);

      expect(uri.path, '/Audio/track-7/stream');
      expect(uri.queryParameters['Static'], 'true');
      expect(uri.queryParameters['api_key'], 'tok-abc');
      expect(uri.queryParameters['DeviceId'], 'dev-xyz');
      expect(uri.queryParameters.containsKey('Container'), isFalse);
      expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse);
    });

    test('buildAudioDirectStreamUrl appends Container and MediaSourceId when provided', () {
      final url = client.buildAudioDirectStreamUrl('track-7', container: 'flac', mediaSourceId: 'src-9');
      final uri = Uri.parse(url);

      expect(uri.queryParameters['Container'], 'flac');
      expect(uri.queryParameters['MediaSourceId'], 'src-9');
    });

    test('buildDirectStreamUrl canonicalizes a mixed-case scheme from stored config', () async {
      // This URL bypasses Dart's Uri normalization on its way to the player,
      // and FFmpeg's protocol lookup is case-sensitive — a stored
      // "Https://..." base URL fails with "Protocol not found" (#1465).
      final mixedCase = await JellyfinClient.create(_conn(baseUrl: 'Https://jf.example.com/'));
      addTearDown(mixedCase.close);
      final url = mixedCase.buildDirectStreamUrl('item-99');
      expect(url, startsWith('https://jf.example.com/Videos/'));
    });

    test('fetchSortOptions exposes the broad Jellyfin sort set', () async {
      final sorts = await client.fetchSortOptions('lib-1');
      expect(sorts.map((sort) => sort.key).toList(), [
        'title',
        'rating',
        'criticRating',
        'addedAt',
        'lastViewedAt',
        'viewCount',
        'productionYear',
        'runtime',
        'officialRating',
        'originallyAvailableAt',
        'startDate',
        'airTime',
        'studio',
        'random',
      ]);
    });

    test('fetchSortOptions adds episode added sort only for shows', () async {
      final showSorts = await client.fetchSortOptions('lib-1', libraryType: 'show');
      expect(showSorts.map((sort) => sort.key).toList(), [
        'title',
        'rating',
        'criticRating',
        'addedAt',
        'episode.addedAt',
        'lastViewedAt',
        'viewCount',
        'productionYear',
        'runtime',
        'officialRating',
        'originallyAvailableAt',
        'startDate',
        'airTime',
        'studio',
        'random',
      ]);

      final movieSorts = await client.fetchSortOptions('lib-1', libraryType: 'movie');
      expect(movieSorts.map((sort) => sort.key), isNot(contains('episode.addedAt')));
    });

    test('fetchExtras combines local trailers and special features as playable videos', () async {
      const itemId = 'movie/id #1?x';
      final encodedItemId = Uri.encodeComponent(itemId);
      final requests = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          requests.add(request.url);
          if (request.url.path == '/Items/$encodedItemId/LocalTrailers') {
            return jsonResponse([
              {
                'Id': 'trailer-1',
                'Name': 'Trailer',
                'Type': 'Trailer',
                'ExtraType': 'Trailer',
                'RunTimeTicks': 900000000,
                'ImageTags': {'Primary': 'trailer-tag'},
              },
              {'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'},
            ]);
          }
          if (request.url.path == '/Items/$encodedItemId/SpecialFeatures') {
            return jsonResponse([
              {'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'},
              {
                'Id': 'featurette-1',
                'Name': 'Making Of',
                'Type': 'Video',
                'ExtraType': 'Featurette',
                'RunTimeTicks': 1800000000,
                'BackdropImageTags': ['featurette-backdrop'],
              },
            ]);
          }
          return http.Response('unexpected ${request.url}', 500);
        }),
      );
      addTearDown(scoped.close);

      final extras = await scoped.fetchExtras(itemId);

      expect(requests.map((uri) => uri.path).toSet(), {
        '/Items/$encodedItemId/LocalTrailers',
        '/Items/$encodedItemId/SpecialFeatures',
      });
      expect(requests.every((uri) => uri.queryParameters['userId'] == 'user-1'), isTrue);
      expect(requests.every((uri) => uri.queryParameters['EnableImageTypes'] == 'Primary,Backdrop,Thumb,Logo'), isTrue);
      expect(requests.every((uri) => uri.queryParameters['ImageTypeLimit'] == '3'), isTrue);
      expect(extras.map((item) => item.id).toList(), ['trailer-1', 'featurette-1']);
      expect(extras.every((item) => item.kind.isVideo), isTrue);
      expect(extras.every((item) => item.serverId == 'srv-1'), isTrue);
      expect(extras.every((item) => item.serverName == 'Home'), isTrue);
      expect(extras[0].kind, MediaKind.clip);
      expect(extras[0].raw?['ExtraType'], 'Trailer');
      expect(extras[1].kind, MediaKind.clip);
      expect(extras[1].raw?['ExtraType'], 'Featurette');
      expect(extras[1].thumbPath, isNull);
      expect(extras[1].artPath, isNotNull);
      expect(extras[1].posterThumb(), extras[1].artPath);
    });

    test('fetchChildren requests media sources for episode-row quality labels', () async {
      final requests = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          requests.add(request.url);
          if (request.url.path == '/Shows/season-1/Seasons') {
            return http.Response('not found', 404);
          }
          if (request.url.path == '/Items') {
            return http.Response(jsonEncode({'Items': <Object>[], 'TotalRecordCount': 0}), 200);
          }
          return http.Response('unexpected ${request.url}', 500);
        }),
      );
      addTearDown(scoped.close);

      await scoped.fetchChildren('season-1');

      final directChildrenRequest = requests.firstWhere((uri) => uri.path == '/Items');
      expect(directChildrenRequest.queryParameters['Fields']!.split(','), contains('MediaSources'));
      expect(directChildrenRequest.queryParameters['SortBy'], 'ParentIndexNumber,IndexNumber,SortName');
      expect(directChildrenRequest.queryParameters['SortOrder'], 'Ascending,Ascending,Ascending');
    });

    test('fetchPlayableDescendantsPage requests media sources for episode-row quality labels', () async {
      Uri? capturedUri;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          capturedUri = request.url;
          return http.Response(jsonEncode({'Items': <Object>[], 'TotalRecordCount': 0}), 200);
        }),
      );
      addTearDown(scoped.close);

      await scoped.fetchPlayableDescendantsPage('show-1');

      expect(capturedUri!.path, '/Items');
      expect(capturedUri!.queryParameters['Fields']!.split(','), contains('MediaSources'));
    });

    test('reportPlaybackProgress sends media source and stream indexes', () async {
      Uri? capturedUri;
      String? capturedBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          capturedUri = request.url;
          capturedBody = request.body;
          return http.Response('', 204);
        }),
      );
      addTearDown(scoped.close);

      await scoped.reportPlaybackProgress(
        itemId: 'item-1',
        position: const Duration(seconds: 12),
        duration: const Duration(seconds: 100),
        isPaused: true,
        playSessionId: 'play-1',
        playMethod: 'Transcode',
        mediaSourceId: 'source-1',
        audioStreamIndex: 2,
        subtitleStreamIndex: -1,
      );

      expect(capturedUri!.path, '/Sessions/Playing/Progress');
      final body = jsonDecode(capturedBody!) as Map<String, dynamic>;
      expect(body['ItemId'], 'item-1');
      expect(body['MediaSourceId'], 'source-1');
      expect(body['AudioStreamIndex'], 2);
      expect(body['SubtitleStreamIndex'], -1);
      expect(body['PlaySessionId'], 'play-1');
      expect(body['PlayMethod'], 'Transcode');
      expect(body['IsPaused'], isTrue);
    });

    test('live playback reports preserve the same server session identity', () async {
      final requests = <({String path, Map<String, dynamic> body})>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          requests.add((path: request.url.path, body: jsonDecode(request.body) as Map<String, dynamic>));
          return http.Response('', 204);
        }),
      );
      addTearDown(scoped.close);

      await scoped.reportPlaybackStarted(
        itemId: 'channel-1',
        position: Duration.zero,
        playSessionId: 'play-1',
        liveStreamId: 'live-1',
        mediaSourceId: 'source-1',
      );
      await scoped.reportPlaybackProgress(
        itemId: 'channel-1',
        position: const Duration(seconds: 10),
        duration: Duration.zero,
        playSessionId: 'play-1',
        liveStreamId: 'live-1',
        mediaSourceId: 'source-1',
      );
      await scoped.reportPlaybackStopped(
        itemId: 'channel-1',
        position: const Duration(seconds: 20),
        playSessionId: 'play-1',
        liveStreamId: 'live-1',
        mediaSourceId: 'source-1',
      );

      expect(requests.map((request) => request.path), [
        '/Sessions/Playing',
        '/Sessions/Playing/Progress',
        '/Sessions/Playing/Stopped',
      ]);
      for (final request in requests) {
        expect(request.body['ItemId'], 'channel-1');
        expect(request.body['PlaySessionId'], 'play-1');
        expect(request.body['LiveStreamId'], 'live-1');
        expect(request.body['MediaSourceId'], 'source-1');
      }
    });

    test('resolveDownload pins direct stream URL and subtitles to selected media source', () async {
      final requests = <Uri>[];
      String? playbackInfoBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          requests.add(request.url);
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []},
                {'Id': 'src-2', 'Container': 'mkv', 'MediaStreams': []},
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            playbackInfoBody = request.body;
            return jsonResponse({
              'MediaSources': [
                {'Id': 'src-1', 'MediaStreams': []},
                {
                  'Id': 'src-2',
                  'MediaStreams': [
                    {
                      'Index': 3,
                      'Type': 'Subtitle',
                      'Codec': 'srt',
                      'Language': 'eng',
                      'DisplayLanguage': 'English',
                      'DisplayTitle': 'English - SRT',
                      'IsExternal': true,
                      'DeliveryMethod': 'External',
                      'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt',
                    },
                    {
                      'Index': 4,
                      'Type': 'Subtitle',
                      'Codec': 'srt',
                      'Language': 'fra',
                      'DisplayLanguage': 'French',
                      'DisplayTitle': 'French - SRT',
                      'DeliveryMethod': 'External',
                      'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/4/Stream.srt',
                    },
                  ],
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final resolution = await scoped.resolveDownload(
        testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
        mediaIndex: 1,
      );

      final uri = Uri.parse(resolution.videoUrl!);
      expect(uri.queryParameters['MediaSourceId'], 'src-2');
      expect(uri.queryParameters['Container'], 'mkv');
      expect(requests.map((u) => u.path), contains('/Items/item-1/PlaybackInfo'));
      final playbackInfoRequest = requests.firstWhere((u) => u.path == '/Items/item-1/PlaybackInfo');
      expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-2');
      final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
      expect(body['MediaSourceId'], 'src-2');
      expect(resolution.externalSubtitles, hasLength(1));
      final subtitle = resolution.externalSubtitles.single;
      expect(subtitle.id, 3);
      expect(subtitle.language, 'English');
      expect(subtitle.languageCode, 'eng');
      final subtitleUri = Uri.parse(subtitle.url);
      expect(subtitleUri.path, '/Videos/item-1/src-2/Subtitles/3/Stream.srt');
      expect(subtitleUri.queryParameters['api_key'], 'tok-abc');

      requests.clear();
      playbackInfoBody = null;
      final pinnedResolution = await scoped.resolveDownload(
        testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
        mediaIndex: 0,
        mediaSourceId: 'src-2',
      );
      expect(Uri.parse(pinnedResolution.videoUrl!).queryParameters['MediaSourceId'], 'src-2');
      expect(
        requests.firstWhere((u) => u.path == '/Items/item-1/PlaybackInfo').queryParameters['MediaSourceId'],
        'src-2',
      );
      expect((jsonDecode(playbackInfoBody!) as Map<String, dynamic>)['MediaSourceId'], 'src-2');
    });

    test('resolveDownload keeps the static stream after non-authentication enrichment failures', () async {
      final cases = <(String, Future<http.Response> Function(http.Request))>[
        ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})),
        ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})),
        ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})),
      ];

      for (final (name, handler) in cases) {
        final scoped = _clientWithPlaybackInfo(handler);
        addTearDown(scoped.close);
        final resolution = await scoped.resolveDownload(
          testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
        );

        final uri = Uri.parse(resolution.videoUrl!);
        expect(uri.path, '/Videos/item-1/stream', reason: name);
        expect(uri.queryParameters['Static'], 'true', reason: name);
        expect(resolution.externalSubtitles, isEmpty, reason: name);
        expect(resolution.externalSubtitlesResolved, isFalse, reason: name);
      }
    });

    test('resolveDownload keeps the static stream when subtitle metadata is malformed', () async {
      final scoped = _clientWithPlaybackInfo(
        (_) async => jsonResponse({
          'MediaSources': [
            {'Id': 'src-1'},
          ],
        }),
      );
      addTearDown(scoped.close);

      final resolution = await scoped.resolveDownload(
        testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
      );

      expect(Uri.parse(resolution.videoUrl!).path, '/Videos/item-1/stream');
      expect(resolution.externalSubtitles, isEmpty);
      expect(resolution.externalSubtitlesResolved, isFalse);
    });

    test('resolveDownload does not hide PlaybackInfo authentication failures', () async {
      final scoped = _clientWithPlaybackInfo(
        (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'}),
      );
      addTearDown(scoped.close);

      await expectLater(
        scoped.resolveDownload(
          testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
        ),
        throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 401)),
      );
    });

    test('getPlaybackInitialization uses static playback after non-authentication negotiation failures', () async {
      final cases = <(String, Future<http.Response> Function(http.Request))>[
        ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})),
        ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})),
        ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})),
      ];

      for (final (name, handler) in cases) {
        final scoped = _clientWithPlaybackInfo(handler);
        addTearDown(scoped.close);
        final result = await scoped.getPlaybackInitialization(
          PlaybackInitializationOptions(
            metadata: testMediaItem(
              id: 'item-1',
              backend: MediaBackend.jellyfin,
              kind: MediaKind.movie,
              serverId: 'srv-1',
            ),
            selectedMediaIndex: 0,
            qualityPreset: TranscodeQualityPreset.original,
          ),
        );

        expect(Uri.parse(result.videoUrl!).path, '/Videos/item-1/stream', reason: name);
        expect(result.playMethod, 'DirectPlay', reason: name);
        expect(result.fallbackReason, TranscodeFallbackReason.decisionFailed, reason: name);
      }
    });

    test('getPlaybackInitialization maps negotiation authentication failures', () async {
      final scoped = _clientWithPlaybackInfo(
        (_) async => http.Response('{}', 403, headers: {'content-type': 'application/json'}),
      );
      addTearDown(scoped.close);

      await expectLater(
        scoped.getPlaybackInitialization(
          PlaybackInitializationOptions(
            metadata: testMediaItem(
              id: 'item-1',
              backend: MediaBackend.jellyfin,
              kind: MediaKind.movie,
              serverId: 'srv-1',
            ),
            selectedMediaIndex: 0,
            qualityPreset: TranscodeQualityPreset.original,
          ),
        ),
        throwsA(
          isA<PlaybackException>().having(
            (error) => error.reason,
            'reason',
            PlaybackFailureReason.authenticationRequired,
          ),
        ),
      );
    });

    test('resolveExternalPlaybackUrl pins primary source id when alternates exist', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {'Id': 'item-1', 'Container': 'mp4', 'MediaStreams': []},
                {'Id': 'src-alt', 'Container': 'mkv', 'MediaStreams': []},
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final url = await scoped.resolveExternalPlaybackUrl(
        testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
        mediaIndex: 0,
        mediaSourceId: 'item-1',
      );

      final uri = Uri.parse(url!);
      expect(uri.queryParameters['MediaSourceId'], 'item-1');
      expect(uri.queryParameters['Container'], 'mp4');
    });

    test('getPlaybackInitialization sends resume ticks without rewriting TranscodingUrl', () async {
      final playbackInfoUris = <Uri>[];
      final playbackInfoBodies = <String>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []},
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            playbackInfoUris.add(request.url);
            playbackInfoBodies.add(request.body);
            return jsonResponse({
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'},
                    {
                      'Index': 2,
                      'Type': 'Subtitle',
                      'Codec': 'srt',
                      'Language': 'eng',
                      'DisplayTitle': 'English - SRT',
                      'DeliveryMethod': 'External',
                      'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt',
                    },
                  ],
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
            viewOffsetMs: 143894,
          ),
          selectedMediaIndex: 0,
          qualityPreset: TranscodeQualityPreset.p720_2mbps,
        ),
      );

      expect(result.isTranscoding, isTrue);
      expect(result.playMethod, 'Transcode');
      expect(result.playSessionId, 'play-session-1');
      expect(playbackInfoUris, hasLength(1));
      expect(playbackInfoUris.single.queryParameters['StartTimeTicks'], '1438940000');
      final body = jsonDecode(playbackInfoBodies.single) as Map<String, dynamic>;
      expect(body['StartTimeTicks'], 1438940000);
      final uri = Uri.parse(result.videoUrl!);
      expect(uri.path, '/Videos/item-1/master.m3u8');
      expect(uri.queryParameters['MediaSourceId'], 'src-1');
      expect(uri.queryParameters['PlaySessionId'], 'play-session-1');
      expect(uri.queryParameters['api_key'], 'tok-abc');
      expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
      expect(result.mediaInfo!.subtitleTracks, hasLength(1));
      expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse);
      expect(result.mediaInfo!.subtitleTracks.single.usesExternalDelivery, isTrue);
      expect(result.externalSubtitles, hasLength(1));
      expect(result.subtitleSidecars.single.sourceStreamId, 2);
      expect(result.externalSubtitles.single.title, 'English');
      expect(result.externalSubtitles.single.language, 'eng');
      final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
      expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/2/Stream.srt');
      expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
    });

    test('getPlaybackInitialization uses negotiated DirectStreamUrl when transcode URL is absent', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []},
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            return jsonResponse({
              'PlaySessionId': 'play-session-direct',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct',
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
          qualityPreset: TranscodeQualityPreset.p720_2mbps,
        ),
      );

      expect(result.isTranscoding, isFalse);
      expect(result.playMethod, 'DirectStream');
      expect(result.fallbackReason, isNull);
      expect(result.playSessionId, 'play-session-direct');
      final uri = Uri.parse(result.videoUrl!);
      expect(uri.path, '/Videos/item-1/stream');
      expect(uri.queryParameters['PlaySessionId'], 'play-session-direct');
      expect(uri.queryParameters['api_key'], 'tok-abc');
    });

    test('getPlaybackInitialization prefers DirectStreamUrl over TranscodingUrl for original playback', () async {
      final requests = <Uri>[];
      String? playbackInfoBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          requests.add(request.url);
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'Container': 'mp4',
                  'MediaStreams': [
                    {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'},
                  ],
                },
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            playbackInfoBody = request.body;
            return jsonResponse({
              'PlaySessionId': 'play-session-direct',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'Container': 'mp4',
                  'DefaultAudioStreamIndex': 1,
                  'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct',
                  'TranscodingUrl':
                      '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode',
                  'MediaStreams': [
                    {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'},
                    {
                      'Index': 3,
                      'Type': 'Subtitle',
                      'Codec': 'srt',
                      'Language': 'eng',
                      'DisplayTitle': 'English - SRT',
                      'IsExternal': true,
                      'DeliveryMethod': 'External',
                      'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt',
                    },
                  ],
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
        ),
      );

      final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo');
      expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse);
      expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse);
      expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-1');
      final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
      expect(body.containsKey('MaxStreamingBitrate'), isFalse);
      expect(body.containsKey('StartTimeTicks'), isFalse);
      final profile = body['DeviceProfile'] as Map<String, dynamic>;
      expect(profile.containsKey('MaxStreamingBitrate'), isFalse);

      expect(result.isTranscoding, isFalse);
      expect(result.playMethod, 'DirectStream');
      expect(result.playSessionId, 'play-session-direct');
      expect(result.activeAudioStreamId, isNull);
      expect(result.mediaInfo!.audioTracks.single.selected, isTrue);
      final uri = Uri.parse(result.videoUrl!);
      expect(uri.path, '/Videos/item-1/stream');
      expect(uri.queryParameters['PlaySessionId'], 'play-session-direct');
      expect(uri.queryParameters['PlaySessionId'], isNot('play-session-transcode'));
      expect(uri.queryParameters['api_key'], 'tok-abc');
      expect(result.mediaInfo!.subtitleTracks, hasLength(1));
      expect(result.externalSubtitles, hasLength(1));
      expect(result.subtitleSidecars.single.sourceStreamId, 3);
      expect(result.externalSubtitles.single.title, 'English');
      final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
      expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
      expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
    });

    test('getPlaybackInitialization maps semantic subtitle preferences to current source rows', () async {
      Uri? playbackInfoUri;
      String? playbackInfoBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'Container': 'mkv',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Video'},
                    {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'},
                    {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'},
                    {'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true},
                  ],
                },
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            playbackInfoUri = request.url;
            playbackInfoBody = request.body;
            return jsonResponse({
              'PlaySessionId': 'play-session-direct',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'Container': 'mkv',
                  'DefaultSubtitleStreamIndex': 4,
                  'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Video'},
                    {
                      'Index': 3,
                      'Type': 'Subtitle',
                      'Codec': 'srt',
                      'Language': 'eng',
                      'DisplayTitle': 'English - SRT',
                      'DeliveryMethod': 'External',
                      'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt',
                    },
                    {
                      'Index': 4,
                      'Type': 'Subtitle',
                      'Codec': 'srt',
                      'Language': 'fra',
                      'DisplayTitle': 'French - SRT',
                      'DeliveryMethod': 'External',
                      'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt',
                    },
                    {
                      'Index': 5,
                      'Type': 'Subtitle',
                      'Codec': 'srt',
                      'Language': 'eng',
                      'DisplayTitle': 'English Forced - SRT',
                      'IsForced': true,
                      'DeliveryMethod': 'External',
                      'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt',
                    },
                  ],
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      Future<PlaybackInitializationResult> initialize(SubtitleTrack preference) {
        return scoped.getPlaybackInitialization(
          PlaybackInitializationOptions(
            metadata: testMediaItem(
              id: 'item-1',
              backend: MediaBackend.jellyfin,
              kind: MediaKind.movie,
              serverId: 'srv-1',
            ),
            selectedMediaIndex: 0,
            preferredSubtitleTrack: preference,
          ),
        );
      }

      void expectRequestedSubtitleIndex(int? expected) {
        expect(playbackInfoUri!.queryParameters['SubtitleStreamIndex'], expected?.toString());
        final playbackInfoJson = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
        expect(playbackInfoJson['SubtitleStreamIndex'], expected);
      }

      final result = await initialize(
        const SubtitleTrack(id: 'source:4', title: 'French - SRT', language: 'fra', codec: 'srt'),
      );
      expectRequestedSubtitleIndex(4);

      await initialize(const SubtitleTrack(id: 'navigation', title: 'English - SRT', language: 'eng', codec: 'srt'));
      expectRequestedSubtitleIndex(3);

      await initialize(const SubtitleTrack(id: 'source:3', title: 'French - SRT', language: 'fra', codec: 'srt'));
      expectRequestedSubtitleIndex(4);

      await initialize(
        const SubtitleTrack(
          id: 'source:3',
          title: 'English Forced - SRT',
          language: 'eng',
          codec: 'srt',
          isForced: true,
        ),
      );
      expectRequestedSubtitleIndex(5);

      await initialize(SubtitleTrack.off);
      expectRequestedSubtitleIndex(-1);

      await initialize(const SubtitleTrack(id: 'navigation', title: 'Japanese - SRT', language: 'jpn', codec: 'srt'));
      expectRequestedSubtitleIndex(null);

      expect(result.playMethod, 'DirectStream');
      expect(result.mediaInfo!.subtitleTracks, hasLength(3));
      expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue);
      expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4, 5]);
      expect(result.externalSubtitles.map((subtitle) => Uri.parse(subtitle.uri!).path), [
        '/Videos/item-1/src-1/Subtitles/3/Stream.srt',
        '/Videos/item-1/src-1/Subtitles/4/Stream.srt',
        '/Videos/item-1/src-1/Subtitles/5/Stream.srt',
      ]);
    });

    test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
      final requests = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          requests.add(request.url);
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'Container': 'mkv',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Video'},
                  ],
                },
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            return jsonResponse({
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'TranscodingUrl':
                      '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Video'},
                  ],
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
        ),
      );

      final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo');
      expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse);
      expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse);
      expect(result.isTranscoding, isFalse);
      expect(result.playMethod, 'DirectPlay');
      expect(result.playSessionId, isNull);
      final uri = Uri.parse(result.videoUrl!);
      expect(uri.path, '/Videos/item-1/stream');
      expect(uri.queryParameters['Static'], 'true');
      expect(uri.queryParameters['MediaSourceId'], 'src-1');
      expect(uri.queryParameters['Container'], 'mkv');
      expect(uri.queryParameters['api_key'], 'tok-abc');
      expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse);
      expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
    });

    test('selected external audio is sent to PlaybackInfo but omitted from static fallback URL', () async {
      Uri? playbackInfoUri;
      String? playbackInfoBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'Container': 'mkv',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Video'},
                    {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true},
                    {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'},
                  ],
                },
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            playbackInfoUri = request.url;
            playbackInfoBody = request.body;
            return jsonResponse({
              'MediaSources': [
                {'Id': 'src-1'},
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
          selectedAudioStreamId: 4,
        ),
      );

      expect(playbackInfoUri!.queryParameters['AudioStreamIndex'], '4');
      final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
      expect(body['AudioStreamIndex'], 4);

      expect(result.playMethod, 'DirectPlay');
      expect(result.activeAudioStreamId, 4);
      final selected = result.mediaInfo!.audioTracks.singleWhere((track) => track.id == 4);
      expect(selected.isExternal, isTrue);
      expect(selected.selected, isTrue);
      final uri = Uri.parse(result.videoUrl!);
      expect(uri.queryParameters.containsKey('AudioStreamIndex'), isFalse);
      expect(uri.queryParameters['MediaSourceId'], 'src-1');
      expect(uri.queryParameters['Container'], 'mkv');
    });

    test('stale selected audio stream is not sent for a source without that stream', () async {
      Uri? playbackInfoUri;
      String? playbackInfoBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'Container': 'mkv',
                  'MediaStreams': [
                    {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'},
                    {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn'},
                  ],
                },
                {
                  'Id': 'src-2',
                  'Container': 'mp4',
                  'DefaultAudioStreamIndex': 8,
                  'MediaStreams': [
                    {'Index': 8, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'},
                  ],
                },
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            playbackInfoUri = request.url;
            playbackInfoBody = request.body;
            return jsonResponse({
              'MediaSources': [
                {'Id': 'src-2'},
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 1,
          selectedAudioStreamId: 4,
        ),
      );

      expect(playbackInfoUri!.queryParameters.containsKey('AudioStreamIndex'), isFalse);
      final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
      expect(body.containsKey('AudioStreamIndex'), isFalse);

      expect(result.activeAudioStreamId, isNull);
      expect(result.mediaInfo!.audioTracks.single.selected, isTrue);
      final uri = Uri.parse(result.videoUrl!);
      expect(uri.queryParameters.containsKey('AudioStreamIndex'), isFalse);
      expect(uri.queryParameters['MediaSourceId'], 'src-2');
    });

    test('playback initialization pins selected media source id over index', () async {
      Uri? playbackInfoUri;
      String? playbackInfoBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {
                  'Id': 'src-4k',
                  'Container': 'mkv',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840},
                  ],
                },
                {
                  'Id': 'src-1080',
                  'Container': 'mp4',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920},
                  ],
                },
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            playbackInfoUri = request.url;
            playbackInfoBody = request.body;
            return jsonResponse({
              'MediaSources': [
                {'Id': 'src-1080'},
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
          selectedMediaSourceId: 'src-1080',
        ),
      );

      expect(playbackInfoUri!.queryParameters['MediaSourceId'], 'src-1080');
      final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
      expect(body['MediaSourceId'], 'src-1080');
      expect(result.availableVersions.map((version) => version.id), ['src-4k', 'src-1080']);
      final uri = Uri.parse(result.videoUrl!);
      expect(uri.queryParameters['MediaSourceId'], 'src-1080');
      expect(uri.queryParameters['Container'], 'mp4');
    });

    test('playback initialization pins primary source id for multi-source direct fallback', () async {
      Uri? playbackInfoUri;
      String? playbackInfoBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {
                  'Id': 'item-1',
                  'Container': 'mp4',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920},
                  ],
                },
                {
                  'Id': 'src-4k',
                  'Container': 'mkv',
                  'MediaStreams': [
                    {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840},
                  ],
                },
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            playbackInfoUri = request.url;
            playbackInfoBody = request.body;
            return jsonResponse({
              'MediaSources': [
                {'Id': 'item-1'},
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
          selectedMediaSourceId: 'item-1',
        ),
      );

      expect(playbackInfoUri!.queryParameters['MediaSourceId'], 'item-1');
      final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
      expect(body['MediaSourceId'], 'item-1');
      final uri = Uri.parse(result.videoUrl!);
      expect(uri.queryParameters['MediaSourceId'], 'item-1');
      expect(uri.queryParameters['Container'], 'mp4');
    });

    test(
      'playback initialization ignores a mismatched negotiated source and keeps the selected static stream',
      () async {
        final scoped = JellyfinClient.forTesting(
          connection: _conn(),
          httpClient: MockClient((request) async {
            if (request.url.path == '/Users/user-1/Items/item-1') {
              return jsonResponse({
                'Id': 'item-1',
                'Type': 'Movie',
                'Name': 'Movie',
                'MediaSources': [
                  {
                    'Id': 'src-1080',
                    'Container': 'mp4',
                    'MediaStreams': [
                      {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920},
                    ],
                  },
                  {
                    'Id': 'src-4k',
                    'Container': 'mkv',
                    'MediaStreams': [
                      {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840},
                    ],
                  },
                ],
              });
            }
            if (request.url.path == '/Items/item-1/PlaybackInfo') {
              return jsonResponse({
                'PlaySessionId': 'wrong-session',
                'MediaSources': [
                  {
                    'Id': 'src-4k',
                    'Container': 'mkv',
                    'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session',
                  },
                ],
              });
            }
            return http.Response('{}', 404);
          }),
        );
        addTearDown(scoped.close);

        final result = await scoped.getPlaybackInitialization(
          PlaybackInitializationOptions(
            metadata: testMediaItem(
              id: 'item-1',
              backend: MediaBackend.jellyfin,
              kind: MediaKind.movie,
              serverId: 'srv-1',
            ),
            selectedMediaIndex: 0,
            selectedMediaSourceId: 'src-1080',
          ),
        );

        expect(result.fallbackReason, TranscodeFallbackReason.decisionFailed);
        final uri = Uri.parse(result.videoUrl!);
        expect(uri.queryParameters['MediaSourceId'], 'src-1080');
        expect(uri.queryParameters['PlaySessionId'], isNull);
      },
    );

    test('empty successful negotiation falls back to the static VOD stream', () async {
      final scoped = _clientWithPlaybackInfo((_) async => jsonResponse({'MediaSources': []}));
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
        ),
      );

      expect(result.fallbackReason, TranscodeFallbackReason.decisionFailed);
      expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-1');
    });

    test('applicable source without negotiated URL falls back to static direct play', () async {
      final scoped = _clientWithPlaybackInfo(
        (_) async => jsonResponse({
          'MediaSources': [
            {'Id': 'src-1'},
          ],
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
          qualityPreset: TranscodeQualityPreset.p720_2mbps,
        ),
      );

      expect(result.isTranscoding, isFalse);
      expect(result.fallbackReason, TranscodeFallbackReason.directPlayOnly);
      expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-1');
    });

    test('video download rejects authentication and cancellation', () async {
      final cases = <(String, Future<http.Response> Function(http.Request))>[
        ('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})),
        ('cancellation', (request) async => throw http.RequestAbortedException(request.url)),
      ];
      final item = testMediaItem(
        id: 'item-1',
        backend: MediaBackend.jellyfin,
        kind: MediaKind.movie,
        serverId: 'srv-1',
      );

      for (final (name, handler) in cases) {
        final scoped = _clientWithPlaybackInfo(handler);
        addTearDown(scoped.close);
        await expectLater(scoped.resolveDownload(item), throwsA(isA<Object>()), reason: name);
      }
    });

    test('video download keeps the static stream for unusable negotiation sources', () async {
      final cases =
          <({Map<String, dynamic> response, List<Map<String, dynamic>>? itemSources, String? expectedSourceId})>[
            (
              response: {'MediaSources': <Object?>[]},
              itemSources: [
                {'Container': 'mkv', 'MediaStreams': <Object?>[]},
              ],
              expectedSourceId: null,
            ),
            (
              response: {
                'MediaSources': ['invalid'],
              },
              itemSources: [
                {'Container': 'mkv', 'MediaStreams': <Object?>[]},
              ],
              expectedSourceId: null,
            ),
            (
              response: {
                'MediaSources': [
                  {'Id': 'other', 'MediaStreams': <Object?>[]},
                ],
              },
              itemSources: null,
              expectedSourceId: 'src-1',
            ),
          ];
      final item = testMediaItem(
        id: 'item-1',
        backend: MediaBackend.jellyfin,
        kind: MediaKind.movie,
        serverId: 'srv-1',
      );

      for (final testCase in cases) {
        final scoped = _clientWithPlaybackInfo(
          (_) async => jsonResponse(testCase.response),
          itemSources: testCase.itemSources,
        );
        addTearDown(scoped.close);
        final resolution = await scoped.resolveDownload(item);
        expect(Uri.parse(resolution.videoUrl!).queryParameters['MediaSourceId'], testCase.expectedSourceId);
        expect(resolution.externalSubtitles, isEmpty);
        expect(resolution.externalSubtitlesResolved, isFalse);
      }
    });

    test('matching download source with empty streams is a complete empty-sidecar plan', () async {
      final scoped = _clientWithPlaybackInfo(
        (_) async => jsonResponse({
          'MediaSources': [
            {'Id': 'src-1', 'MediaStreams': []},
          ],
        }),
      );
      addTearDown(scoped.close);

      final resolution = await scoped.resolveDownload(
        testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
      );

      expect(resolution.videoUrl, isNotNull);
      expect(resolution.externalSubtitles, isEmpty);
    });

    test('track downloads bypass PlaybackInfo', () async {
      var playbackInfoRequests = 0;
      final scoped = _clientWithPlaybackInfo((_) async {
        playbackInfoRequests++;
        return http.Response('{}', 500);
      });
      addTearDown(scoped.close);

      final resolution = await scoped.resolveDownload(
        testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.track, serverId: 'srv-1'),
      );

      expect(resolution.videoUrl, isNotNull);
      expect(resolution.externalSubtitles, isEmpty);
      expect(playbackInfoRequests, 0);
    });

    test('getPlaybackInfo path-encodes reserved item id characters', () async {
      Uri? capturedUri;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          capturedUri = request.url;
          return jsonResponse({'MediaSources': []});
        }),
      );
      addTearDown(scoped.close);

      await scoped.getPlaybackInfo('folder/item #1?x');

      expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo'));
    });

    test('getPlaybackInfo advertises external subtitle support', () async {
      Uri? capturedUri;
      String? capturedBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          capturedUri = request.url;
          capturedBody = request.body;
          return jsonResponse({'MediaSources': []});
        }),
      );
      addTearDown(scoped.close);

      await scoped.getPlaybackInfo(
        'item-1',
        maxStreamingBitrate: 5000000,
        mediaSourceId: 'src-1',
        audioStreamIndex: 1,
        subtitleStreamIndex: 2,
      );

      expect(capturedUri!.queryParameters['MaxStreamingBitrate'], '5000000');
      expect(capturedUri!.queryParameters.containsKey('IsPlayback'), isFalse);
      expect(capturedUri!.queryParameters.containsKey('AutoOpenLiveStream'), isFalse);
      expect(capturedUri!.queryParameters['MediaSourceId'], 'src-1');
      expect(capturedUri!.queryParameters['AudioStreamIndex'], '1');
      expect(capturedUri!.queryParameters['SubtitleStreamIndex'], '2');

      final body = jsonDecode(capturedBody!) as Map<String, dynamic>;
      final profile = body['DeviceProfile'] as Map<String, dynamic>;
      expect(profile['MaxStreamingBitrate'], 5000000);
      expect(profile.containsKey('MaxStaticBitrate'), isFalse);
      expect(profile.containsKey('MusicStreamingTranscodingBitrate'), isFalse);
      expect(profile['DirectPlayProfiles'], isNotEmpty);
      final directPlayProfile = (profile['DirectPlayProfiles'] as List<dynamic>).first as Map<String, dynamic>;
      expect(directPlayProfile['VideoCodec'], contains('mpeg2video'));
      expect(directPlayProfile['AudioCodec'], contains('mp2'));
      expect(profile['TranscodingProfiles'], isNotEmpty);
      expect(profile['CodecProfiles'], isEmpty);
      final subtitleProfiles = profile['SubtitleProfiles'] as List<dynamic>;
      expect(
        subtitleProfiles.map((profile) => (profile as Map<String, dynamic>)['Format']),
        containsAll(['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']),
      );
      expect(subtitleProfiles.every((profile) => (profile as Map<String, dynamic>)['Method'] == 'External'), isTrue);
    });

    test('path-encodes reserved ids for browse and watch-state endpoints', () async {
      final captured = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          captured.add(request.url);
          return jsonResponse({'Items': <Object>[]});
        }),
      );
      addTearDown(scoped.close);

      final item = testMediaItem(
        id: 'folder/item #1?x',
        backend: MediaBackend.jellyfin,
        kind: MediaKind.movie,
        serverId: 'srv-1',
      );

      try {
        await scoped.fetchChildren('folder/show #1?x');
      } catch (_) {
        // This URL-only test does not initialize JellyfinApiCache; fetchChildren
        // may fail after the request when it tries to cache the mock response.
      }
      await scoped.fetchClientSideEpisodeQueue('folder/show #1?x');
      await scoped.markWatched(item);
      await scoped.markUnwatched(item);
      await scoped.rate(item, 7);
      await scoped.rate(item, -1);

      final paths = captured.map((u) => u.path).toList();
      expect(paths, contains('/Shows/folder%2Fshow%20%231%3Fx/Seasons'));
      expect(paths, contains('/Shows/folder%2Fshow%20%231%3Fx/Episodes'));
      expect(paths, contains('/UserPlayedItems/folder%2Fitem%20%231%3Fx'));
      expect(paths.where((p) => p == '/UserPlayedItems/folder%2Fitem%20%231%3Fx'), hasLength(2));
      expect(paths.where((p) => p == '/UserItems/folder%2Fitem%20%231%3Fx/Rating'), hasLength(2));
    });

    test('removeFromContinueWatching is unsupported for Jellyfin and does not call the server', () async {
      var requested = false;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          requested = true;
          return http.Response('', 500);
        }),
      );
      addTearDown(scoped.close);

      final item = testMediaItem(
        id: 'item-1',
        backend: MediaBackend.jellyfin,
        kind: MediaKind.movie,
        serverId: 'srv-1',
      );

      await expectLater(scoped.removeFromContinueWatching(item), throwsA(isA<UnsupportedError>()));
      expect(requested, isFalse);
    });

    test('getPlaybackInitialization URL-encodes appended api_key', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(accessToken: 'tok+with spaces/?&'),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []},
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            return jsonResponse({
              'MediaSources': [
                {'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1'},
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
          qualityPreset: TranscodeQualityPreset.p720_2mbps,
        ),
      );

      expect(result.videoUrl, contains('api_key=tok%2Bwith+spaces%2F%3F%26'));
      expect(Uri.parse(result.videoUrl!).queryParameters['api_key'], 'tok+with spaces/?&');
    });

    test('getPlaybackInitialization builds fallback URL for external subtitle without DeliveryUrl', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'Container': 'mp4',
                  'MediaStreams': [
                    {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsExternal': true},
                  ],
                },
              ],
            });
          }
          if (request.url.path == '/Items/item-1/PlaybackInfo') {
            return jsonResponse({
              'MediaSources': [
                {'Id': 'src-1'},
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
        ),
      );

      expect(result.externalSubtitles, hasLength(1));
      expect(result.playMethod, 'DirectPlay');
      final uri = Uri.parse(result.externalSubtitles.single.uri!);
      expect(uri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
      expect(uri.queryParameters['api_key'], 'tok-abc');
    });

    test('live TV stream resolution requires an HLS transcode', () async {
      final requests = <Uri>[];
      String? capturedBody;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          requests.add(request.url);
          capturedBody = request.body;
          if (request.url.path == '/Items/channel-1/PlaybackInfo') {
            return jsonResponse({
              'PlaySessionId': 'live-session-1',
              'MediaSources': [
                {
                  'Id': 'source-1',
                  'Container': 'ts',
                  'LiveStreamId': 'open-stream-1',
                  'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=live-session-1',
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final resolution = await scoped.liveTv.resolveStreamUrl('channel-1');

      expect(requests.single.path, '/Items/channel-1/PlaybackInfo');
      expect(requests.single.queryParameters['AutoOpenLiveStream'], 'true');
      expect(requests.single.queryParameters['EnableTranscoding'], 'true');
      expect(requests.single.queryParameters['EnableDirectPlay'], 'false');
      expect(requests.single.queryParameters['EnableDirectStream'], 'false');
      expect(requests.single.queryParameters['AllowVideoStreamCopy'], 'true');
      expect(requests.single.queryParameters['AllowAudioStreamCopy'], 'true');
      final body = jsonDecode(capturedBody!) as Map<String, dynamic>;
      expect(body['AutoOpenLiveStream'], isTrue);
      expect(body['EnableTranscoding'], isTrue);
      expect(body['EnableDirectPlay'], isFalse);
      expect(body['EnableDirectStream'], isFalse);
      expect(resolution, isNotNull);
      expect(resolution!.playSessionId, 'live-session-1');
      expect(resolution.mediaSourceId, 'source-1');
      expect(resolution.liveStreamId, 'open-stream-1');
      expect(resolution.playMethod, 'Transcode');
      final uri = Uri.parse(resolution.url);
      expect(uri.path, '/Videos/channel-1/live.m3u8');
      expect(uri.queryParameters['PlaySessionId'], 'live-session-1');
      expect(uri.queryParameters['api_key'], 'tok-abc');
    });

    test('live TV stream resolution recovers identity from a negotiated HLS URL', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Items/channel-1/PlaybackInfo') {
            return jsonResponse({
              'MediaSources': [
                {
                  'Container': 'ts',
                  'TranscodingUrl':
                      '/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url',
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final resolution = await scoped.liveTv.resolveStreamUrl('channel-1');

      expect(resolution, isNotNull);
      expect(resolution!.playSessionId, 'play-url');
      expect(resolution.mediaSourceId, 'source-url');
      expect(resolution.liveStreamId, 'live-url');
      expect(resolution.playMethod, 'Transcode');
    });

    test('live TV stream resolution rejects a non-HLS fallback URL', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Items/channel-1/PlaybackInfo') {
            return jsonResponse({
              'MediaSources': [
                {'DirectStreamUrl': '/Videos/channel-1/stream.ts'},
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      expect(await scoped.liveTv.resolveStreamUrl('channel-1'), isNull);
    });

    test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () {
      final url = client.buildTrickplayTileUrl('item-99', 320, 4);
      final uri = Uri.parse(url);

      expect(uri.scheme, 'https');
      expect(uri.host, 'jf.example.com');
      expect(uri.path, '/Videos/item-99/Trickplay/320/4.jpg');
      expect(uri.queryParameters['api_key'], 'tok-abc');
      expect(uri.queryParameters['DeviceId'], 'dev-xyz');
      expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse);
    });

    test('buildTrickplayTileUrl appends MediaSourceId when provided', () {
      // Multi-source items need the param; without it Jellyfin returns the
      // primary source's tiles even if the user picked a non-default version.
      final url = client.buildTrickplayTileUrl('item-99', 320, 0, mediaSourceId: 'src-2');
      expect(Uri.parse(url).queryParameters['MediaSourceId'], 'src-2');
    });

    test('buildTrickplayTileUrl URL-encodes special chars in itemId', () {
      final url = client.buildTrickplayTileUrl('item with spaces & chars', 160, 1);
      // Path segments are encoded once; the `+` form for spaces is also
      // valid per RFC 3986 — Uri.parse normalizes back to the original.
      expect(url, contains('/Videos/item%20with%20spaces%20%26%20chars/Trickplay/160/1.jpg'));
    });

    test('thumbnailUrl resolves a relative path against baseUrl with api_key', () {
      final url = client.thumbnailUrl('/Items/item-99/Images/Primary?tag=abc');
      final uri = Uri.parse(url);
      expect(uri.scheme, 'https');
      expect(uri.host, 'jf.example.com');
      expect(uri.path, '/Items/item-99/Images/Primary');
      expect(uri.queryParameters['tag'], 'abc');
      expect(uri.queryParameters['api_key'], 'tok-abc');
    });

    test('thumbnailUrl preserves reverse-proxy subpaths for relative artwork paths', () {
      final proxied = JellyfinClient.forTesting(
        connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'),
        httpClient: MockClient((_) async => http.Response('{}', 200)),
      );
      addTearDown(proxied.close);

      final url = proxied.thumbnailUrl('/Items/item-99/Images/Primary?tag=abc');
      final uri = Uri.parse(url);

      expect(uri.path, '/jellyfin/Items/item-99/Images/Primary');
      expect(uri.queryParameters['tag'], 'abc');
      expect(uri.queryParameters['api_key'], 'tok-abc');
    });

    test('negotiated bare relative DirectStreamUrl preserves reverse-proxy subpaths', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'),
        httpClient: MockClient((request) async {
          if (request.url.path == '/jellyfin/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []},
              ],
            });
          }
          if (request.url.path == '/jellyfin/Items/item-1/PlaybackInfo') {
            return jsonResponse({
              'PlaySessionId': 'play-session-direct',
              'MediaSources': [
                {
                  'Id': 'src-1',
                  'DirectStreamUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct',
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: testMediaItem(
            id: 'item-1',
            backend: MediaBackend.jellyfin,
            kind: MediaKind.movie,
            serverId: 'srv-1',
          ),
          selectedMediaIndex: 0,
          qualityPreset: TranscodeQualityPreset.p720_2mbps,
        ),
      );

      final uri = Uri.parse(result.videoUrl!);
      expect(uri.path, '/jellyfin/Videos/item-1/stream');
      expect(uri.queryParameters['PlaySessionId'], 'play-session-direct');
      expect(uri.queryParameters['api_key'], 'tok-abc');
    });

    test('selected source never inherits another source nested trickplay', () async {
      final metadata = testMediaItem(
        id: 'item-trickplay',
        backend: MediaBackend.jellyfin,
        kind: MediaKind.movie,
        serverId: 'srv-1',
      );
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          if (request.url.path == '/Users/user-1/Items/item-trickplay') {
            return jsonResponse({
              'Id': 'item-trickplay',
              'Type': 'Movie',
              'Name': 'Movie',
              'MediaSources': [
                {'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []},
                {'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []},
              ],
              'Trickplay': {
                'src-a': {
                  '160': {
                    'Width': 160,
                    'Height': 90,
                    'TileWidth': 4,
                    'TileHeight': 4,
                    'ThumbnailCount': 16,
                    'Interval': 10000,
                  },
                },
              },
            });
          }
          if (request.url.path == '/Items/item-trickplay/PlaybackInfo') {
            return jsonResponse({
              'PlaySessionId': 'play-b',
              'MediaSources': [
                {
                  'Id': 'src-b',
                  'Container': 'mp4',
                  'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b',
                  'MediaStreams': [],
                },
              ],
            });
          }
          return http.Response('{}', 404);
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.getPlaybackInitialization(
        PlaybackInitializationOptions(
          metadata: metadata,
          selectedMediaIndex: 1,
          selectedMediaSourceId: 'src-b',
          qualityPreset: TranscodeQualityPreset.original,
        ),
      );

      expect(result.mediaInfo?.mediaSourceId, 'src-b');
      expect(result.mediaInfo?.trickplayByWidth, isNull);
      expect(result.selectedVersion?.id, 'src-b');
      expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-b');
      expect(await scoped.createScrubPreviewSource(item: metadata, mediaSource: result.mediaInfo!), isNull);
    });

    test('thumbnailUrl honours width/height hints', () {
      final url = client.thumbnailUrl('/Items/x/Images/Primary', width: 200, height: 300);
      final uri = Uri.parse(url);
      expect(uri.queryParameters['maxWidth'], '200');
      expect(uri.queryParameters['maxHeight'], '300');
    });

    test('thumbnailUrl does not prefix already absolute artwork URLs', () {
      final url = client.thumbnailUrl('https://jf.example.com/Items/x/Images/Primary?tag=abc', width: 200);
      final uri = Uri.parse(url);
      expect(uri.scheme, 'https');
      expect(uri.host, 'jf.example.com');
      expect(uri.path, '/Items/x/Images/Primary');
      expect(url, isNot(contains('https://jf.example.comhttps://jf.example.com')));
      expect(uri.queryParameters['tag'], 'abc');
      expect(uri.queryParameters['maxWidth'], '200');
      expect(uri.queryParameters['api_key'], 'tok-abc');
    });

    test('thumbnailUrl preserves existing auth and size parameters', () {
      final url = client.thumbnailUrl(
        'https://other.example/Items/x/Images/Primary?api_key=existing&maxWidth=100',
        width: 200,
        height: 300,
      );
      final uri = Uri.parse(url);
      expect(uri.host, 'other.example');
      expect(uri.queryParameters['api_key'], 'existing');
      expect(uri.queryParameters['maxWidth'], '100');
      expect(uri.queryParameters['maxHeight'], '300');
    });

    test('thumbnailUrl returns empty string for null/empty path', () {
      expect(client.thumbnailUrl(null), '');
      expect(client.thumbnailUrl(''), '');
    });

    test('every request carries the SDK-style MediaBrowser Authorization header', () {
      // Findroid + the official Jellyfin SDK send this exact header shape.
      // Some setups (Jellyfin 10.9+ behind reverse proxies) reject requests
      // that only carry the legacy X-Emby-Token header, returning a 404 from
      // the proxy/routing layer instead of a 401. We send both.
      final headers = client.defaultHeadersForTesting;

      final auth = headers['Authorization'];
      expect(auth, isNotNull);
      expect(auth, startsWith('MediaBrowser '));
      expect(auth, contains('Client="Plezy"'));
      expect(auth, contains('Device="Plezy"'));
      expect(auth, contains('DeviceId="dev-xyz"'));
      expect(auth, contains(RegExp(r'Version="[^"]+"')));
      expect(auth, contains('Token="tok-abc"'));

      // Belt-and-suspenders: legacy Emby token header is still present for
      // older servers that prefer it.
      expect(headers['X-Emby-Token'], 'tok-abc');
      expect(headers['Accept'], 'application/json');
    });

    test('fetchLibraryContent sends a bounded paged Items request', () async {
      Uri? captured;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          captured = req.url;
          return jsonResponse({
            'Items': [
              {
                'Id': 'movie-1',
                'Type': 'Movie',
                'Name': 'Movie',
                'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'],
              },
            ],
            'TotalRecordCount': 123,
          });
        }),
      );
      addTearDown(scoped.close);

      final page = await scoped.fetchLibraryContent(
        'lib-1',
        const LibraryQuery(kind: MediaKind.movie, offset: 50, limit: 25),
      );

      expect(page.items.single.id, 'movie-1');
      expect(page.items.single.backdropPaths!.map((url) => Uri.parse(url).path).toList(), [
        '/Items/movie-1/Images/Backdrop/0',
        '/Items/movie-1/Images/Backdrop/1',
        '/Items/movie-1/Images/Backdrop/2',
      ]);
      expect(page.totalCount, 123);
      expect(captured, isNotNull);
      expect(captured!.path, '/Items');
      expect(captured!.queryParameters['ParentId'], 'lib-1');
      expect(captured!.queryParameters['StartIndex'], '50');
      expect(captured!.queryParameters['Limit'], '25');
      expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
      expect(captured!.queryParameters['IncludeItemTypes'], 'Movie');
      expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources')));
      expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(captured!.queryParameters['ImageTypeLimit'], '3');
    });

    test('music browse and detail requests use leaf-appropriate fields', () async {
      final captured = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          captured.add(req.url);
          return jsonResponse({'Items': const [], 'TotalRecordCount': 0});
        }),
      );
      addTearDown(scoped.close);

      await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.album, offset: 0, limit: 20));
      await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.track, offset: 0, limit: 20));
      await scoped.fetchArtistAlbums(
        testMediaItem(id: 'artist-1', backend: MediaBackend.jellyfin, kind: MediaKind.artist),
      );
      await scoped.fetchAlbumTracks('album-1');

      final albumBrowse = captured[0].queryParameters;
      final trackBrowse = captured[1].queryParameters;
      final artistAlbums = captured[2].queryParameters;
      final albumTracks = captured[3].queryParameters;

      expect(albumBrowse['Fields'], 'PremiereDate,OriginalTitle,SortName');
      expect(albumBrowse['EnableUserData'], 'false');
      expect(trackBrowse['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
      expect(albumBrowse['IncludeItemTypes'], 'MusicAlbum');
      expect(trackBrowse['IncludeItemTypes'], 'Audio');
      expect(trackBrowse.containsKey('EnableUserData'), isFalse);
      expect(artistAlbums['Fields'], 'PremiereDate,OriginalTitle,SortName');
      expect(artistAlbums['EnableUserData'], 'false');
      expect(albumTracks['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
    });

    test('fetchLibraryFiltersWithValues adds unwatched boolean filter', () async {
      Uri? captured;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          captured = req.url;
          return jsonResponse({
            'Genres': ['Drama', 'Action'],
            'OfficialRatings': ['PG-13'],
            'Tags': ['Holiday'],
            'Years': [2024, 1999],
          });
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.fetchLibraryFiltersWithValues('lib-1');
      final musicResult = await scoped.fetchLibraryFiltersWithValues('lib-1', libraryKind: MediaKind.artist);

      expect(captured, isNotNull);
      expect(captured!.path, '/Items/Filters');
      expect(captured!.queryParameters['ParentId'], 'lib-1');
      expect(captured!.queryParameters['userId'], 'user-1');
      expect(result.filters.map((filter) => filter.filter), [
        'unwatched',
        'favorite',
        'genre',
        'year',
        'contentRating',
        'tag',
      ]);
      expect(result.filters.first.filterType, 'boolean');
      expect(result.filters.first.key, 'jellyfin:unwatched');
      expect(result.filters.first.title, 'Unwatched');
      expect(musicResult.filters.first.title, 'Unplayed');
      expect(result.filters[1].filterType, 'boolean');
      expect(result.filters[1].key, 'jellyfin:favorite');
      expect(result.filters[1].title, 'Favorites');
      expect(result.cachedValues.containsKey('unwatched'), isFalse);
      expect(result.cachedValues.containsKey('favorite'), isFalse);
      expect(result.cachedValues['genre']!.map((value) => value.key), ['Action', 'Drama']);
      expect(result.cachedValues['year']!.map((value) => value.key), ['2024', '1999']);
    });

    test('fetchLibraryContent uses sentinel total fallback when server omits total', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
          final limit = int.parse(req.url.queryParameters['Limit'] ?? '25');
          return jsonResponse({
            'Items': [
              for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'},
            ],
          });
        }),
      );
      addTearDown(scoped.close);

      final page = await scoped.fetchLibraryContent(
        'lib-1',
        const LibraryQuery(kind: MediaKind.movie, offset: 50, limit: 25),
      );

      expect(page.items.length, 25);
      expect(page.totalCount, 76);
    });

    test('fetchLibraryPagedContent uses library kind only when query kind is absent', () async {
      final captured = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          captured.add(req.url);
          return jsonResponse({'Items': const [], 'TotalRecordCount': 0});
        }),
      );
      addTearDown(scoped.close);

      await scoped.fetchLibraryPagedContent(
        'lib-1',
        query: const LibraryQuery(offset: 0, limit: 20),
        libraryKind: MediaKind.show,
      );
      await scoped.fetchLibraryPagedContent(
        'lib-1',
        query: const LibraryQuery(kind: MediaKind.episode, offset: 0, limit: 20),
        libraryKind: MediaKind.show,
      );

      expect(captured.first.queryParameters['IncludeItemTypes'], 'Series');
      expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode');
    });

    test('fetchLibraryFolders splits folder/media queries and orders folders first', () async {
      const allChildren = [
        {'Id': 'track-z', 'Type': 'Audio', 'Name': 'Z Track', 'IsFolder': false},
        {'Id': 'series-a', 'Type': 'Series', 'Name': 'A Show', 'IsFolder': true},
        {'Id': 'folder-z', 'Type': 'Folder', 'Name': 'Z Folder', 'IsFolder': true},
        {'Id': 'movie-m', 'Type': 'Movie', 'Name': 'Movie', 'IsFolder': false},
      ];
      final captured = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          captured.add(req.url);
          final foldersOnly = req.url.queryParameters['IncludeItemTypes'] == 'Folder,CollectionFolder';
          final items = allChildren.where((c) => (c['Type'] == 'Folder') == foldersOnly).toList();
          return jsonResponse({'Items': items, 'TotalRecordCount': items.length});
        }),
      );
      addTearDown(scoped.close);

      final items = await scoped.fetchLibraryFolders('lib-1');

      expect(captured, hasLength(2));
      final folderQuery = captured.firstWhere((u) => u.queryParameters.containsKey('IncludeItemTypes'));
      final mediaQuery = captured.firstWhere((u) => u.queryParameters.containsKey('ExcludeItemTypes'));
      for (final uri in [folderQuery, mediaQuery]) {
        expect(uri.path, '/Items');
        expect(uri.queryParameters['ParentId'], 'lib-1');
        expect(uri.queryParameters['Recursive'], 'false');
        expect(uri.queryParameters['EnableTotalRecordCount'], 'true');
        expect(uri.queryParameters['SortBy'], 'SortName');
        expect(uri.queryParameters['SortOrder'], 'Ascending');
        // Slim field sets: per-item count fields are expensive server-side
        // and Overview is never rendered in the tree.
        expect(uri.queryParameters['Fields'], isNot(contains('MediaSources')));
        expect(uri.queryParameters['Fields'], isNot(contains('RecursiveItemCount')));
        expect(uri.queryParameters['Fields'], isNot(contains('ChildCount')));
        expect(uri.queryParameters['Fields'], isNot(contains('Overview')));
      }
      // User data on folder dtos triggers a per-folder recursive unplayed
      // count on the server; folder rows render no watch state, so skip it.
      expect(folderQuery.queryParameters['IncludeItemTypes'], 'Folder,CollectionFolder');
      expect(folderQuery.queryParameters['EnableUserData'], 'false');
      expect(folderQuery.queryParameters['Fields'], isNot(contains('UserData')));
      // Media rows keep user data (watched state, series unwatched badge).
      expect(mediaQuery.queryParameters['ExcludeItemTypes'], 'Folder,CollectionFolder');
      expect(mediaQuery.queryParameters['Fields'], contains('UserData'));
      expect(items.map((item) => item.id), ['folder-z', 'series-a', 'movie-m', 'track-z']);
      // Folder rows classify as MediaKind.folder so the tree never reads raw.
      expect(items.first.kind, MediaKind.folder);
      expect(items.first.raw?['IsFolder'], isTrue);
      expect(items[1].kind, MediaKind.show);
    });

    test('fetchFolderChildren pages direct folder contents', () async {
      final mediaStarts = <String?>[];
      final pages = <List<MediaItem>>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          if (req.url.queryParameters.containsKey('IncludeItemTypes')) {
            // Folders query — this directory has none.
            return jsonResponse({'Items': const [], 'TotalRecordCount': 0});
          }
          mediaStarts.add(req.url.queryParameters['StartIndex']);
          final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
          const total = 501;
          final end = start == 0 ? 500 : total;
          return jsonResponse({
            'Items': [
              for (var i = start; i < end; i++)
                {'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false},
            ],
            'TotalRecordCount': total,
          });
        }),
      );
      addTearDown(scoped.close);

      final items = await scoped.fetchFolderChildren(
        testMediaItem(id: 'folder-1', backend: MediaBackend.jellyfin, kind: MediaKind.folder),
        onPage: pages.add,
      );

      expect(mediaStarts, ['0', '500']);
      expect(items, hasLength(501));
      // onPage surfaces accumulated items after intermediate pages only; the
      // final page is covered by the returned list.
      expect(pages, hasLength(1));
      expect(pages.single, hasLength(500));
      expect(pages.single.first.id, 'child-0');
    });

    test('fetchFolderChildren pages show/season children through onPage', () async {
      final starts = <String?>[];
      final pages = <List<MediaItem>>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          if (req.url.path.contains('/Shows/')) {
            // A season id is not a series — falls through to the ParentId query.
            return http.Response('Not Found', 404);
          }
          starts.add(req.url.queryParameters['StartIndex']);
          final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
          const total = 501;
          final end = start == 0 ? 500 : total;
          return jsonResponse({
            'Items': [
              for (var i = start; i < end; i++) {'Id': 'ep-$i', 'Type': 'Episode', 'Name': 'Episode $i'},
            ],
            'TotalRecordCount': total,
          });
        }),
      );
      addTearDown(scoped.close);

      final items = await scoped.fetchFolderChildren(
        testMediaItem(id: 'season-1', backend: MediaBackend.jellyfin, kind: MediaKind.season),
        onPage: pages.add,
      );

      expect(starts, ['0', '500']);
      expect(items, hasLength(501));
      // Large seasons render incrementally in the folder tree too: the
      // metadata-hierarchy path must not sever the onPage chain.
      expect(pages, hasLength(1));
      expect(pages.single, hasLength(500));
      expect(pages.single.first.id, 'ep-0');
    });

    test('fetchClientSideEpisodeQueue pages past the first 200 episodes', () async {
      final starts = <String?>[];
      final sortBy = <String?>[];
      final sortOrder = <String?>[];
      final pagedClient = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          starts.add(req.url.queryParameters['StartIndex']);
          sortBy.add(req.url.queryParameters['SortBy']);
          sortOrder.add(req.url.queryParameters['SortOrder']);
          final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
          const total = 250;
          final end = (start + 200).clamp(0, total);
          final items = [
            for (var i = start; i < end; i++)
              {
                'Id': 'ep-$i',
                'Type': 'Episode',
                'Name': 'Episode $i',
                'SeriesId': 'show-1',
                'UserData': {'PlayCount': 0},
              },
          ];
          return jsonResponse({'Items': items, 'TotalRecordCount': total});
        }),
      );
      addTearDown(pagedClient.close);

      final result = await pagedClient.fetchClientSideEpisodeQueue('show-1');

      expect(result, hasLength(250));
      expect(starts, ['0', '200']);
      expect(sortBy, everyElement('ParentIndexNumber,IndexNumber,SortName'));
      expect(sortOrder, everyElement('Ascending,Ascending,Ascending'));
    });

    test('fetchPersonMedia queries items by person id', () async {
      Uri? captured;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          captured = req.url;
          return jsonResponse({
            'Items': [
              {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'},
            ],
            'TotalRecordCount': 1,
          });
        }),
      );
      addTearDown(scoped.close);

      final result = await scoped.fetchPersonMedia('person-1');

      expect(result.single.id, 'movie-1');
      expect(captured, isNotNull);
      expect(captured!.path, '/Items');
      expect(captured!.queryParameters['userId'], 'user-1');
      expect(captured!.queryParameters['PersonIds'], 'person-1');
      expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series');
      expect(captured!.queryParameters['Recursive'], 'true');
      expect(captured!.queryParameters['SortBy'], 'PremiereDate,ProductionYear,SortName');
      expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Ascending');
      expect(captured!.queryParameters['CollapseBoxSetItems'], 'false');
      expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(captured!.queryParameters['ImageTypeLimit'], '3');
    });

    test('fetchItemWithOnDeck keeps resumable NextUp semantics for show detail lookup', () async {
      Uri? capturedNextUp;
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          if (req.url.path == '/Users/user-1/Items/show-1') {
            return jsonResponse({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'});
          }
          if (req.url.path == '/Shows/NextUp') {
            capturedNextUp = req.url;
            return jsonResponse({'Items': []});
          }
          return http.Response('not found', 404);
        }),
      );
      addTearDown(scoped.close);

      await scoped.fetchItemWithOnDeck('show-1');

      expect(capturedNextUp, isNotNull);
      expect(capturedNextUp!.queryParameters['seriesId'], 'show-1');
      expect(capturedNextUp!.queryParameters['Limit'], '1');
      expect(capturedNextUp!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(capturedNextUp!.queryParameters['ImageTypeLimit'], '3');
      expect(capturedNextUp!.queryParameters.containsKey('EnableResumable'), isFalse);
      expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
    });

    test('fetchPlaybackExtras loads native Jellyfin media segments', () async {
      final requests = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          requests.add(req.url);
          if (req.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': []});
          }
          if (req.url.path == '/MediaSegments/item-1') {
            return jsonResponse({
              'Items': [
                {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000},
                {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000},
              ],
            });
          }
          return http.Response('not found', 404);
        }),
      );
      addTearDown(scoped.close);

      final extras = await scoped.fetchPlaybackExtras('item-1');

      expect(requests.map((uri) => uri.path), contains('/MediaSegments/item-1'));
      expect(extras.markers.map((m) => m.type), ['intro', 'credits']);
      expect(extras.markers.first.startTimeOffset, 5000);
      expect(extras.markers.first.endTimeOffset, 45000);
    });

    test('fetchPlaybackExtras falls back to OP/ED chapters when media segments are unavailable', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          if (req.url.path == '/Users/user-1/Items/item-1') {
            return jsonResponse({
              'Id': 'item-1',
              'Type': 'Episode',
              'Name': 'Episode',
              'RunTimeTicks': 1200000000,
              'Chapters': [
                {'Name': 'OP', 'StartPositionTicks': 100000000},
                {'Name': 'Episode', 'StartPositionTicks': 450000000},
                {'Name': 'ED', 'StartPositionTicks': 900000000},
              ],
            });
          }
          if (req.url.path == '/MediaSegments/item-1') {
            return http.Response('not found', 404);
          }
          return http.Response('not found', 404);
        }),
      );
      addTearDown(scoped.close);

      final extras = await scoped.fetchPlaybackExtras('item-1');

      expect(extras.markers.map((m) => m.type), ['intro', 'credits']);
      expect(extras.markers.first.endTimeOffset, 45000);
      expect(extras.markers.last.endTimeOffset, 120000);
    });

    test('fetchContinueWatching merges resume with non-resumable Next Up', () async {
      final requests = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          requests.add(req.url);
          if (req.url.path == '/UserItems/Resume') {
            return jsonResponse({
              'Items': [
                {'Id': 'resume-show-1', 'Type': 'Episode', 'Name': 'Resume Show 1', 'SeriesId': 'show-1'},
                {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'},
              ],
            });
          }
          if (req.url.path == '/Shows/NextUp') {
            return jsonResponse({
              'Items': [
                {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'},
                {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'},
              ],
            });
          }
          return http.Response('not found', 404);
        }),
      );
      addTearDown(scoped.close);

      final items = await scoped.fetchContinueWatching(count: 3);

      expect(items.map((item) => item.id), ['resume-show-1', 'resume-movie-1', 'next-show-2']);
      final resume = requests.singleWhere((uri) => uri.path == '/UserItems/Resume');
      expect(resume.queryParameters['userId'], 'user-1');
      expect(resume.queryParameters['Limit'], '3');
      expect(resume.queryParameters['MediaTypes'], 'Video');
      expect(resume.queryParameters['Recursive'], 'true');
      expect(resume.queryParameters['EnableTotalRecordCount'], 'false');
      expect(resume.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(resume.queryParameters['ImageTypeLimit'], '3');
      final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp');
      expect(nextUp.queryParameters['userId'], 'user-1');
      expect(nextUp.queryParameters['Limit'], '3');
      expect(nextUp.queryParameters['EnableResumable'], 'false');
      expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
      expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(nextUp.queryParameters['ImageTypeLimit'], '3');
      expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
    });

    test('fetchContinueWatching orders a recently watched series Next Up above an older resume item', () async {
      final requests = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          requests.add(req.url);
          if (req.url.path == '/UserItems/Resume') {
            return jsonResponse({
              'Items': [
                {
                  'Id': 'resume-old',
                  'Type': 'Movie',
                  'Name': 'Old Movie',
                  'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'},
                },
              ],
            });
          }
          if (req.url.path == '/Shows/NextUp') {
            return jsonResponse({
              'Items': [
                {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'},
              ],
            });
          }
          if (req.url.path == '/Items') {
            return jsonResponse({
              'Items': [
                {
                  'Id': 'ep-played',
                  'Type': 'Episode',
                  'SeriesId': 'show-recent',
                  'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'},
                },
              ],
            });
          }
          return http.Response('not found', 404);
        }),
      );
      addTearDown(scoped.close);

      final items = await scoped.fetchContinueWatching(count: 10);

      // The Next Up episode inherits its series' recent last-played date, so it
      // sorts above the older resume item (issue #1266).
      expect(items.map((item) => item.id), ['next-recent', 'resume-old']);

      final lookup = requests.singleWhere((uri) => uri.path == '/Items');
      expect(lookup.queryParameters['userId'], 'user-1');
      expect(lookup.queryParameters['IncludeItemTypes'], 'Episode');
      expect(lookup.queryParameters['Recursive'], 'true');
      expect(lookup.queryParameters['SortBy'], 'DatePlayed');
      expect(lookup.queryParameters['SortOrder'], 'Descending');
      expect(lookup.queryParameters['Limit'], '200');
      // No Filters=IsPlayed: a series' newest engagement can sit on an episode
      // with a LastPlayedDate but Played==false (see _attachSeriesLastPlayed).
      expect(lookup.queryParameters.containsKey('Filters'), isFalse);
    });

    test('fetchContinueWatching does not let resume items starve Next Up under the limit', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          if (req.url.path == '/UserItems/Resume') {
            return jsonResponse({
              'Items': [
                {
                  'Id': 'resume-old-1',
                  'Type': 'Movie',
                  'Name': 'Old Movie 1',
                  'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'},
                },
                {
                  'Id': 'resume-old-2',
                  'Type': 'Movie',
                  'Name': 'Old Movie 2',
                  'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'},
                },
              ],
            });
          }
          if (req.url.path == '/Shows/NextUp') {
            return jsonResponse({
              'Items': [
                {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'},
              ],
            });
          }
          if (req.url.path == '/Items') {
            return jsonResponse({
              'Items': [
                {
                  'Id': 'ep-played',
                  'Type': 'Episode',
                  'SeriesId': 'show-recent',
                  'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'},
                },
              ],
            });
          }
          return http.Response('not found', 404);
        }),
      );
      addTearDown(scoped.close);

      // count equals the number of resume items: the old resume-first merge would
      // have filled the limit and dropped Next Up entirely.
      final items = await scoped.fetchContinueWatching(count: 2);

      expect(items.map((item) => item.id), ['next-recent', 'resume-old-2']);
    });

    test('fetchContinueWatching keeps resume items when Next Up fails', () async {
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          if (req.url.path == '/UserItems/Resume') {
            return jsonResponse({
              'Items': [
                {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'},
              ],
            });
          }
          if (req.url.path == '/Shows/NextUp') {
            return http.Response('server error', 500);
          }
          return http.Response('not found', 404);
        }),
      );
      addTearDown(scoped.close);

      final items = await scoped.fetchContinueWatching();

      expect(items.map((item) => item.id), ['resume-movie-1']);
    });

    test('fetchContinueWatching omits Limit when count is null', () async {
      final requests = <Uri>[];
      final scoped = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          requests.add(req.url);
          if (req.url.path == '/UserItems/Resume') {
            return jsonResponse({
              'Items': [
                {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'},
                {'Id': 'resume-movie-2', 'Type': 'Movie', 'Name': 'Resume Movie 2'},
              ],
            });
          }
          if (req.url.path == '/Shows/NextUp') {
            return jsonResponse({
              'Items': [
                {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'},
                {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'},
              ],
            });
          }
          return http.Response('not found', 404);
        }),
      );
      addTearDown(scoped.close);

      final items = await scoped.fetchContinueWatching(count: null);

      expect(items.map((item) => item.id), ['resume-movie-1', 'resume-movie-2', 'next-show-1', 'next-show-2']);
      final resume = requests.singleWhere((uri) => uri.path == '/UserItems/Resume');
      expect(resume.queryParameters.containsKey('Limit'), isFalse);
      final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp');
      expect(nextUp.queryParameters.containsKey('Limit'), isFalse);
    });
  });

  group('JellyfinClient.fetchGlobalHubs URL builders', () {
    late List<Uri> captured;

    JellyfinClient buildClient() {
      captured = [];
      final mock = MockClient((req) async {
        captured.add(req.url);
        return jsonResponse({'Items': []});
      });
      return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
    }

    Uri capturedNextUpRequest() => captured.singleWhere((uri) => uri.path == '/Shows/NextUp');

    test('global preview defaults to shared limit and marks filled previews as more', () async {
      captured = [];
      final mock = MockClient((req) async {
        captured.add(req.url);
        return jsonResponse({
          'Items': [
            for (var i = 0; i < defaultHubPreviewLimit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'},
          ],
        });
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final hubs = await client.fetchGlobalHubs(includePlaybackHubs: false);

      expect(captured.single.queryParameters['Limit'], defaultHubPreviewLimit.toString());
      expect(hubs.single.items, hasLength(defaultHubPreviewLimit));
      expect(hubs.single.more, isTrue);
    });

    test('global Next Up excludes resumable episodes without date cutoff', () async {
      final client = buildClient();
      addTearDown(client.close);

      await client.fetchGlobalHubs(limit: 12);

      final resume = captured.singleWhere((uri) => uri.path == '/UserItems/Resume');
      expect(resume.queryParameters['EnableTotalRecordCount'], 'false');

      final nextUp = capturedNextUpRequest();
      expect(nextUp.queryParameters['userId'], 'user-1');
      expect(nextUp.queryParameters['Limit'], '12');
      expect(nextUp.queryParameters['EnableResumable'], 'false');
      expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
      expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(nextUp.queryParameters['ImageTypeLimit'], '3');
      expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
    });

    test('can skip global playback hubs', () async {
      final client = buildClient();
      addTearDown(client.close);

      await client.fetchGlobalHubs(limit: 12, includePlaybackHubs: false);

      expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']);
      expect(captured.single.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode');
      expect(captured.single.queryParameters['Limit'], '12');
    });
  });

  group('JellyfinClient.fetchLibraryHubs URL builders', () {
    late List<Uri> captured;

    JellyfinClient buildClient() {
      captured = [];
      final mock = MockClient((req) async {
        captured.add(req.url);
        return jsonResponse({'Items': []});
      });
      return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
    }

    test('show library Next Up excludes resumable episodes without date cutoff', () async {
      final client = buildClient();
      addTearDown(client.close);

      await client.fetchLibraryHubs('lib-99', libraryName: 'Shows', limit: 12, libraryKind: MediaKind.show);

      final nextUp = captured.singleWhere((uri) => uri.path == '/Shows/NextUp');
      expect(nextUp.queryParameters['ParentId'], 'lib-99');
      expect(nextUp.queryParameters['userId'], 'user-1');
      expect(nextUp.queryParameters['Limit'], '12');
      expect(nextUp.queryParameters['EnableResumable'], 'false');
      expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
      expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(nextUp.queryParameters['ImageTypeLimit'], '3');
      expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
    });

    test('movie library skips Next Up and disables resume total count', () async {
      final client = buildClient();
      addTearDown(client.close);

      await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12, libraryKind: MediaKind.movie);

      expect(captured.where((uri) => uri.path == '/Shows/NextUp'), isEmpty);
      final resume = captured.singleWhere((uri) => uri.path == '/UserItems/Resume');
      expect(resume.queryParameters['ParentId'], 'lib-99');
      expect(resume.queryParameters['Limit'], '12');
      expect(resume.queryParameters['EnableTotalRecordCount'], 'false');
    });

    test('can skip library playback hubs', () async {
      final client = buildClient();
      addTearDown(client.close);

      await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12, includePlaybackHubs: false);

      expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']);
      expect(captured.single.queryParameters['ParentId'], 'lib-99');
      expect(captured.single.queryParameters['Limit'], '12');
    });
  });

  group('JellyfinClient.fetchMoreHubItems URL builders', () {
    Uri? captured;

    JellyfinClient buildClient() {
      captured = null;
      final mock = MockClient((req) async {
        captured = req.url;
        return http.Response('[]', 200, headers: {'content-type': 'application/json'});
      });
      return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
    }

    test('global "home.recent" uses the pageable Items catalogue with the provided limit', () async {
      final client = buildClient();
      await client.fetchMoreHubItems('home.recent', limit: 80);

      expect(captured, isNotNull);
      expect(captured!.path, '/Items');
      expect(captured!.queryParameters['userId'], 'user-1');
      expect(captured!.queryParameters['StartIndex'], '0');
      expect(captured!.queryParameters['Limit'], '80');
      expect(captured!.queryParameters['Recursive'], 'true');
      expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
      expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo');
      expect(captured!.queryParameters['SortBy'], 'DateCreated,SortName,ProductionYear');
      expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Descending');
      expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(captured!.queryParameters['ImageTypeLimit'], '3');
      expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
      client.close();
    });

    test('global "home.continue" hits /UserItems/Resume with userId', () async {
      final client = buildClient();
      await client.fetchMoreHubItems('home.continue');

      expect(captured, isNotNull);
      expect(captured!.path, '/UserItems/Resume');
      expect(captured!.queryParameters['userId'], 'user-1');
      expect(captured!.queryParameters['Limit'], '50');
      expect(captured!.queryParameters['StartIndex'], '0');
      expect(captured!.queryParameters['MediaTypes'], 'Video');
      expect(captured!.queryParameters['Recursive'], 'true');
      expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
      expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(captured!.queryParameters['ImageTypeLimit'], '3');
      expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
      client.close();
    });

    test('global "home.nextup" hits /Shows/NextUp with userId', () async {
      final client = buildClient();
      await client.fetchMoreHubItems('home.nextup', limit: 25);

      expect(captured, isNotNull);
      expect(captured!.path, '/Shows/NextUp');
      expect(captured!.queryParameters['userId'], 'user-1');
      expect(captured!.queryParameters['Limit'], '25');
      expect(captured!.queryParameters['StartIndex'], '0');
      expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
      expect(captured!.queryParameters['EnableResumable'], 'false');
      expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
      expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(captured!.queryParameters['ImageTypeLimit'], '3');
      expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
      client.close();
    });

    test('library-scoped "library.{id}.recent" pages Items within its parent', () async {
      final client = buildClient();
      await client.fetchMoreHubItems('library.lib-99.recent', limit: 30);

      expect(captured, isNotNull);
      expect(captured!.path, '/Items');
      expect(captured!.queryParameters['userId'], 'user-1');
      expect(captured!.queryParameters['ParentId'], 'lib-99');
      expect(captured!.queryParameters['StartIndex'], '0');
      expect(captured!.queryParameters['Limit'], '30');
      expect(captured!.queryParameters['Recursive'], 'true');
      expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
      expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo');
      expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(captured!.queryParameters['ImageTypeLimit'], '3');
      client.close();
    });

    test('library-scoped "library.{id}.continue" forwards ParentId to Resume', () async {
      final client = buildClient();
      await client.fetchMoreHubItems('library.lib-99.continue');

      expect(captured, isNotNull);
      expect(captured!.path, '/UserItems/Resume');
      expect(captured!.queryParameters['ParentId'], 'lib-99');
      expect(captured!.queryParameters['userId'], 'user-1');
      expect(captured!.queryParameters['StartIndex'], '0');
      expect(captured!.queryParameters['Recursive'], 'true');
      expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
      expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(captured!.queryParameters['ImageTypeLimit'], '3');
      client.close();
    });

    test('library-scoped "library.{id}.nextup" forwards ParentId to NextUp', () async {
      final client = buildClient();
      await client.fetchMoreHubItems('library.lib-99.nextup');

      expect(captured, isNotNull);
      expect(captured!.path, '/Shows/NextUp');
      expect(captured!.queryParameters['ParentId'], 'lib-99');
      expect(captured!.queryParameters['userId'], 'user-1');
      expect(captured!.queryParameters['StartIndex'], '0');
      expect(captured!.queryParameters['EnableResumable'], 'false');
      expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
      expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(captured!.queryParameters['ImageTypeLimit'], '3');
      expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
      client.close();
    });

    test('library-scoped "library.{id}.latestalbums" hits Latest with slim music album fields', () async {
      final client = buildClient();
      await client.fetchMoreHubItems('library.lib-99.latestalbums', limit: 30);

      expect(captured, isNotNull);
      expect(captured!.path, '/Users/user-1/Items/Latest');
      expect(captured!.queryParameters['ParentId'], 'lib-99');
      expect(captured!.queryParameters['Limit'], '30');
      // Album FOLDER dtos: count/user-data fields would each cost the server
      // a recursive per-album COUNT query (#1552).
      expect(captured!.queryParameters['Fields'], 'PremiereDate,OriginalTitle,SortName');
      expect(captured!.queryParameters['EnableUserData'], 'false');
      client.close();
    });

    test('library-scoped "library.{id}.recentlyplayed" queries played audio with slim track fields', () async {
      final client = buildClient();
      await client.fetchMoreHubItems('library.lib-99.recentlyplayed');

      expect(captured, isNotNull);
      expect(captured!.path, '/Items');
      expect(captured!.queryParameters['ParentId'], 'lib-99');
      expect(captured!.queryParameters['userId'], 'user-1');
      expect(captured!.queryParameters['IncludeItemTypes'], 'Audio');
      expect(captured!.queryParameters['Recursive'], 'true');
      expect(captured!.queryParameters['Filters'], 'IsPlayed');
      expect(captured!.queryParameters['SortBy'], 'DatePlayed');
      expect(captured!.queryParameters['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
      client.close();
    });

    test('unknown identifier returns empty without hitting the network', () async {
      final client = buildClient();
      final items = await client.fetchMoreHubItems('totally.unknown');

      expect(items, isEmpty);
      expect(captured, isNull);
      client.close();
    });

    test('paged Resume hub sends requested offset and parses total count', () async {
      Uri? requestUri;
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          requestUri = req.url;
          return jsonResponse({
            'TotalRecordCount': 30,
            'Items': [
              {'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'},
            ],
          });
        }),
      );
      addTearDown(client.close);

      final page = await client.fetchMoreHubItemsPage('home.continue', start: 20, size: 10);

      expect(page.items.single.id, 'resume-20');
      expect(page.totalCount, 30);
      expect(page.offset, 20);
      expect(requestUri, isNotNull);
      expect(requestUri!.path, '/UserItems/Resume');
      expect(requestUri!.queryParameters['StartIndex'], '20');
      expect(requestUri!.queryParameters['Limit'], '10');
      expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true');
    });

    test('paged Recently Added sends the requested offset and parses total count', () async {
      Uri? requestUri;
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          requestUri = req.url;
          return jsonResponse({
            'TotalRecordCount': 321,
            'Items': [
              {'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'},
            ],
          });
        }),
      );
      addTearDown(client.close);

      final page = await client.fetchMoreHubItemsPage('home.recent', start: 20, size: 10);

      expect(page.items.single.id, 'recent-20');
      expect(page.totalCount, 321);
      expect(page.offset, 20);
      expect(requestUri, isNotNull);
      expect(requestUri!.path, '/Items');
      expect(requestUri!.queryParameters['StartIndex'], '20');
      expect(requestUri!.queryParameters['Limit'], '10');
      expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true');
      expect(requestUri!.queryParameters.containsKey('ParentId'), isFalse);
    });

    test('paged hub first-page errors throw while list helper keeps empty fallback', () async {
      var requestCount = 0;
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          requestCount++;
          return http.Response('server error', 500);
        }),
      );
      addTearDown(client.close);

      await expectLater(client.fetchMoreHubItemsPage('home.continue', start: 0, size: 10), throwsA(isA<Exception>()));

      final items = await client.fetchMoreHubItems('home.continue');
      expect(items, isEmpty);
      expect(requestCount, 2);
    });

    test('paged hub later-page errors throw instead of truncating', () async {
      var requestCount = 0;
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((req) async {
          requestCount++;
          return http.Response('server error', 500);
        }),
      );
      addTearDown(client.close);

      await expectLater(client.fetchMoreHubItemsPage('home.continue', start: 20, size: 10), throwsA(isA<Exception>()));

      expect(requestCount, 1);
    });
  });

  group('JellyfinClient.fetchCollections', () {
    test('uses boxsets view instead of selected media library parent', () async {
      final requests = <Uri>[];
      final mock = MockClient((req) async {
        requests.add(req.url);
        if (req.url.path == '/Users/user-1/Views') {
          return jsonResponse({
            'Items': [
              {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'},
              {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'},
            ],
          });
        }
        if (req.url.path == '/Items') {
          return jsonResponse({
            'TotalRecordCount': 1,
            'Items': [
              {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'},
            ],
          });
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final collections = await client.fetchCollections('lib-movies');

      expect(collections.map((c) => c.id).toList(), ['collection-1']);
      expect(collections.single.kind, MediaKind.collection);
      expect(requests.map((u) => u.path).toList(), ['/Users/user-1/Views', '/Items']);
      final itemsRequest = requests.singleWhere((u) => u.path == '/Items');
      expect(itemsRequest.queryParameters['ParentId'], 'lib-boxsets');
      expect(itemsRequest.queryParameters['ParentId'], isNot('lib-movies'));
      expect(itemsRequest.queryParameters['IncludeItemTypes'], 'BoxSet');
      expect(itemsRequest.queryParameters['Recursive'], 'true');
      expect(itemsRequest.queryParameters['StartIndex'], '0');
      expect(itemsRequest.queryParameters['Limit'], '36');
      expect(itemsRequest.queryParameters['SortBy'], 'SortName');
      expect(itemsRequest.queryParameters['SortOrder'], 'Ascending');
      expect(
        itemsRequest.queryParameters['Fields'],
        'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview',
      );
      expect(itemsRequest.queryParameters.containsKey('EnableTotalRecordCount'), isFalse);
      expect(itemsRequest.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
      expect(itemsRequest.queryParameters['ImageTypeLimit'], '3');
    });

    test('fetchCollectionsPage uses requested collection page bounds', () async {
      Uri? itemsRequest;
      final mock = MockClient((req) async {
        if (req.url.path == '/Users/user-1/Views') {
          return jsonResponse({
            'Items': [
              {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'},
            ],
          });
        }
        if (req.url.path == '/Items') {
          itemsRequest = req.url;
          return jsonResponse({
            'TotalRecordCount': 30,
            'Items': [
              {'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'},
            ],
          });
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final page = await client.fetchCollectionsPage('lib-movies', start: 20, size: 10);

      expect(page.totalCount, 30);
      expect(page.offset, 20);
      expect(page.items.single.id, 'collection-20');
      expect(itemsRequest, isNotNull);
      expect(itemsRequest!.queryParameters['ParentId'], 'lib-boxsets');
      expect(itemsRequest!.queryParameters['StartIndex'], '20');
      expect(itemsRequest!.queryParameters['Limit'], '10');
      expect(itemsRequest!.queryParameters.containsKey('EnableTotalRecordCount'), isFalse);
    });

    test('fetchCollectionsPage uses sentinel total when total count is missing', () async {
      final mock = MockClient((req) async {
        if (req.url.path == '/Users/user-1/Views') {
          return jsonResponse({
            'Items': [
              {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'},
            ],
          });
        }
        if (req.url.path == '/Items') {
          return jsonResponse({
            'Items': [
              {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'},
              {'Id': 'collection-2', 'Name': 'Collection 2', 'Type': 'BoxSet'},
            ],
          });
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final page = await client.fetchCollectionsPage('lib-movies', size: 2);

      expect(page.items.map((c) => c.id).toList(), ['collection-1', 'collection-2']);
      expect(page.totalCount, 3);
    });

    test('walks boxsets view in pages', () async {
      final itemRequests = <Uri>[];
      final mock = MockClient((req) async {
        if (req.url.path == '/Users/user-1/Views') {
          return jsonResponse({
            'Items': [
              {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'},
            ],
          });
        }
        if (req.url.path == '/Items') {
          itemRequests.add(req.url);
          final start = req.url.queryParameters['StartIndex'];
          return jsonResponse({
            'TotalRecordCount': 2,
            'Items': [
              {'Id': start == '0' ? 'collection-1' : 'collection-2', 'Name': 'Collection', 'Type': 'BoxSet'},
            ],
          });
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final collections = await client.fetchCollections('lib-movies');

      expect(collections.map((c) => c.id).toList(), ['collection-1', 'collection-2']);
      expect(itemRequests.map((u) => u.queryParameters['StartIndex']).toList(), ['0', '1']);
      expect(itemRequests.every((u) => u.queryParameters['Limit'] == '36'), isTrue);
    });

    test('returns empty when boxsets view is missing', () async {
      var itemsRequested = false;
      final mock = MockClient((req) async {
        if (req.url.path == '/Users/user-1/Views') {
          return jsonResponse({
            'Items': [
              {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'},
            ],
          });
        }
        if (req.url.path == '/Items') {
          itemsRequested = true;
          return jsonResponse({'Items': []});
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final collections = await client.fetchCollections('lib-movies');

      expect(collections, isEmpty);
      expect(itemsRequested, isFalse);
    });

    test('fetchCollectionPage uses Jellyfin item paging', () async {
      Uri? itemsRequest;
      final mock = MockClient((req) async {
        if (req.url.path == '/Items') {
          itemsRequest = req.url;
          return jsonResponse({
            'TotalRecordCount': 25,
            'Items': [
              {'Id': 'movie-1', 'Name': 'Movie 1', 'Type': 'Movie'},
            ],
          });
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final page = await client.fetchCollectionPage('collection-1', start: 20, size: 5);

      expect(page.totalCount, 25);
      expect(page.offset, 20);
      expect(page.items.single.id, 'movie-1');
      expect(itemsRequest, isNotNull);
      expect(itemsRequest!.queryParameters['ParentId'], 'collection-1');
      expect(itemsRequest!.queryParameters['StartIndex'], '20');
      expect(itemsRequest!.queryParameters['Limit'], '5');
      expect(itemsRequest!.queryParameters.containsKey('Recursive'), isFalse);
    });
  });

  group('JellyfinClient.fetchLibraries view filtering', () {
    test('drops boxsets and playlists views — they surface as per-library tabs instead', () async {
      // Jellyfin's `/Users/{userId}/Views` returns the user's collection
      // (BoxSet) and playlist roots as top-level "library" views. Surfacing
      // them in the library list duplicates content that's already exposed as
      // tabs on each real library, matching the Plex shape.
      final mock = MockClient((req) async {
        if (req.url.path == '/Users/user-1/Views') {
          return http.Response(
            '''
            {
              "Items": [
                {"Id": "lib-movies", "Name": "Movies", "CollectionType": "movies", "Type": "CollectionFolder"},
                {"Id": "lib-shows", "Name": "TV Shows", "CollectionType": "tvshows", "Type": "CollectionFolder"},
                {"Id": "lib-music", "Name": "Music", "CollectionType": "music", "Type": "CollectionFolder"},
                {"Id": "lib-coll", "Name": "Collections", "CollectionType": "boxsets", "Type": "CollectionFolder"},
                {"Id": "lib-pl", "Name": "Playlists", "CollectionType": "playlists", "Type": "ManualPlaylistsFolder"}
              ]
            }
            ''',
            200,
            headers: {'content-type': 'application/json'},
          );
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);

      final libraries = await client.fetchLibraries();

      expect(libraries.map((l) => l.id), ['lib-movies', 'lib-shows', 'lib-music']);
      client.close();
    });
  });

  group('JellyfinClient paged media lists', () {
    test('fetchPersonMediaPage uses requested page bounds', () async {
      final routed = _routedClient({
        '/Items': {
          'Items': [
            {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'},
          ],
          'TotalRecordCount': 40,
        },
      });

      final page = await routed.client.fetchPersonMediaPage('person-1', start: 20, size: 10);

      expect(page.items.single.id, 'movie-1');
      expect(page.totalCount, 40);
      expect(page.offset, 20);
      final query = routed.requests['/Items']!.queryParameters;
      expect(query['PersonIds'], 'person-1');
      expect(query['StartIndex'], '20');
      expect(query['Limit'], '10');
    });

    test('fetchPlayableDescendantsPage uses requested page bounds', () async {
      final routed = _routedClient({
        '/Items': {
          'Items': [
            {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'},
          ],
          'TotalRecordCount': 40,
        },
      });

      final page = await routed.client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10);

      expect(page.items.single.id, 'episode-1');
      expect(page.totalCount, 40);
      expect(page.offset, 20);
      final query = routed.requests['/Items']!.queryParameters;
      expect(query['ParentId'], 'show-1');
      expect(query['Recursive'], 'true');
      // Audio rides along so albums/artists/audio playlists expand to tracks.
      expect(query['IncludeItemTypes'], 'Movie,Episode,Audio');
      expect(query['StartIndex'], '20');
      expect(query['Limit'], '10');
    });

    test('fetchSeasonEpisodesPage uses Jellyfin episode endpoint scoped to season', () async {
      final routed = _routedClient({
        '/Shows/show-1/Episodes': {
          'Items': [
            {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'},
          ],
          'TotalRecordCount': 40,
        },
      });

      final page = await routed.client.fetchSeasonEpisodesPage('show-1', 'season-1', start: 20, size: 10);

      expect(page.items.single.id, 'episode-1');
      expect(page.totalCount, 40);
      expect(page.offset, 20);
      final query = routed.requests['/Shows/show-1/Episodes']!.queryParameters;
      expect(query['SeasonId'], 'season-1');
      expect(query['StartIndex'], '20');
      expect(query['Limit'], '10');
      expect(query['EnableTotalRecordCount'], 'true');
      expect(query['IsMissing'], 'false');
      expect(query['IsVirtualUnaired'], 'false');
      expect(query['Fields']!.split(','), contains('MediaSources'));
      expect(query.containsKey('SortBy'), isFalse);
      expect(query.containsKey('SortOrder'), isFalse);
    });

    test('fetchChildrenPage orders direct episode children by season and episode index', () async {
      final routed = _routedClient({
        '/Shows/season-1/Seasons': {'Items': <Object>[]},
        '/Items': {
          'Items': [
            {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'},
          ],
          'TotalRecordCount': 40,
        },
      });

      final page = await routed.client.fetchChildrenPage('season-1', start: 20, size: 10);

      expect(page.items.single.id, 'episode-1');
      expect(page.totalCount, 40);
      expect(page.offset, 20);
      final query = routed.requests['/Items']!.queryParameters;
      expect(query['ParentId'], 'season-1');
      expect(query['StartIndex'], '20');
      expect(query['Limit'], '10');
      expect(query['SortBy'], 'ParentIndexNumber,IndexNumber,SortName');
      expect(query['SortOrder'], 'Ascending,Ascending,Ascending');
    });

    test('fetchPlayableFolderDescendants includes generic video but excludes audio', () async {
      final routed = _routedClient({
        '/Items': {
          'Items': [
            {'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'},
          ],
          'TotalRecordCount': 1,
        },
      });

      final items = await routed.client.fetchPlayableFolderDescendants('folder-1');

      expect(items.single.kind, MediaKind.clip);
      final query = routed.requests['/Items']!.queryParameters;
      expect(query['ParentId'], 'folder-1');
      expect(query['Recursive'], 'true');
      expect(query['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo');
      expect(query['IncludeItemTypes'], isNot(contains('Audio')));
    });

    test('fetchPlayableDescendants cancellation stops before a second page', () async {
      final abort = AbortController();
      final starts = <String?>[];
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          starts.add(request.url.queryParameters['StartIndex']);
          abort.abort();
          return jsonResponse({
            'Items': List.generate(500, (i) => {'Id': 'movie-$i', 'Name': 'Movie $i', 'Type': 'Movie'}),
            'TotalRecordCount': 501,
          });
        }),
      );
      addTearDown(client.close);

      await expectLater(
        client.fetchPlayableDescendants('collection-1', abort: abort),
        throwsA(isA<MediaServerHttpException>().having((e) => e.isCancellation, 'isCancellation', isTrue)),
      );
      expect(starts, ['0']);
    });

    test('fetchPlayableFolderDescendants cancellation stops before a second page', () async {
      final abort = AbortController();
      final starts = <String?>[];
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          starts.add(request.url.queryParameters['StartIndex']);
          abort.abort();
          return jsonResponse({
            'Items': List.generate(500, (i) => {'Id': 'video-$i', 'Name': 'Video $i', 'Type': 'Video'}),
            'TotalRecordCount': 501,
          });
        }),
      );
      addTearDown(client.close);

      await expectLater(
        client.fetchPlayableFolderDescendants('folder-1', abort: abort),
        throwsA(isA<MediaServerHttpException>().having((e) => e.isCancellation, 'isCancellation', isTrue)),
      );
      expect(starts, ['0']);
    });

    test('fetchClientSideEpisodeQueue cancellation stops before a second page and sort', () async {
      final abort = AbortController();
      final starts = <String?>[];
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          starts.add(request.url.queryParameters['StartIndex']);
          abort.abort();
          return jsonResponse({
            'Items': List.generate(
              200,
              (i) => {'Id': 'episode-$i', 'Name': 'Episode $i', 'Type': 'Episode', 'IndexNumber': i + 1},
            ),
            'TotalRecordCount': 201,
          });
        }),
      );
      addTearDown(client.close);

      await expectLater(
        client.fetchClientSideEpisodeQueue('show-1', abort: abort),
        throwsA(isA<MediaServerHttpException>().having((e) => e.isCancellation, 'isCancellation', isTrue)),
      );
      expect(starts, ['0']);
    });

    test('fetchChildren walks generic children pages', () async {
      final itemRequests = <Uri>[];
      final mock = MockClient((req) async {
        if (req.url.path == '/Shows/season-1/Seasons') {
          return jsonResponse({'Items': []});
        }
        if (req.url.path == '/Items') {
          itemRequests.add(req.url);
          final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
          final count = start == 0 ? 500 : 1;
          return jsonResponse({
            'Items': List.generate(count, (i) => {'Id': 'episode-${start + i}', 'Name': 'Episode', 'Type': 'Episode'}),
            'TotalRecordCount': 501,
          });
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final items = await client.fetchChildren('season-1');

      expect(items.length, 501);
      expect(itemRequests.map((u) => u.queryParameters['StartIndex']), ['0', '500']);
      expect(itemRequests.every((u) => u.queryParameters['Limit'] == '500'), isTrue);
    });
  });

  group('JellyfinClient.fetchPlaylists filtering', () {
    JellyfinClient buildClient() {
      final mock = MockClient((req) async {
        if (req.url.path == '/Items') {
          final requestedMediaType = req.url.queryParameters['MediaTypes']?.toLowerCase();
          final items =
              [
                {'Id': 'video-1', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'},
                {'Id': 'audio-1', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'},
                {'Id': 'photo-1', 'Name': 'Photo Playlist', 'Type': 'Playlist', 'MediaType': 'Photo'},
              ].where((item) {
                if (requestedMediaType == null) return true;
                return (item['MediaType'] as String).toLowerCase() == requestedMediaType;
              }).toList();
          return jsonResponse({'Items': items, 'TotalRecordCount': items.length});
        }
        return http.Response('not found', 404);
      });
      return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
    }

    test('returns only requested playlist media type', () async {
      final client = buildClient();

      final playlists = await client.fetchPlaylists(playlistType: 'video');

      expect(playlists.map((p) => p.id), ['video-1']);
      client.close();
    });

    test('fetchPlaylistsPage sends direct filtered offset and returns filtered total', () async {
      final requests = <Uri>[];
      final allItems = List.generate(
        100,
        (i) => {
          'Id': '${i.isEven ? 'video' : 'audio'}-$i',
          'Name': 'Playlist $i',
          'Type': 'Playlist',
          'MediaType': i.isEven ? 'Video' : 'Audio',
        },
      );
      final mock = MockClient((req) async {
        if (req.url.path != '/Items') return http.Response('not found', 404);
        requests.add(req.url);
        final mediaType = req.url.queryParameters['MediaTypes'];
        final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList();
        final start = int.parse(req.url.queryParameters['StartIndex']!);
        final limit = int.parse(req.url.queryParameters['Limit']!);
        return jsonResponse({
          'Items': sliceFakePage(filtered, start: start, size: limit),
          'TotalRecordCount': filtered.length,
        });
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final page = await client.fetchPlaylistsPage(playlistType: 'video', start: 20, size: 10);

      expect(page.items.map((item) => item.id), List.generate(10, (i) => 'video-${40 + i * 2}'));
      expect(page.totalCount, 50);
      expect(page.offset, 20);
      expect(requests, hasLength(1));
      expect(requests.single.queryParameters['StartIndex'], '20');
      expect(requests.single.queryParameters['Limit'], '10');
      expect(requests.single.queryParameters['MediaTypes'], 'Video');
      expect(requests.single.queryParameters['IncludeItemTypes'], 'Playlist');
    });

    test('large sparse filtered pages perform one server-filtered request each', () async {
      final requests = <Uri>[];
      final allItems = List.generate(
        600,
        (i) => {
          'Id': '${i.isEven ? 'video' : 'audio'}-$i',
          'Name': 'Playlist $i',
          'Type': 'Playlist',
          'MediaType': i.isEven ? 'Video' : 'Audio',
        },
      );
      final mock = MockClient((req) async {
        if (req.url.path != '/Items') return http.Response('not found', 404);
        requests.add(req.url);
        final mediaType = req.url.queryParameters['MediaTypes'];
        final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList();
        final start = int.parse(req.url.queryParameters['StartIndex']!);
        final limit = int.parse(req.url.queryParameters['Limit']!);
        return jsonResponse({
          'Items': sliceFakePage(filtered, start: start, size: limit),
          'TotalRecordCount': filtered.length,
        });
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final pages = <LibraryPage<MediaPlaylist>>[];
      for (final start in [0, 100, 200]) {
        pages.add(await client.fetchPlaylistsPage(playlistType: 'video', start: start, size: 100));
      }

      expect(pages.expand((page) => page.items).map((item) => item.id), List.generate(300, (i) => 'video-${i * 2}'));
      expect(pages.map((page) => page.totalCount), everyElement(300));
      expect(requests, hasLength(3));
      expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '100', '200']);
      expect(requests.every((uri) => uri.queryParameters['Limit'] == '100'), isTrue);
      expect(requests.every((uri) => uri.queryParameters['MediaTypes'] == 'Video'), isTrue);
    });

    test('fetchPlaylists complete helper walks direct filtered pages linearly', () async {
      final requests = <Uri>[];
      final videos = List.generate(
        300,
        (i) => {'Id': 'video-$i', 'Name': 'Playlist $i', 'Type': 'Playlist', 'MediaType': 'Video'},
      );
      final mock = MockClient((req) async {
        if (req.url.path != '/Items') return http.Response('not found', 404);
        requests.add(req.url);
        final start = int.parse(req.url.queryParameters['StartIndex']!);
        final limit = int.parse(req.url.queryParameters['Limit']!);
        return jsonResponse({
          'Items': sliceFakePage(videos, start: start, size: limit),
          'TotalRecordCount': videos.length,
        });
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final playlists = await client.fetchPlaylists(playlistType: 'video');

      expect(playlists.map((item) => item.id), List.generate(300, (i) => 'video-$i'));
      expect(requests, hasLength(2));
      expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '200']);
      expect(requests.every((uri) => uri.queryParameters['Limit'] == '200'), isTrue);
      expect(requests.every((uri) => uri.queryParameters['MediaTypes'] == 'Video'), isTrue);
    });

    test('unsupported playlist type returns empty without a request', () async {
      var requestCount = 0;
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((_) async {
          requestCount++;
          return http.Response('unexpected', 500);
        }),
      );
      addTearDown(client.close);

      final page = await client.fetchPlaylistsPage(playlistType: 'unsupported', start: 20, size: 10);

      expect(page.items, isEmpty);
      expect(page.totalCount, 0);
      expect(page.offset, 20);
      expect(requestCount, 0);
    });

    test('fetchPlaylistPage uses requested item page bounds', () async {
      Uri? requestUri;
      final mock = MockClient((req) async {
        if (req.url.path == '/Playlists/pl-1/Items') {
          requestUri = req.url;
          return jsonResponse({
            'Items': [
              {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'},
            ],
            'TotalRecordCount': 40,
          });
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final page = await client.fetchPlaylistPage('pl-1', start: 20, size: 10);

      expect(page.items.single.id, 'movie-1');
      expect(page.totalCount, 40);
      expect(page.offset, 20);
      expect(requestUri, isNotNull);
      expect(requestUri!.queryParameters['StartIndex'], '20');
      expect(requestUri!.queryParameters['Limit'], '10');
    });

    test('fetchPlaylistPage uses minimal fallback total when total count is missing', () async {
      final mock = MockClient((req) async {
        if (req.url.path == '/Playlists/pl-1/Items') {
          return jsonResponse({
            'Items': List.generate(10, (i) => {'Id': 'movie-$i', 'Name': 'Movie', 'Type': 'Movie'}),
          });
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
      addTearDown(client.close);

      final page = await client.fetchPlaylistPage('pl-1', start: 20, size: 10);

      expect(page.items.length, 10);
      expect(page.totalCount, 31);
      expect(page.offset, 20);
    });

    test('absolutizes playlist thumbnail artwork with reverse-proxy subpath', () async {
      final mock = MockClient((req) async {
        if (req.url.path == '/jellyfin/Items') {
          return jsonResponse({
            'Items': [
              {
                'Id': 'video-1',
                'Name': 'Video Playlist',
                'Type': 'Playlist',
                'MediaType': 'Video',
                'ImageTags': {'Primary': 'tag 1'},
              },
            ],
          });
        }
        return http.Response('not found', 404);
      });
      final client = JellyfinClient.forTesting(
        connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'),
        httpClient: mock,
      );
      addTearDown(client.close);

      final playlists = await client.fetchPlaylists(playlistType: 'video');
      final uri = Uri.parse(playlists.single.thumbPath!);

      expect(uri.path, '/jellyfin/Items/video-1/Images/Primary');
      expect(uri.queryParameters['tag'], 'tag 1');
      expect(uri.queryParameters['api_key'], 'tok-abc');
    });

    test('fetchEditableMetadataItem requests full item dto without limited fields', () async {
      Uri? capturedUri;
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          capturedUri = request.url;
          return jsonResponse({
            'Id': 'folder/item #1?x',
            'Name': 'Movie',
            'Type': 'Movie',
            'ProviderIds': {'Tmdb': '1'},
          });
        }),
      );
      addTearDown(client.close);

      final item = await client.fetchEditableMetadataItem('folder/item #1?x');

      expect(item?['ProviderIds'], {'Tmdb': '1'});
      expect(capturedUri!.path, '/Users/user-1/Items/folder%2Fitem%20%231%3Fx');
      expect(capturedUri!.queryParameters.containsKey('Fields'), isFalse);
    });

    test('updateMetadataItem posts full dto to item update endpoint', () async {
      Uri? capturedUri;
      String? capturedBody;
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          capturedUri = request.url;
          capturedBody = request.body;
          return http.Response('', 204);
        }),
      );
      addTearDown(client.close);

      final success = await client.updateMetadataItem('item-1', {
        'Id': 'item-1',
        'Name': 'Edited',
        'Type': 'Movie',
        'ProviderIds': {'Tmdb': '123'},
        'Tags': ['Favorite'],
      });

      expect(success, isTrue);
      expect(capturedUri!.path, '/Items/item-1');
      final body = jsonDecode(capturedBody!) as Map<String, dynamic>;
      expect(body['Name'], 'Edited');
      expect(body['ProviderIds'], {'Tmdb': '123'});
      expect(body['Tags'], ['Favorite']);
    });

    test('remote image search and apply use Jellyfin image endpoints', () async {
      final requests = <Uri>[];
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          requests.add(request.url);
          if (request.url.path == '/Items/item-1/RemoteImages') {
            return jsonResponse({
              'TotalRecordCount': 1,
              'Providers': ['TheMovieDb'],
              'Images': [
                {'ProviderName': 'TheMovieDb', 'Url': 'https://img.example/poster.jpg', 'Type': 'Primary'},
              ],
            });
          }
          return http.Response('', 204);
        }),
      );
      addTearDown(client.close);

      final result = await client.getRemoteImages(
        'item-1',
        imageType: 'Primary',
        limit: 20,
        providerName: 'TheMovieDb',
      );
      final success = await client.downloadRemoteImage(
        'item-1',
        imageType: 'Primary',
        imageUrl: 'https://img.example/poster.jpg',
      );

      expect((result['Images'] as List).single['Url'], 'https://img.example/poster.jpg');
      expect(success, isTrue);
      expect(requests[0].path, '/Items/item-1/RemoteImages');
      expect(requests[0].queryParameters['type'], 'Primary');
      expect(requests[0].queryParameters['limit'], '20');
      expect(requests[0].queryParameters['providerName'], 'TheMovieDb');
      expect(requests[1].path, '/Items/item-1/RemoteImages/Download');
      expect(requests[1].queryParameters['type'], 'Primary');
      expect(requests[1].queryParameters['imageUrl'], 'https://img.example/poster.jpg');
    });

    test('uploadItemImage sends binary image body and image content type', () async {
      Uri? capturedUri;
      List<int>? capturedBody;
      Map<String, String>? capturedHeaders;
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((request) async {
          capturedUri = request.url;
          capturedBody = request.bodyBytes;
          capturedHeaders = request.headers;
          return http.Response('', 204);
        }),
      );
      addTearDown(client.close);

      final success = await client.uploadItemImage(
        'item-1',
        imageType: 'Primary',
        bytes: [0xff, 0xd8, 0xff, 0x00],
        contentType: 'image/jpeg',
      );

      expect(success, isTrue);
      expect(capturedUri!.path, '/Items/item-1/Images/Primary');
      expect(capturedBody, [0xff, 0xd8, 0xff, 0x00]);
      expect(capturedHeaders!['Content-Type'] ?? capturedHeaders!['content-type'], 'image/jpeg');
    });

    test('smart=true returns empty without network I/O', () async {
      var requestCount = 0;
      final client = JellyfinClient.forTesting(
        connection: _conn(),
        httpClient: MockClient((_) async {
          requestCount++;
          return http.Response('unexpected', 500);
        }),
      );
      addTearDown(client.close);

      final playlists = await client.fetchPlaylists(playlistType: 'video', smart: true);

      expect(playlists, isEmpty);
      expect(requestCount, 0);
    });
  });
}
