import 'dart:async';
import '../media/ids.dart';

import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';

import '../connection/connection.dart';
import '../i18n/app_locale_utils.dart';
import '../media/media_server_client.dart';
import '../exceptions/media_server_exceptions.dart';

import 'jellyfin_client.dart';
import 'jellyfin_endpoint_discovery.dart';
import 'plex_client.dart';
import '../models/plex/plex_config.dart';
import '../utils/app_logger.dart';
import '../utils/media_server_timeouts.dart';
import '../utils/active_client_scope.dart';
import '../utils/future_extensions.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'plex_auth_service.dart';
import 'settings_service.dart';
import 'storage_service.dart';

typedef PlexClientFactory =
    Future<PlexClient> Function(
      PlexConfig config, {
      required ServerId serverId,
      required PlexProfileScopeId profileScopeId,
      String? serverName,
      List<String>? prioritizedEndpoints,
      Future<void> Function(String newBaseUrl)? onEndpointChanged,
      VoidCallback? onAllEndpointsExhausted,
      bool? seedTranscoderVideoSupport,
    });

bool _isMediaServerAuthFailure(Object error) =>
    error is MediaServerAuthException ||
    error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403);

/// Manages multiple media-server connections simultaneously.
///
/// The internal map and public accessors are typed against the
/// [MediaServerClient] interface so consumers don't depend on the concrete
/// backend. Onboarding helpers branch on backend (Plex `PlexServer`,
/// Jellyfin `JellyfinConnection`) and instantiate the matching client.
class MultiServerManager {
  MultiServerManager({
    PlexClientFactory plexClientFactory = PlexClient.create,
    Stream<List<ConnectivityResult>> Function()? connectivityChanges,
    Duration connectivityDebounceDuration = const Duration(seconds: 2),
  }) : this._(plexClientFactory, connectivityChanges ?? _defaultConnectivityChanges, connectivityDebounceDuration);

  MultiServerManager._(this._plexClientFactory, this._connectivityChanges, this._connectivityDebounceDuration);

  static Stream<List<ConnectivityResult>> _defaultConnectivityChanges() => Connectivity().onConnectivityChanged;

  final PlexClientFactory _plexClientFactory;
  final Stream<List<ConnectivityResult>> Function() _connectivityChanges;
  final Duration _connectivityDebounceDuration;
  FutureOr<void> Function(JellyfinConnection connection)? onJellyfinConnectionUpdated;

  final Map<String, MediaServerClient> _clients = {};

  final Map<String, PlexServer> _plexServers = {};

  final Map<String, bool> _serverStatus = {};

  /// Servers whose last health probe rejected the auth token (HTTP 401/403).
  /// These rows also have `_serverStatus[serverId] == false` — auth errors are
  /// a *kind* of offline. Surfaces through [authErrorServerIds] so UI can
  /// show a "Sign in again" banner instead of a generic offline state.
  final Set<String> _authErrorServers = {};

  /// Stream controller for server status changes
  final _statusController = StreamController<Map<String, bool>>.broadcast();

  Stream<Map<String, bool>> get statusStream => _statusController.stream;

  /// Publish a snapshot of the per-server online map — subscribers must never
  /// receive the live [_serverStatus] instance.
  void _emitStatus() => _statusController.add(Map.from(_serverStatus));

  /// Per-server connect progress during a bind. Unlike [statusStream] — whose
  /// first emission means "the binder's first connect pass finished" and which
  /// triggers libraries/live-tv work per emission — this fires as each
  /// individual server lands so the startup splash can flip its checkmarks
  /// incrementally without disturbing those contracts.
  final _connectProgressController = StreamController<({String serverId, bool online})>.broadcast();

  Stream<({String serverId, bool online})> get connectProgressStream => _connectProgressController.stream;

  /// Servers whose authentication has failed (token rejected). A re-auth flow
  /// should be offered for these — they will remain "offline" until the user
  /// signs in again. Cleared once a probe succeeds.
  Set<String> get authErrorServerIds => Set.unmodifiable(_authErrorServers);

  /// Connectivity subscription for network monitoring
  StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;

  /// Map of serverId to active optimization futures
  final Map<String, Future<void>> _activeOptimizations = {};

  /// Per-server clientIdentifier. Plex servers added via
  /// [refreshTokensForProfile] register their owning account's
  /// clientIdentifier here so reconnects + endpoint optimization use the
  /// right identity (each account has its own device row on plex.tv).
  final Map<String, String> _clientIdByServer = {};
  final Map<String, PlexProfileScopeId> _plexScopeByServer = {};

  String? _resolveClientIdentifier(ServerId serverId) => _clientIdByServer[serverId];

  /// Record the Plex identity a server is bound under — the single writer for
  /// all three per-server Plex registrations. A null [scope] (only
  /// [markPlexConnectionAuthError], which has no profile yet) leaves any
  /// previously recorded scope in place.
  void _registerPlexServer(
    String serverId,
    PlexServer server, {
    required String clientIdentifier,
    PlexProfileScopeId? scope,
  }) {
    _clientIdByServer[serverId] = clientIdentifier;
    _plexServers[serverId] = server;
    if (scope != null) _plexScopeByServer[serverId] = scope;
  }

  /// Whether [compoundId] is still the client bound as the active user for
  /// [machineId]. Async Jellyfin work must re-check this before publishing a
  /// result — a profile switch can rebind the machine mid-probe.
  bool _isActiveJellyfin(String machineId, String compoundId) => _activeJellyfinMachine[machineId] == compoundId;

  /// All Jellyfin clients ever added, keyed by the compound connection id
  /// (`{serverMachineId}/{userId}`). Lets two users on the same Jellyfin
  /// server coexist — adding the second user's client won't tear down the
  /// first user's in-flight operations. [_clients] holds the currently
  /// "active" entry per machineId for everyone-pass-machineId-as-serverId
  /// consumers (cache resolver, visibility filter, MediaItem.serverId).
  final Map<String, JellyfinClient> _jellyfinByCompoundId = {};
  final Map<String, String> _activeJellyfinMachine = {};
  final Map<String, HealthStatus> _jellyfinHealthByCompoundId = {};

  /// Debounce timers for endpoint-exhaustion-triggered reconnection (per server)
  final Map<String, Timer> _reconnectDebounce = {};

  /// Servers whose endpoint-exhaustion signal is being confirmed by an
  /// auth-required health probe. Exhaustion callbacks raised by that probe
  /// are ignored so a failed confirmation cannot recursively schedule itself.
  final Set<String> _endpointHealthChecks = {};

  /// Coalescing guard for checkServerHealth — prevents concurrent health checks
  Future<void>? _activeHealthCheck;

