package com.rosetta.messenger.network

import android.content.Context
import android.media.AudioManager
import android.util.Log
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository
import java.security.MessageDigest
import java.security.SecureRandom
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.suspendCancellableCoroutine
import org.bouncycastle.math.ec.rfc7748.X25519
import org.json.JSONObject
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpReceiver
import org.webrtc.RtpSender
import org.webrtc.RtpTransceiver
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

enum class CallPhase {
    IDLE,
    INCOMING,
    OUTGOING,
    CONNECTING,
    ACTIVE
}

enum class CallActionResult {
    STARTED,
    ALREADY_IN_CALL,
    NOT_AUTHENTICATED,
    ACCOUNT_NOT_BOUND,
    INVALID_TARGET,
    NOT_INCOMING
}

data class CallUiState(
    val phase: CallPhase = CallPhase.IDLE,
    val peerPublicKey: String = "",
    val peerTitle: String = "",
    val peerUsername: String = "",
    val statusText: String = "",
    val durationSec: Int = 0,
    val isMuted: Boolean = false,
    val isSpeakerOn: Boolean = false,
    val keyCast: String = ""
) {
    val isVisible: Boolean
        get() = phase != CallPhase.IDLE

    val displayName: String
        get() = when {
            peerTitle.isNotBlank() -> peerTitle
            peerUsername.isNotBlank() -> peerUsername
            peerPublicKey.isNotBlank() -> peerPublicKey.take(12)
            else -> "Unknown"
        }
}

private enum class CallRole {
    CALLER,
    CALLEE
}

/**
 * Android call signaling + WebRTC manager.
 * Mirrors desktop signaling flow on packets 0x1A/0x1B/0x1C.
 */
object CallManager {
    private const val TAG = "CallManager"
    private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track"
    private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream"
    private const val BREADCRUMB_FILE_NAME = "e2ee_breadcrumb.txt"
    private const val DIAG_FILE_NAME = "e2ee_diag.txt"
    private const val NATIVE_CRASH_FILE_NAME = "native_crash.txt"
    private const val TAIL_LINES = 300
    private const val PROTOCOL_LOG_TAIL_LINES = 180
    private const val MAX_LOG_PREFIX = 180
    // Backend's CallManager.java uses RINGING_TIMEOUT = 30s. Local timeouts are
    // slightly larger so the server's RINGING_TIMEOUT signal takes precedence when
    // the network is healthy; local jobs are a fallback when the signal is lost.
    private const val INCOMING_RING_TIMEOUT_MS = 35_000L
    private const val OUTGOING_RING_TIMEOUT_MS = 35_000L
    private const val CONNECTING_TIMEOUT_MS = 30_000L

    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
    private val secureRandom = SecureRandom()

    private val _state = MutableStateFlow(CallUiState())
    val state: StateFlow<CallUiState> = _state.asStateFlow()

    @Volatile
    private var initialized = false
    private var appContext: Context? = null
    private var messageRepository: MessageRepository? = null
    private var accountManager: AccountManager? = null
    private var ownPublicKey: String = ""

    private var role: CallRole? = null
    private var serverCallId: String = ""
    private var serverJoinToken: String = ""
    private var offerSent = false
    private var remoteDescriptionSet = false
    private var callSessionId: String = ""
    private var callStartedAtMs: Long = 0L
    private var keyExchangeSent = false
    private var activeSignalSent = false
    private var lastPeerSharedPublicHex: String = ""

    private var localPrivateKey: ByteArray? = null
    private var localPublicKey: ByteArray? = null

    private var durationJob: Job? = null
    private var protocolStateJob: Job? = null
    private var disconnectResetJob: Job? = null
    private var incomingRingTimeoutJob: Job? = null
    private var outgoingRingTimeoutJob: Job? = null
    private var connectingTimeoutJob: Job? = null

    private var signalWaiter: ((Packet) -> Unit)? = null
    private var webRtcWaiter: ((Packet) -> Unit)? = null
    private var iceWaiter: ((Packet) -> Unit)? = null
    private val webRtcSignalMutex = Mutex()

    private var peerConnectionFactory: PeerConnectionFactory? = null
    private var peerConnection: PeerConnection? = null
    private var audioSource: AudioSource? = null
    private var localAudioTrack: AudioTrack? = null
    private val bufferedRemoteCandidates = mutableListOf<IceCandidate>()

    // E2EE (XChaCha20 — compatible with Desktop)
    private var sharedKeyBytes: ByteArray? = null
    private val senderEncryptors = LinkedHashMap<String, XChaCha20E2EE.Encryptor>()
    private val receiverDecryptors = LinkedHashMap<String, XChaCha20E2EE.Decryptor>()
    private var pendingAudioSenderForE2ee: RtpSender? = null
    private var lastRemoteOfferFingerprint: String = ""
    private var lastLocalOfferFingerprint: String = ""
    private var e2eeRebindJob: Job? = null

    @Volatile private var resetting = false
    private var iceServers: List<PeerConnection.IceServer> = emptyList()

    fun initialize(context: Context) {
        if (initialized) return
        initialized = true
        appContext = context.applicationContext
        CallSoundManager.initialize(context)
        CallProximityManager.initialize(context)
        XChaCha20E2EE.initWithContext(context)

        signalWaiter = ProtocolRuntimeAccess.get().waitCallSignal { packet ->
            scope.launch { handleSignalPacket(packet) }
        }
        webRtcWaiter = ProtocolRuntimeAccess.get().waitWebRtcSignal { packet ->
            scope.launch { handleWebRtcPacket(packet) }
        }
        iceWaiter = ProtocolRuntimeAccess.get().waitIceServers { packet ->
            handleIceServersPacket(packet)
        }

        protocolStateJob =
            scope.launch {
                ProtocolRuntimeAccess.get().state.collect { protocolState ->
                    when (protocolState) {
                        ProtocolState.AUTHENTICATED -> {
                            ProtocolRuntimeAccess.get().requestIceServers()
                        }
                        ProtocolState.DISCONNECTED -> {
                            // Не сбрасываем звонок при переподключении WebSocket —
                            // push мог разбудить процесс и вызвать reconnect,
                            // а звонок уже в INCOMING/CONNECTING
                            val phase = _state.value.phase
                            if (phase == CallPhase.IDLE) {
                                val hasResidualSession =
                                    callSessionId.isNotBlank() ||
                                        serverCallId.isNotBlank() ||
                                        serverJoinToken.isNotBlank() ||
                                        role != null ||
                                        _state.value.peerPublicKey.isNotBlank() ||
                                        sharedKeyBytes != null ||
                                        peerConnection != null
                                if (hasResidualSession) {
                                    resetSession(reason = "Disconnected", notifyPeer = false)
                                } else {
                                    breadcrumb("DISCONNECTED in IDLE — skip reset (no active session)")
                                }
                            } else {
                                breadcrumb("DISCONNECTED but phase=$phase — keeping call alive")
                            }
                        }
                        else -> Unit
                    }
                }
            }

        ProtocolRuntimeAccess.get().requestIceServers()
    }

    fun bindDependencies(
        messageRepository: MessageRepository,
        accountManager: AccountManager
    ) {
        this.messageRepository = messageRepository
        this.accountManager = accountManager
    }

    fun bindAccount(publicKey: String) {
        ownPublicKey = publicKey.trim()
    }

