import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/companion_remote/remote_command.dart';
import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart';
import 'package:plezy/services/companion_remote/remote_auth_context.dart';
import 'package:plezy/services/companion_remote/remote_auth_service.dart';

const _ioTimeout = Duration(seconds: 5);

void main() {
  test('auth precondition error uses the active locale', () async {
    await LocaleSettings.setLocale(AppLocale.bg);
    addTearDown(() => LocaleSettings.setLocaleSync(AppLocale.en));
    final peer = CompanionRemotePeerService();
    addTearDown(peer.dispose);

    await expectLater(
      () => peer.createSessionForContexts('Test Host', 'macos', const []),
      throwsA(isA<PeerError>().having((error) => error.message, 'message', 'Неуспешно удостоверяване')),
    );
  });

  test('per-source admission rejects before upgrade and recovers exactly one slot', () async {
    final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 4, maxHostConnectionsPerSource: 2);
    final clients = <_RawWebSocketClient>[];
    addTearDown(() => _disposeHostAndClients(host, clients));
    final port = await _startHost(host);

    clients.add(await _RawWebSocketClient.connect(port));
    clients.add(await _RawWebSocketClient.connect(port));
    await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
    expect(clients, everyElement(predicate<_RawWebSocketClient>((client) => client.isOpen)));

    await clients.removeAt(0).dispose();
    clients.add(await _RawWebSocketClient.connect(port));
    await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
  });

  test('global admission rejects independently and releases one reservation once', () async {
    final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 2, maxHostConnectionsPerSource: 4);
    final clients = <_RawWebSocketClient>[];
    addTearDown(() => _disposeHostAndClients(host, clients));
    final port = await _startHost(host);

    clients.add(await _RawWebSocketClient.connect(port));
    clients.add(await _RawWebSocketClient.connect(port));
    await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);

    await clients.removeAt(0).dispose();
    clients.add(await _RawWebSocketClient.connect(port));
    await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
  });

  test('authentication timeout releases capacity for a legitimate encrypted session', () async {
    final host = CompanionRemotePeerService.forTesting(
      maxTotalHostConnections: 1,
      maxHostConnectionsPerSource: 1,
      authTimeout: const Duration(milliseconds: 300),
    );
    final remote = CompanionRemotePeerService();
    final clients = <_RawWebSocketClient>[];
    addTearDown(() async {
      await remote.dispose();
      await _disposeHostAndClients(host, clients);
    });
    final port = await _startHost(host);

    final silent = await _RawWebSocketClient.connect(port);
    clients.add(silent);
    expect(await silent.closed.timeout(_ioTimeout), 4001);
    clients.remove(silent);

    await remote.joinSessionWithContexts(
      'Test Remote',
      'ios',
      '127.0.0.1:$port',
      [_authContext],
      authContextId: _authContext.id,
      expectedHostClientId: _authContext.clientIdentifier,
    );
    final command = host.onCommandReceived.firstWhere((event) => event.type == RemoteCommandType.play);
    remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play));
    expect((await command.timeout(_ioTimeout)).type, RemoteCommandType.play);
  });

  test('disconnect closes pending sockets and restart restores full capacity', () async {
    final host = CompanionRemotePeerService.forTesting(
      maxTotalHostConnections: 2,
      maxHostConnectionsPerSource: 2,
      authTimeout: const Duration(seconds: 1),
    );
    final clients = <_RawWebSocketClient>[];
    addTearDown(() => _disposeHostAndClients(host, clients));
    var port = await _startHost(host);

    final firstGeneration = [await _RawWebSocketClient.connect(port), await _RawWebSocketClient.connect(port)];
    clients.addAll(firstGeneration);
    await host.disconnect();
    expect(
      await Future.wait(firstGeneration.map((client) => client.closed.timeout(_ioTimeout))),
      everyElement(isNot(4001)),
    );
    clients.removeWhere(firstGeneration.contains);

    port = await _startHost(host);
    clients.add(await _RawWebSocketClient.connect(port));
    clients.add(await _RawWebSocketClient.connect(port));
    await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
  });

  test('failed HTTP upgrade and abrupt socket loss both release the only slot', () async {
    final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 1, maxHostConnectionsPerSource: 1);
    final clients = <_RawWebSocketClient>[];
    addTearDown(() => _disposeHostAndClients(host, clients));
    final port = await _startHost(host);

    final httpClient = HttpClient();
    addTearDown(() => httpClient.close(force: true));
    final request = await httpClient.getUrl(Uri.parse('http://127.0.0.1:$port/ws'));
    final response = await request.close();
    expect(response.statusCode, HttpStatus.badRequest);
    await response.drain<void>();

    final afterFailedUpgrade = await _RawWebSocketClient.connect(port);
    clients.add(afterFailedUpgrade);
    await afterFailedUpgrade.dispose();
    clients.remove(afterFailedUpgrade);

    final abrupt = await _RawUpgradedSocket.connect(port);
    addTearDown(abrupt.dispose);
    await abrupt.dispose();
    await _flushEventQueue();

    clients.add(await _RawWebSocketClient.connect(port));
    await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
  });

  test('setup failure after upgrade closes the socket and releases the only slot', () async {
    var rejectSetup = true;
    final host = CompanionRemotePeerService.forTesting(
      maxTotalHostConnections: 1,
      maxHostConnectionsPerSource: 1,
      afterHostUpgrade: () {
        if (!rejectSetup) return;
        rejectSetup = false;
        throw StateError('injected admission setup failure');
      },
    );
    final clients = <_RawWebSocketClient>[];
    addTearDown(() => _disposeHostAndClients(host, clients));
    final port = await _startHost(host);

    final rejected = await WebSocket.connect('ws://127.0.0.1:$port/ws');
    try {
      await rejected.drain<void>().timeout(_ioTimeout);
      expect(rejected.readyState, WebSocket.closed);
    } finally {
      await rejected.close();
    }

    clients.add(await _RawWebSocketClient.connect(port));
    await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
  });

  test('oversized and malformed first messages close generically and always restore capacity', () async {
    final host = CompanionRemotePeerService.forTesting(
      maxTotalHostConnections: 1,
      maxHostConnectionsPerSource: 1,
      maxPreAuthMessageBytes: 256,
      maxFailedAuthAttempts: 20,
    );
    final clients = <_RawWebSocketClient>[];
    final connected = <Object>[];
    final commands = <RemoteCommand>[];
    final connectedSubscription = host.onDeviceConnected.listen(connected.add);
    final commandSubscription = host.onCommandReceived.listen(commands.add);
    addTearDown(() async {
      await connectedSubscription.cancel();
      await commandSubscription.cancel();
      await _disposeHostAndClients(host, clients);
    });
    final port = await _startHost(host);

    final cases = <({String name, dynamic payload, int closeCode})>[
      (name: 'oversized text', payload: 'x' * 257, closeCode: 4003),
      (name: 'oversized UTF-8 text', payload: 'é' * 129, closeCode: 4003),
      (name: 'binary input', payload: <int>[1, 2, 3], closeCode: 4003),
      (name: 'invalid JSON', payload: '{', closeCode: 4003),
      (name: 'non-map JSON', payload: '[]', closeCode: 4003),
      (name: 'invalid Base64', payload: _minimalAuth(clientNonce: '%%%'), closeCode: 4003),
      (name: 'wrong nonce length', payload: _minimalAuth(clientNonce: base64Encode([1, 2])), closeCode: 4003),
      (name: 'missing fields', payload: jsonEncode({'type': 'auth'}), closeCode: 4003),
      (
        name: 'wrong field type',
        payload: jsonEncode({
          'type': 'auth',
          'authTag': 1,
          'clientNonce': base64Encode(List<int>.filled(32, 1)),
          'userUUID': 'user-1',
          'clientIdentifier': 'remote-client',
          'deviceName': 'Remote',
          'platform': 'test',
        }),
        closeCode: 4003,
      ),
      (name: 'unexpected type', payload: jsonEncode({'type': 'ping'}), closeCode: 4002),
    ];

    for (final malformedCase in cases) {
      final client = await _RawWebSocketClient.connect(port);
      clients.add(client);
      client.add(malformedCase.payload);
      await client.authFailed.timeout(_ioTimeout);
      expect(await client.closed.timeout(_ioTimeout), malformedCase.closeCode, reason: malformedCase.name);
      clients.remove(client);
    }

    final capacityProbe = await _RawWebSocketClient.connect(port);
    clients.add(capacityProbe);
    expect(capacityProbe.isOpen, isTrue);
    expect(connected, isEmpty);
    expect(commands, isEmpty);
  });

  test('malformed first message counts once, ignores a queued valid auth, and locks out before upgrade', () async {
    final host = CompanionRemotePeerService.forTesting(
      maxTotalHostConnections: 2,
      maxHostConnectionsPerSource: 2,
      maxFailedAuthAttempts: 2,
    );
    final clients = <_RawWebSocketClient>[];
    final connected = <Object>[];
    final subscription = host.onDeviceConnected.listen(connected.add);
    addTearDown(() async {
      await subscription.cancel();
      await _disposeHostAndClients(host, clients);
    });
    final port = await _startHost(host);

    final first = await _RawWebSocketClient.connect(port);
    clients.add(first);
    final validAuth = _validAuthMessage(first.challenge, _authContext);
    first.add('{');
    first.add(validAuth);
    await first.authFailed.timeout(_ioTimeout);
    expect(await first.closed.timeout(_ioTimeout), 4003);
    clients.remove(first);

    final second = await _RawWebSocketClient.connect(port);
    clients.add(second);
    second.add(<int>[9]);
    await second.authFailed.timeout(_ioTimeout);
    expect(await second.closed.timeout(_ioTimeout), 4003);
    clients.remove(second);

    await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
    expect(connected, isEmpty);
  });

  test('unknown context, disallowed user, and bad HMAC share the terminal auth failure path', () async {
    final host = CompanionRemotePeerService.forTesting(maxFailedAuthAttempts: 10);
    final clients = <_RawWebSocketClient>[];
    addTearDown(() => _disposeHostAndClients(host, clients));
    final port = await _startHost(host);

    final attempts = <String Function(_RawWebSocketClient)>[
      (client) => _validAuthMessage(client.challenge, _authContext, authContextId: 'missing-context'),
      (client) => _validAuthMessage(client.challenge, _authContext, userUuid: 'disallowed-user'),
      (client) => _validAuthMessage(client.challenge, _authContext, authTag: 'invalid-tag'),
    ];

    for (final message in attempts) {
      final client = await _RawWebSocketClient.connect(port);
      clients.add(client);
      client.add(message(client));
      await client.authFailed.timeout(_ioTimeout);
      expect(await client.closed.timeout(_ioTimeout), 4003);
      clients.remove(client);
    }
  });

  test('single-context auth remains compatible without authContextId', () async {
    final host = CompanionRemotePeerService.forTesting();
    final clients = <_RawWebSocketClient>[];
    addTearDown(() => _disposeHostAndClients(host, clients));
    final port = await _startHost(host);

    final client = await _RawWebSocketClient.connect(port);
    clients.add(client);
    final connected = host.onDeviceConnected.first;
    client.add(_validAuthMessage(client.challenge, _authContext, includeAuthContextId: false));

    await connected.timeout(_ioTimeout);
    expect(host.isConnected, isTrue);
    expect(host.selectedAuthContextId, _authContext.id);
  });

  test('closed authentication race cannot commit and replacement late close cannot clear the winner', () async {
    final secondDerivationEntered = Completer<void>();
    final releaseSecondDerivation = Completer<void>();
    final secondDerivationReturned = Completer<void>();
    var derivationCount = 0;
    final host = CompanionRemotePeerService.forTesting(
      deriveSessionEncKey: (homeSecret, hostNonce, clientNonce) async {
        derivationCount++;
        if (derivationCount == 2) {
          secondDerivationEntered.complete();
          await releaseSecondDerivation.future;
        }
        final key = await RemoteAuthService.instance.deriveSessionEncKey(homeSecret, hostNonce, clientNonce);
        if (derivationCount == 2 && !secondDerivationReturned.isCompleted) {
          secondDerivationReturned.complete();
        }
        return key;
      },
    );
    final clients = <_RawWebSocketClient>[];
    final connected = <Object>[];
    final subscription = host.onDeviceConnected.listen(connected.add);
    addTearDown(() async {
      if (!releaseSecondDerivation.isCompleted) releaseSecondDerivation.complete();
      await subscription.cancel();
      await _disposeHostAndClients(host, clients);
    });
    final port = await _startHost(host);

    final original = await _RawWebSocketClient.connect(port);
    clients.add(original);
    final originalConnected = host.onDeviceConnected.first;
    original.add(_validAuthMessage(original.challenge, _authContext, deviceName: 'Original'));
    await originalConnected.timeout(_ioTimeout);
    expect(host.isConnected, isTrue);

    final racing = await _RawWebSocketClient.connect(port);
    clients.add(racing);
    racing.add(_validAuthMessage(racing.challenge, _authContext, deviceName: 'Closed racer'));
    await secondDerivationEntered.future.timeout(_ioTimeout);
    await racing.dispose();
    clients.remove(racing);
    releaseSecondDerivation.complete();
    await secondDerivationReturned.future.timeout(_ioTimeout);
    await _flushEventQueue();

    expect(host.isConnected, isTrue);
    expect(original.isOpen, isTrue);
    expect(connected, hasLength(1));

    final replacement = await _RawWebSocketClient.connect(port);
    clients.add(replacement);
    final replacementConnected = host.onDeviceConnected.first;
    replacement.add(_validAuthMessage(replacement.challenge, _authContext, deviceName: 'Replacement'));
    await replacementConnected.timeout(_ioTimeout);
    expect(await original.closed.timeout(_ioTimeout), 4004);
    clients.remove(original);
    await _flushEventQueue();

    expect(host.isConnected, isTrue);
    expect(connected, hasLength(2));
  });

  test('address probes release every source slot before the managed connection', () async {
    final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 2, maxHostConnectionsPerSource: 2);
    final remote = CompanionRemotePeerService();
    addTearDown(() async {
      await remote.dispose();
      await host.dispose();
    });
    final port = await _startHost(host);
    final address = '127.0.0.1:$port';

    final winner = await remote.joinSessionRacingWithContexts(
      'Test Remote',
      'ios',
      [address, address],
      [_authContext],
      authContextId: _authContext.id,
      expectedHostClientId: _authContext.clientIdentifier,
    );

    expect(winner, address);
    final command = host.onCommandReceived.firstWhere((event) => event.type == RemoteCommandType.play);
    remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play));
    expect((await command.timeout(_ioTimeout)).type, RemoteCommandType.play);
  });

  test('managed join waits for every probe terminal event and listener cleanup', () async {
    final closeRequested = List.generate(2, (_) => Completer<void>());
    final listenerCleanedUp = List.generate(2, (_) => Completer<void>());
    final controllers = List.generate(2, (_) => StreamController<dynamic>(sync: true));
    var nextProbe = 0;
    final managedJoinStarted = Completer<void>();
    late final _ManagedJoinObservingPeer remote;
    remote = _ManagedJoinObservingPeer(
      raceProbeFactory: (_) {
        final index = nextProbe++;
        return (
          close: () async {
            if (!closeRequested[index].isCompleted) {
              closeRequested[index].complete();
            }
          },
          ready: Future<void>.value(),
          stream: _CancelTrackingStream(controllers[index].stream, () {
            if (!listenerCleanedUp[index].isCompleted) {
              listenerCleanedUp[index].complete();
            }
          }),
        );
      },
      onManagedJoin: () async {
        expect(listenerCleanedUp, everyElement(predicate<Completer<void>>((item) => item.isCompleted)));
        managedJoinStarted.complete();
      },
    );
    addTearDown(() async {
      for (final controller in controllers) {
        if (!controller.isClosed) await controller.close();
      }
      await remote.dispose();
    });

    final join = remote.joinSessionRacingWithContexts(
      'Test Remote',
      'ios',
      const ['probe-a', 'probe-b'],
      [_authContext],
    );
    expect(nextProbe, 2);

    controllers.first.add(jsonEncode({'type': 'challenge'}));
    await closeRequested.first.future.timeout(_ioTimeout);
    expect(managedJoinStarted.isCompleted, isFalse);

    await controllers.first.close();
    await closeRequested.last.future.timeout(_ioTimeout);
    expect(listenerCleanedUp.first.isCompleted, isTrue);
    expect(managedJoinStarted.isCompleted, isFalse);

    await controllers.last.close();
    expect(await join.timeout(_ioTimeout), 'probe-a');
    expect(listenerCleanedUp, everyElement(predicate<Completer<void>>((item) => item.isCompleted)));
    expect(managedJoinStarted.isCompleted, isTrue);
  });

  test('host and remote dispatch encrypted commands through the same contract', () async {
    final host = CompanionRemotePeerService();
    final remote = CompanionRemotePeerService();
    addTearDown(() async {
      await remote.dispose();
      await host.dispose();
    });

    final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
    await remote.joinSessionWithContexts(
      'Test Remote',
      'ios',
      '127.0.0.1:${session.port}',
      [_authContext],
      authContextId: _authContext.id,
      expectedHostClientId: _authContext.clientIdentifier,
    );

    final hostCommand = host.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.play);
    remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}));
    expect(
      await hostCommand.timeout(_ioTimeout),
      const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}),
    );

    final remoteCommand = remote.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.pause);
    host.sendCommand(const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}));
    expect(
      await remoteCommand.timeout(_ioTimeout),
      const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}),
    );
  });
}

