import 'dart:async';
import '../utils/isolate_helper.dart';
import '../utils/json_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;

import '../media/download_resolution.dart';
import '../media/library_filter_result.dart';
import '../media/library_first_character.dart';
import '../media/library_query.dart';
import '../media/live_tv_support.dart';
import '../media/media_backend.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_playlist.dart';
import '../media/media_server_client.dart';
import '../media/server_capabilities.dart';
import '../utils/external_ids.dart';
import 'bif_thumbnail_service.dart';
import 'download_artwork_helpers.dart';
import 'library_query_translator.dart';
import 'scrub_preview_source.dart';
import '../utils/media_server_http_client.dart';
import '../exceptions/media_server_exceptions.dart';
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_hub_result.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../models/plex/plex_activity.dart';
import '../models/plex/plex_config.dart';
import '../models/plex/play_queue_response.dart';
import '../media/media_file_info.dart';
import '../media/media_filter.dart';
import '../media/media_source_info.dart';
import '../models/plex/plex_subtitle_search_result.dart';
import '../models/plex/plex_match_result.dart';
import '../utils/codec_utils.dart';
import '../utils/content_utils.dart';
import '../media/media_sort.dart';
import '../models/plex/plex_video_playback_data.dart';
import '../models/transcode_quality_preset.dart';
import '../utils/endpoint_failover_interceptor.dart';
import '../utils/app_logger.dart';
import '../utils/media_server_retry.dart';
import '../utils/media_server_timeouts.dart';
import '../utils/log_redaction_manager.dart';
import '../utils/plex_cache_parser.dart';
import '../utils/plex_library_section_utils.dart';
import '../utils/plex_url_helper.dart';
import '../utils/session_identifier.dart' as session_id;
import '../utils/watch_state_notifier.dart';
import '../i18n/strings.g.dart';
import '../mpv/mpv.dart';
import 'api_cache.dart';
import 'plex_api_cache.dart';
import 'plex_mappers.dart';
import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';

part 'plex_client/parts/live_tv.dart';

/// Result of a paginated library content fetch
class _LibraryContentResult {
  final List<PlexMetadataDto> items;
  final int totalSize;
  const _LibraryContentResult({required this.items, required this.totalSize});
}

/// Process hub response in an isolate.
/// Top-level function so it can be passed to [Isolate.run].
List<PlexHubDto> _processHubResponse(
  Map<String, dynamic> decoded,
  String serverId,
  String? serverName, {
  int? librarySectionID,
  String? librarySectionTitle,
  bool Function(PlexMetadataDto)? filter,
}) {
  final container = decoded['MediaContainer'] as Map<String, dynamic>?;
  if (container == null || container['Hub'] == null) return [];

  final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
  final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
  final itemFilter = filter ?? (PlexMetadataDto item) => ContentTypes.videoTypes.contains(item.type?.toLowerCase());
  final hubs = <PlexHubDto>[];
  for (final hubJson in container['Hub'] as List) {
    try {
      final hubMap = hubJson as Map<String, dynamic>;
      final hubSectionID = _librarySectionIdFromJson(hubMap) ?? containerSectionID;
      final hubSectionTitle = _librarySectionTitleFromJson(hubMap) ?? containerSectionTitle;
      final hub = _plexHubWithLibrarySection(
        PlexHubDto.fromJson(hubMap, serverId: serverId, serverName: serverName),
        librarySectionID: hubSectionID,
        librarySectionTitle: hubSectionTitle,
      );
      if (hub.items.isEmpty) continue;

      final filteredItems = hub.items.where(itemFilter).toList();

      if (filteredItems.isNotEmpty) {
        hubs.add(
          PlexHubDto(
            hubKey: hub.hubKey,
            title: hub.title,
            type: hub.type,
            hubIdentifier: hub.hubIdentifier,
            size: hub.size,
            more: hub.more,
            items: filteredItems,
            serverId: serverId,
            serverName: serverName,
          ),
        );
      }
    } catch (_) {
      // Skip hubs that fail to parse
    }
  }
  return hubs;
}

int? _librarySectionIdFromJson(Map<String, dynamic>? json) => plexLibrarySectionIdFromJson(json);

int? _librarySectionIdFromString(String? sectionId) => plexLibrarySectionIdFromString(sectionId);

String? _librarySectionTitleFromJson(Map<String, dynamic>? json) => plexLibrarySectionTitleFromJson(json);

PlexMetadataDto _plexMetadataWithLibrarySection(
  PlexMetadataDto metadata, {
  int? librarySectionID,
  String? librarySectionTitle,
}) {
  final nextSectionID = metadata.librarySectionID ?? librarySectionID;
  final nextSectionTitle = metadata.librarySectionTitle ?? librarySectionTitle;
  if (nextSectionID == metadata.librarySectionID && nextSectionTitle == metadata.librarySectionTitle) {
    return metadata;
  }
  return metadata.copyWith(librarySectionID: nextSectionID, librarySectionTitle: nextSectionTitle);
}

PlexHubDto _plexHubWithLibrarySection(PlexHubDto hub, {int? librarySectionID, String? librarySectionTitle}) {
  if (librarySectionID == null && librarySectionTitle == null) return hub;
  return PlexHubDto(
    hubKey: hub.hubKey,
    title: hub.title,
    type: hub.type,
    hubIdentifier: hub.hubIdentifier,
    size: hub.size,
    more: hub.more,
    items: hub.items
        .map(
          (item) => _plexMetadataWithLibrarySection(
            item,
            librarySectionID: librarySectionID,
            librarySectionTitle: librarySectionTitle,
          ),
        )
        .toList(),
    serverId: hub.serverId,
    serverName: hub.serverName,
  );
}

// PlexStreamType moved to plex_constants.dart to break a would-be circular
// import once plex_mappers.dart started referencing the same names.

/// Result of testing a connection, including success status and latency
class ConnectionTestResult {
  final bool success;
  final int latencyMs;
  final String? error;

  /// `transcoderVideo` from the `/` MediaContainer, captured on successful
  /// probes so the connection race doubles as a capability probe. `null`
  /// when the probe didn't succeed or the field was absent.
  final bool? transcoderVideo;

  ConnectionTestResult({required this.success, required this.latencyMs, this.error, this.transcoderVideo});
}

