import 'dart:async';
import 'dart:io' show InternetAddress, InternetAddressType, Platform;
import 'package:package_info_plus/package_info_plus.dart';
import 'storage_service.dart';
import 'plex_client.dart';
import '../exceptions/media_server_exceptions.dart';
import '../models/plex/plex_user_profile.dart';
import '../models/plex/plex_home.dart';
import '../models/user_switch_response.dart';
import '../utils/app_logger.dart';
import '../utils/media_server_timeouts.dart';
import '../utils/media_server_http_client.dart';
import '../utils/poll_with_backoff.dart';

/// Redacts the middle of an IP address or hostname for safe logging.
/// E.g. `192.168.1.50` → `192.***.***.50`, `my.server.example.com` → `my.***.***. com`.
String _redactHost(String host) {
  // Strip brackets from IPv6
  final bare = host.startsWith('[') && host.endsWith(']') ? host.substring(1, host.length - 1) : host;

  // IPv6
  if (bare.contains(':')) {
    final parts = bare.split(':');
    if (parts.length > 2) {
      return '${parts.first}:***:${parts.last}';
    }
    return bare;
  }

  // IPv4
  final ipParts = bare.split('.');
  if (ipParts.length == 4 && ipParts.every((p) => int.tryParse(p) != null)) {
    return '${ipParts.first}.***.***.${ipParts.last}';
  }

  // Hostname
  final hostParts = bare.split('.');
  if (hostParts.length >= 3) {
    return '${hostParts.first}.***.${hostParts.last}';
  }

  return bare;
}

class PlexAuthService {
  static const String _appName = 'Plezy';
  static const String _plexApiBase = 'https://plex.tv/api/v2';
  static const String _clientsApi = 'https://clients.plex.tv/api/v2';

  final MediaServerHttpClient _http;
  final String _clientIdentifier;
  final String _appVersion;
  final String _platformVersion;

  PlexAuthService._(this._http, this._clientIdentifier, this._appVersion, this._platformVersion);

  /// Close the underlying HTTP client. Call when the service is short-lived
  /// (created for a single API call) to avoid leaking sockets.
  void dispose() => _http.close();

  static Future<PlexAuthService> create() async {
    final storage = await StorageService.getInstance();
    final http = MediaServerHttpClient(
      connectTimeout: MediaServerTimeouts.plexTvConnect,
      receiveTimeout: MediaServerTimeouts.plexTvReceive,
    );
    final clientIdentifier = await storage.getOrCreateClientIdentifier();
    final packageInfo = await PackageInfo.fromPlatform();
    return PlexAuthService._(http, clientIdentifier, packageInfo.version, Platform.operatingSystemVersion);
  }

  String get clientIdentifier => _clientIdentifier;

  Map<String, String> _getCommonHeaders({String? authToken}) {
    final headers = {
      'Accept': 'application/json',
      'X-Plex-Product': _appName,
      'X-Plex-Client-Identifier': _clientIdentifier,
    };

    if (authToken != null) {
      headers['X-Plex-Token'] = authToken;
    }

    return headers;
  }

  Future<MediaServerResponse> _getUser(String authToken) {
    return _http.get(
      '$_plexApiBase/user',
      headers: _getCommonHeaders(authToken: authToken),
      timeout: MediaServerTimeouts.plexTvReceive,
    );
  }

  void _checkStatus(MediaServerResponse response) => throwIfHttpError(response);

  /// Verify if a plex.tv token is valid
  Future<bool> verifyToken(String authToken) async {
    final response = await _getUser(authToken);
    if (response.statusCode == 200) return true;
    if (response.statusCode == 401 || response.statusCode == 403) return false;
    _checkStatus(response);
    return false;
  }

  /// Create a PIN for authentication
  Future<Map<String, dynamic>> createPin() async {
    final response = await _http.post(
      '$_plexApiBase/pins?strong=true',
      headers: _getCommonHeaders(),
      timeout: MediaServerTimeouts.plexTvReceive,
    );
    _checkStatus(response);
    return response.data as Map<String, dynamic>;
  }

  /// Construct the Auth App URL for the user to visit
  String getAuthUrl(String pinCode) {
    final params = {'clientID': _clientIdentifier, 'code': pinCode, 'context[device][product]': _appName};

    final queryString = params.entries
        .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
        .join('&');

    return 'https://app.plex.tv/auth#?$queryString';
  }

