//
//  CallManager.swift
//  humand
//
//  Created by Oleksandr Shumihin on 16/5/25.
//  Copyright © 2025 Humand. All rights reserved.
//

import AVFoundation
import CallKit
import Combine
import Foundation
import CoreBluetooth
import UIKit
import React
import react_native_video
import StreamVideo
import StreamWebRTC

@objc
class CallManager: NSObject, CBCentralManagerDelegate, CXCallObserverDelegate {
  @objc static let shared = CallManager()

  /// Notification posted to expo-video's `VideoManager` whenever the set of
  /// active CallKit calls changes. Mirrors Telegram-iOS' approach of having
  /// the call manager push a `.voiceCall` audio session holder, which their
  /// media players observe and react to.
  ///
  /// Keep the name / key in sync with
  /// `VideoManager.hostAppCallStateChangedNotification` and
  /// `VideoManager.hostAppCallStateActiveKey` in expo-video.
  private static let expoVideoCallStateNotification = Notification.Name("ExpoVideoHostAppCallStateChanged")
  private static let expoVideoCallStateActiveKey = "isActive"
  private var lastExpoVideoCallActive: Bool = false

  var streamVideo: StreamVideo?
  var streamCall: Call?
  var currentCallingState: String = ""
  var localUserId: String?

  private var lastVoIPToken: String?
  private var voIPTokenObservationCancellable: AnyCancellable?
  private var activeCallObservationCancellable: AnyCancellable?
  private var callEventsCancellable: AnyCancellable?
  private var mediaStateCancellables: Set<AnyCancellable> = []
  private var callKitInitialized = false
  private var callKitInitializing = false
  private var ringbackPlayer: AVAudioPlayer?
  private var humandCallId: String?
  private var providerCallId: String?
  private var isGroup: Bool = false
  private var isCaller: Bool = false
  private var startedCallCallee: Any = NSNull()
  private var participantUserIds: [Int] = []
  private var callTimeoutTimer: DispatchSourceTimer?
  private let audioSession = AVAudioSession.sharedInstance()
  private var bluetoothManager: CBCentralManager?
  private var pendingRouteChangeWorkItem: DispatchWorkItem?
  private var lastNotifiedRoute: String?
  private var isVideoSessionActive = false
  private var localCameraEnabled = false
  private var remoteVideoActive = false
  private var callHasVideo = false
  private var hasEmittedRingingEvent = false
  private var observedRemoteParticipantSessionIds: Set<String> = []
  private let callObserver = CXCallObserver()
  private var pendingEndSource: NativeCallsEndSource = .unknown
  private var pendingEndReason: NativeCallsEndReason = .unknown
  private var pendingEndedByUserId: Any?
  
  /// Returns true only when there is an active call in progress.
  /// Used by AudioRecorder to decide whether to skip audio session setup.
  func isAudioSessionOwnedByCall() -> Bool {
    if hasActiveCall() {
      return true
    }
    return false
  }

  /// Returns true when WebRTC audio infrastructure is still running
  /// (active call OR persistent StreamVideo client for push notifications).
  /// Used by restoreAudioToSpeaker to avoid deactivating the session while
  /// WebRTC's AudioEngineDevice is still alive, which would cause a crash.
  func isWebRTCAudioEngineAlive() -> Bool {
    if hasActiveCall() { return true }
    if streamVideo != nil { return true }
    return false
  }

  override init() {
    InjectedValues[\.callKitPushNotificationAdapter] = HumandCallKitPushNotificationAdapter()

    super.init()

    LogConfig.level = .debug
    LogConfig.subsystems = .callKit
    // Adapter configuration is deferred to `initializeCallKit`. Touching
    // callSettings / participantAutoLeavePolicy / streamVideo on the adapter
    // triggers the CallKitService dispatch_once in Stream SDK 1.46+, which
    // eagerly instantiates LastParticipantAutoLeavePolicy. That policy reads
    // @Injected(\.streamVideo) from its init — fatalError if not yet set.

    // Observe CallKit call lifecycle for the expo-video bridge. CXCallObserver
    // fires for every state transition (new, connecting, connected, ended) on
    // every CallKit call — Stream's VoIP push, native phone calls, FaceTime,
    // outgoing calls we initiate, everything. We translate that into the
    // simple "any voice call active right now?" signal expo-video listens for.
    callObserver.setDelegate(self, queue: nil)
  }

  // MARK: - CXCallObserverDelegate

