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

import 'package:flutter/foundation.dart'
    show TargetPlatform, defaultTargetPlatform;
import 'package:flutter_webrtc/flutter_webrtc.dart';

import '../utils/logger.dart';
import '../utils/parse.dart';
import 'call_admin.dart';
import 'call_bridge.dart';
import 'call_info.dart';
import 'conversation_params.dart';
import 'ws2_signaling.dart';

enum CallRole { caller, callee, joiner }

enum CallSessionState { connecting, ringing, active, ended }

class CallParticipant {
  final int id;
  final bool isSelf;
  int? externalId;
  String state;
  bool audioEnabled;
  bool videoEnabled;
  bool screenSharing;
  bool handRaised;
  List<String> roles;

  CallParticipant({
    required this.id,
    this.isSelf = false,
    this.externalId,
    this.state = '',
    this.audioEnabled = true,
    this.videoEnabled = false,
    this.screenSharing = false,
    this.handRaised = false,
    this.roles = const [],
  });

  bool get isAdmin => roles.contains('ADMIN') || roles.contains('CREATOR');
  bool get isCreator => roles.contains('CREATOR');
  bool get isSpeaker => roles.contains('SPEAKER');
}

class CallChatMessage {
  final String text;
  final bool mine;
  final DateTime time;

  CallChatMessage({required this.text, required this.mine, required this.time});
}

class CallSession {
  final Ws2Config ws2Config;

  final ConversationParams? params;
  final CallRole role;
  final bool isGroup;

  CallSession({
    required this.ws2Config,
    required this.role,
    this.params,
    this.isGroup = false,
  });

  Ws2Signaling? _signaling;
  RTCPeerConnection? _pc;
  MediaStream? _localStream;
  MediaStream? _remoteStreamRef;

  int? _peerId;
  String _peerType = 'USER';
  int _peerDeviceIdx = 0;

  bool _muted = false;
  bool _accepted = false;
  bool _peerMuted = false;
  bool _peerVideo = false;
  bool _mediaConnected = false;
  bool _remoteDescSet = false;
  bool _ownRemoteStream = false;
  final List<RTCIceCandidate> _pendingCandidates = [];
  Future<void> _tail = Future.value();

  final Map<int, CallParticipant> _participants = {};
  final Map<int, MediaStream> _participantStreams = {};
  final _participantStreamUpdates = StreamController<int>.broadcast();

  String? _topology;
  List _iceServers = const [];
  Object? _sfuSessionId;
  Set<int> _speaking = const {};

  bool _localVideo = false;
  bool _localScreen = false;
  MediaStream? _cameraStream;
  MediaStream? _screenStream;
  RTCRtpSender? _videoSender;
  RTCRtpSender? _screenSender;

  Completer<void>? _gatherReady;
  bool _gotConnection = false;

  bool _reconnecting = false;
  bool _iceRestarting = false;
  int _iceRestarts = 0;
  static const int _maxIceRestarts = 6;
  static const int _maxReconnectAttempts = 12;
  static const Duration _maxReconnectDelay = Duration(seconds: 20);

  bool get isReconnecting => _reconnecting;

  Timer? _levelTimer;
  final Map<int, int> _speakHold = {};

  static const double _speakLevelOn = 0.05;
  static const int _speakHoldTicks = 3;

  RTCDataChannel? _probeChannel;
  bool _peerIsKomet = false;

  static const String _probeQuestion = 'AreYouKomet?';
  static const String _probeAnswer = 'YesImKomet😎';

  final List<CallChatMessage> _chat = [];
  final _chatController = StreamController<CallChatMessage>.broadcast();
  final _gameController = StreamController<Map<String, dynamic>>.broadcast();

  List<CallChatMessage> get chatLog => List.unmodifiable(_chat);
  Stream<CallChatMessage> get chatMessages => _chatController.stream;
  Stream<Map<String, dynamic>> get gameMessages => _gameController.stream;

  int get selfUserId => ws2Config.userId;
  int? get peerUserId => _peerId;

  bool get localVideo => _localVideo;
  bool get localScreen => _localScreen;
  MediaStream? get localVideoStream =>
      _localScreen ? _screenStream : _cameraStream;
  MediaStream? get localCameraStream => _cameraStream;
  MediaStream? get localScreenStream => _screenStream;

  CallAdmin? get admin {
    final signaling = _signaling;
    return signaling == null ? null : CallAdmin(signaling);
  }

  List<CallParticipant> get participants =>
      _participants.values.toList(growable: false);

  Map<int, MediaStream> get participantStreams =>
      Map.unmodifiable(_participantStreams);

  Stream<int> get participantStreamUpdates => _participantStreamUpdates.stream;

  MediaStream? streamOf(int participantId) => _participantStreams[participantId];

  int get participantCount => _participants.length;

  bool isSpeaking(int id) => _speaking.contains(id);

  String? get topology => _topology;

  bool get _wantVideo => params?.isVideo == true;

  final CallInfo info = CallInfo();

  final _state = StreamController<CallSessionState>.broadcast();
  final _remoteStream = StreamController<MediaStream>.broadcast();
  final _info = StreamController<void>.broadcast();
  final _kometDetected = StreamController<void>.broadcast();

  Stream<CallSessionState> get stateStream => _state.stream;
  Stream<MediaStream> get remoteStreamStream => _remoteStream.stream;
  MediaStream? get remoteStream => _remoteStreamRef;

  Stream<void> get infoUpdates => _info.stream;

  Stream<void> get peerKometDetected => _kometDetected.stream;
  bool get peerIsKomet => _peerIsKomet;

  bool get isMuted => _muted;
  bool get peerMuted => _peerMuted;
  bool get peerVideo => _peerVideo;
  bool get mediaConnected => _mediaConnected;

  CallSessionState _current = CallSessionState.connecting;
  DateTime? _activeSince;

  CallSessionState get currentState => _current;

  int get elapsedSeconds => _activeSince == null
      ? 0
      : DateTime.now().difference(_activeSince!).inSeconds;

  void _setState(CallSessionState s) {
    if (_current == s || _current == CallSessionState.ended) return;
    if (s == CallSessionState.active) _activeSince ??= DateTime.now();
    _current = s;
    _state.add(s);
  }

  void _notifyInfo() {
    if (!_info.isClosed) _info.add(null);
  }

  Future<void> start() async {
    _setState(CallSessionState.connecting);
    info.region = ws2Config.uri.host;
    await _openSignaling();
    _levelTimer = Timer.periodic(
      const Duration(milliseconds: 300),
      (_) => unawaited(_sampleLevels()),
    );
  }

  Future<void> _openSignaling() async {
    final signaling = Ws2Signaling(ws2Config);
    _signaling = signaling;
    signaling.notifications.listen(
      _enqueue,
      onError: (_) => _onSignalingLost(),
    );
    signaling.done.then((_) => _onSignalingLost());
    await signaling.connect();
    logger.i('[call] signaling connected to ${ws2Config.uri.host}');
    Timer(const Duration(seconds: 10), () {
      if (_ended || _gotConnection) return;
      logger.w(
        '[call] ws2 молчит 10 с: нотификация "connection" не пришла — '
        'конференция закрыта или токен протух',
      );
    });
  }

  void _onSignalingLost() {
    if (_ended || _reconnecting) return;
    logger.w('[call] signaling lost, reconnecting');
    unawaited(_reconnect());
  }