  /// Poll the PIN to check if it has been claimed
  Future<String?> checkPin(int pinId) async {
    final response = await _http.get(
      '$_plexApiBase/pins/$pinId',
      headers: _getCommonHeaders(),
      timeout: MediaServerTimeouts.plexTvReceive,
    );

    if (response.statusCode == 404 || response.statusCode == 410) {
      throw const MediaServerPinExpiredException();
    }
    if (response.statusCode == 401 || response.statusCode == 403) {
      throw MediaServerAuthException('Plex PIN check rejected', statusCode: response.statusCode);
    }
    _checkStatus(response);

    final data = response.data as Map<String, dynamic>;
    return data['authToken'] as String?;
  }

  /// Poll the PIN until it's claimed or timeout.
  ///
  /// Uses an exponential backoff (1s → 2s → 4s, capped at 5s) so a stalled
  /// claim doesn't hammer plex.tv every second for two minutes.
  Future<String?> pollPinUntilClaimed(
    int pinId, {
    Duration timeout = const Duration(minutes: 2),
    bool Function()? shouldCancel,
  }) {
    return pollWithBackoff<String>(
      probe: () => checkPin(pinId),
      endTime: DateTime.now().add(timeout),
      shouldCancel: shouldCancel,
    );
  }

  /// Fetch available Plex servers for the authenticated user
  Future<List<PlexServer>> fetchServers(String authToken) async {
    final response = await _http.get(
      '$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1',
      headers: _getCommonHeaders(authToken: authToken),
    );

    _checkStatus(response);

    final List<dynamic> resources = response.data as List<dynamic>;

    // Filter for server resources and map to PlexServer objects
    final servers = <PlexServer>[];
    final invalidServers = <Map<String, dynamic>>[];

    for (final resource in resources.where((r) => r['provides'] == 'server')) {
      try {
        final server = PlexServer.fromJson(resource as Map<String, dynamic>);
        servers.add(server);
      } catch (e) {
        // Collect invalid servers for debugging
        invalidServers.add(resource as Map<String, dynamic>);
        continue;
      }
    }

    // If we have invalid servers but some valid ones, that's okay
    // If we have no valid servers but some invalid ones, throw with debug info
    if (servers.isEmpty && invalidServers.isNotEmpty) {
      throw ServerParsingException(
        'No valid servers found. All ${invalidServers.length} server(s) have malformed data.',
        invalidServers,
      );
    }

    return servers;
  }

  /// Get user information
  Future<Map<String, dynamic>> getUserInfo(String authToken) async {
    final response = await _getUser(authToken);
    _checkStatus(response);
    return response.data as Map<String, dynamic>;
  }

  /// Get user profile with preferences (audio/subtitle settings)
  Future<PlexUserProfile> getUserProfile(String authToken) async {
    final response = await _http.get('$_clientsApi/user', headers: _getCommonHeaders(authToken: authToken));
    _checkStatus(response);
    return PlexUserProfile.fromJson(response.data as Map<String, dynamic>);
  }

  /// Get home users for the authenticated user
  Future<PlexHome> getHomeUsers(String authToken) async {
    final response = await _http.get('$_clientsApi/home/users', headers: _getCommonHeaders(authToken: authToken));
    _checkStatus(response);
    return PlexHome.fromJson(response.data as Map<String, dynamic>);
  }

  /// Switch to a different user in the home
  Future<UserSwitchResponse> switchToUser(String userUUID, String currentToken, {String? pin}) async {
    final queryParams = {
      'includeSubscriptions': '1',
      'includeProviders': '1',
      'includeSettings': '1',
      'includeSharedSettings': '1',
      'X-Plex-Product': _appName,
      'X-Plex-Version': _appVersion,
      'X-Plex-Client-Identifier': _clientIdentifier,
      'X-Plex-Platform': 'Flutter',
      'X-Plex-Platform-Version': _platformVersion,
      'X-Plex-Token': currentToken,
      'X-Plex-Language': 'en',
      'pin': ?pin,
    };

    final response = await _http.post(
      '$_clientsApi/home/users/$userUUID/switch',
      queryParameters: queryParams,
      headers: {'Accept': 'application/json', 'Content-Length': '0'},
    );

    _checkStatus(response);
    return UserSwitchResponse.fromJson(response.data as Map<String, dynamic>);
  }
}

/// Classification of an endpoint URL by the server's published connection type.
enum PlexNetworkClass { local, remote, relay, unknown }

/// Helper class to track connection candidates during testing
class _ConnectionCandidate {
  final PlexConnection connection;
  final String url;
  final bool isPlexDirectUri;
  final bool isHttps;

  const _ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri, this.isHttps);
}