typedef _TestRaceProbeConnection = ({Future<void> Function() close, Future<void> ready, Stream<dynamic> stream});

class _ManagedJoinObservingPeer extends CompanionRemotePeerService {
  _ManagedJoinObservingPeer({
    required _TestRaceProbeConnection Function(Uri uri) raceProbeFactory,
    required this._onManagedJoin,
  }) : super.forTesting(raceProbeFactory: raceProbeFactory);

  final Future<void> Function() _onManagedJoin;

  @override
  Future<void> joinSessionWithContexts(
    String deviceName,
    String platform,
    String hostAddress,
    List<RemoteAuthContext> authContexts, {
    String? authContextId,
    String expectedHostClientId = '',
  }) {
    return _onManagedJoin();
  }
}

class _CancelTrackingStream<T> extends Stream<T> {
  _CancelTrackingStream(this._delegate, this._onCancel);

  final Stream<T> _delegate;
  final void Function() _onCancel;

  @override
  StreamSubscription<T> listen(
    void Function(T event)? onData, {
    Function? onError,
    void Function()? onDone,
    bool? cancelOnError,
  }) {
    return _CancelTrackingSubscription(
      _delegate.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError),
      _onCancel,
    );
  }
}

class _CancelTrackingSubscription<T> implements StreamSubscription<T> {
  _CancelTrackingSubscription(this._delegate, this._onCancel);

