import AVFAudio
import CryptoKit
import Foundation
import os
import SwiftUI
import UIKit
import WebRTC

@MainActor
extension CallManager {

    func handleWebRtcPacket(_ packet: PacketWebRTC) async {
        guard uiState.phase == .webRtcExchange || uiState.phase == .active else { return }

        // Buffer packets if peer connection not yet created (CallKit didActivate delay).
        // Android doesn't need this — no CallKit, peerConnection created immediately.
        guard let peerConnection = self.peerConnection else {
            callLogger.info("[Call] Buffering WebRTC packet (type=\(packet.signalType.rawValue, privacy: .public)) — peerConnection not ready")
            bufferedWebRtcPackets.append(packet)
            return
        }

        await processWebRtcPacket(packet, on: peerConnection)
    }

    func processWebRtcPacket(_ packet: PacketWebRTC, on peerConnection: RTCPeerConnection) async {
        switch packet.signalType {
        case .answer:
            guard let answer = parseSessionDescription(from: packet.sdpOrCandidate),
                  answer.type == .answer else {
                return
            }
            callLogger.info("[Call] Received ANSWER from SFU")
            do {
                try await setRemoteDescription(answer, on: peerConnection)
                remoteDescriptionSet = true
                await flushBufferedRemoteCandidates()
            } catch {
                callLogger.error("[Call] Failed to apply answer: \(error.localizedDescription, privacy: .public)")
                finishCall(reason: "Failed to apply answer", notifyPeer: false)
            }
        case .offer:
            guard let offer = parseSessionDescription(from: packet.sdpOrCandidate),
                  offer.type == .offer else {
                return
            }
            callLogger.info("[Call] Received OFFER from SFU (renegotiation)")
            do {
                try await setRemoteDescription(offer, on: peerConnection)
                remoteDescriptionSet = true
                await flushBufferedRemoteCandidates()

                let answer = try await createAnswer(on: peerConnection)
                try await setLocalDescription(answer, on: peerConnection)
                ProtocolManager.shared.sendWebRtcSignal(
                    signalType: .answer,
                    sdpOrCandidate: serializeSessionDescription(answer)
                )
            } catch {
                callLogger.error("[Call] Failed to handle offer: \(error.localizedDescription, privacy: .public)")
                finishCall(reason: "Failed to handle offer", notifyPeer: false)
            }
        case .iceCandidate:
            guard let candidate = parseIceCandidate(from: packet.sdpOrCandidate) else { return }
            if !remoteDescriptionSet {
                bufferedRemoteCandidates.append(candidate)
                return
            }
            do {
                try await peerConnection.add(candidate)
            } catch {
                callLogger.warning("[Call] Failed to add ICE candidate: \(error.localizedDescription, privacy: .public)")
            }
        }
    }

    /// Called by CallKit when audio session is activated (didActivate callback)
    /// or by force-activation fallback. Idempotent — safe to call multiple times.
    /// Peer connection is created separately (immediately on createRoom signal).
    /// This method only enables audio on already-created tracks.
    func onAudioSessionActivated() async {
        let wasAlreadyActivated = audioSessionActivated
        audioSessionActivated = true
        pendingWebRtcSetup = false
        callLogger.notice("[Call] didActivate: phase=\(self.uiState.phase.rawValue, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public) wasAlready=\(wasAlreadyActivated.description, privacy: .public) hasPeer=\((self.peerConnection != nil).description, privacy: .public) hasTrack=\((self.localAudioTrack != nil).description, privacy: .public) wsState=\(String(describing: ProtocolManager.shared.connectionState), privacy: .public)")
        guard uiState.phase != .idle else { return }

        // ALWAYS re-apply routing and re-enable track — even on duplicate calls.
        // The real CallKit didActivate may arrive after a failed force-activation.
        // Re-enabling ensures audio works when the proper entitlement is granted.
        applyAudioOutputRouting()
        UIDevice.current.isProximityMonitoringEnabled = true
        localAudioTrack?.isEnabled = !uiState.isMuted
        callLogger.info("[Call] Audio ready, track=\((self.localAudioTrack != nil).description, privacy: .public) enabled=\((!self.uiState.isMuted).description, privacy: .public)")
    }