    /**
     * Вызывается из FCM push когда приходит type=call.
     * Ставит CallManager в INCOMING сразу, не дожидаясь WebSocket сигнала.
     * Если WebSocket CALL придёт позже — дедупликация его отбросит.
     */
    fun setIncomingFromPush(
        peerPublicKey: String,
        peerTitle: String,
        callId: String = "",
        joinToken: String = ""
    ) {
        val peer = peerPublicKey.trim()
        if (peer.isBlank()) return
        // Уже в звонке — не перебиваем
        if (_state.value.phase != CallPhase.IDLE) {
            breadcrumb("setIncomingFromPush SKIP: phase=${_state.value.phase}")
            return
        }
        serverCallId = callId.trim()
        serverJoinToken = joinToken.trim()
        breadcrumb(
            "setIncomingFromPush peer=${peer.take(8)}… title=$peerTitle " +
                "callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)}"
        )
        beginCallSession("incoming-push:${peer.take(8)}")
        role = CallRole.CALLEE
        resetRtcObjects()
        val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(peer)
        val title = peerTitle.ifBlank { cachedInfo?.title.orEmpty() }
        val username = cachedInfo?.username.orEmpty()
        setPeer(peer, title, username)
        updateState {
            it.copy(
                phase = CallPhase.INCOMING,
                statusText = "Incoming call..."
            )
        }
        appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
        appContext?.let { ctx ->
            CallForegroundService.syncWithCallState(ctx, _state.value)
        }
        resolvePeerIdentity(peer)
        incomingRingTimeoutJob?.cancel()
        incomingRingTimeoutJob = scope.launch {
            delay(INCOMING_RING_TIMEOUT_MS)
            val pending = _state.value
            if (pending.phase == CallPhase.INCOMING && pending.peerPublicKey == peer) {
                breadcrumb("setIncomingFromPush: timeout → auto-decline")
                declineIncomingCall()
            }
        }
    }

    fun startOutgoingCall(user: SearchUser): CallActionResult {
        val targetKey = user.publicKey.trim()
        if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET
        if (!canStartNewCall()) return CallActionResult.ALREADY_IN_CALL
        if (ownPublicKey.isBlank()) return CallActionResult.ACCOUNT_NOT_BOUND
        if (!ProtocolRuntimeAccess.get().isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED

        resetSession(reason = null, notifyPeer = false)
        beginCallSession("outgoing:${targetKey.take(8)}")
        role = CallRole.CALLER
        generateSessionKeys()
        setPeer(targetKey, user.title, user.username)
        updateState {
            it.copy(
                phase = CallPhase.OUTGOING,
                statusText = "Calling"
            )
        }

        ProtocolRuntimeAccess.get().sendCallSignal(
            signalType = SignalType.CALL,
            src = ownPublicKey,
            dst = targetKey
        )
        breadcrumbState("startOutgoingCall")
        appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }

        // Local fallback for caller: if RINGING_TIMEOUT signal from the server is lost,
        // stop ringing after the same window the server uses (~30s + small buffer).
        outgoingRingTimeoutJob?.cancel()
        outgoingRingTimeoutJob = scope.launch {
            delay(OUTGOING_RING_TIMEOUT_MS)
            val snap = _state.value
            if (snap.phase == CallPhase.OUTGOING && snap.peerPublicKey == targetKey) {
                breadcrumb("startOutgoingCall: local ring timeout (${OUTGOING_RING_TIMEOUT_MS}ms) → reset")
                resetSession(reason = "No answer", notifyPeer = true)
            }
        }
        return CallActionResult.STARTED
    }

    fun acceptIncomingCall(): CallActionResult {
        val snapshot = _state.value
        if (snapshot.phase != CallPhase.INCOMING) return CallActionResult.NOT_INCOMING
        if (snapshot.peerPublicKey.isBlank()) return CallActionResult.INVALID_TARGET

        // Если ownPublicKey пустой (push разбудил но аккаунт не привязан) — попробуем привязать
        if (ownPublicKey.isBlank()) {
            val lastPk = accountManager?.getLastLoggedPublicKey().orEmpty()
            if (lastPk.isNotBlank()) {
                bindAccount(lastPk)
                breadcrumb("acceptIncomingCall: auto-bind account pk=${lastPk.take(8)}…")
            } else {
                return CallActionResult.ACCOUNT_NOT_BOUND
            }
        }
        val restoredAuth = ProtocolRuntimeAccess.get().restoreAuthFromStoredCredentials(
            preferredPublicKey = ownPublicKey,
            reason = "accept_incoming_call"
        )
        if (restoredAuth) {
            ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_incoming_call")
            breadcrumb("acceptIncomingCall: auth restore requested")
        }

        role = CallRole.CALLEE
        generateSessionKeys()

        incomingRingTimeoutJob?.cancel()
        incomingRingTimeoutJob = null

        updateState {
            it.copy(
                phase = CallPhase.CONNECTING,
                statusText = "Connecting..."
            )
        }
        armConnectingTimeout("acceptIncomingCall")

        // Отправляем ACCEPT с callId/joinToken. Если push пришел раньше WS CALL,
        // подождем немного пока идентификаторы звонка подтянутся.
        scope.launch {
            var sent = false
            for (attempt in 1..60) { // 60 * 200ms = 12 sec
                val callIdNow = serverCallId.trim()
                val joinTokenNow = serverJoinToken.trim()
                if (callIdNow.isBlank() || joinTokenNow.isBlank()) {
                    breadcrumb("acceptIncomingCall: waiting callId/joinToken (attempt #$attempt)")
                    kotlinx.coroutines.delay(200)
                    continue
                }
                ProtocolRuntimeAccess.get().sendCallSignal(
                    signalType = SignalType.ACCEPT,
                    src = ownPublicKey,
                    dst = snapshot.peerPublicKey,
                    callId = callIdNow,
                    joinToken = joinTokenNow
                )
                // ACCEPT может быть отправлен до AUTHENTICATED — пакет будет отправлен
                // сразу при открытии сокета (или останется в очереди до onOpen).
                ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_send_$attempt")
                breadcrumb(
                    "acceptIncomingCall: ACCEPT queued/sent (attempt #$attempt) " +
                        "callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
                )
                sent = true
                break
            }
            if (!sent) {
                breadcrumb("acceptIncomingCall: FAILED to send ACCEPT after 12s — resetting")
                resetSession(reason = "Failed to connect", notifyPeer = false)
            }
        }

        breadcrumbState("acceptIncomingCall")
        return CallActionResult.STARTED
    }