  final StreamSubscription<T> _delegate;
  final void Function() _onCancel;

  @override
  Future<void> cancel() {
    _onCancel();
    return _delegate.cancel();
  }

  @override
  void onData(void Function(T data)? handleData) => _delegate.onData(handleData);

  @override
  void onError(Function? handleError) => _delegate.onError(handleError);

  @override
  void onDone(void Function()? handleDone) => _delegate.onDone(handleDone);

  @override
  void pause([Future<void>? resumeSignal]) => _delegate.pause(resumeSignal);

  @override
  void resume() => _delegate.resume();

  @override
  bool get isPaused => _delegate.isPaused;

  @override
  Future<E> asFuture<E>([E? futureValue]) => _delegate.asFuture(futureValue);
}

final _authContext = RemoteAuthContext(
  id: 'context-1',
  backend: 'plex',
  connectionId: 'connection-1',
  homeSecret: List<int>.generate(32, (index) => index),
  discoveryKey: List<int>.generate(32, (index) => 255 - index),
  clientIdentifier: 'host-client',
  userUuid: 'user-1',
  allowedUserUuids: const ['user-1'],
);

Future<int> _startHost(CompanionRemotePeerService host) async {
  final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
  return session.port;
}

Future<void> _disposeHostAndClients(CompanionRemotePeerService host, List<_RawWebSocketClient> clients) async {
  for (final client in List<_RawWebSocketClient>.of(clients)) {
    await client.dispose();
  }
  clients.clear();
  await host.dispose();
}