  Future<void> _reconnect() async {
    _reconnecting = true;
    _setState(CallSessionState.connecting);
    _notifyInfo();

    for (var attempt = 1; attempt <= _maxReconnectAttempts; attempt++) {
      final backoff = Duration(seconds: 1 << (attempt - 1));
      final delay = backoff > _maxReconnectDelay ? _maxReconnectDelay : backoff;
      await Future<void>.delayed(delay);
      if (_ended) break;

      logger.i('[call] reconnect attempt $attempt/$_maxReconnectAttempts');
      try {
        await _resetForReconnect();
        await _openSignaling();
        _reconnecting = false;
        return;
      } catch (e) {
        logger.w('[call] reconnect attempt $attempt failed: $e');
      }
    }

    _reconnecting = false;
    if (!_ended) {
      logger.w('[call] reconnect gave up');
      _end();
    }
  }

  Future<void> _restartIce() async {
    if (_ended || _iceRestarting || _topology == 'SERVER') return;
    if (_iceRestarts >= _maxIceRestarts) {
      logger.w('[call] ice restart budget exhausted, ending call');
      _end();
      return;
    }
    _iceRestarting = true;
    _iceRestarts++;
    _setState(CallSessionState.connecting);
    _notifyInfo();
    logger.i('[call] ice restart $_iceRestarts/$_maxIceRestarts');
    try {
      _pendingCandidates.clear();
      await _createAndSendOffer(iceRestart: true);
    } catch (e) {
      logger.w('[call] ice restart failed: $e');
    } finally {
      _iceRestarting = false;
    }
  }

  Future<void> _resetForReconnect() async {
    try {
      await _signaling?.close();
    } catch (_) {}
    _signaling = null;

    try {
      await _probeChannel?.close();
    } catch (_) {}
    _probeChannel = null;

    try {
      await _pc?.close();
    } catch (_) {}
    _pc = null;

    _videoSender = null;
    _screenSender = null;
    _remoteDescSet = false;
    _pendingCandidates.clear();
    _accepted = false;
    _mediaConnected = false;
    _sfuSessionId = null;
    await _clearParticipantStreams();

    for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
      try {
        await track.stop();
      } catch (_) {}
    }
    try {
      await _localStream?.dispose();
    } catch (_) {}
    _localStream = null;