  /// Coalescing guard for reconnectOfflineServers — prevents concurrent reconnect sweeps
  Future<void>? _activeReconnect;
  int _profileRefreshEpoch = 0;
  final Map<String, int> _profileRefreshGenerations = {};

  /// Debounce timer for connectivity events — collapses rapid network flapping
  Timer? _connectivityDebounce;

  /// Get all registered server IDs (Plex + Jellyfin).
  ///
  /// Sourced from [_clients] rather than [_plexServers] because
  /// [_plexServers] only holds the Plex-specific [PlexServer] structs
  /// (host/port metadata used for connection-racing). Jellyfin connections
  /// are registered as clients only — falling back to [_plexServers] would
  /// silently exclude them and callers (the active-profile binder, library
  /// refresh gates) would behave as if the manager were empty for
  /// Jellyfin-only profiles.
  List<String> get serverIds => _clients.keys.toList();

  List<String> get onlineServerIds => _serverStatus.entries.where((e) => e.value).map((e) => e.key).toList();

  List<String> get offlineServerIds => _serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList();

  /// Get client for specific server.
  MediaServerClient? getClient(ServerId serverId) => _clients[serverId];

  /// Resolve an exact private client namespace without falling back to a
  /// different active user on the same public server.
  MediaServerClient? getClientByScope(String clientScopeId) {
    final jellyfin = getJellyfinClientByCompoundId(clientScopeId);
    if (jellyfin != null) return jellyfin;
    final plexScope = PlexProfileScopeId.tryParse(clientScopeId);
    if (plexScope == null || _plexScopeByServer[plexScope.publicServerId] != plexScope) return null;
    final client = _clients[plexScope.publicServerId];
    return client is PlexClient && client.profileScopeId == plexScope ? client : null;
  }

  /// Server ids visible to the active profile; `null` means no restriction.
  /// Owned here rather than on `MultiServerProvider` so non-UI consumers
  /// (the download client resolver) apply the same filter the UI does —
  /// the provider delegates its filter state to this field.
  Set<String>? _visibleServerIds;

  Set<String>? get visibleServerIds => _visibleServerIds;

  void setVisibleServerIds(Set<String>? ids) => _visibleServerIds = ids;

  bool isServerVisible(ServerId serverId) => _visibleServerIds?.contains(serverId) ?? true;

  /// Resolve the client for a queued download. A supplied private namespace
  /// must match exactly; falling back to another active user would run work
  /// under the wrong authenticated identity.
  MediaServerClient? resolveDownloadClient(ServerId serverId, {String? clientScopeId}) {
    if (!isServerVisible(serverId)) return null;
    if (clientScopeId != null && clientScopeId.isNotEmpty) {
      final scoped = getClientByScope(clientScopeId);
      return scoped?.serverId == serverId ? scoped : null;
    }
    return getClient(serverId);
  }

  /// Get the [PlexClient] for a server, or `null` if the server is Jellyfin
  /// (or not registered). Use for Plex-only flows (Live TV, server prefs,
  /// endpoint optimization) that don't yet have a backend-neutral
  /// equivalent on [MediaServerClient].
  PlexClient? getPlexClient(ServerId serverId) {
    final client = _clients[serverId];
    return client is PlexClient ? client : null;
  }

  void updatePlexLanguage(String languageCode) {
    for (final client in _clients.values) {
      if (client is PlexClient) {
        client.applyLanguageUpdate(languageCode);
      }
    }
  }

  String? get _currentPlexLanguageCode =>
      SettingsService.instanceOrNull?.read(SettingsService.appLocale).plexLanguageCode;

  @visibleForTesting
  void debugRegisterJellyfinClientForTesting(JellyfinClient client, {bool online = true}) {
    _wireJellyfinConnectionUpdates(client);
    final compoundId = client.connection.id;
    final machineId = client.connection.serverMachineId;
    _jellyfinByCompoundId[compoundId] = client;
    _jellyfinHealthByCompoundId[compoundId] = online ? HealthStatus.online : HealthStatus.offline;
    _clients[machineId] = client;
    _activeJellyfinMachine[machineId] = compoundId;
    _serverStatus[machineId] = online;
  }

  @visibleForTesting
  void debugRegisterClientForTesting(MediaServerClient client, {bool online = true}) {
    _clients[client.serverId] = client;
    _serverStatus[client.serverId] = online;
    if (client is PlexClient) _plexScopeByServer[client.serverId] = client.profileScopeId;
  }

  @visibleForTesting
  void debugMarkAuthErrorForTesting(ServerId serverId) {
    _serverStatus[serverId] = false;
    _authErrorServers.add(serverId);
    _emitStatus();
  }

  /// Mark every cached Plex server on [connection] as auth-rejected without
  /// requiring a live client. Startup auth failures happen before a client can
  /// exist, but the UI still needs a server id/name for the re-auth banner.
  void markPlexConnectionAuthError(PlexAccountConnection connection) {
    for (final server in connection.servers) {
      final id = server.clientIdentifier;
      _registerPlexServer(id, server, clientIdentifier: connection.clientIdentifier);
      _serverStatus[id] = false;
      _authErrorServers.add(id);
    }
    _emitStatus();
  }

  String serverDisplayName(ServerId serverId) =>
      _clients[serverId]?.serverName ?? _plexServers[serverId]?.name ?? serverId;

  /// Backend-neutral "is this user an owner/admin on [serverId]?" probe used
  /// by UI gates that hide destructive admin entries (delete, edit metadata,
  /// match/unmatch). Returns:
  ///   - Plex: `PlexServer.owned` for the server (the matching profile-level
  ///     `plexAdmin` check stays at the call site so it can fold in
  ///     `ActiveProfileProvider`).
  ///   - Jellyfin: `JellyfinConnection.isAdministrator` captured at sign-in.
  ///   - Unknown server: `false`.
  bool isOwnerOrAdmin(ServerId serverId) {
    final client = _clients[serverId];
    if (client is PlexClient) {
      return _plexServers[serverId]?.owned == true;
    }
    if (client is JellyfinClient) {
      return client.connection.isAdministrator;
    }
    return false;
  }

  /// Get all online clients
  Map<String, MediaServerClient> get onlineClients {
    final result = <String, MediaServerClient>{};
    for (final serverId in onlineServerIds) {
      final client = _clients[serverId];
      if (client != null) {
        result[serverId] = client;
      }
    }
    return result;
  }

  /// Check if a server is online
  bool isServerOnline(ServerId serverId) => _serverStatus[serverId] ?? false;