    func ensurePeerConnectionAndOffer() async {
        // Guard: finishCall() may have run during the async gap before this Task executes.
        guard uiState.phase == .webRtcExchange else {
            callLogger.info("[Call] ensurePeerConnectionAndOffer: skipped (phase=\(self.uiState.phase.rawValue, privacy: .public))")
            return
        }
        callLogger.info("[Call] ensurePeerConnectionAndOffer: starting")
        do {
            let peerConnection = try ensurePeerConnection()
            applySenderCryptorIfPossible()
            // Apply audio routing + enable track NOW — didActivate may have already
            // fired before createRoom arrived, so its applyAudioOutputRouting was a no-op
            // (no track existed yet). Without this, audio is silent on both sides.
            applyAudioOutputRouting()
            localAudioTrack?.isEnabled = !uiState.isMuted

            let offer = try await createOffer(on: peerConnection)
            try await setLocalDescription(offer, on: peerConnection)
            ProtocolManager.shared.sendWebRtcSignal(
                signalType: .offer,
                sdpOrCandidate: serializeSessionDescription(offer)
            )
            offerSent = true
            callLogger.info("[Call] ensurePeerConnectionAndOffer: offer sent")

            // Flush WebRTC packets AFTER local description is set.
            // Buffered ANSWER requires local offer to be set first (signalingState must be
            // have-local-offer, not stable). ICE candidates also need remote desc first —
            // they'll go to bufferedRemoteCandidates via processWebRtcPacket.
            if !bufferedWebRtcPackets.isEmpty {
                callLogger.info("[Call] Flushing \(self.bufferedWebRtcPackets.count, privacy: .public) buffered WebRTC packets")
                let packets = bufferedWebRtcPackets
                bufferedWebRtcPackets.removeAll()
                for packet in packets {
                    await processWebRtcPacket(packet, on: peerConnection)
                }
            }
        } catch {
            callLogger.error("[Call] ensurePeerConnectionAndOffer FAILED: \(error.localizedDescription, privacy: .public)")
            finishCall(reason: "Failed to establish call", notifyPeer: false)
        }
    }

    func beginCallSession(peerPublicKey: String, title: String, username: String) {
        finishCall(reason: nil, notifyPeer: false)
        signalingMode = .undecided
        callId = ""
        joinToken = ""
        hasConnectedTransport = false
        hasRemoteAudioTrack = false
        pendingIncomingAccept = false
        uiState = CallUiState(
            phase: .idle,
            peerPublicKey: peerPublicKey,
            peerTitle: title,
            peerUsername: username
        )
    }