Future<void> _expectWebSocketHandshakeStatus(int port, int statusCode) async {
  await expectLater(
    WebSocket.connect('ws://127.0.0.1:$port/ws'),
    throwsA(
      isA<WebSocketException>().having((error) => error.toString(), 'message', contains('status code: $statusCode')),
    ),
  );
}

String _minimalAuth({required String clientNonce}) {
  return jsonEncode({
    'type': 'auth',
    'authTag': 'tag',
    'clientNonce': clientNonce,
    'userUUID': 'user-1',
    'clientIdentifier': 'remote-client',
    'deviceName': 'Remote',
    'platform': 'test',
  });
}

String _validAuthMessage(
  Map<String, dynamic> challenge,
  RemoteAuthContext context, {
  String? authContextId,
  String? userUuid,
  String? authTag,
  String deviceName = 'Raw Remote',
  bool includeAuthContextId = true,
}) {
  final hostNonce = base64Decode(challenge['nonce'] as String);
  final clientNonce = RemoteAuthService.instance.generateNonce();
  final selectedUserUuid = userUuid ?? context.userUuid;
  final computedAuthTag = RemoteAuthService.instance.computeAuthTag(
    homeSecret: context.homeSecret,
    hostNonce: hostNonce,
    clientNonce: clientNonce,
    hostClientId: context.clientIdentifier,
    userUUID: selectedUserUuid,
    clientIdentifier: 'raw-remote-client',
    deviceName: deviceName,
    platform: 'test',
  );
  return jsonEncode({
    'type': 'auth',
    if (includeAuthContextId) 'authContextId': authContextId ?? context.id,
    'clientNonce': base64Encode(clientNonce),
    'userUUID': selectedUserUuid,
    'clientIdentifier': 'raw-remote-client',
    'deviceName': deviceName,
    'platform': 'test',
    'authTag': authTag ?? computedAuthTag,
  });
}