  /// Check whether the active or exact scoped client for [serverId] is online.
  bool isClientOnline(ServerId serverId, {String? clientScopeId}) {
    if (clientScopeId != null && clientScopeId.isNotEmpty) {
      final client = getClientByScope(clientScopeId);
      if (client == null || client.serverId != serverId) return false;
      if (client is JellyfinClient) {
        return _jellyfinHealthByCompoundId[clientScopeId] == HealthStatus.online;
      }
    }
    return isServerOnline(serverId);
  }

  /// Creates and initializes a PlexClient for a given server
  ///
  /// Handles finding working connection, loading cached endpoint,
  /// creating config, and building client with failover support.
  Future<PlexClient> _createClientForServer({
    required PlexServer server,
    required String clientIdentifier,
    required PlexProfileScopeId profileScopeId,
  }) async {
    final serverId = server.clientIdentifier;
    final stopwatch = Stopwatch()..start();

    // Get storage and load cached endpoint for this server
    final storage = await StorageService.getInstance();
    final cachedEndpoint = storage.getServerEndpoint(ServerId(serverId));

    // The connection race already hits `/` on the winning endpoint — capture
    // `transcoderVideo` from that response so PlexClient.create can skip the
    // redundant warm-up probe.
    bool? observedTranscoderVideo;

    // Find best working connection, passing cached endpoint for fast-path
    final streamIterator = StreamIterator(
      server.findBestWorkingConnection(
        preferredUri: cachedEndpoint,
        clientIdentifier: clientIdentifier,
        onTranscoderCapability: (b) => observedTranscoderVideo = b,
      ),
    );

    if (!await streamIterator.moveNext()) {
      throw Exception('No working connection found');
    }

    final workingConnection = streamIterator.current;
    final baseUrl = workingConnection.uri;
    final firstConnectionMs = stopwatch.elapsedMilliseconds;

    // Create PlexClient with failover support
    final prioritizedEndpoints = server.prioritizedEndpointUrls(preferredFirst: baseUrl);
    final config = await PlexConfig.create(
      baseUrl: baseUrl,
      token: server.accessToken,
      clientIdentifier: clientIdentifier,
      languageCode: _currentPlexLanguageCode,
    );

    final client = await _plexClientFactory(
      config,
      serverId: ServerId(serverId),
      profileScopeId: profileScopeId,
      serverName: server.name,
      prioritizedEndpoints: prioritizedEndpoints,
      onEndpointChanged: (newUrl) async {
        await storage.saveServerEndpoint(ServerId(serverId), newUrl);
        appLogger.i('Updated endpoint for ${server.name} after failover: $newUrl');
      },
      onAllEndpointsExhausted: () => _onServerEndpointsExhausted(ServerId(serverId)),
      seedTranscoderVideoSupport: observedTranscoderVideo,
    );

    // Save the initial endpoint
    await storage.saveServerEndpoint(ServerId(serverId), baseUrl);

    appLogger.i(
      'Connected ${server.name}',
      error: {
        'uri': baseUrl,
        'hadCachedEndpoint': cachedEndpoint != null,
        'firstConnectionMs': firstConnectionMs,
        'totalMs': stopwatch.elapsedMilliseconds,
      },
    );

    // Drain remaining stream values in background to apply better connections
    _drainOptimizationStream(streamIterator, client: client, server: server, storage: storage);

    return client;
  }

  /// Persists a new endpoint, rebuilds the failover list, and switches the
  /// client only while it is still the registered client for this server.
  Future<bool> _promoteEndpoint({
    required PlexClient client,
    required PlexServer server,
    required StorageService storage,
    required String newUrl,
  }) async {
    final serverId = ServerId(server.clientIdentifier);
    bool isCurrent() {
      final registered = _clients[serverId];
      return identical(_plexServers[serverId], server) && (registered == null || identical(registered, client));
    }

    if (!isCurrent()) return false;
    await storage.saveServerEndpoint(serverId, newUrl);
    if (!isCurrent()) return false;
    final newEndpoints = server.prioritizedEndpointUrls(preferredFirst: newUrl);
    await client.updateEndpointPreferences(newEndpoints, switchToFirst: true);
    return isCurrent();
  }

  /// Continues draining the connection optimization stream in the background,
  /// switching the client to any better endpoint found.
  void _drainOptimizationStream(
    StreamIterator<PlexConnection> streamIterator, {
    required PlexClient client,
    required PlexServer server,
    required StorageService storage,
  }) {
    () async {
      try {
        while (await streamIterator.moveNext()) {
          final serverId = ServerId(server.clientIdentifier);
          final registered = _clients[serverId];
          if (!identical(_plexServers[serverId], server) || (registered != null && !identical(registered, client))) {
            appLogger.d('Stopping stale endpoint optimization for ${server.name}');
            break;
          }
          final connection = streamIterator.current;
          final newUrl = connection.uri;

          if (newUrl == client.config.baseUrl) {
            appLogger.d('Background optimization confirmed current endpoint for ${server.name}');
            continue;
          }

          appLogger.i(
            'Background optimization found better endpoint for ${server.name}',
            error: {'from': client.config.baseUrl, 'to': newUrl, 'type': connection.displayType},
          );

          await _promoteEndpoint(client: client, server: server, storage: storage, newUrl: newUrl);
        }
      } catch (e, stackTrace) {
        appLogger.w('Background connection optimization failed for ${server.name}', error: e, stackTrace: stackTrace);
      } finally {
        await streamIterator.cancel();
      }
    }();
  }

  /// Remove a server connection
  void removeServer(ServerId serverId) {
    final jellyfinCompoundIds = _jellyfinByCompoundId.entries
        .where((entry) => entry.value.connection.serverMachineId == serverId)
        .map((entry) => entry.key)
        .toList();
    final activeClient = _forgetServer(serverId);
    if (jellyfinCompoundIds.isNotEmpty) {
      final closed = <JellyfinClient>{};
      for (final compoundId in jellyfinCompoundIds) {
        final client = _jellyfinByCompoundId.remove(compoundId);
        _jellyfinHealthByCompoundId.remove(compoundId);
        if (client != null && closed.add(client)) {
          unawaited(_closeClientGracefully(client));
        }
      }
    } else if (activeClient != null) {
      // Jellyfin's clients were all closed above.
      unawaited(_closeClientGracefully(activeClient));
    }
    _emitStatus();
    appLogger.i('Removed server: $serverId');
  }

  /// Drop every registration keyed by [serverId], cancel its pending exhaustion
  /// retry, and return the client that was bound (the caller closes it). The
  /// single teardown for both removal paths, so they cannot drift apart again.
  /// The in-flight guards ([_activeOptimizations], [_endpointHealthChecks]) are
  /// deliberately left alone — they are owned by the futures that set them.
  MediaServerClient? _forgetServer(String serverId) {
    _reconnectDebounce.remove(serverId)?.cancel();
    final client = _clients.remove(serverId);
    _activeJellyfinMachine.remove(serverId);
    _plexServers.remove(serverId);
    _clientIdByServer.remove(serverId);
    _plexScopeByServer.remove(serverId);
    _serverStatus.remove(serverId);
    _authErrorServers.remove(serverId);
    return client;
  }