    func finishCall(reason: String?, notifyPeer: Bool, skipAttachment: Bool = false) {
        // Guard: finishCall can be called twice when CXEndCallAction callback
        // re-enters via CallManager.endCall(). Skip if already idle or mid-finish.
        guard !isFinishingCall, uiState.phase != .idle else { return }
        isFinishingCall = true
        pendingCallKitAccept = false
        pendingIncomingAccept = false
        defer { isFinishingCall = false }

        callLogger.notice("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)")

        let snapshot = uiState
        let snapshotCallId = callId
        let snapshotJoinToken = joinToken
        let snapshotOwnPublicKey = ownPublicKey

        // Step 0: Cancel recovery/rebind tasks and clear packet buffer.
        disconnectRecoveryTask?.cancel()
        disconnectRecoveryTask = nil
        e2eeRebindTask?.cancel()
        e2eeRebindTask = nil
        bufferedWebRtcPackets.removeAll()

        // Step 1: Notify peer FIRST — before closing WebRTC (Android parity).
        // Send endCall while the CallSession still exists on server, before SFU
        // detects our WebRTC disconnect and server's periodic cleanup removes it.
        if notifyPeer,
           snapshotOwnPublicKey.isEmpty == false,
           snapshot.peerPublicKey.isEmpty == false,
           snapshot.phase != .idle {
            ProtocolManager.shared.sendCallSignal(
                signalType: .endCall,
                src: snapshotOwnPublicKey,
                dst: snapshot.peerPublicKey,
                callId: snapshotCallId,
                joinToken: snapshotJoinToken
            )
        }

        // Step 2: Close WebRTC — SFU sees peer disconnect immediately.
        // Without this, SFU waits for ICE timeout (~30s) before releasing the room,
        // blocking new calls to the same peer.
        durationTask?.cancel()
        durationTask = nil

        if let localAudioSender {
            WebRTCFrameCryptorBridge.detach(localAudioSender)
        }
        if let currentPeerConnection = self.peerConnection {
            for receiver in currentPeerConnection.receivers {
                WebRTCFrameCryptorBridge.detach(receiver)
            }
            currentPeerConnection.close()
        }

        localAudioTrack = nil
        localAudioSource = nil
        localAudioSender = nil
        self.peerConnection = nil
        bufferedRemoteCandidates.removeAll()
        attachedReceiverIds.removeAll()

        // Step 3: Report to CallKit.
        if notifyPeer {
            CallKitManager.shared.endCall()
        } else {
            CallKitManager.shared.reportCallEndedByRemote()
        }

        // Step 4: Cancel timers, sounds, live activity.
        pendingMinimizeTask?.cancel()
        pendingMinimizeTask = nil
        cancelRingTimeout()
        endLiveActivity()
        let wasActive = snapshot.phase == .active
        if wasActive {
            CallSoundManager.shared.playEndCall()
        } else {
            CallSoundManager.shared.stopAll()
        }

        // Step 5: Send call attachment (async, non-blocking).
        if !skipAttachment,
           role == .caller,
           snapshot.peerPublicKey.isEmpty == false {
            let duration = max(snapshot.durationSec, 0)
            let peerKey = snapshot.peerPublicKey
            Task { @MainActor in
                try? await SessionManager.shared.sendCallAttachment(
                    toPublicKey: peerKey,
                    durationSec: duration,
                    opponentTitle: snapshot.peerTitle,
                    opponentUsername: snapshot.peerUsername
                )
                MessageRepository.shared.refreshDialogCache(for: peerKey)
            }
        }

        // Step 6: Deactivate audio BEFORE resetting state.
        // deactivateAudioSession() checks didReceiveCallKitDeactivation to avoid
        // double-decrementing WebRTC's internal activation counter. Resetting the
        // flag first would bypass the guard → double-deactivate → broken audio
        // on the next call within the same process.
        deactivateAudioSession()

        // Step 7: Reset all state.
        role = nil
        signalingMode = .undecided
        roomId = ""
        callId = ""
        joinToken = ""
        hasConnectedTransport = false
        hasRemoteAudioTrack = false
        localPrivateKey = nil
        localPublicKeyHex = ""
        sharedKey = nil
        offerSent = false
        remoteDescriptionSet = false
        lastPeerSharedPublicHex = ""
        audioSessionActivated = false
        pendingWebRtcSetup = false
        didReceiveCallKitDeactivation = false

        var finalState = CallUiState()
        finalState.peerPublicKey = uiState.peerPublicKey
        finalState.peerTitle = uiState.peerTitle
        finalState.peerUsername = uiState.peerUsername
        if let reason, !reason.isEmpty {
            finalState.statusText = reason
        }
        withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
            uiState = finalState
        }