    fun declineIncomingCall() {
        val snapshot = _state.value
        if (snapshot.phase != CallPhase.INCOMING) return
        incomingRingTimeoutJob?.cancel()
        incomingRingTimeoutJob = null
        val callIdNow = serverCallId.trim()
        val joinTokenNow = serverJoinToken.trim()
        if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank() && callIdNow.isNotBlank() && joinTokenNow.isNotBlank()) {
            ProtocolRuntimeAccess.get().sendCallSignal(
                signalType = SignalType.END_CALL,
                src = ownPublicKey,
                dst = snapshot.peerPublicKey,
                callId = callIdNow,
                joinToken = joinTokenNow
            )
        } else {
            breadcrumb(
                "declineIncomingCall: skip END_CALL (missing ids) " +
                    "callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
            )
        }
        resetSession(reason = null, notifyPeer = false)
    }

    fun endCall() {
        val callerTrace =
            Throwable()
                .stackTrace
                .take(6)
                .joinToString(" | ") { "${it.className}.${it.methodName}:${it.lineNumber}" }
        breadcrumb("UI: endCall requested by $callerTrace")
        resetSession(reason = null, notifyPeer = true)
    }

    fun toggleMute() {
        updateState { current ->
            val nextMuted = !current.isMuted
            localAudioTrack?.setEnabled(!nextMuted)
            current.copy(isMuted = nextMuted)
        }
    }

    fun toggleSpeaker() {
        updateState { current ->
            val nextSpeaker = !current.isSpeakerOn
            setSpeakerphone(nextSpeaker)
            current.copy(isSpeakerOn = nextSpeaker)
        }
    }

    private fun canStartNewCall(): Boolean {
        return _state.value.phase == CallPhase.IDLE
    }

    private suspend fun handleSignalPacket(packet: PacketSignalPeer) {
        breadcrumb("SIG: ${packet.signalType} from=${packet.src.take(8)}… phase=${_state.value.phase}")

        when (packet.signalType) {
            SignalType.END_CALL_BECAUSE_BUSY -> {
                breadcrumb("SIG: peer busy → reset")
                resetSession(reason = "User is busy", notifyPeer = false)
                return
            }
            SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED -> {
                breadcrumb("SIG: peer disconnected → reset")
                resetSession(reason = "Peer disconnected", notifyPeer = false)
                return
            }
            SignalType.RINGING_TIMEOUT -> {
                breadcrumb("SIG: ringing timeout → reset")
                resetSession(reason = "No answer", notifyPeer = false)
                return
            }
            SignalType.END_CALL -> {
                breadcrumb("SIG: END_CALL → reset")
                resetSession(reason = "Call ended", notifyPeer = false)
                return
            }
            else -> Unit
        }

        val currentPeer = _state.value.peerPublicKey
        val src = packet.src.trim()
        if (currentPeer.isNotBlank() && src.isNotBlank() && src != currentPeer && src != ownPublicKey) {
            breadcrumb("SIG: IGNORED (src mismatch: expected=${currentPeer.take(8)}… got=${src.take(8)}…)")
            return
        }

        when (packet.signalType) {
            SignalType.CALL -> {
                val incomingPeer = packet.src.trim()
                if (incomingPeer.isBlank()) return
                serverCallId = packet.callId.trim()
                serverJoinToken = packet.joinToken.trim()

                // Дедупликация: push уже поставил INCOMING для этого peer — обновляем только имя
                if (_state.value.phase == CallPhase.INCOMING && _state.value.peerPublicKey == incomingPeer) {
                    breadcrumb(
                        "SIG: CALL from ${incomingPeer.take(8)}… but already INCOMING — dedup " +
                            "callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)}"
                    )
                    resolvePeerIdentity(incomingPeer)
                    return
                }

                if (_state.value.phase != CallPhase.IDLE) {
                    breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
                    if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) {
                        ProtocolRuntimeAccess.get().sendCallSignal(
                            signalType = SignalType.END_CALL_BECAUSE_BUSY,
                            src = ownPublicKey,
                            dst = incomingPeer
                        )
                    }
                    return
                }
                beginCallSession("incoming:${incomingPeer.take(8)}")
                breadcrumb(
                    "SIG: CALL from ${incomingPeer.take(8)}… → INCOMING " +
                        "callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)}"
                )
                role = CallRole.CALLEE
                resetRtcObjects()
                // Пробуем сразу взять имя из кэша чтобы ForegroundService показал его
                val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(incomingPeer)
                val cachedTitle = cachedInfo?.title.orEmpty()
                val cachedUsername = cachedInfo?.username.orEmpty()
                setPeer(incomingPeer, cachedTitle, cachedUsername)
                updateState {
                    it.copy(
                        phase = CallPhase.INCOMING,
                        statusText = "Incoming call..."
                    )
                }
                appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
                // Запускаем ForegroundService + IncomingCallActivity
                appContext?.let { ctx ->
                    CallForegroundService.syncWithCallState(ctx, _state.value)
                    // Пробуем запустить IncomingCallActivity напрямую
                    try {
                        val activityIntent = android.content.Intent(
                            ctx,
                            com.rosetta.messenger.IncomingCallActivity::class.java
                        ).apply {
                            flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
                                android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
                        }
                        ctx.startActivity(activityIntent)
                        breadcrumb("IncomingCallActivity started from WebSocket OK")
                    } catch (e: Throwable) {
                        breadcrumb("IncomingCallActivity start FAILED: ${e.message} — relying on fullScreenIntent")
                    }
                }
                resolvePeerIdentity(incomingPeer)
                incomingRingTimeoutJob?.cancel()
                incomingRingTimeoutJob =
                    scope.launch {
                        delay(INCOMING_RING_TIMEOUT_MS)
                        val pending = _state.value
                        if (pending.phase == CallPhase.INCOMING &&
                            pending.peerPublicKey == incomingPeer
                        ) {
                            breadcrumb("SIG: incoming timeout (${INCOMING_RING_TIMEOUT_MS}ms) → auto-decline")
                            declineIncomingCall()
                        }
                    }
            }
            SignalType.KEY_EXCHANGE -> {
                breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange")
                handleKeyExchange(packet)
            }
            SignalType.ACCEPT -> {
                breadcrumb(
                    "SIG: ACCEPT callId=${packet.callId.take(12)} join=${packet.joinToken.take(12)}"
                )
                serverCallId = packet.callId.trim()
                serverJoinToken = packet.joinToken.trim()
                if (role != CallRole.CALLER) {
                    breadcrumb("SIG: ACCEPT ignored — role=$role")
                    return
                }
                // Callee answered before timeout — cancel outgoing ring timer
                outgoingRingTimeoutJob?.cancel()
                outgoingRingTimeoutJob = null
                if (localPrivateKey == null || localPublicKey == null) {
                    breadcrumb("SIG: ACCEPT — generating local session keys")
                    generateSessionKeys()
                }
                val localPublic = localPublicKey ?: return
                ProtocolRuntimeAccess.get().sendCallSignal(
                    signalType = SignalType.KEY_EXCHANGE,
                    src = ownPublicKey,
                    dst = _state.value.peerPublicKey,
                    sharedPublic = localPublic.toHex()
                )
                keyExchangeSent = true
                updateState {
                    it.copy(
                        phase = CallPhase.CONNECTING,
                        statusText = "Exchanging keys..."
                    )
                }
                armConnectingTimeout("signal:accept")
            }
            SignalType.ACTIVE -> {
                breadcrumb("SIG: ACTIVE")
                if (sharedKeyBytes == null && localPrivateKey == null) {
                    breadcrumb("SIG: ACTIVE but no session keys — resetting")
                    CallSoundManager.stop()
                    resetSession(reason = null, notifyPeer = false)
                    return
                }
                updateState {
                    it.copy(
                        phase = CallPhase.CONNECTING,
                        statusText = "Connecting..."
                    )
                }
                armConnectingTimeout("signal:active")
                ensurePeerConnectionAndOffer()
            }
            SignalType.ACTIVE_CALL -> Unit
            else -> breadcrumb("SIG: unhandled ${packet.signalType}")
        }
    }

    private suspend fun handleKeyExchange(packet: PacketSignalPeer) {
        val peerKey = packet.src.trim().ifBlank { _state.value.peerPublicKey }
        if (peerKey.isBlank()) {
            breadcrumb("KE: ABORT — peerKey blank")
            return
        }
        setPeer(peerKey, _state.value.peerTitle, _state.value.peerUsername)

        val peerPublicHex = packet.sharedPublic.trim()
        if (peerPublicHex.isBlank()) {
            breadcrumb("KE: ABORT — sharedPublic blank")
            return
        }
        val duplicatePeerKey = lastPeerSharedPublicHex.equals(peerPublicHex, ignoreCase = true)
        if (duplicatePeerKey && sharedKeyBytes != null) {
            breadcrumb("KE: duplicate peer key ignored")
            return
        }
        breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}…")
        lastPeerSharedPublicHex = peerPublicHex

        if (role == CallRole.CALLER) {
            if (localPrivateKey == null || localPublicKey == null) {
                breadcrumb("KE: CALLER — generating session keys (were null)")
                generateSessionKeys()
            }
            val sharedKey = computeSharedSecretHex(peerPublicHex)
            if (sharedKey == null) {
                breadcrumb("KE: CALLER — computeSharedSecret FAILED")
                return
            }
            setupE2EE(sharedKey)
            breadcrumb("KE: CALLER — E2EE ready, notifying ACTIVE")
            updateState { it.copy(keyCast = sharedKey, statusText = "Connecting...") }
            if (!activeSignalSent) {
                ProtocolRuntimeAccess.get().sendCallSignal(
                    signalType = SignalType.ACTIVE,
                    src = ownPublicKey,
                    dst = peerKey
                )
                activeSignalSent = true
            }
            updateState { it.copy(phase = CallPhase.CONNECTING) }
            armConnectingTimeout("key_exchange:caller")
            return
        }

        if (role == CallRole.CALLEE) {
            if (localPrivateKey == null || localPublicKey == null) {
                breadcrumb("KE: CALLEE — regenerating session keys (were null)")
                generateSessionKeys()
            }
            val sharedKey = computeSharedSecretHex(peerPublicHex)
            if (sharedKey == null) {
                breadcrumb("KE: CALLEE — computeSharedSecret FAILED")
                return
            }
            setupE2EE(sharedKey)
            if (!keyExchangeSent) {
                val localPublic = localPublicKey ?: return
                ProtocolRuntimeAccess.get().sendCallSignal(
                    signalType = SignalType.KEY_EXCHANGE,
                    src = ownPublicKey,
                    dst = peerKey,
                    sharedPublic = localPublic.toHex()
                )
                keyExchangeSent = true
            }
            breadcrumb("KE: CALLEE — E2EE ready, waiting for ACTIVE")
            updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) }
            armConnectingTimeout("key_exchange:callee")
            return
        }

        breadcrumb("KE: ignored — unknown role")
    }

    private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
        webRtcSignalMutex.withLock {
            val phase = _state.value.phase
            breadcrumb("RTC: packet=${packet.signalType} payloadLen=${packet.sdpOrCandidate.length} phase=$phase")
            if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
                breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
                return@withLock
            }
            val pc = peerConnection
            if (pc == null) {
                breadcrumb("RTC: IGNORED ${packet.signalType} — peerConnection=null!")
                return@withLock
            }

            when (packet.signalType) {
                WebRTCSignalType.ANSWER -> {
                    val answer = parseSessionDescription(packet.sdpOrCandidate) ?: return@withLock
                    if (answer.type != SessionDescription.Type.ANSWER) {
                        breadcrumb("RTC: ANSWER packet with type=${answer.type} ignored")
                        return@withLock
                    }

                    val stateBefore = pc.signalingState()
                    breadcrumb("RTC: ANSWER received state=$stateBefore")
                    if (stateBefore == PeerConnection.SignalingState.STABLE && remoteDescriptionSet) {
                        breadcrumb("RTC: ANSWER duplicate ignored (already stable)")
                        return@withLock
                    }

                    try {
                        pc.setRemoteDescriptionAwait(answer)
                        remoteDescriptionSet = true
                        flushBufferedRemoteCandidates()
                        attachReceiverE2EEFromPeerConnection()
                        breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}")
                    } catch (e: Exception) {
                        breadcrumb("RTC: ANSWER FAILED — ${e.message}")
                        saveCrashReport("setRemoteDescription(answer) failed", e)
                    }
                }
                WebRTCSignalType.ICE_CANDIDATE -> {
                    val candidate = parseIceCandidate(packet.sdpOrCandidate) ?: return@withLock
                    if (!remoteDescriptionSet) {
                        breadcrumb("RTC: ICE buffered (remoteDesc not set yet)")
                        bufferedRemoteCandidates.add(candidate)
                        return@withLock
                    }
                    breadcrumb("RTC: ICE added: ${candidate.sdp.take(40)}…")
                    runCatching { pc.addIceCandidate(candidate) }
                }
                WebRTCSignalType.OFFER -> {
                    val remoteOffer = parseSessionDescription(packet.sdpOrCandidate) ?: return@withLock
                    if (remoteOffer.type != SessionDescription.Type.OFFER) {
                        breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored")
                        return@withLock
                    }
                    val offerFingerprint = remoteOffer.description.shortFingerprintHex(10)
                    val phaseNow = _state.value.phase
                    if (offerFingerprint == lastLocalOfferFingerprint) {
                        breadcrumb("RTC: OFFER loopback ignored fp=$offerFingerprint")
                        return@withLock
                    }
                    if (phaseNow == CallPhase.ACTIVE && offerFingerprint == lastRemoteOfferFingerprint) {
                        breadcrumb("RTC: OFFER duplicate in ACTIVE ignored fp=$offerFingerprint")
                        return@withLock
                    }

                    breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})")
                    try {
                        pc.setRemoteDescriptionAwait(remoteOffer)
                        remoteDescriptionSet = true
                        flushBufferedRemoteCandidates()
                        attachReceiverE2EEFromPeerConnection()

                        val stateAfterRemote = pc.signalingState()
                        if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER &&
                            stateAfterRemote != PeerConnection.SignalingState.HAVE_LOCAL_PRANSWER
                        ) {
                            breadcrumb("RTC: OFFER skip createAnswer, bad state=$stateAfterRemote")
                            return@withLock
                        }

                        val answer = pc.createAnswerAwait()
                        pc.setLocalDescriptionAwait(answer)
                        ProtocolRuntimeAccess.get().sendWebRtcSignal(
                            signalType = WebRTCSignalType.ANSWER,
                            sdpOrCandidate = serializeSessionDescription(answer)
                        )
                        attachReceiverE2EEFromPeerConnection()
                        lastRemoteOfferFingerprint = offerFingerprint
                        breadcrumb("RTC: OFFER handled → ANSWER sent")
                    } catch (e: Exception) {
                        breadcrumb("RTC: OFFER FAILED — ${e.message}")
                        saveCrashReport("handleOffer failed", e)
                    }
                }
            }
        }
    }

    private fun handleIceServersPacket(packet: PacketIceServers) {
        iceServers =
            packet.iceServers.mapNotNull { server ->
                val url = server.url.trim()
                val transport = server.transport.trim()
                if (url.isBlank() || transport.isBlank()) return@mapNotNull null
                PeerConnection.IceServer
                    .builder("turn:$url?transport=$transport")
                    .setUsername(server.username)
                    .setPassword(server.credential)
                    .createIceServer()
            }
    }

    private suspend fun ensurePeerConnectionAndOffer() {
        val peerKey = _state.value.peerPublicKey
        if (peerKey.isBlank()) {
            breadcrumb("PC: ensurePCAndOffer SKIP — peer=${peerKey.take(8)}…")
            return
        }
        if (offerSent) {
            breadcrumb("PC: ensurePCAndOffer SKIP — offerSent=true")
            return
        }

        breadcrumb("PC: ensurePCAndOffer START role=$role")
        ensurePeerFactory()
        val factory = peerConnectionFactory
        if (factory == null) {
            breadcrumb("PC: ABORT — factory=null")
            return
        }
        val pc = peerConnection ?: createPeerConnection(factory)
        if (pc == null) {
            breadcrumb("PC: ABORT — createPeerConnection returned null")
            return
        }

        if (audioSource == null) {
            audioSource = factory.createAudioSource(MediaConstraints())
        }
        var senderToAttach: RtpSender? = null
        if (localAudioTrack == null) {
            localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
            localAudioTrack?.setEnabled(!_state.value.isMuted)
            val txInit =
                RtpTransceiver.RtpTransceiverInit(
                    RtpTransceiver.RtpTransceiverDirection.SEND_RECV,
                    listOf(LOCAL_MEDIA_STREAM_ID)
                )
            val transceiver = pc.addTransceiver(localAudioTrack, txInit)
            senderToAttach = transceiver?.sender
            pendingAudioSenderForE2ee = senderToAttach
            breadcrumb("PC: audio transceiver added (E2EE attach deferred)")
        } else {
            senderToAttach =
                runCatching {
                    pc.senders.firstOrNull { sender ->
                        sender.track()?.kind() == "audio"
                    }
                }.getOrNull()
            if (senderToAttach != null) {
                pendingAudioSenderForE2ee = senderToAttach
            }
        }
        attachSenderE2EE(pendingAudioSenderForE2ee ?: senderToAttach)

        try {
            val offer = pc.createOfferAwait()
            pc.setLocalDescriptionAwait(offer)
            lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
            breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
            ProtocolRuntimeAccess.get().sendWebRtcSignal(
                signalType = WebRTCSignalType.OFFER,
                sdpOrCandidate = serializeSessionDescription(offer)
            )
            offerSent = true
            breadcrumb("PC: OFFER sent OK")
        } catch (e: Exception) {
            breadcrumb("PC: OFFER FAILED — ${e.message}")
            saveCrashReport("createOffer failed", e)
        }
    }

    private fun createPeerConnection(factory: PeerConnectionFactory): PeerConnection? {
        breadcrumb("PC: createPeerConnection iceServers=${iceServers.size}")
        val rtcIceServers =
            if (iceServers.isNotEmpty()) {
                iceServers
            } else {
                breadcrumb("PC: no TURN servers — using Google STUN fallback")
                listOf(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer())
            }

        val configuration = PeerConnection.RTCConfiguration(rtcIceServers).apply {
            sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
        }

        val observer =
            object : PeerConnection.Observer {
                override fun onSignalingChange(newState: PeerConnection.SignalingState?) {
                    breadcrumb("PC: signalingState=$newState")
                }
                override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
                    breadcrumb("PC: iceConnState=$newState")
                }
                override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
                override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
                    breadcrumb("PC: iceGathering=$newState")
                }
                override fun onIceCandidate(candidate: IceCandidate?) {
                    if (candidate == null) return
                    breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}…")
                    ProtocolRuntimeAccess.get().sendWebRtcSignal(
                        signalType = WebRTCSignalType.ICE_CANDIDATE,
                        sdpOrCandidate = serializeIceCandidate(candidate)
                    )
                }
                override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
                override fun onAddStream(stream: org.webrtc.MediaStream?) = Unit
                override fun onRemoveStream(stream: org.webrtc.MediaStream?) = Unit
                override fun onDataChannel(dataChannel: org.webrtc.DataChannel?) = Unit
                override fun onRenegotiationNeeded() = Unit
                override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
                override fun onTrack(transceiver: RtpTransceiver?) {
                    breadcrumb("PC: onTrack → attachReceiverE2EE")
                    attachReceiverE2EE(transceiver?.receiver)
                    attachReceiverE2EEFromPeerConnection()
                }
                override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
                    breadcrumb("PC: connState=$newState")
                    breadcrumbState("onConnectionChange:$newState")
                    when (newState) {
                        PeerConnection.PeerConnectionState.CONNECTED -> {
                            disconnectResetJob?.cancel()
                            disconnectResetJob = null
                            onCallConnected()
                        }
                        PeerConnection.PeerConnectionState.FAILED,
                        PeerConnection.PeerConnectionState.CLOSED,
                        PeerConnection.PeerConnectionState.DISCONNECTED -> {
                            // Desktop flow is signal-driven. Keep call alive across short
                            // transport glitches and only fallback-reset after timeout.
                            disconnectResetJob?.cancel()
                            disconnectResetJob =
                                scope.launch {
                                    val timeoutMs =
                                        when (newState) {
                                            PeerConnection.PeerConnectionState.DISCONNECTED -> 15_000L
                                            PeerConnection.PeerConnectionState.FAILED -> 12_000L
                                            PeerConnection.PeerConnectionState.CLOSED -> 12_000L
                                            else -> 12_000L
                                        }
                                    delay(timeoutMs)
                                    val pcState = peerConnection?.connectionState()
                                    if (pcState == PeerConnection.PeerConnectionState.DISCONNECTED ||
                                        pcState == PeerConnection.PeerConnectionState.FAILED ||
                                        pcState == PeerConnection.PeerConnectionState.CLOSED
                                    ) {
                                        breadcrumb("PC: $newState timeout ($timeoutMs ms) → reset")
                                        resetSession(reason = "Connection lost", notifyPeer = false)
                                    } else {
                                        breadcrumb("PC: $newState recovered (state=$pcState)")
                                    }
                                }
                        }
                        else -> Unit
                    }
                }
            }

        peerConnection = factory.createPeerConnection(configuration, observer)
        return peerConnection
    }

    private fun onCallConnected() {
        disarmConnectingTimeout("connected")
        appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) }
        val keyFp = sharedKeyBytes?.fingerprintHex(8) ?: "null"
        breadcrumb("CONNECTED: e2eeAvail=$e2eeAvailable keyFp=$keyFp sEnc=${senderEncryptors.size} rDec=${receiverDecryptors.size} nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
        breadcrumbState("onCallConnected")
        updateState { it.copy(phase = CallPhase.ACTIVE, statusText = "Call active") }
        durationJob?.cancel()
        durationJob =
            scope.launch {
                while (true) {
                    delay(1_000L)
                    _state.update { current ->
                        if (current.phase != CallPhase.ACTIVE) return@update current
                        current.copy(durationSec = current.durationSec + 1)
                    }
                }
            }
    }

    private fun armConnectingTimeout(origin: String) {
        connectingTimeoutJob?.cancel()
        connectingTimeoutJob =
            scope.launch {
                delay(CONNECTING_TIMEOUT_MS)
                val snapshot = _state.value
                if (snapshot.phase != CallPhase.CONNECTING) return@launch
                breadcrumb(
                    "CONNECTING TIMEOUT origin=$origin role=$role callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)} " +
                        "keyExSent=$keyExchangeSent activeSent=$activeSignalSent offerSent=$offerSent " +
                        "remoteDesc=$remoteDescriptionSet peer=${snapshot.peerPublicKey.take(8)}…"
                )
                resetSession(reason = "Connecting timeout", notifyPeer = false)
            }
        breadcrumb("CONNECTING watchdog armed origin=$origin timeoutMs=$CONNECTING_TIMEOUT_MS")
    }

    private fun disarmConnectingTimeout(origin: String) {
        if (connectingTimeoutJob != null) {
            connectingTimeoutJob?.cancel()
            connectingTimeoutJob = null
            breadcrumb("CONNECTING watchdog disarmed origin=$origin")
        }
    }

    private fun setPeer(publicKey: String, title: String, username: String) {
        updateState {
            it.copy(
                peerPublicKey = publicKey.trim(),
                peerTitle = title.trim(),
                peerUsername = username.trim()
            )
        }
    }

    private fun resolvePeerIdentity(publicKey: String) {
        scope.launch {
            val resolved = ProtocolRuntimeAccess.get().resolveUserInfo(publicKey)
            if (resolved != null && _state.value.peerPublicKey == publicKey) {
                setPeer(publicKey, resolved.title, resolved.username)
            }
        }
    }

    private fun ensurePeerFactory() {
        if (peerConnectionFactory != null) return
        val context = appContext ?: return
        PeerConnectionFactory.initialize(
            PeerConnectionFactory.InitializationOptions.builder(context)
                .createInitializationOptions()
        )
        peerConnectionFactory = PeerConnectionFactory.builder().createPeerConnectionFactory()
    }

    private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
        val peerPublicKey = snapshot.peerPublicKey.trim()
        if (peerPublicKey.isBlank()) return

        val durationSec = snapshot.durationSec.coerceAtLeast(0)
        val callAttachment =
            MessageAttachment(
                id = java.util.UUID.randomUUID().toString().replace("-", "").take(16),
                blob = "",
                type = AttachmentType.CALL,
                preview = durationSec.toString()
            )

        // Capture role synchronously before the coroutine launches, because
        // resetSession() sets role = null right after calling this function —
        // otherwise the async check below would fall through to the callee branch.
        val capturedRole = role

        scope.launch {
            runCatching {
                val repository = messageRepository
                if (repository == null) {
                    breadcrumb("CALL ATTACHMENT: MessageRepository not bound")
                    return@runCatching
                }
                if (capturedRole == CallRole.CALLER) {
                    // CALLER: send call attachment as a message (peer will receive it)
                    repository.sendMessage(
                        toPublicKey = peerPublicKey,
                        text = "",
                        attachments = listOf(callAttachment)
                    )
                } else {
                    // CALLEE: do not create local fallback call message.
                    // Caller sends a single canonical CALL attachment; local fallback here
                    // caused duplicates (local + remote) in direct dialogs.
                    breadcrumb("CALL ATTACHMENT: CALLEE skip local fallback, waiting caller message")
                }
            }.onFailure { error ->
                Log.w(TAG, "Failed to emit call attachment", error)
            }
        }
    }

    private fun resetSession(reason: String?, notifyPeer: Boolean) {
        resetting = true
        disarmConnectingTimeout("resetSession")
        breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
        breadcrumbState("resetSession")
        appContext?.let { CallProximityManager.setEnabled(it, false) }
        val snapshot = _state.value
        val wasActive = snapshot.phase != CallPhase.IDLE
        val peerToNotify = snapshot.peerPublicKey
        if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) {
            ProtocolRuntimeAccess.get().sendCallSignal(
                signalType = SignalType.END_CALL,
                src = ownPublicKey,
                dst = peerToNotify,
                callId = serverCallId,
                joinToken = serverJoinToken
            )
        }
        // Отменяем все jobs ПЕРВЫМИ — чтобы они не вызвали updateState с пустым state
        durationJob?.cancel()
        durationJob = null
        e2eeRebindJob?.cancel()
        e2eeRebindJob = null
        disconnectResetJob?.cancel()
        disconnectResetJob = null
        incomingRingTimeoutJob?.cancel()
        incomingRingTimeoutJob = null
        outgoingRingTimeoutJob?.cancel()
        outgoingRingTimeoutJob = null
        // Play end call sound, then stop all
        if (wasActive) {
            appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
        } else {
            CallSoundManager.stop()
        }
        if (!reason.isNullOrBlank()) {
            Log.d(TAG, reason)
        }
        emitCallAttachmentIfNeeded(snapshot)
        resetRtcObjects()
        e2eeAvailable = true
        lastScanLog = ""
        lastHealthLog = ""
        healthLogCount = 0
        role = null
        serverCallId = ""
        serverJoinToken = ""
        offerSent = false
        remoteDescriptionSet = false
        keyExchangeSent = false
        activeSignalSent = false
        lastPeerSharedPublicHex = ""
        lastRemoteOfferFingerprint = ""
        lastLocalOfferFingerprint = ""
        localPrivateKey = null
        localPublicKey = null
        callSessionId = ""
        callStartedAtMs = 0L
        setSpeakerphone(false)
        // Останавливаем ForegroundService и сбрасываем state
        appContext?.let { CallForegroundService.stop(it) }
        _state.value = CallUiState()
        resetting = false
    }

    private fun resetRtcObjects() {
        bufferedRemoteCandidates.clear()
        // Teardown E2EE BEFORE closing PeerConnection — WebRTC may access
        // encryptor/decryptor during close(), causing SIGSEGV if done after.
        teardownE2EE()
        runCatching { localAudioTrack?.setEnabled(false) }
        runCatching { localAudioTrack?.dispose() }
        runCatching { audioSource?.dispose() }
        // close() stops media but does NOT release native resources (ICE agent,
        // ports, OWTF threads). Without dispose() the old PC keeps ports bound
        // for ~30 s, blocking the next call from connecting.
        runCatching { peerConnection?.close() }
        runCatching { peerConnection?.dispose() }
        localAudioTrack = null
        audioSource = null
        peerConnection = null
    }

    private fun flushBufferedRemoteCandidates() {
        val pc = peerConnection ?: return
        if (bufferedRemoteCandidates.isEmpty()) return
        bufferedRemoteCandidates.forEach { candidate ->
            runCatching { pc.addIceCandidate(candidate) }
        }
        bufferedRemoteCandidates.clear()
    }

    // ── E2EE (XChaCha20 — compatible with Desktop) ──────────────────

    @Volatile
    private var e2eeAvailable = true

    private fun setupE2EE(sharedKeyHex: String) {
        val keyBytes = sharedKeyHex.hexToBytes()
        if (keyBytes == null || keyBytes.size < 32) {
            Log.e(TAG, "E2EE: invalid key (${keyBytes?.size ?: 0} bytes)")
            return
        }
        sharedKeyBytes = keyBytes.copyOf(32)
        breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
        // Enable frame-level diagnostics for all builds (needed for debugging E2EE issues).
        try {
            val dir = java.io.File(appContext!!.filesDir, "crash_reports")
            if (!dir.exists()) dir.mkdirs()
            val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
            XChaCha20E2EE.nativeOpenDiagFile(diagPath)
            breadcrumb("E2EE: diag file opened at $diagPath")
        } catch (t: Throwable) {
            breadcrumb("E2EE: diag file open FAILED: ${t.message}")
        }
        // If sender track already exists, bind encryptor now.
        val existingSender =
            pendingAudioSenderForE2ee
                ?: runCatching {
                    peerConnection?.senders?.firstOrNull { sender -> sender.track()?.kind() == "audio" }
                }.getOrNull()
        if (existingSender != null) {
            attachSenderE2EE(existingSender)
        }
        attachReceiverE2EEFromPeerConnection()
        startE2EERebindLoopIfNeeded()
        Log.i(TAG, "E2EE key ready (XChaCha20)")
    }

    private var lastHealthLog = ""
    private var healthLogCount = 0

    private fun startE2EERebindLoopIfNeeded() {
        if (e2eeRebindJob?.isActive == true) return
        healthLogCount = 0
        lastHealthLog = ""
        e2eeRebindJob =
            scope.launch {
                while (true) {
                    delay(1500L)
                    if (!e2eeAvailable || sharedKeyBytes == null) continue
                    val phaseNow = _state.value.phase
                    if (phaseNow != CallPhase.CONNECTING && phaseNow != CallPhase.ACTIVE) continue
                    val pc = peerConnection ?: continue
                    val sender =
                        runCatching {
                            pc.senders.firstOrNull { it.track()?.kind() == "audio" }
                        }.getOrNull()
                    if (sender != null) {
                        attachSenderE2EE(sender)
                    }
                    attachReceiverE2EEFromPeerConnection()
                    // Periodic health check: log frame counts from native
                    healthLogCount++
                    if (healthLogCount % 4 == 0) { // every ~6s
                        logE2EEHealth()
                    }
                }
            }
    }

    private fun logE2EEHealth() {
        val encFrames = senderEncryptors.values.firstOrNull()?.frameCount() ?: -1
        val decFrames = receiverDecryptors.values.firstOrNull()?.frameCount() ?: -1
        val badStreak = receiverDecryptors.values.firstOrNull()?.badStreak() ?: -1
        val keyFp = sharedKeyBytes?.fingerprintHex(8) ?: "null"
        val health = "enc=$encFrames dec=$decFrames bad=$badStreak keyFp=$keyFp sEnc=${senderEncryptors.size} rDec=${receiverDecryptors.size}"
        // Only log if state changed or every 5th iteration to avoid spam
        if (health != lastHealthLog || healthLogCount % 20 == 0) {
            breadcrumb("E2EE-HEALTH: $health")
            lastHealthLog = health
        }
    }

    private var lastScanLog = ""

    private fun attachReceiverE2EEFromPeerConnection() {
        val pc = peerConnection ?: return
        runCatching {
            var fromReceivers = 0
            var fromTransceivers = 0
            pc.receivers.forEach { receiver ->
                if (isAudioReceiver(receiver)) {
                    attachReceiverE2EE(receiver)
                    fromReceivers++
                }
            }
            pc.transceivers.forEach { transceiver ->
                val receiver = transceiver.receiver ?: return@forEach
                if (isAudioReceiver(receiver)) {
                    attachReceiverE2EE(receiver)
                    fromTransceivers++
                }
            }
            val hasKey = sharedKeyBytes != null
            val scanLog = "recv=$fromReceivers tx=$fromTransceivers totalMap=${receiverDecryptors.size} hasKey=$hasKey e2eeAvail=$e2eeAvailable"
            if (scanLog != lastScanLog) {
                breadcrumb("E2EE: scan receivers attached $scanLog")
                lastScanLog = scanLog
            }
        }.onFailure {
            breadcrumb("E2EE: attachReceiverE2EEFromPeerConnection failed: ${it.message}")
        }
    }

    /** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */
    private fun breadcrumb(step: String) {
        try {
            val dir = ensureCrashReportsDir() ?: return
            if (!dir.exists()) dir.mkdirs()
            val f = java.io.File(dir, BREADCRUMB_FILE_NAME)
            // Reset file at start of key exchange
            if (step.startsWith("KE:") && step.contains("agreement")) {
                f.writeText("")
            }
            val sidPrefix = if (callSessionId.isNotBlank()) "[sid=$callSessionId] " else ""
            f.appendText("${System.currentTimeMillis()} $sidPrefix$step\n")
        } catch (_: Throwable) {}
    }

    /** Save a full crash report to crash_reports/ */
    private fun saveCrashReport(title: String, error: Throwable) {
        try {
            val dir = ensureCrashReportsDir() ?: return
            if (!dir.exists()) dir.mkdirs()
            val ts = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.getDefault()).format(java.util.Date())
            val f = java.io.File(dir, "crash_e2ee_$ts.txt")
            val sw = java.io.StringWriter()
            error.printStackTrace(java.io.PrintWriter(sw))
            val breadcrumbTail = readFileTail(java.io.File(dir, BREADCRUMB_FILE_NAME), TAIL_LINES)
            val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES)
            val nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES)
            val protocolTail =
                ProtocolRuntimeAccess.get().debugLogs.value
                    .takeLast(PROTOCOL_LOG_TAIL_LINES)
                    .joinToString("\n")
            f.writeText(
                buildString {
                    appendLine("=== E2EE CRASH REPORT ===")
                    appendLine(title)
                    appendLine()
                    appendLine("Time: $ts")
                    appendLine("Type: ${error.javaClass.name}")
                    appendLine("Message: ${error.message}")
                    appendLine()
                    appendLine("--- CALL SNAPSHOT ---")
                    appendLine(buildStateSnapshot())
                    appendLine()
                    appendLine("--- STACKTRACE ---")
                    appendLine(sw.toString())
                    appendLine()
                    appendLine("--- NATIVE CRASH (tail) ---")
                    appendLine(nativeCrash)
                    appendLine()
                    appendLine("--- E2EE DIAG (tail) ---")
                    appendLine(diagTail)
                    appendLine()
                    appendLine("--- E2EE BREADCRUMB (tail) ---")
                    appendLine(breadcrumbTail)
                    appendLine()
                    appendLine("--- PROTOCOL LOGS (tail) ---")
                    appendLine(if (protocolTail.isBlank()) "<empty>" else protocolTail)
                }
            )
        } catch (_: Throwable) {}
    }

    private fun beginCallSession(seed: String) {
        val bytes = ByteArray(4)
        secureRandom.nextBytes(bytes)
        val random = bytes.joinToString("") { "%02x".format(it) }
        callSessionId = "${seed.take(8)}-$random"
        callStartedAtMs = System.currentTimeMillis()
        breadcrumb("SESSION: begin seed=$seed")
    }

    private fun ensureCrashReportsDir(): java.io.File? {
        val context = appContext ?: return null
        return java.io.File(context.filesDir, "crash_reports").apply { if (!exists()) mkdirs() }
    }

    private fun readFileTail(file: java.io.File, maxLines: Int): String {
        if (!file.exists()) return "<missing: ${file.name}>"
        return runCatching {
            val lines = file.readLines()
            val tail = if (lines.size <= maxLines) lines else lines.takeLast(maxLines)
            if (tail.isEmpty()) "<empty: ${file.name}>" else tail.joinToString("\n")
        }.getOrElse { "<read-failed: ${file.name}: ${it.message}>" }
    }

    private fun buildStateSnapshot(): String {
        val st = _state.value
        val now = System.currentTimeMillis()
        val age = if (callStartedAtMs > 0L) now - callStartedAtMs else -1L
        val pc = peerConnection
        val pcSig = runCatching { pc?.signalingState() }.getOrNull()
        val pcIce = runCatching { pc?.iceConnectionState() }.getOrNull()
        val pcConn = runCatching { pc?.connectionState() }.getOrNull()
        val pcLocal = runCatching { pc?.localDescription?.type?.canonicalForm() }.getOrDefault("-")
        val pcRemote = runCatching { pc?.remoteDescription?.type?.canonicalForm() }.getOrDefault("-")
        val senders = runCatching { pc?.senders?.size ?: 0 }.getOrDefault(-1)
        val receivers = runCatching { pc?.receivers?.size ?: 0 }.getOrDefault(-1)
        return buildString {
            append("sid=").append(if (callSessionId.isBlank()) "<none>" else callSessionId)
            append(" ageMs=").append(age)
            append(" phase=").append(st.phase)
            append(" role=").append(role)
            append(" peer=").append(st.peerPublicKey.take(12))
            append(" callId=").append(serverCallId.take(16))
            append(" join=").append(serverJoinToken.take(16))
            append(" offerSent=").append(offerSent)
            append(" remoteDescSet=").append(remoteDescriptionSet)
            append(" e2eeAvail=").append(e2eeAvailable)
            append(" keyBytes=").append(sharedKeyBytes?.size ?: 0)
            append(" pc(sig=").append(pcSig)
            append(",ice=").append(pcIce)
            append(",conn=").append(pcConn)
            append(",local=").append(pcLocal)
            append(",remote=").append(pcRemote)
            append(",senders=").append(senders)
            append(",receivers=").append(receivers)
            append(")")
        }
    }

    private fun breadcrumbState(marker: String) {
        breadcrumb("STATE[$marker] ${buildStateSnapshot()}")
    }

    private fun senderMapKey(sender: RtpSender): String {
        val id = runCatching { sender.id() }.getOrNull().orEmpty()
        return if (id.isNotBlank()) "sid:$id" else "sender@${System.identityHashCode(sender)}"
    }

    private fun receiverMapKey(receiver: RtpReceiver): String {
        val id = runCatching { receiver.id() }.getOrNull().orEmpty()
        return if (id.isNotBlank()) "rid:$id" else "recv@${System.identityHashCode(receiver)}"
    }

    private fun attachSenderE2EE(sender: RtpSender?) {
        if (!e2eeAvailable) {
            breadcrumb("E2EE: attachSender SKIP — e2eeAvailable=false")
            return
        }
        val key = sharedKeyBytes
        if (key == null) {
            breadcrumb("E2EE: attachSender SKIP — sharedKeyBytes=null")
            return
        }
        if (sender == null) return
        val mapKey = senderMapKey(sender)
        val existing = senderEncryptors[mapKey]
        if (existing != null) {
            runCatching { sender.setFrameEncryptor(existing) }
            return
        }

        try {
            breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
            val enc = XChaCha20E2EE.Encryptor(key)
            breadcrumb("2. encryptor created")
            val ptr = enc.getNativeFrameEncryptor()
            breadcrumb("3. encryptor ptr=0x${ptr.toString(16)}")
            if (ptr == 0L) {
                saveCrashReport("Encryptor native ptr is 0", RuntimeException("null native ptr"))
                return
            }
            breadcrumb("4. calling sender.setFrameEncryptor…")
            sender.setFrameEncryptor(enc)
            breadcrumb("5. setFrameEncryptor OK!")
            senderEncryptors[mapKey] = enc
            pendingAudioSenderForE2ee = null
        } catch (e: Throwable) {
            saveCrashReport("attachSenderE2EE failed", e)
            Log.e(TAG, "E2EE: sender encryptor failed", e)
            e2eeAvailable = false
        }
    }

    private fun isAudioReceiver(receiver: RtpReceiver?): Boolean {
        if (receiver == null) return false
        return runCatching { receiver.track()?.kind() == "audio" }.getOrDefault(false)
    }

    private fun attachReceiverE2EE(receiver: RtpReceiver?) {
        if (!e2eeAvailable) {
            breadcrumb("E2EE: attachReceiver SKIP — e2eeAvailable=false")
            return
        }
        val key = sharedKeyBytes
        if (key == null) {
            breadcrumb("E2EE: attachReceiver SKIP — sharedKeyBytes=null")
            return
        }
        if (receiver == null) return
        val mapKey = receiverMapKey(receiver)
        val existing = receiverDecryptors[mapKey]
        if (existing != null) {
            runCatching { receiver.setFrameDecryptor(existing) }
            return
        }

        try {
            breadcrumb("6. decryptor: creating…")
            val dec = XChaCha20E2EE.Decryptor(key)
            breadcrumb("7. decryptor created")
            val ptr = dec.getNativeFrameDecryptor()
            breadcrumb("8. decryptor ptr=0x${ptr.toString(16)}")
            if (ptr == 0L) {
                saveCrashReport("Decryptor native ptr is 0", RuntimeException("null native ptr"))
                return
            }
            breadcrumb("9. calling receiver.setFrameDecryptor…")
            receiver.setFrameDecryptor(dec)
            breadcrumb("10. setFrameDecryptor OK!")
            receiverDecryptors[mapKey] = dec
        } catch (e: Throwable) {
            saveCrashReport("attachReceiverE2EE failed", e)
            Log.e(TAG, "E2EE: receiver decryptor failed", e)
            e2eeAvailable = false
        }
    }

    private fun teardownE2EE() {
        // Release our ref. WebRTC holds its own ref via scoped_refptr.
        // After our Release: WebRTC ref remains. On peerConnection.close()
        // WebRTC releases its ref → ref=0 → native object deleted.
        senderEncryptors.values.forEach { enc ->
            runCatching { enc.dispose() }
        }
        receiverDecryptors.values.forEach { dec ->
            runCatching { dec.dispose() }
        }
        senderEncryptors.clear()
        receiverDecryptors.clear()
        pendingAudioSenderForE2ee = null
        sharedKeyBytes?.let { it.fill(0) }
        sharedKeyBytes = null
        runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
    }

    private fun generateSessionKeys() {
        val privateKey = ByteArray(32)
        X25519.generatePrivateKey(secureRandom, privateKey)
        val publicKey = ByteArray(32)
        X25519.generatePublicKey(privateKey, 0, publicKey, 0)
        localPrivateKey = privateKey
        localPublicKey = publicKey
        breadcrumb("KE: local keypair pub=${publicKey.shortHex()} privFp=${privateKey.fingerprintHex(6)}")
    }

    private fun computeSharedSecretHex(peerPublicHex: String): String? {
        val privateKey = localPrivateKey ?: return null
        val peerPublic = peerPublicHex.hexToBytes() ?: return null
        if (peerPublic.size != 32) return null
        val rawDh = ByteArray(32)
        breadcrumb("KE: X25519 agreement with peerPub=${peerPublic.shortHex()}…")
        val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0)
        if (!ok) {
            breadcrumb("KE: X25519 FAILED")
            return null
        }
        breadcrumb("KE: X25519 OK rawDhFp=${rawDh.fingerprintHex(8)}, calling HSalsa20…")
        return try {
            val naclShared = XChaCha20E2EE.hsalsa20(rawDh)
            rawDh.fill(0)
            breadcrumb("KE: HSalsa20 OK keyFp=${naclShared.fingerprintHex(8)}")
            naclShared.toHex()
        } catch (e: Throwable) {
            saveCrashReport("HSalsa20 failed", e)
            breadcrumb("KE: HSalsa20 FAILED: ${e.message}")
            e2eeAvailable = false
            val hex = rawDh.toHex()
            rawDh.fill(0)
            hex
        }
    }

    private fun serializeSessionDescription(description: SessionDescription): String {
        return JSONObject()
            .put("type", description.type.canonicalForm())
            .put("sdp", description.description)
            .toString()
    }

    private fun parseSessionDescription(raw: String): SessionDescription? {
        return runCatching {
            val json = JSONObject(raw)
            val type = SessionDescription.Type.fromCanonicalForm(json.getString("type"))
            val sdp = json.getString("sdp")
            SessionDescription(type, sdp)
        }.onFailure { error ->
            val preview = raw.replace('\n', ' ').replace('\r', ' ')
            breadcrumb(
                "RTC: parseSessionDescription FAILED len=${raw.length} " +
                    "preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}"
            )
        }.getOrNull()
    }

    private fun serializeIceCandidate(candidate: IceCandidate): String {
        return JSONObject()
            .put("candidate", candidate.sdp)
            .put("sdpMid", candidate.sdpMid)
            .put("sdpMLineIndex", candidate.sdpMLineIndex)
            .toString()
    }

    private fun parseIceCandidate(raw: String): IceCandidate? {
        return runCatching {
            val json = JSONObject(raw)
            val candidate = json.getString("candidate")
            val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null
            val sdpMLineIndex = json.optInt("sdpMLineIndex", 0)
            IceCandidate(sdpMid, sdpMLineIndex, candidate)
        }.onFailure { error ->
            val preview = raw.replace('\n', ' ').replace('\r', ' ')
            breadcrumb(
                "RTC: parseIceCandidate FAILED len=${raw.length} " +
                    "preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}"
            )
        }.getOrNull()
    }

    private fun setSpeakerphone(enabled: Boolean) {
        val context = appContext ?: return
        val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
        audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
        audioManager.isSpeakerphoneOn = enabled
    }

    private fun updateState(reducer: (CallUiState) -> CallUiState) {
        if (resetting) return // Не синхронизируем во время resetSession — иначе "Unknown" мелькает
        val old = _state.value
        _state.update(reducer)
        val newState = _state.value
        // Proximity is needed only while call is connecting/active and speaker is off.
        appContext?.let { context ->
            val shouldEnableProximity =
                (newState.phase == CallPhase.CONNECTING || newState.phase == CallPhase.ACTIVE) &&
                    !newState.isSpeakerOn
            CallProximityManager.setEnabled(context, shouldEnableProximity)
        }
        // Синхронизируем ForegroundService при смене фазы или имени
        if (newState.phase != CallPhase.IDLE &&
            (newState.phase != old.phase || newState.displayName != old.displayName)) {
            appContext?.let { ctx ->
                CallForegroundService.syncWithCallState(ctx, newState)
            }
        }
    }

    private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
    private fun ByteArray.shortHex(bytes: Int = 6): String =
        take(bytes.coerceAtMost(size)).joinToString("") { "%02x".format(it) }
    private fun ByteArray.fingerprintHex(bytes: Int = 8): String {
        val digest = MessageDigest.getInstance("SHA-256").digest(this)
        return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) }
    }
    private fun String.shortFingerprintHex(bytes: Int = 8): String {
        val digest = MessageDigest.getInstance("SHA-256").digest(toByteArray(Charsets.UTF_8))
        return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) }
    }

    private fun String.hexToBytes(): ByteArray? {
        val clean = trim().lowercase()
        if (clean.length % 2 != 0) return null
        return runCatching {
            ByteArray(clean.length / 2) { idx ->
                clean.substring(idx * 2, idx * 2 + 2).toInt(16).toByte()
            }
        }.getOrNull()
    }

    private suspend fun PeerConnection.createOfferAwait(): SessionDescription =
        suspendCancellableCoroutine { continuation ->
            createOffer(
                object : SdpObserver {
                    override fun onCreateSuccess(sdp: SessionDescription?) {
                        if (sdp == null) {
                            continuation.resumeWithException(IllegalStateException("Offer is null"))
                            return
                        }
                        continuation.resume(sdp)
                    }

                    override fun onSetSuccess() = Unit
                    override fun onCreateFailure(error: String?) {
                        continuation.resumeWithException(
                            IllegalStateException(error ?: "createOffer failed")
                        )
                    }

                    override fun onSetFailure(error: String?) = Unit
                },
                MediaConstraints().apply {
                    mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
                    mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false"))
                }
            )
        }

    private suspend fun PeerConnection.createAnswerAwait(): SessionDescription =
        suspendCancellableCoroutine { continuation ->
            createAnswer(
                object : SdpObserver {
                    override fun onCreateSuccess(sdp: SessionDescription?) {
                        if (sdp == null) {
                            continuation.resumeWithException(IllegalStateException("Answer is null"))
                            return
                        }
                        continuation.resume(sdp)
                    }

                    override fun onSetSuccess() = Unit
                    override fun onCreateFailure(error: String?) {
                        continuation.resumeWithException(
                            IllegalStateException(error ?: "createAnswer failed")
                        )
                    }

                    override fun onSetFailure(error: String?) = Unit
                },
                MediaConstraints()
            )
        }

    private suspend fun PeerConnection.setLocalDescriptionAwait(description: SessionDescription) {
        suspendCancellableCoroutine<Unit> { continuation ->
            setLocalDescription(
                object : SdpObserver {
                    override fun onCreateSuccess(sdp: SessionDescription?) = Unit
                    override fun onSetSuccess() {
                        continuation.resume(Unit)
                    }
                    override fun onCreateFailure(error: String?) = Unit
                    override fun onSetFailure(error: String?) {
                        continuation.resumeWithException(
                            IllegalStateException(error ?: "setLocalDescription failed")
                        )
                    }
                },
                description
            )
        }
    }

    private suspend fun PeerConnection.setRemoteDescriptionAwait(description: SessionDescription) {
        suspendCancellableCoroutine<Unit> { continuation ->
            setRemoteDescription(
                object : SdpObserver {
                    override fun onCreateSuccess(sdp: SessionDescription?) = Unit
                    override fun onSetSuccess() {
                        continuation.resume(Unit)
                    }
                    override fun onCreateFailure(error: String?) = Unit
                    override fun onSetFailure(error: String?) {
                        continuation.resumeWithException(
                            IllegalStateException(error ?: "setRemoteDescription failed")
                        )
                    }
                },
                description
            )
        }
    }
}