/// Represents a Plex Media Server
class PlexServer {
  final String name;
  final String clientIdentifier;
  final String accessToken;
  final List<PlexConnection> connections;
  final bool owned;
  final String? product;
  final String? platform;
  final DateTime? lastSeenAt;
  final bool presence;

  PlexServer({
    required this.name,
    required this.clientIdentifier,
    required this.accessToken,
    required this.connections,
    required this.owned,
    this.product,
    this.platform,
    this.lastSeenAt,
    this.presence = false,
  });

  factory PlexServer.fromJson(Map<String, dynamic> json) {
    // Validate required fields first
    if (!_isValidServerJson(json)) {
      throw const FormatException(
        'Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)',
      );
    }

    final List<dynamic> connectionsJson = json['connections'] as List<dynamic>;
    final connections = <PlexConnection>[];

    // Parse connections and generate HTTP fallbacks for HTTPS connections
    for (final c in connectionsJson) {
      try {
        final connection = PlexConnection.fromJson(c as Map<String, dynamic>);
        connections.add(connection);

        if (_allowsHttpFallback(connection)) {
          connections.add(connection.toHttpFallback());
        }
      } catch (e) {
        // Skip invalid connections rather than failing the entire server
        continue;
      }
    }

    // If no valid connections were parsed, this server is unusable
    if (connections.isEmpty) {
      throw const FormatException('Server has no valid connections');
    }

    DateTime? lastSeenAt;
    if (json['lastSeenAt'] != null) {
      try {
        lastSeenAt = DateTime.parse(json['lastSeenAt'] as String);
      } catch (e) {
        lastSeenAt = null;
      }
    }

    return PlexServer(
      name: json['name'] as String, // Safe because validated above
      clientIdentifier: json['clientIdentifier'] as String, // Safe because validated above
      accessToken: json['accessToken'] as String, // Safe because validated above
      connections: connections,
      owned: json['owned'] as bool? ?? false,
      product: json['product'] as String?,
      platform: json['platform'] as String?,
      lastSeenAt: lastSeenAt,
      presence: json['presence'] as bool? ?? false,
    );
  }

  /// Validates that server JSON contains all required fields with correct types
  static bool _isValidServerJson(Map<String, dynamic> json) {
    // Check for required string fields
    if (json['name'] is! String || (json['name'] as String).isEmpty) {
      return false;
    }
    if (json['clientIdentifier'] is! String || (json['clientIdentifier'] as String).isEmpty) {
      return false;
    }
    if (json['accessToken'] is! String || (json['accessToken'] as String).isEmpty) {
      return false;
    }

    // Check for connections array
    if (json['connections'] is! List || (json['connections'] as List).isEmpty) {
      return false;
    }

    return true;
  }

  Map<String, dynamic> toJson() {
    return {
      'name': name,
      'clientIdentifier': clientIdentifier,
      'accessToken': accessToken,
      'connections': connections.map((c) => c.toJson()).toList(),
      'owned': owned,
      'product': product,
      'platform': platform,
      'lastSeenAt': lastSeenAt?.toIso8601String(),
      'presence': presence,
    };
  }

  /// Check if server is online using the presence field
  bool get isOnline => presence;

  /// Find the best working connection by testing them
  /// Returns a Stream that emits connections progressively:
  /// 1. First emission: The first connection that responds successfully
  /// 2. Second emission (optional): The best connection after latency testing
  /// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency
  /// Tests both plex.direct URI and direct IP for each connection
  /// HTTPS connections are tested first, with HTTP as fallback
  Stream<PlexConnection> findBestWorkingConnection({
    String? preferredUri,
    String? clientIdentifier,
    void Function(bool)? onTranscoderCapability,
  }) async* {
    if (connections.isEmpty) {
      appLogger.w('No connections available for server discovery');
      return;
    }

    const preferredTimeout = MediaServerTimeouts.preferredEndpointProbe;
    const raceTimeout = MediaServerTimeouts.connectionRace;

    final candidates = _buildPrioritizedCandidates();
    if (candidates.isEmpty) {
      appLogger.w('No connection candidates generated for server discovery');
      return;
    }

    final totalCandidates = candidates.length;
    appLogger.d(
      'Starting server connection discovery',
      error: {'preferred': preferredUri, 'candidateCount': totalCandidates},
    );

    for (final conn in connections) {
      final redactedUri = conn.uri.replaceAll(RegExp(r'//[^:/]+'), '//${_redactHost(conn.address)}');
      appLogger.d(
        'Raw API connection',
        error: {
          'uri': redactedUri,
          'address': _redactHost(conn.address),
          'local': conn.local,
          'relay': conn.relay,
          'protocol': conn.protocol,
        },
      );
    }

    _ConnectionCandidate? firstCandidate;

    // Fast-path: if we have a cached working URI, probe it with a short timeout
    if (preferredUri != null) {
      final cachedCandidate = _candidateForUrl(preferredUri);
      if (cachedCandidate != null) {
        appLogger.d('Testing cached endpoint before running full race', error: {'uri': preferredUri});
        final result = await PlexClient.testConnectionWithLatency(
          cachedCandidate.url,
          accessToken,
          timeout: preferredTimeout,
          clientIdentifier: clientIdentifier,
        );

        if (result.success) {
          appLogger.i('Cached endpoint succeeded, using immediately', error: {'uri': preferredUri});
          firstCandidate = cachedCandidate;
          if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!);
        } else {
          appLogger.w('Cached endpoint failed, falling back to candidate race', error: {'uri': preferredUri});
        }
      }
    }