Future<void> _flushEventQueue() async {
  await Future<void>.delayed(Duration.zero);
  await Future<void>.delayed(Duration.zero);
}

class _RawWebSocketClient {
  _RawWebSocketClient._(this.socket) {
    _subscription = socket.listen(
      (data) {
        if (data is! String) return;
        try {
          final decoded = jsonDecode(data);
          if (decoded is! Map<String, dynamic>) return;
          if (decoded['type'] == 'challenge' && !_challenge.isCompleted) {
            _challengeValue = decoded;
            _challenge.complete(decoded);
          } else if (decoded['type'] == 'authFailed' && !_authFailed.isCompleted) {
            _authFailed.complete();
          }
        } catch (_) {
          // Encrypted and malformed server messages are not part of this helper.
        }
      },
      onDone: () {
        if (!_closed.isCompleted) _closed.complete(socket.closeCode);
      },
      onError: (Object error, StackTrace stackTrace) {
        if (!_closed.isCompleted) _closed.completeError(error, stackTrace);
      },
      cancelOnError: true,
    );
  }

  static Future<_RawWebSocketClient> connect(int port) async {
    // Ownership transfers to the returned helper.
    // ignore: close_sinks
    final socket = await WebSocket.connect('ws://127.0.0.1:$port/ws').timeout(_ioTimeout);
    final client = _RawWebSocketClient._(socket);
    try {
      await client._challenge.future.timeout(_ioTimeout);
      return client;
    } catch (_) {
      await client.dispose();
      rethrow;
    }
  }