  /// Close [client], draining in-flight requests when it supports it. Callers
  /// that do not need to wait wrap the call in `unawaited(...)`.
  Future<void> _closeClientGracefully(
    MediaServerClient client, {
    Duration drainTimeout = const Duration(seconds: 2),
  }) async {
    if (client case final GracefullyCloseable graceful) {
      await graceful.closeGracefully(drainTimeout: drainTimeout);
    } else {
      client.close();
    }
  }

  /// Apply a freshly-fetched [PlexAccountConnection] to the manager,
  /// rotating per-server access tokens in place when possible.
  ///
  /// Used by [ActiveProfileBinder] on profile switch: after Plex hands us
  /// the new home-user-scoped per-server tokens, we swap the [PlexConfig]
  /// on existing healthy [PlexClient]s instead of tearing them down and
  /// reconnecting. Auth-error clients can also be reused because the failure
  /// was the old token; other offline servers fall through to the standard
  /// [_createClientForServer] path so they get a fresh handshake.
  ///
  /// Returns the [clientIdentifier]s that ended up actually bound (token
  /// reused or freshly connected). Failed servers are excluded so the
  /// caller's visibility filter doesn't surface unreachable servers.
  Future<Set<String>> refreshTokensForProfile(
    PlexAccountConnection connection, {
    required String profileId,
    Duration timeout = MediaServerTimeouts.perServerConnect,
  }) async {
    final accountId = connection.id;
    final epoch = _profileRefreshEpoch;
    final generation = (_profileRefreshGenerations[accountId] ?? 0) + 1;
    _profileRefreshGenerations[accountId] = generation;
    bool isStale() => epoch != _profileRefreshEpoch || _profileRefreshGenerations[accountId] != generation;
    if (connection.servers.isEmpty) {
      if (!isStale()) _profileRefreshGenerations.remove(accountId);
      return const {};
    }
    final bound = <String>{};
    final futures = connection.servers.map((server) async {
      final serverId = server.clientIdentifier;
      final profileScopeId = buildPlexProfileScopeId(serverId: ServerId(serverId), profileId: profileId);
      final existing = _clients[serverId];
      if (existing is PlexClient && ((_serverStatus[serverId] ?? false) || _authErrorServers.contains(serverId))) {
        try {
          final applied = await existing.applyProfileUpdate(
            newToken: server.accessToken,
            newProfileScopeId: profileScopeId,
          );
          if (!applied || isStale() || !identical(_clients[serverId], existing)) return;

          _registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId);
          _authErrorServers.remove(serverId);
          _serverStatus[serverId] = true;
          bound.add(serverId);
          _connectProgressController.add((serverId: serverId, online: true));
        } catch (e, stackTrace) {
          if (isStale() || !identical(_clients[serverId], existing)) return;
          appLogger.e('refreshTokensForProfile: failed to refresh ${server.name}', error: e, stackTrace: stackTrace);
          _serverStatus[serverId] = false;
          if (_isMediaServerAuthFailure(e)) _authErrorServers.add(serverId);
          _connectProgressController.add((serverId: serverId, online: false));
        }
        return;
      }

      _registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId);
      try {
        final client = await _createClientForServer(
          server: server,
          clientIdentifier: connection.clientIdentifier,
          profileScopeId: profileScopeId,
        ).namedTimeout(timeout, operation: 'connect to ${server.name}');
        if (isStale() || !identical(_plexServers[serverId], server)) {
          unawaited(_closeClientGracefully(client));
          return;
        }
        final oldClient = _clients[serverId];
        if (oldClient != null) unawaited(_closeClientGracefully(oldClient));
        _clients[serverId] = client;
        _serverStatus[serverId] = true;
        _authErrorServers.remove(serverId);
        bound.add(serverId);
        _connectProgressController.add((serverId: serverId, online: true));
      } catch (e, stackTrace) {
        if (isStale() || !identical(_plexServers[serverId], server)) return;
        appLogger.e('refreshTokensForProfile: failed to connect ${server.name}', error: e, stackTrace: stackTrace);
        _serverStatus[serverId] = false;
        if (_isMediaServerAuthFailure(e)) _authErrorServers.add(serverId);
        _connectProgressController.add((serverId: serverId, online: false));
      }
    });
    await Future.wait(futures);
    if (isStale()) return const {};
    _emitStatus();
    if (bound.isNotEmpty && _connectivitySubscription == null) {
      _startNetworkMonitoring();
    }
    _profileRefreshGenerations.remove(accountId);
    return bound;
  }

  /// Add a Jellyfin server backed by an authenticated [JellyfinConnection].
  /// Returns true on success.
  ///
  /// When a live client already exists for the same compound id and the
  /// connection is equivalent (see [canReuseJellyfinClient]), that client is
  /// reused instead of recreated — profile rebinds re-add unchanged
  /// connections routinely, and tearing the client down would abort its
  /// in-flight requests. A material change (token, deviceId, URL set) still
  /// replaces the client. This mirrors the Plex rebind path, where
  /// [refreshTokensForProfile] reuses the online client via an in-place
  /// token update.
  ///
  /// Jellyfin clients use the shared endpoint-racing flow when multiple URLs
  /// are configured, then instantiate the client against the lowest-latency
  /// reachable URL.
  ///
  /// Two users on the same Jellyfin server are tracked separately in
  /// [_jellyfinByCompoundId]; only one is "active" per machineId at a time.
  /// Adding the second user's connection doesn't close the first user's
  /// client (preserves any in-flight operations on the prior profile).
  Future<bool> addJellyfinConnection(JellyfinConnection connection) async {
    try {
      // Every close path detaches the client from [_jellyfinByCompoundId]
      // before closing it, so a client found here is never mid-close.
      final existing = _jellyfinByCompoundId[connection.id];
      if (existing != null && canReuseJellyfinClient(live: existing.connection, incoming: connection)) {
        return _reuseJellyfinClient(existing);
      }

      var resolvedConnection = connection;
      var endpointSelectionValidated = false;
      if (connection.baseUrls.length > 1) {
        try {
          final endpoint = await JellyfinEndpointDiscovery().raceEndpoints(
            connection.baseUrls,
            preferredUrl: connection.baseUrl,
            expectedMachineId: connection.serverMachineId,
            // Historic alternates are independent retry candidates, not one
            // atomic user-entered group. Reconcile each probe outcome below
            // instead of rejecting the whole stored connection.
            baseUrlsToValidate: const [],
          );
          resolvedConnection = connection.copyWith(
            baseUrl: endpoint.activeBaseUrl,
            baseUrls: endpoint.reconcilePreviouslyStoredBaseUrls(connection.baseUrls),
            serverName: endpoint.serverInfo.serverName,
          );
          endpointSelectionValidated = true;
        } catch (e, st) {
          appLogger.w(
            'Jellyfin endpoint race failed; using only the stored active endpoint',
            error: e.runtimeType,
            stackTrace: st,
          );
          resolvedConnection = connection.copyWith(baseUrl: connection.baseUrl, baseUrls: [connection.baseUrl]);
        }
      }

      final exhaustedMachineId = resolvedConnection.serverMachineId;
      final exhaustedCompoundId = resolvedConnection.id;
      final client = await JellyfinClient.create(
        resolvedConnection,
        onAllEndpointsExhausted: () => _onJellyfinEndpointsExhausted(exhaustedMachineId, exhaustedCompoundId),
      );
      // Admin status can change server-side; re-broadcast and persist so
      // admin-gated UI survives app restarts without requiring re-auth.
      _wireJellyfinConnectionUpdates(
        client,
        baseUrlsForPersistence: endpointSelectionValidated ? null : connection.baseUrls,
      );
      if (endpointSelectionValidated &&
          (resolvedConnection.baseUrl != connection.baseUrl ||
              !listEquals(resolvedConnection.baseUrls, connection.baseUrls))) {
        try {
          await onJellyfinConnectionUpdated?.call(resolvedConnection);
        } catch (e, st) {
          // Persistence failure does not alter the already-reconciled
          // in-memory client.
          appLogger.w('Failed to persist reconciled Jellyfin endpoints', error: e.runtimeType, stackTrace: st);
        }
      }
      final compoundId = resolvedConnection.id;
      final machineId = resolvedConnection.serverMachineId;

      // Replace the prior client for this compound id — reaching here means
      // the connection materially changed (token refresh, URL-set edit); an
      // unchanged re-add was already handled by the reuse branch above.
      final oldClient = _jellyfinByCompoundId[compoundId];
      if (oldClient != null) unawaited(_closeClientGracefully(oldClient));
      _jellyfinByCompoundId[compoundId] = client;

      // Bind this user as the active client for its machine. A previously
      // active client for a *different* compound id stays alive in
      // [_jellyfinByCompoundId] so a future profile switch can re-bind it.
      _clients[machineId] = client;
      _activeJellyfinMachine[machineId] = compoundId;

      final health = await client.checkHealth();
      final healthy = health == HealthStatus.online;
      _jellyfinHealthByCompoundId[compoundId] = health;
      _applyHealth(ServerId(machineId), health);

      appLogger.i('Added Jellyfin server: ${resolvedConnection.serverName}${healthy ? '' : ' (unhealthy)'}');
      if (_connectivitySubscription == null && healthy) {
        _startNetworkMonitoring();
      }
      return healthy;
    } catch (e, stackTrace) {
      appLogger.e('Failed to add Jellyfin server ${connection.serverName}', error: e, stackTrace: stackTrace);
      return false;
    }
  }

  /// Whether the live client bound to [live] can serve [incoming] without
  /// being recreated. Recreation is required when a field baked into the
  /// client at construction time changes:
  /// - `accessToken` / `deviceId` are embedded in the auth headers when the
  ///   HTTP client is built;
  /// - `baseUrls` fixes the failover candidate set. Compared as a set: both
  ///   the client and the add-path endpoint race reorder the list as
  ///   endpoints are promoted, so ordering drifts on an unchanged server.
  ///
  /// Everything else is deliberately ignored: the active `baseUrl` drifts as
  /// the client rotates endpoints, `isAdministrator` self-refreshes on health
  /// checks, and the remaining fields are display metadata. `userId` and
  /// `serverMachineId` equality is implied by the compound-id lookup that
  /// precedes this check.
  @visibleForTesting
  static bool canReuseJellyfinClient({required JellyfinConnection live, required JellyfinConnection incoming}) {
    return live.accessToken == incoming.accessToken &&
        live.deviceId == incoming.deviceId &&
        setEquals(live.baseUrls.toSet(), incoming.baseUrls.toSet());
  }

  /// Re-add of an unchanged connection: keep the live client (preserving its
  /// in-flight requests and settled endpoint choice), re-bind it as the
  /// machine's active user, and run a fresh health probe so callers still
  /// get a current result. Skips the endpoint race ([JellyfinClient] has
  /// per-request failover plus exhaustion-triggered reconnect), the
  /// connection-update wiring (already attached when the client was first
  /// added), and the connection persist (the client persists its own
  /// endpoint rotations).
  Future<bool> _reuseJellyfinClient(JellyfinClient client) async {
    final compoundId = client.connection.id;
    final machineId = client.connection.serverMachineId;
    final rebound = !_isActiveJellyfin(machineId, compoundId);
    _clients[machineId] = client;
    _activeJellyfinMachine[machineId] = compoundId;

    final health = await client.checkHealth();
    _jellyfinHealthByCompoundId[compoundId] = health;
    if (!_isActiveJellyfin(machineId, compoundId)) {
      // A concurrent remove/re-add won while the probe was in flight.
      appLogger.d('Ignoring stale Jellyfin reuse result for ${client.connection.serverName}');
      return health == HealthStatus.online;
    }
    _applyHealth(ServerId(machineId), health);
    if (rebound) {
      // The machine's active user changed even if its online status didn't;
      // client-map consumers need to observe the swap.
      _emitStatus();
    }
    final healthy = health == HealthStatus.online;
    appLogger.i(
      'Reusing existing Jellyfin client for ${client.connection.serverName}'
      '${healthy ? '' : ' (unhealthy)'} (connection unchanged)',
    );
    if (_connectivitySubscription == null && healthy) {
      _startNetworkMonitoring();
    }
    return healthy;
  }

  void _wireJellyfinConnectionUpdates(JellyfinClient client, {List<String>? baseUrlsForPersistence}) {
    client.onConnectionUpdated = (updated) async {
      if (_jellyfinByCompoundId[updated.id] != client) {
        appLogger.d('Ignoring stale Jellyfin connection update for ${updated.serverName}');
        return;
      }
      final persist = onJellyfinConnectionUpdated;
      if (persist != null) {
        try {
          final connectionToPersist = baseUrlsForPersistence == null
              ? updated
              : updated.copyWith(baseUrls: baseUrlsForPersistence);
          await Future.sync(() => persist(connectionToPersist));
        } catch (e, st) {
          appLogger.w('Failed to persist Jellyfin connection update', error: e, stackTrace: st);
        }
      }
      _emitStatus();
    };
  }

  /// Look up a tracked Jellyfin client by its compound id
  /// (`{serverMachineId}/{userId}`). Returns `null` if no Jellyfin
  /// connection with that id has been added. Useful for callers that need
  /// the *specific* user's client, not whichever is currently active for
  /// the machine.
  JellyfinClient? getJellyfinClientByCompoundId(String compoundId) => _jellyfinByCompoundId[compoundId];

  /// Tear down a specific Jellyfin user's client. If it was the active one
  /// for its machine, the machine slot is cleared.
  void removeJellyfinConnection(JellyfinConnection connection) {
    final compoundId = connection.id;
    final machineId = connection.serverMachineId;
    final client = _jellyfinByCompoundId.remove(compoundId);
    _jellyfinHealthByCompoundId.remove(compoundId);
    if (client != null) unawaited(_closeClientGracefully(client));
    if (_isActiveJellyfin(machineId, compoundId)) {
      _forgetServer(machineId);
      _emitStatus();
    }
  }

  /// Update server status (used for health monitoring).
  ///
  /// Clears the auth-error flag — callers that observed an auth failure
  /// should use [_applyHealth] instead.
  void updateServerStatus(ServerId serverId, bool isOnline) =>
      _applyHealth(serverId, isOnline ? HealthStatus.online : HealthStatus.offline);

  /// Apply a health-probe outcome to both online state and auth-error
  /// tracking. Used by the manager's own health checks; external callers
  /// without an auth-distinct signal should use [updateServerStatus].
  void _applyHealth(ServerId serverId, HealthStatus status) {
    final isOnline = status == HealthStatus.online;
    final isAuthError = status == HealthStatus.authError;
    final prevOnline = _serverStatus[serverId];
    final hadAuthError = _authErrorServers.contains(serverId);

    _serverStatus[serverId] = isOnline;
    if (isAuthError) {
      _authErrorServers.add(serverId);
    } else {
      _authErrorServers.remove(serverId);
    }

    final changed = prevOnline != isOnline || hadAuthError != isAuthError;
    if (changed) {
      _emitStatus();
      if (isAuthError) {
        appLogger.w('Server $serverId auth rejected — token expired or revoked');
      } else {
        appLogger.d('Server $serverId status changed to: $isOnline');
      }
    }
  }

  /// Test connection health for all servers. The probe is backend-defined:
  /// Plex hits `/identity` (HTTP 200), Jellyfin hits `/Users/Me` (auth-required)
  /// so a server with a revoked token is correctly reported as offline.
  Future<void> checkServerHealth() async {
    // Coalesce concurrent calls — return the in-flight future if one exists
    if (_activeHealthCheck != null) return _activeHealthCheck!;

    _activeHealthCheck = _doCheckServerHealth();
    try {
      await _activeHealthCheck;
    } finally {
      _activeHealthCheck = null;
    }
  }

  Future<void> _doCheckServerHealth() async {
    appLogger.d('Checking health for ${_clients.length} servers');

    final healthChecks = _clients.entries.map((entry) async {
      final serverId = entry.key;
      final client = entry.value;
      final expectedJellyfinCompoundId = client is JellyfinClient ? client.connection.id : null;

      final status = await client.checkHealth();
      if (client is JellyfinClient) {
        final compoundId = expectedJellyfinCompoundId ?? client.connection.id;
        _jellyfinHealthByCompoundId[compoundId] = status;
        if (!_isActiveJellyfin(serverId, compoundId)) {
          appLogger.d('Ignoring stale Jellyfin health result for ${client.connection.serverName}');
          return;
        }
      }
      _applyHealth(ServerId(serverId), status);
      if (status != HealthStatus.online) {
        appLogger.w('Server $serverId health check failed: ${status.name}');
      }
    });

    await Future.wait(healthChecks);
  }

  /// Start monitoring network connectivity for all servers
  void _startNetworkMonitoring() {
    if (_connectivitySubscription != null) {
      appLogger.d('Network monitoring already active');
      return;
    }

    appLogger.i('Starting network monitoring for all servers');
    try {
      _connectivitySubscription = _connectivityChanges().listen(
        (results) {
          final status = results.isNotEmpty ? results.first : ConnectivityResult.none;

          if (status == ConnectivityResult.none) {
            appLogger.w('Connectivity lost, pausing optimization until network returns');
            return;
          }

          // Debounce rapid connectivity events (e.g. WiFi flapping) into a single trigger
          _connectivityDebounce?.cancel();
          _connectivityDebounce = Timer(_connectivityDebounceDuration, () {
            _connectivityDebounce = null;

            appLogger.d(
              'Connectivity change detected, re-optimizing all servers',
              error: {
                'status': status.name,
                'interfaces': results.map((r) => r.name).toList(),
                'serverCount': _plexServers.length,
              },
            );

            // Re-optimize all servers and re-probe offline ones
            _reoptimizeAllServers(reason: 'connectivity:${status.name}');
            checkServerHealth();
          });
        },
        onError: (error, stackTrace) {
          appLogger.w('Connectivity listener error', error: error, stackTrace: stackTrace);
        },
      );
    } catch (e) {
      appLogger.w('Connectivity monitoring unavailable', error: e);
    }
  }

  /// Stop monitoring network connectivity
  void _stopNetworkMonitoring() {
    _connectivitySubscription?.cancel();
    _connectivitySubscription = null;
    _connectivityDebounce?.cancel();
    _connectivityDebounce = null;
    appLogger.i('Stopped network monitoring');
  }

  /// Run [taskBuilder] as the single in-flight optimize/reconnect task for
  /// [serverId] — the sole owner of the [_activeOptimizations] invariant.
  ///
  /// While an entry exists the builder is never invoked and a completed future
  /// is returned, so a caller awaiting a batch never waits on work it did not
  /// start. The registered future always clears its own entry. [timeout] bounds
  /// the task, logging `<timeoutLabel> timed out for <serverId>` when it fires.
  Future<void> _runServerTask(
    String serverId,
    Future<void> Function() taskBuilder, {
    Duration? timeout,
    String? timeoutLabel,
  }) {
    if (_activeOptimizations.containsKey(serverId)) return Future<void>.value();

    var task = taskBuilder();
    if (timeout != null) {
      task = task.timeout(timeout, onTimeout: () => appLogger.d('$timeoutLabel timed out for $serverId'));
    }
    // Must not *return* the removed entry — whenComplete would then await this very future.
    final registered = task.whenComplete(() {
      _activeOptimizations.remove(serverId);
    });
    _activeOptimizations[serverId] = registered;
    return registered;
  }

  /// Re-optimize all connected servers and attempt reconnection for offline ones
  void _reoptimizeAllServers({required String reason}) {
    for (final entry in _plexServers.entries) {
      final serverId = entry.key;
      final server = entry.value;

      // Skip if optimization/reconnection already running for this server
      if (_activeOptimizations.containsKey(serverId)) {
        appLogger.d('Optimization already running for ${server.name}, skipping', error: {'reason': reason});
        continue;
      }

      // Online servers get their endpoints re-raced; offline ones a full reconnect.
      unawaited(
        _runServerTask(
          serverId,
          () => isServerOnline(ServerId(serverId))
              ? _reoptimizeServer(serverId: ServerId(serverId), server: server, reason: reason)
              : _reconnectServer(ServerId(serverId), server),
        ),
      );
    }

    // Jellyfin re-probes offline servers here. Online clients keep their current
    // endpoint and can still fail over per request through JellyfinClient.
    for (final entry in _activeJellyfinMachine.entries) {
      final serverId = entry.key;
      if (isServerOnline(ServerId(serverId))) continue;

      final client = _jellyfinByCompoundId[entry.value];
      if (client == null) continue;

      unawaited(_runServerTask(serverId, () => _reconnectJellyfinServer(serverId, client)));
    }
  }

  /// Re-optimize connection for a specific server.
  ///
  /// Today this only runs against Plex servers — the connection-racing logic
  /// is built around [PlexServer.findBestWorkingConnection]. Non-Plex
  /// clients short-circuit until a backend-agnostic equivalent lands.
  Future<void> _reoptimizeServer({
    required ServerId serverId,
    required PlexServer server,
    required String reason,
  }) async {
    final storage = await StorageService.getInstance();
    final raw = _clients[serverId];
    final client = raw is PlexClient ? raw : null;
    if (raw != null && client == null) {
      // Non-Plex client registered for this serverId — no Plex-style optimizer to run.
      return;
    }
    final cachedEndpoint = storage.getServerEndpoint(serverId);

    try {
      appLogger.d('Starting connection optimization for ${server.name}', error: {'reason': reason});

      await for (final connection in server.findBestWorkingConnection(
        preferredUri: cachedEndpoint,
        clientIdentifier: _resolveClientIdentifier(serverId),
      )) {
        final newUrl = connection.uri;

        // Check if this is actually a better connection than current
        if (client != null && client.config.baseUrl == newUrl) {
          appLogger.d('Already using optimal endpoint for ${server.name}: $newUrl');
          continue;
        }

        if (client != null) {
          final promoted = await _promoteEndpoint(client: client, server: server, storage: storage, newUrl: newUrl);
          if (!promoted) return;
          appLogger.i('Switched ${server.name} to better endpoint: $newUrl', error: {'type': connection.displayType});
        } else {
          if (_plexServers[serverId] != server) return;
          await storage.saveServerEndpoint(serverId, newUrl);
          if (_plexServers[serverId] != server) return;
          appLogger.i('Updated optimal endpoint for ${server.name}: $newUrl', error: {'type': connection.displayType});
        }
      }
    } catch (e, stackTrace) {
      appLogger.w('Connection optimization failed for ${server.name}', error: e, stackTrace: stackTrace);
    }
  }

  /// Attempt full reconnection for a single offline server
  Future<void> _reconnectServer(ServerId serverId, PlexServer server) async {
    final clientId = _resolveClientIdentifier(serverId);
    if (clientId == null) {
      appLogger.w('Cannot reconnect ${server.name}: no client identifier cached');
      return;
    }
    final profileScopeId = _plexScopeByServer[serverId];
    if (profileScopeId == null) {
      appLogger.w('Cannot reconnect ${server.name}: no Plex profile scope cached');
      return;
    }

    try {
      appLogger.d('Attempting reconnection for ${server.name}');
      final client = await _createClientForServer(
        server: server,
        clientIdentifier: clientId,
        profileScopeId: profileScopeId,
      );
      if (!identical(_plexServers[serverId], server) ||
          _resolveClientIdentifier(serverId) != clientId ||
          _plexScopeByServer[serverId] != profileScopeId) {
        unawaited(_closeClientGracefully(client));
        appLogger.d('Ignoring stale reconnection result for ${server.name}');
        return;
      }

      final oldClient = _clients[serverId];
      if (oldClient != null) unawaited(_closeClientGracefully(oldClient));
      _clients[serverId] = client;
      updateServerStatus(serverId, true);
      appLogger.i('Successfully reconnected to ${server.name}');
    } catch (e) {
      appLogger.d('Reconnection failed for ${server.name}: $e');
      // Leave status as offline — will retry on next trigger
    }
  }

  /// Attempt reconnection for a single offline Jellyfin server.
  ///
  /// Jellyfin has a single fixed base URL — there's no connection-racing to
  /// run, just a health round-trip. The existing [JellyfinClient] is reused
  /// (the access token persists in [JellyfinConnection]); on success we flip
  /// the machine slot back to online so MediaServer-aware UI un-greys the
  /// entry.
  Future<void> _reconnectJellyfinServer(String machineId, JellyfinClient client) async {
    final expectedCompoundId = client.connection.id;
    try {
      appLogger.d('Attempting reconnection for Jellyfin server ${client.connection.serverName}');
      final status = await client.checkHealth();
      _jellyfinHealthByCompoundId[expectedCompoundId] = status;
      if (!_isActiveJellyfin(machineId, expectedCompoundId)) {
        appLogger.d('Ignoring stale Jellyfin reconnection result for ${client.connection.serverName}');
        return;
      }
      _applyHealth(ServerId(machineId), status);
      if (status == HealthStatus.online) {
        appLogger.i('Successfully reconnected to ${client.connection.serverName}');
      } else {
        appLogger.d('Reconnection probe for ${client.connection.serverName} returned ${status.name}');
      }
    } catch (e) {
      appLogger.d('Reconnection failed for ${client.connection.serverName}: $e');
      // Leave status as offline — will retry on next trigger
    }
  }

  /// Attempt reconnection for all offline servers.
  ///
  /// When [forceRediscovery] is true, the cached endpoint is cleared before
  /// reconnecting so the fast-path is skipped and a full candidate race runs.
  /// Used by the manual reconnect button when the cached URL may be stale
  /// (e.g. after a network change while the app was backgrounded).
  Future<void> reconnectOfflineServers({bool forceRediscovery = false}) async {
    // Coalesce concurrent calls — return the in-flight future if one exists
    if (_activeReconnect != null) return _activeReconnect!;

    _activeReconnect = _doReconnectOfflineServers(forceRediscovery: forceRediscovery);
    try {
      await _activeReconnect;
    } finally {
      _activeReconnect = null;
    }
  }

  Future<void> _doReconnectOfflineServers({required bool forceRediscovery}) async {
    final offline = offlineServerIds;
    if (offline.isEmpty) return;

    appLogger.d('Attempting reconnection for ${offline.length} offline servers');
    unawaited(
      Sentry.addBreadcrumb(
        Breadcrumb(message: 'Reconnecting ${offline.length} offline server(s)', category: 'servers'),
      ),
    );

    if (forceRediscovery) {
      final storage = await StorageService.getInstance();
      await Future.wait(offline.map((id) => storage.clearServerEndpoint(ServerId(id))));
    }

    final futures = offline.map((serverId) {
      final server = _plexServers[serverId];
      if (server != null) {
        return _runServerTask(
          serverId,
          () => _reconnectServer(ServerId(serverId), server),
          timeout: const Duration(seconds: 15),
          timeoutLabel: 'Reconnection',
        );
      }

      // Jellyfin offline path — no `_plexServers` entry, but the active
      // [JellyfinClient] is keyed by machineId in `_clients` and tracked in
      // `_activeJellyfinMachine`. Run the same auth probe used at add time.
      final activeCompoundId = _activeJellyfinMachine[serverId];
      final jellyfinClient = activeCompoundId != null ? _jellyfinByCompoundId[activeCompoundId] : null;
      if (jellyfinClient == null) return Future<void>.value();

      return _runServerTask(
        serverId,
        () => _reconnectJellyfinServer(serverId, jellyfinClient),
        timeout: const Duration(seconds: 15),
        timeoutLabel: 'Jellyfin reconnection',
      );
    });

    await Future.wait(futures);
  }

  /// Called when all failover endpoints are exhausted for a server.
  ///
  /// A content route timing out does not prove the server itself is offline.
  /// Debounce parallel failures, then confirm with the backend's lightweight
  /// auth-required health probe before publishing an offline transition.
  void _onServerEndpointsExhausted(ServerId serverId) {
    if (_endpointHealthChecks.contains(serverId)) return;

    _reconnectDebounce[serverId]?.cancel();
    _reconnectDebounce[serverId] = Timer(const Duration(seconds: 5), () {
      _reconnectDebounce.remove(serverId);
      unawaited(_verifyServerEndpointsExhausted(serverId));
    });
  }

  /// Fire-and-forget safe: both backends' `checkHealth` catch every failure
  /// and fold it into a [HealthStatus], and the scheduled reconnection guards
  /// its own errors — this future must never complete with one.
  Future<void> _verifyServerEndpointsExhausted(ServerId serverId) async {
    final client = _clients[serverId];
    if (client == null || !_endpointHealthChecks.add(serverId)) return;

    try {
      final health = await client.checkHealth();
      if (!identical(_clients[serverId], client)) return;

      if (client is JellyfinClient) {
        _jellyfinHealthByCompoundId[client.connection.id] = health;
      }

      if (health == HealthStatus.online) {
        _applyHealth(serverId, health);
        appLogger.d('Endpoint exhaustion not confirmed for $serverId; health probe succeeded');
        return;
      }

      _applyHealth(serverId, health);
      if (health == HealthStatus.authError) return;

      final plexServer = _plexServers[serverId];
      final jellyfinClient = client is JellyfinClient ? client : null;
      if (plexServer == null && jellyfinClient == null) return;

      appLogger.i('Health probe confirmed $serverId offline, triggering reconnection');

      unawaited(
        _runServerTask(
          serverId,
          () => plexServer != null
              ? _reconnectServer(serverId, plexServer)
              : _reconnectJellyfinServer(serverId, jellyfinClient!),
        ),
      );
    } finally {
      _endpointHealthChecks.remove(serverId);
    }
  }

  /// Jellyfin clients outlive their active binding (a previous profile's
  /// client stays in [_jellyfinByCompoundId]); only the currently bound
  /// client's exhaustion may verify and flip the machine's status.
  void _onJellyfinEndpointsExhausted(String machineId, String compoundId) {
    if (!_isActiveJellyfin(machineId, compoundId)) {
      appLogger.d('Ignoring endpoint exhaustion from inactive Jellyfin client', error: compoundId);
      return;
    }
    _onServerEndpointsExhausted(ServerId(machineId));
  }

  @visibleForTesting
  Future<void> debugVerifyServerEndpointsExhaustedForTesting(ServerId serverId) =>
      _verifyServerEndpointsExhausted(serverId);

  /// Entry point matching production exhaustion wiring (debounce + the
  /// in-flight-verification guard), for tests driving the full retry loop.
  @visibleForTesting
  void debugTriggerEndpointsExhaustedForTesting(ServerId serverId) => _onServerEndpointsExhausted(serverId);

  /// Disconnect all servers, fire-and-forget.
  ///
  /// Registrations are dropped synchronously ([_detachAllClients] runs before
  /// the first await); only the socket drain is left running in the background.
  void disconnectAll() {
    unawaited(disconnectAllGracefully(drainTimeout: const Duration(seconds: 2)));
  }

  Future<void> disconnectAllGracefully({Duration drainTimeout = const Duration(seconds: 5)}) async {
    appLogger.i('Gracefully disconnecting all servers');
    final clients = _detachAllClients();
    await Future.wait(
      clients.map((client) => _closeClientGracefully(client, drainTimeout: drainTimeout)),
      eagerError: false,
    );
  }

  Set<MediaServerClient> _detachAllClients() {
    ++_profileRefreshEpoch;
    _profileRefreshGenerations.clear();
    _stopNetworkMonitoring();
    for (final timer in _reconnectDebounce.values) {
      timer.cancel();
    }
    _reconnectDebounce.clear();
    _activeHealthCheck = null;
    _activeReconnect = null;
    final clients = <MediaServerClient>{..._clients.values, ..._jellyfinByCompoundId.values};
    _clients.clear();
    _jellyfinByCompoundId.clear();
    _activeJellyfinMachine.clear();
    _jellyfinHealthByCompoundId.clear();
    _plexServers.clear();
    _serverStatus.clear();
    _authErrorServers.clear();
    _clientIdByServer.clear();
    _plexScopeByServer.clear();
    _activeOptimizations.clear();
    if (!_statusController.isClosed) {
      _statusController.add({});
    }
    return clients;
  }

  /// Dispose resources
  void dispose() {
    disconnectAll();
    if (!_statusController.isClosed) {
      _statusController.close();
    }
    if (!_connectProgressController.isClosed) {
      _connectProgressController.close();
    }
  }
}