    // If no cached candidate or it failed, race candidates to find first success
    if (firstCandidate == null) {
      final completer = Completer<_ConnectionCandidate?>();
      int completedTests = 0;

      appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates});

      for (final candidate in candidates) {
        unawaited(
          PlexClient.testConnectionWithLatency(
            candidate.url,
            accessToken,
            timeout: raceTimeout,
            clientIdentifier: clientIdentifier,
          ).then((result) {
            completedTests++;

            if (!result.success) {
              appLogger.w(
                'Connection candidate failed',
                error: {
                  'url': candidate.url,
                  'type': candidate.connection.displayType,
                  'https': candidate.isHttps,
                  'error': result.error,
                  'latencyMs': result.latencyMs,
                },
              );
            }

            if (result.success && !completer.isCompleted) {
              if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!);
              completer.complete(candidate);
            }

            if (completedTests == candidates.length && !completer.isCompleted) {
              completer.complete(null);
            }
          }),
        );
      }

      firstCandidate = await completer.future;
      if (firstCandidate == null) {
        appLogger.e(
          'No working server connections after race',
          error: {
            'server': name,
            'candidateCount': totalCandidates,
            'types': candidates.map((c) => c.connection.displayType).toSet().toList(),
          },
        );
        return; // No working connections found
      }
      appLogger.i(
        'Connection race found first working endpoint',
        error: {'uri': firstCandidate.url, 'type': firstCandidate.connection.displayType},
      );
    }

    // Attempt HTTPS upgrade on the Phase 1 winner before emitting
    final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible(
      firstCandidate,
      clientIdentifier: clientIdentifier,
    );
    final emitCandidate = upgradedFirstCandidate ?? firstCandidate;

    final firstConnection = _updateConnectionUrl(emitCandidate.connection, emitCandidate.url);
    yield firstConnection;
    if (upgradedFirstCandidate != null && upgradedFirstCandidate.url != firstCandidate.url) {
      appLogger.i(
        'Phase 1 winner upgraded to HTTPS',
        error: {'from': firstCandidate.url, 'to': upgradedFirstCandidate.url},
      );
    }
    appLogger.d(
      'Emitted first working connection, continuing latency tests in background',
      error: {'uri': firstConnection.uri},
    );

    // Phase 2: Continue testing in background to find best connection
    // Test each candidate 2-3 times and average the latency
    final candidateResults = <_ConnectionCandidate, ConnectionTestResult>{};

    await Future.wait(
      candidates.map((candidate) async {
        final result = await PlexClient.testConnectionWithAverageLatency(
          candidate.url,
          accessToken,
          attempts: 2,
          clientIdentifier: clientIdentifier,
        );

        if (result.success) {
          candidateResults[candidate] = result;
        }
      }),
    );

    // If no connections succeeded, we're done
    if (candidateResults.isEmpty) {
      appLogger.w('Latency sweep found no additional working endpoints');
      return;
    }

    appLogger.d(
      'Completed latency sweep for server connections',
      error: {'successfulCandidates': candidateResults.length},
    );

    // Find the best connection considering priority, latency, and URL type
    final bestCandidate = _selectBestCandidateWithLatency(candidateResults);

    // Emit the best connection if it's different from the first one
    if (bestCandidate != null) {
      final upgradedCandidate =
          await _upgradeCandidateToHttpsIfPossible(bestCandidate, clientIdentifier: clientIdentifier) ?? bestCandidate;

      final bestConnection = _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url);
      if (bestConnection.uri != firstConnection.uri) {
        appLogger.i('Latency sweep selected better endpoint', error: {'uri': bestConnection.uri});
        yield bestConnection;
      } else {
        appLogger.d('Latency sweep confirmed initial endpoint is optimal', error: {'uri': bestConnection.uri});
      }
    }
  }

  /// Update a connection's URI to use the specified URL
  PlexConnection _updateConnectionUrl(PlexConnection connection, String url) {
    // If the URL matches the original URI, return as-is
    if (url == connection.uri) {
      return connection;
    }

    // Otherwise, create a new connection with the directUrl as the uri
    return PlexConnection(
      protocol: connection.protocol,
      address: connection.address,
      port: connection.port,
      uri: url,
      local: connection.local,
      relay: connection.relay,
      ipv6: connection.ipv6,
    );
  }

  _ConnectionCandidate? _candidateForUrl(String url) {
    for (final connection in connections) {
      final httpUrl = connection.httpDirectUrl;
      if (httpUrl == url) {
        return _ConnectionCandidate(connection, httpUrl, false, false);
      }

      final uri = connection.uri;
      if (uri == url) {
        final isHttps = uri.startsWith('https://');
        final parsedHost = Uri.tryParse(uri)?.host ?? '';
        final isPlexDirect = parsedHost.toLowerCase().contains('plex.direct');
        return _ConnectionCandidate(connection, uri, isPlexDirect, isHttps);
      }
    }
    return null;
  }

  /// Classify [url] against the server's published connections. Custom public
  /// HTTPS hostnames are treated as remote so failover avoids LAN-only URLs.
  PlexNetworkClass networkClassForUrl(String url) {
    return _candidateForUrl(url)?.connection.networkClass ?? _classifyCustomPreferredUrl(url);
  }

  PlexNetworkClass _classifyCustomPreferredUrl(String url) {
    final uri = Uri.tryParse(url);
    if (uri == null || uri.scheme.toLowerCase() != 'https') return PlexNetworkClass.unknown;

    final host = _normalizedHost(uri.host);
    if (host.isEmpty || _isLocalOrPrivateHost(host)) return PlexNetworkClass.unknown;

    // A manually entered HTTPS reverse-proxy hostname behaves like a remote
    // endpoint for failover: LAN candidates often cannot be reached from it.
    return PlexNetworkClass.remote;
  }

  List<_ConnectionCandidate> _buildPrioritizedCandidates({Set<String>? excludeUrls, PlexNetworkClass? restrictTo}) {
    final seen = <String>{};
    if (excludeUrls != null) {
      seen.addAll(excludeUrls);
    }

    final httpsLocal = <_ConnectionCandidate>[];
    final httpsRemote = <_ConnectionCandidate>[];
    final httpsRelay = <_ConnectionCandidate>[];
    final httpLocal = <_ConnectionCandidate>[];
    final httpRemote = <_ConnectionCandidate>[];
    final httpRelay = <_ConnectionCandidate>[];

    List<_ConnectionCandidate> bucketFor(PlexConnection connection, bool isHttps) {
      if (isHttps) {
        if (connection.relay) return httpsRelay;
        if (connection.local) return httpsLocal;
        return httpsRemote;
      } else {
        if (connection.relay) return httpRelay;
        if (connection.local) return httpLocal;
        return httpRemote;
      }
    }

    void addCandidate(PlexConnection connection, String url, bool isPlexDirectUri, bool isHttps) {
      if (url.isEmpty || seen.contains(url)) {
        return;
      }
      seen.add(url);
      bucketFor(connection, isHttps).add(_ConnectionCandidate(connection, url, isPlexDirectUri, isHttps));
    }

    for (final connection in connections) {
      // Skip endpoints that are never reachable from an external client:
      // Docker bridge addresses and IPv6 link-local / all-zeros addresses.
      if (_isUnreachableAddress(connection.address)) {
        continue;
      }

      // First, try the actual connection URI (may be HTTPS plex.direct)
      final isPlexDirect = _isPlexDirectUri(connection.uri);
      final isHttps = connection.protocol == 'https';
      addCandidate(connection, connection.uri, isPlexDirect, isHttps);

      if (_allowsHttpFallback(connection)) {
        addCandidate(connection, _httpFallbackUrl(connection), false, false);
      }
    }

    final all = [...httpsLocal, ...httpsRemote, ...httpsRelay, ...httpLocal, ...httpRemote, ...httpRelay];

    // Failover reachability filter. After discovery, failover should stay within
    // endpoint families plausible for the current session: when a remote or relay
    // endpoint is what's working, LAN-only endpoints are unreachable from off-LAN
    // clients and only pollute the retry chain (see GH #902). Reconnect /
    // reoptimization widens the search again via _reoptimizeServer. Local is kept
    // conservative — it may still be reachable over VPN/routed-LAN, but those
    // setups re-widen on the next connectivity event.
    if (restrictTo == PlexNetworkClass.remote || restrictTo == PlexNetworkClass.relay) {
      final filtered = all.where((c) => c.connection.networkClass != PlexNetworkClass.local).toList();
      if (filtered.isNotEmpty) return filtered;
    }
    return all;
  }

  List<String> prioritizedEndpointUrls({String? preferredFirst}) {
    final urls = <String>[];
    final exclude = <String>{};
    PlexNetworkClass? restrictTo;

    if (preferredFirst != null && preferredFirst.isNotEmpty) {
      urls.add(preferredFirst);
      exclude.add(preferredFirst);
      restrictTo = networkClassForUrl(preferredFirst);
    }

    final candidates = _buildPrioritizedCandidates(excludeUrls: exclude, restrictTo: restrictTo);
    urls.addAll(candidates.map((candidate) => candidate.url));
    return urls;
  }

  Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(
    _ConnectionCandidate candidate, {
    String? clientIdentifier,
  }) async {
    final currentUrl = candidate.url;
    if (currentUrl.startsWith('https://')) {
      return null;
    }

    late final String httpsUrl;
    bool resultingIsPlexDirect = candidate.isPlexDirectUri;

    if (candidate.isPlexDirectUri) {
      if (!currentUrl.startsWith('http://')) {
        return null;
      }
      httpsUrl = currentUrl.replaceFirst('http://', 'https://');
    } else {
      // Raw IP endpoints can't present HTTPS certificates, so prefer their
      // plex.direct alias. Native custom hostnames on :32400 can be probed on
      // the same host to avoid permanently downgrading to HTTP when HTTPS works.
      final originalUri = candidate.connection.uri;
      if (originalUri.isEmpty) {
        return null;
      }

      if (originalUri.startsWith('https://')) {
        httpsUrl = originalUri;
      } else if (originalUri.startsWith('http://')) {
        httpsUrl = originalUri.replaceFirst('http://', 'https://');
      } else {
        return null;
      }

      final upgradedHost = Uri.tryParse(httpsUrl)?.host;
      if (upgradedHost == null) {
        return null;
      }

      if (upgradedHost.toLowerCase().endsWith('.plex.direct')) {
        resultingIsPlexDirect = true;
      } else if (!_isNativePlexHostnameHttps(candidate.connection)) {
        appLogger.d(
          'Skipping HTTPS upgrade for HTTP candidate: no safe HTTPS target available',
          error: {'candidate': currentUrl, 'target': httpsUrl},
        );
        return null;
      }
    }

    if (httpsUrl == currentUrl) {
      return null;
    }

    appLogger.d('Attempting HTTPS upgrade for candidate endpoint', error: {'from': currentUrl, 'to': httpsUrl});

    final result = await PlexClient.testConnectionWithLatency(
      httpsUrl,
      accessToken,
      timeout: MediaServerTimeouts.connectionRace,
      clientIdentifier: clientIdentifier,
    );

    if (!result.success) {
      appLogger.w(
        'HTTPS upgrade failed, staying on HTTP candidate',
        error: {'url': currentUrl, 'reason': result.error},
      );
      return null;
    }

    appLogger.i('HTTPS upgrade succeeded for candidate endpoint', error: {'httpsUrl': httpsUrl});

    final httpsConnection = PlexConnection(
      protocol: 'https',
      address: candidate.connection.address,
      port: candidate.connection.port,
      uri: httpsUrl,
      local: candidate.connection.local,
      relay: candidate.connection.relay,
      ipv6: candidate.connection.ipv6,
    );

    return _ConnectionCandidate(httpsConnection, httpsUrl, resultingIsPlexDirect, true);
  }

  Future<PlexConnection?> upgradeConnectionToHttps(PlexConnection current) async {
    if (current.uri.startsWith('https://')) {
      return current;
    }

    final baseConnection = _findMatchingBaseConnection(current);
    if (baseConnection == null) {
      return null;
    }

    final candidate = _ConnectionCandidate(
      baseConnection,
      current.uri,
      current.uri.contains('.plex.direct'),
      current.uri.startsWith('https://'),
    );
    final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(candidate);
    if (upgradedCandidate == null) {
      return null;
    }
    return _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url);
  }

  PlexConnection? _findMatchingBaseConnection(PlexConnection connection) {
    for (final base in connections) {
      final sameAddress = base.address == connection.address;
      final samePort = base.port == connection.port;
      final sameLocal = base.local == connection.local;
      final sameRelay = base.relay == connection.relay;
      if (sameAddress && samePort && sameLocal && sameRelay) {
        return base;
      }
    }
    return null;
  }

  /// Select the best candidate considering priority, latency, and URL type preference
  _ConnectionCandidate? _selectBestCandidateWithLatency(Map<_ConnectionCandidate, ConnectionTestResult> results) {
    // Group candidates by connection type (local/remote/relay)
    final localCandidates = results.entries.where((e) => e.key.connection.local && !e.key.connection.relay).toList();
    final remoteCandidates = results.entries.where((e) => !e.key.connection.local && !e.key.connection.relay).toList();
    final relayCandidates = results.entries.where((e) => e.key.connection.relay).toList();

    // Find best in each category
    return _findLowestLatencyCandidate(localCandidates) ??
        _findLowestLatencyCandidate(remoteCandidates) ??
        _findLowestLatencyCandidate(relayCandidates);
  }

  /// Find the candidate with lowest latency, preferring HTTPS and plex.direct URI on tie
  _ConnectionCandidate? _findLowestLatencyCandidate(
    List<MapEntry<_ConnectionCandidate, ConnectionTestResult>> entries,
  ) {
    if (entries.isEmpty) return null;

    // Sort by protocol first (HTTPS enables H2 multiplexing), then latency, then URL type
    entries.sort((a, b) {
      // Prefer HTTPS over HTTP
      final aIsHttps = a.key.isHttps;
      final bIsHttps = b.key.isHttps;
      if (aIsHttps && !bIsHttps) return -1;
      if (!aIsHttps && bIsHttps) return 1;

      // Within same protocol, sort by latency
      final latencyCompare = a.value.latencyMs.compareTo(b.value.latencyMs);
      if (latencyCompare != 0) return latencyCompare;

      // Prefer plex.direct URI on tie
      if (a.key.isPlexDirectUri && !b.key.isPlexDirectUri) return -1;
      if (!a.key.isPlexDirectUri && b.key.isPlexDirectUri) return 1;
      return 0;
    });

    return entries.first.key;
  }

  /// True when the address is a raw IP (no hostname → no reverse proxy → HTTP
  /// fallback on an HTTPS port is safe to try).
  static bool _isIpLiteral(String address) {
    final bare = address.startsWith('[') && address.endsWith(']') ? address.substring(1, address.length - 1) : address;
    return InternetAddress.tryParse(bare) != null;
  }

  static bool _allowsHttpFallback(PlexConnection connection) {
    if (connection.protocol != 'https') return false;
    return _isPlexDirectUri(connection.uri) ||
        _isIpLiteral(connection.address) ||
        _isNativePlexHostnameHttps(connection);
  }

  static String _httpFallbackUrl(PlexConnection connection) {
    if (_isNativePlexHostnameHttps(connection)) {
      return connection.uri.replaceFirst('https://', 'http://');
    }
    return connection.httpDirectUrl;
  }

  static bool _isPlexDirectUri(String uri) {
    final host = Uri.tryParse(uri)?.host.toLowerCase();
    return host?.endsWith('.plex.direct') ?? uri.contains('.plex.direct');
  }

  static bool _isNativePlexHostnameHttps(PlexConnection connection) {
    if (connection.protocol != 'https' || connection.port != 32400 || _isIpLiteral(connection.address)) {
      return false;
    }

    final uri = Uri.tryParse(connection.uri);
    if (uri == null || uri.scheme != 'https' || uri.hasQuery || uri.hasFragment) {
      return false;
    }
    if (uri.port != connection.port) {
      return false;
    }
    if (uri.path.isNotEmpty && uri.path != '/') {
      return false;
    }

    final address = _normalizedHost(connection.address);
    return address.isNotEmpty && uri.host.toLowerCase() == address;
  }

  static String _normalizedHost(String host) {
    final bare = host.startsWith('[') && host.endsWith(']') ? host.substring(1, host.length - 1) : host;
    return bare.toLowerCase();
  }

  static bool _isLocalOrPrivateHost(String host) {
    final address = InternetAddress.tryParse(host);
    if (address != null) return _isPrivateOrLocalAddress(address);

    if (host == 'localhost' || !host.contains('.')) return true;
    if (host.endsWith('.local') || host.endsWith('.lan') || host.endsWith('.home.arpa') || host.endsWith('.internal')) {
      return true;
    }

    return false;
  }

  static bool _isPrivateOrLocalAddress(InternetAddress address) {
    final bytes = address.rawAddress;
    if (address.type == InternetAddressType.IPv4 && bytes.length == 4) {
      final a = bytes[0];
      final b = bytes[1];
      return a == 0 ||
          a == 10 ||
          a == 127 ||
          (a == 169 && b == 254) ||
          (a == 172 && b >= 16 && b <= 31) ||
          (a == 192 && b == 168);
    }

    if (address.type == InternetAddressType.IPv6 && bytes.length == 16) {
      final first = bytes[0];
      final second = bytes[1];
      final isLoopback = bytes.take(15).every((b) => b == 0) && bytes[15] == 1;
      final isUnspecified = bytes.every((b) => b == 0);
      return isLoopback || isUnspecified || (first & 0xfe) == 0xfc || (first == 0xfe && (second & 0xc0) == 0x80);
    }

    return false;
  }

  /// Returns true if the address is known to be unreachable from external
  /// clients (IPv6 link-local or all-zeros).
  static bool _isUnreachableAddress(String address) {
    // IPv6 all-zeros (::) or link-local (fe80::)
    final normalized = address.replaceAll('-', ':').toLowerCase();
    if (normalized == '::' || normalized == '0000:0000:0000:0000:0000:0000:0000:0000') {
      return true;
    }
    // Condensed all-zeros variants
    if (RegExp(r'^(0+:){7}0+$').hasMatch(normalized)) {
      return true;
    }
    if (normalized.startsWith('fe80:') || normalized.startsWith('fe80::')) {
      return true;
    }

    return false;
  }
}