        ProtocolManager.shared.endCallBackgroundTask()
    }

    func ensureLocalSessionKeys() {
        guard localPrivateKey == nil else { return }
        let key = Curve25519.KeyAgreement.PrivateKey()
        localPrivateKey = key
        localPublicKeyHex = key.publicKey.rawRepresentation.hexString
    }

    func hydratePeerIdentity(for publicKey: String) {
        let hadName = !uiState.peerTitle.isEmpty
        if let dialog = DialogRepository.shared.dialogs[publicKey] {
            if uiState.peerTitle.isEmpty {
                uiState.peerTitle = dialog.opponentTitle
            }
            if uiState.peerUsername.isEmpty {
                uiState.peerUsername = dialog.opponentUsername
            }
        }
        // Fallback: after cold start, DialogRepository may not be loaded yet.
        // Check UserDefaults contact_display_names (same source VoIP push uses).
        if uiState.peerTitle.isEmpty, uiState.peerUsername.isEmpty {
            for defaults in [UserDefaults(suiteName: "group.com.rosetta.dev"), UserDefaults.standard] {
                if let names = defaults?.dictionary(forKey: "contact_display_names") as? [String: String],
                   let name = names[publicKey], !name.isEmpty {
                    uiState.peerTitle = name
                    break
                }
            }
        }
        // If we just resolved a name that wasn't available before (e.g. DB loaded
        // after cold start), update CallKit so lock screen shows the name.
        if !hadName, !uiState.peerTitle.isEmpty {
            CallKitManager.shared.updateCallerName(uiState.displayName)
        }
    }

    func applySenderCryptorIfPossible() {
        guard let sharedKey, sharedKey.count >= CallMediaCrypto.keyLength else { return }
        guard let localAudioSender else { return }
        let attached = WebRTCFrameCryptorBridge.attach(localAudioSender, sharedKey: sharedKey)
        if !attached {
            callLogger.error("[Call] E2EE: FAILED to attach sender encryptor — audio may be unencrypted")
        }
    }

    func startDurationTimerIfNeeded() {
        durationTask?.cancel()
        durationTask = Task { [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                guard !Task.isCancelled else { return }
                await MainActor.run {
                    guard let self else { return }
                    guard self.uiState.phase == .active else { return }
                    self.uiState.durationSec += 1
                    if self.uiState.durationSec % 2 == 0 {
                        self.updateLiveActivity()
                    }
                }
            }
        }
    }

    // MARK: - WebRTC setup

    func ensurePeerConnection() throws -> RTCPeerConnection {
        if let currentPeerConnection = self.peerConnection { return currentPeerConnection }

        callLogger.info("[Call] Creating new peer connection (iceServers=\(self.iceServers.count, privacy: .public))")

        if peerConnectionFactory == nil {
            RTCPeerConnectionFactory.initialize()
            peerConnectionFactory = RTCPeerConnectionFactory()
        }
        let factory = peerConnectionFactory ?? RTCPeerConnectionFactory()

        let configuration = RTCConfiguration()
        if iceServers.isEmpty {
            configuration.iceServers = [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])]
        } else {
            configuration.iceServers = iceServers
        }
        configuration.sdpSemantics = .unifiedPlan

        let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
        guard let connection = factory.peerConnection(
            with: configuration,
            constraints: constraints,
            delegate: self
        ) else {
            throw NSError(domain: "CallManager", code: -3)
        }

        let audioSource = factory.audioSource(with: constraints)
        let audioTrack = factory.audioTrack(with: audioSource, trackId: "rosetta_audio_track")
        audioTrack.isEnabled = !uiState.isMuted
        localAudioSender = connection.add(audioTrack, streamIds: ["rosetta_audio_stream"])

        self.localAudioSource = audioSource
        self.localAudioTrack = audioTrack
        self.peerConnection = connection
        self.hasConnectedTransport = false
        self.hasRemoteAudioTrack = false
        callLogger.info("[Call] Peer connection created, audio track enabled=\((!self.uiState.isMuted).description, privacy: .public)")
        return connection
    }

    func deactivateAudioSession() {
        UIDevice.current.isProximityMonitoringEnabled = false
        let rtcSession = RTCAudioSession.sharedInstance()
        // Only call setActive(false) if CallKit's didDeactivate did NOT fire.
        // CallKit's didDeactivate already calls audioSessionDidDeactivate() which
        // decrements WebRTC's internal activation count. Calling setActive(false)
        // again would double-decrement, breaking audio on the next call.
        if !didReceiveCallKitDeactivation {
            rtcSession.lockForConfiguration()
            try? rtcSession.setActive(false)
            rtcSession.unlockForConfiguration()
        }
        rtcSession.isAudioEnabled = false
        didReceiveCallKitDeactivation = false
    }

    func applyAudioOutputRouting() {
        let session = AVAudioSession.sharedInstance()
        if uiState.isSpeakerOn {
            try? session.overrideOutputAudioPort(.speaker)
        } else {
            try? session.overrideOutputAudioPort(.none)
        }
    }

    // MARK: - SDP / ICE

    private func createOffer(on peerConnection: RTCPeerConnection) async throws -> RTCSessionDescription {
        try await withCheckedThrowingContinuation { continuation in
            let constraints = RTCMediaConstraints(
                mandatoryConstraints: ["OfferToReceiveAudio": "true"],
                optionalConstraints: nil
            )
            peerConnection.offer(for: constraints) { sdp, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                guard let sdp else {
                    continuation.resume(throwing: NSError(domain: "CallManager", code: -1))
                    return
                }
                continuation.resume(returning: sdp)
            }
        }
    }

    private func createAnswer(on peerConnection: RTCPeerConnection) async throws -> RTCSessionDescription {
        try await withCheckedThrowingContinuation { continuation in
            let constraints = RTCMediaConstraints(
                mandatoryConstraints: ["OfferToReceiveAudio": "true"],
                optionalConstraints: nil
            )
            peerConnection.answer(for: constraints) { sdp, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                guard let sdp else {
                    continuation.resume(throwing: NSError(domain: "CallManager", code: -2))
                    return
                }
                continuation.resume(returning: sdp)
            }
        }
    }

    private func setLocalDescription(_ sdp: RTCSessionDescription, on peerConnection: RTCPeerConnection) async throws {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            peerConnection.setLocalDescription(sdp) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: ())
                }
            }
        }
    }

    private func setRemoteDescription(_ sdp: RTCSessionDescription, on peerConnection: RTCPeerConnection) async throws {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            peerConnection.setRemoteDescription(sdp) { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: ())
                }
            }
        }
    }

    private func flushBufferedRemoteCandidates() async {
        guard let currentPeerConnection = self.peerConnection else { return }
        guard !bufferedRemoteCandidates.isEmpty else { return }
        for candidate in bufferedRemoteCandidates {
            do {
                try await currentPeerConnection.add(candidate)
            } catch {
                callLogger.warning("[Call] Failed to add buffered ICE candidate: \(error.localizedDescription, privacy: .public)")
            }
        }
        bufferedRemoteCandidates.removeAll()
    }

    private func serializeSessionDescription(_ sdp: RTCSessionDescription) -> String {
        let payload: [String: Any] = [
            "type": serializeSdpType(sdp.type),
            "sdp": sdp.sdp,
        ]
        guard let data = try? JSONSerialization.data(withJSONObject: payload),
              let raw = String(data: data, encoding: .utf8) else {
            return ""
        }
        return raw
    }

    private func parseSessionDescription(from raw: String) -> RTCSessionDescription? {
        guard let data = raw.data(using: .utf8),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let typeRaw = json["type"] as? String,
              let sdp = json["sdp"] as? String else {
            return nil
        }

        let type: RTCSdpType
        switch typeRaw.lowercased() {
        case "offer": type = .offer
        case "answer": type = .answer
        case "pranswer": type = .prAnswer
        case "rollback": type = .rollback
        default: return nil
        }

        return RTCSessionDescription(type: type, sdp: sdp)
    }

    private func serializeSdpType(_ type: RTCSdpType) -> String {
        switch type {
        case .offer:
            return "offer"
        case .answer:
            return "answer"
        case .prAnswer:
            return "pranswer"
        case .rollback:
            return "rollback"
        @unknown default:
            return "offer"
        }
    }

    private func parseIceCandidate(from raw: String) -> RTCIceCandidate? {
        guard let data = raw.data(using: .utf8),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let candidate = json["candidate"] as? String else {
            return nil
        }
        let sdpMid = json["sdpMid"] as? String
        let lineIndex = (json["sdpMLineIndex"] as? NSNumber)?.int32Value ?? 0
        return RTCIceCandidate(sdp: candidate, sdpMLineIndex: lineIndex, sdpMid: sdpMid)
    }
}