  func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) {
    // Whenever any CallKit call appears or ends, recompute whether there's a
    // voice call active on the device and tell expo-video. `hasEnded` flips to
    // true at the very same moment the system tears the call down — covering
    // accept → end, local reject, missed call, and remote hangup uniformly.
    let hasActiveCall = callObserver.calls.contains { !$0.hasEnded }
    postExpoVideoCallStateChange(isActive: hasActiveCall)
  }

  /// Posts the expo-video call-state notification, deduplicating no-op
  /// transitions (CXCallObserver fires multiple times during a single call's
  /// lifecycle, e.g. on connecting → connected, and we only care about the
  /// active / not-active edges).
  private func postExpoVideoCallStateChange(isActive: Bool) {
    guard isActive != lastExpoVideoCallActive else { return }
    lastExpoVideoCallActive = isActive
    NotificationCenter.default.post(
      name: Self.expoVideoCallStateNotification,
      object: nil,
      userInfo: [Self.expoVideoCallStateActiveKey: isActive]
    )
  }

  // MARK: - CallKit Adapter Integration

  /// Connect the CallKit adapter to a Stream Video client.
  /// Called when auth tokens become available (login / app restore).
  func initializeCallKit() {
    guard !callKitInitialized, !callKitInitializing else {
      callLog("initializeCallKit: already initialized or initializing, skipping")
      return
    }
    callKitInitializing = true

    Task {
      do {
        let (client, participantId) = try await CallApiService.shared.createStreamClient(
          existingClient: streamVideo,
          existingUserId: localUserId,
          log: callLog,
          context: "CallManager",
          tokenFetcher: {
            do {
              let token = try await CallApiService.withRetry(logger: callLog) {
                if let tokenResponse = await CallApiService.shared.getCallToken() {
                  return tokenResponse
                }
                throw NSError(
                  domain: "CallManager",
                  code: 100,
                  userInfo: [NSLocalizedDescriptionKey: "Call token response was nil"]
                )
              }
              return token
            } catch {
              return nil
            }
          }
        )
        streamVideo = client
        localUserId = participantId

        let adapter = InjectedValues[\.callKitAdapter]
        adapter.availabilityPolicy = .always
        adapter.streamVideo = client
        adapter.callSettings = CallSettings(audioOn: true, videoOn: false, speakerOn: false)
        adapter.participantAutoLeavePolicy = LastParticipantAutoLeavePolicy()
        adapter.registerForIncomingCalls()

        let serviceHasClient = InjectedValues[\.callKitService].streamVideo != nil
        callLog("initializeCallKit: CallKit adapter configured (serviceHasClient: \(serviceHasClient))")

        observeVoIPToken()
        observeActiveCall(client)
        observeCallEvents(client)
        callKitInitialized = true
        callKitInitializing = false

        callLog("initializeCallKit: Stream client connected")
      } catch {
        callLog("initializeCallKit error: \(error.localizedDescription)")
        callKitInitializing = false
      }
    }
  }

  /// Re-attach CallKit adapter and observers after another manager (e.g. LivestreamManager)
  /// may have created a separate StreamVideo client that overrode SDK injected values.
  func restoreCallKitBinding() {
    guard let client = streamVideo else {
      callLog("restoreCallKitBinding: no client to restore")
      return
    }

    // Restore SDK global so internal event routing (CallRingEvent, activeCall) uses call client
    InjectedValues[\.streamVideo] = client

    let adapter = InjectedValues[\.callKitAdapter]
    adapter.streamVideo = client

    observeActiveCall(client)
    observeCallEvents(client)
    callLog("restoreCallKitBinding: CallKit adapter, InjectedValues, and observers re-attached")
  }

  /// Observe VoIP device token changes and register/unregister with Humand backend.
  private func observeVoIPToken() {
    voIPTokenObservationCancellable?.cancel()
    let pushAdapter = InjectedValues[\.callKitPushNotificationAdapter]
    voIPTokenObservationCancellable = pushAdapter.$deviceToken.sink { [weak self] updatedToken in
      Task { [weak self] in
        guard let self = self else { return }
        if let lastToken = self.lastVoIPToken, !lastToken.isEmpty {
          await CallApiService.shared.removeVoipToken(token: lastToken)
        }
        if !updatedToken.isEmpty {
          await CallApiService.shared.registerVoipToken(token: updatedToken)
        }
        self.lastVoIPToken = updatedToken
        callLog("VoIP token updated: \(updatedToken.isEmpty ? "cleared" : "registered")")
      }
    }
  }

  /// Observe call events for following scenarios:
  /// 1. Callee side: current user declined via CallKit → notify Humand backend (Stream only notifies its own backend)
  /// 2. Caller side: callee rejected a 1:1 call → stop ringback and end the call
  /// 3. Callee side: same user joined from another device/session → dismiss CallKit ringing as answered elsewhere
  private func observeCallEvents(_ client: StreamVideo) {
    callEventsCancellable?.cancel()

    callEventsCancellable = client.eventPublisher()
      .sink { [weak self] event in
        guard let self = self else { return }
        if case let .typeCallSessionParticipantJoinedEvent(response) = event {
          let streamCallId = response.callCid.components(separatedBy: ":").last ?? response.callCid
          let joinedUserId = response.participant.user.id
          let joinedSessionId = response.participant.userSessionId

          if joinedUserId == self.localUserId {
            DispatchQueue.main.async {
              let localSessionId = self.streamCall?.state.localParticipant?.sessionId

              guard localSessionId == nil || joinedSessionId != localSessionId else {
                return
              }

              callLog(
                """
                observeCallEvents: same user joined from another session, \
                dismissing ringing for stream call \(streamCallId) \
                (joinedSessionId: \(joinedSessionId), localSessionId: \(localSessionId ?? "nil"))
                """
              )
              self.dismissPendingIncomingCallAsAnsweredElsewhere(streamCallId: streamCallId)
            }
          }
        } else if case let .typeCallRejectedEvent(response) = event {
          if response.user.id == self.localUserId {
            // Callee side: current user rejected via CallKit — notify Humand backend
            let streamCallId = response.call.id
            callLog("observeCallEvents: current user rejected call \(streamCallId)")

            let callData = (InjectedValues[\.callKitPushNotificationAdapter] as? HumandCallKitPushNotificationAdapter)?
              .callData(forStreamCallId: streamCallId)
            let isGroup = callData?[CallConstants.CallKeys.isGroup] as? Bool ?? false
            let hasVideo = self.cameraEnabled(from: callData)
            let caller = self.callerIdentity(from: callData, isGroup: isGroup)
            NativeCallsEventObserver.shared.observeCallRejected(
              callId: callData?[CallConstants.CallKeys.id] as? String ?? streamCallId,
              providerCallId: streamCallId,
              cameraEnabled: hasVideo,
              isGroup: isGroup,
              caller: caller
            )

            guard let pushAdapter = InjectedValues[\.callKitPushNotificationAdapter] as? HumandCallKitPushNotificationAdapter,
                  let humandCallId = pushAdapter.humandCallId(forStreamCallId: streamCallId) else {
              callLog("observeCallEvents: no Humand call ID found for stream call \(streamCallId)")
              return
            }

            Task {
              let success = await CallApiService.shared.reject(id: humandCallId)
              callLog("observeCallEvents: backend reject \(success ? "succeeded" : "failed") for \(humandCallId)")
            }
          } else if self.isCaller && !self.isGroup && response.call.id == self.streamCall?.callId {
            // Caller side: callee rejected a 1:1 call — stop ringback and leave
            callLog("observeCallEvents: callee rejected 1:1 call, leaving")
            Task { @MainActor in
              self.leaveCall(reason: CallConstants.LeaveReason.calleeRejected)
            }
          }
        }
      }
  }

  private func dismissPendingIncomingCallAsAnsweredElsewhere(streamCallId: String) {
    let pendingCalls = callObserver.calls.filter { !$0.hasEnded && !$0.hasConnected }
    guard !pendingCalls.isEmpty else {
      callLog("dismissPendingIncomingCallAsAnsweredElsewhere: no pending CallKit call for stream call \(streamCallId)")
      return
    }

    let callKitService = InjectedValues[\.callKitService]
    for pendingCall in pendingCalls {
      callKitService.callProvider.reportCall(
        with: pendingCall.uuid,
        endedAt: nil,
        reason: .answeredElsewhere
      )
    }

    callLog(
      "dismissPendingIncomingCallAsAnsweredElsewhere: dismissed \(pendingCalls.count) pending CallKit call(s) for stream call \(streamCallId)"
    )
  }

  /// Observe activeCall transitions to notify Humand backend on accept and end/hangup.
  private func observeActiveCall(_ client: StreamVideo) {
    activeCallObservationCancellable?.cancel()

    activeCallObservationCancellable = client.state.$activeCall
      .receive(on: DispatchQueue.main)
      .sink { [weak self] activeCall in
        guard let self = self else { return }
        if let call = activeCall {
          guard call.callType != "livestream" else {
            callLog("observeActiveCall: skipping livestream call \(call.callId)")
            return
          }
          Task { @MainActor in
            self.onCallAccepted(call)
          }
        } else if let streamCallId = self.streamCall?.callId {
          self.onCallEnded(streamCallId: streamCallId)
        }
      }
  }

  @MainActor
  private func onCallAccepted(_ call: Call) {
    // Skip outgoing calls — startCall() handles setup directly
    guard !isCaller else { return }

    let streamCallId = call.callId
    observeCallState(call)

    // Mute mic by default when member count >= threshold
    // For incoming calls, use call members (all invited) as participantUserIds is not set
    let memberCount = call.state.members.count
    let shouldMuteMic = memberCount >= CallConstants.Call.micDisabledParticipantThreshold
    if shouldMuteMic {
      Task {
        try? await call.microphone.disable()
        callLog("onCallAccepted: muted mic (members=\(memberCount), threshold=\(CallConstants.Call.micDisabledParticipantThreshold))")
      }
    }

    guard let pushAdapter = InjectedValues[\.callKitPushNotificationAdapter] as? HumandCallKitPushNotificationAdapter,
          let callData = pushAdapter.callData(forStreamCallId: streamCallId) else { return }

    humandCallId = callData[CallConstants.CallKeys.id] as? String
    isGroup = callData[CallConstants.CallKeys.isGroup] as? Bool ?? false
    callHasVideo = cameraEnabled(from: callData)

    NativeCallsEventObserver.shared.observeCallAccepted(
      callId: humandCallId ?? streamCallId,
      providerCallId: streamCallId,
      cameraEnabled: callHasVideo,
      isGroup: isGroup,
      caller: acceptedCallerIdentity(for: call, callData: callData, isGroup: isGroup)
    )

    RNCallManager.emitAcceptedCall(callData)

    if let humandCallId = humandCallId {
      callLog("onCallAccepted: notifying backend for Humand call \(humandCallId)")
      Task {
        let success = await CallApiService.shared.accept(id: humandCallId)
        callLog("onCallAccepted: backend accept \(success ? "succeeded" : "failed")")
      }
    }
  }

  @MainActor
  private func observeCallState(_ call: Call) {
    streamCall = call
    mediaStateCancellables.removeAll()

    startAudioRouteMonitoring()
    PublicAudioSessionManager.setIsAudioSessionManagementDisabled(true)

    Task {
      await call.updateAudioSessionPolicy(DefaultAudioSessionPolicy())
      // Stable participant sorting matching Android: pin, screen-share, WebRTC over RTMP, join time, user ID.
      await call.updateParticipantsSorting(with: [pinned, screenSharing, participantSource(.webRTCUnspecified), joinedAt, userId])
    }

    // Observe joined state via session
    call.state.$session
      .receive(on: DispatchQueue.main)
      .sink { [weak self] session in
        guard session != nil else { return }
        self?.updateCallingState(CallConstants.CallingState.joined)
      }
      .store(in: &mediaStateCancellables)

    // Observe reconnection status
    call.state.$reconnectionStatus
      .receive(on: DispatchQueue.main)
      .sink { [weak self] status in
        guard let self = self else { return }
        switch status {
        case .reconnecting:
          self.updateCallingState(CallConstants.CallingState.reconnecting)
        case .disconnected:
          self.updateCallingState(CallConstants.CallingState.reconnectingFailed)
          Task { @MainActor in
            self.handlePermanentConnectionFailure(reason: "Stream reconnection status disconnected")
          }
        case .connected:
          if self.currentCallingState == CallConstants.CallingState.reconnecting {
            self.updateCallingState(CallConstants.CallingState.joined)
          }
        default:
          break
        }
      }
      .store(in: &mediaStateCancellables)

    // Observe microphone state
    call.microphone.$status
      .receive(on: DispatchQueue.main)
      .sink { [weak self] status in
        self?.emitMediaStateChanged(microphoneEnabled: status == .enabled, cameraEnabled: nil, speakerEnabled: nil)
      }
      .store(in: &mediaStateCancellables)

    // Observe camera state
    call.camera.$status
      .receive(on: DispatchQueue.main)
      .sink { [weak self] status in
        let isEnabled = status == .enabled
        self?.updateLocalCameraState(isEnabled: isEnabled)
        self?.emitMediaStateChanged(microphoneEnabled: nil, cameraEnabled: isEnabled, speakerEnabled: nil)
      }
      .store(in: &mediaStateCancellables)

    // Observe speaker state
    call.speaker.$status
      .receive(on: DispatchQueue.main)
      .sink { [weak self] status in
        self?.emitMediaStateChanged(microphoneEnabled: nil, cameraEnabled: nil, speakerEnabled: status == .enabled)
      }
      .store(in: &mediaStateCancellables)

    // Observe call ended by remote (endedAt set by Stream when the other party ends the call).
    // Only for 1:1 calls — in group calls, endedAt may be set when one participant ends
    // but remaining participants should stay connected.
    if !isGroup {
      call.state.$endedAt
        .receive(on: DispatchQueue.main)
        .sink { [weak self] endedAt in
          guard let self = self, endedAt != nil else { return }
          callLog("observeCallState: endedAt set, remote party ended the call")
          Task { @MainActor in
            self.leaveCall(reason: CallConstants.LeaveReason.remoteEnded)
          }
        }
        .store(in: &mediaStateCancellables)
    }

    // Observe participants changes
    call.state.$participants
      .receive(on: DispatchQueue.main)
      .sink { [weak self] participants in
        guard let self = self else { return }
        let remoteParticipants = participants.filter { $0.userId != self.localUserId }
        let currentRemoteSessionIds = Set(remoteParticipants.map(\.sessionId))
        let joinedRemoteParticipants = remoteParticipants.filter {
          !self.observedRemoteParticipantSessionIds.contains($0.sessionId)
        }
        self.observedRemoteParticipantSessionIds = currentRemoteSessionIds
        if !remoteParticipants.isEmpty {
          self.cancelCallTimeouts()
          self.stopRingback()
        }
        // Auto-promote to group when 2+ remote participants join
        if remoteParticipants.count >= 2 && !self.isGroup {
          self.isGroup = true
          callLog("Auto-promoted to group call (remote participants: \(remoteParticipants.count))")
        }
        var participantDicts = participants.map { self.participantToDict($0) }
        if let localParticipant = self.streamCall?.state.localParticipant {
          let alreadyIncluded = participants.contains { $0.userId == localParticipant.userId }
          if !alreadyIncluded {
            participantDicts.insert(self.participantToDict(localParticipant), at: 0)
          }
        }
        let remoteHasVideo = participants.contains { $0.userId != self.localUserId && $0.hasVideo }
        self.updateRemoteVideoState(hasRemoteVideo: remoteHasVideo)
        joinedRemoteParticipants.forEach { participant in
          guard let callId = self.humandCallId ?? self.streamCall?.callId else { return }
          NativeCallsEventObserver.shared.observeParticipantJoined(
            callId: callId,
            providerCallId: call.callId,
            cameraEnabled: self.callHasVideo,
            isGroup: self.isGroup,
            participant: self.normalizedParticipantIdentity(participant.userId)
          )
        }
        self.emitParticipantsChanged(participantDicts)
      }
      .store(in: &mediaStateCancellables)

  }

  private func onCallEnded(streamCallId: String) {
    callLog("onCallEnded: set callEndedAt for audio session cooldown")
    cancelCallTimeouts()
    stopRingback()
    resetVideoSessionState()
    stopAudioRouteMonitoring()
    if let callId = humandCallId ?? (InjectedValues[\.callKitPushNotificationAdapter] as? HumandCallKitPushNotificationAdapter)?
      .humandCallId(forStreamCallId: streamCallId) {
      NativeCallsEventObserver.shared.observeCallEnded(
        callId: callId,
        providerCallId: streamCallId,
        cameraEnabled: callHasVideo,
        isGroup: isGroup,
        endSource: pendingEndSource,
        endReason: pendingEndReason,
        endedByUserId: pendingEndedByUserId
      )
    }
    RNCallManager.emitEndCall()
    let isGroupCall = isGroup
    let endedCall = streamCall

    streamCall = nil
    mediaStateCancellables.removeAll()

    let callId = humandCallId ?? {
      guard let pushAdapter = InjectedValues[\.callKitPushNotificationAdapter] as? HumandCallKitPushNotificationAdapter else { return nil }
      return pushAdapter.humandCallId(forStreamCallId: streamCallId)
    }()

    if let pushAdapter = InjectedValues[\.callKitPushNotificationAdapter] as? HumandCallKitPushNotificationAdapter {
      pushAdapter.removeMapping(forStreamCallId: streamCallId)
    }

    humandCallId = nil
    providerCallId = nil
    isGroup = false
    isCaller = false
    startedCallCallee = NSNull()
    participantUserIds = []
    callHasVideo = false
    hasEmittedRingingEvent = false
    observedRemoteParticipantSessionIds.removeAll()
    pendingEndSource = .unknown
    pendingEndReason = .unknown
    pendingEndedByUserId = nil

    Task {
      if let callId = callId, !isGroupCall {
        let success = await CallApiService.shared.end(id: callId)
        callLog("onCallEnded: backend end \(success ? "succeeded" : "failed") (1:1)")
      }
    }

    restoreAudioToSpeaker()
  }

  @MainActor
  private func hasRemoteParticipants(in call: Call?) -> Bool {
    guard let call = call else { return false }
    return call.state.participants.contains { $0.userId != localUserId }
  }

  @MainActor
  private func shouldNotifyBackendOfCallEnd(for isGroupCall: Bool, call: Call?) -> Bool {
    if isGroupCall {
      return !hasRemoteParticipants(in: call)
    }

    return false
  }

  @MainActor
  private func handlePermanentConnectionFailure(reason: String) {
    guard streamCall != nil else {
      callLog("handlePermanentConnectionFailure: \(reason) but no active call to clean up")
      return
    }
    callLog("handlePermanentConnectionFailure: \(reason) — leaving with reconnectionFailed")
    // Delegate to leaveCall so the SFU receives the failure reason and the
    // activeCall observer drives onCallEnded (with leaveCall's 500ms fallback
    // if the observer does not fire).
    leaveCall(reason: CallConstants.LeaveReason.reconnectionFailed)
  }

  func setIsGroup(_ isGroup: Bool) {
    self.isGroup = isGroup
    callLog("setIsGroup: \(isGroup)")
  }

  // MARK: - Leave Call

  @MainActor
  func leaveCall(reason: String? = nil) {
    stopRingback()
    guard let call = streamCall else { return }
    let streamCallId = call.callId
    let leaveReason = reason ?? CallConstants.LeaveReason.userInitiated
    capturePendingEndContext(reason: leaveReason, call: call)
    let callId = humandCallId
    let isGroupCall = isGroup
    Task {
      let shouldNotifyBackend = await MainActor.run {
        self.shouldNotifyBackendOfCallEnd(for: isGroupCall, call: call)
      }
      if let callId = callId, shouldNotifyBackend {
        let success = await CallApiService.shared.end(id: callId)
        callLog("leaveCall: backend end \(success ? "succeeded" : "failed") (group-last-participant)")
      }
      call.leave(reason: leaveReason)
      // For outgoing group calls, the activeCall observer may not fire
      // after leave() because CallKit didn't initiate the call and the
      // call continues for other participants. Trigger cleanup directly
      // to ensure the audio session is released.
      try? await Task.sleep(nanoseconds: 500_000_000)
      await MainActor.run {
        if self.streamCall != nil {
          callLog("leaveCall: activeCall observer did not fire, triggering onCallEnded manually")
          self.onCallEnded(streamCallId: streamCallId)
        }
      }
    }
  }

  // MARK: - Ringback Tone

  private func startRingback() {
    guard let url = Bundle.main.url(forResource: "incallmanager_ringback", withExtension: "mp3") else {
      callLog("startRingback: sound file not found")
      return
    }
    do {
      let player = try AVAudioPlayer(contentsOf: url)
      player.numberOfLoops = -1
      player.play()
      ringbackPlayer = player
      emitObservedCallRinging(providerCallId: providerCallId ?? streamCall?.callId)
      callLog("startRingback: playing")
    } catch {
      callLog("startRingback error: \(error.localizedDescription)")
    }
  }

  private func stopRingback() {
    guard ringbackPlayer != nil else { return }
    ringbackPlayer?.stop()
    ringbackPlayer = nil
    callLog("stopRingback: stopped")
  }

  // MARK: - Call Timeouts

  private func startOutgoingCallTimeout() {
    cancelCallTimeouts()
    callLog("startOutgoingCallTimeout: \(CallConstants.Timeout.outgoingCallSeconds)s")
    let timer = DispatchSource.makeTimerSource(queue: .main)
    timer.schedule(deadline: .now() + CallConstants.Timeout.outgoingCallSeconds)
    timer.setEventHandler { [weak self] in
      guard let self = self else { return }
      let callId = self.humandCallId
      let userIds = self.participantUserIds
      Task {
        // Safety check: if a remote participant has already joined, the timeout
        // should have been cancelled by the $participants observer. If it wasn't
        // (e.g. due to a Combine delivery race), skip the timeout action.
        let remoteParticipantAlreadyJoined = await MainActor.run {
          self.streamCall?.state.participants.contains {
            $0.userId != self.localUserId
          } ?? false
        }
        if remoteParticipantAlreadyJoined {
          callLog("startOutgoingCallTimeout: timed out but remote participant present, cancelling instead of leaving")
          await MainActor.run { self.cancelCallTimeouts() }
          return
        }

        callLog("startOutgoingCallTimeout: timed out, calling miss API then leaving")
        if let callId = callId, !userIds.isEmpty {
          await CallApiService.shared.miss(id: callId, participantUserIds: userIds)
        }
        await MainActor.run {
          self.leaveCall(reason: CallConstants.LeaveReason.outgoingTimeout)
        }
      }
    }
    timer.resume()
    callTimeoutTimer = timer
  }

  private func cancelCallTimeouts() {
    guard callTimeoutTimer != nil else { return }
    callTimeoutTimer?.cancel()
    callTimeoutTimer = nil
    callLog("cancelCallTimeouts: timer cancelled")
  }

  // MARK: - Outgoing Calls

  func startCall(id: String, providerCallId: String, cameraEnabled: Bool, callerName: String, isGroup: Bool, isCaller: Bool, participantUserIds: [Int]) {
    humandCallId = id
    self.providerCallId = providerCallId
    self.isGroup = isGroup
    self.isCaller = isCaller
    self.participantUserIds = participantUserIds
    self.startedCallCallee = startedCallIdentity(
      callerName: callerName,
      isGroup: isGroup,
      participantUserIds: participantUserIds
    )
    self.callHasVideo = cameraEnabled
    self.hasEmittedRingingEvent = false
    self.observedRemoteParticipantSessionIds.removeAll()
    startAudioRouteMonitoring()
    Task {
      do {
        updateCallingState(CallConstants.CallingState.joining)

        let (client, participantId) = try await CallApiService.shared.createStreamClient(
          existingClient: streamVideo,
          existingUserId: localUserId,
          log: callLog,
          context: "CallManager",
          tokenFetcher: {
            await CallApiService.shared.getCallToken()
          }
        )
        let isNewClient = streamVideo !== client
        streamVideo = client
        localUserId = participantId

        if isNewClient {
          observeActiveCall(client)
        }

        let call = client.call(callType: "default", callId: providerCallId)
        let callSettings = CallSettings(audioOn: true, videoOn: cameraEnabled, speakerOn: cameraEnabled)

        // Update CallKit adapter settings so that when CallKit activates the audio
        // session it uses the correct video/speaker config for this call
        let adapter = InjectedValues[\.callKitAdapter]
        adapter.callSettings = callSettings

        if isCaller || !isGroup {
          try await CallApiService.withRetry(logger: callLog) { try await call.get() }
        }
        try await call.updateAudioSessionPolicy(DefaultAudioSessionPolicy())

        if isCaller && isGroup {
          let success = await CallApiService.shared.accept(id: id)
          callLog("startCall: backend accept (pre-join, group) \(success ? "succeeded" : "failed")")
        }

        if isCaller {
          try await CallApiService.withRetry(logger: callLog) {
            try await call.join(
              create: true,
              callSettings: callSettings,
              policy: .peerConnectionReadinessAware
            )
          }
        } else {
          try await CallApiService.withRetry(logger: callLog) {
            try await call.join(
              create: false,
              callSettings: callSettings,
              policy: .peerConnectionReadinessAware
            )
          }
        }

        // Stable participant sorting matching Android: pin, screen-share, WebRTC over RTMP, join time, user ID.
        await call.updateParticipantsSorting(with: [pinned, screenSharing, participantSource(.webRTCUnspecified), joinedAt, userId])

        // Mute mic by default when member count >= threshold
        // Use participantUserIds (all invited members) rather than live participants
        // which may not be fully populated immediately after join
        let memberCount = participantUserIds.count
        let shouldMuteMic = memberCount >= CallConstants.Call.micDisabledParticipantThreshold
        if shouldMuteMic {
          try? await call.microphone.disable()
          callLog("startCall: muted mic (members=\(memberCount), threshold=\(CallConstants.Call.micDisabledParticipantThreshold))")
        }

        await MainActor.run {
          observeCallState(call)
        }

        if isCaller {
          if !isGroup {
            let success = await CallApiService.shared.accept(id: id)
            callLog("startCall: backend accept \(success ? "succeeded" : "failed")")
          }
        } else {
          let success = await CallApiService.shared.accept(id: id)
          callLog("startCall: backend accept (incoming/manual) \(success ? "succeeded" : "failed")")

          if !success && !isGroup {
            let callEnded = await MainActor.run { call.state.endedAt != nil }

            if callEnded {
              callLog("startCall: backend accept failed and call already ended, cleaning up")
              await MainActor.run {
                self.leaveCall(reason: CallConstants.LeaveReason.remoteEnded)
              }
              return
            }

            callLog("startCall: backend accept failed but call not ended, proceeding")
          }
        }

        if isCaller && !isGroup {
          let remoteParticipantAlreadyJoined = await MainActor.run {
            call.state.participants.contains {
              $0.userId != participantId
            }
          }

          if remoteParticipantAlreadyJoined {
            callLog("startCall: remote participant already joined, skipping ringback and timeout")
          } else {
            await MainActor.run {
              self.startRingback()
              self.startOutgoingCallTimeout()
            }
          }
        }

        if isCaller {
          self.emitObservedCallStarted()
        } else {
          await MainActor.run {
            self.emitObservedCallAccepted()
          }
        }
        callLog("startCall: joined call \(providerCallId)")
      } catch {
        callLog("startCall error: \(error.localizedDescription)")
        updateCallingState(CallConstants.CallingState.idle)
      }
    }
  }

  /// Cleanup CallKit adapter on logout.
  /// Unregisters VoIP device and disconnects the adapter from the Stream client.
  func cleanupCallKit() {
    cancelCallTimeouts()
    stopRingback()
    resetVideoSessionState()
    stopAudioRouteMonitoring()
    PublicAudioSessionManager.setIsAudioSessionManagementDisabled(false)
    isCaller = false
    startedCallCallee = NSNull()
    providerCallId = nil
    participantUserIds = []
    callHasVideo = false
    hasEmittedRingingEvent = false
    observedRemoteParticipantSessionIds.removeAll()
    let pushAdapter = InjectedValues[\.callKitPushNotificationAdapter]
    let deviceToken = pushAdapter.deviceToken
    if !deviceToken.isEmpty {
      Task {
        await CallApiService.shared.removeVoipToken(token: deviceToken)
      }
    }
    InjectedValues[\.callKitAdapter].streamVideo = nil
    voIPTokenObservationCancellable?.cancel()
    voIPTokenObservationCancellable = nil
    activeCallObservationCancellable?.cancel()
    activeCallObservationCancellable = nil
    mediaStateCancellables.removeAll()
    lastVoIPToken = nil
    streamVideo = nil
    localUserId = nil
    Task {
      await CallApiService.shared.disconnectSharedClient()
    }
    callKitInitialized = false
    callKitInitializing = false
  }

  func emitParticipantsChanged(_ participants: [[String: Any]]) {
    RNCallManager.emitParticipantsChanged(participants)
  }

  func emitMediaStateChanged(microphoneEnabled: Bool?, cameraEnabled: Bool?, speakerEnabled: Bool?) {
    RNCallManager.emitMediaStateChanged(
      microphoneEnabled: microphoneEnabled,
      cameraEnabled: cameraEnabled,
      speakerEnabled: speakerEnabled
    )
  }

  func updateCallingState(_ state: String) {
    currentCallingState = state
    RNCallManager.emitCallStateChanged(state)
  }

  @MainActor
  func getCurrentState() -> [String: Any] {
    let participants = streamCall?.state.participants.map { participantToDict($0) } ?? []
    let localParticipant = streamCall?.state.localParticipant.map { participantToDict($0) }
    let micEnabled = streamCall?.microphone.status == .enabled
    let speakerEnabled = streamCall?.speaker.status == .enabled

    return [
      "callingState": currentCallingState,
      "participants": participants,
      "localParticipant": localParticipant as Any,
      "microphoneEnabled": micEnabled,
      "cameraEnabled": streamCall?.camera.status == .enabled,
      "speakerEnabled": speakerEnabled
    ]
  }

  func cleanupStreamClient(endCall: Bool = false) async {
    callLog("cleanupStreamClient: Starting cleanup (endCall: \(endCall))")

    mediaStateCancellables.removeAll()

    guard let call = streamCall else {
      callLog("cleanupStreamClient: No active call to cleanup")
      return
    }

    streamCall = nil

    do {
      try await call.camera.disable()
      try await call.microphone.disable()
      callLog("cleanupStreamClient: Disabled camera and microphone")
    } catch {
      callLog("cleanupStreamClient: Error disabling media - \(error.localizedDescription)")
    }

    if endCall {
      do {
        try await call.end()
        callLog("cleanupStreamClient: Ended call for all participants")
      } catch {
        callLog("cleanupStreamClient: Error ending call - \(error.localizedDescription)")
      }
    } else {
      call.leave(reason: CallConstants.LeaveReason.cleanup)
      callLog("cleanupStreamClient: Left call")
    }

    await disconnectStreamVideo()
  }

  private func startedCallIdentity(callerName: String, isGroup: Bool, participantUserIds: [Int]) -> Any {
    if isGroup {
      return callerName.isEmpty ? participantUserIds : callerName
    }

    if let participantUserId = participantUserIds.first {
      return participantUserId
    }

    return callerName.isEmpty ? NSNull() : callerName
  }

  private func emitObservedCallStarted() {
    guard let callId = humandCallId, let providerCallId else { return }

    NativeCallsEventObserver.shared.observeCallStarted(
      callId: callId,
      providerCallId: providerCallId,
      cameraEnabled: callHasVideo,
      isGroup: isGroup,
      callee: startedCallCallee,
      participantUserIds: participantUserIds
    )
  }

  @MainActor
  private func emitObservedCallAccepted() {
    guard let callId = humandCallId, let providerCallId else { return }

    NativeCallsEventObserver.shared.observeCallAccepted(
      callId: callId,
      providerCallId: providerCallId,
      cameraEnabled: callHasVideo,
      isGroup: isGroup,
      caller: acceptedCallIdentity()
    )
  }

  @MainActor
  private func acceptedCallIdentity() -> Any {
    let remoteMemberIds = streamCall?.state.members.compactMap { member -> Any? in
      guard member.user.id != localUserId else { return nil }
      return normalizedParticipantIdentity(member.user.id)
    } ?? []

    if isGroup {
      if let displayName = startedCallCallee as? String, !displayName.isEmpty {
        return displayName
      }
      return remoteMemberIds.isEmpty ? NSNull() : remoteMemberIds
    }

    if let firstRemoteParticipantId = remoteMemberIds.first {
      return firstRemoteParticipantId
    }

    if let displayName = startedCallCallee as? String, !displayName.isEmpty {
      return displayName
    }

    return NSNull()
  }

  @MainActor
  private func acceptedCallerIdentity(for call: Call, callData: [String: Any], isGroup: Bool) -> Any {
    let remoteMemberIds = call.state.members.compactMap { member -> Any? in
      guard member.user.id != localUserId else { return nil }
      return normalizedParticipantIdentity(member.user.id)
    }

    if isGroup {
      if let name = callData[CallConstants.CallKeys.name] as? String, !name.isEmpty {
        return name
      }
      return remoteMemberIds
    }

    if let firstRemoteMemberId = remoteMemberIds.first {
      return firstRemoteMemberId
    }

    if let name = callData[CallConstants.CallKeys.name] as? String, !name.isEmpty {
      return name
    }

    return NSNull()
  }

  @MainActor
  private func capturePendingEndContext(reason: String, call: Call?) {
    switch reason {
    case CallConstants.LeaveReason.userInitiated:
      pendingEndSource = .local
      pendingEndReason = .localHangup
      pendingEndedByUserId = localUserId.map(normalizedParticipantIdentity)
    case CallConstants.LeaveReason.remoteEnded:
      pendingEndSource = .remote
      pendingEndReason = .remoteHangup
      pendingEndedByUserId = remoteEndedByUserId(from: call)
    case CallConstants.LeaveReason.calleeRejected:
      pendingEndSource = .remote
      pendingEndReason = .rejected
      pendingEndedByUserId = remoteEndedByUserId(from: call)
    case CallConstants.LeaveReason.outgoingTimeout:
      pendingEndSource = .system
      pendingEndReason = .timeout
      pendingEndedByUserId = nil
    case CallConstants.LeaveReason.reconnectionFailed:
      pendingEndSource = .system
      pendingEndReason = .connectionFailed
      pendingEndedByUserId = nil
    default:
      pendingEndSource = .unknown
      pendingEndReason = .unknown
      pendingEndedByUserId = nil
    }
  }

  @MainActor
  private func remoteEndedByUserId(from call: Call?) -> Any? {
    guard let call else { return nil }

    let remoteParticipantIds = call.state.participants.compactMap { participant -> Any? in
      guard participant.userId != localUserId else { return nil }
      return normalizedParticipantIdentity(participant.userId)
    }

    if isGroup {
      return remoteParticipantIds.isEmpty ? nil : remoteParticipantIds
    }

    return remoteParticipantIds.first
  }

  private func callerIdentity(from callData: [String: Any]?, isGroup: Bool) -> Any {
    guard let callData = callData else { return NSNull() }
    let name = callData[CallConstants.CallKeys.name] as? String
    return name?.isEmpty == false ? name as Any : NSNull()
  }

  private func cameraEnabled(from callData: [String: Any]?) -> Bool {
    guard let configString = callData?[CallConstants.CallKeys.initializationConfig] as? String,
          let data = configString.data(using: .utf8),
          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
      return false
    }

    return json["cameraEnabled"] as? Bool ?? false
  }

  private func normalizedParticipantIdentity(_ userId: String) -> Any {
    if let intValue = Int(userId) {
      return intValue
    }
    return userId
  }

  private func emitObservedCallRinging(providerCallId: String?) {
    guard !hasEmittedRingingEvent,
          let callId = humandCallId,
          let providerCallId = providerCallId else { return }

    hasEmittedRingingEvent = true
    NativeCallsEventObserver.shared.observeCallRinging(
      callId: callId,
      providerCallId: providerCallId,
      cameraEnabled: callHasVideo,
      isGroup: isGroup,
      direction: isCaller ? "outgoing" : "incoming"
    )
  }

  func disconnectStreamVideo() async {
    callLog("disconnectStreamVideo: Disconnecting shared StreamVideo client")
    await CallApiService.shared.disconnectSharedClient()
    streamVideo = nil
    localUserId = nil
    callLog("disconnectStreamVideo: done")
  }

  func setMicrophoneEnabled(_ enabled: Bool) async {
    guard let call = streamCall else { return }
    do {
      if enabled {
        try await call.microphone.enable()
      } else {
        try await call.microphone.disable()
      }
      callLog("Microphone \(enabled ? "enabled" : "disabled")")
    } catch {
      callLog("setMicrophoneEnabled error: \(error.localizedDescription)")
    }
  }

  func setCameraEnabled(_ enabled: Bool) async {
    guard let call = streamCall else { return }
    do {
      if enabled {
        try await call.camera.enable()

        // Auto-enable speaker when video is turned on, unless BT/headset is connected
        if !isExternalAudioRouteActive() {
          try await call.speaker.enableSpeakerPhone()
          callLog("setCameraEnabled: auto-enabled speaker (no external audio)")
        }
      } else {
        try await call.camera.disable()
      }
      let cameraEnabled = call.camera.status == .enabled
      let speakerEnabled = call.speaker.status == .enabled
      let participants = await call.state.participants.map { participantToDict($0) }
      await MainActor.run {
        self.emitMediaStateChanged(
          microphoneEnabled: nil,
          cameraEnabled: cameraEnabled,
          speakerEnabled: speakerEnabled
        )
        self.emitParticipantsChanged(participants)
      }
    } catch {
      callLog("setCameraEnabled error: \(error.localizedDescription)")
    }
  }

  func hasActiveCall() -> Bool {
    return streamCall != nil
  }

  private func isExternalAudioRouteActive() -> Bool {
    let route = audioSession.currentRoute
    for output in route.outputs {
      switch output.portType {
      case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE, .headphones, .headsetMic:
        return true
      default:
        continue
      }
    }
    return false
  }

  /// Restore audio output to the built-in speaker after a call ends.
  /// Delayed to let CallKit / Stream SDK finish releasing the audio session first.
  private func restoreAudioToSpeaker() {
   DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
     guard let self = self else { return }

     // Re-enable react-native-video audio session management now that WebRTC has torn down
     if self.streamCall == nil {
       PublicAudioSessionManager.setIsAudioSessionManagementDisabled(false)
     }

     guard self.streamCall == nil else {
       callLog("restoreAudioToSpeaker: new call active, skipping")
       return
     }
     guard !self.isExternalAudioRouteActive() else {
       callLog("restoreAudioToSpeaker: external route active, skipping")
       return
     }

     let session = self.audioSession
     do {
       try session.setCategory(.playback, mode: .default, options: [])
       // Skip deactivation when StreamVideo client is still alive — WebRTC's
       // AudioEngineDevice crashes on the route change notification triggered
       // by session deactivation (even between calls, the client keeps WebRTC
       // infrastructure running for push notification handling).
       if self.isWebRTCAudioEngineAlive() {
         callLog("restoreAudioToSpeaker: skipping deactivation, WebRTC audio engine still alive")
       } else {
         try session.setActive(false, options: .notifyOthersOnDeactivation)
         callLog("restoreAudioToSpeaker: audio session reset and deactivated")
       }
     } catch {
       callLog("restoreAudioToSpeaker error: \(error.localizedDescription)")
     }
   }
  }

  private func startAudioRouteMonitoring() {
    if bluetoothManager != nil {
      notifyAudioRouteChanged()
      return
    }

    DispatchQueue.main.async { [weak self] in
      guard let self = self else { return }
      guard self.bluetoothManager == nil else {
        self.notifyAudioRouteChanged()
        return
      }

      self.bluetoothManager = CBCentralManager(
        delegate: self,
        queue: nil,
        options: [CBCentralManagerOptionShowPowerAlertKey: false]
      )
      NotificationCenter.default.addObserver(
        self,
        selector: #selector(self.audioRouteChanged),
        name: AVAudioSession.routeChangeNotification,
        object: nil
      )
      self.notifyAudioRouteChanged()
    }
  }

  private func stopAudioRouteMonitoring() {
    guard bluetoothManager != nil else { return }
    NotificationCenter.default.removeObserver(
      self,
      name: AVAudioSession.routeChangeNotification,
      object: nil
    )
    bluetoothManager = nil
    pendingRouteChangeWorkItem?.cancel()
    pendingRouteChangeWorkItem = nil
    lastNotifiedRoute = nil
  }

  @objc
  private func audioRouteChanged(notification: Notification) {
    notifyAudioRouteChanged()
  }

  func centralManagerDidUpdateState(_ central: CBCentralManager) {
    switch central.state {
    case .poweredOn, .poweredOff:
      notifyAudioRouteChanged()
    case .unauthorized:
      callLog("Bluetooth unauthorized")
    default:
      callLog("Bluetooth state: \(central.state.rawValue)")
    }
  }

  private func notifyAudioRouteChanged() {
    pendingRouteChangeWorkItem?.cancel()

    let workItem = DispatchWorkItem { [weak self] in
      guard let self = self else { return }

      let currentRoute = self.getCurrentAudioRouteName()
      let hasBluetooth = self.hasBluetoothDevice()

      guard currentRoute != self.lastNotifiedRoute else {
        callLog("Skipping duplicate audio route notification: \(currentRoute)")
        return
      }

      self.lastNotifiedRoute = currentRoute
      callLog("Sending audio route change event: \(currentRoute)")
      RNCallManager.emitAudioRouteChanged(currentRoute, hasBluetoothDevice: hasBluetooth)
    }

    pendingRouteChangeWorkItem = workItem
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
  }

  func getCurrentAudioRoute() -> String {
    return getCurrentAudioRouteName()
  }

  private func hasBluetoothDevice() -> Bool {
    return audioSession.availableInputs?.contains(where: {
      $0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothLE
    }) == true
  }

  private func getCurrentAudioRouteName() -> String {
    let currentRoute = audioSession.currentRoute
    for output in currentRoute.outputs {
      switch output.portType {
      case .builtInSpeaker:
        return "SPEAKER"
      case .headphones, .headsetMic:
        return "WIRED_HEADPHONES"
      case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE:
        return "BLUETOOTH"
      case .builtInReceiver:
        return "EARPIECE"
      case .usbAudio:
        return "USB_AUDIO"
      default:
        break
      }
    }
    return "UNKNOWN"
  }

  private func updateLocalCameraState(isEnabled: Bool) {
    guard localCameraEnabled != isEnabled else { return }
    localCameraEnabled = isEnabled
    updateVideoSessionStateIfNeeded()
  }

  private func updateRemoteVideoState(hasRemoteVideo: Bool) {
    guard remoteVideoActive != hasRemoteVideo else { return }
    remoteVideoActive = hasRemoteVideo
    updateVideoSessionStateIfNeeded()
  }

  private func updateVideoSessionStateIfNeeded() {
    let shouldDisableProximity = localCameraEnabled || remoteVideoActive
    guard isVideoSessionActive != shouldDisableProximity else { return }
    isVideoSessionActive = shouldDisableProximity
    DispatchQueue.main.async {
      UIDevice.current.isProximityMonitoringEnabled = !shouldDisableProximity
      UIApplication.shared.isIdleTimerDisabled = shouldDisableProximity
    }
  }

  private func resetVideoSessionState() {
    localCameraEnabled = false
    remoteVideoActive = false
    isVideoSessionActive = false
    DispatchQueue.main.async {
      UIDevice.current.isProximityMonitoringEnabled = false
      UIApplication.shared.isIdleTimerDisabled = false
    }
  }

  func switchCamera() async {
    guard let call = streamCall else { return }
    do {
      try await call.camera.flip()
    } catch {
      callLog("switchCamera error: \(error.localizedDescription)")
    }
  }

  func setSpeakerEnabled(_ enabled: Bool) async {
    guard let call = streamCall else { return }
    do {
      if enabled {
        try await call.speaker.enableSpeakerPhone()
      } else {
        try await call.speaker.disableSpeakerPhone()
      }
      callLog("Speaker \(enabled ? "enabled" : "disabled")")
    } catch {
      callLog("setSpeakerEnabled error: \(error.localizedDescription)")
    }
  }

  func participantToDict(_ participant: CallParticipant) -> [String: Any] {
    return [
      "sessionId": participant.sessionId,
      "userId": participant.userId,
      "name": participant.name,
      "profileImageURL": participant.profileImageURL?.absoluteString as Any,
      "isSpeaking": participant.isSpeaking,
      "isDominantSpeaker": participant.isDominantSpeaker,
      "hasVideo": participant.hasVideo,
      "hasAudio": participant.hasAudio,
      "isScreenSharing": participant.isScreensharing,
      "isLocalParticipant": participant.userId == localUserId,
      "connectionQuality": connectionQualityToString(participant.connectionQuality),
      "source": participant.source.stringValue
    ]
  }

  func connectionQualityToString(_ quality: ConnectionQuality) -> String {
    switch quality {
    case .excellent: return "excellent"
    case .good: return "good"
    case .poor: return "poor"
    default: return "unknown"
    }
  }
}