    await _disposeStream(_cameraStream);
    await _disposeStream(_screenStream);
    _cameraStream = null;
    _screenStream = null;
    _localVideo = false;
    _localScreen = false;
  }

  Future<void> _sampleLevels() async {
    final pc = _pc;
    if (pc == null || _ended) return;
    if (!_mediaConnected || _current != CallSessionState.active) return;

    var local = 0.0;
    var remote = 0.0;
    try {
      for (final r in await pc.getStats()) {
        final lvl = r.values['audioLevel'];
        if (lvl is! num) continue;
        final kind = r.values['kind'] ?? r.values['mediaType'];
        if (kind != 'audio') continue;
        if (r.type == 'media-source') {
          local = lvl.toDouble();
        } else if (r.type == 'inbound-rtp') {
          final v = lvl.toDouble();
          if (v > remote) remote = v;
        }
      }
    } catch (_) {
      return;
    }

    final loud = <int>{};
    if (!_muted && local > _speakLevelOn) loud.add(ws2Config.userId);
    final others = _participants.values.where((p) => !p.isSelf).toList();
    if (others.length == 1 && remote > _speakLevelOn) loud.add(others.first.id);

    for (final id in loud) {
      _speakHold[id] = _speakHoldTicks;
    }
    _speakHold.updateAll((id, ticks) => loud.contains(id) ? ticks : ticks - 1);
    _speakHold.removeWhere((_, ticks) => ticks <= 0);

    final next = _speakHold.keys.toSet();
    if (next.length != _speaking.length || !next.containsAll(_speaking)) {
      _speaking = next;
      _notifyInfo();
    }
  }

  void _enqueue(Map<String, dynamic> msg) {
    _tail = _tail.then((_) => _onNotification(msg)).catchError((
      Object e,
      StackTrace st,
    ) {
      logger.w('[call] handler failed for ${msg['notification']}: $e\n$st');
    });
  }

  Future<void> _onNotification(Map<String, dynamic> msg) async {
    final name = msg['notification'] ?? msg['response'] ?? msg['type'];
    logger.i('[call] ws2 <- $name');
    if (msg['type'] == 'error') {
      _onWs2Error(msg);
      return;
    }
    _applyPeerMedia(msg);
    switch (msg['notification']) {
      case 'connection':
        await _onConnection(msg);
        break;
      case 'transmitted-data':
        await _onTransmittedData(msg);
        break;
      case 'accepted-call':
        _setState(CallSessionState.active);
        break;
      case 'registered-peer':
        _applyRegisteredPeer(msg);
        break;
      case 'participant-joined':
      case 'participant-added':
        _onParticipantJoined(msg);
        break;
      case 'media-settings-changed':
        _onParticipantMedia(msg);
        break;
      case 'participant-state-changed':
        _onParticipantStateChanged(msg);
        break;
      case 'roles-changed':
        _onRolesChanged(msg);
        break;
      case 'participants-state-changed':
        _onParticipantsStateChanged(msg);
        break;
      case 'participant-left':
      case 'participant-removed':
        _onParticipantLeft(msg);
        break;
      case 'force-media-settings-change':
      case 'switch-micro':
        _onForcedMedia(msg);
        break;
      case 'mute-participant':
        _onMuteParticipant(msg);
        break;
      case 'hungup':
        _onHungup(msg);
        break;
      case 'topology-changed':
        await _onTopologyChanged(msg);
        break;
      case 'producer-updated':
        await _onProducerUpdated(msg);
        break;
      case 'session-state':
        _onSessionState(msg);
        break;
      case 'closed-conversation':
        _end();
        break;
    }
  }

  void _onWs2Error(Map<String, dynamic> msg) {
    final err = msg['error'];
    logger.w('[call] ws2 error: $err');
    if (err == 'conversation-ended') _end();
  }

  int? _participantIdFrom(Object? raw) {
    if (raw is int) return raw;
    if (raw is! String) return null;
    for (final seg in raw.split(':')) {
      if (seg.isEmpty) continue;
      final c = seg[0];
      if (c == 'u' || c == 'g') {
        final v = int.tryParse(seg.substring(1));
        if (v != null) return v;
      } else if (c != 'd') {
        final v = int.tryParse(seg);
        if (v != null) return v;
      }
    }
    return null;
  }

  void _onForcedMedia(Map<String, dynamic> msg) {
    bool? audioOn;
    final ms = msg['mediaSettings'];
    if (ms is Map && ms['isAudioEnabled'] is bool) {
      audioOn = ms['isAudioEnabled'] as bool;
    }
    final muteStates = msg['muteStates'];
    if (muteStates is Map && muteStates['AUDIO'] is String) {
      audioOn = muteStates['AUDIO'] == 'UNMUTE';
    }
    final mute = msg['mute'];
    if (mute is bool) audioOn = !mute;
    if (audioOn == null) return;
    logger.t('[call] forced media audioEnabled=$audioOn raw=$msg');
    _applyMuted(!audioOn);
  }

  void _onMuteParticipant(Map<String, dynamic> msg) {
    final muteStates = msg['muteStates'];
    if (muteStates is! Map || muteStates['AUDIO'] is! String) return;
    final audioOn = muteStates['AUDIO'] == 'UNMUTE';
    final target = _participantIdFrom(msg['participantId']);
    final muteAll = msg['muteAll'] == true;

    if (target != null) {
      final p = _participants[target];
      if (p != null) {
        p.audioEnabled = audioOn;
        _notifyInfo();
      }
    }
    if (muteAll || target == null || target == ws2Config.userId) {
      _applyMuted(!audioOn);
    }
  }

  void _onHungup(Map<String, dynamic> msg) {
    final raw =
        msg['participantId'] ??
        (msg['participant'] is Map ? (msg['participant'] as Map)['id'] : null);
    if (raw is! int) return;
    if (raw == ws2Config.userId) {
      _end();
      return;
    }
    if (_participants.remove(raw) != null) _notifyInfo();
  }

  void _onSessionState(Map<String, dynamic> msg) {
    logger.t(
      '[call][sfu] session-state id=${msg['participantId']} connected=${msg['connected']}',
    );
  }

  void _resolveParticipants(Object? conversation) {
    if (conversation is! Map) return;
    final list = conversation['participants'];
    if (list is! List) return;
    final seen = <int>{};
    for (final p in list.whereType<Map>()) {
      final id = p['id'];
      if (id is! int) continue;
      seen.add(id);
      _upsertParticipant(
        id,
        externalId: _externalId(p['externalId']),
        state: p['state'] as String?,
        mediaSettings: p['mediaSettings'],
        muteStates: p['muteStates'],
        roles: p['roles'],
      );
    }
    _participants.removeWhere((key, _) => !seen.contains(key));
    _notifyInfo();
  }

  CallParticipant _upsertParticipant(
    int id, {
    int? externalId,
    String? state,
    Object? mediaSettings,
    Object? muteStates,
    bool? handRaised,
    Object? roles,
  }) {
    final p = _participants.putIfAbsent(
      id,
      () => CallParticipant(id: id, isSelf: id == ws2Config.userId),
    );
    if (externalId != null) p.externalId = externalId;
    if (state != null) p.state = state;
    if (mediaSettings is Map) {
      final a = mediaSettings['isAudioEnabled'];
      final v = mediaSettings['isVideoEnabled'];
      final s = mediaSettings['isScreenSharingEnabled'];
      if (a is bool) p.audioEnabled = a;
      if (v is bool) p.videoEnabled = v;
      if (s is bool) p.screenSharing = s;
    }
    if (muteStates is Map) {
      final a = muteStates['AUDIO'];
      final v = muteStates['VIDEO'];
      final s = muteStates['SCREEN_SHARING'];
      if (a is String) p.audioEnabled = a == 'UNMUTE';
      if (v is String) p.videoEnabled = v == 'UNMUTE';
      if (s is String) p.screenSharing = s == 'UNMUTE';
    }
    if (handRaised != null) p.handRaised = handRaised;
    if (roles is List) {
      p.roles = roles.whereType<String>().toList(growable: false);
    }
    return p;
  }

  int? _externalId(Object? ext) {
    if (ext is! Map) return null;
    return parseIntOrNull(ext['id']);
  }

  bool? _handFrom(Object? participantState) {
    if (participantState is! Map) return null;
    final state = participantState['state'];
    if (state is! Map || !state.containsKey('hand')) return null;
    return state['hand'] == '1' || state['hand'] == true;
  }

  void _onParticipantMedia(Map<String, dynamic> msg) {
    final id = _participantIdFrom(msg['participantId']);
    if (id == null) return;
    _upsertParticipant(
      id,
      externalId: _externalId(msg['externalId']),
      mediaSettings: msg['mediaSettings'],
      muteStates: msg['muteStates'],
    );
    _maybeAdoptPeer(id, msg);
    _notifyInfo();
  }

  void _onParticipantJoined(Map<String, dynamic> msg) {
    final nested = msg['participant'];
    final p = nested is Map ? nested : msg;
    final id = _participantIdFrom(
      p['id'] ?? p['participantId'] ?? msg['participantId'],
    );
    if (id == null) return;
    _upsertParticipant(
      id,
      externalId: _externalId(p['externalId']),
      state: p['state'] as String?,
      mediaSettings: p['mediaSettings'],
      muteStates: p['muteStates'],
      handRaised: _handFrom(p['participantState']),
      roles: p['roles'],
    );
    _maybeAdoptPeer(id, p);
    _notifyInfo();
  }

  void _maybeAdoptPeer(int id, Map<dynamic, dynamic> source) {
    if (role != CallRole.joiner || _peerId != null || _pc == null) return;
    if (_topology == 'SERVER') return;
    if (id == ws2Config.userId) return;
    _peerId = id;
    final type = source['participantType'] ?? source['idType'];
    if (type is String && type.isNotEmpty) _peerType = type;
    final deviceIdx = source['deviceIdx'];
    if (deviceIdx is int) _peerDeviceIdx = deviceIdx;
    logger.t('[call] adopting peer $_peerId on join');
    unawaited(_createAndSendOffer());
  }

  void _onRolesChanged(Map<String, dynamic> msg) {
    final id = _participantIdFrom(msg['participantId']);
    if (id == null) return;
    _upsertParticipant(id, roles: msg['roles']);
    _notifyInfo();
  }

  void _onParticipantStateChanged(Map<String, dynamic> msg) {
    final id = msg['participantId'];
    if (id is! int) return;
    _upsertParticipant(id, handRaised: _handFrom(msg['participantState']));
    _notifyInfo();
  }

  void _onParticipantsStateChanged(Map<String, dynamic> msg) {
    final list = msg['participants'];
    if (list is! List) return;
    for (final p in list.whereType<Map>()) {
      final id = _participantIdFrom(p['participantId'] ?? p['id']);
      if (id == null) continue;
      _upsertParticipant(
        id,
        externalId: _externalId(p['externalId']),
        state: p['state'] as String?,
        mediaSettings: p['mediaSettings'],
        muteStates: p['muteStates'],
        handRaised: _handFrom(p['participantState']),
        roles: p['roles'],
      );
    }
    _notifyInfo();
  }

  void _onParticipantLeft(Map<String, dynamic> msg) {
    final id = msg['participantId'];
    if (id is! int) return;
    if (_participants.remove(id) != null) _notifyInfo();
  }

  Future<void> _onConnection(Map<String, dynamic> msg) async {
    _gotConnection = true;
    logger.i('[call] connection notification received');
    final convParams = msg['conversationParams'];
    final conversation = msg['conversation'];

    final ice = _iceServersFrom(convParams) ?? params?.iceServers ?? const [];
    _iceServers = ice;
    _resolvePeer(conversation);
    _resolveParticipants(conversation);
    _applyConnectionInfo(msg, ice);

    _topology =
        (conversation is Map ? conversation['topology']?.toString() : null) ??
        _topology;
    logger.i('[call] connection role=$role peer=$_peerId topology=$_topology');

    if (_topology == 'SERVER') {
      await _setupSfu();
      await accept(activate: role != CallRole.caller);
      return;
    }

    final pc = await _createPc(ice);
    _pc = pc;
    await _addLocalMedia(pc);

    await pc.addTransceiver(
      kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
      init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
    );

    await _setupKometProbe(pc);

    if (_isDesktop) await _preferVp8Codecs(pc);

    if (role == CallRole.caller) {
      _setState(CallSessionState.ringing);
      await _createAndSendOffer();
    } else if (role == CallRole.joiner) {
      await _createAndSendOffer();
    }
    await accept(activate: role != CallRole.caller);
  }

  Future<RTCPeerConnection> _createPc(List ice) async {
    final pc = await createPeerConnection({
      'iceServers': ice,
      'sdpSemantics': 'unified-plan',
      'bundlePolicy': 'max-bundle',
      'rtcpMuxPolicy': 'require',
      'tcpCandidatePolicy': 'enabled',
      'continualGatheringPolicy': 'gather_continually',
      'audioJitterBufferMaxPackets': 200,
    });
    pc.onIceCandidate = _onLocalCandidate;
    pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
    pc.onDataChannel = (channel) => _bindProbeChannel(channel, ask: false);
    pc.onIceConnectionState = (s) {
      logger.i('[call] ice $s');
      if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return;
      if (_topology != 'SERVER' || _ended) return;
      unawaited(_dumpIceStats(pc));
      logger.w('[call][sfu] ice failed, request-realloc');
      unawaited(
        _signaling?.requestRealloc().catchError(
              (e) => logger.w('[call] request-realloc failed: $e'),
            ) ??
            Future.value(),
      );
    };
    pc.onConnectionState = (s) {
      logger.i('[call] pc $s');
      final connected =
          s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
      if (connected != _mediaConnected) {
        _mediaConnected = connected;
        _notifyInfo();
        if (connected) {
          _iceRestarts = 0;
          if (role == CallRole.joiner || _topology == 'SERVER') {
            _setState(CallSessionState.active);
          }
          unawaited(_resolvePath());
          unawaited(_collectReceivers());
        }
      }
      if (_topology == 'SERVER') return;
      if (s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
        _end();
        return;
      }
      if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {
        unawaited(_restartIce());
      }
    };
    return pc;
  }

  Future<void> _addLocalMedia(RTCPeerConnection pc) async {
    _localStream = await navigator.mediaDevices.getUserMedia({
      'audio': true,
      'video': _wantVideo,
    });
    for (final track in _localStream!.getTracks()) {
      await pc.addTrack(track, _localStream!);
    }
  }

  Future<void> _setupKometProbe(RTCPeerConnection pc) async {
    if (_topology == 'SERVER') return;
    try {
      final channel = await pc.createDataChannel(
        'komet',
        RTCDataChannelInit()..ordered = true,
      );
      _probeChannel = channel;
      _bindProbeChannel(channel, ask: true);
    } catch (_) {}
  }

  void _bindProbeChannel(RTCDataChannel channel, {required bool ask}) {
    channel.onMessage = (message) => _onProbeMessage(channel, message);
    channel.onDataChannelState = (state) {
      if (ask && state == RTCDataChannelState.RTCDataChannelOpen) {
        _sendProbe(channel, _probeQuestion);
      }
    };
  }

  void _onProbeMessage(RTCDataChannel channel, RTCDataChannelMessage message) {
    if (message.isBinary) return;
    final text = message.text;

    final frame = _decodeFrame(text);
    if (frame != null && frame['t'] == 'chat') {
      final body = frame['text'];
      if (body is String && body.isNotEmpty) {
        _addChat(
          CallChatMessage(text: body, mine: false, time: DateTime.now()),
        );
      }
      return;
    }
    if (frame != null && frame['t'] == 'game') {
      final data = Map<String, dynamic>.of(frame)..remove('t');
      if (!_gameController.isClosed) _gameController.add(data);
      return;
    }

    if (text == _probeQuestion) {
      _sendProbe(channel, _probeAnswer);
    } else if (text == _probeAnswer) {
      _markPeerKomet();
    }
  }

  Map<String, dynamic>? _decodeFrame(String text) {
    try {
      final v = jsonDecode(text);
      return v is Map<String, dynamic> ? v : null;
    } catch (_) {
      return null;
    }
  }

  void _sendProbe(RTCDataChannel channel, String text) {
    try {
      channel.send(RTCDataChannelMessage(text));
    } catch (_) {}
  }

  void sendChatMessage(String text) {
    final body = text.trim();
    final channel = _probeChannel;
    if (body.isEmpty || channel == null) return;
    try {
      channel.send(
        RTCDataChannelMessage(jsonEncode({'t': 'chat', 'text': body})),
      );
    } catch (_) {
      return;
    }
    _addChat(CallChatMessage(text: body, mine: true, time: DateTime.now()));
  }

  void sendGame(Map<String, dynamic> data) {
    final channel = _probeChannel;
    if (channel == null) return;
    try {
      channel.send(RTCDataChannelMessage(jsonEncode({'t': 'game', ...data})));
    } catch (_) {}
  }

  void _addChat(CallChatMessage message) {
    _chat.add(message);
    if (!_chatController.isClosed) _chatController.add(message);
  }

  void _markPeerKomet() {
    if (_peerIsKomet) return;
    _peerIsKomet = true;
    logger.t('[call] peer is Komet');
    if (!_kometDetected.isClosed) _kometDetected.add(null);
    _notifyInfo();
  }

  Future<void> _setupSfu() async {
    if (_pc != null) {
      await _pc!.close();
      _pc = null;
      _probeChannel = null;
      _remoteDescSet = false;
      _pendingCandidates.clear();
      for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
        await track.stop();
      }
      await _localStream?.dispose();
      _localStream = null;
      _videoSender = null;
      _screenSender = null;
    }
    _setState(CallSessionState.connecting);
    final pc = await _createPc(_iceServers);
    _pc = pc;
    await _addLocalMedia(pc);
    await _republishVideo(pc);
    logger.i(
      '[call][sfu] allocate-consumer camera=$_localVideo screen=$_localScreen',
    );
    try {
      final reply = await _signaling?.allocateConsumer();
      logger.i('[call][sfu] allocate-consumer reply: $reply');
    } catch (e) {
      logger.w('[call][sfu] allocate-consumer failed: $e');
    }
  }

  Future<void> _rebuildSfuPc() async {
    try {
      await _pc?.close();
    } catch (_) {}
    _pc = null;
    _videoSender = null;
    _screenSender = null;
    _remoteDescSet = false;
    _pendingCandidates.clear();
    await _clearParticipantStreams();

    for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
      try {
        await track.stop();
      } catch (_) {}
    }
    try {
      await _localStream?.dispose();
    } catch (_) {}
    _localStream = null;

    final pc = await _createPc(_iceServers);
    _pc = pc;
    await _addLocalMedia(pc);
    await _republishVideo(pc);
  }

  Future<void> _republishVideo(RTCPeerConnection pc) async {
    final camera = _cameraStream;
    if (camera != null) {
      final tracks = camera.getVideoTracks();
      if (tracks.isNotEmpty) {
        _videoSender = await pc.addTrack(tracks.first, camera);
      }
    }
    final screen = _screenStream;
    if (screen != null) {
      final tracks = screen.getVideoTracks();
      if (tracks.isNotEmpty) {
        _screenSender = await pc.addTrack(tracks.first, screen);
      }
    }
  }

  Future<void> _onTopologyChanged(Map<String, dynamic> msg) async {
    final topo = msg['topology']?.toString();
    if (topo == null) return;
    logger.i('[call] topology-changed -> $topo');
    info.topology = topo;
    final switchingToSfu = topo == 'SERVER' && _topology != 'SERVER';
    _topology = topo;
    _notifyInfo();
    if (switchingToSfu) await _setupSfu();
  }

  Future<void> _onProducerUpdated(Map<String, dynamic> msg) async {
    logger.i(
      '[call][sfu] producer-updated fields=${msg.keys.toList()} '
      'sessionId=${msg['sessionId']}',
    );
    if (_pc == null) return;

    final session = msg['sessionId'];
    final previous = _sfuSessionId;
    if (session != null) _sfuSessionId = session;
    if (previous != null && session != null && session != previous) {
      logger.i('[call][sfu] session changed, recreating peer connection');
      await _rebuildSfuPc();
    }

    final pc = _pc;
    if (pc == null) return;

    final description = msg['description'];
    String? sdp;
    var type = 'offer';
    if (description is Map) {
      sdp = (description['sdp'] ?? description['description']) as String?;
      type = (description['type'] as String?) ?? 'offer';
    } else if (description is String) {
      sdp = description;
    }
    if (sdp == null) {
      logger.w('[call][sfu] producer-updated without sdp: $msg');
      return;
    }

    final ssrcs = _extractSsrcs(sdp);
    logger.i(
      '[call][sfu] producer offer: ${_mLines(sdp)} m-lines, '
      'ssrcs=${ssrcs.length}, candidates=${_countCandidates(sdp)} '
      '(${_candidateTypes(sdp)}), ${_sdpSummary(sdp)}, ice=${_iceServerUrls()}',
    );
    logger.i('[call][sfu] producer m-lines: ${_mLineDetails(sdp)}');
    await pc.setRemoteDescription(RTCSessionDescription(sdp, type));
    _remoteDescSet = true;
    await _flushCandidates();
    await _addRemoteCandidatesFromSdp(pc, sdp);

    final answer = await pc.createAnswer({});
    if (_pc != pc) return;
    await pc.setLocalDescription(answer);
    if (_pc != pc) {
      logger.w('[call][sfu] peer connection replaced, dropping answer');
      return;
    }

    await _awaitReflexiveCandidates(pc);
    if (_pc != pc) {
      logger.w('[call][sfu] peer connection replaced while gathering');
      return;
    }

    RTCSessionDescription? local;
    try {
      local = await pc.getLocalDescription();
    } catch (e) {
      logger.w('[call][sfu] getLocalDescription failed: $e');
    }
    final answerSdp = local?.sdp ?? answer.sdp ?? '';
    if (answerSdp.isEmpty) return;
    logger.i(
      '[call][sfu] answer: ${_mLines(answerSdp)} m-lines, '
      'candidates=${_countCandidates(answerSdp)} '
      '(${_candidateTypes(answerSdp)}), ${_sdpSummary(answerSdp)}, '
      'gathering=${pc.iceGatheringState}',
    );
    logger.i('[call][sfu] answer m-lines: ${_mLineDetails(answerSdp)}');
    await _logSenders();

    try {
      final localSsrcs = _extractSsrcs(answerSdp);
      logger.i('[call][sfu] accept-producer ssrcs=$localSsrcs');
      final reply = await _signaling?.acceptProducer(
        description: _labelLocalTracks(answerSdp),
        ssrcs: localSsrcs,
        sessionId: _sfuSessionId,
      );
      logger.i('[call][sfu] accept-producer reply: $reply');
    } catch (e) {
      logger.w('[call][sfu] accept-producer failed: $e');
    }

    Timer(const Duration(seconds: 5), () {
      if (_pc == pc && !_ended) unawaited(_dumpIceStats(pc));
    });

    if (_accepted) await _sendMediaSettings();
    unawaited(_collectReceivers());
  }

  int _countCandidates(String sdp) =>
      RegExp(r'^a=candidate:', multiLine: true).allMatches(sdp).length;

  String _candidateTypes(String sdp) {
    final counts = <String, int>{};
    for (final m in RegExp(
      r'^a=candidate:.* typ (\w+)',
      multiLine: true,
    ).allMatches(sdp)) {
      final type = m.group(1) ?? '?';
      counts[type] = (counts[type] ?? 0) + 1;
    }
    return counts.isEmpty
        ? 'none'
        : counts.entries.map((e) => '${e.key}=${e.value}').join(' ');
  }

  Future<void> _addRemoteCandidatesFromSdp(
    RTCPeerConnection pc,
    String sdp,
  ) async {
    final mid = RegExp(r'^a=mid:(\S+)', multiLine: true).firstMatch(sdp);
    if (mid == null) return;
    final seen = <String>{};
    var added = 0;
    for (final m in RegExp(
      r'^a=(candidate:\S.*)$',
      multiLine: true,
    ).allMatches(sdp)) {
      final line = m.group(1)!.trim();
      if (!seen.add(line)) continue;
      try {
        await pc.addCandidate(RTCIceCandidate(line, mid.group(1), 0));
        added++;
      } catch (_) {}
    }
    logger.i(
      '[call][sfu] remote candidates added=$added: '
      '${seen.map((c) => c.split(' ').take(6).join(' ')).join(' | ')}',
    );
  }

  Future<void> _dumpIceStats(RTCPeerConnection pc) async {
    try {
      final reports = await pc.getStats();
      final candidates = <String, String>{};
      for (final r in reports) {
        if (r.type != 'local-candidate' && r.type != 'remote-candidate') {
          continue;
        }
        final v = r.values;
        candidates[r.id] =
            '${v['candidateType']}/${v['protocol']} '
            '${v['ip'] ?? v['address']}:${v['port']}';
      }

      for (final r in reports) {
        if (r.type != 'candidate-pair' && r.type != 'googCandidatePair') {
          continue;
        }
        final v = r.values;
        final from = candidates[v['localCandidateId']] ?? '?';
        final to = candidates[v['remoteCandidateId']] ?? '?';
        logger.w(
          '[call][sfu] pair ${v['state'] ?? v['googState']}: $from -> $to '
          'sent=${v['requestsSent']} recv=${v['responsesReceived']} '
          'inRecv=${v['requestsReceived']} nominated=${v['nominated']}',
        );
      }
    } catch (e) {
      logger.w('[call][sfu] ice stats failed: $e');
    }
  }

  String _iceServerUrls() =>
      _iceServers.whereType<Map>().map((s) => '${s['urls']}').join(' | ');

  String _sdpSummary(String sdp) {
    final bundle = RegExp(
      r'^a=group:BUNDLE (.*)$',
      multiLine: true,
    ).firstMatch(sdp);
    final mids = bundle == null
        ? 'none'
        : '${bundle.group(1)!.trim().split(RegExp(r'\s+')).length}';
    final ufrags = RegExp(
      r'^a=ice-ufrag:(\S+)',
      multiLine: true,
    ).allMatches(sdp).map((m) => m.group(1)).toSet().length;
    var active = 0;
    var total = 0;
    for (final m in RegExp(r'^m=\S+ (\d+)', multiLine: true).allMatches(sdp)) {
      total++;
      if (m.group(1) != '0') active++;
    }
    final setup = RegExp(
      r'^a=setup:(\S+)',
      multiLine: true,
    ).allMatches(sdp).map((m) => m.group(1)).toSet().join(',');
    final lite = sdp.contains('a=ice-lite') ? ' ice-lite' : '';
    return 'bundle=$mids ufrags=$ufrags active=$active/$total '
        'setup=$setup$lite';
  }

  int _mLines(String sdp) =>
      RegExp(r'^m=', multiLine: true).allMatches(sdp).length;

  String _mLineDetails(String sdp) {
    final rows = <String>[];
    String? kind;
    String? port;
    String? mid;
    String? dir;
    String? msid;

    void flush() {
      if (kind == null) return;
      rows.add(
        '[$kind:$port mid=${mid ?? '?'} ${dir ?? '?'} msid=${msid ?? '-'}]',
      );
    }

    for (var line in sdp.split('\n')) {
      line = line.trim();
      if (line.startsWith('m=')) {
        flush();
        final parts = line.substring(2).split(' ');
        kind = parts.isEmpty ? '?' : parts.first;
        port = parts.length > 1 ? parts[1] : '?';
        mid = null;
        dir = null;
        msid = null;
      } else if (line.startsWith('a=mid:')) {
        mid = line.substring(6);
      } else if (line == 'a=sendrecv' ||
          line == 'a=recvonly' ||
          line == 'a=sendonly' ||
          line == 'a=inactive') {
        dir = line.substring(2);
      } else if (line.startsWith('a=msid:')) {
        msid = line.substring(7);
      }
    }
    flush();
    return rows.join(' ');
  }

  List<String> _extractSsrcs(String sdp) {
    final set = <String>{};
    for (final m in RegExp(r'a=ssrc:(\d+)', multiLine: true).allMatches(sdp)) {
      final v = m.group(1);
      if (v != null) set.add(v);
    }
    return set.toList();
  }

  String _labelLocalTracks(String sdp) {
    final self = 'u${ws2Config.userId}';
    final names = <String, String>{};
    final camera = _videoSender?.track?.id;
    final screen = _screenSender?.track?.id;
    if (camera != null && camera.isNotEmpty) names[camera] = '$self:sCAMERA';
    if (screen != null && screen.isNotEmpty) names[screen] = '$self:sSCREEN';
    if (names.isEmpty) return sdp;

    var out = sdp;
    for (final entry in names.entries) {
      final id = RegExp.escape(entry.key);
      final name = entry.value;
      out = out.replaceAllMapped(
        RegExp('^a=msid:(\\S+) $id\\s*\$', multiLine: true),
        (m) => 'a=msid:${m[1]} $name',
      );
      out = out.replaceAllMapped(
        RegExp('^(a=ssrc:\\d+ msid:\\S+) $id\\s*\$', multiLine: true),
        (m) => '${m[1]} $name',
      );
      out = out.replaceAllMapped(
        RegExp('^(a=ssrc:\\d+ label:)$id\\s*\$', multiLine: true),
        (m) => '${m[1]}$name',
      );
    }
    return out;
  }

  String _videoDir(String sdp) {
    var inVideo = false;
    String? mline;
    var dir = '?';
    for (var line in sdp.split('\n')) {
      line = line.trim();
      if (line.startsWith('m=')) {
        inVideo = line.startsWith('m=video');
        if (inVideo) mline = line;
      } else if (inVideo &&
          (line == 'a=sendrecv' ||
              line == 'a=recvonly' ||
              line == 'a=sendonly' ||
              line == 'a=inactive')) {
        dir = line.substring(2);
      }
    }
    return mline == null ? 'НЕТ m=video' : '$mline -> $dir';
  }

  Future<void> _onRemoteTrack(RTCTrackEvent event) async {
    logger.t(
      '[call] remote track: ${event.track.kind} id=${event.track.id} '
      'streams=${event.streams.length}',
    );
    await _bindParticipantTrack(event.track);
    if (event.streams.isNotEmpty) {
      _remoteStreamRef = event.streams.first;
      _remoteStream.add(event.streams.first);
    } else {
      await _collectReceivers();
    }
  }

  int? _participantFromTrackId(String? trackId) {
    if (trackId == null) return null;
    for (final prefix in const ['video-', 'audio-']) {
      if (trackId.length > prefix.length && trackId.startsWith(prefix)) {
        final parsed = _participantIdFrom(trackId.substring(prefix.length));
        if (parsed != null) return parsed;
      }
    }
    return null;
  }

  Future<void> _bindParticipantTrack(MediaStreamTrack track) async {
    final id = _participantFromTrackId(track.id);
    if (id == null || id == ws2Config.userId) return;
    var stream = _participantStreams[id];
    if (stream == null) {
      stream = await createLocalMediaStream('komet_p$id');
      _participantStreams[id] = stream;
    }
    if (stream.getTracks().any((t) => t.id == track.id)) return;
    try {
      await stream.addTrack(track);
    } catch (_) {
      return;
    }
    logger.t('[call] track ${track.id} -> participant $id');
    if (!_participantStreamUpdates.isClosed) _participantStreamUpdates.add(id);
  }

  Future<void> _pushRemoteTrack(MediaStreamTrack track) async {
    var stream = _remoteStreamRef;
    if (stream == null) {
      stream = await createLocalMediaStream('komet_remote');
      _ownRemoteStream = true;
    }
    _remoteStreamRef = stream;
    if (!stream.getTracks().any((t) => t.id == track.id)) {
      try {
        await stream.addTrack(track);
      } catch (_) {}
    }
    _remoteStream.add(stream);
  }

  Future<void> _logSenders() async {
    final pc = _pc;
    if (pc == null) return;
    try {
      final rows = <String>[];
      for (final tr in await pc.getTransceivers()) {
        final sent = tr.sender.track;
        final received = tr.receiver.track;
        rows.add(
          '[mid=${tr.mid} dir=${await tr.getCurrentDirection()} '
          'send=${sent == null ? '-' : '${sent.kind}:${sent.id}'} '
          'recv=${received == null ? '-' : '${received.kind}:${received.id}'}]',
        );
      }
      logger.i('[call][sfu] transceivers: ${rows.join(' ')}');
    } catch (e) {
      logger.w('[call][sfu] transceiver dump failed: $e');
    }
  }

  Future<void> _collectReceivers() async {
    final pc = _pc;
    if (pc == null) return;
    try {
      for (final tr in await pc.getTransceivers()) {
        final track = tr.receiver.track;
        if (track != null) {
          logger.t('[call] receiver track: ${track.kind} id=${track.id}');
          await _bindParticipantTrack(track);
          await _pushRemoteTrack(track);
        }
      }
    } catch (_) {}
  }

  Future<void> _createAndSendOffer({bool iceRestart = false}) async {
    final pc = _pc;
    final peerId = _peerId;
    if (pc == null || peerId == null) return;

    final offer = await pc.createOffer(iceRestart ? {'iceRestart': true} : {});
    final sdp = offer.sdp ?? '';
    await pc.setLocalDescription(RTCSessionDescription(sdp, offer.type));
    logger.t('[call] our offer video: ${_videoDir(sdp)}');
    await _signaling?.transmitSdp(
      participantId: peerId,
      participantType: _peerType,
      deviceIdx: _peerDeviceIdx,
      type: offer.type!,
      sdp: _labelLocalTracks(sdp),
    );
  }

  bool get _isDesktop =>
      defaultTargetPlatform == TargetPlatform.linux ||
      defaultTargetPlatform == TargetPlatform.windows ||
      defaultTargetPlatform == TargetPlatform.macOS;

  Future<void> _preferVp8Codecs(RTCPeerConnection pc) async {
    try {
      final caps = await getRtpSenderCapabilities('video');
      final all = caps.codecs ?? const <RTCRtpCodecCapability>[];
      final hasVp8 = all.any((c) => c.mimeType.toLowerCase() == 'video/vp8');
      if (!hasVp8) return;
      final preferred = all.where((c) {
        final m = c.mimeType.toLowerCase();
        return m == 'video/vp8' || m == 'video/rtx';
      }).toList();
      if (preferred.isEmpty) return;
      for (final t in await pc.getTransceivers()) {
        try {
          await t.setCodecPreferences(preferred);
        } catch (_) {}
      }
    } catch (e) {
      logger.t('[call] setCodecPreferences недоступен: $e');
    }
  }

  Future<void> _onTransmittedData(Map<String, dynamic> msg) async {
    final pc = _pc;
    if (pc == null) return;

    final data = msg['data'];
    if (data is! Map) return;

    final sdp = data['sdp'];
    if (sdp is Map) {
      final type = sdp['type'] as String?;
      final desc = sdp['sdp'] as String?;
      if (type == null || desc == null) return;

      _applyRemoteSdp(desc);
      logger.t('[call] remote $type video: ${_videoDir(desc)}');

      if (type == 'answer' &&
          pc.signalingState !=
              RTCSignalingState.RTCSignalingStateHaveLocalOffer) {
        logger.t('[call] extra answer ignored (state=${pc.signalingState})');
        return;
      }

      if (type == 'offer' &&
          pc.signalingState ==
              RTCSignalingState.RTCSignalingStateHaveLocalOffer) {
        logger.w('[call] offer glare, rolling back local offer');
        await pc.setLocalDescription(RTCSessionDescription(null, 'rollback'));
      }

      await pc.setRemoteDescription(RTCSessionDescription(desc, type));
      _remoteDescSet = true;
      await _flushCandidates();

      if (type == 'offer') {
        final answer = await pc.createAnswer({});
        await pc.setLocalDescription(answer);
        logger.t('[call] our answer video: ${_videoDir(answer.sdp ?? '')}');
        final peerId = _peerId;
        if (peerId != null) {
          await _signaling?.transmitSdp(
            participantId: peerId,
            participantType: _peerType,
            deviceIdx: _peerDeviceIdx,
            type: answer.type!,
            sdp: _labelLocalTracks(answer.sdp!),
          );
        }
        if (_current == CallSessionState.connecting) {
          _setState(CallSessionState.ringing);
        }
      }
      unawaited(_collectReceivers());
      return;
    }

    final candidate = data['candidate'];
    if (candidate is Map) {
      _applyRemoteCandidate(candidate['candidate']);
      final ice = RTCIceCandidate(
        candidate['candidate'] as String?,
        candidate['sdpMid'] as String?,
        candidate['sdpMLineIndex'] as int?,
      );
      if (_remoteDescSet) {
        try {
          await pc.addCandidate(ice);
        } catch (_) {}
      } else {
        _pendingCandidates.add(ice);
      }
    }
  }

  Future<void> _flushCandidates() async {
    final pc = _pc;
    if (pc == null || _pendingCandidates.isEmpty) return;
    final pending = List<RTCIceCandidate>.from(_pendingCandidates);
    _pendingCandidates.clear();
    for (final c in pending) {
      try {
        await pc.addCandidate(c);
      } catch (_) {}
    }
  }

  static bool _isReflexive(String? line) =>
      line != null &&
      (line.contains(' typ srflx') || line.contains(' typ relay'));

  Future<void> _awaitReflexiveCandidates(
    RTCPeerConnection pc, {
    Duration timeout = const Duration(seconds: 4),
  }) async {
    if (pc.iceGatheringState ==
        RTCIceGatheringState.RTCIceGatheringStateComplete) {
      return;
    }
    try {
      final current = await pc.getLocalDescription();
      if (_isReflexive(current?.sdp)) return;
    } catch (_) {}

    final ready = Completer<void>();
    _gatherReady = ready;
    try {
      await ready.future.timeout(timeout);
      logger.i('[call][sfu] reflexive candidate gathered');
    } catch (_) {
      logger.w('[call][sfu] no reflexive candidate within $timeout');
    } finally {
      _gatherReady = null;
    }
  }

  void _onLocalCandidate(RTCIceCandidate candidate) {
    final pending = _gatherReady;
    if (pending != null &&
        !pending.isCompleted &&
        _isReflexive(candidate.candidate)) {
      pending.complete();
    }
    if (_topology == 'SERVER') return;
    final peerId = _peerId;
    if (peerId == null || candidate.candidate == null) return;
    _signaling?.transmitCandidate(
      participantId: peerId,
      participantType: _peerType,
      deviceIdx: _peerDeviceIdx,
      candidate: candidate.candidate!,
      sdpMid: candidate.sdpMid ?? '0',
      sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
    );
  }

  Future<void> accept({bool activate = true}) async {
    if (_accepted) return;
    _accepted = true;
    logger.i('[call] accept-call sent (activate=$activate)');
    await _signaling?.acceptCall(
      isAudioEnabled: !_muted,
      isVideoEnabled: _localVideo,
      isScreenSharingEnabled: _localScreen,
    );
    if (activate) _setState(CallSessionState.active);
  }

  Future<void> sendAudioEnabledSignal(bool enabled) async {
    await _signaling?.changeMediaSettings(isAudioEnabled: enabled);
  }

  Future<void> setMuted(bool muted) async {
    await _applyMuted(muted, announce: true);
  }

  Future<void> _applyMuted(bool muted, {bool announce = false}) async {
    _muted = muted;
    for (final track
        in _localStream?.getAudioTracks() ?? <MediaStreamTrack>[]) {
      track.enabled = !muted;
    }
    _notifyInfo();
    if (announce) await _sendMediaSettings();
  }

  Future<void> _sendMediaSettings() async {
    await _signaling?.changeMediaSettings(
      isAudioEnabled: !_muted,
      isVideoEnabled: _localVideo,
      isScreenSharingEnabled: _localScreen,
    );
  }

  Future<void> setVideoEnabled(bool on) => on ? _startCamera() : _stopCamera();

  Future<void> setScreenSharing(bool on) =>
      on ? _startScreenShare() : _stopScreenShare();

  Future<void> switchToServerTopology({bool force = false}) async {
    if (_topology == 'SERVER') return;
    try {
      await _signaling?.switchTopology(force: force);
    } catch (e) {
      logger.w('[call] switch-topology failed: $e');
    }
  }

  Future<void> _startCamera() async {
    final pc = _pc;
    if (pc == null) return;

    final stream = await navigator.mediaDevices.getUserMedia(<String, dynamic>{
      'video': true,
      'audio': false,
    });

    await _disposeStream(_cameraStream);
    _cameraStream = stream;

    final tracks = stream.getVideoTracks();
    final track = tracks.isEmpty ? null : tracks.first;
    if (track != null) {
      if (_videoSender == null) {
        _videoSender = await pc.addTrack(track, stream);
      } else {
        await _videoSender!.replaceTrack(track);
      }
    }

    _localVideo = true;
    await _renegotiate();
    await _sendMediaSettings();
    _notifyInfo();
  }

  Future<void> _stopCamera() async {
    try {
      await _videoSender?.replaceTrack(null);
    } catch (_) {}
    await _disposeStream(_cameraStream);
    _cameraStream = null;
    _localVideo = false;
    await _sendMediaSettings();
    _notifyInfo();
  }

  Future<void> _startScreenShare() async {
    if (_pc == null) return;

    await CallBridge.instance.setScreenShare(true);

    _localScreen = true;
    await _sendMediaSettings();
    _notifyInfo();

    final MediaStream stream;
    try {
      stream = await _captureScreen();
    } catch (e) {
      _localScreen = false;
      await CallBridge.instance.setScreenShare(false);
      await _sendMediaSettings();
      _notifyInfo();
      rethrow;
    }
    logger.i('[call] screen captured, topology=$_topology');

    await _disposeStream(_screenStream);
    _screenStream = stream;

    final pc = _pc;
    if (pc == null) return;

    final tracks = stream.getVideoTracks();
    final track = tracks.isEmpty ? null : tracks.first;
    if (track != null) {
      if (_screenSender == null) {
        _screenSender = await pc.addTrack(track, stream);
      } else {
        await _screenSender!.replaceTrack(track);
      }
    }

    logger.i('[call] screen share published, topology=$_topology');
    await _renegotiate();
    await _sendMediaSettings();
    _notifyInfo();
  }

  Future<MediaStream> _captureScreen() async {
    if (!_isDesktop) {
      return navigator.mediaDevices.getDisplayMedia(<String, dynamic>{
        'video': true,
        'audio': false,
      });
    }
    final sources = await desktopCapturer.getSources(
      types: [SourceType.Screen],
    );
    if (sources.isEmpty) {
      throw StateError('нет доступных экранов для захвата');
    }
    return navigator.mediaDevices.getDisplayMedia(<String, dynamic>{
      'video': {
        'deviceId': {'exact': sources.first.id},
        'mandatory': {'frameRate': 30.0},
      },
      'audio': false,
    });
  }

  Future<void> _stopScreenShare() async {
    try {
      await _screenSender?.replaceTrack(null);
    } catch (_) {}
    await _disposeStream(_screenStream);
    _screenStream = null;
    _localScreen = false;
    await CallBridge.instance.setScreenShare(false);
    await _sendMediaSettings();
    _notifyInfo();
  }

  Future<void> _renegotiate() async {
    if (_topology == 'SERVER') return;
    try {
      await _createAndSendOffer();
    } catch (e) {
      logger.w('[call] renegotiation offer failed: $e');
    }
  }

  Future<void> _clearParticipantStreams() async {
    final streams = _participantStreams.values.toList(growable: false);
    _participantStreams.clear();
    for (final stream in streams) {
      try {
        await stream.dispose();
      } catch (_) {}
    }
  }

  Future<void> _disposeStream(MediaStream? stream) async {
    if (stream == null) return;
    for (final track in stream.getTracks()) {
      try {
        await track.stop();
      } catch (_) {}
    }
    try {
      await stream.dispose();
    } catch (_) {}
  }

  Future<void> hangup({String? reason}) async {
    final r = reason ?? _autoHangupReason();
    try {
      await _signaling?.hangup(reason: r);
    } catch (_) {}
    _end();
  }

  String _autoHangupReason() {
    if (_current != CallSessionState.active) {
      if (role == CallRole.caller) return 'CANCELED';
      if (role == CallRole.callee && !_accepted) return 'REJECTED';
    }
    return 'HUNGUP';
  }

  bool _ended = false;
  void _end() {
    if (_ended) return;
    _ended = true;
    _setState(CallSessionState.ended);
    _dispose();
  }

  Future<void> _dispose() async {
    _levelTimer?.cancel();
    try {
      await _probeChannel?.close();
    } catch (_) {}
    _probeChannel = null;
    for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
      await track.stop();
    }
    await _localStream?.dispose();
    await _disposeStream(_cameraStream);
    await _disposeStream(_screenStream);
    _cameraStream = null;
    _screenStream = null;
    await _pc?.close();
    if (_ownRemoteStream) {
      try {
        await _remoteStreamRef?.dispose();
      } catch (_) {}
    }
    await _clearParticipantStreams();
    await _signaling?.close();
    if (!_participantStreamUpdates.isClosed) {
      await _participantStreamUpdates.close();
    }
    if (!_state.isClosed) await _state.close();
    if (!_remoteStream.isClosed) await _remoteStream.close();
    if (!_info.isClosed) await _info.close();
    if (!_kometDetected.isClosed) await _kometDetected.close();
    if (!_chatController.isClosed) await _chatController.close();
    if (!_gameController.isClosed) await _gameController.close();
  }

  void _applyConnectionInfo(Map<String, dynamic> msg, List iceServers) {
    final conv = msg['conversation'];
    if (conv is Map) {
      info.conversationId = conv['id']?.toString();
      info.topology = conv['topology']?.toString();
      final features = conv['features'];
      if (features is List) info.record = features.contains('RECORD');
      final parts = conv['participants'];
      if (parts is List) {
        for (final p in parts.whereType<Map>()) {
          if (p['id'] != ws2Config.userId) {
            final ms = p['mediaSettings'];
            if (ms is Map) {
              _peerMuted = ms['isAudioEnabled'] != true;
              _peerVideo = ms['isVideoEnabled'] == true;
            }
          }
        }
      }
    }
    final mm = msg['mediaModifiers'];
    if (mm is Map) {
      info.denoise = mm['denoise'] == true || mm['denoiseAnn'] == true;
    }
    info.stun.clear();
    info.turn.clear();
    for (final s in iceServers.whereType<Map>()) {
      final urls = s['urls'];
      final list = urls is List ? urls : [urls];
      for (final u in list) {
        final str = u.toString();
        if (str.startsWith('stun')) {
          info.stun.add(str);
        } else if (str.startsWith('turn')) {
          info.turn.add(str);
        }
      }
    }
    _notifyInfo();
  }

  void _applyPeerMedia(Map<String, dynamic> msg) {
    final ms = msg['mediaSettings'];
    if (ms is! Map) return;
    final pid = msg['participantId'];
    if (_peerId != null && pid != null && pid != _peerId) return;

    final muted = ms['isAudioEnabled'] != true;
    final video = ms['isVideoEnabled'] == true;
    if (muted != _peerMuted || video != _peerVideo) {
      _peerMuted = muted;
      _peerVideo = video;
      _notifyInfo();
      if (video) unawaited(_collectReceivers());
    }
  }

  void _applyRegisteredPeer(Map<String, dynamic> msg) {
    final peer = msg['peerId'];
    if (peer is Map && peer['type'] == 'WEB_TRANSPORT') return;
    final platform = msg['platform'];
    if (platform is String && platform.isNotEmpty) {
      info.peerPlatform = platform;
      _notifyInfo();
    }
  }

  void _applyRemoteSdp(String sdp) {
    info.peerEngine = CallParse.engine(sdp);
    info.audioCodec ??= CallParse.audioCodec(sdp);
    info.dtlsFingerprint ??= CallParse.fingerprint(sdp);
    if (CallParse.hasAnimoji(sdp)) info.animoji = true;
    _notifyInfo();
  }

  void _applyRemoteCandidate(Object? raw) {
    if (raw is! String || raw.isEmpty) return;
    final c = CallParse.candidate(raw);
    final type = c['type'];
    final ip = c['ip'];
    if (ip == null) return;
    if ((type == 'srflx' || type == 'host') && !CallParse.isServerIp(ip)) {
      info.peerIp = ip;
      info.peerNetwork = CallParse.networkLabel(c['cost']);
      _notifyInfo();
    }
  }

  Future<void> _resolvePath() async {
    final pc = _pc;
    if (pc == null) return;
    try {
      final stats = await pc.getStats();
      final byId = {for (final r in stats) r.id: r};
      StatsReport? pair;
      StatsReport? anySucceeded;
      for (final r in stats) {
        if (r.type != 'candidate-pair') continue;
        if (r.values['state'] != 'succeeded') continue;
        anySucceeded ??= r;
        if (r.values['nominated'] == true || r.values['selected'] == true) {
          pair = r;
          break;
        }
      }
      pair ??= anySucceeded;
      if (pair == null) return;
      final local = byId[pair.values['localCandidateId']];
      final remote = byId[pair.values['remoteCandidateId']];
      info.path = CallParse.pathLabel(
        local?.values['candidateType']?.toString(),
        remote?.values['candidateType']?.toString(),
      );
      _notifyInfo();
    } catch (_) {}
  }

  void _resolvePeer(Object? conversation) {
    if (conversation is! Map) return;
    final participants = conversation['participants'];
    if (participants is! List) return;
    for (final p in participants.whereType<Map>()) {
      final id = p['id'];
      if (id is int && id != ws2Config.userId) {
        _peerId = id;
        final responderTypes = p['responderTypes'];
        if (responderTypes is List && responderTypes.isNotEmpty) {
          _peerType = responderTypes.first.toString();
        }
        final deviceIdxs = p['responderDeviceIdxs'];
        if (deviceIdxs is List &&
            deviceIdxs.isNotEmpty &&
            deviceIdxs.first is int) {
          _peerDeviceIdx = deviceIdxs.first as int;
        }
        break;
      }
    }
  }

  List<Map<String, dynamic>>? _iceServersFrom(Object? convParams) {
    if (convParams is! Map) return null;
    final servers = <Map<String, dynamic>>[];
    final stun = convParams['stun'];
    if (stun is Map && stun['urls'] != null) {
      servers.add({'urls': stun['urls']});
    }
    final turn = convParams['turn'];
    if (turn is Map && turn['urls'] != null) {
      servers.add({
        'urls': turn['urls'],
        if (turn['username'] != null) 'username': turn['username'],
        if (turn['credential'] != null) 'credential': turn['credential'],
      });
    }
    return servers.isEmpty ? null : servers;
  }
}