  final WebSocket socket;
  final Completer<Map<String, dynamic>> _challenge = Completer<Map<String, dynamic>>();
  final Completer<void> _authFailed = Completer<void>();
  final Completer<int?> _closed = Completer<int?>();
  late final StreamSubscription<dynamic> _subscription;
  late Map<String, dynamic> _challengeValue;

  Map<String, dynamic> get challenge => _challengeValue;
  Future<void> get authFailed => _authFailed.future;
  Future<int?> get closed => _closed.future;
  bool get isOpen => socket.readyState == WebSocket.open;

  void add(dynamic data) => socket.add(data);

  Future<void> dispose() async {
    if (!_closed.isCompleted) {
      try {
        await socket.close(WebSocketStatus.normalClosure);
      } catch (_) {
        // The peer may already have closed the transport.
      }
    }
    if (!_closed.isCompleted) {
      await _closed.future.timeout(_ioTimeout);
    }
    await _subscription.cancel();
  }
}

class _RawUpgradedSocket {
  _RawUpgradedSocket._(this.socket, this._subscription, this._done);

  static Future<_RawUpgradedSocket> connect(int port) async {
    final socket = await Socket.connect(InternetAddress.loopbackIPv4, port).timeout(_ioTimeout);
    final responseHeaders = StringBuffer();
    final upgraded = Completer<void>();
    final done = Completer<void>();
    late final StreamSubscription<List<int>> subscription;
    subscription = socket.listen(
      (bytes) {
        responseHeaders.write(latin1.decode(bytes));
        if (!upgraded.isCompleted && responseHeaders.toString().contains('\r\n\r\n')) {
          upgraded.complete();
        }
      },
      onError: (Object error, StackTrace stackTrace) {
        if (!upgraded.isCompleted) upgraded.completeError(error, stackTrace);
        if (!done.isCompleted) done.complete();
      },
      onDone: () {
        if (!upgraded.isCompleted) {
          upgraded.completeError(StateError('Socket closed before WebSocket upgrade'));
        }
        if (!done.isCompleted) done.complete();
      },
    );
    try {
      final key = base64Encode(List<int>.generate(16, (index) => index));
      socket.write(
        'GET /ws HTTP/1.1\r\n'
        'Host: 127.0.0.1:$port\r\n'
        'Connection: Upgrade\r\n'
        'Upgrade: websocket\r\n'
        'Sec-WebSocket-Version: 13\r\n'
        'Sec-WebSocket-Key: $key\r\n'
        '\r\n',
      );
      await socket.flush();
      await upgraded.future.timeout(_ioTimeout);
      expect(responseHeaders.toString(), startsWith('HTTP/1.1 101'));
      return _RawUpgradedSocket._(socket, subscription, done);
    } catch (_) {
      socket.destroy();
      await subscription.cancel();
      rethrow;
    }
  }

  final Socket socket;
  final StreamSubscription<List<int>> _subscription;
  final Completer<void> _done;

  Future<void> dispose() async {
    if (!_done.isCompleted) {
      socket.destroy();
      await _done.future.timeout(_ioTimeout);
    }
    await _subscription.cancel();
  }
}