/// Represents a connection to a Plex server
class PlexConnection {
  final String protocol;
  final String address;
  final int port;
  final String uri;
  final bool local;
  final bool relay;
  final bool ipv6;

  PlexConnection({
    required this.protocol,
    required this.address,
    required this.port,
    required this.uri,
    required this.local,
    required this.relay,
    required this.ipv6,
  });

  factory PlexConnection.fromJson(Map<String, dynamic> json) {
    // Validate required fields
    if (!_isValidConnectionJson(json)) {
      throw const FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)');
    }

    return PlexConnection(
      protocol: json['protocol'] as String, // Safe because validated above
      address: json['address'] as String, // Safe because validated above
      port: json['port'] as int, // Safe because validated above
      uri: json['uri'] as String, // Safe because validated above
      local: json['local'] as bool? ?? false,
      relay: json['relay'] as bool? ?? false,
      ipv6: json['IPv6'] as bool? ?? false,
    );
  }

  /// Validates that connection JSON contains all required fields with correct types
  static bool _isValidConnectionJson(Map<String, dynamic> json) {
    // Check for required string fields
    if (json['protocol'] is! String || (json['protocol'] as String).isEmpty) {
      return false;
    }
    if (json['address'] is! String || (json['address'] as String).isEmpty) {
      return false;
    }
    if (json['uri'] is! String || (json['uri'] as String).isEmpty) {
      return false;
    }

    // Check for required port (integer)
    if (json['port'] is! int) {
      return false;
    }

    return true;
  }

  Map<String, dynamic> toJson() {
    return {
      'protocol': protocol,
      'address': address,
      'port': port,
      'uri': uri,
      'local': local,
      'relay': relay,
      'IPv6': ipv6,
    };
  }

  /// Get the direct URL constructed from address and port
  /// This bypasses plex.direct DNS and connects directly to the IP
  String get directUrl => '$protocol://$address:$port';

  /// Always return an HTTP URL that points directly at the IP/port combo.
  String get httpDirectUrl {
    final needsBrackets = address.contains(':') && !address.startsWith('[');
    final safeAddress = needsBrackets ? '[$address]' : address;
    return 'http://$safeAddress:$port';
  }

  String get displayType {
    if (relay) return 'Relay';
    if (local) return 'Local';
    return 'Remote';
  }

  PlexNetworkClass get networkClass {
    if (relay) return PlexNetworkClass.relay;
    if (local) return PlexNetworkClass.local;
    return PlexNetworkClass.remote;
  }

  /// Create an HTTP fallback version of this HTTPS connection
  /// This allows testing HTTP when HTTPS is unavailable (e.g., certificate issues)
  PlexConnection toHttpFallback() {
    assert(protocol == 'https', 'Can only create HTTP fallback for HTTPS connections');

    return PlexConnection(
      protocol: 'http',
      address: address,
      port: port,
      uri: uri.replaceFirst('https://', 'http://'),
      local: local,
      relay: relay,
      ipv6: ipv6,
    );
  }
}

/// Custom exception for server parsing errors that includes debug data
class ServerParsingException implements Exception {
  final String message;
  final List<Map<String, dynamic>> invalidServerData;

  ServerParsingException(this.message, this.invalidServerData);

  @override
  String toString() => message;
}