// MARK: - RTCPeerConnectionDelegate

extension CallManager: RTCPeerConnectionDelegate {
    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {}
    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
        Task { @MainActor in
            callLogger.info("[Call] Remote stream added: \(stream.streamId, privacy: .public) audioTracks=\(stream.audioTracks.count, privacy: .public)")
            for audioTrack in stream.audioTracks {
                audioTrack.isEnabled = true
                callLogger.info("[Call] Remote audio track: \(audioTrack.trackId, privacy: .public) enabled=\(audioTrack.isEnabled.description, privacy: .public) state=\(audioTrack.readyState.rawValue, privacy: .public)")
            }
            if !stream.audioTracks.isEmpty {
                self.hasRemoteAudioTrack = true
                self.setCallActiveIfNeeded()
            }
        }
    }
    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
    nonisolated func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
        Task { @MainActor in
            self.handleIceConnectionStateChanged(newState)
        }
    }
    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {}
    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
        Task { @MainActor in
            self.handleGeneratedCandidate(candidate)
        }
    }
    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {}

    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) {
        callLogger.info("[Call] PeerConnection state: \(newState.rawValue, privacy: .public)")
        if newState == .connected {
            Task { @MainActor in
                if !self.hasConnectedTransport {
                    // Fallback: on some devices we may observe PeerConnection.connected
                    // before ICE callback is delivered on MainActor.
                    self.hasConnectedTransport = true
                }
                if peerConnection.receivers.contains(where: { ($0.track?.kind ?? "") == "audio" }) {
                    self.hasRemoteAudioTrack = true
                }
                self.setCallActiveIfNeeded()
                let localEnabled = self.localAudioTrack?.isEnabled ?? false
                let senderCount = peerConnection.senders.count
                let receiverCount = peerConnection.receivers.count
                let transceiverCount = peerConnection.transceivers.count
                callLogger.info("[Call] CONNECTED — localAudio=\(localEnabled.description, privacy: .public) senders=\(senderCount, privacy: .public) receivers=\(receiverCount, privacy: .public) transceivers=\(transceiverCount, privacy: .public)")
                for (i, t) in peerConnection.transceivers.enumerated() {
                    let dir = t.direction.rawValue
                    let hasSender = t.sender.track != nil
                    let hasReceiver = t.receiver.track != nil
                    let recvEnabled = t.receiver.track?.isEnabled ?? false
                    callLogger.info("[Call] Transceiver[\(i, privacy: .public)]: dir=\(dir, privacy: .public) hasSender=\(hasSender.description, privacy: .public) hasReceiver=\(hasReceiver.description, privacy: .public) recvEnabled=\(recvEnabled.description, privacy: .public)")
                }
                let session = AVAudioSession.sharedInstance()
                callLogger.info("[Call] AudioSession: category=\(session.category.rawValue, privacy: .public) mode=\(session.mode.rawValue, privacy: .public)")
            }
        }
        // NOTE: .failed/.disconnected/.closed handled by ICE state handler only.
        // Previously both handlers called finishCall() → dual-kill on single disconnect event.
    }

    nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
        guard let receiver = transceiver.receiver as RTCRtpReceiver? else { return }
        Task { @MainActor in
            receiver.track?.isEnabled = true
            let trackKind = transceiver.mediaType == .audio ? "audio" : "video"
            callLogger.info("[Call] didStartReceivingOn: \(trackKind, privacy: .public) receiverId=\(receiver.receiverId, privacy: .public) trackEnabled=\((receiver.track?.isEnabled ?? false).description, privacy: .public)")
            if transceiver.mediaType == .audio {
                self.hasRemoteAudioTrack = true
                self.setCallActiveIfNeeded()
            }
            self.attachReceiverCryptor(receiver)
        }
    }
}