class PlexClient
    with MediaServerCacheMixin, _PlexLiveTvClientMethods
    implements MediaServerClient, GracefullyCloseable {
  @override
  PlexConfig config;

  @override
  late final MediaServerHttpClient _http;
  final EndpointFailoverManager? _endpointManager;
  final Future<void> Function(String newBaseUrl)? _onEndpointChanged;
  final VoidCallback? _onAllEndpointsExhausted;

  /// Server identifier - all PlexMetadataDto items created by this client are tagged with this
  @override
  final String serverId;

  /// Server name - all PlexMetadataDto items created by this client are tagged with this
  @override
  final String? serverName;

  /// API response cache for offline support
  final PlexApiCache _cache = PlexApiCache.instance;

  /// Expose the cache through the [MediaServerClient] interface so the shared
  /// `fetchWithCacheFallback` / `fetchWithCacheFirst` helpers route through
  /// the Plex-specific cache substrate.
  @override
  ApiCache get cache => _cache;

  /// Whether to operate in offline mode (use cache only)
  bool _offlineMode = false;

  /// Cached result of [serverSupportsVideoTranscoding]. `null` = not yet fetched.
  bool? _serverTranscoderCached;

  /// In-flight probe for [serverSupportsVideoTranscoding], used to dedupe
  /// concurrent callers (e.g. the post-connect warm-up racing the first
  /// playback).
  Future<bool>? _serverTranscoderPending;

  /// Libraries parsed from /media/providers (includes individually shared items)
  List<PlexLibraryDto> _providerLibraries = const [];

  /// Home hub endpoint advertised by /media/providers (usually /hubs).
  String? _providerHomeHubKey;

  /// Promoted home hub endpoint advertised by /media/providers (usually /hubs/promoted).
  String? _providerPromotedHubKey;

  /// EPG providers parsed from /media/providers
  @override
  List<({String identifier, String gridEndpoint})> _providerEpg = const [];

  /// Server-level preferences fetched from /:/prefs
  Map<String, dynamic> _serverPrefs = {};

  /// Get all fetched server preferences
  Map<String, dynamic> get serverPrefs => Map.unmodifiable(_serverPrefs);

  /// Get the server's watched threshold percentage (default 90)
  int get watchedThresholdPercent {
    final value = _serverPrefs['LibraryVideoPlayedThreshold'];
    if (value is int) return value;
    if (value is String) return int.tryParse(value) ?? 90;
    return 90;
  }

  /// Set offline mode - when true, only cached responses are returned
  @override
  void setOfflineMode(bool offline) {
    _offlineMode = offline;
  }

  /// Get current offline mode state
  @override
  bool get isOfflineMode => _offlineMode;

  /// Create a fully initialized PlexClient.
  /// Fetches /media/providers to discover libraries (including individually shared items) and EPG providers.
  static Future<PlexClient> create(
    PlexConfig config, {
    required String serverId,
    String? serverName,
    List<String>? prioritizedEndpoints,
    Future<void> Function(String newBaseUrl)? onEndpointChanged,
    VoidCallback? onAllEndpointsExhausted,
    bool? seedTranscoderVideoSupport,
    http.Client? httpClient,
  }) async {
    final client = PlexClient._(
      config,
      serverId: serverId,
      serverName: serverName,
      prioritizedEndpoints: prioritizedEndpoints,
      onEndpointChanged: onEndpointChanged,
      onAllEndpointsExhausted: onAllEndpointsExhausted,
      httpClient: httpClient,
    );
    if (seedTranscoderVideoSupport != null) {
      client._serverTranscoderCached = seedTranscoderVideoSupport;
    }
    await client._initMediaProviders();
    // If the connection race didn't seed the capability, warm the cache in
    // the background so the first playback doesn't pay the probe cost on its
    // hot path.
    if (seedTranscoderVideoSupport == null) {
      unawaited(client.serverSupportsVideoTranscoding());
    }
    return client;
  }

  PlexClient._(
    this.config, {
    required this.serverId,
    this.serverName,
    List<String>? prioritizedEndpoints,
    Future<void> Function(String newBaseUrl)? onEndpointChanged,
    VoidCallback? onAllEndpointsExhausted,
    http.Client? httpClient,
  }) : _endpointManager = (prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty)
           ? EndpointFailoverManager(prioritizedEndpoints)
           : null,
       _onEndpointChanged = onEndpointChanged,
       _onAllEndpointsExhausted = onAllEndpointsExhausted {
    LogRedactionManager.registerServer(config.baseUrl, config.token);

    _http = MediaServerHttpClient(
      baseUrl: config.baseUrl,
      defaultHeaders: config.headers,
      connectTimeout: MediaServerTimeouts.connect,
      receiveTimeout: MediaServerTimeouts.receive,
      usePlexApiClient: true,
      client: httpClient,
    );
  }

  /// Test-only factory that injects an [http.Client] so URL-builder tests can
  /// capture the request URI without spinning up a real Plex server. Mirrors
  /// [JellyfinClient.forTesting]. Skips the [_initMediaProviders] step from
  /// [create] — tests that need libraries should mock the `/media/providers`
  /// response themselves.
  @visibleForTesting
  static PlexClient forTesting({
    required PlexConfig config,
    required String serverId,
    String? serverName,
    required http.Client httpClient,
    List<String>? prioritizedEndpoints,
    List<({String identifier, String gridEndpoint})> epgProviders = const [],
    String? homeHubKey,
    String? promotedHubKey,
  }) {
    final client = PlexClient._(
      config,
      serverId: serverId,
      serverName: serverName,
      httpClient: httpClient,
      prioritizedEndpoints: prioritizedEndpoints,
    );
    client._providerLibraries = const [];
    client._providerEpg = epgProviders;
    client._providerHomeHubKey = homeHubKey;
    client._providerPromotedHubKey = promotedHubKey;
    return client;
  }

  @override
  void close() {
    _http.close();
  }

  @override
  Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) {
    return _http.closeGracefully(drainTimeout: drainTimeout);
  }

  bool _failoverSwitching = false;

  /// Execute a GET request with endpoint failover retry. On timeout/connection
  /// errors the next endpoint is tried (once). Non-GET methods are not retried.
  /// Optional hub surfaces disable endpoint failover so a slow row does not
  /// move the whole client away from an otherwise working endpoint.
  @override
  Future<MediaServerResponse> _getWithFailover(
    String path, {
    Map<String, dynamic>? queryParameters,
    Map<String, String>? headers,
    Duration? timeout,
    AbortController? abort,
    bool allowEndpointFailover = true,
  }) async {
    final gen = _endpointManager?.generation;
    try {
      final response = await _http.get(
        path,
        queryParameters: queryParameters,
        headers: headers,
        timeout: timeout,
        abort: abort,
      );
      throwIfHttpError(response);
      return response;
    } on MediaServerHttpException catch (e) {
      if (!allowEndpointFailover ||
          !_shouldAttemptFailover(e) ||
          _failoverSwitching ||
          _endpointManager == null ||
          gen != _endpointManager.generation) {
        rethrow;
      }

      if (!_endpointManager.hasFallback) {
        _endpointManager.resetToFirst();
        _onAllEndpointsExhausted?.call();
        rethrow;
      }

      final failedEndpoint = _endpointManager.current;
      final nextBaseUrl = _endpointManager.moveToNext();
      if (nextBaseUrl == null) rethrow;

      _failoverSwitching = true;
      try {
        appLogger.i(
          'Switching Plex endpoint after GET failure',
          error: {'from': failedEndpoint, 'to': nextBaseUrl, 'path': path},
        );
        await _handleEndpointSwitch(nextBaseUrl, persist: false);
        final response = await _http.get(
          path,
          queryParameters: queryParameters,
          headers: headers,
          timeout: timeout,
          abort: abort,
        );
        throwIfHttpError(response);
        appLogger.i('Endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl});
        await _onEndpointChanged?.call(nextBaseUrl);
        return response;
      } finally {
        _failoverSwitching = false;
      }
    }
  }

  bool _shouldAttemptFailover(MediaServerHttpException e) {
    if (e.isTransient) return true;
    final sc = e.statusCode;
    return sc != null && sc >= 500 && sc <= 599;
  }

  /// Fetch /media/providers and parse libraries + EPG providers from the response.
  /// This discovers individually shared items that don't appear in /library/sections.
  Future<void> _initMediaProviders() async {
    try {
      final response = await _getWithFailover('/media/providers');
      final container = _getMediaContainer(response);
      if (container == null) {
        _providerLibraries = [];
        _providerEpg = [];
        _providerHomeHubKey = null;
        _providerPromotedHubKey = null;
        return;
      }

      final providers = container['MediaProvider'] as List?;
      if (providers == null) {
        _providerLibraries = [];
        _providerEpg = [];
        _providerHomeHubKey = null;
        _providerPromotedHubKey = null;
        return;
      }

      final libraries = <PlexLibraryDto>[];
      final epg = <({String identifier, String gridEndpoint})>[];
      String? homeHubKey;
      String? promotedHubKey;

      for (final provider in providers) {
        if (provider is! Map) continue;
        final identifier = provider['identifier'] as String?;
        if (identifier == null) continue;

        final features = provider['Feature'] as List?;
        if (features == null) continue;

        // Library provider — extract directories as libraries
        if (identifier == 'com.plexapp.plugins.library') {
          for (final feature in features) {
            if (feature is! Map) continue;

            if (feature['type'] == 'promoted') {
              promotedHubKey ??= feature['key'] as String?;
            }

            if (feature['type'] != 'content') continue;

            final directories = feature['Directory'] as List?;
            if (directories == null) continue;

            for (final dir in directories) {
              try {
                if (dir is! Map<String, dynamic>) continue;

                // Skip entries without id (Home hub) and playlists
                final id = dir['id']?.toString();
                if (id == null) {
                  homeHubKey ??= dir['hubKey'] as String?;
                  continue;
                }
                if (dir['type'] == 'playlist') continue;

                final isNumericId = int.tryParse(id) != null;
                final isSharedLibrary = !isNumericId && dir['key']?.toString().startsWith('/library/shared') == true;

                // Skip non-numeric IDs unless it's a shared library
                if (!isNumericId && !isSharedLibrary) continue;

                // Set key = id so downstream code gets a plain section ID (e.g. "1" or "shared")
                final json = Map<String, dynamic>.from(dir);
                json['key'] = id;

                libraries.add(
                  PlexLibraryDto.fromJson(
                    json,
                  ).copyWith(serverId: serverId, serverName: serverName, isShared: isSharedLibrary),
                );
              } catch (e) {
                appLogger.w('Failed to parse media provider directory entry', error: e);
              }
            }
          }
        }

        // EPG provider — extract grid endpoints
        final protocols = provider['protocols'] as String?;
        if (protocols != null && protocols.contains('livetv')) {
          for (final feature in features) {
            if (feature is! Map) continue;
            if (feature['type'] == 'grid') {
              final gridEndpoint = feature['key'] as String?;
              if (gridEndpoint != null) {
                epg.add((identifier: identifier, gridEndpoint: gridEndpoint));
                appLogger.d('Discovered EPG provider: $identifier (grid: $gridEndpoint)');
              }
            }
          }
        }
      }

      _providerLibraries = libraries;
      _providerEpg = epg;
      _providerHomeHubKey = homeHubKey;
      _providerPromotedHubKey = promotedHubKey;
      appLogger.d('Media providers: ${libraries.length} libraries, ${epg.length} EPG provider(s)');
    } catch (e) {
      appLogger.w('Failed to fetch /media/providers, will fall back to /library/sections', error: e);
      _providerLibraries = [];
      _providerEpg = [];
      _providerHomeHubKey = null;
      _providerPromotedHubKey = null;
    }
  }

  /// Update endpoint priority list and optionally hop to the new best endpoint.
  Future<void> updateEndpointPreferences(List<String> prioritizedEndpoints, {bool switchToFirst = false}) async {
    if (_endpointManager == null || prioritizedEndpoints.isEmpty) {
      return;
    }

    final targetBaseUrl = switchToFirst ? prioritizedEndpoints.first : config.baseUrl;
    _endpointManager.reset(prioritizedEndpoints, currentBaseUrl: targetBaseUrl);

    if (switchToFirst && targetBaseUrl != config.baseUrl) {
      await _handleEndpointSwitch(targetBaseUrl);
    }
  }

  /// Test connection to a specific URL with token and measure latency
  static Future<ConnectionTestResult> testConnectionWithLatency(
    String baseUrl,
    String token, {
    Duration timeout = const Duration(seconds: 5),
    String? clientIdentifier,
  }) async {
    final stopwatch = Stopwatch()..start();
    MediaServerHttpClient? client;

    try {
      client = MediaServerHttpClient(baseUrl: baseUrl, connectTimeout: timeout, receiveTimeout: timeout);

      final headers = <String, String>{'X-Plex-Token': token};
      if (clientIdentifier != null) {
        headers['X-Plex-Client-Identifier'] = clientIdentifier;
        headers['X-Plex-Product'] = 'Plezy';
        headers['X-Plex-Device-Name'] = 'Plezy';
      }

      final response = await client.get('/', headers: headers);

      stopwatch.stop();
      final success = response.statusCode == 200;

      bool? transcoderVideo;
      if (success && response.data is Map && response.data['MediaContainer'] is Map) {
        transcoderVideo = flexibleBool((response.data['MediaContainer'] as Map)['transcoderVideo']);
      }

      return ConnectionTestResult(
        success: success,
        latencyMs: stopwatch.elapsedMilliseconds,
        error: success ? null : 'HTTP ${response.statusCode}',
        transcoderVideo: transcoderVideo,
      );
    } on MediaServerHttpException catch (e) {
      stopwatch.stop();
      final label = switch (e.type) {
        MediaServerHttpErrorType.connectionTimeout => 'Connection timeout',
        MediaServerHttpErrorType.receiveTimeout => 'Receive timeout',
        MediaServerHttpErrorType.connectionError => 'Connection error',
        _ => e.type.name,
      };
      final message = e.message.trim();
      var error = message.isEmpty ? label : '$label: $message';
      if (e.statusCode != null) {
        error += ' (HTTP ${e.statusCode})';
      }
      return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: error);
    } catch (e) {
      stopwatch.stop();
      return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.toString());
    } finally {
      client?.close();
    }
  }

  /// Test connection multiple times and return average latency
  static Future<ConnectionTestResult> testConnectionWithAverageLatency(
    String baseUrl,
    String token, {
    int attempts = 3,
    Duration timeout = const Duration(seconds: 5),
    String? clientIdentifier,
  }) async {
    final results = <ConnectionTestResult>[];

    for (int i = 0; i < attempts; i++) {
      final result = await testConnectionWithLatency(
        baseUrl,
        token,
        timeout: timeout,
        clientIdentifier: clientIdentifier,
      );

      // If any attempt fails, return failed result immediately
      if (!result.success) {
        return ConnectionTestResult(success: false, latencyMs: result.latencyMs);
      }

      results.add(result);
    }

    // Calculate average latency from successful attempts
    final avgLatency = results.fold<int>(0, (sum, result) => sum + result.latencyMs) ~/ results.length;

    return ConnectionTestResult(success: true, latencyMs: avgLatency);
  }

  @override
  Map<String, dynamic>? _getMediaContainer(MediaServerResponse response) {
    if (response.data is Map && response.data.containsKey('MediaContainer')) {
      return response.data['MediaContainer'];
    }
    return null;
  }

  PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) =>
      metadata.copyWith(serverId: serverId, serverName: serverName);

  PlexMetadataDto _tagMetadataWithLibrary(
    PlexMetadataDto metadata, {
    int? librarySectionID,
    String? librarySectionTitle,
  }) {
    return _plexMetadataWithLibrarySection(
      _tagMetadata(metadata),
      librarySectionID: librarySectionID,
      librarySectionTitle: librarySectionTitle,
    );
  }

  @override
  PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json) => _tagMetadata(PlexMetadataDto.fromJson(json));

  PlexMetadataDto _createTaggedMetadataWithLibrary(
    Map<String, dynamic> json, {
    int? librarySectionID,
    String? librarySectionTitle,
  }) {
    return _tagMetadataWithLibrary(
      PlexMetadataDto.fromJson(json),
      librarySectionID: _librarySectionIdFromJson(json) ?? librarySectionID,
      librarySectionTitle: _librarySectionTitleFromJson(json) ?? librarySectionTitle,
    );
  }

  @override
  List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response) => _extractMetadataListWithLibrary(response);

  List<PlexMetadataDto> _extractMetadataListWithLibrary(
    MediaServerResponse response, {
    int? librarySectionID,
    String? librarySectionTitle,
  }) {
    final container = _getMediaContainer(response);
    if (container != null && container['Metadata'] != null) {
      final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
      final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
      return (container['Metadata'] as List)
          .map(
            (json) => _createTaggedMetadataWithLibrary(
              json as Map<String, dynamic>,
              librarySectionID: containerSectionID,
              librarySectionTitle: containerSectionTitle,
            ),
          )
          .toList();
    }
    return [];
  }

  Map<String, dynamic>? _getFirstMetadataJson(MediaServerResponse response) {
    final container = _getMediaContainer(response);
    if (container != null && container['Metadata'] != null && (container['Metadata'] as List).isNotEmpty) {
      return container['Metadata'][0] as Map<String, dynamic>;
    }
    return null;
  }

  List<T> _extractDirectoryList<T>(MediaServerResponse response, T Function(Map<String, dynamic>) fromJson) {
    final container = _getMediaContainer(response);
    if (container != null && container['Directory'] != null) {
      return (container['Directory'] as List).map((json) => fromJson(json as Map<String, dynamic>)).toList();
    }
    return [];
  }

  List<PlexLibraryDto> _extractLibraryList(MediaServerResponse response) {
    final container = _getMediaContainer(response);
    if (container != null && container['Directory'] != null) {
      return (container['Directory'] as List)
          .map(
            (json) => PlexLibraryDto.fromJson(
              json as Map<String, dynamic>,
            ).copyWith(serverId: serverId, serverName: serverName),
          )
          .toList();
    }
    return [];
  }

  List<PlexPlaylistDto> _extractPlaylistList(MediaServerResponse response) {
    final container = _getMediaContainer(response);
    if (container != null && container['Metadata'] != null) {
      return (container['Metadata'] as List)
          .map(
            (json) => PlexPlaylistDto.fromJson(
              json as Map<String, dynamic>,
            ).copyWith(serverId: serverId, serverName: serverName),
          )
          .toList();
    }
    return [];
  }

  Future<Map<String, dynamic>> getServerIdentity() async {
    final response = await _getWithFailover('/identity');
    return response.data;
  }

  /// Check if the server connection is healthy (reachable AND authenticated).
  ///
  /// Hits the root `/` MediaContainer (auth-required) rather than `/identity`
  /// (an unauthenticated discovery endpoint). With `/identity`, a server with
  /// a revoked or expired token would still report healthy, only to 401 on
  /// the very next real call. Mirrors Jellyfin's `/Users/Me` choice.
  ///
  /// Distinguishes 401/403 (token revoked / wrong user) as
  /// [HealthStatus.authError] from generic transport failures so the
  /// manager can route them to a re-auth banner instead of generic
  /// "server offline" UI.
  @override
  Future<HealthStatus> checkHealth() async {
    try {
      final response = await _getWithFailover('/');
      return response.statusCode == 200 ? HealthStatus.online : HealthStatus.offline;
    } on MediaServerHttpException catch (e) {
      if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError;
      return HealthStatus.offline;
    } catch (_) {
      return HealthStatus.offline;
    }
  }

  @override
  Future<bool> isHealthy() async => (await checkHealth()) == HealthStatus.online;

  /// Get running background tasks (thumbnail generation, credit detection, etc.)
  Future<List<PlexActivity>> getActivities() async {
    try {
      final response = await _getWithFailover('/activities');
      final container = _getMediaContainer(response);
      if (container == null) return [];
      final activityList = container['Activity'] as List?;
      if (activityList == null) return [];
      return activityList.map((json) => PlexActivity.fromJson(json as Map<String, dynamic>)).toList();
    } catch (e) {
      appLogger.e('Failed to get activities', error: e);
      return [];
    }
  }

  /// Cancel a running background task by its UUID.
  Future<void> cancelActivity(String uuid) async {
    await _http.delete('/activities/$uuid');
  }

  /// Get library sections
  /// Returns libraries automatically tagged with this client's serverId and serverName.
  /// Prefers /media/providers data (includes individually shared items),
  /// falls back to /library/sections for old servers.
  Future<List<PlexLibraryDto>> _getLibraries() async {
    if (_providerLibraries.isNotEmpty) return _providerLibraries;
    // Fallback for old servers that don't support /media/providers
    final response = await _getWithFailover('/library/sections');
    return _extractLibraryList(response);
  }

  /// Get library content by section ID
  Future<_LibraryContentResult> _getLibraryContent(
    String sectionId, {
    int? start,
    int? size,
    Map<String, String>? filters,
    AbortController? abort,
  }) async {
    final queryParams = _buildPaginationParams(start, size);
    if (filters != null) queryParams.addAll(filters);
    final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all';
    final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort);
    return _extractLibraryContentResult(response, librarySectionID: _librarySectionIdFromString(sectionId));
  }

  Map<String, dynamic> _buildPaginationParams(int? start, int? size) {
    final params = <String, dynamic>{};
    if (start != null) params['X-Plex-Container-Start'] = start;
    if (size != null) params['X-Plex-Container-Size'] = size;
    return params;
  }

  _LibraryContentResult _extractLibraryContentResult(
    MediaServerResponse response, {
    int? librarySectionID,
    String? librarySectionTitle,
  }) {
    final items = _extractMetadataListWithLibrary(
      response,
      librarySectionID: librarySectionID,
      librarySectionTitle: librarySectionTitle,
    );
    final container = _getMediaContainer(response);
    final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length;
    return _LibraryContentResult(items: items, totalSize: totalSize);
  }

  Future<_LibraryContentResult> _fetchPaginatedList(
    String path, {
    int? start,
    int? size,
    AbortController? abort,
    int? librarySectionID,
    String? librarySectionTitle,
  }) async {
    final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort);
    return _extractLibraryContentResult(
      response,
      librarySectionID: librarySectionID,
      librarySectionTitle: librarySectionTitle,
    );
  }

  /// Parse list of PlexMetadataDto from a cached response
  List<PlexMetadataDto> _parseMetadataListFromCachedResponse(Map<String, dynamic> cached) {
    final container = cached['MediaContainer'] is Map<String, dynamic>
        ? cached['MediaContainer'] as Map<String, dynamic>
        : null;
    final containerSectionID = _librarySectionIdFromJson(container);
    final containerSectionTitle = _librarySectionTitleFromJson(container);
    final metadataList = PlexCacheParser.extractMetadataList(cached);
    if (metadataList != null) {
      return metadataList
          .map(
            (json) => _createTaggedMetadataWithLibrary(
              json as Map<String, dynamic>,
              librarySectionID: containerSectionID,
              librarySectionTitle: containerSectionTitle,
            ),
          )
          .toList();
    }
    return [];
  }

  /// Get the server's machine identifier
  @override
  Future<String?> getMachineIdentifier() async {
    try {
      final response = await _getWithFailover('/');
      final container = _getMediaContainer(response);
      if (container == null) return null;
      return container['machineIdentifier'] as String?;
    } catch (e) {
      appLogger.e('Failed to get machine identifier', error: e);
      return null;
    }
  }

  /// Build a proper metadata URI for adding to playlists
  /// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey}
  Future<String> buildMetadataUri(String ratingKey) async {
    // Use cached machine identifier from config if available
    final machineId = config.machineIdentifier ?? await getMachineIdentifier();
    if (machineId == null) {
      throw Exception('Could not get server machine identifier');
    }
    return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey';
  }

  /// Build a server URI from a folder key for play queue creation.
  /// Folder keys are like `/library/sections/1/folder?parent=123`.
  Future<String> buildFolderUri(String folderKey) async {
    final machineId = config.machineIdentifier ?? await getMachineIdentifier();
    if (machineId == null) {
      throw Exception('Could not get server machine identifier');
    }
    return 'server://$machineId/com.plexapp.plugins.library$folderKey';
  }

  /// Get metadata by rating key with images (includes clearLogo and OnDeck)
  /// Uses cache when offline or as fallback on network error
  /// Note: OnDeck data is not relevant for offline mode
  /// Always fetches with chapters/markers but caches at base endpoint
  Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(String ratingKey) async {
    // Cache key is always the base endpoint (no query params)
    final cacheKey = '/library/metadata/$ratingKey';

    // Special handling needed for OnDeck - can't use simple fetchWithCacheFallback
    // because OnDeck is only available from network response, not cache
    return await fetchWithCacheFallback<Map<String, dynamic>>(
          cacheKey: cacheKey,
          networkCall: () => _http.get(
            '/library/metadata/$ratingKey',
            queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'includeOnDeck': 1},
          ),
          parseCache: (cachedData) {
            final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData);
            return {'metadata': metadata, 'onDeckEpisode': null};
          },
          parseResponse: (response) {
            PlexMetadataDto? metadata;
            PlexMetadataDto? onDeckEpisode;

            final container = _getMediaContainer(response);
            final containerSectionID = _librarySectionIdFromJson(container);
            final containerSectionTitle = _librarySectionTitleFromJson(container);
            final metadataJson = _getFirstMetadataJson(response);

            if (metadataJson != null) {
              metadata = _tagMetadataWithLibrary(
                PlexMetadataDto.fromJsonWithImages(metadataJson),
                librarySectionID: _librarySectionIdFromJson(metadataJson) ?? containerSectionID,
                librarySectionTitle: _librarySectionTitleFromJson(metadataJson) ?? containerSectionTitle,
              );

              // Check if OnDeck is nested inside Metadata
              if (metadataJson.containsKey('OnDeck') && metadataJson['OnDeck'] != null) {
                final onDeckData = metadataJson['OnDeck'];

                // OnDeck can be either a Map with 'Metadata' key or direct metadata
                if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
                  final onDeckMetadata = onDeckData['Metadata'];
                  if (onDeckMetadata != null) {
                    onDeckEpisode = _createTaggedMetadataWithLibrary(
                      onDeckMetadata as Map<String, dynamic>,
                      librarySectionID: metadata.librarySectionID ?? containerSectionID,
                      librarySectionTitle: metadata.librarySectionTitle ?? containerSectionTitle,
                    );
                  }
                }
              }
            }

            return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
          },
        ) ??
        {'metadata': null, 'onDeckEpisode': null};
  }

  /// Get metadata by rating key with images (includes clearLogo)
  /// Uses cache when offline or as fallback on network error
  /// Always fetches with chapters/markers but caches at base endpoint
  Future<PlexMetadataDto?> _getMetadataWithImages(String ratingKey) async {
    // Cache key is always the base endpoint (no query params)
    final cacheKey = '/library/metadata/$ratingKey';

    return fetchWithCacheFallback<PlexMetadataDto>(
      cacheKey: cacheKey,
      networkCall: () =>
          _http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}),
      parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData),
      parseResponse: (response) {
        final container = _getMediaContainer(response);
        final metadataJson = _getFirstMetadataJson(response);
        return metadataJson != null
            ? _tagMetadataWithLibrary(
                PlexMetadataDto.fromJsonWithImages(metadataJson),
                librarySectionID: _librarySectionIdFromJson(metadataJson) ?? _librarySectionIdFromJson(container),
                librarySectionTitle:
                    _librarySectionTitleFromJson(metadataJson) ?? _librarySectionTitleFromJson(container),
              )
            : null;
      },
    );
  }

  /// Parse PlexMetadataDto with images from a cached response
  PlexMetadataDto? _parseMetadataWithImagesFromCachedResponse(Map<String, dynamic> cached) {
    final container = cached['MediaContainer'] is Map<String, dynamic>
        ? cached['MediaContainer'] as Map<String, dynamic>
        : null;
    final firstMetadata = PlexCacheParser.extractFirstMetadata(cached);
    if (firstMetadata != null) {
      return _tagMetadataWithLibrary(
        PlexMetadataDto.fromJsonWithImages(firstMetadata),
        librarySectionID: _librarySectionIdFromJson(firstMetadata) ?? _librarySectionIdFromJson(container),
        librarySectionTitle: _librarySectionTitleFromJson(firstMetadata) ?? _librarySectionTitleFromJson(container),
      );
    }
    return null;
  }

  /// Get first metadata JSON from response data
  Map<String, dynamic>? _getFirstMetadataJsonFromData(Map<String, dynamic>? data) =>
      PlexCacheParser.extractFirstMetadata(data);

  /// Wraps an API call that returns a boolean success status.
  ///
  /// Contract (matches the rest of the [MediaServerClient] surface):
  ///   - HTTP 2xx → returns `true`.
  ///   - HTTP 4xx/5xx → throws [MediaServerHttpException] (via
  ///     [throwIfHttpError]) so callers can show a real error rather than a
  ///     silent "success: false".
  ///   - Network/IO failure → exception bubbles unchanged.
  ///   - Non-2xx success that the server reports without an error code is
  ///     vanishingly rare for these endpoints; we still return `false` so
  ///     callers don't celebrate a non-200 silently.
  Future<bool> _wrapBoolApiCall(Future<MediaServerResponse> Function() apiCall, String errorMessage) async {
    try {
      final response = await apiCall();
      throwIfHttpError(response);
      return response.statusCode == 200;
    } catch (e, st) {
      appLogger.e(errorMessage, error: e, stackTrace: st);
      rethrow;
    }
  }

  /// Wraps an API call that returns a list, returning empty list on error
  @override
  Future<List<T>> _wrapListApiCall<T>(
    Future<MediaServerResponse> Function() apiCall,
    List<T> Function(MediaServerResponse response) parseResponse,
    String errorMessage,
  ) async {
    try {
      final response = await apiCall();
      return parseResponse(response);
    } catch (e) {
      appLogger.e(errorMessage, error: e);
      return [];
    }
  }

  /// Page size for iterating all items via [_fetchAllPages]. Also the cap
  /// for endpoints that send `X-Plex-Container-Size` but aren't truly paginated
  /// (collections listing, playlists listing, search).
  static const int _defaultListContainerSize = 1000;

  /// Page size used when walking all pages of a paginated endpoint.
  static const int _fetchAllPageSize = 200;

  /// Iterate every page of a paginated endpoint and concatenate the results.
  /// Stops as soon as [_LibraryContentResult.totalSize] is reached or a page
  /// returns no items. Errors propagate.
  Future<List<PlexMetadataDto>> _fetchAllPages(
    Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, {
    AbortController? abort,
  }) async {
    final all = <PlexMetadataDto>[];
    var start = 0;
    while (true) {
      final page = await fetchPage(start, _fetchAllPageSize, abort);
      all.addAll(page.items);
      start += page.items.length;
      if (page.items.isEmpty) break;
      if (start >= page.totalSize) break;
    }
    return all;
  }

  /// Set per-media language preferences (audio and subtitle)
  /// For TV shows, use grandparentRatingKey to set preference for the entire series
  /// For movies, use the movie's ratingKey
  Future<bool> setMetadataPreferences(String ratingKey, {String? audioLanguage, String? subtitleLanguage}) async {
    final queryParams = <String, dynamic>{};
    if (audioLanguage != null) {
      queryParams['audioLanguage'] = audioLanguage;
    }
    if (subtitleLanguage != null) {
      queryParams['subtitleLanguage'] = subtitleLanguage;
    }

    // If no preferences to set, return early
    if (queryParams.isEmpty) {
      return true;
    }

    return _wrapBoolApiCall(
      () => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: queryParams),
      'Failed to set metadata preferences',
    );
  }

  /// Select specific audio and subtitle streams for playback
  /// This updates which streams are "selected" in the media metadata
  /// Uses the part ID from media info for accurate stream selection
  Future<bool> selectStreams(int partId, {int? audioStreamID, int? subtitleStreamID, bool allParts = true}) async {
    final queryParams = <String, dynamic>{};
    if (audioStreamID != null) {
      queryParams['audioStreamID'] = audioStreamID;
    }
    if (subtitleStreamID != null) {
      queryParams['subtitleStreamID'] = subtitleStreamID;
    }
    if (allParts) {
      // If no streams to select, return early
      if (queryParams.isEmpty) {
        return true;
      }

      // Use PUT request on /library/parts/{partId}
      return _wrapBoolApiCall(
        () => _http.put('/library/parts/$partId', queryParameters: queryParams),
        'Failed to select streams',
      );
    }
    return true;
  }

  /// Search for subtitles from external providers (e.g. OpenSubtitles) via the Plex server.
  /// [language] is an ISO 639-1 two-letter code (e.g. "en", "es").
  Future<List<PlexSubtitleSearchResult>> searchSubtitles(
    String ratingKey, {
    required String language,
    String? title,
    int hearingImpaired = 0,
    int forced = 0,
  }) async {
    return _wrapListApiCall<PlexSubtitleSearchResult>(
      () => _http.get(
        '/library/metadata/$ratingKey/subtitles',
        queryParameters: {
          'language': language,
          if (title != null && title.isNotEmpty) 'title': title,
          'hearingImpaired': hearingImpaired,
          'forced': forced,
        },
      ),
      (response) {
        final container = _getMediaContainer(response);
        final streams = container?['Stream'] as List? ?? [];
        return streams.map((s) => PlexSubtitleSearchResult.fromJson(s as Map<String, dynamic>)).toList();
      },
      'Failed to search subtitles',
    );
  }

  /// Download a subtitle from an external provider and add it to the media item.
  /// The server downloads the file asynchronously; the new stream appears after a short delay.
  Future<bool> downloadSubtitle(
    String ratingKey, {
    required String key,
    required String codec,
    required String language,
    required bool hearingImpaired,
    required bool forced,
    required String providerTitle,
  }) async {
    return _wrapBoolApiCall(
      () => _http.put(
        '/library/metadata/$ratingKey/subtitles',
        queryParameters: {
          'key': key,
          'codec': codec,
          'language': language,
          'hearingImpaired': hearingImpaired ? 1 : 0,
          'forced': forced ? 1 : 0,
          'providerTitle': providerTitle,
        },
      ),
      'Failed to download subtitle',
    );
  }

  /// Search across all libraries including individually shared items.
  /// Uses /library/search (same endpoint as Plex Web) which finds shared content.
  /// Only returns movies and shows, filtering out other types.
  Future<List<PlexMetadataDto>> _search(String query, {int limit = 30}) async {
    final response = await _getWithFailover(
      '/library/search',
      queryParameters: {
        'query': query,
        'limit': limit,
        'searchTypes': 'movies,tv',
        'includeCollections': 1,
        'includeExternalMedia': 1,
        'X-Plex-Container-Size': limit,
      },
    );

    final results = <PlexMetadataDto>[];

    final container = _getMediaContainer(response);
    if (container == null) return results;

    final searchResults = container['SearchResult'] as List?;
    if (searchResults == null) return results;

    for (final result in searchResults) {
      try {
        if (result is! Map) continue;
        final metadata = result['Metadata'];
        if (metadata is! Map<String, dynamic>) continue;

        final type = metadata['type'] as String?;
        if (type != 'movie' && type != 'show') continue;

        results.add(_createTaggedMetadata(metadata));
      } catch (e) {
        appLogger.w('Failed to parse search result', error: e);
      }
    }

    return results;
  }

  /// Get recently added media (filtered to video content only)
  Future<List<PlexMetadataDto>> _getRecentlyAdded({int limit = 50}) async {
    final response = await _getWithFailover(
      '/library/recentlyAdded',
      queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1},
    );
    final allItems = _extractMetadataList(response);

    // Filter out music content (artists, albums, tracks)
    return allItems.where((item) => !ContentTypes.musicTypes.contains(item.type?.toLowerCase())).toList();
  }

  /// Get continue watching items via the hubs system.
  /// Uses /hubs?identifier=home.continue,home.ondeck which respects the
  /// server's OnDeckWindow preference (unlike /library/onDeck).
  Future<List<PlexMetadataDto>> _getContinueWatching({int? count = 20}) async {
    final response = await retryTransientMediaServerCall(
      operation: 'Plex continue watching hubs',
      attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
      call: (timeout, abort) => _getWithFailover(
        '/hubs',
        queryParameters: {'identifier': 'home.continue,home.ondeck', 'count': ?count, 'includeGuids': 1},
        timeout: timeout,
        abort: abort,
        allowEndpointFailover: false,
      ),
    );
    final sid = serverId;
    final sname = serverName;
    final data = response.data as Map<String, dynamic>;
    final hubs = await tryIsolateRun(() => _processHubResponse(data, sid, sname));
    // Deduplicate across home.continue and home.ondeck hubs.
    // Like plex-web, episodes from the same show (same grandparentRatingKey)
    // are deduplicated, preferring the in-progress item (has viewOffset).
    final items = hubs.expand((hub) => hub.items).toList();
    final result = <PlexMetadataDto>[];
    for (final item in items) {
      final isEpisode = item.type?.toLowerCase() == 'episode';
      final gpKey = item.grandparentRatingKey;
      if (isEpisode && gpKey != null) {
        final idx = result.indexWhere((e) => e.type?.toLowerCase() == 'episode' && e.grandparentRatingKey == gpKey);
        if (idx != -1) {
          if (result[idx].viewOffset == null && item.viewOffset != null) {
            result[idx] = item;
          }
          continue;
        }
      }
      result.add(item);
    }
    return result;
  }

  /// Get children of a metadata item (e.g., seasons for a show, episodes for a season)
  /// Uses cache when offline or as fallback on network error
  Future<List<PlexMetadataDto>> _getChildren(String ratingKey) async {
    final endpoint = '/library/metadata/$ratingKey/children';

    return await fetchWithCacheFallback<List<PlexMetadataDto>>(
          cacheKey: endpoint,
          networkCall: () => _http.get(endpoint),
          parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
          parseResponse: (response) => _extractMetadataList(response),
        ) ??
        [];
  }

  /// Get every episode beneath a show or season in one call — for a show
  /// this returns episodes across every season (no per-season walk), for a
  /// season the episodes directly. Mirrors [_getChildren]'s cache-fallback
  /// behaviour.
  ///
  /// Uses `/grandchildren` rather than `/allLeaves` because the live server
  /// returns 0 items for `/allLeaves` on a season — `/grandchildren` is the
  /// only endpoint Plex serves that one-shots both levels (and is also the
  /// recommended path for mini-series shows that set `skipChildren=true`,
  /// per the API docs).
  Future<List<PlexMetadataDto>> _getGrandchildren(String ratingKey) async {
    final endpoint = '/library/metadata/$ratingKey/grandchildren';

    return await fetchWithCacheFallback<List<PlexMetadataDto>>(
          cacheKey: endpoint,
          networkCall: () => _http.get(endpoint),
          parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
          parseResponse: (response) => _extractMetadataList(response),
        ) ??
        [];
  }

  /// Get extras for a metadata item (trailers, behind-the-scenes, etc.)
  /// Uses cache when offline or as fallback on network error
  Future<List<PlexMetadataDto>> _getExtras(String ratingKey) async {
    final endpoint = '/library/metadata/$ratingKey/extras';

    return await fetchWithCacheFallback<List<PlexMetadataDto>>(
          cacheKey: endpoint,
          networkCall: () => _http.get(endpoint),
          parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
          parseResponse: (response) => _extractMetadataList(response),
        ) ??
        [];
  }

  /// Get thumbnail URL
  String getThumbnailUrl(String? thumbPath) {
    if (thumbPath == null || thumbPath.isEmpty) return '';
    return _http.buildUri(thumbPath).toString().withPlexToken(config.token);
  }

  /// Download the full BIF (Base Index Frames) file for a given part.
  /// Returns the raw bytes, or null on failure.
  Future<Uint8List?> downloadBifFile(int partId) async {
    try {
      final bytes = await _http.getBytes(
        '${_http.baseUrl}/library/parts/$partId/indexes/sd',
        timeout: const Duration(seconds: 30),
      );
      if (bytes.isNotEmpty) return bytes;
      return null;
    } catch (_) {
      return null;
    }
  }

  /// Get chapters and markers from cached metadata or fetch if needed
  /// Uses same cache key as other metadata methods for consistency
  Future<PlaybackExtras> getPlaybackExtras(
    String ratingKey, {
    String? introPattern,
    String? creditsPattern,
    bool forceChapterFallback = false,
    bool forceRefresh = false,
  }) async {
    try {
      final fetch = forceRefresh ? fetchWithCacheFallback : fetchWithCacheFirst;
      final data = await fetch<Map<String, dynamic>>(
        cacheKey: '/library/metadata/$ratingKey',
        networkCall: () =>
            _http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}),
        parseCache: (cached) => cached as Map<String, dynamic>?,
        parseResponse: (response) => response.data as Map<String, dynamic>?,
      );
      final metadataJson = _getFirstMetadataJsonFromData(data);
      return _parsePlaybackExtrasFromMetadataJson(
        metadataJson,
        introPattern: introPattern,
        creditsPattern: creditsPattern,
        forceChapterFallback: forceChapterFallback,
      );
    } catch (e) {
      appLogger.w('Failed to get playback extras', error: e);
      return PlaybackExtras(chapters: [], markers: []);
    }
  }

  /// Parse PlaybackExtras from metadata JSON
  PlaybackExtras _parsePlaybackExtrasFromMetadataJson(
    Map<String, dynamic>? metadataJson, {
    String? introPattern,
    String? creditsPattern,
    bool forceChapterFallback = false,
  }) => plexPlaybackExtrasFromCacheJson(
    metadataJson,
    introPattern: introPattern,
    creditsPattern: creditsPattern,
    forceChapterFallback: forceChapterFallback,
  );

  /// Parse video playback data from raw metadata JSON (no network call).
  /// Used by [getVideoPlaybackData] to avoid redundant fetches when the
  /// response is already available.
  PlexVideoPlaybackData parseVideoPlaybackDataFromJson(Map<String, dynamic>? metadataJson, {int mediaIndex = 0}) {
    return parsePlexVideoPlaybackDataFromJson(
      metadataJson,
      baseUrl: config.baseUrl,
      token: config.token,
      mediaIndex: mediaIndex,
      onVersionFallback: (requested, fallback) {
        appLogger.w('Version $requested inaccessible/missing — falling back to version $fallback');
      },
    );
  }

  /// Get consolidated video playback data (URL, media info, versions, and markers) in a single API call.
  /// This is the primary method for playback initialization.
  /// Uses cache for offline mode support and network fallback.
  Future<PlexVideoPlaybackData> getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async {
    Map<String, dynamic>? data;
    try {
      data = await fetchWithCacheFallback<Map<String, dynamic>>(
        cacheKey: '/library/metadata/$ratingKey',
        // checkFiles=1 populates Part.accessible/exists so we can skip
        // deleted-but-still-indexed versions before play.
        networkCall: () => _http.get(
          '/library/metadata/$ratingKey',
          queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1},
        ),
        parseCache: (cached) => cached as Map<String, dynamic>?,
        parseResponse: (response) => response.data as Map<String, dynamic>?,
      );
    } catch (_) {
      // Gracefully degrade: return empty playback data on total failure
    }
    final metadataJson = _getFirstMetadataJsonFromData(data);
    return parseVideoPlaybackDataFromJson(metadataJson, mediaIndex: mediaIndex);
  }

  /// Get file information for a media item.
  ///
  /// Uses cache for offline mode support and network fallback. Wires the
  /// neutral [MediaServerClient.getFileInfo] override below.
  @override
  Future<MediaFileInfo?> getFileInfo(MediaItem item) => _fetchFileInfo(item.id);

  Future<MediaFileInfo?> _fetchFileInfo(String ratingKey) async {
    try {
      final data = await fetchWithCacheFirst<Map<String, dynamic>>(
        cacheKey: '/library/metadata/$ratingKey',
        networkCall: () =>
            _http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
        parseCache: (cached) => cached as Map<String, dynamic>?,
        parseResponse: (response) => response.data as Map<String, dynamic>?,
      );
      final metadataJson = _getFirstMetadataJsonFromData(data);

      return parsePlexFileInfoFromJson(metadataJson);
    } catch (e) {
      appLogger.e('Failed to get file info: $e');
      return null;
    }
  }

  /// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`).
  ///
  /// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or
  /// an empty list if the item has no external IDs / can't be fetched.
  /// Used by the Trakt integration to match Plex items against Trakt's catalog.
  Future<List<dynamic>> fetchExternalGuids(String ratingKey) async {
    try {
      final response = await _getWithFailover('/library/metadata/$ratingKey', queryParameters: {'includeGuids': 1});
      final data = response.data;
      if (data is! Map) return const [];
      final container = data['MediaContainer'] as Map?;
      final metadata = container?['Metadata'];
      if (metadata is! List || metadata.isEmpty) return const [];
      final first = metadata.first;
      if (first is! Map) return const [];
      final guids = first['Guid'];
      if (guids is List) return guids;
      return const [];
    } catch (e) {
      appLogger.d('fetchExternalGuids failed for $ratingKey', error: e);
      return const [];
    }
  }

  /// Mark media as watched
  ///
  /// If [item] is provided, emits a [WatchStateEvent] for UI updates.
  Future<void> markAsWatched(String ratingKey, {MediaItem? item}) async {
    await _getWithFailover(
      '/:/scrobble',
      queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'},
    );
    if (item != null) {
      WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
    }
  }

  /// Mark media as unwatched
  ///
  /// If [item] is provided, emits a [WatchStateEvent] for UI updates.
  Future<void> markAsUnwatched(String ratingKey, {MediaItem? item}) async {
    await _getWithFailover(
      '/:/unscrobble',
      queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'},
    );
    if (item != null) {
      WatchStateNotifier().notifyWatched(item: item, isNowWatched: false);
    }
  }

  /// Update playback progress
  Future<void> updateProgress(
    String ratingKey, {
    required int time,
    required String state, // 'playing', 'paused', 'stopped', 'buffering'
    int? duration,
  }) async {
    final response = await _http.post(
      '/:/timeline',
      queryParameters: {
        'ratingKey': ratingKey,
        'key': '/library/metadata/$ratingKey',
        'time': time,
        'state': state,
        'duration': ?duration,
      },
    );
    // Surface non-2xx instead of swallowing — progress is the cornerstone
    // of resume/Continue Watching, so silent failures hurt the user later.
    throwIfHttpError(response);
  }

  /// Remove item from Continue Watching (On Deck) without affecting watch status or progress
  /// This uses the same endpoint Plex Web uses to hide items from Continue Watching
  Future<void> removeFromOnDeck(String ratingKey) async {
    await _http.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey});
  }

  /// Delete a media item from the library
  /// This permanently removes the item and its associated files from the server
  /// Returns true if deletion was successful, false otherwise
  @override
  Future<bool> deleteMediaItem(MediaItem item) {
    return _wrapBoolApiCall(() => _http.delete('/library/metadata/${item.id}'), 'Failed to delete media item');
  }

  /// Parse a Plex Settings response into a map of id --> value.
  Map<String, dynamic> _parseSettingsMap(dynamic response) {
    final container = _getMediaContainer(response);
    if (container == null) return {};
    final settings = container['Setting'];
    if (settings == null) return {};
    final list = settings is List ? settings : [settings];
    return {for (final s in list) s['id'] as String: s['value']};
  }

  /// Fetch all server-level preferences and store them in [serverPrefs].
  ///
  /// Non-blocking: intended to be called fire-and-forget on connect.
  Future<void> fetchServerPrefs() async {
    try {
      final response = await _getWithFailover('/:/prefs');
      _serverPrefs = _parseSettingsMap(response);
    } catch (e) {
      appLogger.d('Failed to fetch server prefs: $e');
    }
  }

  /// Get preferences for a library section.
  ///
  /// Returns a map of setting id --> value for all settings in the library.
  Future<Map<String, dynamic>> getLibrarySectionPrefs(String sectionId) async {
    final response = await _getWithFailover('/library/sections/$sectionId/prefs');
    return _parseSettingsMap(response);
  }

  /// Get available filters for a library section
  Future<List<MediaFilter>> getLibraryFilters(String sectionId) async {
    if (sectionId == 'shared') return [];
    final response = await _getWithFailover('/library/sections/$sectionId/filters');
    return _extractDirectoryList(response, MediaFilter.fromJson);
  }

  /// Get first characters (alphabet index) for a library section
  Future<List<LibraryFirstCharacter>> getFirstCharacters(
    String sectionId, {
    int? type,
    Map<String, String>? filters,
  }) async {
    final queryParams = <String, dynamic>{};
    if (type != null) queryParams['type'] = type;
    if (filters != null) queryParams.addAll(filters);

    final response = await _getWithFailover(
      '/library/sections/$sectionId/firstCharacter',
      queryParameters: queryParams,
    );
    return _extractDirectoryList(response, (json) {
      // The Plex /firstCharacter endpoint returns rows with `key`/`title`/
      // `size` (size is a string in the wire payload).
      return LibraryFirstCharacter(
        key: (json['key'] as String?) ?? '',
        title: (json['title'] as String?) ?? '',
        size: int.tryParse((json['size'] ?? '').toString()) ?? 0,
      );
    });
  }

  /// Get filter values (e.g., list of genres, years, etc.)
  Future<List<MediaFilterValue>> getFilterValues(String filterKey) async {
    final response = await _getWithFailover(filterKey);
    return _extractDirectoryList(response, MediaFilterValue.fromJson);
  }

  /// Get available sort options for a library section
  ///
  /// If [libraryType] is provided (e.g., 'movie', 'show'), it's used for fallback
  /// sorts without needing to re-fetch the library sections list.
  @override
  Future<List<MediaSort>> fetchSortOptions(String sectionId, {String? libraryType}) async {
    if (sectionId == 'shared') {
      return [
        MediaSort(
          key: 'titleSort',
          descKey: 'titleSort:desc',
          title: t.libraries.sortLabels.title,
          defaultDirection: 'asc',
        ),
        MediaSort(
          key: 'taggingCreatedAt',
          descKey: 'taggingCreatedAt:desc',
          title: t.libraries.sortLabels.dateShared,
          defaultDirection: 'desc',
        ),
      ];
    }
    try {
      final response = await _getWithFailover('/library/sections/$sectionId/sorts');
      final sorts = _extractDirectoryList(response, MediaSort.fromJson);

      if (sorts.isNotEmpty) {
        return sorts;
      }

      // Fallback: return common sort options if API doesn't provide them
      return _getFallbackSorts(libraryType);
    } catch (e) {
      appLogger.e('Failed to get library sorts: $e');
      // Return fallback sort options on error
      return _getFallbackSorts(libraryType);
    }
  }

  /// Build fallback sort options based on library type.
  ///
  /// If [libraryType] is null, returns generic sorts without the show-specific options.
  List<MediaSort> _getFallbackSorts(String? libraryType) {
    final fallbackSorts = <MediaSort>[
      MediaSort(key: 'titleSort', title: t.libraries.sortLabels.title, defaultDirection: 'asc'),
      MediaSort(
        key: 'addedAt',
        descKey: 'addedAt:desc',
        title: t.libraries.sortLabels.dateAdded,
        defaultDirection: 'desc',
      ),
    ];

    // Add "Latest Episode Air Date" only for TV show libraries
    if (libraryType?.toLowerCase() == 'show') {
      fallbackSorts.add(
        MediaSort(
          key: 'episode.originallyAvailableAt',
          descKey: 'episode.originallyAvailableAt:desc',
          title: t.libraries.sortLabels.latestEpisodeAirDate,
          defaultDirection: 'desc',
        ),
      );
    }

    fallbackSorts.addAll([
      MediaSort(
        key: 'originallyAvailableAt',
        descKey: 'originallyAvailableAt:desc',
        title: t.libraries.sortLabels.releaseDate,
        defaultDirection: 'desc',
      ),
      MediaSort(key: 'rating', descKey: 'rating:desc', title: t.libraries.sortLabels.rating, defaultDirection: 'desc'),
    ]);

    return fallbackSorts;
  }

  /// Get library hubs (recommendations for a specific library section)
  /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc.
  Future<List<PlexHubDto>> _getLibraryHubs(String sectionId, {int limit = 10, String? libraryName}) async {
    try {
      final response = await retryTransientMediaServerCall(
        operation: 'Plex library hubs',
        attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
        call: (timeout, abort) => _getWithFailover(
          '/hubs/sections/$sectionId',
          queryParameters: {'count': limit, 'includeGuids': 1},
          timeout: timeout,
          abort: abort,
          allowEndpointFailover: false,
        ),
      );
      final sid = serverId;
      final sname = serverName;
      final data = response.data as Map<String, dynamic>;
      return await tryIsolateRun(
        () => _processHubResponse(
          data,
          sid,
          sname,
          librarySectionID: _librarySectionIdFromString(sectionId),
          librarySectionTitle: libraryName,
        ),
      );
    } catch (e) {
      appLogger.e('Failed to get library hubs: $e');
    }
    return [];
  }

  /// Get global hubs (home page recommendations)
  /// Returns actual home page hubs like "Recently Added Movies", "Recently Added TV", etc.
  /// This matches the official Plex client's home page layout.
  Future<List<PlexHubDto>> _getGlobalHubs({int limit = 10}) async {
    try {
      final hubKey = _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs';
      final response = await retryTransientMediaServerCall(
        operation: 'Plex global hubs',
        attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
        call: (timeout, abort) => _getWithFailover(
          hubKey,
          queryParameters: {'count': limit, 'includeGuids': 1},
          timeout: timeout,
          abort: abort,
          allowEndpointFailover: false,
        ),
      );
      final sid = serverId;
      final sname = serverName;
      final data = response.data as Map<String, dynamic>;
      return await tryIsolateRun(() => _processHubResponse(data, sid, sname));
    } catch (e) {
      appLogger.e('Failed to get global hubs: $e');
    }
    return [];
  }

  /// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor)
  Future<List<PlexHubDto>> _getRelatedHubs(String ratingKey, {int count = 10}) async {
    try {
      final response = await _getWithFailover(
        '/hubs/metadata/$ratingKey/related',
        queryParameters: {'count': count},
        allowEndpointFailover: false,
      );
      final sid = serverId;
      final sname = serverName;
      final data = response.data as Map<String, dynamic>;
      return await tryIsolateRun(
        () => _processHubResponse(
          data,
          sid,
          sname,
          filter: (item) {
            final type = item.type?.toLowerCase();
            return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection;
          },
        ),
      );
    } catch (e) {
      appLogger.e('Failed to get related hubs: $e');
    }
    return [];
  }

  /// Get full content from a hub using its hub key
  /// Returns the complete list of metadata items in the hub
  Future<List<PlexMetadataDto>> _getHubContent(String hubKey) async {
    final hubSectionID = _librarySectionIdFromString(hubKey);
    return _wrapListApiCall<PlexMetadataDto>(() => _http.get(hubKey), (response) {
      final allItems = _extractMetadataListWithLibrary(response, librarySectionID: hubSectionID);
      // Filter to only video content (movies, shows, seasons, episodes)
      return allItems.where((item) {
        return ContentTypes.videoTypes.contains(item.type?.toLowerCase());
      }).toList();
    }, 'Failed to get hub content');
  }

  /// Get playlist content by playlist ID, paginated.
  Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) =>
      _fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort);

  /// Fetch every page of a playlist's items. For callers that need the full list
  /// (downloads, sync rules, context-menu shuffle).
  Future<List<PlexMetadataDto>> _fetchAllPlaylistItemsDto(String playlistId) =>
      _fetchAllPages((start, size, abort) => _getPlaylist(playlistId, start: start, size: size, abort: abort));

  /// Get all playlists
  /// Filters by playlistType=video by default
  /// Set smart to true/false to filter smart playlists, or null for all
  Future<List<PlexPlaylistDto>> _getPlaylists({String playlistType = 'video', bool? smart}) {
    final queryParams = <String, dynamic>{
      'playlistType': playlistType,
      'X-Plex-Container-Size': _defaultListContainerSize,
    };
    if (smart != null) {
      queryParams['smart'] = smart ? '1' : '0';
    }

    return _wrapListApiCall<PlexPlaylistDto>(
      () => _http.get('/playlists', queryParameters: queryParams),
      _extractPlaylistList,
      'Failed to get playlists',
    );
  }

  /// Get playlist metadata by playlist ID
  /// Returns the playlist details (not the items)
  Future<PlexPlaylistDto?> _getPlaylistMetadata(String playlistId) async {
    try {
      final response = await _getWithFailover('/playlists/$playlistId');
      final container = _getMediaContainer(response);

      if (container == null || container['Metadata'] == null) {
        return null;
      }

      final List<dynamic> metadata = container['Metadata'] as List;

      if (metadata.isEmpty) {
        return null;
      }

      return PlexPlaylistDto.fromJson(metadata.first as Map<String, dynamic>);
    } catch (e) {
      appLogger.e('Failed to get playlist metadata: $e');
      return null;
    }
  }

  /// Neutral [MediaServerClient.createPlaylist] override — wraps
  /// [createPlaylistFromUri] after building a Plex metadata URI from
  /// the supplied items.
  @override
  Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
    if (items.isEmpty) {
      return createPlaylistFromUri(title: title);
    }
    final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
    return createPlaylistFromUri(title: title, uri: uri);
  }

  /// Create a new playlist
  /// [title] - Name of the playlist
  /// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234")
  /// [playQueueId] - Optional play queue ID to create playlist from
  ///
  /// Errors propagate to the caller (matches the [MediaServerClient]
  /// contract — throw on HTTP/transport failures, return `null` only when
  /// the server replied 2xx but with no usable playlist payload).
  Future<MediaPlaylist?> createPlaylistFromUri({required String title, String? uri, int? playQueueId}) async {
    final queryParams = <String, dynamic>{'type': 'video', 'title': title, 'smart': '0'};

    if (uri != null) {
      queryParams['uri'] = uri;
    }
    if (playQueueId != null) {
      queryParams['playQueueID'] = playQueueId.toString();
    }

    final response = await _http.post('/playlists', queryParameters: queryParams);
    throwIfHttpError(response);
    final container = _getMediaContainer(response);

    if (container == null || container['Metadata'] == null) {
      return null;
    }

    final List<dynamic> metadata = container['Metadata'] as List;

    if (metadata.isEmpty) {
      return null;
    }

    final dto = PlexPlaylistDto.fromJson(
      metadata.first as Map<String, dynamic>,
    ).copyWith(serverId: serverId, serverName: serverName);
    return PlexMappers.mediaPlaylist(dto);
  }

  /// Delete a playlist
  @override
  Future<bool> deletePlaylist(MediaPlaylist playlist) {
    return _wrapBoolApiCall(() => _http.delete('/playlists/${playlist.id}'), 'Failed to delete playlist');
  }

  /// Neutral [MediaServerClient.addToPlaylist] override — builds a Plex
  /// metadata URI from [items] and delegates to [addItemsToPlaylistByUri].
  @override
  Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items}) async {
    if (items.isEmpty) return true;
    final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
    return addItemsToPlaylistByUri(playlistId: playlistId, uri: uri);
  }

  /// Add items to a playlist
  /// [playlistId] - The playlist to add items to
  /// [uri] - Comma-separated list of item URIs to add
  Future<bool> addItemsToPlaylistByUri({required String playlistId, required String uri}) async {
    appLogger.d(
      'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
    );
    final result = await _wrapBoolApiCall(
      () => _http.put('/playlists/$playlistId/items', queryParameters: {'uri': uri}),
      'Failed to add to playlist',
    );
    if (result) {
      appLogger.d('Add to playlist response status: 200');
    }
    return result;
  }

  @override
  Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) {
    if (item is! PlexMediaItem || item.playlistItemId == null) return Future.value(false);
    return _wrapBoolApiCall(
      () => _http.delete('/playlists/$playlistId/items/${item.playlistItemId}'),
      'Failed to remove from playlist',
    );
  }

  /// Plex's `?after=0` sentinel means "move to the top". For any other index
  /// the API needs the playlist-item id of the row that should sit immediately
  /// before [item] after the move — that's what [afterItem] provides.
  @override
  Future<bool> movePlaylistItem({
    required String playlistId,
    required MediaItem item,
    required int newIndex,
    required MediaItem? afterItem,
  }) async {
    if (item is! PlexMediaItem || item.playlistItemId == null) return false;
    final int after;
    if (newIndex == 0) {
      after = 0;
    } else if (afterItem is PlexMediaItem && afterItem.playlistItemId != null) {
      after = afterItem.playlistItemId!;
    } else {
      return false;
    }
    appLogger.d('Moving playlist item ${item.playlistItemId} after $after in playlist $playlistId');
    return _wrapBoolApiCall(
      () => _http.put('/playlists/$playlistId/items/${item.playlistItemId}/move', queryParameters: {'after': after}),
      'Failed to move playlist item',
    );
  }

  /// Update metadata fields for a media item
  Future<bool> updateMetadata({
    required int sectionId,
    required String ratingKey,
    required int typeNumber,
    String? title,
    String? titleSort,
    String? originalTitle,
    String? originallyAvailableAt,
    String? contentRating,
    String? studio,
    String? tagline,
    String? summary,
    Map<String, ({List<String> current, List<String> original})>? tagChanges,
  }) {
    final queryParams = <String, dynamic>{'type': typeNumber, 'id': ratingKey};

    void addField(String name, String? value) {
      if (value != null) {
        queryParams['$name.value'] = value;
        queryParams['$name.locked'] = '1';
      }
    }

    addField('title', title);
    addField('titleSort', titleSort);
    addField('originalTitle', originalTitle);
    addField('originallyAvailableAt', originallyAvailableAt);
    addField('contentRating', contentRating);
    addField('studio', studio);
    addField('tagline', tagline);
    addField('summary', summary);

    if (tagChanges != null) {
      for (final entry in tagChanges.entries) {
        final field = entry.key;
        final current = entry.value.current;
        final original = entry.value.original;
        for (var i = 0; i < current.length; i++) {
          queryParams['$field[$i].tag.tag'] = current[i];
        }
        final removed = original.where((t) => !current.contains(t)).toList();
        if (removed.isNotEmpty) {
          queryParams['$field[].tag.tag-'] = removed.map(Uri.encodeComponent).join(',');
        }
        queryParams['$field.locked'] = '1';
      }
    }

    return _wrapBoolApiCall(
      () => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams),
      'Failed to update metadata',
    );
  }

  /// Search for match candidates for a media item.
  Future<List<PlexMatchResult>> findMatches(
    String ratingKey, {
    String? title,
    String? year,
    String? agent,
    String? language,
  }) async {
    final queryParams = <String, dynamic>{'manual': 1};
    if (title != null && title.isNotEmpty) queryParams['title'] = title;
    if (year != null && year.isNotEmpty) queryParams['year'] = year;
    if (agent != null && agent.isNotEmpty) queryParams['agent'] = agent;
    if (language != null && language.isNotEmpty) queryParams['language'] = language;

    return _wrapListApiCall<PlexMatchResult>(
      () => _getWithFailover('/library/metadata/$ratingKey/matches', queryParameters: queryParams),
      (response) {
        final container = _getMediaContainer(response);
        if (container == null || container['SearchResult'] == null) return [];
        return (container['SearchResult'] as List)
            .map((json) => PlexMatchResult.fromJson(json as Map<String, dynamic>))
            .toList();
      },
      'Failed to search for matches',
    );
  }

  /// Apply a chosen match to a media item.
  Future<bool> applyMatch(String ratingKey, {required String guid, String? name, String? year}) async {
    final queryParams = <String, dynamic>{'guid': guid};
    if (name != null && name.isNotEmpty) queryParams['name'] = name;
    if (year != null && year.isNotEmpty) queryParams['year'] = year;

    final result = await _wrapBoolApiCall(
      () => _http.put('/library/metadata/$ratingKey/match', queryParameters: queryParams),
      'Failed to apply match',
    );
    if (result) {
      await _cache.deleteForItem(serverId, ratingKey);
    }
    return result;
  }

  Future<bool> unmatchItem(String ratingKey) async {
    final result = await _wrapBoolApiCall(
      () => _http.put('/library/metadata/$ratingKey/unmatch'),
      'Failed to unmatch item',
    );
    if (result) {
      await _cache.deleteForItem(serverId, ratingKey);
    }
    return result;
  }

  /// Get available artwork (posters or backgrounds) for a media item
  Future<List<Map<String, dynamic>>> getAvailableArtwork(String ratingKey, String element) async {
    try {
      final response = await _getWithFailover('/library/metadata/$ratingKey/$element');
      final container = _getMediaContainer(response);
      if (container != null && container['Metadata'] != null) {
        return (container['Metadata'] as List).cast<Map<String, dynamic>>();
      }
      return [];
    } catch (e) {
      appLogger.e('Failed to get available artwork', error: e);
      return [];
    }
  }

  /// Set artwork from a URL (can be a Plex internal path or external URL)
  Future<bool> setArtworkFromUrl(String ratingKey, String element, String url) {
    final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
    return _wrapBoolApiCall(
      () => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}),
      'Failed to set artwork from URL',
    );
  }

  /// Upload artwork from binary data
  Future<bool> uploadArtwork(String ratingKey, String element, List<int> bytes) {
    final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
    return _wrapBoolApiCall(
      () => _http.put(
        '/library/metadata/$ratingKey/$setElement',
        body: bytes,
        headers: {'Content-Type': 'application/octet-stream', 'Content-Length': '${bytes.length}'},
      ),
      'Failed to upload artwork',
    );
  }

  /// Update per-media advanced preferences
  Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) {
    return _wrapBoolApiCall(
      () => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs),
      'Failed to update metadata preferences',
    );
  }

  /// Get all collections for a library section
  /// Returns collections as PlexMetadataDto objects with type="collection"
  Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
    return _wrapListApiCall<PlexMetadataDto>(
      () => _http.get(
        '/library/sections/$sectionId/collections',
        queryParameters: {'includeGuids': 1, 'X-Plex-Container-Size': _defaultListContainerSize},
      ),
      (response) {
        final allItems = _extractMetadataListWithLibrary(
          response,
          librarySectionID: _librarySectionIdFromString(sectionId),
        );
        // Collections should have type="collection"
        return allItems.where((item) {
          return item.type?.toLowerCase() == ContentTypes.collection;
        }).toList();
      },
      'Failed to get library collections',
    );
  }

  /// Get items in a collection, paginated.
  Future<_LibraryContentResult> _getCollectionItems(
    String collectionId, {
    int? start,
    int? size,
    AbortController? abort,
    String? librarySectionID,
    String? librarySectionTitle,
  }) => _fetchPaginatedList(
    '/library/collections/$collectionId/children',
    start: start,
    size: size,
    abort: abort,
    librarySectionID: _librarySectionIdFromString(librarySectionID),
    librarySectionTitle: librarySectionTitle,
  );

  /// Fetch every item in a collection (downloads, sync rules, context-menu shuffle).
  Future<List<PlexMetadataDto>> _fetchAllCollectionItemsDto(
    String collectionId, {
    String? librarySectionID,
    String? librarySectionTitle,
  }) => _fetchAllPages(
    (start, size, abort) => _getCollectionItems(
      collectionId,
      start: start,
      size: size,
      abort: abort,
      librarySectionID: librarySectionID,
      librarySectionTitle: librarySectionTitle,
    ),
  );

  /// Get media featuring a specific person (actor/director), paginated.
  Future<_LibraryContentResult> _getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) =>
      _fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort);

  /// Fetch every media item featuring a given person.
  Future<List<PlexMetadataDto>> _fetchAllPersonMediaDto(String personId) =>
      _fetchAllPages((start, size, abort) => _getPersonMedia(personId, start: start, size: size, abort: abort));

  /// Delete a collection. Reads the section id from [collection.libraryId].
  @override
  Future<bool> deleteCollection(MediaItem collection) async {
    final sectionId = collection.libraryId ?? '';
    return deleteCollectionById(sectionId, collection.id);
  }

  Future<bool> deleteCollectionById(String sectionId, String collectionId) async {
    appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId');
    final result = await _wrapBoolApiCall(
      () => _http.delete('/library/collections/$collectionId'),
      'Failed to delete collection',
    );
    if (result) {
      appLogger.d('Delete collection response: 200');
    }
    return result;
  }

  /// Neutral [MediaServerClient.createCollection] — builds a Plex metadata
  /// URI for [items] and maps [itemKind] to Plex's section type id.
  @override
  Future<String?> createCollection({
    required String libraryId,
    required String title,
    required List<MediaItem> items,
    MediaKind? itemKind,
  }) async {
    final uri = items.isEmpty ? '' : await buildMetadataUri(items.map((i) => i.id).join(','));
    final type = switch (itemKind) {
      MediaKind.movie => 1,
      MediaKind.show => 2,
      MediaKind.season => 3,
      MediaKind.episode => 4,
      _ => null,
    };
    return createCollectionFromUri(sectionId: libraryId, title: title, uri: uri, type: type);
  }

  /// Create a new collection
  /// Creates a new collection and optionally adds items to it
  /// Returns the created collection ID or null if failed
  Future<String?> createCollectionFromUri({
    required String sectionId,
    required String title,
    required String uri,
    int? type,
  }) async {
    try {
      appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type');
      final response = await _http.post(
        '/library/collections',
        queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri},
      );
      appLogger.d('Create collection response: ${response.statusCode}');

      // Extract the collection ID from the response
      // The response should contain the created collection metadata
      final container = _getMediaContainer(response);
      if (container != null) {
        final metadata = container['Metadata'];
        if (metadata != null && (metadata as List).isNotEmpty) {
          final collectionId = metadata.first['ratingKey']?.toString();
          appLogger.d('Created collection with ID: $collectionId');
          return collectionId;
        }
      }

      return null;
    } catch (e) {
      appLogger.e('Failed to create collection', error: e);
      return null;
    }
  }

  /// Neutral [MediaServerClient.addToCollection] — builds a Plex metadata URI
  /// from [items] and delegates to [addItemsToCollectionByUri].
  @override
  Future<bool> addToCollection({required String collectionId, required List<MediaItem> items}) async {
    if (items.isEmpty) return true;
    final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
    return addItemsToCollectionByUri(collectionId: collectionId, uri: uri);
  }

  /// Add items to an existing collection
  /// Adds one or more items (specified by URI) to an existing collection
  Future<bool> addItemsToCollectionByUri({required String collectionId, required String uri}) async {
    appLogger.d('Adding items to collection: collectionId=$collectionId');
    final result = await _wrapBoolApiCall(
      () => _http.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}),
      'Failed to add items to collection',
    );
    if (result) {
      appLogger.d('Add to collection response: 200');
    }
    return result;
  }

  /// Remove an item from a collection
  /// Removes a single item from an existing collection
  @override
  Future<bool> removeFromCollection({required String collectionId, required MediaItem item}) async {
    appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=${item.id}');
    final result = await _wrapBoolApiCall(
      () => _http.delete('/library/collections/$collectionId/items/${item.id}'),
      'Failed to remove item from collection',
    );
    if (result) {
      appLogger.d('Remove from collection response: 200');
    }
    return result;
  }

  /// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with
  /// MediaItem-typed entries.
  PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) {
    final container = data is Map && data['MediaContainer'] is Map
        ? data['MediaContainer'] as Map<String, dynamic>
        : data as Map<String, dynamic>;
    final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
    final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
    final metadata = container['Metadata'];
    List<MediaItem>? items;
    if (metadata is List) {
      items = [
        for (final e in metadata)
          if (e is Map<String, dynamic>)
            PlexMappers.mediaItem(
              _createTaggedMetadataWithLibrary(
                e,
                librarySectionID: containerSectionID,
                librarySectionTitle: containerSectionTitle,
              ),
            ),
      ];
    }
    return PlayQueueResponse(
      playQueueID: (container['playQueueID'] as num).toInt(),
      playQueueSelectedItemID: (container['playQueueSelectedItemID'] as num?)?.toInt(),
      playQueueSelectedItemOffset: (container['playQueueSelectedItemOffset'] as num?)?.toInt(),
      playQueueSelectedMetadataItemID: container['playQueueSelectedMetadataItemID'] as String?,
      playQueueShuffled: flexibleBool(container['playQueueShuffled']),
      playQueueSourceURI: container['playQueueSourceURI'] as String?,
      playQueueTotalCount: (container['playQueueTotalCount'] as num?)?.toInt(),
      playQueueVersion: (container['playQueueVersion'] as num).toInt(),
      size: (container['size'] as num?)?.toInt(),
      items: items,
    );
  }

  /// Create a new play queue
  /// Either uri or playlistID must be specified
  Future<PlayQueueResponse?> createPlayQueue({
    String? uri,
    int? playlistID,
    required String type,
    String? key,
    int shuffle = 0,
    int repeat = 0,
    int continuous = 0,
    String? librarySectionID,
    String? librarySectionTitle,
  }) async {
    try {
      final queryParams = <String, dynamic>{
        'type': type,
        'shuffle': shuffle,
        'repeat': repeat,
        'continuous': continuous,
      };

      if (uri != null) {
        queryParams['uri'] = uri;
      }
      if (playlistID != null) {
        queryParams['playlistID'] = playlistID;
      }
      if (key != null) {
        queryParams['key'] = key;
      }

      final response = await _http.post('/playQueues', queryParameters: queryParams);

      return _parsePlayQueueResponse(
        response.data,
        librarySectionID: _librarySectionIdFromString(librarySectionID),
        librarySectionTitle: librarySectionTitle,
      );
    } catch (e) {
      appLogger.e('Failed to create play queue', error: e);
      return null;
    }
  }

  /// Get a play queue with optional windowing
  /// Can request a window of items around a specific item
  Future<PlayQueueResponse?> getPlayQueue(
    int playQueueId, {
    String? center,
    int window = 50,
    int includeBefore = 1,
    int includeAfter = 1,
    String? librarySectionID,
    String? librarySectionTitle,
  }) async {
    try {
      final queryParams = <String, dynamic>{
        'window': window,
        'includeBefore': includeBefore,
        'includeAfter': includeAfter,
      };

      if (center != null) {
        queryParams['center'] = center;
      }

      final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams);

      return _parsePlayQueueResponse(
        response.data,
        librarySectionID: _librarySectionIdFromString(librarySectionID),
        librarySectionTitle: librarySectionTitle,
      );
    } catch (e) {
      appLogger.e('Failed to get play queue: $e');
      return null;
    }
  }

  /// Create a play queue for a TV show (all episodes)
  ///
  /// This is a convenience method that creates a play queue from a show's URI.
  /// Perfect for sequential or shuffle playback of an entire series.
  ///
  /// Parameters:
  /// - [showRatingKey]: The rating key of the show
  /// - [shuffle]: Whether to shuffle the episodes (0 = off, 1 = on)
  /// - [startingEpisodeKey]: Optional rating key of episode to start from
  ///
  /// Returns a PlayQueueResponse with all episodes from the show
  Future<PlayQueueResponse?> createShowPlayQueue({
    required String showRatingKey,
    int shuffle = 0,
    String? startingEpisodeKey,
    String? librarySectionID,
    String? librarySectionTitle,
  }) async {
    try {
      final machineId = config.machineIdentifier ?? await getMachineIdentifier();
      if (machineId == null) {
        throw Exception('Could not get server machine identifier');
      }

      final uri = 'server://$machineId/com.plexapp.plugins.library/library/metadata/$showRatingKey/children';
      return await createPlayQueue(
        uri: uri,
        type: 'video',
        shuffle: shuffle,
        key: startingEpisodeKey != null ? '/library/metadata/$startingEpisodeKey' : null,
        continuous: startingEpisodeKey != null && shuffle == 0 ? 1 : 0,
        librarySectionID: librarySectionID,
        librarySectionTitle: librarySectionTitle,
      );
    } catch (e) {
      appLogger.e('Failed to create show play queue', error: e);
      return null;
    }
  }

  /// Extract both Metadata and Directory entries from response
  /// Folders can come back as either type
  /// Automatically tags all items with this client's serverId and serverName
  List<PlexMetadataDto> _extractMetadataAndDirectories(
    MediaServerResponse response, {
    int? librarySectionID,
    String? librarySectionTitle,
  }) {
    final List<PlexMetadataDto> items = [];
    final container = _getMediaContainer(response);

    if (container != null) {
      final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
      final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
      // Extract Metadata entries - try full parsing first
      if (container['Metadata'] != null) {
        for (final json in container['Metadata'] as List) {
          try {
            // Try to parse with full PlexMetadataDto.fromJson first
            items.add(
              _createTaggedMetadataWithLibrary(
                json as Map<String, dynamic>,
                librarySectionID: containerSectionID,
                librarySectionTitle: containerSectionTitle,
              ),
            );
          } catch (e) {
            // If full parsing fails, use minimal safe parsing
            appLogger.d('Using minimal parsing for metadata item: $e');
            try {
              items.add(
                PlexMetadataDto(
                  ratingKey: json['key'] ?? json['ratingKey'] ?? '',
                  key: json['key'] ?? '',
                  type: json['type'] ?? 'folder',
                  title: json['title'] ?? 'Untitled',
                  thumb: json['thumb'],
                  art: json['art'],
                  year: json['year'],
                  librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID,
                  librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle,
                  serverId: serverId,
                  serverName: serverName,
                ),
              );
            } catch (e2) {
              appLogger.e('Failed to parse metadata item: $e2');
            }
          }
        }
      }

      // Extract Directory entries (folders)
      if (container['Directory'] != null) {
        for (final json in container['Directory'] as List) {
          try {
            // Try to parse as PlexMetadataDto first
            items.add(
              _createTaggedMetadataWithLibrary(
                json as Map<String, dynamic>,
                librarySectionID: containerSectionID,
                librarySectionTitle: containerSectionTitle,
              ),
            );
          } catch (e) {
            // If that fails, use minimal folder representation
            try {
              items.add(
                PlexMetadataDto(
                  ratingKey: json['key'] ?? json['ratingKey'] ?? '',
                  key: json['key'] ?? '',
                  type: json['type'] ?? 'folder',
                  title: json['title'] ?? 'Untitled',
                  thumb: json['thumb'],
                  art: json['art'],
                  librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID,
                  librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle,
                  serverId: serverId,
                  serverName: serverName,
                ),
              );
            } catch (e2) {
              appLogger.e('Failed to parse directory item: $e2');
            }
          }
        }
      }
    }

    return items;
  }

  /// Get root folders for a library section
  /// Returns the top-level folder structure for filesystem-based browsing
  Future<List<PlexMetadataDto>> _getLibraryFolders(String sectionId) async {
    try {
      final response = await _getWithFailover(
        '/library/sections/$sectionId/folder',
        queryParameters: {'includeCollections': 0},
      );
      return _extractMetadataAndDirectories(response, librarySectionID: _librarySectionIdFromString(sectionId));
    } catch (e) {
      appLogger.e('Failed to get library folders: $e');
      return [];
    }
  }

  /// Get children of a specific folder
  /// Returns files and subfolders within the given folder
  Future<List<PlexMetadataDto>> _getFolderChildren(
    String folderKey, {
    String? librarySectionID,
    String? librarySectionTitle,
  }) async {
    try {
      final response = await _getWithFailover(folderKey);
      return _extractMetadataAndDirectories(
        response,
        librarySectionID: _librarySectionIdFromString(folderKey) ?? _librarySectionIdFromString(librarySectionID),
        librarySectionTitle: librarySectionTitle,
      );
    } catch (e) {
      appLogger.e('Failed to get folder children: $e');
      return [];
    }
  }

  /// Get library-specific playlists
  /// Filters playlists by checking if they contain items from the specified library
  /// This is a client-side filter since the API doesn't support sectionId for playlists
  Future<List<PlexPlaylistDto>> _getLibraryPlaylists({String playlistType = 'video'}) {
    // For now, return all video playlists
    // Future enhancement: filter by checking playlist items' library
    return _getPlaylists(playlistType: playlistType);
  }

  /// Scan/refresh a library section to detect new files
  Future<void> scanLibrary(String sectionId) async {
    await _getWithFailover('/library/sections/$sectionId/refresh');
  }

  /// Refresh metadata for a library section
  @override
  Future<void> refreshLibraryMetadata(String sectionId) async {
    await _getWithFailover('/library/sections/$sectionId/refresh?force=1');
  }

  /// Empty trash for a library section
  Future<void> emptyLibraryTrash(String sectionId) async {
    await _http.put('/library/sections/$sectionId/emptyTrash');
  }

  /// Analyze library section
  Future<void> analyzeLibrary(String sectionId) async {
    await _getWithFailover('/library/sections/$sectionId/analyze');
  }

  /// Generate 24-char random alphanumeric string. Backend-neutral helper —
  /// prefer importing `utils/session_identifier.dart` directly. This thin
  /// forwarder stays for callers that already had a `PlexClient.` reference;
  /// remove it once they migrate.
  static String generateSessionIdentifier() => session_id.generateSessionIdentifier();

  /// Coerce String values to num for fields that json_serializable expects as num.
  /// Plex tune responses use XML-to-JSON conversion where all values are strings.
  static void _coerceNumericFields(Map<String, dynamic> json) {
    const numericKeys = [
      'duration',
      'year',
      'addedAt',
      'updatedAt',
      'lastViewedAt',
      'parentIndex',
      'index',
      'viewOffset',
      'viewCount',
      'leafCount',
      'viewedLeafCount',
      'childCount',
      'rating',
      'audienceRating',
      'userRating',
      'ratingCount',
      'skipCount',
      'lastRatedAt',
    ];
    for (final key in numericKeys) {
      final val = json[key];
      if (val is String) {
        json[key] = num.tryParse(val);
      }
    }
  }

  /// Checks whether the server has video transcoding enabled.
  ///
  /// Reads `transcoderVideo` from the root MediaContainer. Result is cached
  /// for the lifetime of this [PlexClient]. Returns `true` on error (fail-open)
  /// — the transcode decision call itself will fail gracefully if transcoding
  /// really is unavailable.
  Future<bool> serverSupportsVideoTranscoding() {
    final cached = _serverTranscoderCached;
    if (cached != null) return Future.value(cached);
    return _serverTranscoderPending ??= _fetchTranscoderCapability();
  }

  /// Synchronous view of the probe — returns the cached value, or `true`
  /// (assume supported) if the post-connect warm-up hasn't landed yet. The
  /// transcode decision call has its own fallback path, so guessing wrong
  /// here just routes through that fallback instead of blocking playback.
  bool get serverSupportsVideoTranscodingCached => _serverTranscoderCached ?? true;

  Future<bool> _fetchTranscoderCapability() async {
    try {
      // Tight timeout: `/` returns a tiny MediaContainer — any responsive
      // server answers in well under a second. Inheriting the default 120 s
      // receive timeout would keep a hung server from ever resolving.
      final response = await _http.get('/', timeout: const Duration(seconds: 5));
      final container = _getMediaContainer(response);
      final value = container?['transcoderVideo'];
      final supported = flexibleBool(value);
      _serverTranscoderCached = supported;
      return supported;
    } catch (e) {
      appLogger.w('Failed to query server transcoder capability', error: e);
      _serverTranscoderCached = true;
      return true;
    }
  }

  /// Build a VOD transcode stream URL (decision + start path).
  ///
  /// Mirrors [buildLiveStreamPath] but for on-demand video with a quality
  /// preset, selected audio stream, and HLS protocol. Plex returns a single
  /// audio track and no subtitle tracks in the transcoded stream — callers
  /// are expected to sidecar additional subtitles separately.
  ///
  /// [transcodeSessionId] and [sessionIdentifier] should be reused across
  /// seeks + quality/version/audio switches within one playback so the
  /// server-side transcode session is preserved.
  Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({
    required String ratingKey,
    required int mediaIndex,
    required TranscodeQualityPreset preset,
    required String sessionIdentifier,
    required String transcodeSessionId,
    int? audioStreamId,
    int? offsetMs,
  }) async {
    try {
      final isOriginal = preset.isOriginal;
      final metadataPath = '/library/metadata/$ratingKey';

      // Build the client profile from scratch via X-Plex-Client-Profile-Extra.
      // We use the `Generic` base platform (see [_transcodePlatformName]) which
      // has no pre-installed transcode targets, so we must `add-transcode-target`
      // rather than `append-transcode-target-codec` (which only edits existing
      // targets — empty on Generic, hence Plex returned decision code 2000
      // "neither direct play nor conversion is available").
      //
      // For non-original presets we also add a bitrate limitation that caps
      // the video codec; with `replace=true` it overrides any default limit.
      //
      // See openapi.md §"Profile Augmentations" for the DSL reference.
      final profileExtraClauses = <String>[];
      if (!isOriginal && preset.videoBitrateKbps != null) {
        profileExtraClauses.add(
          'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
          '&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)',
        );
      }
      // Declare both h264 and hevc as allowed transcode targets. In practice
      // Plex's decision engine strongly prefers h264 for HLS output, so hevc
      // only gets chosen in edge cases (e.g. HDR content where the server
      // wants to preserve dynamic range). The codec-list comma is pre-encoded
      // as `%2C` — see the profile-extra encoding note above.
      profileExtraClauses.add(
        'add-transcode-target(type=videoProfile&context=streaming'
        '&protocol=hls&container=mpegts&videoCodec=h264%2Chevc&audioCodec=aac)',
      );
      final clientProfileExtra = profileExtraClauses.join('+');

      // HLS protocol: seekable via manifest segments. We started with `dash`
      // (what Plex Web on Chrome uses) but Plex's server only has DASH
      // transcode profiles for Chrome/Firefox/Safari/Opera — mobile/desktop
      // platforms fall through with "No conversion profile found for
      // protocol dash". HLS profiles exist for every Plex-accepted platform.
      final allParams = <String, String>{
        'hasMDE': '1',
        'path': metadataPath,
        'mediaIndex': mediaIndex.toString(),
        'partIndex': '0',
        'protocol': 'hls',
        'fastSeek': '1',
        'directPlay': isOriginal ? '1' : '0',
        'directStream': isOriginal ? '1' : '0',
        'subtitleSize': '100',
        'audioBoost': '100',
        'location': 'lan',
        if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(),
        'addDebugOverlay': '0',
        'autoAdjustQuality': '0',
        'directStreamAudio': '0',
        'mediaBufferSize': '102400',
        'session': transcodeSessionId,
        // Subtitles are delivered as client-side sidecars (see
        // [PlaybackInitializationService._buildTranscodeSidecarSubtitles]).
        // `subtitles=none` makes the server set the subtitle decision to
        // `ignore`, so nothing is embedded or burned into the video stream.
        'subtitles': 'none',
        // Preserve source timestamps in the transcoded segments. Without it,
        // Plex resets segment PTS to 0 — so mpv shows 0:00 and sidecar
        // subtitles desync even though the server is transcoding from the
        // `offset` position. With copyts=1 the first segment's PTS equals
        // the source offset and the player's clock lines up with source time.
        'copyts': '1',
        if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
        'Accept-Language': 'en',
        'X-Plex-Session-Identifier': sessionIdentifier,
        'X-Plex-Client-Profile-Extra': clientProfileExtra,
        'X-Plex-Incomplete-Segments': '1',
        'X-Plex-Features': 'external-media,indirect-media',
        'X-Plex-Model': 'standalone',
        'X-Plex-Language': 'en',
        'X-Plex-Product': config.product,
        'X-Plex-Version': config.version,
        'X-Plex-Client-Identifier': config.clientIdentifier,
        // Plex's server rejects unknown platform names with HTTP 400 and maps
        // known names to codec/bitrate base profiles. Our usual "Flutter"
        // platform, plus "MacOSX" / "Linux", are all rejected; swap to a
        // Plex-recognized name just for transcode requests. See
        // [_transcodePlatformName] for the mapping.
        'X-Plex-Platform': _transcodePlatformName(),
        if (config.device != null) 'X-Plex-Device': config.device!,
        if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
        if (config.token != null) 'X-Plex-Token': config.token!,
      };

      final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');

      final decisionClient = MediaServerHttpClient(
        connectTimeout: MediaServerTimeouts.connect,
        receiveTimeout: MediaServerTimeouts.receive,
        defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'},
      );
      try {
        final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString';
        final decisionResponse = await decisionClient.get(decisionUrl);

        final decisionBody = decisionResponse.data?.toString() ?? '<empty>';
        appLogger.i(
          'Transcode decision [${decisionResponse.statusCode}] body: '
          '${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}',
        );

        if (decisionResponse.statusCode != 200) {
          appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
          return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
        }

        final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal);
        if (outcome == TranscodeDecisionOutcome.failed) {
          return (startPath: null, outcome: outcome);
        }

        final startParams = Map<String, String>.from(allParams)..remove('X-Plex-Token');
        final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');

        // `.m3u8` tells the server to return an HLS manifest.
        return (startPath: '/video/:/transcode/universal/start.m3u8?$startQuery', outcome: outcome);
      } finally {
        decisionClient.close();
      }
    } catch (e, st) {
      appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
      return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
    }
  }

  /// Platform name Plex Media Server accepts on the transcode decision
  /// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400,
  /// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected.
  /// `Generic` is accepted and comes with no preset transcode targets — we
  /// build the profile ourselves via `X-Plex-Client-Profile-Extra` with
  /// `add-transcode-target`.
  static String _transcodePlatformName() => 'Generic';

  /// Strict percent-encoder matching Plex Web's URL encoder — escapes the
  /// extra characters `(`, `)`, `*`, `'`, `!` that Dart's [Uri.encodeComponent]
  /// leaves literal. Required for `X-Plex-Client-Profile-Extra` whose parens
  /// and asterisks must appear as `%28`, `%29`, `%2A` on the wire.
  static String _plexEncode(String value) {
    return Uri.encodeComponent(value)
        .replaceAll('(', '%28')
        .replaceAll(')', '%29')
        .replaceAll('*', '%2A')
        .replaceAll("'", '%27')
        .replaceAll('!', '%21');
  }

  /// Parse decision response for outcome. Any decision code >= 2000 = error
  /// (matching Plex Web's error detector).
  TranscodeDecisionOutcome _parseTranscodeDecisionOutcome(dynamic data, {required bool isOriginal}) {
    try {
      Map<String, dynamic>? container;
      if (data is Map && data['MediaContainer'] is Map) {
        container = Map<String, dynamic>.from(data['MediaContainer'] as Map);
      } else if (data is Map<String, dynamic>) {
        container = data;
      }
      if (container == null) return TranscodeDecisionOutcome.failed;

      final general = flexibleInt(container['generalDecisionCode']);
      final transcode = flexibleInt(container['transcodeDecisionCode']);
      final mde = flexibleInt(container['mdeDecisionCode']);

      bool isError(int? code) => code != null && code >= 2000;
      if (isError(general) || isError(transcode) || isError(mde)) {
        appLogger.w('Transcode decision error codes: general=$general transcode=$transcode mde=$mde');
        return TranscodeDecisionOutcome.failed;
      }

      if (isOriginal) return TranscodeDecisionOutcome.transcodeOk;

      if (transcode == 1000) return TranscodeDecisionOutcome.directPlayOnly;
      if (transcode == 1001) return TranscodeDecisionOutcome.transcodeOk;
      if (general == 1001) return TranscodeDecisionOutcome.transcodeOk;
      if (general == 1000) return TranscodeDecisionOutcome.directPlayOnly;

      return TranscodeDecisionOutcome.transcodeOk;
    } catch (e) {
      appLogger.w('Failed to parse transcode decision', error: e);
      return TranscodeDecisionOutcome.failed;
    }
  }

  Future<void> _handleEndpointSwitch(String newBaseUrl, {bool persist = true}) async {
    if (config.baseUrl == newBaseUrl) {
      return;
    }

    appLogger.i('Applying Plex endpoint switch', error: newBaseUrl);
    _http.baseUrl = newBaseUrl;
    config = config.copyWith(baseUrl: newBaseUrl);
    LogRedactionManager.registerServerUrl(newBaseUrl);

    if (persist && _onEndpointChanged != null) {
      await _onEndpointChanged(newBaseUrl);
    }
  }

  /// Apply a fresh per-server access token to this client *in place*. Used
  /// by [MultiServerManager.refreshTokensForProfile] when switching the
  /// active profile so the existing client picks up the new user's
  /// identity without a teardown / reconnect.
  ///
  /// Updates both `config.token` and `_http.defaultHeaders` — without the
  /// header refresh the next request still sends the previous user's
  /// `X-Plex-Token`, so the server returns the *previous* user's view of
  /// On Deck / hubs / watch state.
  Future<void> applyTokenUpdate(String newToken) async {
    if (config.token == newToken) return;
    config = config.copyWith(token: newToken);
    _http.defaultHeaders = Map.of(config.headers);
    LogRedactionManager.registerToken(newToken);
    await _initMediaProviders();
  }

  // ────────────────────────────────────────────────────────────────────
  // MediaServerClient implementation
  //
  // These methods wrap the existing Plex-typed methods above and return
  // backend-neutral types. They form a thin façade so providers and UI can
  // be migrated off `PlexMetadataDto` without changing the underlying transport.
  // ────────────────────────────────────────────────────────────────────

  @override
  MediaBackend get backend => MediaBackend.plex;

  @override
  ServerCapabilities get capabilities => ServerCapabilities.plex.copyWith(
    // Per-server probe: not every Plex install ships with a working
    // transcoder (depends on Plex Pass + sufficient hardware). The
    // cached value defaults to `true` until [serverSupportsVideoTranscoding]
    // resolves — kicked off as a background probe at the end of
    // [PlexClient.create] so the first quality-picker tap reflects
    // reality on warm clients.
    videoTranscoding: serverSupportsVideoTranscodingCached,
  );

  @override
  Future<List<MediaLibrary>> fetchLibraries() async {
    final libraries = await _getLibraries();
    return libraries.map((l) => PlexMappers.mediaLibrary(l)).toList();
  }

  @override
  Future<LibraryPage<MediaItem>> fetchLibraryContent(String libraryId, LibraryQuery query) async {
    final filters = const PlexLibraryQueryTranslator().toQueryParameters(query);
    final result = await _getLibraryContent(libraryId, start: query.offset, size: query.limit, filters: filters);
    return LibraryPage<MediaItem>(
      items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
      totalCount: result.totalSize,
      offset: query.offset,
    );
  }

  @override
  Future<MediaItem?> fetchItem(String id) async {
    final metadata = await _getMetadataWithImages(id);
    return metadata == null ? null : PlexMappers.mediaItem(metadata);
  }

  @override
  Future<List<MediaItem>> fetchChildren(String parentId) async {
    final children = await _getChildren(parentId);
    return children.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  @override
  Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
    final leaves = await _getGrandchildren(parentId);
    return leaves.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  /// Plex maintains episode queues server-side via `/playQueues`, so the
  /// client-side window EpisodeNavigationService builds for Jellyfin isn't
  /// needed here.
  @override
  Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async => null;

  /// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata,
  /// then either runs the transcode-decision flow or returns the direct-play
  /// URL. External subtitle tracks are absolutized with the server's auth
  /// token; when transcoding, every source subtitle is sidecar-attached so
  /// the player can hot-swap.
  @override
  Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
    try {
      final data = await getVideoPlaybackData(options.metadata.id, mediaIndex: options.selectedMediaIndex);

      if (!data.hasValidVideoUrl) {
        throw PlaybackException(t.messages.fileInfoNotAvailable);
      }

      final wantTranscode = !options.qualityPreset.isOriginal;
      if (wantTranscode && options.sessionIdentifier != null && options.transcodeSessionId != null) {
        final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
        // Note: no `offsetMs` — seeking is handled by the player via the HLS
        // manifest, matching Plex Web's behavior. Baking `offset=` into the URL
        // makes the server pre-position the transcoder, but the resulting
        // segments and mpv's native HLS positioning fight each other, leaving
        // the player clock at 0 and desyncing sidecar subtitles.
        final result = await buildTranscodeStartPath(
          ratingKey: options.metadata.id,
          mediaIndex: options.selectedMediaIndex,
          preset: options.qualityPreset,
          sessionIdentifier: options.sessionIdentifier!,
          transcodeSessionId: options.transcodeSessionId!,
          audioStreamId: resolvedAudioId,
        );

        if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
          final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
          final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo);
          return PlaybackInitializationResult(
            availableVersions: data.availableVersions,
            videoUrl: transcodeUrl,
            mediaInfo: data.mediaInfo,
            externalSubtitles: sidecarSubs,
            isOffline: false,
            isTranscoding: true,
            activeAudioStreamId: resolvedAudioId,
            playMethod: 'Transcode',
          );
        }

        // Decision failed or said direct-play only — fall through to direct-play path
        // and surface the fallback reason so the UI can notify the user.
        final fallbackReason = result.outcome == TranscodeDecisionOutcome.directPlayOnly
            ? TranscodeFallbackReason.directPlayOnly
            : TranscodeFallbackReason.decisionFailed;
        appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}');
        return PlaybackInitializationResult(
          availableVersions: data.availableVersions,
          videoUrl: data.videoUrl,
          mediaInfo: data.mediaInfo,
          externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
          isOffline: false,
          isTranscoding: false,
          fallbackReason: fallbackReason,
          playMethod: 'DirectPlay',
        );
      }

      return PlaybackInitializationResult(
        availableVersions: data.availableVersions,
        videoUrl: data.videoUrl,
        mediaInfo: data.mediaInfo,
        externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
        isOffline: false,
        playMethod: 'DirectPlay',
      );
    } catch (e) {
      if (e is PlaybackException) rethrow;
      throw PlaybackException(t.messages.errorLoading(error: e.toString()));
    }
  }

  /// Pick the audio stream ID to send to the transcoder. Preference order:
  /// explicit [explicit] → audio track with `selected == true` → first → null.
  int? _resolveAudioStreamId(int? explicit, MediaSourceInfo? info) {
    if (explicit != null) return explicit;
    if (info == null) return null;
    final tracks = info.audioTracks;
    if (tracks.isEmpty) return null;
    for (final track in tracks) {
      if (track.selected) return track.id;
    }
    return tracks.first.id;
  }

  /// Build the absolute URL for an external subtitle track on this Plex
  /// server. Returns `null` for tracks that aren't external (no `/library/
  /// streams/{id}` key) or when the server has no auth token.
  ///
  /// Used by the in-player OpenSubtitles polling flow which needs the URL
  /// after the new track shows up in the metadata response.
  String? buildExternalSubtitleUrl(MediaSubtitleTrack track) {
    if (!track.isExternal) return null;
    final token = config.token;
    if (token == null) return null;
    final ext = CodecUtils.getSubtitleExtension(track.codec);
    return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token';
  }

  /// Sidecar URL for any subtitle track (internal or external), used in
  /// transcode mode where embedded subtitle streams are stripped. Falls
  /// back to `/library/streams/{id}.{ext}` when [track.key] is missing.
  /// Returns `null` when no auth token is available.
  String? _buildSidecarSubtitleUrl(MediaSubtitleTrack track) {
    final token = config.token;
    if (token == null) return null;
    final ext = CodecUtils.getSubtitleExtension(track.codec);
    final path = (track.key != null && track.key!.isNotEmpty) ? track.key! : '/library/streams/${track.id}';
    return '${config.baseUrl}$path.$ext?encoding=utf-8&X-Plex-Token=$token';
  }

  /// Build sidecar SubtitleTracks for ALL source subtitle streams (internal +
  /// external) so the player can hot-swap between them when the main stream
  /// is transcoded and has no embedded subs.
  List<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
    if (mediaInfo == null) return const [];
    if (config.token == null) {
      appLogger.w('No auth token available for transcode sidecar subtitles');
      return const [];
    }

    final tracks = <SubtitleTrack>[];
    for (final sub in mediaInfo.subtitleTracks) {
      try {
        final url = _buildSidecarSubtitleUrl(sub);
        if (url == null) continue;
        tracks.add(
          SubtitleTrack.uri(
            url,
            title: sub.displayTitle ?? sub.language ?? 'Track ${sub.id}',
            language: sub.languageCode,
          ),
        );
      } catch (e) {
        appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
      }
    }
    return tracks;
  }

  /// Build list of external subtitle tracks from media info
  List<SubtitleTrack> _buildExternalSubtitles(MediaSourceInfo? mediaInfo) {
    final externalSubtitles = <SubtitleTrack>[];

    if (mediaInfo == null) {
      return externalSubtitles;
    }

    final externalTracks = mediaInfo.subtitleTracks.where((MediaSubtitleTrack track) => track.isExternal).toList();

    if (externalTracks.isNotEmpty) {
      appLogger.d('Found ${externalTracks.length} external subtitle track(s)');
    }

    for (final plexTrack in externalTracks) {
      try {
        final url = buildExternalSubtitleUrl(plexTrack);
        if (url == null) {
          appLogger.w('Could not build URL for external subtitle ${plexTrack.id}');
          continue;
        }

        externalSubtitles.add(
          SubtitleTrack.uri(
            url,
            title: plexTrack.displayTitle ?? plexTrack.language ?? 'Track ${plexTrack.id}',
            language: plexTrack.languageCode,
          ),
        );
      } catch (e) {
        appLogger.w('Failed to add external subtitle track ${plexTrack.id}', error: e);
      }
    }

    return externalSubtitles;
  }

  /// Plex's filter listing is lazy: categories come from
  /// `/library/sections/{id}/filters` and values are fetched per category
  /// when the user opens a filter. The result has empty [LibraryFilterResult.cachedValues];
  /// the FiltersBottomSheet hits the per-category endpoint on demand.
  @override
  Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId) async {
    final filters = await getLibraryFilters(libraryId);
    return LibraryFilterResult(filters: filters, cachedValues: const {});
  }

  @override
  Future<PlaybackExtras> fetchPlaybackExtras(
    String itemId, {
    String? introPattern,
    String? creditsPattern,
    bool forceChapterFallback = false,
    bool forceRefresh = false,
  }) => getPlaybackExtras(
    itemId,
    introPattern: introPattern,
    creditsPattern: creditsPattern,
    forceChapterFallback: forceChapterFallback,
    forceRefresh: forceRefresh,
  );

  @override
  Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
    String itemId, {
    String? introPattern,
    String? creditsPattern,
    bool forceChapterFallback = false,
  }) async {
    final cached = await cache.get(serverId, '/library/metadata/$itemId');
    if (cached == null) return null;
    final metadataJson = _getFirstMetadataJsonFromData(cached);
    if (metadataJson == null) return null;
    return _parsePlaybackExtrasFromMetadataJson(
      metadataJson,
      introPattern: introPattern,
      creditsPattern: creditsPattern,
      forceChapterFallback: forceChapterFallback,
    );
  }

  @override
  Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId) async {
    final cached = await cache.get(serverId, '/library/metadata/$itemId');
    if (cached == null) return null;
    final metadataJson = _getFirstMetadataJsonFromData(cached);
    if (metadataJson == null) return null;
    return plexMediaSourceInfoFromCacheJson(metadataJson);
  }

  @override
  Future<ScrubPreviewSource?> createScrubPreviewSource({
    required MediaItem item,
    required MediaSourceInfo mediaSource,
  }) async {
    if (!capabilities.scrubThumbnails) return null;
    final partId = mediaSource.partId;
    if (partId == null) return null;
    final service = BifThumbnailService();
    try {
      await service.load(this, partId);
      return service;
    } catch (e, st) {
      appLogger.w('BIF thumbnail load failed for part $partId', error: e, stackTrace: st);
      service.dispose();
      return null;
    }
  }

  @override
  Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
    String libraryId, {
    required LibraryQuery query,
    MediaKind? libraryKind,
    AbortController? abort,
  }) async {
    // Translate the neutral query back to Plex's flat key=value map. Plex's
    // section endpoint takes filters verbatim — `PlexLibraryQueryTranslator`
    // emits both typed slots (genre/year/contentRating/tag/alphaPrefix) and
    // generic `query.filters` entries, matching what the legacy
    // `plexStyleFilters` map carried.
    final filters = const PlexLibraryQueryTranslator().toQueryParameters(query);
    // Browse tab always asked for collections; preserve as Plex's default
    // server behaviour can vary across versions.
    filters['includeCollections'] = '1';
    final result = await fetchLibraryPage(
      libraryId,
      start: query.offset,
      size: query.limit,
      filters: filters,
      abort: abort,
    );
    return LibraryPage<MediaItem>(items: result.items, totalCount: result.totalSize, offset: query.offset);
  }

  @override
  Future<List<LibraryFirstCharacter>> fetchFirstCharacters(String libraryId, {Map<String, String>? filters}) async {
    return getFirstCharacters(libraryId, filters: filters);
  }

  @override
  Future<List<MediaItem>> searchItems(String query, {int limit = 30}) async {
    final results = await _search(query, limit: limit);
    return results.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  @override
  Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50}) async {
    final items = await _getRecentlyAdded(limit: limit);
    return items.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  @override
  Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async {
    final items = await _getContinueWatching(count: count);
    return items.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  @override
  Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async {
    final hubs = await _getGlobalHubs(limit: limit);
    return hubs.map((h) => PlexMappers.mediaHub(h)).toList();
  }

  @override
  Future<List<MediaHub>> fetchLibraryHubs(
    String libraryId, {
    required String libraryName,
    int limit = 10,
    bool includePlaybackHubs = true,
    MediaKind? libraryKind,
  }) async {
    // libraryName is unused: Plex's /hubs/sections/{id} returns hubs already
    // titled per-library (e.g. "Recently Added in Movies").
    final hubs = await _getLibraryHubs(libraryId, limit: limit, libraryName: libraryName);
    return hubs.map((h) => PlexMappers.mediaHub(h)).toList();
  }

  @override
  Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10}) async {
    final hubs = await _getRelatedHubs(id, count: count);
    return hubs.map((h) => PlexMappers.mediaHub(h)).toList();
  }

  @override
  Future<void> markWatched(MediaItem item) => markAsWatched(item.id, item: item);

  @override
  Future<void> markUnwatched(MediaItem item) => markAsUnwatched(item.id, item: item);

  @override
  Future<void> removeFromContinueWatching(MediaItem item) async {
    await removeFromOnDeck(item.id);
    WatchStateNotifier().notifyRemovedFromContinueWatching(item: item);
  }

  /// Rate a media item (0.0-10.0 scale, where each integer = half a star).
  /// Pass `-1` to clear an existing rating. Throws [MediaServerHttpException]
  /// on non-2xx — call sites surface a snackbar on the catch arm.
  @override
  Future<void> rate(MediaItem item, double rating) async {
    final response = await _http.put(
      '/:/rate',
      queryParameters: {'key': item.id, 'identifier': 'com.plexapp.plugins.library', 'rating': rating},
    );
    throwIfHttpError(response);
  }

  @override
  Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
    final playlists = await _getPlaylists(playlistType: playlistType, smart: smart);
    return playlists.map((p) => PlexMappers.mediaPlaylist(p)).toList();
  }

  @override
  Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
    final p = await _getPlaylistMetadata(id);
    return p == null ? null : PlexMappers.mediaPlaylist(p);
  }

  @override
  Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
    final result = await _getPlaylist(id, start: offset, size: limit);
    return result.items.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  /// Plex-specific: paginated playlist content. Returns neutral [MediaItem]s.
  /// The total size from the server is needed for paginated UI; tests in
  /// `playlist_detail_screen.dart` rely on this.
  Future<({List<MediaItem> items, int totalSize})> fetchPlaylistPage(
    String playlistId, {
    int? start,
    int? size,
    AbortController? abort,
  }) async {
    final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort);
    return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize);
  }

  @override
  Future<List<MediaItem>> fetchCollections(String libraryId) async {
    final raw = await _getLibraryCollections(libraryId);
    return raw.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  @override
  Future<LibraryPage<MediaItem>> fetchCollectionPage(
    String collectionId, {
    int? start,
    int? size,
    AbortController? abort,
    String? libraryId,
    String? libraryTitle,
  }) async {
    final result = await _getCollectionItems(
      collectionId,
      start: start,
      size: size,
      abort: abort,
      librarySectionID: libraryId,
      librarySectionTitle: libraryTitle,
    );
    return LibraryPage<MediaItem>(
      items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
      totalCount: result.totalSize,
      offset: start ?? 0,
    );
  }

  /// Plex-specific: full collection contents across pages.
  Future<List<MediaItem>> fetchAllCollectionItemsAsMediaItems(
    String collectionId, {
    String? libraryId,
    String? libraryTitle,
  }) async {
    final raw = await _fetchAllCollectionItemsDto(
      collectionId,
      librarySectionID: libraryId,
      librarySectionTitle: libraryTitle,
    );
    return raw.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  /// Plex-specific: full playlist contents across pages.
  Future<List<MediaItem>> fetchAllPlaylistItemsAsMediaItems(String playlistId) async {
    final raw = await _fetchAllPlaylistItemsDto(playlistId);
    return raw.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  /// Plex-specific: paginated person-media listing.
  Future<({List<MediaItem> items, int totalSize})> fetchPersonMediaPage(
    String personId, {
    int? start,
    int? size,
    AbortController? abort,
  }) async {
    final result = await _getPersonMedia(personId, start: start, size: size, abort: abort);
    return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize);
  }

  @override
  Future<List<MediaItem>> fetchPersonMedia(String personId) => fetchAllPersonMediaAsMediaItems(personId);

  /// Plex-specific: full person-media listing across pages.
  Future<List<MediaItem>> fetchAllPersonMediaAsMediaItems(String personId) async {
    final raw = await _fetchAllPersonMediaDto(personId);
    return raw.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  /// Plex-specific: hub content as neutral [MediaItem]s.
  Future<List<MediaItem>> fetchHubContent(String hubKey) async {
    final raw = await _getHubContent(hubKey);
    return raw.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  @override
  Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) => fetchHubContent(hubId);

  /// Plex-specific: top-level folders in a library.
  Future<List<MediaItem>> fetchLibraryFolders(String sectionId) async {
    final raw = await _getLibraryFolders(sectionId);
    return raw.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  /// Plex-specific: contents of a folder (files and subfolders).
  Future<List<MediaItem>> fetchFolderChildren(String folderKey, {String? libraryId, String? libraryTitle}) async {
    final raw = await _getFolderChildren(folderKey, librarySectionID: libraryId, librarySectionTitle: libraryTitle);
    return raw.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  /// Plex-specific: extras (trailers, behind-the-scenes) for a media item.
  Future<List<MediaItem>> fetchExtras(String ratingKey) async {
    final raw = await _getExtras(ratingKey);
    return raw.map((m) => PlexMappers.mediaItem(m)).toList();
  }

  /// Plex-specific: library-scoped playlists.
  Future<List<MediaPlaylist>> fetchLibraryPlaylists({String playlistType = 'video'}) async {
    final raw = await _getLibraryPlaylists(playlistType: playlistType);
    return raw.map((p) => PlexMappers.mediaPlaylist(p)).toList();
  }

  /// Plex-specific: paginated library content with raw Plex filter map,
  /// returning neutral [MediaItem]s. The aggregation bridge uses this when it
  /// has Plex-specific filter strings (`unwatched=1`, `genre=...`) to forward.
  Future<({List<MediaItem> items, int totalSize})> fetchLibraryPage(
    String sectionId, {
    int? start,
    int? size,
    Map<String, String>? filters,
    AbortController? abort,
  }) async {
    final result = await _getLibraryContent(sectionId, start: start, size: size, filters: filters, abort: abort);
    return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize);
  }

  /// Full item with on-deck episode from a single `/library/metadata/{id}`
  /// round-trip. Implements [MediaServerClient.fetchItemWithOnDeck];
  /// Jellyfin has no analogous endpoint and returns onDeck=null there.
  @override
  Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
    final result = await getMetadataWithImagesAndOnDeck(id);
    final itemDto = result['metadata'] as PlexMetadataDto?;
    final onDeckDto = result['onDeckEpisode'] as PlexMetadataDto?;
    return (
      item: itemDto == null ? null : PlexMappers.mediaItem(itemDto),
      onDeckEpisode: onDeckDto == null ? null : PlexMappers.mediaItem(onDeckDto),
    );
  }

  @override
  String thumbnailUrl(String? path, {int? width, int? height}) {
    if (path == null || path.isEmpty) return '';
    // No sizing requested, or already-processed/external URL — passthrough.
    if (width == null && height == null) return getThumbnailUrl(path);
    if (path.startsWith('http://') || path.startsWith('https://')) {
      // External URLs route through [externalImageUrl] for proxying.
      // Direct callers without sizing get the raw URL.
      return getThumbnailUrl(path);
    }
    final token = config.token;
    if (token == null) return getThumbnailUrl(path);
    final encoded = Uri.encodeComponent(path.withPlexToken(token));
    final parts = <String>[
      if (width != null) 'width=$width',
      if (height != null) 'height=$height',
      'minSize=1',
      'upscale=1',
      'url=$encoded',
      'X-Plex-Token=$token',
    ];
    return '${config.baseUrl}/photo/:/transcode?${parts.join('&')}';
  }

  @override
  String externalImageUrl(String url, {int? width, int? height}) {
    final token = config.token;
    if (token == null || (width == null && height == null)) return url;
    final encoded = Uri.encodeComponent(url);
    final parts = <String>[
      if (width != null) 'width=$width',
      if (height != null) 'height=$height',
      'minSize=1',
      'upscale=1',
      'url=$encoded',
      'X-Plex-Token=$token',
    ];
    return '${config.baseUrl}/photo/:/transcode?${parts.join('&')}';
  }

  @override
  double get watchedThreshold => watchedThresholdPercent / 100.0;

  @override
  Map<String, String> get streamHeaders => Map.unmodifiable(config.headers);

  @override
  Future<ExternalIds> fetchExternalIds(String itemId) async {
    final guids = await fetchExternalGuids(itemId);
    return ExternalIds.fromGuids(guids);
  }

  @override
  Future<void> reportPlaybackStarted({
    required String itemId,
    required Duration position,
    Duration? duration,
    String? playSessionId,
    String? playMethod,
    String? mediaSourceId,
    int? audioStreamIndex,
    int? subtitleStreamIndex,
  }) => updateProgress(itemId, time: position.inMilliseconds, state: 'playing', duration: duration?.inMilliseconds);

  @override
  Future<void> reportPlaybackProgress({
    required String itemId,
    required Duration position,
    required Duration duration,
    bool isPaused = false,
    String? playSessionId,
    String? playMethod,
    String? mediaSourceId,
    int? audioStreamIndex,
    int? subtitleStreamIndex,
  }) => updateProgress(
    itemId,
    time: position.inMilliseconds,
    state: isPaused ? 'paused' : 'playing',
    duration: duration.inMilliseconds,
  );

  @override
  Future<void> reportPlaybackStopped({
    required String itemId,
    required Duration position,
    Duration? duration,
    String? playSessionId,
    String? mediaSourceId,
  }) => updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds);

  // ── Downloads ────────────────────────────────────────────────────

  @override
  Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async {
    final playbackData = await getVideoPlaybackData(item.id, mediaIndex: mediaIndex);
    return playbackData.hasValidVideoUrl ? playbackData.videoUrl : null;
  }

  @override
  Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0}) async {
    final playbackData = await getVideoPlaybackData(item.id, mediaIndex: mediaIndex);
    final subtitles = <DownloadSubtitleSpec>[];
    final mediaInfo = playbackData.mediaInfo;
    if (mediaInfo != null) {
      for (final subtitle in mediaInfo.subtitleTracks) {
        if (!subtitle.isExternal || subtitle.key == null) continue;
        final url = buildExternalSubtitleUrl(subtitle);
        if (url == null) continue;
        subtitles.add(
          DownloadSubtitleSpec(
            id: subtitle.id,
            url: url,
            codec: subtitle.codec,
            language: subtitle.language,
            languageCode: subtitle.languageCode,
            forced: subtitle.forced,
            displayTitle: subtitle.displayTitle,
          ),
        );
      }
    }
    return DownloadResolution(videoUrl: playbackData.videoUrl, externalSubtitles: subtitles);
  }

  @override
  List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) {
    return buildArtworkSpecs(item, getThumbnailUrl);
  }
}
